mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
Dynamic (#77)
This commit is contained in:
parent
55247b7fcd
commit
991c84a1eb
1464 changed files with 225448 additions and 1985 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface FindingsParams {
|
|||
language?: string;
|
||||
rule_id?: string;
|
||||
status?: string;
|
||||
verification?: string;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
|
|
|
|||
11
frontend/src/api/queries/surface.ts
Normal file
11
frontend/src/api/queries/surface.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
43
frontend/src/api/queries/targets.ts
Normal file
43
frontend/src/api/queries/targets.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 21–23) ───────────────────────────────────────
|
||||
|
||||
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[];
|
||||
}
|
||||
|
|
|
|||
64
frontend/src/components/VerdictBadge.tsx
Normal file
64
frontend/src/components/VerdictBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
84
frontend/src/graph/adapters/surface.ts
Normal file
84
frontend/src/graph/adapters/surface.ts
Normal 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 },
|
||||
})),
|
||||
};
|
||||
}
|
||||
123
frontend/src/graph/components/SurfaceGraphCanvas.tsx
Normal file
123
frontend/src/graph/components/SurfaceGraphCanvas.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type GraphViewKind = 'callgraph' | 'cfg';
|
||||
export type GraphViewKind = 'callgraph' | 'cfg' | 'surface';
|
||||
|
||||
export interface GraphPoint {
|
||||
x: number;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">→</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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
314
frontend/src/pages/SurfacePage.tsx
Normal file
314
frontend/src/pages/SurfacePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
154
frontend/src/test/components/dynamicVerdictSection.test.tsx
Normal file
154
frontend/src/test/components/dynamicVerdictSection.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
144
frontend/src/test/components/verdictBadge.test.tsx
Normal file
144
frontend/src/test/components/verdictBadge.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
110
frontend/src/test/graph/surfaceAdapter.test.ts
Normal file
110
frontend/src/test/graph/surfaceAdapter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
frontend/src/test/modals/NewScanModal.test.tsx
Normal file
83
frontend/src/test/modals/NewScanModal.test.tsx
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue