mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-27 20:29:39 +02:00
Dynamic (#77)
This commit is contained in:
parent
55247b7fcd
commit
991c84a1eb
1464 changed files with 225448 additions and 1985 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue