mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 23: Track F.4 — nyx surface subcommand + human-readable output
This commit is contained in:
parent
66a59200ae
commit
655ec45b21
13 changed files with 1248 additions and 1 deletions
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -892,3 +892,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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -68,6 +68,13 @@ const NAV_SECTIONS: NavItem[] = [
|
|||
Icon: ExplorerIcon,
|
||||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'surface',
|
||||
label: 'Surface',
|
||||
path: '/surface',
|
||||
Icon: ExplorerIcon,
|
||||
group: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'debug',
|
||||
label: 'Debug',
|
||||
|
|
|
|||
363
frontend/src/pages/SurfacePage.tsx
Normal file
363
frontend/src/pages/SurfacePage.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
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 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={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 'var(--space-1)',
|
||||
padding: 'var(--space-3)',
|
||||
border: `1px solid ${selected ? color : 'var(--border)'}`,
|
||||
borderLeft: `4px solid ${color}`,
|
||||
borderRadius: 'var(--radius-2)',
|
||||
background: selected ? 'var(--surface-2)' : 'var(--surface-1)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 'var(--text-2xs)', color: 'var(--text-tertiary)' }}>
|
||||
#{index} · {node.node.replace('_', ' ')}
|
||||
{node.node === 'entry_point' && node.auth_required ? ' · auth' : ''}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>
|
||||
{nodeTitle(node)}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)' }}>
|
||||
{nodeSubtitle(node)}
|
||||
</span>
|
||||
<code style={{ fontSize: 'var(--text-2xs)', color: 'var(--text-tertiary)' }}>
|
||||
{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 style={{ color: 'var(--text-tertiary)' }}>
|
||||
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 style={{ color: 'var(--text-tertiary)' }}>
|
||||
(no {direction === 'in' ? 'inbound' : 'outbound'} edges)
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-1)',
|
||||
}}
|
||||
>
|
||||
{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}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-1)',
|
||||
background: 'var(--surface-2)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{EDGE_KIND_LABELS[e.kind]}
|
||||
</span>
|
||||
<span>
|
||||
{direction === 'in' ? '←' : '→'} <strong>{nodeTitle(other)}</strong>
|
||||
</span>
|
||||
<code
|
||||
style={{ fontSize: 'var(--text-2xs)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{nodeLocation(other)}
|
||||
</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ marginTop: 0 }}>{nodeTitle(node)}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: 0 }}>
|
||||
{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'];
|
||||
|
||||
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 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
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 'var(--space-4)',
|
||||
marginBottom: 'var(--space-4)',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0 }}>Attack surface</h1>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
{summary.entries} entry-points · {summary.stores} stores ·{' '}
|
||||
{summary.externals} services · {summary.dangerous} dangerous locals ·{' '}
|
||||
{data.edges.length} edges
|
||||
</span>
|
||||
</header>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 'var(--space-2)',
|
||||
marginBottom: 'var(--space-3)',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Filter by name, label, or path"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{
|
||||
flex: '1 1 220px',
|
||||
padding: 'var(--space-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-1)',
|
||||
background: 'var(--surface-1)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as NodeKindFilter)}
|
||||
style={{
|
||||
padding: 'var(--space-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-1)',
|
||||
background: 'var(--surface-1)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.4fr)',
|
||||
gap: 'var(--space-4)',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-2)',
|
||||
maxHeight: '70vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-tertiary)' }}>No nodes match.</p>
|
||||
) : (
|
||||
visible.map(({ node, index }) => (
|
||||
<NodeCard
|
||||
key={index}
|
||||
node={node}
|
||||
index={index}
|
||||
selected={selected === index}
|
||||
onClick={() => setSelected(index)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<aside
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-2)',
|
||||
padding: 'var(--space-4)',
|
||||
background: 'var(--surface-1)',
|
||||
}}
|
||||
>
|
||||
<NeighborList map={data} index={selected} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/queryclient.ts","./src/api/types.ts","./src/api/mutations/baseline.ts","./src/api/mutations/config.ts","./src/api/mutations/rules.ts","./src/api/mutations/scans.ts","./src/api/mutations/triage.ts","./src/api/queries/config.ts","./src/api/queries/debug.ts","./src/api/queries/explorer.ts","./src/api/queries/findings.ts","./src/api/queries/health.ts","./src/api/queries/overview.ts","./src/api/queries/rules.ts","./src/api/queries/scans.ts","./src/api/queries/triage.ts","./src/components/copymarkdownbutton.tsx","./src/components/verdictbadge.tsx","./src/components/charts/horizontalbarchart.tsx","./src/components/charts/linechart.tsx","./src/components/data-display/codeviewer.tsx","./src/components/data-display/filetree.tsx","./src/components/explorer/analysisworkspace.tsx","./src/components/icons/icons.tsx","./src/components/layout/applayout.tsx","./src/components/layout/headerbar.tsx","./src/components/layout/sidebar.tsx","./src/components/overview/overviewwidgets.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/errorstate.tsx","./src/components/ui/loadingstate.tsx","./src/components/ui/modal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/shortcutshelp.tsx","./src/components/ui/statcard.tsx","./src/components/ui/toaster.tsx","./src/contexts/ssecontext.tsx","./src/contexts/themecontext.tsx","./src/contexts/toastcontext.tsx","./src/graph/styles.ts","./src/graph/types.ts","./src/graph/adapters/callgraph.ts","./src/graph/adapters/cfg.ts","./src/graph/components/callgraphcanvas.tsx","./src/graph/components/cfggraphcanvas.tsx","./src/graph/components/graphtoolbar.tsx","./src/graph/hooks/useelklayout.ts","./src/graph/layout/elk.ts","./src/graph/layout/text.ts","./src/graph/reduction/cfgcompaction.ts","./src/graph/reduction/neighborhood.ts","./src/graph/rendering/sigma/sigmagraph.tsx","./src/graph/rendering/sigma/buildgraph.ts","./src/graph/rendering/sigma/edgeoverlay.ts","./src/hooks/usechordnavigation.ts","./src/hooks/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usepagetitle.ts","./src/hooks/usepersistedstate.ts","./src/modals/codeviewermodal.tsx","./src/modals/newscanmodal.tsx","./src/pages/configpage.tsx","./src/pages/explorerpage.tsx","./src/pages/findingdetailpage.tsx","./src/pages/findingspage.tsx","./src/pages/overviewpage.tsx","./src/pages/rulespage.tsx","./src/pages/scancomparepage.tsx","./src/pages/scandetailpage.tsx","./src/pages/scanspage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/authanalysispage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/pointerviewerpage.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.tsx","./src/pages/debug/typefactspage.tsx","./src/test/setup.ts","./src/test/api/client.test.ts","./src/test/components/pagination.test.tsx","./src/test/components/statcard.test.tsx","./src/test/components/dynamicverdictsection.test.tsx","./src/test/components/statecomponents.test.tsx","./src/test/components/verdictbadge.test.tsx","./src/test/graph/cfgadapter.test.ts","./src/test/graph/compactgraph.test.ts","./src/test/graph/nodestyles.test.ts","./src/test/hooks/usedebounce.test.ts","./src/test/modals/newscanmodal.test.tsx","./src/test/utils/findingmarkdown.test.ts","./src/test/utils/formatdate.test.ts","./src/test/utils/syntaxhighlight.test.ts","./src/test/utils/truncpath.test.ts","./src/utils/findingmarkdown.ts","./src/utils/formatdate.ts","./src/utils/parsenote.ts","./src/utils/syntaxhighlight.ts","./src/utils/truncpath.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/queryclient.ts","./src/api/types.ts","./src/api/mutations/baseline.ts","./src/api/mutations/config.ts","./src/api/mutations/rules.ts","./src/api/mutations/scans.ts","./src/api/mutations/triage.ts","./src/api/queries/config.ts","./src/api/queries/debug.ts","./src/api/queries/explorer.ts","./src/api/queries/findings.ts","./src/api/queries/health.ts","./src/api/queries/overview.ts","./src/api/queries/rules.ts","./src/api/queries/scans.ts","./src/api/queries/surface.ts","./src/api/queries/triage.ts","./src/components/copymarkdownbutton.tsx","./src/components/verdictbadge.tsx","./src/components/charts/horizontalbarchart.tsx","./src/components/charts/linechart.tsx","./src/components/data-display/codeviewer.tsx","./src/components/data-display/filetree.tsx","./src/components/explorer/analysisworkspace.tsx","./src/components/icons/icons.tsx","./src/components/layout/applayout.tsx","./src/components/layout/headerbar.tsx","./src/components/layout/sidebar.tsx","./src/components/overview/overviewwidgets.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/errorstate.tsx","./src/components/ui/loadingstate.tsx","./src/components/ui/modal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/shortcutshelp.tsx","./src/components/ui/statcard.tsx","./src/components/ui/toaster.tsx","./src/contexts/ssecontext.tsx","./src/contexts/themecontext.tsx","./src/contexts/toastcontext.tsx","./src/graph/styles.ts","./src/graph/types.ts","./src/graph/adapters/callgraph.ts","./src/graph/adapters/cfg.ts","./src/graph/components/callgraphcanvas.tsx","./src/graph/components/cfggraphcanvas.tsx","./src/graph/components/graphtoolbar.tsx","./src/graph/hooks/useelklayout.ts","./src/graph/layout/elk.ts","./src/graph/layout/text.ts","./src/graph/reduction/cfgcompaction.ts","./src/graph/reduction/neighborhood.ts","./src/graph/rendering/sigma/sigmagraph.tsx","./src/graph/rendering/sigma/buildgraph.ts","./src/graph/rendering/sigma/edgeoverlay.ts","./src/hooks/usechordnavigation.ts","./src/hooks/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usepagetitle.ts","./src/hooks/usepersistedstate.ts","./src/modals/codeviewermodal.tsx","./src/modals/newscanmodal.tsx","./src/pages/configpage.tsx","./src/pages/explorerpage.tsx","./src/pages/findingdetailpage.tsx","./src/pages/findingspage.tsx","./src/pages/overviewpage.tsx","./src/pages/rulespage.tsx","./src/pages/scancomparepage.tsx","./src/pages/scandetailpage.tsx","./src/pages/scanspage.tsx","./src/pages/surfacepage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/authanalysispage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/pointerviewerpage.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.tsx","./src/pages/debug/typefactspage.tsx","./src/test/setup.ts","./src/test/api/client.test.ts","./src/test/components/pagination.test.tsx","./src/test/components/statcard.test.tsx","./src/test/components/dynamicverdictsection.test.tsx","./src/test/components/statecomponents.test.tsx","./src/test/components/verdictbadge.test.tsx","./src/test/graph/cfgadapter.test.ts","./src/test/graph/compactgraph.test.ts","./src/test/graph/nodestyles.test.ts","./src/test/hooks/usedebounce.test.ts","./src/test/modals/newscanmodal.test.tsx","./src/test/utils/findingmarkdown.test.ts","./src/test/utils/formatdate.test.ts","./src/test/utils/syntaxhighlight.test.ts","./src/test/utils/truncpath.test.ts","./src/utils/findingmarkdown.ts","./src/utils/formatdate.ts","./src/utils/parsenote.ts","./src/utils/syntaxhighlight.ts","./src/utils/truncpath.ts"],"version":"6.0.3"}
|
||||
45
src/cli.rs
45
src/cli.rs
|
|
@ -50,6 +50,7 @@ impl Commands {
|
|||
Commands::Scan { explain_engine, .. } => *explain_engine,
|
||||
Commands::List { .. } => true,
|
||||
Commands::Rules { .. } => true,
|
||||
Commands::Surface { .. } => true,
|
||||
Commands::Config { action } => {
|
||||
matches!(action, ConfigAction::Show { .. } | ConfigAction::Path)
|
||||
}
|
||||
|
|
@ -105,6 +106,32 @@ pub enum ScanMode {
|
|||
Taint,
|
||||
}
|
||||
|
||||
/// Output format for `nyx surface`.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)]
|
||||
pub enum SurfaceFormat {
|
||||
/// Indented tree, one entry-point per line, with reach summary.
|
||||
#[default]
|
||||
Text,
|
||||
/// Canonical SurfaceMap JSON, byte-identical to the SQLite payload.
|
||||
Json,
|
||||
/// Graphviz DOT source; pipe through `dot -Tsvg` to render.
|
||||
Dot,
|
||||
/// SVG produced by spawning the local `dot` binary on the DOT
|
||||
/// rendering. Fails when graphviz is not installed.
|
||||
Svg,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SurfaceFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SurfaceFormat::Text => write!(f, "text"),
|
||||
SurfaceFormat::Json => write!(f, "json"),
|
||||
SurfaceFormat::Dot => write!(f, "dot"),
|
||||
SurfaceFormat::Svg => write!(f, "svg"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Engine-depth profile that sets the full stack of analysis toggles
|
||||
/// in one shot. Individual engine flags override the profile.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
|
||||
|
|
@ -564,6 +591,24 @@ pub enum Commands {
|
|||
action: RulesAction,
|
||||
},
|
||||
|
||||
/// Print the project's attack-surface map.
|
||||
///
|
||||
/// Loads the SurfaceMap persisted by the most recent indexed scan
|
||||
/// when available, otherwise builds an entry-point-only map by
|
||||
/// running the per-language framework probes against the on-disk
|
||||
/// source. Use `--format dot` and pipe through `dot -Tsvg` to
|
||||
/// produce a renderable graph; `--format svg` does the same in one
|
||||
/// step when graphviz is installed locally.
|
||||
Surface {
|
||||
/// Path to inspect (defaults to current directory)
|
||||
#[arg(default_value = ".")]
|
||||
path: String,
|
||||
|
||||
/// Output format: text (default), json, dot, svg
|
||||
#[arg(long, value_enum, default_value_t = SurfaceFormat::Text)]
|
||||
format: SurfaceFormat,
|
||||
},
|
||||
|
||||
/// Start the local web UI for browsing scan results
|
||||
Serve {
|
||||
/// Path to scan root (defaults to current directory)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub mod rules;
|
|||
pub mod scan;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod serve;
|
||||
pub mod surface;
|
||||
|
||||
use crate::cli::{Commands, EngineProfile, IndexMode, ScanMode};
|
||||
use crate::errors::NyxResult;
|
||||
|
|
@ -418,6 +419,10 @@ pub fn handle_command(
|
|||
Commands::Rules { action } => {
|
||||
self::rules::handle(action, config)?;
|
||||
}
|
||||
Commands::Surface { path, format } => {
|
||||
install_from_config(config);
|
||||
surface::handle(&path, format, database_dir, config)?;
|
||||
}
|
||||
Commands::Serve {
|
||||
path,
|
||||
port,
|
||||
|
|
|
|||
532
src/commands/surface.rs
Normal file
532
src/commands/surface.rs
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
//! Phase 23 — `nyx surface` subcommand.
|
||||
//!
|
||||
//! Walks the project tree, builds a [`SurfaceMap`] from the framework
|
||||
//! probes (plus any persisted data-store / external-service /
|
||||
//! dangerous-local nodes from a prior indexed scan) and renders the
|
||||
//! map in the format requested by the user.
|
||||
//!
|
||||
//! Output formats:
|
||||
//! * `text` — indented tree per entry-point, grouped by file
|
||||
//! * `json` — canonical JSON (byte-identical to the SQLite payload)
|
||||
//! * `dot` — graphviz source, ready to pipe through `dot -Tsvg`
|
||||
//! * `svg` — graphviz source rendered via the local `dot` binary
|
||||
//!
|
||||
//! The command is read-only: it never persists to SQLite and never
|
||||
//! modifies the project tree. It tries to load a previously persisted
|
||||
//! map first; if none exists (no `nyx scan` ever ran, or the index was
|
||||
//! cleaned) it falls back to building a fresh entry-point-only map by
|
||||
//! running the framework probes against the on-disk source.
|
||||
|
||||
use crate::callgraph;
|
||||
use crate::cli::SurfaceFormat;
|
||||
use crate::database::index::Indexer;
|
||||
use crate::errors::{NyxError, NyxResult};
|
||||
use crate::summary::GlobalSummaries;
|
||||
use crate::surface::{
|
||||
DataStoreKind, EdgeKind, EntryPoint, ExternalServiceKind, SurfaceMap, SurfaceNode,
|
||||
build::{SurfaceBuildInputs, build_surface_map},
|
||||
};
|
||||
use crate::utils::Config;
|
||||
use crate::utils::project::get_project_info;
|
||||
use crate::walk::spawn_file_walker;
|
||||
use crossbeam_channel::TryRecvError;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Top-level CLI handler. Resolves the scan root, loads or builds a
|
||||
/// [`SurfaceMap`], renders it in `format`, and writes to stdout.
|
||||
pub fn handle(
|
||||
path: &str,
|
||||
format: SurfaceFormat,
|
||||
database_dir: &Path,
|
||||
config: &Config,
|
||||
) -> NyxResult<()> {
|
||||
let scan_root = Path::new(path).canonicalize()?;
|
||||
let map = load_or_build(&scan_root, database_dir, config)?;
|
||||
let stdout = std::io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
match format {
|
||||
SurfaceFormat::Text => {
|
||||
out.write_all(render_text(&map, Some(&scan_root)).as_bytes())?;
|
||||
}
|
||||
SurfaceFormat::Json => {
|
||||
let mut canon = map;
|
||||
let bytes = canon
|
||||
.to_json()
|
||||
.map_err(|e| NyxError::Msg(format!("surface map JSON: {e}")))?;
|
||||
out.write_all(&bytes)?;
|
||||
out.write_all(b"\n")?;
|
||||
}
|
||||
SurfaceFormat::Dot => {
|
||||
out.write_all(render_dot(&map).as_bytes())?;
|
||||
}
|
||||
SurfaceFormat::Svg => {
|
||||
let svg = render_svg(&map)?;
|
||||
out.write_all(&svg)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the SurfaceMap persisted under `scan_root`'s project entry, or
|
||||
/// build a fresh entry-point-only map from the filesystem when no
|
||||
/// indexed scan has ever populated one.
|
||||
pub fn load_or_build(
|
||||
scan_root: &Path,
|
||||
database_dir: &Path,
|
||||
config: &Config,
|
||||
) -> NyxResult<SurfaceMap> {
|
||||
if let Ok((project, db_path)) = get_project_info(scan_root, database_dir) {
|
||||
if db_path.exists() {
|
||||
if let Ok(pool) = Indexer::init(&db_path) {
|
||||
if let Ok(idx) = Indexer::from_pool(&project, &pool) {
|
||||
if let Ok(Some(map)) = idx.load_surface_map() {
|
||||
if !map.nodes.is_empty() {
|
||||
return Ok(map);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
build_from_filesystem(scan_root, config)
|
||||
}
|
||||
|
||||
fn build_from_filesystem(scan_root: &Path, config: &Config) -> NyxResult<SurfaceMap> {
|
||||
let files = collect_files(scan_root, config)?;
|
||||
let summaries = GlobalSummaries::new();
|
||||
let call_graph = callgraph::build_call_graph(&summaries, &[]);
|
||||
let inputs = SurfaceBuildInputs {
|
||||
files: &files,
|
||||
scan_root: Some(scan_root),
|
||||
global_summaries: &summaries,
|
||||
call_graph: &call_graph,
|
||||
config,
|
||||
};
|
||||
Ok(build_surface_map(&inputs))
|
||||
}
|
||||
|
||||
fn collect_files(root: &Path, config: &Config) -> NyxResult<Vec<PathBuf>> {
|
||||
let (rx, handle) = spawn_file_walker(root, config);
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(batch) => out.extend(batch),
|
||||
Err(TryRecvError::Empty) => match rx.recv() {
|
||||
Ok(batch) => out.extend(batch),
|
||||
Err(_) => break,
|
||||
},
|
||||
Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
let _ = handle.join();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Text rendering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Produce a human-readable tree. Files appear as top-level headers;
|
||||
/// each entry-point sits under its host file with its reach summary
|
||||
/// (`Reaches: …`). Data stores / external services / dangerous locals
|
||||
/// that no entry-point reaches are grouped under a trailing "Unreached"
|
||||
/// section so a reviewer notices orphaned attack surface.
|
||||
pub fn render_text(map: &SurfaceMap, scan_root: Option<&Path>) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(root) = scan_root {
|
||||
out.push_str(&format!("Surface map for {}\n", root.display()));
|
||||
} else {
|
||||
out.push_str("Surface map\n");
|
||||
}
|
||||
out.push_str(&format!(
|
||||
" {} entry-points, {} data stores, {} external services, {} dangerous locals\n\n",
|
||||
count_kind(map, |n| matches!(n, SurfaceNode::EntryPoint(_))),
|
||||
count_kind(map, |n| matches!(n, SurfaceNode::DataStore(_))),
|
||||
count_kind(map, |n| matches!(n, SurfaceNode::ExternalService(_))),
|
||||
count_kind(map, |n| matches!(n, SurfaceNode::DangerousLocal(_))),
|
||||
));
|
||||
|
||||
if map.nodes.is_empty() {
|
||||
out.push_str(" (no entry-points or sinks detected)\n");
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut by_file: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
|
||||
for (idx, node) in map.nodes.iter().enumerate() {
|
||||
by_file
|
||||
.entry(node.location().file.as_str())
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
|
||||
let mut reached: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for edge in &map.edges {
|
||||
if matches!(edge.kind, EdgeKind::Reaches) {
|
||||
reached.insert(edge.to);
|
||||
}
|
||||
}
|
||||
|
||||
for (file, indices) in &by_file {
|
||||
out.push_str(&format!("{file}\n"));
|
||||
let entry_indices: Vec<usize> = indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|i| matches!(map.nodes[*i], SurfaceNode::EntryPoint(_)))
|
||||
.collect();
|
||||
if !entry_indices.is_empty() {
|
||||
for &ei in &entry_indices {
|
||||
let SurfaceNode::EntryPoint(ep) = &map.nodes[ei] else {
|
||||
continue;
|
||||
};
|
||||
render_entry_point(&mut out, ep, ei as u32, map);
|
||||
}
|
||||
}
|
||||
for &i in indices {
|
||||
match &map.nodes[i] {
|
||||
SurfaceNode::DataStore(_) | SurfaceNode::ExternalService(_)
|
||||
| SurfaceNode::DangerousLocal(_) => {
|
||||
if !entry_indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if reached.contains(&(i as u32)) {
|
||||
continue;
|
||||
}
|
||||
render_node_line(&mut out, &map.nodes[i], " ");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Orphans: destinations that no entry-point reaches.
|
||||
let mut orphans: Vec<usize> = Vec::new();
|
||||
for (idx, node) in map.nodes.iter().enumerate() {
|
||||
if matches!(node, SurfaceNode::EntryPoint(_)) {
|
||||
continue;
|
||||
}
|
||||
if reached.contains(&(idx as u32)) {
|
||||
continue;
|
||||
}
|
||||
// Already printed under host file when there were no entry-points;
|
||||
// suppress to avoid duplication.
|
||||
let host_has_entries = by_file
|
||||
.get(node.location().file.as_str())
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.any(|&j| matches!(map.nodes[j], SurfaceNode::EntryPoint(_)))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !host_has_entries {
|
||||
continue;
|
||||
}
|
||||
orphans.push(idx);
|
||||
}
|
||||
if !orphans.is_empty() {
|
||||
out.push_str("Unreached surface\n");
|
||||
for idx in orphans {
|
||||
render_node_line(&mut out, &map.nodes[idx], " ");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn render_entry_point(out: &mut String, ep: &EntryPoint, ep_idx: u32, map: &SurfaceMap) {
|
||||
let auth = if ep.auth_required { " [auth]" } else { "" };
|
||||
out.push_str(&format!(
|
||||
" {} {} ({:?}){}\n",
|
||||
method_str(ep.method),
|
||||
ep.route,
|
||||
ep.framework,
|
||||
auth
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" handler: {} at {}:{}\n",
|
||||
ep.handler_name, ep.handler_location.file, ep.handler_location.line
|
||||
));
|
||||
let mut reached: Vec<&SurfaceNode> = map
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.from == ep_idx && matches!(e.kind, EdgeKind::Reaches))
|
||||
.filter_map(|e| map.nodes.get(e.to as usize))
|
||||
.collect();
|
||||
reached.sort_by(|a, b| a.location().cmp(b.location()));
|
||||
if reached.is_empty() {
|
||||
out.push_str(" reaches: (none)\n");
|
||||
return;
|
||||
}
|
||||
out.push_str(" reaches:\n");
|
||||
for node in reached {
|
||||
render_node_line(out, node, " - ");
|
||||
}
|
||||
}
|
||||
|
||||
fn render_node_line(out: &mut String, node: &SurfaceNode, prefix: &str) {
|
||||
match node {
|
||||
SurfaceNode::EntryPoint(ep) => {
|
||||
out.push_str(&format!(
|
||||
"{prefix}entry {} {} ({:?})\n",
|
||||
method_str(ep.method),
|
||||
ep.route,
|
||||
ep.framework
|
||||
));
|
||||
}
|
||||
SurfaceNode::DataStore(ds) => {
|
||||
out.push_str(&format!(
|
||||
"{prefix}data-store ({}): {} [{}:{}]\n",
|
||||
ds_kind_str(ds.kind),
|
||||
ds.label,
|
||||
ds.location.file,
|
||||
ds.location.line
|
||||
));
|
||||
}
|
||||
SurfaceNode::ExternalService(es) => {
|
||||
out.push_str(&format!(
|
||||
"{prefix}external ({}): {} [{}:{}]\n",
|
||||
es_kind_str(es.kind),
|
||||
es.label,
|
||||
es.location.file,
|
||||
es.location.line
|
||||
));
|
||||
}
|
||||
SurfaceNode::DangerousLocal(dl) => {
|
||||
out.push_str(&format!(
|
||||
"{prefix}dangerous: {} (cap=0x{:x}) [{}:{}]\n",
|
||||
dl.function_name, dl.cap_bits, dl.location.file, dl.location.line
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn count_kind<F: Fn(&SurfaceNode) -> bool>(map: &SurfaceMap, f: F) -> usize {
|
||||
map.nodes.iter().filter(|n| f(n)).count()
|
||||
}
|
||||
|
||||
fn method_str(m: crate::entry_points::HttpMethod) -> &'static str {
|
||||
use crate::entry_points::HttpMethod::*;
|
||||
match m {
|
||||
GET => "GET",
|
||||
HEAD => "HEAD",
|
||||
POST => "POST",
|
||||
PUT => "PUT",
|
||||
PATCH => "PATCH",
|
||||
DELETE => "DELETE",
|
||||
OPTIONS => "OPTIONS",
|
||||
}
|
||||
}
|
||||
|
||||
fn ds_kind_str(k: DataStoreKind) -> &'static str {
|
||||
match k {
|
||||
DataStoreKind::Sql => "sql",
|
||||
DataStoreKind::KeyValue => "key_value",
|
||||
DataStoreKind::Document => "document",
|
||||
DataStoreKind::BlobStore => "blob_store",
|
||||
DataStoreKind::Filesystem => "filesystem",
|
||||
DataStoreKind::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn es_kind_str(k: ExternalServiceKind) -> &'static str {
|
||||
match k {
|
||||
ExternalServiceKind::HttpApi => "http_api",
|
||||
ExternalServiceKind::MessageBroker => "message_broker",
|
||||
ExternalServiceKind::SearchIndex => "search_index",
|
||||
ExternalServiceKind::AuthProvider => "auth_provider",
|
||||
ExternalServiceKind::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DOT / SVG rendering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn render_dot(map: &SurfaceMap) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("digraph nyx_surface {\n");
|
||||
out.push_str(" rankdir=LR;\n");
|
||||
out.push_str(" node [fontname=\"Helvetica\", shape=box, style=rounded];\n");
|
||||
for (i, node) in map.nodes.iter().enumerate() {
|
||||
let (label, shape, color) = match node {
|
||||
SurfaceNode::EntryPoint(ep) => (
|
||||
format!(
|
||||
"{} {}\\n{:?}\\n{}",
|
||||
method_str(ep.method),
|
||||
escape_dot(&ep.route),
|
||||
ep.framework,
|
||||
escape_dot(&ep.handler_name),
|
||||
),
|
||||
"box",
|
||||
if ep.auth_required { "#3aa57c" } else { "#3072c4" },
|
||||
),
|
||||
SurfaceNode::DataStore(ds) => (
|
||||
format!("DataStore ({})\\n{}", ds_kind_str(ds.kind), escape_dot(&ds.label)),
|
||||
"cylinder",
|
||||
"#b07a18",
|
||||
),
|
||||
SurfaceNode::ExternalService(es) => (
|
||||
format!(
|
||||
"External ({})\\n{}",
|
||||
es_kind_str(es.kind),
|
||||
escape_dot(&es.label)
|
||||
),
|
||||
"component",
|
||||
"#8b3aa5",
|
||||
),
|
||||
SurfaceNode::DangerousLocal(dl) => (
|
||||
format!(
|
||||
"Dangerous\\n{}\\ncap=0x{:x}",
|
||||
escape_dot(&dl.function_name),
|
||||
dl.cap_bits
|
||||
),
|
||||
"octagon",
|
||||
"#c44141",
|
||||
),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
" n{i} [label=\"{label}\", shape={shape}, color=\"{color}\", fontcolor=\"{color}\"];\n",
|
||||
));
|
||||
}
|
||||
for edge in &map.edges {
|
||||
let style = match edge.kind {
|
||||
EdgeKind::Reaches => "solid",
|
||||
EdgeKind::Calls => "dashed",
|
||||
EdgeKind::ReadsFrom => "solid",
|
||||
EdgeKind::WritesTo => "bold",
|
||||
EdgeKind::TalksTo => "solid",
|
||||
EdgeKind::Triggers => "dotted",
|
||||
EdgeKind::AuthRequiredOn => "dotted",
|
||||
};
|
||||
out.push_str(&format!(
|
||||
" n{} -> n{} [label=\"{:?}\", style={style}];\n",
|
||||
edge.from, edge.to, edge.kind
|
||||
));
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_dot(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
}
|
||||
|
||||
fn render_svg(map: &SurfaceMap) -> NyxResult<Vec<u8>> {
|
||||
let dot = render_dot(map);
|
||||
let mut child = Command::new("dot")
|
||||
.arg("-Tsvg")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
NyxError::Msg(format!(
|
||||
"failed to spawn `dot` for SVG rendering: {e}. Install graphviz, or use `--format dot` and pipe through `dot -Tsvg` yourself."
|
||||
))
|
||||
})?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(dot.as_bytes())
|
||||
.map_err(|e| NyxError::Msg(format!("write DOT to dot stdin: {e}")))?;
|
||||
}
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| NyxError::Msg(format!("waiting on `dot`: {e}")))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
return Err(NyxError::Msg(format!("dot exited non-zero: {stderr}")));
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::entry_points::HttpMethod;
|
||||
use crate::surface::{
|
||||
EntryPoint, Framework, SourceLocation, SurfaceEdge, SurfaceNode,
|
||||
};
|
||||
|
||||
fn flask_fixture_map() -> SurfaceMap {
|
||||
let mut map = SurfaceMap::new();
|
||||
map.nodes.push(SurfaceNode::EntryPoint(EntryPoint {
|
||||
location: SourceLocation::new("app.py", 5, 1),
|
||||
framework: Framework::Flask,
|
||||
method: HttpMethod::GET,
|
||||
route: "/users".into(),
|
||||
handler_name: "list_users".into(),
|
||||
handler_location: SourceLocation::new("app.py", 6, 1),
|
||||
auth_required: false,
|
||||
}));
|
||||
map.canonicalize();
|
||||
map
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_render_shows_entry_point() {
|
||||
let m = flask_fixture_map();
|
||||
let text = render_text(&m, None);
|
||||
assert!(text.contains("GET /users"));
|
||||
assert!(text.contains("handler: list_users"));
|
||||
assert!(text.contains("app.py"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_render_emits_digraph_header() {
|
||||
let m = flask_fixture_map();
|
||||
let dot = render_dot(&m);
|
||||
assert!(dot.starts_with("digraph nyx_surface"));
|
||||
assert!(dot.contains("GET /users"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_escapes_quotes_in_labels() {
|
||||
let mut m = SurfaceMap::new();
|
||||
m.nodes.push(SurfaceNode::EntryPoint(EntryPoint {
|
||||
location: SourceLocation::new("a.py", 1, 1),
|
||||
framework: Framework::Flask,
|
||||
method: HttpMethod::GET,
|
||||
route: r#"/with"quote"#.into(),
|
||||
handler_name: "h".into(),
|
||||
handler_location: SourceLocation::new("a.py", 2, 1),
|
||||
auth_required: false,
|
||||
}));
|
||||
let dot = render_dot(&m);
|
||||
assert!(dot.contains(r#"/with\"quote"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_render_groups_reaches_under_entry() {
|
||||
let mut m = flask_fixture_map();
|
||||
m.nodes
|
||||
.push(SurfaceNode::DangerousLocal(crate::surface::DangerousLocal {
|
||||
location: SourceLocation::new("app.py", 12, 1),
|
||||
function_name: "eval".into(),
|
||||
cap_bits: crate::labels::Cap::CODE_EXEC.bits(),
|
||||
}));
|
||||
// Build edge after canonicalize so indices are stable.
|
||||
m.canonicalize();
|
||||
let ep_idx = m
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|n| matches!(n, SurfaceNode::EntryPoint(_)))
|
||||
.unwrap() as u32;
|
||||
let dl_idx = m
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|n| matches!(n, SurfaceNode::DangerousLocal(_)))
|
||||
.unwrap() as u32;
|
||||
m.edges.push(SurfaceEdge {
|
||||
from: ep_idx,
|
||||
to: dl_idx,
|
||||
kind: EdgeKind::Reaches,
|
||||
});
|
||||
m.canonicalize();
|
||||
let text = render_text(&m, None);
|
||||
assert!(text.contains("reaches:"));
|
||||
assert!(text.contains("dangerous: eval"));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ pub mod health;
|
|||
pub mod overview;
|
||||
pub mod rules;
|
||||
pub mod scans;
|
||||
pub mod surface;
|
||||
pub mod triage;
|
||||
|
||||
use crate::server::app::AppState;
|
||||
|
|
@ -26,5 +27,6 @@ pub fn api_routes() -> Router<AppState> {
|
|||
.merge(triage::routes())
|
||||
.merge(overview::routes())
|
||||
.merge(explorer::routes())
|
||||
.merge(surface::routes())
|
||||
.merge(debug::routes())
|
||||
}
|
||||
|
|
|
|||
43
src/server/routes/surface.rs
Normal file
43
src/server/routes/surface.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! `GET /api/surface` — serve the project's [`SurfaceMap`].
|
||||
//!
|
||||
//! Loads the map persisted by the most recent indexed scan from
|
||||
//! SQLite, falling back to building a fresh entry-point-only map from
|
||||
//! the on-disk source when no scan has populated one yet. The
|
||||
//! response shape is the canonical `SurfaceMap` JSON — identical to
|
||||
//! `nyx surface --format json` — so the frontend can reuse the same
|
||||
//! deserialisation in both surfaces.
|
||||
|
||||
use crate::commands::surface::load_or_build;
|
||||
use crate::server::app::AppState;
|
||||
use crate::server::error::{ApiError, ApiResult};
|
||||
use axum::extract::State;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/surface", get(get_surface))
|
||||
}
|
||||
|
||||
async fn get_surface(State(state): State<AppState>) -> ApiResult<Json<Value>> {
|
||||
let scan_root = state.scan_root.clone();
|
||||
let database_dir = state.database_dir.clone();
|
||||
let cfg = state.config.read().clone();
|
||||
|
||||
// Building the surface map can do filesystem IO + tree-sitter
|
||||
// parsing; keep it off the async runtime.
|
||||
let join_result = tokio::task::spawn_blocking(move || {
|
||||
load_or_build(&scan_root, &database_dir, &cfg)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("surface map task failed: {e}")))?;
|
||||
|
||||
let mut map = join_result
|
||||
.map_err(|e| ApiError::internal(format!("failed to build surface map: {e}")))?;
|
||||
let bytes = map
|
||||
.to_json()
|
||||
.map_err(|e| ApiError::internal(format!("encode surface map: {e}")))?;
|
||||
let value: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| ApiError::internal(format!("re-parse surface map JSON: {e}")))?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
8
tests/dynamic_fixtures/surface/cli_output.golden.txt
Normal file
8
tests/dynamic_fixtures/surface/cli_output.golden.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
Surface map
|
||||
1 entry-points, 0 data stores, 0 external services, 0 dangerous locals
|
||||
|
||||
app.py
|
||||
GET /users (Flask)
|
||||
handler: list_users at app.py:7
|
||||
reaches: (none)
|
||||
|
||||
120
tests/surface_cli.rs
Normal file
120
tests/surface_cli.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! Phase 23 — `nyx surface` subcommand smoke tests.
|
||||
//!
|
||||
//! Builds a [`SurfaceMap`] against the Phase 21 Flask fixture, renders
|
||||
//! it via the three text-mode formatters (text / json / dot) and asserts
|
||||
//! the output matches the recorded golden file and contains the
|
||||
//! expected structural markers.
|
||||
|
||||
use nyx_scanner::callgraph::CallGraph;
|
||||
use nyx_scanner::commands::surface::{load_or_build, render_dot, render_text};
|
||||
use nyx_scanner::summary::GlobalSummaries;
|
||||
use nyx_scanner::surface::{
|
||||
build::{build_surface_map, SurfaceBuildInputs},
|
||||
SurfaceMap,
|
||||
};
|
||||
use nyx_scanner::utils::config::Config;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const FLASK_FIXTURE: &str = "tests/dynamic_fixtures/surface/python_flask";
|
||||
const GOLDEN_PATH: &str = "tests/dynamic_fixtures/surface/cli_output.golden.txt";
|
||||
|
||||
fn empty_call_graph() -> CallGraph {
|
||||
CallGraph {
|
||||
graph: petgraph::graph::DiGraph::new(),
|
||||
index: Default::default(),
|
||||
unresolved_not_found: vec![],
|
||||
unresolved_ambiguous: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, out);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flask_map() -> (SurfaceMap, PathBuf) {
|
||||
let dir = Path::new(FLASK_FIXTURE).to_path_buf();
|
||||
let mut files = Vec::new();
|
||||
walk(&dir, &mut files);
|
||||
let cfg = Config::default();
|
||||
let gs = GlobalSummaries::new();
|
||||
let cg = empty_call_graph();
|
||||
let inputs = SurfaceBuildInputs {
|
||||
files: &files,
|
||||
scan_root: Some(&dir),
|
||||
global_summaries: &gs,
|
||||
call_graph: &cg,
|
||||
config: &cfg,
|
||||
};
|
||||
let map = build_surface_map(&inputs);
|
||||
(map, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_output_matches_golden_for_flask_fixture() {
|
||||
let (map, dir) = flask_map();
|
||||
// The golden file was recorded with no scan root prefix so it
|
||||
// stays valid across machines. Pass `None` so the renderer
|
||||
// produces the same fixed header.
|
||||
let actual = render_text(&map, None);
|
||||
|
||||
// Refresh the golden when running with UPDATE_GOLDEN=1. Useful
|
||||
// when intentionally changing the formatter; mirrors the
|
||||
// convention used elsewhere in the test suite.
|
||||
if std::env::var("UPDATE_GOLDEN").ok().as_deref() == Some("1") {
|
||||
std::fs::write(GOLDEN_PATH, &actual).unwrap();
|
||||
}
|
||||
|
||||
let expected = std::fs::read_to_string(GOLDEN_PATH)
|
||||
.expect("read tests/dynamic_fixtures/surface/cli_output.golden.txt");
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"render_text output drifted from golden; re-run with UPDATE_GOLDEN=1 if intentional.\nfixture: {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_output_contains_entry_and_digraph_header() {
|
||||
let (map, _) = flask_map();
|
||||
let dot = render_dot(&map);
|
||||
assert!(dot.starts_with("digraph nyx_surface"), "{dot}");
|
||||
assert!(dot.contains("GET /users"), "DOT missing entry route: {dot}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_output_round_trips_byte_identical() {
|
||||
let (mut map, _) = flask_map();
|
||||
let bytes = map.to_json().expect("canonical JSON");
|
||||
let mut rt = SurfaceMap::from_json(&bytes).expect("from_json");
|
||||
let rt_bytes = rt.to_json().expect("re-serialise");
|
||||
assert_eq!(bytes, rt_bytes, "canonical JSON must round-trip identically");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_or_build_falls_back_to_filesystem_when_no_db() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let py = tmp.path().join("app.py");
|
||||
std::fs::write(
|
||||
&py,
|
||||
"from flask import Flask\napp = Flask(__name__)\n@app.get('/u')\ndef u(): pass\n",
|
||||
)
|
||||
.unwrap();
|
||||
let db_dir = tempfile::tempdir().unwrap();
|
||||
let cfg = Config::default();
|
||||
let map = load_or_build(tmp.path(), db_dir.path(), &cfg).expect("load_or_build");
|
||||
assert!(
|
||||
map.entry_points().next().is_some(),
|
||||
"expected at least one entry-point in fallback path"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue