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

@ -4,11 +4,26 @@ import type { ScanView } from '../types';
export type ScanMode = 'full' | 'ast' | 'cfg' | 'taint';
export type EngineProfile = 'fast' | 'balanced' | 'deep';
export type VerifyBackend = 'auto' | 'docker' | 'process' | 'firecracker';
export type HardenProfile = 'standard' | 'strict';
export interface StartScanBody {
scan_root?: string;
mode?: ScanMode;
engine_profile?: EngineProfile;
/**
* Override dynamic verification for this scan.
* true - force on.
* false - force off.
* absent - use server config default.
*/
verify?: boolean;
/** Also verify Confidence < Medium findings. Default false. */
verify_all_confidence?: boolean;
/** Sandbox backend for dynamic verification. */
verify_backend?: VerifyBackend;
/** Process-backend hardening profile. */
harden_profile?: HardenProfile;
}
export function useStartScan() {

View file

@ -11,6 +11,7 @@ export interface FindingsParams {
language?: string;
rule_id?: string;
status?: string;
verification?: string;
search?: string;
sort_by?: string;
sort_dir?: string;

View file

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type { SurfaceMap } from '../types';
export function useSurfaceMap() {
return useQuery({
queryKey: ['surface'],
queryFn: ({ signal }) => apiGet<SurfaceMap>('/surface', signal),
staleTime: 30_000,
});
}

View file

@ -0,0 +1,43 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiDelete, apiGet, apiPost } from '../client';
import type { TargetView } from '../types';
export function useTargets() {
return useQuery({
queryKey: ['targets'],
queryFn: ({ signal }) => apiGet<TargetView[]>('/targets', signal),
});
}
export function useAddTarget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { path: string }) =>
apiPost<TargetView>('/targets', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['targets'] });
},
});
}
export function useSelectTarget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { id?: string; path?: string }) =>
apiPost<TargetView>('/targets/select', body),
onSuccess: () => {
qc.invalidateQueries();
},
});
}
export function useDeleteTarget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiDelete<void>(`/targets/${encodeURIComponent(id)}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['targets'] });
},
});
}

View file

@ -2,6 +2,44 @@
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'
| 'PartiallyConfirmed'
| '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 DynamicVerificationSummary {
total: number;
confirmed: number;
partially_confirmed: number;
not_confirmed: number;
inconclusive: number;
unsupported: number;
}
export interface FlowStep {
step: number;
kind: FlowStepKind;
@ -40,6 +78,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,10 +97,31 @@ export interface RelatedFindingView {
severity: string;
}
// Baseline / patch-validation types (M6.5)
export type VerdictTransition =
| 'New'
| 'Unchanged'
| 'Resolved'
| 'Regressed'
| 'FlippedConfirmed'
| 'FlippedNotConfirmed';
export interface VerdictDiffEntry {
stable_hash: number;
path: string;
line: number;
rule_id: string;
baseline_status?: VerifyStatus;
current_status?: VerifyStatus;
transition: VerdictTransition;
}
export interface FindingView {
index: number;
fingerprint: string;
portable_fingerprint?: string;
/** Blake3-derived stable cross-commit identity (M6.5). */
stable_hash?: number;
path: string;
line: number;
col: number;
@ -79,6 +140,7 @@ export interface FindingView {
triage_note?: string;
code_context?: CodeContextView;
evidence?: Evidence;
dynamic_verdict?: VerifyResult;
guard_kind?: string;
rank_reason?: [string, string][];
sanitizer_status?: string;
@ -100,6 +162,7 @@ export interface FilterValues {
languages: string[];
rules: string[];
statuses: string[];
verification_statuses: string[];
}
// Scan types
@ -135,6 +198,17 @@ export interface ScanView {
metrics?: ScanMetricsSnapshot;
}
export interface TargetView {
id: string;
name: string;
path: string;
db_path: string;
last_seen_at: string;
last_scan_at?: string;
active: boolean;
exists: boolean;
}
// Scan Comparison types
export interface CompareScanInfo {
id: string;
@ -173,6 +247,8 @@ export interface CompareResponse {
fixed_findings: ComparedFinding[];
changed_findings: ChangedFinding[];
unchanged_findings: ComparedFinding[];
/** Verdict-level diff (M6.5). Present when findings carry stable_hash values. */
verdict_diff?: VerdictDiffEntry[];
}
// Overview types
@ -302,6 +378,7 @@ export interface ScannerQuality {
call_resolution_rate: number;
symex_verified_rate: number;
symex_breakdown: Record<string, number>;
dynamic_verification: DynamicVerificationSummary;
}
export interface IssueCategoryBucket {
@ -843,3 +920,106 @@ export interface AuthAnalysisView {
units: AuthUnitView[];
enabled: boolean;
}
// ── Surface map (Phase 2123) ───────────────────────────────────────
export interface SurfaceSourceLocation {
file: string;
line: number;
col: number;
}
export type SurfaceFramework =
| 'flask'
| 'fast_api'
| 'django'
| 'express'
| 'koa'
| 'spring'
| 'jax_rs'
| 'quarkus'
| 'rails'
| 'sinatra'
| 'laravel'
| 'slim'
| 'axum'
| 'actix'
| 'rocket'
| 'net_http'
| 'gin'
| 'next_app_router'
| 'next_server_action';
export type SurfaceHttpMethod =
| 'GET'
| 'HEAD'
| 'POST'
| 'PUT'
| 'PATCH'
| 'DELETE'
| 'OPTIONS';
export type SurfaceDataStoreKind =
| 'sql'
| 'key_value'
| 'document'
| 'blob_store'
| 'filesystem'
| 'unknown';
export type SurfaceExternalKind =
| 'http_api'
| 'message_broker'
| 'search_index'
| 'auth_provider'
| 'unknown';
export type SurfaceEdgeKind =
| 'calls'
| 'reads_from'
| 'writes_to'
| 'talks_to'
| 'reaches'
| 'triggers'
| 'auth_required_on';
export type SurfaceNode =
| {
node: 'entry_point';
location: SurfaceSourceLocation;
framework: SurfaceFramework;
method: SurfaceHttpMethod;
route: string;
handler_name: string;
handler_location: SurfaceSourceLocation;
auth_required: boolean;
}
| {
node: 'data_store';
location: SurfaceSourceLocation;
kind: SurfaceDataStoreKind;
label: string;
}
| {
node: 'external_service';
location: SurfaceSourceLocation;
kind: SurfaceExternalKind;
label: string;
}
| {
node: 'dangerous_local';
location: SurfaceSourceLocation;
function_name: string;
cap_bits: number;
};
export interface SurfaceEdge {
from: number;
to: number;
kind: SurfaceEdgeKind;
}
export interface SurfaceMap {
nodes: SurfaceNode[];
edges: SurfaceEdge[];
}

View file

@ -0,0 +1,64 @@
import type { VerifyResult, VerifyStatus } from '../api/types';
const STATUS_LABELS: Record<VerifyStatus, string> = {
Confirmed: 'Confirmed',
PartiallyConfirmed: 'Partially 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 'PartiallyConfirmed':
return detail
? `Partially confirmed (sink reached): ${detail}`
: 'Partially confirmed: sink reached but exploit chain did not complete';
case 'NotConfirmed':
return (verdict.attempts?.length ?? 0) > 0
? `Not confirmed after ${verdict.attempts?.length ?? 0} 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

@ -17,6 +17,7 @@ import { RulesPage } from '../../pages/RulesPage';
import { TriagePage } from '../../pages/TriagePage';
import { ConfigPage } from '../../pages/ConfigPage';
import { ExplorerPage } from '../../pages/ExplorerPage';
import { SurfacePage } from '../../pages/SurfacePage';
import { DebugLayout } from '../../pages/debug/DebugLayout';
import { CallGraphPage } from '../../pages/debug/CallGraphPage';
import { SummaryExplorerPage } from '../../pages/debug/SummaryExplorerPage';
@ -50,6 +51,12 @@ export function AppLayout() {
label: 'Explorer',
to: '/explorer',
},
{
id: 'go-surface',
group: 'Navigate',
label: 'Attack surface',
to: '/surface',
},
{
id: 'go-debug-cg',
group: 'Navigate',
@ -141,6 +148,7 @@ export function AppLayout() {
<Route path="/triage" element={<TriagePage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/explorer" element={<ExplorerPage />} />
<Route path="/surface" element={<SurfacePage />} />
<Route path="/debug" element={<DebugLayout />}>
<Route
index

View file

@ -8,13 +8,17 @@ import {
ConfigIcon,
ExplorerIcon,
DebugIcon,
FolderIcon,
TagIcon,
} from '../icons/Icons';
import type { FC } from 'react';
import { useEffect, useRef, useState, type FC, type FormEvent } from 'react';
import type { IconProps } from '../icons/Icons';
import { useHealth } from '../../api/queries/health';
import { useOverview } from '../../api/queries/overview';
import {
useAddTarget,
useSelectTarget,
useTargets,
} from '../../api/queries/targets';
import { useSSE } from '../../contexts/SSEContext';
interface NavItem {
@ -68,6 +72,13 @@ const NAV_SECTIONS: NavItem[] = [
Icon: ExplorerIcon,
group: 'secondary',
},
{
id: 'surface',
label: 'Surface',
path: '/surface',
Icon: ExplorerIcon,
group: 'secondary',
},
{
id: 'debug',
label: 'Debug',
@ -88,6 +99,167 @@ function navLinkClass({ isActive }: { isActive: boolean }) {
return `nav-link${isActive ? ' active' : ''}`;
}
function targetNameFromPath(path: string) {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || path || 'Project';
}
function targetInitial(name: string) {
return name.trim().charAt(0).toUpperCase() || '?';
}
function compactPath(path: string) {
return path.replace(/^\/Users\/[^/]+/, '~');
}
function TargetSwitcher({ scanRoot }: { scanRoot?: string }) {
const { data: targets = [] } = useTargets();
const addTarget = useAddTarget();
const selectTarget = useSelectTarget();
const [open, setOpen] = useState(false);
const [newPath, setNewPath] = useState('');
const menuRef = useRef<HTMLDivElement | null>(null);
const activeTarget =
targets.find((target) => target.active) ??
(scanRoot
? {
id: '__active__',
name: targetNameFromPath(scanRoot),
path: scanRoot,
active: true,
exists: true,
}
: undefined);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: MouseEvent) {
if (
menuRef.current &&
event.target instanceof Node &&
!menuRef.current.contains(event.target)
) {
setOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
function handleSelect(id: string) {
selectTarget.mutate(
{ id },
{
onSuccess: () => setOpen(false),
},
);
}
function handleAddSubmit(event: FormEvent) {
event.preventDefault();
const path = newPath.trim();
if (!path || addTarget.isPending) return;
addTarget.mutate(
{ path },
{
onSuccess: (target) => {
setNewPath('');
selectTarget.mutate(
{ id: target.id },
{
onSuccess: () => setOpen(false),
},
);
},
},
);
}
const isBusy = addTarget.isPending || selectTarget.isPending;
const errorMessage =
addTarget.error instanceof Error ? addTarget.error.message : null;
return (
<div className="target-switcher" ref={menuRef}>
<button
type="button"
className="target-trigger"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label="Select project target"
title={activeTarget?.path}
>
<span className="target-avatar">
{targetInitial(activeTarget?.name ?? 'Project')}
</span>
<span className="target-trigger-copy">
<span className="target-name">
{activeTarget?.name ?? 'Select target'}
</span>
<span className="target-path">
{activeTarget?.path ? compactPath(activeTarget.path) : 'No target'}
</span>
</span>
<span className={`target-caret${open ? ' open' : ''}`} />
</button>
{open && (
<div className="target-menu" role="menu">
<div className="target-options">
{targets.map((target) => (
<button
key={target.id}
type="button"
className={`target-option${target.active ? ' active' : ''}`}
onClick={() => handleSelect(target.id)}
disabled={target.active || !target.exists || isBusy}
title={target.path}
>
<span className="target-option-avatar">
{targetInitial(target.name)}
</span>
<span className="target-option-copy">
<span className="target-option-name">{target.name}</span>
<span className="target-option-path">
{target.exists ? compactPath(target.path) : 'Missing path'}
</span>
</span>
</button>
))}
</div>
<form className="target-add-form" onSubmit={handleAddSubmit}>
<input
value={newPath}
onChange={(event) => setNewPath(event.target.value)}
placeholder="/path/to/project"
aria-label="Project path"
/>
<button
type="submit"
className="target-add-button"
disabled={!newPath.trim() || addTarget.isPending}
title="Add target"
aria-label="Add target"
>
+
</button>
</form>
{errorMessage && <div className="target-error">{errorMessage}</div>}
</div>
)}
</div>
);
}
export function Sidebar() {
const { data: health } = useHealth();
const { data: overview } = useOverview();
@ -105,6 +277,8 @@ export function Sidebar() {
<img src="/logo.png" alt="Nyx" className="sidebar-logo-img" />
</div>
<TargetSwitcher scanRoot={health?.scan_root} />
<ul className="nav-list">
{primary.map((item) => (
<li key={item.id}>
@ -154,12 +328,6 @@ export function Sidebar() {
</div>
<div className="sidebar-meta">
{health?.scan_root && (
<div className="sidebar-meta-item" title={health.scan_root}>
<FolderIcon />
<span>{health.scan_root}</span>
</div>
)}
{health?.version && (
<div className="sidebar-meta-item">
<TagIcon />

View file

@ -241,6 +241,18 @@ export function ScannerQualityPanel({
: quality.files_scanned > 0
? `${quality.files_scanned.toLocaleString()} freshly indexed`
: undefined;
const dynamic = quality.dynamic_verification ?? {
total: 0,
confirmed: 0,
partially_confirmed: 0,
not_confirmed: 0,
inconclusive: 0,
unsupported: 0,
};
const dynamicDetail =
dynamic.total > 0
? `${dynamic.total.toLocaleString()} verdicts · ${dynamic.partially_confirmed.toLocaleString()} partially confirmed · ${dynamic.not_confirmed.toLocaleString()} not confirmed · ${dynamic.inconclusive.toLocaleString()} inconclusive · ${dynamic.unsupported.toLocaleString()} unsupported`
: 'no dynamic verdicts in latest scan';
const rows: Array<{
label: string;
@ -287,6 +299,15 @@ export function ScannerQualityPanel({
? `${symexAttempted} of ${symexTotal} taint findings`
: 'no taint findings',
},
{
label: 'Dynamic verification',
hint: 'Findings re-run in generated harnesses against the dynamic payload corpus.',
value:
dynamic.total > 0
? `${dynamic.confirmed.toLocaleString()} confirmed`
: 'not run',
detail: dynamicDetail,
},
];
return (

View file

@ -58,6 +58,7 @@ export function SSEProvider({ children }: { children: ReactNode }) {
es.addEventListener('scan_started', () => {
setIsScanRunning(true);
queryClient.invalidateQueries({ queryKey: ['scans'] });
queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('scan_progress', (e) => {
@ -75,12 +76,14 @@ export function SSEProvider({ children }: { children: ReactNode }) {
queryClient.invalidateQueries({ queryKey: ['scans'] });
queryClient.invalidateQueries({ queryKey: ['overview'] });
queryClient.invalidateQueries({ queryKey: ['findings'] });
queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('scan_failed', () => {
setScanProgress(null);
setIsScanRunning(false);
queryClient.invalidateQueries({ queryKey: ['scans'] });
queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('config_changed', () => {

View file

@ -0,0 +1,84 @@
import type { SurfaceEdge, SurfaceMap, SurfaceNode } from '@/api/types';
import type { GraphModel } from '../types';
const MAX_LABEL = 44;
const MAX_DETAIL = 48;
function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 1)}` : value;
}
export const SURFACE_NODE_KIND: Record<SurfaceNode['node'], string> = {
entry_point: 'EntryPoint',
data_store: 'DataStore',
external_service: 'ExternalService',
dangerous_local: 'DangerousLocal',
};
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 nodeDetail(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): { file: string; line: number } {
if (node.node === 'entry_point') return node.handler_location;
return node.location;
}
export function adaptSurfaceMap(data: SurfaceMap): GraphModel {
return {
kind: 'surface',
nodes: data.nodes.map((node, index) => {
const loc = nodeLocation(node);
const title = nodeTitle(node);
const detail = nodeDetail(node);
const searchText = [title, detail, loc.file].join(' ').toLowerCase();
const authBadge =
node.node === 'entry_point' && node.auth_required
? ['auth']
: undefined;
return {
key: String(index),
rawId: index,
label: truncate(title, MAX_LABEL),
kind: SURFACE_NODE_KIND[node.node],
detail: truncate(detail, MAX_DETAIL),
line: loc.line,
badges: authBadge,
metadata: {
surfaceKind: node.node,
node,
searchText,
},
};
}),
edges: data.edges.map((edge: SurfaceEdge, index) => ({
key: `surface:${edge.from}:${edge.to}:${edge.kind}:${index}`,
source: String(edge.from),
target: String(edge.to),
kind: edge.kind,
metadata: { ...edge },
})),
};
}

View file

@ -0,0 +1,123 @@
import { useMemo, useState } from 'react';
import type { SurfaceMap } from '@/api/types';
import { adaptSurfaceMap } from '../adapters/surface';
import { useElkLayout } from '../hooks/useElkLayout';
import {
collectSearchMatches,
extractNeighborhoodSubgraph,
} from '../reduction/neighborhood';
import { SigmaGraph } from '../rendering/sigma/SigmaGraph';
interface SurfaceGraphCanvasProps {
data: SurfaceMap;
selectedNodeId: number | null;
onSelectNode: (id: number) => void;
}
export function SurfaceGraphCanvas({
data,
selectedNodeId,
onSelectNode,
}: SurfaceGraphCanvasProps) {
const [searchQuery, setSearchQuery] = useState('');
const [neighborhoodOnly, setNeighborhoodOnly] = useState(false);
const [radius, setRadius] = useState(2);
const fullGraph = useMemo(() => adaptSurfaceMap(data), [data]);
const selectedNodeKey =
selectedNodeId == null ? null : String(selectedNodeId);
const matches = useMemo(
() => collectSearchMatches(fullGraph, searchQuery, 60),
[fullGraph, searchQuery],
);
const matchKeys = useMemo(
() => new Set(matches.map((node) => node.key)),
[matches],
);
const visibleGraph = useMemo(() => {
if (!neighborhoodOnly || !selectedNodeKey) return fullGraph;
return extractNeighborhoodSubgraph(fullGraph, selectedNodeKey, radius);
}, [fullGraph, neighborhoodOnly, radius, selectedNodeKey]);
const { graph, isLoading, error } = useElkLayout(visibleGraph);
if (error) {
return (
<div className="error-state">Failed to compute the surface layout.</div>
);
}
if (!graph) {
return <div className="loading">Preparing surface graph</div>;
}
const extras = (
<>
<label className="graph-toolbar-field">
<span>Search</span>
<input
className="graph-toolbar-input"
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Route, label, or path"
/>
</label>
<label className="graph-toolbar-field">
<span>Match</span>
<select
className="graph-toolbar-select"
value={selectedNodeKey ?? ''}
onChange={(event) => {
const next = event.target.value;
if (!next) return;
onSelectNode(Number(next));
}}
>
<option value="">Select</option>
{matches.map((match) => (
<option key={match.key} value={match.key}>
{match.label}
</option>
))}
</select>
</label>
<label className="graph-toolbar-check">
<input
type="checkbox"
checked={neighborhoodOnly}
onChange={(event) => setNeighborhoodOnly(event.target.checked)}
/>
<span>Neighbors only</span>
</label>
<label className="graph-toolbar-field graph-toolbar-field-compact">
<span>Radius</span>
<input
className="graph-toolbar-range"
type="range"
min="1"
max="4"
step="1"
value={radius}
disabled={!neighborhoodOnly}
onChange={(event) => setRadius(Number(event.target.value))}
/>
<strong>{radius}</strong>
</label>
</>
);
return (
<SigmaGraph
graph={graph}
viewKind="surface"
selectedNodeKey={selectedNodeKey}
onNodeClick={(key) => onSelectNode(Number(key))}
searchMatchKeys={matchKeys}
toolbarExtras={extras}
loading={isLoading}
/>
);
}

View file

@ -39,6 +39,14 @@ const PRESETS: Record<GraphViewKind, ElkLayoutPreset> = {
padding: 32,
edgeRouting: 'ORTHOGONAL',
},
surface: {
direction: 'RIGHT',
nodeSpacing: 44,
layerSpacing: 156,
edgeNodeSpacing: 28,
padding: 36,
edgeRouting: 'POLYLINE',
},
};
function measureNode(

View file

@ -31,6 +31,13 @@ const CONFIG: Record<GraphViewKind, TextLayoutConfig> = {
maxSecondaryLines: 2,
maxSublabelLines: 1,
},
surface: {
primaryChars: 32,
secondaryChars: 32,
maxPrimaryLines: 2,
maxSecondaryLines: 2,
maxSublabelLines: 1,
},
};
function normalizeWhitespace(value: string): string {

View file

@ -195,6 +195,95 @@ function cfgNodeStyle(
}
}
function surfaceNodeStyle(type: string, palette: GraphThemePalette): NodeStyle {
switch (type) {
case 'EntryPoint':
return {
fill: palette.success,
stroke: withAlpha(palette.success, 0.85),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.78),
shape: 'double',
strokeWidth: 1.8,
accentFill: palette.accent,
neighborFill: withAlpha(palette.success, 0.75),
};
case 'DataStore':
return {
fill: palette.warning,
stroke: withAlpha(palette.warning, 0.85),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 1.5,
accentFill: palette.accent,
neighborFill: withAlpha(palette.warning, 0.76),
};
case 'ExternalService':
return {
fill: palette.accent,
stroke: withAlpha(palette.accent, 0.82),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 1.5,
accentFill: palette.accent,
neighborFill: palette.accentSoft,
};
case 'DangerousLocal':
return {
fill: palette.danger,
stroke: withAlpha(palette.danger, 0.86),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'terminal',
strokeWidth: 1.7,
accentFill: palette.accent,
neighborFill: withAlpha(palette.danger, 0.75),
};
default:
return {
fill: withAlpha(palette.neutral, 0.92),
stroke: withAlpha(palette.neutral, 0.8),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.78),
shape: 'rect',
strokeWidth: 1.2,
accentFill: palette.accent,
neighborFill: withAlpha(palette.neutralSoft, 0.88),
};
}
}
function surfaceEdgeStyle(type: string, palette: GraphThemePalette): EdgeStyle {
switch (type) {
case 'calls':
return {
color: withAlpha(palette.textSecondary, 0.78),
width: 1.4,
dash: [],
};
case 'reads_from':
return { color: palette.success, width: 1.5, dash: [] };
case 'writes_to':
return { color: palette.warning, width: 1.6, dash: [] };
case 'talks_to':
return { color: palette.accent, width: 1.4, dash: [] };
case 'reaches':
return { color: palette.danger, width: 1.7, dash: [] };
case 'triggers':
return { color: palette.success, width: 1.5, dash: [4, 3] };
case 'auth_required_on':
return { color: palette.textTertiary, width: 1.3, dash: [2, 4] };
default:
return {
color: withAlpha(palette.textTertiary, 0.78),
width: 1.3,
dash: [],
};
}
}
function callGraphNodeStyle(
palette: GraphThemePalette,
metadata?: GraphMetadata,
@ -221,9 +310,15 @@ export function getNodeStyle(
metadata?: GraphMetadata,
palette = FALLBACK_PALETTE,
): NodeStyle {
return graphKind === 'callgraph'
? callGraphNodeStyle(palette, metadata)
: cfgNodeStyle(type, palette, metadata);
switch (graphKind) {
case 'callgraph':
return callGraphNodeStyle(palette, metadata);
case 'surface':
return surfaceNodeStyle(type, palette);
case 'cfg':
default:
return cfgNodeStyle(type, palette, metadata);
}
}
export function getEdgeStyle(
@ -239,6 +334,10 @@ export function getEdgeStyle(
};
}
if (graphKind === 'surface') {
return surfaceEdgeStyle(type, palette);
}
switch (type) {
case 'True':
return { color: palette.success, width: 1.8, dash: [] };

View file

@ -1,4 +1,4 @@
export type GraphViewKind = 'callgraph' | 'cfg';
export type GraphViewKind = 'callgraph' | 'cfg' | 'surface';
export interface GraphPoint {
x: number;

View file

@ -13,6 +13,7 @@ export interface FindingsURLState {
language: string;
rule_id: string;
status: string;
verification: string;
search: string;
}
@ -27,6 +28,7 @@ const FINDINGS_DEFAULTS: FindingsURLState = {
language: '',
rule_id: '',
status: '',
verification: '',
search: '',
};
@ -52,6 +54,7 @@ const FILTER_KEYS: ReadonlySet<string> = new Set([
'language',
'rule_id',
'status',
'verification',
'search',
]);

View file

@ -8,6 +8,8 @@ import {
useStartScan,
type ScanMode,
type EngineProfile,
type VerifyBackend,
type HardenProfile,
type StartScanBody,
} from '../api/mutations/scans';
@ -29,6 +31,18 @@ const PROFILE_HINTS: Record<EngineProfile, string> = {
deep: 'Adds symex (cross-file + interproc) and demand-driven backwards taint. About 2 to 3x slower.',
};
const BACKEND_HINTS: Record<VerifyBackend, string> = {
auto: 'Use Docker when it fits, otherwise fall back to process.',
docker: 'Require Docker-backed harness execution.',
process: 'Unsafe local process backend for quick test runs.',
firecracker: 'Use the Firecracker backend when available.',
};
const HARDEN_HINTS: Record<HardenProfile, string> = {
standard: 'Baseline process limits.',
strict: 'Stricter process confinement when supported.',
};
export function NewScanModal({ open, onClose }: NewScanModalProps) {
const { data: health } = useHealth();
const startScan = useStartScan();
@ -38,6 +52,9 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
const [scanRoot, setScanRoot] = useState('');
const [mode, setMode] = useState<ScanMode>('full');
const [engineProfile, setEngineProfile] = useState<EngineProfile>('balanced');
const [noVerify, setNoVerify] = useState(false);
const [verifyBackend, setVerifyBackend] = useState<VerifyBackend>('auto');
const [hardenProfile, setHardenProfile] = useState<HardenProfile>('standard');
const handleStart = async () => {
const root = scanRoot.trim();
@ -45,6 +62,12 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
if (root && root !== defaultRoot) body.scan_root = root;
if (mode !== 'full') body.mode = mode;
body.engine_profile = engineProfile;
if (noVerify) {
body.verify = false;
} else {
body.verify_backend = verifyBackend;
body.harden_profile = hardenProfile;
}
const payload = Object.keys(body).length ? body : undefined;
try {
await startScan.mutateAsync(payload);
@ -105,6 +128,54 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
</select>
<span className="form-hint">{PROFILE_HINTS[engineProfile]}</span>
</div>
<div className="form-group">
<label>Dynamic Verification</label>
<div className="toggle-inline">
<input
type="checkbox"
id="new-scan-no-verify"
checked={noVerify}
onChange={(e) => setNoVerify(e.target.checked)}
/>
<label htmlFor="new-scan-no-verify">
Skip dynamic verification for this scan.
</label>
</div>
<span className="form-hint">
Verification runs by default on Medium and High confidence
findings. Check to skip and get a fast static-only result.
</span>
</div>
<div className="form-group">
<label>Verification Backend</label>
<select
value={verifyBackend}
disabled={noVerify}
onChange={(e) =>
setVerifyBackend(e.target.value as VerifyBackend)
}
>
<option value="auto">Auto</option>
<option value="docker">Docker</option>
<option value="process">Process (unsafe)</option>
<option value="firecracker">Firecracker</option>
</select>
<span className="form-hint">{BACKEND_HINTS[verifyBackend]}</span>
</div>
<div className="form-group">
<label>Process Hardening</label>
<select
value={hardenProfile}
disabled={noVerify || verifyBackend !== 'process'}
onChange={(e) =>
setHardenProfile(e.target.value as HardenProfile)
}
>
<option value="standard">Standard</option>
<option value="strict">Strict</option>
</select>
<span className="form-hint">{HARDEN_HINTS[hardenProfile]}</span>
</div>
<div className="scan-modal-actions">
<button className="btn btn-sm" onClick={onClose}>
Cancel

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>
);
}

View file

@ -177,6 +177,165 @@ a:hover {
color: var(--text-tertiary);
font-family: var(--font-mono);
}
.target-switcher {
position: relative;
padding: 0 var(--space-3) var(--space-2);
}
.target-trigger,
.target-option,
.target-add-button {
appearance: none;
border: 0;
font: inherit;
cursor: pointer;
}
.target-trigger {
width: 100%;
min-height: 48px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr) 12px;
align-items: center;
gap: var(--space-2);
padding: 7px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
text-align: left;
}
.target-trigger:hover,
.target-trigger[aria-expanded='true'] {
border-color: var(--line-strong);
background: var(--bg-secondary);
}
.target-avatar,
.target-option-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent-light);
color: var(--accent);
font-weight: var(--weight-semibold);
flex-shrink: 0;
}
.target-trigger-copy,
.target-option-copy {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.25;
}
.target-name,
.target-option-name {
color: var(--text);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.target-path,
.target-option-path {
color: var(--text-tertiary);
font-size: 0.7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.target-caret {
width: 8px;
height: 8px;
border-right: 1.5px solid var(--text-tertiary);
border-bottom: 1.5px solid var(--text-tertiary);
transform: rotate(45deg) translateY(-2px);
transition: transform var(--transition-base);
}
.target-caret.open {
transform: rotate(225deg) translateY(-2px);
}
.target-menu {
position: absolute;
left: var(--space-3);
right: var(--space-3);
top: calc(100% - var(--space-1));
z-index: 30;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.target-options {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 220px;
overflow-y: auto;
}
.target-option {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: 42px;
padding: 5px 6px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
text-align: left;
}
.target-option:hover:not(:disabled) {
background: var(--bg-secondary);
}
.target-option.active {
background: var(--accent-light);
}
.target-option:disabled {
cursor: default;
opacity: 0.7;
}
.target-option-avatar {
width: 28px;
height: 28px;
font-size: 0.8rem;
}
.target-add-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 30px;
gap: var(--space-1);
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--border-light);
}
.target-add-form input {
min-width: 0;
height: 30px;
padding: 5px 8px;
font-size: 0.75rem;
}
.target-add-button {
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-contrast);
font-size: 1rem;
font-weight: var(--weight-semibold);
}
.target-add-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.target-error {
margin-top: var(--space-2);
color: var(--sev-high);
font-size: 0.72rem;
line-height: 1.3;
}
.nav-list {
list-style: none;
padding: var(--space-3) var(--space-3);
@ -2504,6 +2663,143 @@ tr.selected td {
color: var(--text);
}
/* ── Finding Detail: dynamic verification ─────────────────────────── */
.badge-dyn-confirmed {
background: var(--success-bg);
color: var(--success);
}
.badge-dyn-partiallyconfirmed {
background: var(--conf-medium-bg);
color: var(--conf-medium);
}
.badge-dyn-notconfirmed {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.badge-dyn-inconclusive {
background: var(--sev-medium-bg);
color: var(--sev-medium);
}
.badge-dyn-unsupported {
background: var(--conf-low-bg);
color: var(--conf-low);
}
.dynamic-verdict-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
font-size: var(--text-sm);
line-height: 1.45;
}
.dynamic-verdict-badge-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.dynamic-toolchain-match {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: var(--text-xs);
}
.repro-panel,
.dynamic-verdict-detail,
.dynamic-attempts {
border: 1px solid var(--border-light);
border-radius: 6px;
background: var(--bg);
padding: var(--space-3);
}
.repro-cmd-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
flex-wrap: wrap;
}
.repro-label,
.dynamic-attempts > strong,
.dynamic-verdict-detail strong {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.repro-cmd {
flex: 1 1 220px;
min-width: 0;
overflow-wrap: anywhere;
padding: 4px 7px;
border-radius: var(--radius-sm);
background: var(--terminal-bg);
color: var(--terminal-text);
font-size: 0.78rem;
}
.repro-copy-btn {
flex: 0 0 auto;
}
.dynamic-verdict-detail {
display: grid;
gap: var(--space-2);
}
.dynamic-verdict-detail-text {
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.dynamic-attempt-list {
list-style: none;
display: grid;
gap: var(--space-2);
margin: var(--space-2) 0 0;
padding: 0;
}
.attempt-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-2);
padding: 8px 10px;
border: 1px solid var(--border-light);
border-radius: 6px;
background: var(--surface);
}
.attempt-row.triggered {
border-color: color-mix(in srgb, var(--success) 35%, var(--border));
background: var(--success-bg);
}
.attempt-row code {
min-width: 0;
overflow-wrap: anywhere;
font-size: 0.8rem;
}
.attempt-outcome,
.attempt-exit-code {
color: var(--text-secondary);
font-size: var(--text-xs);
white-space: nowrap;
}
.attempt-row.triggered .attempt-outcome {
color: var(--success);
font-weight: var(--weight-semibold);
}
@media (max-width: 640px) {
.attempt-row {
grid-template-columns: 1fr;
align-items: start;
}
.attempt-outcome,
.attempt-exit-code {
white-space: normal;
}
}
/* ── Code Viewer Modal ────────────────────────────────────────────── */
.code-modal-overlay {
position: fixed;
@ -8793,3 +9089,153 @@ input[type='checkbox'] {
[data-theme='light'] .code-modal-title {
color: var(--text);
}
/* SurfacePage */
.surface-header {
display: flex;
align-items: baseline;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.surface-header h1 {
margin: 0;
}
.surface-header-summary {
color: var(--text-tertiary);
font-size: var(--text-sm);
}
.surface-filter-row {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.surface-filter-input {
flex: 1 1 220px;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-1);
background: var(--surface-1);
color: var(--text-primary);
}
.surface-filter-select {
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-1);
background: var(--surface-1);
color: var(--text-primary);
}
.surface-grid {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.4fr);
gap: var(--space-4);
align-items: flex-start;
}
.surface-node-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 70vh;
overflow-y: auto;
}
.surface-node-list-empty {
color: var(--text-tertiary);
}
.surface-sidebar {
border: 1px solid var(--border);
border-radius: var(--radius-2);
padding: var(--space-4);
background: var(--surface-1);
}
.surface-node-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
padding: var(--space-3);
border-radius: var(--radius-2);
cursor: pointer;
text-align: left;
width: 100%;
}
.surface-node-card-meta {
font-size: var(--text-2xs);
color: var(--text-tertiary);
}
.surface-node-card-title {
font-weight: 600;
font-size: var(--text-sm);
}
.surface-node-card-subtitle {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.surface-node-card-loc {
font-size: var(--text-2xs);
color: var(--text-tertiary);
}
.surface-neighbor-empty {
color: var(--text-tertiary);
}
.surface-neighbor-title {
margin-top: 0;
}
.surface-neighbor-subtitle {
color: var(--text-secondary);
margin-top: 0;
}
.surface-neighbor-edges {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.surface-neighbor-edge {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
}
.surface-neighbor-edge-kind {
padding: 2px 6px;
border-radius: var(--radius-1);
background: var(--surface-2);
color: var(--text-secondary);
}
.surface-neighbor-edge-loc {
font-size: var(--text-2xs);
color: var(--text-tertiary);
}
.surface-view-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-1);
overflow: hidden;
background: var(--surface-1);
}
.surface-view-toggle-button {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 0;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--text-xs);
}
.surface-view-toggle-button:not(:last-child) {
border-right: 1px solid var(--border);
}
.surface-view-toggle-button.selected {
background: var(--surface-2);
color: var(--text-primary);
font-weight: 600;
}
.surface-graph-frame {
position: relative;
min-height: 70vh;
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--surface-1);
overflow: hidden;
}

View file

@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { DynamicVerdictSection } from '@/pages/FindingDetailPage';
import type { VerifyResult } from '@/api/types';
function makeVerdict(
status: VerifyResult['status'],
extras: Partial<VerifyResult> = {},
): VerifyResult {
return {
finding_id: 'test-finding-id-abc',
status,
attempts: [],
...extras,
};
}
// Mock navigator.clipboard before each test.
beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
writable: true,
});
});
describe('DynamicVerdictSection', () => {
it('renders Confirmed badge', () => {
render(
<DynamicVerdictSection
verdict={makeVerdict('Confirmed', {
triggered_payload: 'sqli-tautology',
})}
/>,
);
expect(screen.getByTestId('verdict-badge-confirmed')).toBeInTheDocument();
});
it('renders NotConfirmed badge', () => {
render(<DynamicVerdictSection verdict={makeVerdict('NotConfirmed')} />);
expect(
screen.getByTestId('verdict-badge-notconfirmed'),
).toBeInTheDocument();
});
it('renders PartiallyConfirmed badge', () => {
render(
<DynamicVerdictSection
verdict={makeVerdict('PartiallyConfirmed', {
detail: 'sink reached but exploit chain did not complete',
})}
/>,
);
expect(
screen.getByTestId('verdict-badge-partiallyconfirmed'),
).toBeInTheDocument();
});
it('does not crash when the API omits an empty attempts array', () => {
render(
<DynamicVerdictSection
verdict={{ finding_id: 'no-attempts', status: 'Confirmed' }}
/>,
);
expect(screen.getByTestId('verdict-badge-confirmed')).toBeInTheDocument();
});
it('renders Unsupported badge', () => {
render(
<DynamicVerdictSection
verdict={makeVerdict('Unsupported', { reason: 'NoPayloadsForCap' })}
/>,
);
expect(screen.getByTestId('verdict-badge-unsupported')).toBeInTheDocument();
});
it('renders Inconclusive badge', () => {
render(
<DynamicVerdictSection
verdict={makeVerdict('Inconclusive', {
inconclusive_reason: 'BuildFailed',
})}
/>,
);
expect(
screen.getByTestId('verdict-badge-inconclusive'),
).toBeInTheDocument();
});
it('shows repro panel only for Confirmed status', () => {
const { unmount } = render(
<DynamicVerdictSection verdict={makeVerdict('Confirmed')} />,
);
expect(screen.getByTestId('repro-panel')).toBeInTheDocument();
unmount();
for (const status of [
'PartiallyConfirmed',
'NotConfirmed',
'Unsupported',
'Inconclusive',
] as const) {
const { unmount: u } = render(
<DynamicVerdictSection verdict={makeVerdict(status)} />,
);
expect(screen.queryByTestId('repro-panel')).toBeNull();
u();
}
});
it('repro-panel contains the finding_id in the CLI command', () => {
render(
<DynamicVerdictSection
verdict={makeVerdict('Confirmed', { finding_id: 'cafecafe12345678' })}
/>,
);
const panel = screen.getByTestId('repro-panel');
expect(panel.textContent).toContain('cafecafe12345678');
expect(panel.textContent).toContain('nyx repro');
});
it('Copy button triggers clipboard writeText with the repro command', async () => {
const findingId = 'test-finding-id-abc';
render(<DynamicVerdictSection verdict={makeVerdict('Confirmed')} />);
const copyBtn = screen.getByRole('button', { name: /copy/i });
fireEvent.click(copyBtn);
expect(navigator.clipboard.writeText).toHaveBeenCalledOnce();
const calledWith = (
navigator.clipboard.writeText as ReturnType<typeof vi.fn>
).mock.calls[0][0] as string;
expect(calledWith).toContain(findingId);
expect(calledWith).toContain('nyx repro');
});
it('shows exact toolchain match label when toolchain_match is exact', () => {
render(
<DynamicVerdictSection
verdict={makeVerdict('Confirmed', { toolchain_match: 'exact' })}
/>,
);
expect(screen.getByText('exact toolchain')).toBeInTheDocument();
});
it('shows approximate toolchain match label when toolchain_match is drift', () => {
render(
<DynamicVerdictSection
verdict={makeVerdict('Confirmed', { toolchain_match: 'drift' })}
/>,
);
expect(screen.getByText('approximate toolchain')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,144 @@
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 PartiallyConfirmed badge with amber class and no flame', () => {
render(
<VerdictBadge
verdict={makeVerdict('PartiallyConfirmed', {
detail:
'sink-reachability probe fired but the oracle marker was not observed',
})}
/>,
);
const badge = screen.getByTestId('verdict-badge-partiallyconfirmed');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('badge-dyn-partiallyconfirmed');
expect(badge.textContent).not.toContain('🔥');
expect(badge.getAttribute('title')).toContain('sink reached');
});
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 when attempts are omitted by the API', () => {
render(
<VerdictBadge
verdict={{ finding_id: 'test-finding-id', status: 'NotConfirmed' }}
/>,
);
expect(
screen.getByTestId('verdict-badge-notconfirmed'),
).toBeInTheDocument();
});
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 five VerifyStatus variants without crashing', () => {
const statuses: VerifyResult['status'][] = [
'Confirmed',
'PartiallyConfirmed',
'NotConfirmed',
'Unsupported',
'Inconclusive',
];
for (const status of statuses) {
const { unmount } = render(
<VerdictBadge verdict={makeVerdict(status)} />,
);
expect(
screen.getByTestId(`verdict-badge-${status.toLowerCase()}`),
).toBeInTheDocument();
unmount();
}
});
});

View file

@ -49,6 +49,29 @@ describe('getNodeStyle', () => {
const s = getNodeStyle('Call', 'callgraph', { isRecursive: true });
expect(s.fill).toBe('#5a5042');
});
it('returns a double shape for surface entry-point nodes', () => {
const s = getNodeStyle('EntryPoint', 'surface');
expect(s.shape).toBe('double');
expect(s.fill).toBe('#1c5c38');
});
it('returns a terminal shape for surface dangerous-local nodes', () => {
const s = getNodeStyle('DangerousLocal', 'surface');
expect(s.shape).toBe('terminal');
expect(s.fill).toBe('#9d2f25');
});
it('returns a warning fill for surface data-store nodes', () => {
const s = getNodeStyle('DataStore', 'surface');
expect(s.fill).toBe('#8c6310');
expect(s.shape).toBe('rect');
});
it('returns an accent fill for surface external-service nodes', () => {
const s = getNodeStyle('ExternalService', 'surface');
expect(s.fill).toBe('#0b3d2a');
});
});
describe('getEdgeStyle', () => {
@ -90,4 +113,26 @@ describe('getEdgeStyle', () => {
const s = getEdgeStyle('Call', 'callgraph');
expect(s.dash).toEqual([]);
});
it('returns a dashed style for surface auth_required_on edges', () => {
const s = getEdgeStyle('auth_required_on', 'surface');
expect(s.dash).toEqual([2, 4]);
});
it('returns a solid danger color for surface reaches edges', () => {
const s = getEdgeStyle('reaches', 'surface');
expect(s.color).toBe('#9d2f25');
expect(s.dash).toEqual([]);
});
it('returns a dashed success style for surface triggers edges', () => {
const s = getEdgeStyle('triggers', 'surface');
expect(s.dash).toEqual([4, 3]);
});
it('returns a fallback style for unknown surface edge kinds', () => {
const s = getEdgeStyle('mystery', 'surface');
expect(s.color).toContain('rgba');
expect(s.dash).toEqual([]);
});
});

View file

@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { adaptSurfaceMap, SURFACE_NODE_KIND } from '@/graph/adapters/surface';
import type { SurfaceMap } from '@/api/types';
const SAMPLE: SurfaceMap = {
nodes: [
{
node: 'entry_point',
location: { file: 'app.py', line: 10, col: 0 },
framework: 'flask',
method: 'POST',
route: '/api/run',
handler_name: 'run',
handler_location: { file: 'app.py', line: 12, col: 2 },
auth_required: false,
},
{
node: 'data_store',
location: { file: 'db.py', line: 40, col: 0 },
kind: 'sql',
label: 'orders',
},
{
node: 'external_service',
location: { file: 'client.py', line: 5, col: 0 },
kind: 'http_api',
label: 'github.com',
},
{
node: 'dangerous_local',
location: { file: 'app.py', line: 24, col: 4 },
function_name: 'run',
cap_bits: 0x400,
},
],
edges: [
{ from: 0, to: 3, kind: 'calls' },
{ from: 3, to: 1, kind: 'writes_to' },
{ from: 0, to: 2, kind: 'talks_to' },
],
};
describe('adaptSurfaceMap', () => {
it('produces a surface-kind GraphModel', () => {
const model = adaptSurfaceMap(SAMPLE);
expect(model.kind).toBe('surface');
expect(model.nodes).toHaveLength(4);
expect(model.edges).toHaveLength(3);
});
it('keys nodes by index so SurfaceEdge.from/to map directly', () => {
const model = adaptSurfaceMap(SAMPLE);
expect(model.nodes.map((n) => n.key)).toEqual(['0', '1', '2', '3']);
expect(model.edges[0]?.source).toBe('0');
expect(model.edges[0]?.target).toBe('3');
});
it('maps each SurfaceNode kind to a distinct style discriminator', () => {
const model = adaptSurfaceMap(SAMPLE);
expect(model.nodes[0]?.kind).toBe(SURFACE_NODE_KIND.entry_point);
expect(model.nodes[1]?.kind).toBe(SURFACE_NODE_KIND.data_store);
expect(model.nodes[2]?.kind).toBe(SURFACE_NODE_KIND.external_service);
expect(model.nodes[3]?.kind).toBe(SURFACE_NODE_KIND.dangerous_local);
});
it('builds entry-point labels from method and route', () => {
const model = adaptSurfaceMap(SAMPLE);
expect(model.nodes[0]?.label).toBe('POST /api/run');
expect(model.nodes[0]?.detail).toBe('flask · run');
});
it('renders dangerous_local cap_bits as hex in detail', () => {
const model = adaptSurfaceMap(SAMPLE);
expect(model.nodes[3]?.detail).toBe('cap=0x400');
});
it('uses handler_location for entry_point line, location for others', () => {
const model = adaptSurfaceMap(SAMPLE);
expect(model.nodes[0]?.line).toBe(12);
expect(model.nodes[1]?.line).toBe(40);
});
it('emits an auth badge only for entry_points marked auth_required', () => {
const protectedEntry = adaptSurfaceMap({
nodes: [
{
...SAMPLE.nodes[0],
node: 'entry_point',
auth_required: true,
} as SurfaceMap['nodes'][0],
],
edges: [],
});
expect(protectedEntry.nodes[0]?.badges).toEqual(['auth']);
const openEntry = adaptSurfaceMap(SAMPLE);
expect(openEntry.nodes[0]?.badges).toBeUndefined();
});
it('produces unique edge keys even for parallel edges of the same kind', () => {
const parallel: SurfaceMap = {
nodes: SAMPLE.nodes,
edges: [
{ from: 0, to: 1, kind: 'calls' },
{ from: 0, to: 1, kind: 'calls' },
],
};
const model = adaptSurfaceMap(parallel);
expect(model.edges[0]?.key).not.toBe(model.edges[1]?.key);
});
});

View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { NewScanModal } from '@/modals/NewScanModal';
const mockMutateAsync = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const mockToastSuccess = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
vi.mock('@/api/queries/health', () => ({
useHealth: () => ({ data: { scan_root: '/test/project' } }),
}));
vi.mock('@/api/mutations/scans', () => ({
useStartScan: () => ({
mutateAsync: mockMutateAsync,
isPending: false,
}),
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
vi.mock('@/contexts/ToastContext', () => ({
useToast: () => ({ success: mockToastSuccess, error: mockToastError }),
}));
vi.mock('@/components/ui/Modal', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Modal: ({ open, children }: { open: boolean; children?: any }) =>
open ? <>{children}</> : null,
}));
describe('NewScanModal', () => {
beforeEach(() => {
mockMutateAsync.mockReset();
mockMutateAsync.mockResolvedValue(undefined);
mockNavigate.mockReset();
mockToastSuccess.mockReset();
mockToastError.mockReset();
});
it('renders when open is true', () => {
render(<NewScanModal open={true} onClose={vi.fn()} />);
expect(screen.getByText('Start new scan')).toBeInTheDocument();
});
it('calls mutateAsync without verify key when checkbox is untouched', async () => {
render(<NewScanModal open={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Start scan' }));
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce());
const payload = mockMutateAsync.mock.calls[0][0];
expect(payload).not.toHaveProperty('verify');
expect(payload).toEqual({
engine_profile: 'balanced',
verify_backend: 'auto',
harden_profile: 'standard',
});
});
it('calls mutateAsync with verify: false when checkbox is checked', async () => {
render(<NewScanModal open={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: 'Start scan' }));
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce());
const payload = mockMutateAsync.mock.calls[0][0];
expect(payload).toEqual({ engine_profile: 'balanced', verify: false });
});
it('allows selecting the unsafe process verification backend', async () => {
render(<NewScanModal open={true} onClose={vi.fn()} />);
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[2], { target: { value: 'process' } });
fireEvent.click(screen.getByRole('button', { name: 'Start scan' }));
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce());
const payload = mockMutateAsync.mock.calls[0][0];
expect(payload).toMatchObject({
verify_backend: 'process',
harden_profile: 'standard',
});
});
});