docs: clarify per-finding exposure logic

This commit is contained in:
elipeter 2026-06-10 13:21:56 -05:00
parent 792db2b86f
commit 3cc5eef877
4 changed files with 21 additions and 10 deletions

View file

@ -13,9 +13,9 @@ 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.
- **Per-finding exposure.** A finding reachable from an externally-facing route now carries the worst-case 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). Findings with no reaching entry point, and all findings when the project has no detected entry points, carry no record, so an absent `Exposure` means "not connected", not "safe". 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.
- **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 when the handler seed cannot be resolved in the call graph, or when a destination loaded from an older persisted map predates the owning-function field.
- **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.

View file

@ -3,9 +3,12 @@
//! 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.
//! response is the canonical `SurfaceMap` JSON (the same `nodes` +
//! `edges` shape `nyx surface --format json` emits, so the frontend
//! reuses the same `SurfaceMap` deserialiser) plus one extra
//! top-level `entry_risks` array — the per-entry-point risk
//! assessment the CLI prints as a banner rather than serialising.
//! Consumers that only need the map ignore the extra key.
use crate::commands::surface::load_or_build;
use crate::server::app::AppState;

View file

@ -87,9 +87,13 @@ pub enum Framework {
/// Every node carries the route's declared path string, HTTP method,
/// and a resolved handler [`SourceLocation`] pointing at the function
/// definition. `auth_required` is `true` when the decorator stack
/// (or framework equivalent) contains an auth guard the probe was
/// able to identify; Phase 21 recognises Flask's `@login_required`,
/// `@auth_required`, and `@jwt_required` decorators.
/// (or framework equivalent — annotation, middleware argument, or a
/// body-level guard call) contains an auth marker the probe was able
/// to identify. The marker set is the per-framework registry in
/// [`crate::auth_analysis::auth_markers`] (e.g. Flask's
/// `@login_required` / `@auth_required` / `@jwt_required` /
/// `@token_required` / `@requires_auth` / `@authenticated` /
/// `@require_login`), not a fixed three-decorator list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntryPoint {
pub location: SourceLocation,

View file

@ -36,8 +36,12 @@ impl RiskTier {
/// * 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`;
/// * an unauthenticated route that only reads a SQL store
/// (15 × 1.5 + 5 = 27) or talks to one external service
/// (8 × 1.5 + 5 = 17) is `Medium`;
/// * the same single read / single egress *behind auth* (no ×1.5
/// scaling) usually stays `Low` — an auth-gated KV/document read
/// (10) or one external call (8) is below the 12 threshold;
/// * a route with no reachable destination at all is `Low`.
pub fn from_score(score: f64) -> Self {
if score >= 60.0 {