mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
feat(surface): make attack surface first-class in the finding pipeline
This commit is contained in:
parent
1abcdedbfe
commit
792db2b86f
4 changed files with 718 additions and 1 deletions
|
|
@ -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
334
src/surface/exposure.rs
Normal 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
372
src/surface/risk.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/",
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue