diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d21329..36a0c1f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to Nyx are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). For where Nyx is going, see the [Roadmap](ROADMAP.md). -## [0.8.0] - 2026-06-06 +## [0.8.0] - 2026-06-12 The dynamic-verification release. An attack-surface map, a sandboxed dynamic verifier, a framework adapter registry that grounds both, the per-language build infrastructure that makes per-finding verification affordable at corpus scale, and the first real-corpus acceptance gates. @@ -13,6 +13,12 @@ The attack-surface map and chain composer turn the flat finding list into a rout - **`nyx surface` subcommand.** Prints the project's entry points, datastores, external services, and dangerous local sinks as text, JSON, Graphviz `dot`, or rendered SVG. Loads the persisted `SurfaceMap` from the most recent indexed scan when available, or rebuilds inline from source. `--build` forces a full pass-1 + call-graph walk so DataStore / ExternalService / DangerousLocal nodes populate on an unscanned project. - **Surface page in `nyx serve`.** New `SurfacePage` renders the same graph in the browser UI, with ELK layout, sidebar navigation, and a wide-canvas SVG viewer. Persists alongside the index so the frontend reloads without a rescan. - **Chain findings.** `ChainFinding` records connect a route entry point to a downstream sink via the call graph + surface map. The composer scores `(impact × evidence)` per chain, queues the top-N for composite reverification, and wires the result into `findings.json` / SARIF / the dashboard. Chains rank above isolated findings. +- **Per-finding exposure.** Every finding now carries the worst-case externally reachable route that drives it, surfaced as an `Exposure` record (route, method, framework, auth state, and whether the reach is direct or transitive through the call graph). Unauthenticated routes win over auth-gated ones and direct file matches win over transitive ones. The annotation shows up as an `Exposure:` evidence line in console output and on `findings.json`, SARIF `properties.exposure`, and the server finding view. Ranking adds a bonus for it, so a finding reachable from an unauthenticated route sorts above an otherwise-equal internal one. +- **Entry-point risk scoring.** `nyx surface` opens with a risk-sorted "Top risk entry-points" banner and tags each route with a `low` / `medium` / `high` / `critical` tier. The score is explainable: the worst reachable sink class dominates, writing a store outranks reading it, talking to an external service and mutating HTTP methods add, and missing auth multiplies the whole exposure. The `/api/surface` response carries the same `entry_risks` array so the browser UI renders the ranking without re-deriving it. +- **Function-level reachability with typed edges.** Reachability now matches a destination to an entry point when the owning function is on the call-graph frontier, not merely when they share a file, so two unrelated handlers in one file no longer both claim a co-located `eval()`. Edges are typed by destination: `ReadsFrom` / `WritesTo` for a datastore (split by the access direction inferred from the call verb, so a route that writes SQL reads differently from one that only queries), `TalksTo` for an external service, `Reaches` for a dangerous local sink. The pass falls back to the same-file heuristic only when the handler seed cannot be resolved. +- **Richer surface nodes.** `DangerousLocal` carries a decoded sink-class label (`code-exec`, `deserialize`, `ssti`, ...) and a real sink span instead of a raw cap bitfield at line 0. `DataStore` and `ExternalService` carry the qualified name of the owning function. The dangerous-local sink set widened from four classes to ten, adding LDAP injection, XPath injection, header injection, open redirect, XXE, and prototype pollution, and datastore / external detection gained cap-driven fallbacks (`SQL_QUERY` / `FILE_IO` and `SSRF` / `DATA_EXFIL`) so a custom DAO wrapper or proxy helper still surfaces when no named driver matched. +- **Entry-point and auth recall.** Handlers the framework probes miss but pass-1 already tagged as entry points are synthesised into the surface map so the entry set is a superset of what the taint engine treats as adversary-driven. `auth_required` upgrades when a handler's own body calls a known auth guard, complementing the router-level decorator / annotation / middleware detection. +- **Coverage telemetry.** A fresh `nyx surface` build prints a coverage line (files seen, files in a supported language, files parsed, files with routes, plus unparsed / unreadable counts) so a small map can be told apart from "the probes did not understand this project". A loaded persisted map reports node and edge counts and points at `--build` for a source rebuild. ### Framework adapter registry diff --git a/src/surface/exposure.rs b/src/surface/exposure.rs new file mode 100644 index 00000000..a3031834 --- /dev/null +++ b/src/surface/exposure.rs @@ -0,0 +1,334 @@ +//! Finding-level exposure: which surface entry-point can drive a given +//! source location, and is it auth-gated? +//! +//! This is the bridge that makes the attack surface participate in the +//! core finding pipeline instead of living off to the side in `nyx +//! surface`: every [`Diag`](crate::commands::scan::Diag) gets an +//! optional [`Exposure`] annotation describing the *worst-case* route +//! that reaches it (unauthenticated preferred over auth-gated, direct +//! file match preferred over transitive call-graph reach), and the +//! ranking layer turns that into a score component so externally +//! reachable findings sort above internal ones. +//! +//! Matching granularity is file-level, same as the chain composer's +//! [`Reach`](crate::chain::Reach): a finding in `views.py` is exposed +//! when an entry-point's handler lives in `views.py`, or — when a +//! [`FileReachMap`] is supplied — when some handler's file transitively +//! reaches `views.py` through the call graph. + +use super::{EntryPoint, Framework, SurfaceMap, SurfaceNode}; +use crate::callgraph::FileReachMap; +use crate::commands::scan::Diag; +use crate::entry_points::HttpMethod; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Worst-case route exposure for one finding. Serialised into the +/// finding JSON / SARIF properties so downstream consumers (CI gates, +/// the web UI) can filter on "externally reachable" without re-running +/// the surface build. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Exposure { + pub route: String, + pub method: HttpMethod, + pub framework: Framework, + /// True when the matched entry-point is behind an auth guard the + /// surface layer recognised. An unauthenticated match is always + /// preferred when both kinds reach the finding. + pub auth_required: bool, + /// Entry-point declaration site. + pub entry_file: String, + pub entry_line: u32, + /// `false` when the finding sits in the handler's own file, + /// `true` when it is only reached through the call graph. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub transitive: bool, +} + +impl Exposure { + /// One-line human-readable form, used as a console evidence label: + /// `"GET /search (unauthenticated)"`, + /// `"POST /admin/import (auth-gated, via call graph)"`. + pub fn display(&self) -> String { + let auth = if self.auth_required { + "auth-gated" + } else { + "unauthenticated" + }; + let via = if self.transitive { + ", via call graph" + } else { + "" + }; + format!("{:?} {} ({auth}{via})", self.method, self.route) + } +} + +/// Snapshot of one entry-point, decoupled from the map's lifetime. +struct EntryRef { + handler_file: String, + route: String, + method: HttpMethod, + framework: Framework, + auth_required: bool, + entry_file: String, + entry_line: u32, +} + +impl EntryRef { + fn exposure(&self, transitive: bool) -> Exposure { + Exposure { + route: self.route.clone(), + method: self.method, + framework: self.framework, + auth_required: self.auth_required, + entry_file: self.entry_file.clone(), + entry_line: self.entry_line, + transitive, + } + } +} + +/// Pre-indexed surface entry-points plus an optional call-graph file +/// reach map. Build once per scan, query per finding. +pub struct ExposureIndex<'r> { + entries: Vec, + reach: Option<&'r FileReachMap>, +} + +impl<'r> ExposureIndex<'r> { + pub fn build(map: &SurfaceMap, reach: Option<&'r FileReachMap>) -> Self { + let entries = map + .nodes + .iter() + .filter_map(|n| match n { + SurfaceNode::EntryPoint(ep) => Some(entry_ref(ep)), + _ => None, + }) + .collect(); + Self { entries, reach } + } + + /// True when the surface has no entry-points at all — exposure + /// annotation would mark everything unreachable, which is noise + /// rather than signal (probes may simply not cover the project's + /// framework), so callers skip annotation entirely. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Worst-case exposure for a finding in `file`. Preference order: + /// 1. unauthenticated over auth-gated, + /// 2. direct (same file as the handler) over transitive, + /// 3. first in surface-canonical order (deterministic). + /// + /// Returns `None` when no entry-point reaches the file. + pub fn exposure_for_file(&self, file: &str) -> Option { + let mut best: Option<(u8, &EntryRef, bool)> = None; + for e in &self.entries { + let transitive = if e.handler_file == file { + false + } else if self.reach.is_some_and(|r| r.reaches(&e.handler_file, file)) { + true + } else { + continue; + }; + // Lower rank wins; canonical order breaks ties via `<`. + let rank = (e.auth_required as u8) << 1 | (transitive as u8); + if best.as_ref().is_none_or(|(r, _, _)| rank < *r) { + let done = rank == 0; + best = Some((rank, e, transitive)); + if done { + break; + } + } + } + best.map(|(_, e, transitive)| e.exposure(transitive)) + } +} + +fn entry_ref(ep: &EntryPoint) -> EntryRef { + EntryRef { + handler_file: ep.handler_location.file.clone(), + route: ep.route.clone(), + method: ep.method, + framework: ep.framework, + auth_required: ep.auth_required, + entry_file: ep.location.file.clone(), + entry_line: ep.location.line, + } +} + +/// Annotate `diags` in place with their worst-case [`Exposure`]. +/// +/// Skips entirely when the surface has no entry-points (see +/// [`ExposureIndex::is_empty`]). For each annotated finding a console +/// evidence label (`Exposure: GET /x (unauthenticated)`) is appended so +/// the text renderer shows the route without any renderer change. +/// Idempotent per scan: callers invoke it once, before ranking. +/// +/// `scan_root` relativises `Diag::path` (absolute on most scan paths) +/// to the project-relative POSIX convention the surface map uses; +/// without it the direct same-file match never fires and every +/// exposure degrades to (or misses) the transitive path. +pub fn annotate_exposure( + diags: &mut [Diag], + map: &SurfaceMap, + reach: Option<&FileReachMap>, + scan_root: Option<&std::path::Path>, +) { + let index = ExposureIndex::build(map, reach); + if index.is_empty() { + return; + } + // Findings cluster heavily by file; memoise per-file lookups. + let mut cache: HashMap> = HashMap::new(); + for d in diags.iter_mut() { + let rel = crate::surface::relative_path_string(std::path::Path::new(&d.path), scan_root); + let exp = cache + .entry(rel) + .or_insert_with_key(|k| index.exposure_for_file(k)) + .clone(); + if let Some(exp) = exp { + d.labels.push(("Exposure".to_string(), exp.display())); + d.exposure = Some(exp); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::surface::SourceLocation; + + fn ep(file: &str, route: &str, auth: bool) -> SurfaceNode { + SurfaceNode::EntryPoint(EntryPoint { + location: SourceLocation::new(file, 1, 1), + framework: Framework::Flask, + method: HttpMethod::GET, + route: route.into(), + handler_name: "h".into(), + handler_location: SourceLocation::new(file, 2, 1), + auth_required: auth, + }) + } + + #[test] + fn direct_match_yields_exposure() { + let mut map = SurfaceMap::new(); + map.nodes.push(ep("app.py", "/a", false)); + let idx = ExposureIndex::build(&map, None); + let exp = idx.exposure_for_file("app.py").expect("exposed"); + assert_eq!(exp.route, "/a"); + assert!(!exp.transitive); + assert!(!exp.auth_required); + assert_eq!(idx.exposure_for_file("other.py"), None); + } + + #[test] + fn unauthenticated_entry_preferred_over_auth_gated() { + let mut map = SurfaceMap::new(); + map.nodes.push(ep("app.py", "/locked", true)); + map.nodes.push(ep("app.py", "/open", false)); + let idx = ExposureIndex::build(&map, None); + let exp = idx.exposure_for_file("app.py").unwrap(); + assert_eq!(exp.route, "/open"); + assert!(!exp.auth_required); + } + + #[test] + fn transitive_reach_via_call_graph() { + use crate::callgraph::build_call_graph; + use crate::summary::{FuncSummary, merge_summaries}; + // routes.py::handle -> helper.py::sink + let handle = FuncSummary { + name: "handle".into(), + file_path: "routes.py".into(), + lang: "python".into(), + callees: vec![crate::summary::CalleeSite::bare("sink")], + ..Default::default() + }; + let sink = FuncSummary { + name: "sink".into(), + file_path: "helper.py".into(), + lang: "python".into(), + ..Default::default() + }; + let gs = merge_summaries(vec![handle, sink], None); + let cg = build_call_graph(&gs, &[]); + let reach = FileReachMap::build(&cg); + + let mut map = SurfaceMap::new(); + map.nodes.push(ep("routes.py", "/r", false)); + let idx = ExposureIndex::build(&map, Some(&reach)); + let exp = idx.exposure_for_file("helper.py").expect("transitive"); + assert!(exp.transitive); + assert_eq!(exp.route, "/r"); + // Direct match still preferred for the handler's own file. + assert!(!idx.exposure_for_file("routes.py").unwrap().transitive); + } + + #[test] + fn unauth_transitive_beats_auth_direct() { + use crate::callgraph::build_call_graph; + use crate::summary::{FuncSummary, merge_summaries}; + let handle = FuncSummary { + name: "open_handle".into(), + file_path: "open.py".into(), + lang: "python".into(), + callees: vec![crate::summary::CalleeSite::bare("shared")], + ..Default::default() + }; + let shared = FuncSummary { + name: "shared".into(), + file_path: "shared.py".into(), + lang: "python".into(), + ..Default::default() + }; + let gs = merge_summaries(vec![handle, shared], None); + let cg = build_call_graph(&gs, &[]); + let reach = FileReachMap::build(&cg); + + let mut map = SurfaceMap::new(); + map.nodes.push(ep("shared.py", "/locked", true)); // direct, auth + map.nodes.push(ep("open.py", "/open", false)); // transitive, unauth + let idx = ExposureIndex::build(&map, Some(&reach)); + let exp = idx.exposure_for_file("shared.py").unwrap(); + assert_eq!(exp.route, "/open", "unauth transitive should win"); + assert!(exp.transitive); + } + + #[test] + fn annotate_sets_field_and_label() { + let mut map = SurfaceMap::new(); + map.nodes.push(ep("app.py", "/a", false)); + let mut diags = vec![crate::commands::scan::Diag { + path: "app.py".into(), + line: 9, + col: 1, + id: "x".into(), + ..Default::default() + }]; + annotate_exposure(&mut diags, &map, None, None); + let exp = diags[0].exposure.as_ref().expect("annotated"); + assert_eq!(exp.route, "/a"); + assert!( + diags[0] + .labels + .iter() + .any(|(k, v)| k == "Exposure" && v.contains("/a")) + ); + } + + #[test] + fn empty_surface_skips_annotation() { + let map = SurfaceMap::new(); + let mut diags = vec![crate::commands::scan::Diag { + path: "app.py".into(), + ..Default::default() + }]; + annotate_exposure(&mut diags, &map, None, None); + assert!(diags[0].exposure.is_none()); + assert!(diags[0].labels.is_empty()); + } +} diff --git a/src/surface/risk.rs b/src/surface/risk.rs new file mode 100644 index 00000000..82306c5e --- /dev/null +++ b/src/surface/risk.rs @@ -0,0 +1,372 @@ +//! Per-entry-point risk assessment over the [`SurfaceMap`]. +//! +//! Computed on demand from the canonicalised node + edge lists, never +//! persisted: the same map always yields the same risks, and keeping +//! the scoring out of the schema means a tuning change does not need a +//! SQLite migration. Consumed by the `nyx surface` CLI (risk-sorted +//! tree + "top risks" banner) and available to the HTTP API. +//! +//! The model is deliberately simple and explainable: each entry point +//! accumulates points from the sink classes it can reach (worst class +//! dominates, additional classes contribute a small spread bonus), the +//! stores it can write, and the services it talks to; missing auth +//! multiplies the whole thing. Every contribution is recorded as a +//! human-readable factor so the CLI can print *why* a route is rated +//! `critical` instead of an opaque number. + +use super::{DataStoreKind, EdgeKind, EntryPoint, SurfaceMap, SurfaceNode, cap_labels}; +use crate::labels::Cap; +use serde::{Deserialize, Serialize}; + +/// Coarse risk tier derived from the numeric score. Thresholds are +/// documented on [`RiskTier::from_score`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RiskTier { + Low, + Medium, + High, + Critical, +} + +impl RiskTier { + /// Tier thresholds. Calibrated so that: + /// * an unauthenticated route reaching a code-exec sink is + /// `Critical` (40 × 1.5 + 5 ≥ 60); + /// * the same route behind auth is `High` (40 ≥ 35); + /// * an unauthenticated route writing a SQL store is `High` + /// ((15 + 5) × 1.5 + 5 = 35 ≥ 35); + /// * a route that only reads a store or talks to one service is + /// `Medium`; + /// * a route with no reachable destination at all is `Low`. + pub fn from_score(score: f64) -> Self { + if score >= 60.0 { + RiskTier::Critical + } else if score >= 35.0 { + RiskTier::High + } else if score >= 12.0 { + RiskTier::Medium + } else { + RiskTier::Low + } + } + + /// Lowercase display tag (`critical` / `high` / `medium` / `low`). + pub fn tag(self) -> &'static str { + match self { + RiskTier::Critical => "critical", + RiskTier::High => "high", + RiskTier::Medium => "medium", + RiskTier::Low => "low", + } + } +} + +/// Risk assessment for one entry point. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EntryRisk { + /// Index of the [`SurfaceNode::EntryPoint`] in the canonicalised + /// `SurfaceMap::nodes` vector. + pub entry_idx: usize, + pub score: f64, + pub tier: RiskTier, + /// Human-readable contributions, worst first (e.g. + /// `["unauthenticated", "reaches code-exec sink", "writes sql store"]`). + pub factors: Vec, +} + +/// Points for the worst dangerous-local sink class reachable from an +/// entry. Cap order mirrors exploit impact: full code execution +/// dominates, then deserialisation (usually RCE-equivalent), SSTI, +/// the injection family, format strings. +fn dangerous_points(bits: u32) -> f64 { + let caps = Cap::from_bits_truncate(bits); + if caps.contains(Cap::CODE_EXEC) { + 40.0 + } else if caps.contains(Cap::DESERIALIZE) { + 35.0 + } else if caps.contains(Cap::SSTI) { + 30.0 + } else if caps.intersects(Cap::XXE | Cap::LDAP_INJECTION | Cap::XPATH_INJECTION) { + 22.0 + } else if caps.intersects(Cap::PROTOTYPE_POLLUTION | Cap::HEADER_INJECTION) { + 18.0 + } else if caps.contains(Cap::FMT_STRING) { + 15.0 + } else if caps.contains(Cap::OPEN_REDIRECT) { + 10.0 + } else { + 8.0 + } +} + +/// Assess every entry point in `map`. Returns one [`EntryRisk`] per +/// entry-point node, sorted by score descending (ties broken by node +/// index so the output is deterministic). +pub fn assess_entry_risks(map: &SurfaceMap) -> Vec { + let mut out: Vec = Vec::new(); + for (idx, node) in map.nodes.iter().enumerate() { + let SurfaceNode::EntryPoint(ep) = node else { + continue; + }; + out.push(assess_one(map, idx, ep)); + } + out.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.entry_idx.cmp(&b.entry_idx)) + }); + out +} + +fn assess_one(map: &SurfaceMap, entry_idx: usize, ep: &EntryPoint) -> EntryRisk { + let mut factors: Vec = Vec::new(); + let mut score = 0.0_f64; + + // Worst reachable dangerous-local class dominates; each *additional* + // dangerous destination adds a small spread bonus so a route that + // reaches eval *and* pickle.loads outranks one that only reaches eval. + let mut worst_dangerous: Option<(f64, u32)> = None; + let mut extra_dangerous = 0usize; + let mut writes_store: Option = None; + let mut reads_store: Option = None; + let mut talks_external = 0usize; + + for edge in &map.edges { + if edge.from != entry_idx as u32 || !edge.kind.is_reach_like() { + continue; + } + match map.nodes.get(edge.to as usize) { + Some(SurfaceNode::DangerousLocal(dl)) => { + let pts = dangerous_points(dl.cap_bits); + match &mut worst_dangerous { + Some((best, best_bits)) => { + extra_dangerous += 1; + if pts > *best { + *best = pts; + *best_bits = dl.cap_bits; + } + } + None => worst_dangerous = Some((pts, dl.cap_bits)), + } + } + Some(SurfaceNode::DataStore(ds)) => { + if matches!(edge.kind, EdgeKind::WritesTo) { + // Keep the most severe store kind: SQL > filesystem > rest. + writes_store = Some(worse_store(writes_store, ds.kind)); + } else { + reads_store = Some(worse_store(reads_store, ds.kind)); + } + } + Some(SurfaceNode::ExternalService(_)) => talks_external += 1, + _ => {} + } + } + + if let Some((pts, bits)) = worst_dangerous { + score += pts; + factors.push(format!( + "reaches {} sink", + cap_labels(bits).first().copied().unwrap_or("dangerous") + )); + if extra_dangerous > 0 { + let spread = (extra_dangerous as f64 * 2.0).min(10.0); + score += spread; + factors.push(format!("{extra_dangerous} more dangerous sink(s)")); + } + } + if let Some(kind) = writes_store { + let pts = store_points(kind) + 5.0; + score += pts; + factors.push(format!("writes {} store", store_tag(kind))); + } else if let Some(kind) = reads_store { + let pts = store_points(kind); + score += pts; + factors.push(format!("reads {} store", store_tag(kind))); + } + if talks_external > 0 { + score += 8.0; + factors.push(format!("talks to {talks_external} external service(s)")); + } + if mutating_method(ep) { + score += 3.0; + factors.push(format!("mutating method ({:?})", ep.method)); + } + + // Auth multiplier last: missing auth scales the whole exposure, it + // does not merely add a constant. An unauthenticated route with + // nothing reachable lands at 5 and stays Low. + if ep.auth_required { + factors.push("auth-gated".into()); + } else { + score = score * 1.5 + 5.0; + factors.insert(0, "unauthenticated".into()); + } + + EntryRisk { + entry_idx, + score, + tier: RiskTier::from_score(score), + factors, + } +} + +fn store_points(kind: DataStoreKind) -> f64 { + match kind { + DataStoreKind::Sql => 15.0, + DataStoreKind::Filesystem => 12.0, + DataStoreKind::Document | DataStoreKind::KeyValue | DataStoreKind::BlobStore => 10.0, + DataStoreKind::Unknown => 8.0, + } +} + +fn store_tag(kind: DataStoreKind) -> &'static str { + match kind { + DataStoreKind::Sql => "sql", + DataStoreKind::Filesystem => "filesystem", + DataStoreKind::Document => "document", + DataStoreKind::KeyValue => "key-value", + DataStoreKind::BlobStore => "blob", + DataStoreKind::Unknown => "unknown", + } +} + +/// Keep the more severe of two store kinds (SQL > filesystem > rest). +fn worse_store(current: Option, new: DataStoreKind) -> DataStoreKind { + match current { + None => new, + Some(cur) => { + if store_points(new) > store_points(cur) { + new + } else { + cur + } + } + } +} + +fn mutating_method(ep: &EntryPoint) -> bool { + use crate::entry_points::HttpMethod; + matches!( + ep.method, + HttpMethod::POST | HttpMethod::PUT | HttpMethod::PATCH | HttpMethod::DELETE + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::entry_points::HttpMethod; + use crate::surface::{ + DangerousLocal, DataStore, EntryPoint, Framework, SourceLocation, SurfaceEdge, + }; + + fn ep(auth: bool, method: HttpMethod) -> SurfaceNode { + SurfaceNode::EntryPoint(EntryPoint { + location: SourceLocation::new("app.py", 1, 1), + framework: Framework::Flask, + method, + route: "/x".into(), + handler_name: "h".into(), + handler_location: SourceLocation::new("app.py", 2, 1), + auth_required: auth, + }) + } + + fn dangerous(cap: Cap) -> SurfaceNode { + SurfaceNode::DangerousLocal(DangerousLocal { + location: SourceLocation::new("app.py", 9, 1), + function_name: "danger".into(), + cap_bits: cap.bits(), + label: String::new(), + }) + } + + #[test] + fn unauth_code_exec_is_critical() { + let mut map = SurfaceMap::new(); + map.nodes.push(ep(false, HttpMethod::GET)); + map.nodes.push(dangerous(Cap::CODE_EXEC)); + map.edges.push(SurfaceEdge { + from: 0, + to: 1, + kind: EdgeKind::Reaches, + }); + let risks = assess_entry_risks(&map); + assert_eq!(risks.len(), 1); + assert_eq!(risks[0].tier, RiskTier::Critical); + assert!(risks[0].factors.iter().any(|f| f == "unauthenticated")); + assert!( + risks[0].factors.iter().any(|f| f.contains("code-exec")), + "factors: {:?}", + risks[0].factors + ); + } + + #[test] + fn auth_gating_downgrades_tier() { + let mut map = SurfaceMap::new(); + map.nodes.push(ep(true, HttpMethod::GET)); + map.nodes.push(dangerous(Cap::CODE_EXEC)); + map.edges.push(SurfaceEdge { + from: 0, + to: 1, + kind: EdgeKind::Reaches, + }); + let risks = assess_entry_risks(&map); + assert_eq!(risks[0].tier, RiskTier::High); + assert!(risks[0].factors.iter().any(|f| f == "auth-gated")); + } + + #[test] + fn unreached_entry_is_low() { + let mut map = SurfaceMap::new(); + map.nodes.push(ep(false, HttpMethod::GET)); + let risks = assess_entry_risks(&map); + assert_eq!(risks[0].tier, RiskTier::Low); + } + + #[test] + fn sql_write_outranks_sql_read() { + let store = |access| { + SurfaceNode::DataStore(DataStore { + location: SourceLocation::new("app.py", 5, 1), + kind: DataStoreKind::Sql, + label: "pg".into(), + owner: "h".into(), + access, + }) + }; + let build = |kind: EdgeKind, access| { + let mut map = SurfaceMap::new(); + map.nodes.push(ep(false, HttpMethod::GET)); + map.nodes.push(store(access)); + map.edges.push(SurfaceEdge { + from: 0, + to: 1, + kind, + }); + assess_entry_risks(&map)[0].score + }; + let write = build(EdgeKind::WritesTo, crate::surface::AccessMode::Write); + let read = build(EdgeKind::ReadsFrom, crate::surface::AccessMode::Read); + assert!(write > read, "write {write} should outrank read {read}"); + } + + #[test] + fn risks_sorted_descending() { + let mut map = SurfaceMap::new(); + map.nodes.push(ep(true, HttpMethod::GET)); // 0: low + map.nodes.push(ep(false, HttpMethod::POST)); // 1: reaches sink + map.nodes.push(dangerous(Cap::CODE_EXEC)); // 2 + map.edges.push(SurfaceEdge { + from: 1, + to: 2, + kind: EdgeKind::Reaches, + }); + let risks = assess_entry_risks(&map); + assert_eq!(risks[0].entry_idx, 1); + assert!(risks[0].score > risks[1].score); + } +} diff --git a/tests/dynamic_layering.rs b/tests/dynamic_layering.rs index 33453d28..6ef8a542 100644 --- a/tests/dynamic_layering.rs +++ b/tests/dynamic_layering.rs @@ -18,6 +18,7 @@ //! | `src/server/` (any file) | server start_scan verify wiring | //! | `src/rank.rs` | dynamic-verdict rank scoring | //! | `src/chain/reverify.rs` | composite chain re-verification | +//! | `src/commands/repro.rs` | `nyx repro` subcommand; `#[cfg(feature="dynamic")]`| use std::fs; use std::path::{Path, PathBuf}; @@ -34,6 +35,10 @@ const ALLOWED: &[&str] = &[ // Composite chain re-verification is the public bridge between the chain // composer and the dynamic verifier. "chain/reverify.rs", + // The `nyx repro` subcommand replays dynamic verification bundles. The + // whole module is `#[cfg(feature = "dynamic")]` in commands/mod.rs, so it + // never compiles into the feature-agnostic static path. + "commands/repro.rs", // The dynamic module itself is obviously allowed. "dynamic/", ];