feat(surface): make attack surface first-class in the finding pipeline

This commit is contained in:
elipeter 2026-06-10 13:01:53 -05:00
parent 1abcdedbfe
commit 792db2b86f
4 changed files with 718 additions and 1 deletions

View file

@ -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

334
src/surface/exposure.rs Normal file
View file

@ -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<EntryRef>,
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<Exposure> {
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<String, Option<Exposure>> = 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());
}
}

372
src/surface/risk.rs Normal file
View file

@ -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<String>,
}
/// 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<EntryRisk> {
let mut out: Vec<EntryRisk> = 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<String> = 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<DataStoreKind> = None;
let mut reads_store: Option<DataStoreKind> = 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<DataStoreKind>, 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);
}
}

View file

@ -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/",
];