diff --git a/frontend/src/api/queries/surface.ts b/frontend/src/api/queries/surface.ts new file mode 100644 index 00000000..32a19adb --- /dev/null +++ b/frontend/src/api/queries/surface.ts @@ -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('/surface', signal), + staleTime: 30_000, + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7bd7ad4f..ffc627c0 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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[]; +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 7616ca9f..6bfd6700 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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() { } /> } /> } /> + } /> }> = { + 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 = { + 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 ( + + ); +} + +function summarize(map: SurfaceMap): { + entries: number; + stores: number; + externals: number; + dangerous: number; + edgeKinds: Record; +} { + 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 = {}; + 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 ( +

+ Select a node on the left to see its neighbours. +

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

+ (no {direction === 'in' ? 'inbound' : 'outbound'} edges) +

+ ); + } + return ( +
    + {edges.map((e, i) => { + const otherIdx = direction === 'in' ? e.from : e.to; + const other = map.nodes[otherIdx]; + if (!other) return null; + return ( +
  • + + {EDGE_KIND_LABELS[e.kind]} + + + {direction === 'in' ? '←' : '→'} {nodeTitle(other)} + + + {nodeLocation(other)} + +
  • + ); + })} +
+ ); + }; + + return ( +
+

{nodeTitle(node)}

+

+ {nodeSubtitle(node)} — {nodeLocation(node)} +

+

Outbound

+ {renderEdges(outgoing, 'out')} +

Inbound

+ {renderEdges(incoming, 'in')} +
+ ); +} + +type NodeKindFilter = 'all' | SurfaceNode['node']; + +export function SurfacePage() { + usePageTitle('Surface'); + const { data, isLoading, error } = useSurfaceMap(); + const [selected, setSelected] = useState(null); + const [filter, setFilter] = useState('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 ; + if (error) return ; + if (!data || data.nodes.length === 0) { + return ( + + ); + } + + const summary = summarize(data); + + return ( +
+
+

Attack surface

+ + {summary.entries} entry-points · {summary.stores} stores ·{' '} + {summary.externals} services · {summary.dangerous} dangerous locals ·{' '} + {data.edges.length} edges + +
+
+ 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)', + }} + /> + +
+
+
+ {visible.length === 0 ? ( +

No nodes match.

+ ) : ( + visible.map(({ node, index }) => ( + setSelected(index)} + /> + )) + )} +
+ +
+
+ ); +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index ed2a462b..50416713 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index fab3be31..cbcfbd85 100644 --- a/src/cli.rs +++ b/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) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 50fb2f0e..3706b72f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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, diff --git a/src/commands/surface.rs b/src/commands/surface.rs new file mode 100644 index 00000000..6179bbce --- /dev/null +++ b/src/commands/surface.rs @@ -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 { + 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 { + 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> { + 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> = 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 = 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 = 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 = 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 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> { + 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")); + } +} diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs index 3cbde330..7986edad 100644 --- a/src/server/routes/mod.rs +++ b/src/server/routes/mod.rs @@ -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 { .merge(triage::routes()) .merge(overview::routes()) .merge(explorer::routes()) + .merge(surface::routes()) .merge(debug::routes()) } diff --git a/src/server/routes/surface.rs b/src/server/routes/surface.rs new file mode 100644 index 00000000..fd35490f --- /dev/null +++ b/src/server/routes/surface.rs @@ -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 { + Router::new().route("/surface", get(get_surface)) +} + +async fn get_surface(State(state): State) -> ApiResult> { + 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)) +} diff --git a/tests/dynamic_fixtures/surface/cli_output.golden.txt b/tests/dynamic_fixtures/surface/cli_output.golden.txt new file mode 100644 index 00000000..bbdcb329 --- /dev/null +++ b/tests/dynamic_fixtures/surface/cli_output.golden.txt @@ -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) + diff --git a/tests/surface_cli.rs b/tests/surface_cli.rs new file mode 100644 index 00000000..2a609dae --- /dev/null +++ b/tests/surface_cli.rs @@ -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) { + 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" + ); +}