mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue