This commit is contained in:
Eli Peter 2026-06-05 10:16:30 -05:00 committed by GitHub
parent 55247b7fcd
commit 991c84a1eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1464 changed files with 225448 additions and 1985 deletions

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,107 @@ function HowToFix({ finding }: { finding: FindingView }) {
);
}
// ── Dynamic Verification Panel ──────────────────────────────────────────────
export function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
const [copied, setCopied] = useState(false);
const attempts = verdict.attempts ?? [];
// The repro bundle is keyed by spec_hash (not finding_id) inside the Nyx
// cache. Rather than showing a path that may not match, surface the CLI
// command that locates and opens the bundle regardless of the hash.
const reproCmd = `nyx repro --finding ${verdict.finding_id}`;
const copyCmd = () => {
if (!navigator.clipboard) return;
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-cmd-row">
<span className="repro-label">Reproduce:</span>
<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>
)}
{attempts.length > 0 && (
<div className="dynamic-attempts">
<strong>Payload attempts:</strong>
<ul className="dynamic-attempt-list">
{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({
@ -861,6 +964,7 @@ export function FindingDetailPage() {
const f = finding;
const evidence = f.evidence;
const dynamicVerdict = evidence?.dynamic_verdict ?? f.dynamic_verdict;
const isState = isStateFinding(f);
const hasWhySection =
f.message ||
@ -1017,6 +1121,13 @@ export function FindingDetailPage() {
</CollapsibleSection>
)}
{/* Dynamic Verification */}
{dynamicVerdict && (
<CollapsibleSection title="Dynamic Verification">
<DynamicVerdictSection verdict={dynamicVerdict} />
</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';
@ -28,6 +29,12 @@ function formatTriageState(state: string): string {
return (state || 'open').replace(/_/g, ' ');
}
function formatVerificationStatus(status: string): string {
if (status === 'NotConfirmed') return 'Not confirmed';
if (status === 'PartiallyConfirmed') return 'Partially confirmed';
return status || 'Unverified';
}
// ── Filter Bar ──────────────────────────────────────────────────────────────
interface FilterSelectProps {
@ -36,6 +43,7 @@ interface FilterSelectProps {
values: string[] | undefined;
current: string;
onChange: (value: string) => void;
formatValue?: (value: string) => string;
}
function FilterSelect({
@ -44,6 +52,7 @@ function FilterSelect({
values,
current,
onChange,
formatValue,
}: FilterSelectProps) {
if (!values || values.length === 0) return null;
return (
@ -51,7 +60,7 @@ function FilterSelect({
<option value="">All {label}</option>
{values.map((v) => (
<option key={v} value={v}>
{v}
{formatValue ? formatValue(v) : v}
</option>
))}
</select>
@ -321,6 +330,7 @@ export function FindingsPage() {
language: state.language || undefined,
rule_id: state.rule_id || undefined,
status: state.status || undefined,
verification: state.verification || undefined,
search: state.search || undefined,
}),
[state],
@ -620,6 +630,14 @@ export function FindingsPage() {
current={state.status}
onChange={(v) => handleFilterChange('status', v)}
/>
<FilterSelect
id="filter-verification"
label="Verification"
values={filters?.verification_statuses}
current={state.verification}
onChange={(v) => handleFilterChange('verification', v)}
formatValue={formatVerificationStatus}
/>
{hasActiveFilters && (
<button className="btn btn-sm btn-clear" onClick={resetFilters}>
Clear All
@ -711,6 +729,7 @@ export function FindingsPage() {
currentDir={state.sort_dir}
onSort={handleSort}
/>
<th>Verified</th>
</tr>
</thead>
<tbody>
@ -760,6 +779,14 @@ export function FindingsPage() {
{formatTriageState(f.triage_state || f.status)}
</span>
</td>
<td>
<VerdictBadge
verdict={
f.dynamic_verdict ?? f.evidence?.dynamic_verdict
}
compact
/>
</td>
</tr>
))}
</tbody>

View file

@ -8,6 +8,7 @@ import type {
CompareResponse,
ComparedFinding,
ChangedFinding,
VerdictTransition,
} from '../api/types';
function truncPath(p?: string, max = 50): string {
@ -273,7 +274,115 @@ function CompareByGroup({
// ── Page ─────────────────────────────────────────────────────────────────────
type CompareTab = 'status' | 'rule' | 'file';
// ── Verdict Diff Tab ─────────────────────────────────────────────────────────
const TRANSITION_ORDER: VerdictTransition[] = [
'FlippedConfirmed',
'Regressed',
'New',
'FlippedNotConfirmed',
'Resolved',
'Unchanged',
];
const TRANSITION_LABELS: Record<VerdictTransition, string> = {
FlippedConfirmed: 'Flipped Confirmed',
Regressed: 'Regressed',
New: 'New',
FlippedNotConfirmed: 'Flipped Not Confirmed',
Resolved: 'Resolved',
Unchanged: 'Unchanged',
};
const TRANSITION_ROW_CLS: Record<VerdictTransition, string> = {
FlippedConfirmed: 'compare-finding-row--new',
Regressed: 'compare-finding-row--new',
New: 'compare-finding-row--new',
FlippedNotConfirmed: 'compare-finding-row--changed',
Resolved: 'compare-finding-row--fixed',
Unchanged: 'compare-finding-row--unchanged',
};
function VerdictDiffSection({ data }: { data: CompareResponse }) {
const entries = data.verdict_diff;
if (!entries || entries.length === 0) {
return (
<div
style={{ color: 'var(--text-secondary)', padding: 'var(--space-4)' }}
>
No verdict-level transitions. Both scans share no findings with stable
hashes.
</div>
);
}
const grouped: Partial<Record<VerdictTransition, typeof entries>> = {};
for (const e of entries) {
if (!grouped[e.transition]) grouped[e.transition] = [];
grouped[e.transition]!.push(e);
}
return (
<>
{TRANSITION_ORDER.map((t) => {
const items = grouped[t];
if (!items || items.length === 0) return null;
return (
<CollapsibleSection
key={t}
sectionKey={t}
defaultCollapsed={t === 'Unchanged'}
headerContent={
<>
<span
className={`compare-finding-row ${TRANSITION_ROW_CLS[t]}`}
style={{
padding: '0 var(--space-2)',
borderRadius: 'var(--radius-sm)',
}}
>
{TRANSITION_LABELS[t]}
</span>
<span style={{ marginLeft: 'var(--space-2)' }}>
({items.length})
</span>
</>
}
>
{items.map((e, i) => (
<div
key={i}
className={`compare-finding-row ${TRANSITION_ROW_CLS[t]}`}
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
}}
>
<span style={{ color: 'var(--text-tertiary)' }}>
{e.path}:{e.line}
</span>
<span>{e.rule_id}</span>
{e.baseline_status && (
<span style={{ color: 'var(--text-secondary)' }}>
{e.baseline_status}
</span>
)}
{e.current_status && (
<>
<span className="delta-arrow">&rarr;</span>
<span>{e.current_status}</span>
</>
)}
</div>
))}
</CollapsibleSection>
);
})}
</>
);
}
type CompareTab = 'status' | 'rule' | 'file' | 'verdict';
export function ScanComparePage() {
usePageTitle('Compare scans');
@ -403,6 +512,12 @@ export function ScanComparePage() {
>
By File
</button>
<button
className={`scan-detail-tab ${activeTab === 'verdict' ? 'active' : ''}`}
onClick={() => setActiveTab('verdict')}
>
Verdict Diff
</button>
</div>
<div id="compare-tab-content">
@ -413,6 +528,7 @@ export function ScanComparePage() {
{activeTab === 'file' && (
<CompareByGroup data={data} groupField="path" />
)}
{activeTab === 'verdict' && <VerdictDiffSection data={data} />}
</div>
</>
);

View file

@ -0,0 +1,314 @@
import { useMemo, useState } from 'react';
import { useSurfaceMap } from '../api/queries/surface';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import { EmptyState } from '../components/ui/EmptyState';
import { usePageTitle } from '../hooks/usePageTitle';
import { SurfaceGraphCanvas } from '../graph/components/SurfaceGraphCanvas';
import type {
SurfaceEdge,
SurfaceEdgeKind,
SurfaceMap,
SurfaceNode,
} from '../api/types';
const EDGE_KIND_LABELS: Record<SurfaceEdgeKind, string> = {
calls: 'Calls',
reads_from: 'Reads',
writes_to: 'Writes',
talks_to: 'Talks to',
reaches: 'Reaches',
triggers: 'Triggers',
auth_required_on: 'Auth required',
};
const NODE_KIND_COLORS: Record<SurfaceNode['node'], string> = {
entry_point: 'var(--accent)',
data_store: 'var(--sev-medium)',
external_service: 'var(--sev-low)',
dangerous_local: 'var(--sev-high)',
};
function nodeTitle(node: SurfaceNode): string {
switch (node.node) {
case 'entry_point':
return `${node.method} ${node.route}`;
case 'data_store':
return `${node.kind}: ${node.label}`;
case 'external_service':
return `${node.kind}: ${node.label}`;
case 'dangerous_local':
return node.function_name;
}
}
function nodeSubtitle(node: SurfaceNode): string {
switch (node.node) {
case 'entry_point':
return `${node.framework}${node.handler_name}`;
case 'data_store':
return 'Data store';
case 'external_service':
return 'External service';
case 'dangerous_local':
return `cap=0x${node.cap_bits.toString(16)}`;
}
}
function nodeLocation(node: SurfaceNode): string {
const loc =
node.node === 'entry_point' ? node.handler_location : node.location;
return `${loc.file}:${loc.line}`;
}
function NodeCard({
node,
index,
selected,
onClick,
}: {
node: SurfaceNode;
index: number;
selected: boolean;
onClick: () => void;
}) {
const color = NODE_KIND_COLORS[node.node];
return (
<button
type="button"
onClick={onClick}
className={`surface-node-card${selected ? ' selected' : ''}`}
style={{
border: `1px solid ${selected ? color : 'var(--border)'}`,
borderLeft: `4px solid ${color}`,
background: selected ? 'var(--surface-2)' : 'var(--surface-1)',
}}
>
<span className="surface-node-card-meta">
#{index} · {node.node.replace('_', ' ')}
{node.node === 'entry_point' && node.auth_required ? ' · auth' : ''}
</span>
<span className="surface-node-card-title">{nodeTitle(node)}</span>
<span className="surface-node-card-subtitle">{nodeSubtitle(node)}</span>
<code className="surface-node-card-loc">{nodeLocation(node)}</code>
</button>
);
}
function summarize(map: SurfaceMap): {
entries: number;
stores: number;
externals: number;
dangerous: number;
edgeKinds: Record<string, number>;
} {
let entries = 0;
let stores = 0;
let externals = 0;
let dangerous = 0;
for (const n of map.nodes) {
if (n.node === 'entry_point') entries++;
else if (n.node === 'data_store') stores++;
else if (n.node === 'external_service') externals++;
else if (n.node === 'dangerous_local') dangerous++;
}
const edgeKinds: Record<string, number> = {};
for (const e of map.edges) {
edgeKinds[e.kind] = (edgeKinds[e.kind] ?? 0) + 1;
}
return { entries, stores, externals, dangerous, edgeKinds };
}
function NeighborList({
map,
index,
}: {
map: SurfaceMap;
index: number | null;
}) {
if (index === null) {
return (
<p className="surface-neighbor-empty">
Select a node on the left to see its neighbours.
</p>
);
}
const node = map.nodes[index];
if (!node) return null;
const outgoing: SurfaceEdge[] = map.edges.filter((e) => e.from === index);
const incoming: SurfaceEdge[] = map.edges.filter((e) => e.to === index);
const renderEdges = (edges: SurfaceEdge[], direction: 'in' | 'out') => {
if (edges.length === 0) {
return (
<p className="surface-neighbor-empty">
(no {direction === 'in' ? 'inbound' : 'outbound'} edges)
</p>
);
}
return (
<ul className="surface-neighbor-edges">
{edges.map((e, i) => {
const otherIdx = direction === 'in' ? e.from : e.to;
const other = map.nodes[otherIdx];
if (!other) return null;
return (
<li key={`${direction}-${i}`} className="surface-neighbor-edge">
<span className="surface-neighbor-edge-kind">
{EDGE_KIND_LABELS[e.kind]}
</span>
<span>
{direction === 'in' ? '←' : '→'}{' '}
<strong>{nodeTitle(other)}</strong>
</span>
<code className="surface-neighbor-edge-loc">
{nodeLocation(other)}
</code>
</li>
);
})}
</ul>
);
};
return (
<div>
<h3 className="surface-neighbor-title">{nodeTitle(node)}</h3>
<p className="surface-neighbor-subtitle">
{nodeSubtitle(node)} <code>{nodeLocation(node)}</code>
</p>
<h4>Outbound</h4>
{renderEdges(outgoing, 'out')}
<h4>Inbound</h4>
{renderEdges(incoming, 'in')}
</div>
);
}
type NodeKindFilter = 'all' | SurfaceNode['node'];
type SurfaceViewMode = 'list' | 'graph';
export function SurfacePage() {
usePageTitle('Surface');
const { data, isLoading, error } = useSurfaceMap();
const [selected, setSelected] = useState<number | null>(null);
const [filter, setFilter] = useState<NodeKindFilter>('all');
const [query, setQuery] = useState('');
const [viewMode, setViewMode] = useState<SurfaceViewMode>('list');
const visible = useMemo(() => {
if (!data) return [] as Array<{ node: SurfaceNode; index: number }>;
const q = query.trim().toLowerCase();
return data.nodes
.map((node, index) => ({ node, index }))
.filter(({ node }) => filter === 'all' || node.node === filter)
.filter(({ node }) => {
if (!q) return true;
return (
nodeTitle(node).toLowerCase().includes(q) ||
nodeSubtitle(node).toLowerCase().includes(q) ||
nodeLocation(node).toLowerCase().includes(q)
);
});
}, [data, filter, query]);
if (isLoading) return <LoadingState message="Loading surface map..." />;
if (error) return <ErrorState message={error.message} />;
if (!data || data.nodes.length === 0) {
return (
<EmptyState message="No surface yet. Run an indexed scan (`nyx scan`) to populate the attack-surface map, or invoke `nyx surface` against the project." />
);
}
const summary = summarize(data);
return (
<div className="page-content">
<header className="surface-header">
<h1>Attack surface</h1>
<span className="surface-header-summary">
{summary.entries} entry-points · {summary.stores} stores ·{' '}
{summary.externals} services · {summary.dangerous} dangerous locals ·{' '}
{data.edges.length} edges
</span>
</header>
<div className="surface-filter-row">
<input
type="search"
value={query}
placeholder="Filter by name, label, or path"
onChange={(e) => setQuery(e.target.value)}
className="surface-filter-input"
disabled={viewMode === 'graph'}
/>
<select
value={filter}
onChange={(e) => setFilter(e.target.value as NodeKindFilter)}
className="surface-filter-select"
disabled={viewMode === 'graph'}
>
<option value="all">All node kinds</option>
<option value="entry_point">Entry points</option>
<option value="data_store">Data stores</option>
<option value="external_service">External services</option>
<option value="dangerous_local">Dangerous locals</option>
</select>
<div
className="surface-view-toggle"
role="tablist"
aria-label="Surface view"
>
<button
type="button"
role="tab"
aria-selected={viewMode === 'list'}
className={`surface-view-toggle-button${viewMode === 'list' ? ' selected' : ''}`}
onClick={() => setViewMode('list')}
>
List
</button>
<button
type="button"
role="tab"
aria-selected={viewMode === 'graph'}
className={`surface-view-toggle-button${viewMode === 'graph' ? ' selected' : ''}`}
onClick={() => setViewMode('graph')}
>
Graph
</button>
</div>
</div>
<div className="surface-grid">
{viewMode === 'list' ? (
<div className="surface-node-list">
{visible.length === 0 ? (
<p className="surface-node-list-empty">No nodes match.</p>
) : (
visible.map(({ node, index }) => (
<NodeCard
key={index}
node={node}
index={index}
selected={selected === index}
onClick={() => setSelected(index)}
/>
))
)}
</div>
) : (
<div className="surface-graph-frame">
<SurfaceGraphCanvas
data={data}
selectedNodeId={selected}
onSelectNode={(id) => setSelected(id)}
/>
</div>
)}
<aside className="surface-sidebar">
<NeighborList map={data} index={selected} />
</aside>
</div>
</div>
);
}