[pitboss] phase 07: M6 — Evidence consumers: formatters, ranking, UI

This commit is contained in:
pitboss 2026-05-12 13:26:52 -04:00
parent 6f8a645077
commit bfdfcb9d1a
18 changed files with 3208 additions and 46 deletions

View file

@ -2,6 +2,30 @@
export type Confidence = 'Low' | 'Medium' | 'High';
export type FlowStepKind = 'source' | 'assignment' | 'call' | 'phi' | 'sink';
// Dynamic verification types (from src/evidence.rs VerifyStatus / VerifyResult)
export type VerifyStatus = 'Confirmed' | 'NotConfirmed' | 'Inconclusive' | 'Unsupported';
export interface AttemptSummary {
payload_label: string;
exit_code?: number;
timed_out: boolean;
triggered: boolean;
sink_hit?: boolean;
}
export interface VerifyResult {
finding_id: string;
status: VerifyStatus;
triggered_payload?: string;
/** Typed UnsupportedReason (PascalCase string) */
reason?: string;
/** Typed InconclusiveReason (PascalCase string) */
inconclusive_reason?: string;
detail?: string;
attempts: AttemptSummary[];
toolchain_match?: string;
}
export interface FlowStep {
step: number;
kind: FlowStepKind;
@ -40,6 +64,8 @@ export interface Evidence {
flow_steps: FlowStep[];
explanation?: string;
confidence_limiters: string[];
/** Dynamic verification result; present only when --verify was active. */
dynamic_verdict?: VerifyResult;
}
// Finding types

View file

@ -0,0 +1,57 @@
import type { VerifyResult, VerifyStatus } from '../api/types';
const STATUS_LABELS: Record<VerifyStatus, string> = {
Confirmed: 'Confirmed',
NotConfirmed: 'Not confirmed',
Inconclusive: 'Inconclusive',
Unsupported: 'Unsupported',
};
function verdictTooltip(verdict: VerifyResult): string {
const { status, triggered_payload, reason, inconclusive_reason, detail } =
verdict;
switch (status) {
case 'Confirmed':
return triggered_payload
? `Confirmed via payload: ${triggered_payload}`
: 'Dynamically confirmed exploitable';
case 'NotConfirmed':
return verdict.attempts.length > 0
? `Not confirmed after ${verdict.attempts.length} payload attempt(s)`
: 'Not confirmed';
case 'Unsupported':
return reason ? `Unsupported: ${reason}` : 'Dynamic verification not supported';
case 'Inconclusive':
return inconclusive_reason
? `Inconclusive: ${inconclusive_reason}${detail ? `: ${detail}` : ''}`
: detail || 'Inconclusive';
}
}
interface VerdictBadgeProps {
verdict: VerifyResult | undefined;
/** Show full label (default) or compact icon-only mode */
compact?: boolean;
}
export function VerdictBadge({ verdict, compact = false }: VerdictBadgeProps) {
if (!verdict) {
return <span style={{ color: 'var(--text-tertiary)' }}>-</span>;
}
const { status } = verdict;
const label = STATUS_LABELS[status] ?? status;
const tooltip = verdictTooltip(verdict);
const flame = status === 'Confirmed' ? '🔥 ' : '';
return (
<span
className={`badge badge-dyn-${status.toLowerCase()}`}
title={tooltip}
data-testid={`verdict-badge-${status.toLowerCase()}`}
>
{flame}
{compact ? status.charAt(0) : label}
</span>
);
}

View file

@ -8,6 +8,7 @@ import { escapeHtml, highlightSyntax } from '../utils/syntaxHighlight';
import { parseNoteText } from '../utils/parseNote';
import { findingToMarkdown } from '../utils/findingMarkdown';
import { CopyMarkdownButton } from '../components/CopyMarkdownButton';
import { VerdictBadge } from '../components/VerdictBadge';
import { Dropdown, DropdownItem } from '../components/ui/Dropdown';
import { CodeViewerModal } from '../modals/CodeViewerModal';
import type {
@ -16,6 +17,7 @@ import type {
FlowStep,
SpanEvidence,
RelatedFindingView,
VerifyResult,
} from '../api/types';
// ── Helpers ─────────────────────────────────────────────────────────────────
@ -701,6 +703,97 @@ function HowToFix({ finding }: { finding: FindingView }) {
);
}
// ── Dynamic Verification Panel ──────────────────────────────────────────────
function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
const [copied, setCopied] = useState(false);
const reproPath = `~/.cache/nyx/dynamic/repro/${verdict.finding_id}/`;
const reproCmd = './reproduce.sh';
const copyCmd = () => {
navigator.clipboard.writeText(reproCmd).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="dynamic-verdict-section">
<div className="dynamic-verdict-badge-row">
<VerdictBadge verdict={verdict} />
{verdict.toolchain_match && (
<span
className="dynamic-toolchain-match"
title={`Toolchain match: ${verdict.toolchain_match}`}
>
{verdict.toolchain_match === 'exact' ? 'exact toolchain' : 'approximate toolchain'}
</span>
)}
</div>
{verdict.status === 'Confirmed' && (
<div className="repro-panel" data-testid="repro-panel">
<div className="repro-path-row">
<span className="repro-label">Repro artifact:</span>
<code className="repro-path">{reproPath}</code>
</div>
<div className="repro-cmd-row">
<code className="repro-cmd">{reproCmd}</code>
<button
type="button"
className="btn btn-sm repro-copy-btn"
onClick={copyCmd}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
)}
{(verdict.reason || verdict.inconclusive_reason || verdict.detail) && (
<div className="dynamic-verdict-detail">
{verdict.reason && (
<div>
<strong>Reason:</strong> {verdict.reason}
</div>
)}
{verdict.inconclusive_reason && (
<div>
<strong>Inconclusive reason:</strong> {verdict.inconclusive_reason}
</div>
)}
{verdict.detail && (
<div className="dynamic-verdict-detail-text">{verdict.detail}</div>
)}
</div>
)}
{verdict.attempts.length > 0 && (
<div className="dynamic-attempts">
<strong>Payload attempts:</strong>
<ul className="dynamic-attempt-list">
{verdict.attempts.map((a, i) => (
<li key={i} className={`attempt-row ${a.triggered ? 'triggered' : ''}`}>
<code>{a.payload_label}</code>
<span className="attempt-outcome">
{a.triggered
? 'triggered'
: a.timed_out
? 'timeout'
: 'no hit'}
</span>
{a.exit_code != null && (
<span className="attempt-exit-code">exit {a.exit_code}</span>
)}
</li>
))}
</ul>
</div>
)}
</div>
);
}
// ── Status Control ──────────────────────────────────────────────────────────
function StatusControl({
@ -1017,6 +1110,13 @@ export function FindingDetailPage() {
</CollapsibleSection>
)}
{/* Dynamic Verification */}
{evidence?.dynamic_verdict && (
<CollapsibleSection title="Dynamic Verification">
<DynamicVerdictSection verdict={evidence.dynamic_verdict} />
</CollapsibleSection>
)}
{/* Code Preview */}
{hasCode && (
<CollapsibleSection title="Code Preview" defaultOpen={false}>

View file

@ -17,6 +17,7 @@ import { Dropdown, DropdownItem } from '../components/ui/Dropdown';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import { CopyMarkdownButton } from '../components/CopyMarkdownButton';
import { VerdictBadge } from '../components/VerdictBadge';
import { truncPath } from '../utils/truncPath';
import { findingsToMarkdown } from '../utils/findingMarkdown';
import { ApiError } from '../api/client';
@ -711,6 +712,7 @@ export function FindingsPage() {
currentDir={state.sort_dir}
onSort={handleSort}
/>
<th>Verified</th>
</tr>
</thead>
<tbody>
@ -760,6 +762,12 @@ export function FindingsPage() {
{formatTriageState(f.triage_state || f.status)}
</span>
</td>
<td>
<VerdictBadge
verdict={f.evidence?.dynamic_verdict}
compact
/>
</td>
</tr>
))}
</tbody>

View file

@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { VerdictBadge } from '@/components/VerdictBadge';
import type { VerifyResult } from '@/api/types';
function makeVerdict(
status: VerifyResult['status'],
extras: Partial<VerifyResult> = {},
): VerifyResult {
return {
finding_id: 'test-finding-id',
status,
attempts: [],
...extras,
};
}
describe('VerdictBadge', () => {
it('renders dash when verdict is undefined', () => {
render(<VerdictBadge verdict={undefined} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('renders Confirmed badge with flame and correct class', () => {
render(
<VerdictBadge
verdict={makeVerdict('Confirmed', { triggered_payload: 'sqli-tautology' })}
/>,
);
const badge = screen.getByTestId('verdict-badge-confirmed');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('badge-dyn-confirmed');
expect(badge.textContent).toContain('🔥');
});
it('renders NotConfirmed badge with correct class', () => {
render(<VerdictBadge verdict={makeVerdict('NotConfirmed')} />);
const badge = screen.getByTestId('verdict-badge-notconfirmed');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('badge-dyn-notconfirmed');
expect(badge.textContent).not.toContain('🔥');
});
it('renders Unsupported badge with correct class', () => {
render(
<VerdictBadge
verdict={makeVerdict('Unsupported', { reason: 'NoPayloadsForCap' })}
/>,
);
const badge = screen.getByTestId('verdict-badge-unsupported');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('badge-dyn-unsupported');
});
it('renders Inconclusive badge with amber class', () => {
render(
<VerdictBadge
verdict={makeVerdict('Inconclusive', {
inconclusive_reason: 'BuildFailed',
})}
/>,
);
const badge = screen.getByTestId('verdict-badge-inconclusive');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('badge-dyn-inconclusive');
});
it('tooltip contains payload for Confirmed', () => {
render(
<VerdictBadge
verdict={makeVerdict('Confirmed', { triggered_payload: 'sqli-payload' })}
/>,
);
const badge = screen.getByTestId('verdict-badge-confirmed');
expect(badge.getAttribute('title')).toContain('sqli-payload');
});
it('tooltip contains reason for Unsupported', () => {
render(
<VerdictBadge
verdict={makeVerdict('Unsupported', { reason: 'ConfidenceTooLow' })}
/>,
);
const badge = screen.getByTestId('verdict-badge-unsupported');
expect(badge.getAttribute('title')).toContain('ConfidenceTooLow');
});
it('compact mode renders single character', () => {
render(<VerdictBadge verdict={makeVerdict('Confirmed')} compact />);
const badge = screen.getByTestId('verdict-badge-confirmed');
// Compact: first char of status + flame emoji
expect(badge.textContent?.replace('🔥 ', '')).toBe('C');
});
it('renders all four VerifyStatus variants without crashing', () => {
const statuses: VerifyResult['status'][] = [
'Confirmed',
'NotConfirmed',
'Unsupported',
'Inconclusive',
];
for (const status of statuses) {
const { unmount } = render(<VerdictBadge verdict={makeVerdict(status)} />);
expect(
screen.getByTestId(`verdict-badge-${status.toLowerCase()}`),
).toBeInTheDocument();
unmount();
}
});
});