mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
[pitboss] phase 07: M6 — Evidence consumers: formatters, ranking, UI
This commit is contained in:
parent
6f8a645077
commit
bfdfcb9d1a
18 changed files with 3208 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
57
frontend/src/components/VerdictBadge.tsx
Normal file
57
frontend/src/components/VerdictBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
110
frontend/src/test/components/verdictBadge.test.tsx
Normal file
110
frontend/src/test/components/verdictBadge.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue