mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
Merge pull request #92 from nyx-sec/attack-surface-overhaul
Attack surface overhaul
This commit is contained in:
commit
59e4359257
53 changed files with 2356 additions and 249 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.** 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 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.
|
||||
|
||||
### Framework adapter registry
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
|
||||
<h2>Overview of licenses:</h2>
|
||||
<ul class="licenses-overview">
|
||||
<li><a href="#Apache-2.0">Apache License 2.0</a> (160)</li>
|
||||
<li><a href="#Apache-2.0">Apache License 2.0</a> (161)</li>
|
||||
<li><a href="#MIT">MIT License</a> (71)</li>
|
||||
<li><a href="#Zlib">zlib License</a> (2)</li>
|
||||
<li><a href="#BSD-2-Clause">BSD 2-Clause "Simplified" License</a> (1)</li>
|
||||
|
|
@ -905,8 +905,8 @@
|
|||
<h3 id="Apache-2.0">Apache License 2.0</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/google/zerocopy ">zerocopy-derive 0.8.48</a></li>
|
||||
<li><a href=" https://github.com/google/zerocopy ">zerocopy 0.8.48</a></li>
|
||||
<li><a href=" https://github.com/google/zerocopy ">zerocopy-derive 0.8.50</a></li>
|
||||
<li><a href=" https://github.com/google/zerocopy ">zerocopy 0.8.50</a></li>
|
||||
</ul>
|
||||
<pre class="license-text"> Apache License
|
||||
Version 2.0, January 2004
|
||||
|
|
@ -1988,7 +1988,7 @@ limitations under the License.
|
|||
<h3 id="Apache-2.0">Apache License 2.0</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/hyperium/http ">http 1.4.0</a></li>
|
||||
<li><a href=" https://github.com/hyperium/http ">http 1.4.1</a></li>
|
||||
</ul>
|
||||
<pre class="license-text"> Apache License
|
||||
Version 2.0, January 2004
|
||||
|
|
@ -2617,11 +2617,11 @@ limitations under the License.</pre>
|
|||
<li><a href=" https://github.com/bluss/arrayvec ">arrayvec 0.7.6</a></li>
|
||||
<li><a href=" https://github.com/Nullus157/async-compression ">async-compression 0.4.42</a></li>
|
||||
<li><a href=" https://github.com/smol-rs/atomic-waker ">atomic-waker 1.1.2</a></li>
|
||||
<li><a href=" https://github.com/cuviper/autocfg ">autocfg 1.5.0</a></li>
|
||||
<li><a href=" https://github.com/bitflags/bitflags ">bitflags 2.11.1</a></li>
|
||||
<li><a href=" https://github.com/cuviper/autocfg ">autocfg 1.5.1</a></li>
|
||||
<li><a href=" https://github.com/bitflags/bitflags ">bitflags 2.12.1</a></li>
|
||||
<li><a href=" https://github.com/BurntSushi/bstr ">bstr 1.12.1</a></li>
|
||||
<li><a href=" https://github.com/japaric/cast.rs ">cast 0.3.0</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/cc-rs ">cc 1.2.62</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/cc-rs ">cc 1.2.63</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/cfg-if ">cfg-if 1.0.4</a></li>
|
||||
<li><a href=" https://github.com/Nullus157/async-compression ">compression-codecs 0.4.38</a></li>
|
||||
<li><a href=" https://github.com/Nullus157/async-compression ">compression-core 0.4.32</a></li>
|
||||
|
|
@ -2632,7 +2632,7 @@ limitations under the License.</pre>
|
|||
<li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-deque 0.8.6</a></li>
|
||||
<li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-epoch 0.9.18</a></li>
|
||||
<li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-utils 0.8.21</a></li>
|
||||
<li><a href=" https://github.com/rayon-rs/either ">either 1.15.0</a></li>
|
||||
<li><a href=" https://github.com/rayon-rs/either ">either 1.16.0</a></li>
|
||||
<li><a href=" https://github.com/indexmap-rs/equivalent ">equivalent 1.0.2</a></li>
|
||||
<li><a href=" https://github.com/lambda-fairy/rust-errno ">errno 0.3.14</a></li>
|
||||
<li><a href=" https://github.com/smol-rs/fastrand ">fastrand 2.4.1</a></li>
|
||||
|
|
@ -2649,10 +2649,11 @@ limitations under the License.</pre>
|
|||
<li><a href=" https://github.com/seanmonstar/httparse ">httparse 1.10.1</a></li>
|
||||
<li><a href=" https://github.com/indexmap-rs/indexmap ">indexmap 2.14.0</a></li>
|
||||
<li><a href=" https://github.com/rust-itertools/itertools ">itertools 0.13.0</a></li>
|
||||
<li><a href=" https://github.com/rust-itertools/itertools ">itertools 0.14.0</a></li>
|
||||
<li><a href=" https://github.com/rust-lang-nursery/lazy-static.rs ">lazy_static 1.5.0</a></li>
|
||||
<li><a href=" https://github.com/sunfishcode/linux-raw-sys ">linux-raw-sys 0.12.1</a></li>
|
||||
<li><a href=" https://github.com/Amanieu/parking_lot ">lock_api 0.4.14</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/log ">log 0.4.29</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/log ">log 0.4.32</a></li>
|
||||
<li><a href=" https://github.com/hyperium/mime ">mime 0.3.17</a></li>
|
||||
<li><a href=" https://github.com/rust-num/num-traits ">num-traits 0.2.19</a></li>
|
||||
<li><a href=" https://github.com/seanmonstar/num_cpus ">num_cpus 1.17.0</a></li>
|
||||
|
|
@ -2673,12 +2674,12 @@ limitations under the License.</pre>
|
|||
<li><a href=" https://github.com/bluss/scopeguard ">scopeguard 1.2.0</a></li>
|
||||
<li><a href=" https://github.com/vorner/signal-hook ">signal-hook-registry 1.4.8</a></li>
|
||||
<li><a href=" https://github.com/servo/rust-smallvec ">smallvec 1.15.1</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/socket2 ">socket2 0.6.3</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/socket2 ">socket2 0.6.4</a></li>
|
||||
<li><a href=" https://github.com/Stebalien/tempfile ">tempfile 3.27.0</a></li>
|
||||
<li><a href=" https://github.com/Amanieu/thread_local-rs ">thread_local 1.1.9</a></li>
|
||||
<li><a href=" https://github.com/bheisler/TinyTemplate ">tinytemplate 1.2.1</a></li>
|
||||
<li><a href=" https://github.com/unicode-rs/unicode-width ">unicode-width 0.2.2</a></li>
|
||||
<li><a href=" https://github.com/uuid-rs/uuid ">uuid 1.23.1</a></li>
|
||||
<li><a href=" https://github.com/uuid-rs/uuid ">uuid 1.23.2</a></li>
|
||||
<li><a href=" https://github.com/alexcrichton/wait-timeout ">wait-timeout 0.2.1</a></li>
|
||||
</ul>
|
||||
<pre class="license-text"> Apache License
|
||||
|
|
@ -2888,7 +2889,7 @@ limitations under the License.
|
|||
<h3 id="Apache-2.0">Apache License 2.0</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/kyren/hashlink ">hashlink 0.11.0</a></li>
|
||||
<li><a href=" https://github.com/djc/hashlink ">hashlink 0.11.1</a></li>
|
||||
</ul>
|
||||
<pre class="license-text"> Apache License
|
||||
Version 2.0, January 2004
|
||||
|
|
@ -4138,7 +4139,7 @@ limitations under the License.
|
|||
<li><a href=" https://github.com/VoidStarKat/half-rs ">half 2.7.1</a></li>
|
||||
<li><a href=" https://github.com/dtolnay/itoa ">itoa 1.0.18</a></li>
|
||||
<li><a href=" https://github.com/rust-lang/libc ">libc 0.2.186</a></li>
|
||||
<li><a href=" https://github.com/jhpratt/num-conv ">num-conv 0.2.1</a></li>
|
||||
<li><a href=" https://github.com/jhpratt/num-conv ">num-conv 0.2.2</a></li>
|
||||
<li><a href=" https://github.com/taiki-e/pin-project-lite ">pin-project-lite 0.2.17</a></li>
|
||||
<li><a href=" https://github.com/taiki-e/portable-atomic ">portable-atomic 1.13.1</a></li>
|
||||
<li><a href=" https://github.com/dtolnay/proc-macro2 ">proc-macro2 1.0.106</a></li>
|
||||
|
|
@ -4149,10 +4150,10 @@ limitations under the License.
|
|||
<li><a href=" https://github.com/serde-rs/serde ">serde 1.0.228</a></li>
|
||||
<li><a href=" https://github.com/serde-rs/serde ">serde_core 1.0.228</a></li>
|
||||
<li><a href=" https://github.com/serde-rs/serde ">serde_derive 1.0.228</a></li>
|
||||
<li><a href=" https://github.com/serde-rs/json ">serde_json 1.0.149</a></li>
|
||||
<li><a href=" https://github.com/serde-rs/json ">serde_json 1.0.150</a></li>
|
||||
<li><a href=" https://github.com/dtolnay/path-to-error ">serde_path_to_error 0.1.20</a></li>
|
||||
<li><a href=" https://github.com/nox/serde_urlencoded ">serde_urlencoded 0.7.1</a></li>
|
||||
<li><a href=" https://github.com/comex/rust-shlex ">shlex 1.3.0</a></li>
|
||||
<li><a href=" https://github.com/comex/rust-shlex ">shlex 2.0.1</a></li>
|
||||
<li><a href=" https://github.com/jedisct1/rust-siphash ">siphasher 1.0.3</a></li>
|
||||
<li><a href=" https://github.com/dtolnay/syn ">syn 2.0.117</a></li>
|
||||
<li><a href=" https://github.com/Actyx/sync_wrapper ">sync_wrapper 1.0.2</a></li>
|
||||
|
|
@ -4242,7 +4243,7 @@ limitations under the License.
|
|||
<h3 id="Apache-2.0">Apache License 2.0</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/chronotope/chrono ">chrono 0.4.44</a></li>
|
||||
<li><a href=" https://github.com/chronotope/chrono ">chrono 0.4.45</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">Rust-chrono is dual-licensed under The MIT License [1] and
|
||||
Apache 2.0 License [2]. Copyright (c) 2014--2026, Kang Seonghoon and
|
||||
|
|
@ -4795,7 +4796,7 @@ The GNU General Public License does not permit incorporating your program into p
|
|||
<h3 id="MIT">MIT License</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/tokio-rs/mio ">mio 1.2.0</a></li>
|
||||
<li><a href=" https://github.com/tokio-rs/mio ">mio 1.2.1</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">Copyright (c) 2014 Carl Lerche and other MIO contributors
|
||||
|
||||
|
|
@ -4877,7 +4878,7 @@ IN THE SOFTWARE.
|
|||
<h3 id="MIT">MIT License</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/hyperium/hyper ">hyper 1.9.0</a></li>
|
||||
<li><a href=" https://github.com/hyperium/hyper ">hyper 1.10.1</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">Copyright (c) 2014-2026 Sean McArthur
|
||||
|
||||
|
|
@ -5163,7 +5164,7 @@ DEALINGS IN THE SOFTWARE.
|
|||
<h3 id="MIT">MIT License</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/tower-rs/tower-http ">tower-http 0.6.10</a></li>
|
||||
<li><a href=" https://github.com/tower-rs/tower-http ">tower-http 0.6.11</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">Copyright (c) 2019-2021 Tower Contributors
|
||||
|
||||
|
|
@ -5346,7 +5347,7 @@ SOFTWARE.
|
|||
<h3 id="MIT">MIT License</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/xacrimon/dashmap ">dashmap 6.1.0</a></li>
|
||||
<li><a href=" https://github.com/xacrimon/dashmap ">dashmap 6.2.1</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">MIT License
|
||||
|
||||
|
|
@ -5621,7 +5622,7 @@ DEALINGS IN THE SOFTWARE.
|
|||
<h3 id="MIT">MIT License</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/winnow-rs/winnow ">winnow 1.0.2</a></li>
|
||||
<li><a href=" https://github.com/winnow-rs/winnow ">winnow 1.0.3</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
|
|
@ -5711,8 +5712,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/BurntSushi/aho-corasick ">aho-corasick 1.1.4</a></li>
|
||||
<li><a href=" https://github.com/BurntSushi/ripgrep/tree/master/crates/globset ">globset 0.4.18</a></li>
|
||||
<li><a href=" https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore ">ignore 0.4.25</a></li>
|
||||
<li><a href=" https://github.com/BurntSushi/memchr ">memchr 2.8.0</a></li>
|
||||
<li><a href=" https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore ">ignore 0.4.26</a></li>
|
||||
<li><a href=" https://github.com/BurntSushi/memchr ">memchr 2.8.1</a></li>
|
||||
<li><a href=" https://github.com/BurntSushi/walkdir ">walkdir 2.5.0</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">The MIT License (MIT)
|
||||
|
|
@ -5923,7 +5924,7 @@ SOFTWARE.
|
|||
<h4>Used by:</h4>
|
||||
<ul class="license-used-by">
|
||||
<li><a href=" https://github.com/tree-sitter/tree-sitter ">tree-sitter-language 0.1.7</a></li>
|
||||
<li><a href=" https://github.com/tree-sitter/tree-sitter ">tree-sitter 0.26.8</a></li>
|
||||
<li><a href=" https://github.com/tree-sitter/tree-sitter ">tree-sitter 0.26.9</a></li>
|
||||
</ul>
|
||||
<pre class="license-text">The MIT License (MIT)
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ fn parse_timeout_diag(path: &Path, timeout_ms: u64) -> Diag {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -711,6 +712,7 @@ fn build_taint_diag(
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1400,6 +1402,7 @@ impl<'a> ParsedSource<'a> {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -2046,6 +2049,7 @@ impl<'a> ParsedFile<'a> {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -2129,6 +2133,7 @@ impl<'a> ParsedFile<'a> {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -1044,6 +1044,7 @@ fn auth_finding_to_diag(finding: &checks::AuthFinding, tree: &Tree, file_path: &
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -404,6 +404,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -192,35 +192,41 @@ pub fn pick_chain_cap(bits: u32) -> Option<Cap> {
|
|||
}
|
||||
|
||||
fn locate_reach(loc: &SourceLocation, surface: &SurfaceMap, reach: Option<&FileReachMap>) -> Reach {
|
||||
// Pass 1: file-local match (legacy behaviour, always applies).
|
||||
for node in &surface.nodes {
|
||||
if let SurfaceNode::EntryPoint(ep) = node
|
||||
&& ep.handler_location.file == loc.file
|
||||
{
|
||||
return Reach::Reachable {
|
||||
location: ep.location.clone(),
|
||||
method: ep.method,
|
||||
route: ep.route.clone(),
|
||||
auth_required: ep.auth_required,
|
||||
};
|
||||
// Within each pass, prefer an *unauthenticated* entry-point over an
|
||||
// auth-gated one: the chain composer scores worst-case exposure, and
|
||||
// taking the first match used to under-report whenever an auth-gated
|
||||
// route happened to sort first in the same file.
|
||||
let pick = |matches_entry: &dyn Fn(&crate::surface::EntryPoint) -> bool| -> Option<Reach> {
|
||||
let mut best: Option<&crate::surface::EntryPoint> = None;
|
||||
for node in &surface.nodes {
|
||||
if let SurfaceNode::EntryPoint(ep) = node
|
||||
&& matches_entry(ep)
|
||||
{
|
||||
if !ep.auth_required {
|
||||
best = Some(ep);
|
||||
break;
|
||||
}
|
||||
best.get_or_insert(ep);
|
||||
}
|
||||
}
|
||||
best.map(|ep| Reach::Reachable {
|
||||
location: ep.location.clone(),
|
||||
method: ep.method,
|
||||
route: ep.route.clone(),
|
||||
auth_required: ep.auth_required,
|
||||
})
|
||||
};
|
||||
// Pass 1: file-local match (legacy behaviour, always applies).
|
||||
if let Some(found) = pick(&|ep| ep.handler_location.file == loc.file) {
|
||||
return found;
|
||||
}
|
||||
// Pass 2: transitive caller match via the call graph. Only fires
|
||||
// when `reach` is supplied — keeps the legacy file-local behaviour
|
||||
// for callers that have not yet wired the call-graph reach map.
|
||||
if let Some(reach) = reach {
|
||||
for node in &surface.nodes {
|
||||
if let SurfaceNode::EntryPoint(ep) = node
|
||||
&& reach.reaches(&ep.handler_location.file, &loc.file)
|
||||
{
|
||||
return Reach::Reachable {
|
||||
location: ep.location.clone(),
|
||||
method: ep.method,
|
||||
route: ep.route.clone(),
|
||||
auth_required: ep.auth_required,
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(reach) = reach
|
||||
&& let Some(found) = pick(&|ep| reach.reaches(&ep.handler_location.file, &loc.file))
|
||||
{
|
||||
return found;
|
||||
}
|
||||
Reach::Unreachable
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,6 +463,7 @@ mod tests {
|
|||
location: loc(file, line),
|
||||
function_name: fname.into(),
|
||||
cap_bits: caps.bits(),
|
||||
label: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -671,6 +672,8 @@ mod tests {
|
|||
location: loc("app.py", 5),
|
||||
kind: DataStoreKind::KeyValue,
|
||||
label: "redis://127.0.0.1:6379".into(),
|
||||
owner: String::new(),
|
||||
access: Default::default(),
|
||||
}));
|
||||
let boosted = find_chains(
|
||||
&[edge()],
|
||||
|
|
|
|||
|
|
@ -167,6 +167,14 @@ pub struct Diag {
|
|||
/// Breakdown of how the ranking score was computed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rank_reason: Option<Vec<(String, String)>>,
|
||||
/// Worst-case attack-surface exposure: the externally-reachable
|
||||
/// route that can drive this finding, when the surface map's
|
||||
/// entry-points reach the finding's file (directly or via the call
|
||||
/// graph). `None` when the project has no detected entry-points
|
||||
/// or no route reaches the file. Populated by
|
||||
/// [`crate::surface::exposure::annotate_exposure`] before ranking.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exposure: Option<crate::surface::exposure::Exposure>,
|
||||
/// Whether this finding was suppressed by an inline `nyx:ignore` directive.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub suppressed: bool,
|
||||
|
|
@ -251,6 +259,7 @@ impl Default for Diag {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: default_triage_state(),
|
||||
|
|
@ -346,6 +355,7 @@ pub fn format_dynamic_verification_summary(summary: &DynamicVerificationSummary)
|
|||
/// composite-chain re-verification can reuse preloaded summaries and callgraph
|
||||
/// context.
|
||||
#[cfg(feature = "dynamic")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn verify_findings_for_scan(
|
||||
diags: &mut [Diag],
|
||||
project_name: &str,
|
||||
|
|
@ -2547,6 +2557,15 @@ pub(crate) fn scan_filesystem_with_observer(
|
|||
if let Some(p) = progress {
|
||||
p.set_stage(ScanStage::PostProcessing);
|
||||
}
|
||||
// Surface exposure: tag each finding with the worst-case route that
|
||||
// reaches it before ranking, so `rank_diags` can weigh external
|
||||
// reachability.
|
||||
crate::surface::exposure::annotate_exposure(
|
||||
&mut diags,
|
||||
&surface_map,
|
||||
chain_reach_out.and_then(|s| s.get()),
|
||||
Some(root),
|
||||
);
|
||||
post_process_diags(&mut diags, cfg);
|
||||
if let Some(p) = progress {
|
||||
p.record_post_process_ms(pp_start.elapsed().as_millis() as u64);
|
||||
|
|
@ -3398,6 +3417,15 @@ pub fn scan_with_index_parallel_observer(
|
|||
None,
|
||||
);
|
||||
}
|
||||
// Surface exposure: tag each finding with the worst-case route
|
||||
// that reaches it before ranking, so `rank_diags` can weigh
|
||||
// external reachability.
|
||||
crate::surface::exposure::annotate_exposure(
|
||||
&mut diags,
|
||||
&surface_map,
|
||||
chain_reach_out.and_then(|s| s.get()),
|
||||
Some(scan_root),
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: Taint-mode output is *not* filtered here. `run_rules_on_bytes`
|
||||
|
|
@ -3603,6 +3631,7 @@ fn rollup_findings(
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -3837,6 +3866,7 @@ mod dedup_taint_flow_tests {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -4007,6 +4037,7 @@ mod scc_tagging_tests {
|
|||
evidence: Some(Evidence::default()),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -4301,6 +4332,7 @@ fn severity_filter_applied_at_output_stage() {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -4325,6 +4357,7 @@ fn severity_filter_applied_at_output_stage() {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -4376,6 +4409,7 @@ mod prioritize_tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -4809,6 +4843,7 @@ mod prioritize_tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -4901,6 +4936,7 @@ mod stable_hash_tests {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ use crate::errors::{NyxError, NyxResult};
|
|||
use crate::summary::GlobalSummaries;
|
||||
use crate::surface::{
|
||||
DataStoreKind, EdgeKind, EntryPoint, ExternalServiceKind, SurfaceMap, SurfaceNode,
|
||||
build::{SurfaceBuildInputs, build_surface_map},
|
||||
build::{SurfaceBuildInputs, SurfaceCoverage, build_surface_map_with_coverage},
|
||||
};
|
||||
use crate::utils::Config;
|
||||
use crate::utils::project::get_project_info;
|
||||
|
|
@ -60,11 +60,18 @@ pub fn handle(
|
|||
config: &Config,
|
||||
) -> NyxResult<()> {
|
||||
let scan_root = Path::new(path).canonicalize()?;
|
||||
let map = if build_inline {
|
||||
build_full_from_filesystem(&scan_root, config)?
|
||||
let (map, coverage) = if build_inline {
|
||||
let (m, c) = build_full_from_filesystem(&scan_root, config)?;
|
||||
(m, Some(c))
|
||||
} else {
|
||||
load_or_build(&scan_root, database_dir, config)?
|
||||
};
|
||||
// Coverage goes to stderr so stdout stays clean for json / dot / svg
|
||||
// consumers. Only available when the map was built this run (a
|
||||
// persisted map carries no coverage).
|
||||
if let Some(cov) = &coverage {
|
||||
eprint!("{}", render_coverage(cov));
|
||||
}
|
||||
let stdout = std::io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
match format {
|
||||
|
|
@ -97,7 +104,7 @@ pub fn load_or_build(
|
|||
scan_root: &Path,
|
||||
database_dir: &Path,
|
||||
config: &Config,
|
||||
) -> NyxResult<SurfaceMap> {
|
||||
) -> NyxResult<(SurfaceMap, Option<SurfaceCoverage>)> {
|
||||
if let Ok((project, db_path)) = get_project_info(scan_root, database_dir)
|
||||
&& db_path.exists()
|
||||
&& let Ok(pool) = Indexer::init(&db_path)
|
||||
|
|
@ -105,12 +112,25 @@ pub fn load_or_build(
|
|||
&& let Ok(Some(map)) = idx.load_surface_map()
|
||||
&& !map.nodes.is_empty()
|
||||
{
|
||||
return Ok(map);
|
||||
// Persisted map: no coverage to report. Say where the data came
|
||||
// from on stderr — a reviewer comparing the tree against freshly
|
||||
// edited source needs to know it reflects the last indexed scan,
|
||||
// not the working tree.
|
||||
eprintln!(
|
||||
"Surface map: {} nodes, {} edges from the last indexed scan (pass --build to rebuild from source)",
|
||||
map.node_count(),
|
||||
map.edge_count()
|
||||
);
|
||||
return Ok((map, None));
|
||||
}
|
||||
build_from_filesystem(scan_root, config)
|
||||
let (map, cov) = build_from_filesystem(scan_root, config)?;
|
||||
Ok((map, Some(cov)))
|
||||
}
|
||||
|
||||
fn build_from_filesystem(scan_root: &Path, config: &Config) -> NyxResult<SurfaceMap> {
|
||||
fn build_from_filesystem(
|
||||
scan_root: &Path,
|
||||
config: &Config,
|
||||
) -> NyxResult<(SurfaceMap, SurfaceCoverage)> {
|
||||
let files = collect_files(scan_root, config)?;
|
||||
let summaries = GlobalSummaries::new();
|
||||
let call_graph = callgraph::build_call_graph(&summaries, &[]);
|
||||
|
|
@ -121,7 +141,7 @@ fn build_from_filesystem(scan_root: &Path, config: &Config) -> NyxResult<Surface
|
|||
call_graph: &call_graph,
|
||||
config,
|
||||
};
|
||||
Ok(build_surface_map(&inputs))
|
||||
Ok(build_surface_map_with_coverage(&inputs))
|
||||
}
|
||||
|
||||
/// Build a full SurfaceMap from source by running pass-1 summary
|
||||
|
|
@ -129,7 +149,10 @@ fn build_from_filesystem(scan_root: &Path, config: &Config) -> NyxResult<Surface
|
|||
/// resulting [`GlobalSummaries`] + [`CallGraph`] to
|
||||
/// [`build_surface_map`]. Same cost as `nyx index build` pass 1 but
|
||||
/// holds nothing in SQLite.
|
||||
fn build_full_from_filesystem(scan_root: &Path, config: &Config) -> NyxResult<SurfaceMap> {
|
||||
fn build_full_from_filesystem(
|
||||
scan_root: &Path,
|
||||
config: &Config,
|
||||
) -> NyxResult<(SurfaceMap, SurfaceCoverage)> {
|
||||
let files = collect_files(scan_root, config)?;
|
||||
let mut summaries = build_summaries_inline(&files, scan_root, config);
|
||||
summaries.install_hierarchy();
|
||||
|
|
@ -141,7 +164,26 @@ fn build_full_from_filesystem(scan_root: &Path, config: &Config) -> NyxResult<Su
|
|||
call_graph: &call_graph,
|
||||
config,
|
||||
};
|
||||
Ok(build_surface_map(&inputs))
|
||||
Ok(build_surface_map_with_coverage(&inputs))
|
||||
}
|
||||
|
||||
/// One-line coverage summary printed to stderr after a fresh build, so an
|
||||
/// operator can tell a genuinely small attack surface apart from "our
|
||||
/// probes did not understand this project". Parse failures and
|
||||
/// unsupported-language skips were previously swallowed silently.
|
||||
fn render_coverage(cov: &SurfaceCoverage) -> String {
|
||||
let mut s = format!(
|
||||
"Coverage: {} files, {} in a supported language ({} parsed, {} with routes)",
|
||||
cov.files_total, cov.files_supported, cov.files_parsed, cov.files_with_entry_points,
|
||||
);
|
||||
if cov.files_parse_failed > 0 {
|
||||
s.push_str(&format!(", {} unparsed", cov.files_parse_failed));
|
||||
}
|
||||
if cov.files_unreadable > 0 {
|
||||
s.push_str(&format!(", {} unreadable", cov.files_unreadable));
|
||||
}
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
|
||||
/// Run pass-1 summary extraction across `files` in parallel and merge
|
||||
|
|
@ -242,6 +284,36 @@ pub fn render_text(map: &SurfaceMap, scan_root: Option<&Path>) -> String {
|
|||
return out;
|
||||
}
|
||||
|
||||
// Risk banner: the highest-risk entry-points first, so a reviewer
|
||||
// sees "what should I look at" before the per-file inventory.
|
||||
let risks = crate::surface::risk::assess_entry_risks(map);
|
||||
let risk_by_idx: std::collections::HashMap<usize, &crate::surface::risk::EntryRisk> =
|
||||
risks.iter().map(|r| (r.entry_idx, r)).collect();
|
||||
let top: Vec<&crate::surface::risk::EntryRisk> = risks
|
||||
.iter()
|
||||
.filter(|r| r.tier >= crate::surface::risk::RiskTier::Medium)
|
||||
.take(10)
|
||||
.collect();
|
||||
if !top.is_empty() {
|
||||
out.push_str("Top risk entry-points\n");
|
||||
for r in &top {
|
||||
let Some(SurfaceNode::EntryPoint(ep)) = map.nodes.get(r.entry_idx) else {
|
||||
continue;
|
||||
};
|
||||
out.push_str(&format!(
|
||||
" [{}] {} {} ({:?}) — {} [{}:{}]\n",
|
||||
r.tier.tag(),
|
||||
method_str(ep.method),
|
||||
ep.route,
|
||||
ep.framework,
|
||||
r.factors.join(", "),
|
||||
ep.location.file,
|
||||
ep.location.line
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
let mut by_file: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
|
||||
for (idx, node) in map.nodes.iter().enumerate() {
|
||||
by_file
|
||||
|
|
@ -252,7 +324,7 @@ pub fn render_text(map: &SurfaceMap, scan_root: Option<&Path>) -> String {
|
|||
|
||||
let mut reached: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for edge in &map.edges {
|
||||
if matches!(edge.kind, EdgeKind::Reaches) {
|
||||
if edge.kind.is_reach_like() {
|
||||
reached.insert(edge.to);
|
||||
}
|
||||
}
|
||||
|
|
@ -269,7 +341,7 @@ pub fn render_text(map: &SurfaceMap, scan_root: Option<&Path>) -> String {
|
|||
let SurfaceNode::EntryPoint(ep) = &map.nodes[ei] else {
|
||||
continue;
|
||||
};
|
||||
render_entry_point(&mut out, ep, ei as u32, map);
|
||||
render_entry_point(&mut out, ep, ei as u32, map, risk_by_idx.get(&ei).copied());
|
||||
}
|
||||
}
|
||||
for &i in indices {
|
||||
|
|
@ -323,24 +395,46 @@ pub fn render_text(map: &SurfaceMap, scan_root: Option<&Path>) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
fn render_entry_point(out: &mut String, ep: &EntryPoint, ep_idx: u32, map: &SurfaceMap) {
|
||||
fn render_entry_point(
|
||||
out: &mut String,
|
||||
ep: &EntryPoint,
|
||||
ep_idx: u32,
|
||||
map: &SurfaceMap,
|
||||
risk: Option<&crate::surface::risk::EntryRisk>,
|
||||
) {
|
||||
let auth = if ep.auth_required { " [auth]" } else { "" };
|
||||
// Only Medium and above gets a tag — every line reading `[low]`
|
||||
// would be noise, absence of a tag *is* the low signal.
|
||||
let risk_tag = risk
|
||||
.filter(|r| r.tier >= crate::surface::risk::RiskTier::Medium)
|
||||
.map(|r| format!(" [risk: {}]", r.tier.tag()))
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
" {} {} ({:?}){}\n",
|
||||
" {} {} ({:?}){}{}\n",
|
||||
method_str(ep.method),
|
||||
ep.route,
|
||||
ep.framework,
|
||||
auth
|
||||
auth,
|
||||
risk_tag
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" handler: {} at {}:{}\n",
|
||||
ep.handler_name, ep.handler_location.file, ep.handler_location.line
|
||||
));
|
||||
let mut reached: Vec<&SurfaceNode> = map
|
||||
// Dedupe destinations: a read-write data store carries both a
|
||||
// ReadsFrom and a WritesTo edge to the same node — one line each
|
||||
// would print the store twice.
|
||||
let mut to_indices: Vec<u32> = map
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.from == ep_idx && matches!(e.kind, EdgeKind::Reaches))
|
||||
.filter_map(|e| map.nodes.get(e.to as usize))
|
||||
.filter(|e| e.from == ep_idx && e.kind.is_reach_like())
|
||||
.map(|e| e.to)
|
||||
.collect();
|
||||
to_indices.sort_unstable();
|
||||
to_indices.dedup();
|
||||
let mut reached: Vec<&SurfaceNode> = to_indices
|
||||
.iter()
|
||||
.filter_map(|&i| map.nodes.get(i as usize))
|
||||
.collect();
|
||||
reached.sort_by(|a, b| a.location().cmp(b.location()));
|
||||
if reached.is_empty() {
|
||||
|
|
@ -364,9 +458,16 @@ fn render_node_line(out: &mut String, node: &SurfaceNode, prefix: &str) {
|
|||
));
|
||||
}
|
||||
SurfaceNode::DataStore(ds) => {
|
||||
let access = match ds.access {
|
||||
crate::surface::AccessMode::Read => ", read",
|
||||
crate::surface::AccessMode::Write => ", write",
|
||||
crate::surface::AccessMode::ReadWrite => ", read-write",
|
||||
crate::surface::AccessMode::Unknown => "",
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"{prefix}data-store ({}): {} [{}:{}]\n",
|
||||
"{prefix}data-store ({}{}): {} [{}:{}]\n",
|
||||
ds_kind_str(ds.kind),
|
||||
access,
|
||||
ds.label,
|
||||
ds.location.file,
|
||||
ds.location.line
|
||||
|
|
@ -382,9 +483,14 @@ fn render_node_line(out: &mut String, node: &SurfaceNode, prefix: &str) {
|
|||
));
|
||||
}
|
||||
SurfaceNode::DangerousLocal(dl) => {
|
||||
let caps = if dl.label.is_empty() {
|
||||
crate::surface::cap_label_string(dl.cap_bits)
|
||||
} else {
|
||||
dl.label.clone()
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"{prefix}dangerous: {} (cap=0x{:x}) [{}:{}]\n",
|
||||
dl.function_name, dl.cap_bits, dl.location.file, dl.location.line
|
||||
"{prefix}dangerous ({}): {} [{}:{}]\n",
|
||||
caps, dl.function_name, dl.location.file, dl.location.line
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -474,15 +580,22 @@ pub fn render_dot(map: &SurfaceMap) -> String {
|
|||
"component",
|
||||
"#8b3aa5",
|
||||
),
|
||||
SurfaceNode::DangerousLocal(dl) => (
|
||||
format!(
|
||||
"Dangerous\\n{}\\ncap=0x{:x}",
|
||||
escape_dot(&dl.function_name),
|
||||
dl.cap_bits
|
||||
),
|
||||
"octagon",
|
||||
"#c44141",
|
||||
),
|
||||
SurfaceNode::DangerousLocal(dl) => {
|
||||
let caps = if dl.label.is_empty() {
|
||||
crate::surface::cap_label_string(dl.cap_bits)
|
||||
} else {
|
||||
dl.label.clone()
|
||||
};
|
||||
(
|
||||
format!(
|
||||
"Dangerous ({})\\n{}",
|
||||
escape_dot(&caps),
|
||||
escape_dot(&dl.function_name),
|
||||
),
|
||||
"octagon",
|
||||
"#c44141",
|
||||
)
|
||||
}
|
||||
};
|
||||
out.push_str(&format!(
|
||||
" n{i} [label=\"{label}\", shape={shape}, color=\"{color}\", fontcolor=\"{color}\"];\n",
|
||||
|
|
@ -603,6 +716,7 @@ mod tests {
|
|||
location: SourceLocation::new("app.py", 12, 1),
|
||||
function_name: "eval".into(),
|
||||
cap_bits: crate::labels::Cap::CODE_EXEC.bits(),
|
||||
label: "code-exec".into(),
|
||||
},
|
||||
));
|
||||
// Build edge after canonicalize so indices are stable.
|
||||
|
|
@ -625,7 +739,7 @@ mod tests {
|
|||
m.canonicalize();
|
||||
let text = render_text(&m, None);
|
||||
assert!(text.contains("reaches:"));
|
||||
assert!(text.contains("dangerous: eval"));
|
||||
assert!(text.contains("dangerous (code-exec): eval"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -691,7 +805,7 @@ mod tests {
|
|||
|
||||
let cfg = Config::default();
|
||||
let canon = project_dir.canonicalize().unwrap();
|
||||
let map = build_full_from_filesystem(&canon, &cfg).expect("inline build succeeds");
|
||||
let (map, _cov) = build_full_from_filesystem(&canon, &cfg).expect("inline build succeeds");
|
||||
|
||||
let has_entry = map
|
||||
.nodes
|
||||
|
|
@ -722,7 +836,7 @@ mod tests {
|
|||
|
||||
let cfg = Config::default();
|
||||
let canon = project_dir.canonicalize().unwrap();
|
||||
let map = build_from_filesystem(&canon, &cfg).expect("fallback build succeeds");
|
||||
let (map, _cov) = build_from_filesystem(&canon, &cfg).expect("fallback build succeeds");
|
||||
|
||||
// Entry point should still appear (framework probes run in the
|
||||
// fallback path too).
|
||||
|
|
|
|||
|
|
@ -1091,6 +1091,7 @@ pub mod index {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -1600,6 +1600,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
14
src/fmt.rs
14
src/fmt.rs
|
|
@ -985,6 +985,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1009,6 +1010,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1047,6 +1049,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1085,6 +1088,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1109,6 +1113,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1145,6 +1150,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1178,6 +1184,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1215,6 +1222,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: Some(120.0),
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1311,6 +1319,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: Some(36.0),
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1360,6 +1369,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1395,6 +1405,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: Some(42.0),
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1434,6 +1445,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1469,6 +1481,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -1518,6 +1531,7 @@ mod tests {
|
|||
}),
|
||||
rank_score: Some(47.0),
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -241,6 +241,23 @@ pub fn build_sarif_with_chains(diags: &[Diag], chains: &[ChainFinding], scan_roo
|
|||
props.insert("data_exfil_field".into(), json!(field));
|
||||
}
|
||||
|
||||
// Attack-surface exposure: the externally-reachable route
|
||||
// that drives this finding. Lets a SARIF consumer (CI gate,
|
||||
// dashboard) filter on "reachable from an unauthenticated
|
||||
// route" without re-running the surface build.
|
||||
if let Some(exp) = &d.exposure {
|
||||
props.insert(
|
||||
"exposure".into(),
|
||||
json!({
|
||||
"route": exp.route,
|
||||
"method": format!("{:?}", exp.method),
|
||||
"framework": format!("{:?}", exp.framework),
|
||||
"auth_required": exp.auth_required,
|
||||
"transitive": exp.transitive,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if !d.finding_id.is_empty() {
|
||||
props.insert("finding_id".into(), json!(d.finding_id));
|
||||
}
|
||||
|
|
@ -395,6 +412,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ pub fn scan_ejs_file(path: &Path, bytes: &[u8]) -> Vec<Diag> {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
27
src/rank.rs
27
src/rank.rs
|
|
@ -55,6 +55,32 @@ pub fn compute_attack_rank(diag: &Diag) -> AttackRank {
|
|||
components.push(("evidence".into(), format!("{evidence_bonus}")));
|
||||
}
|
||||
|
||||
// ── 3b. Surface exposure ────────────────────────────────────────────
|
||||
//
|
||||
// A finding reachable from a surface entry-point is more exploitable
|
||||
// than an internal one; reachable *without auth* more so. Transitive
|
||||
// reach (through the call graph rather than in the handler's own
|
||||
// file) is slightly discounted because the file-level reach map can
|
||||
// over-approximate. Magnitudes keep the severity tier ordering: the
|
||||
// maximum exposure bonus (+10) plus all other Medium-tier bonuses
|
||||
// stays below the High severity base (see tier tests).
|
||||
if let Some(exp) = &diag.exposure {
|
||||
let mut exposure_bonus = if exp.auth_required { 4.0 } else { 10.0 };
|
||||
if exp.transitive {
|
||||
exposure_bonus -= 2.0;
|
||||
}
|
||||
score += exposure_bonus;
|
||||
let auth_tag = if exp.auth_required {
|
||||
"auth-gated"
|
||||
} else {
|
||||
"unauthenticated"
|
||||
};
|
||||
components.push((
|
||||
"exposure".into(),
|
||||
format!("{exposure_bonus:+} ({auth_tag})"),
|
||||
));
|
||||
}
|
||||
|
||||
// ── 4. State finding sub-ranking ────────────────────────────────────
|
||||
let state_bonus = state_finding_bonus(&diag.id);
|
||||
score += state_bonus;
|
||||
|
|
@ -421,6 +447,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -610,6 +610,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ pub struct FindingView {
|
|||
pub guard_kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rank_reason: Option<Vec<(String, String)>>,
|
||||
/// Worst-case attack-surface exposure (route, method, auth) when a
|
||||
/// surface entry-point reaches this finding.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exposure: Option<crate::surface::exposure::Exposure>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sanitizer_status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
|
|
@ -345,6 +349,7 @@ pub fn finding_from_diag(index: usize, d: &Diag) -> FindingView {
|
|||
.and_then(|ev| ev.dynamic_verdict.clone()),
|
||||
guard_kind: None,
|
||||
rank_reason: None,
|
||||
exposure: d.exposure.clone(),
|
||||
sanitizer_status: None,
|
||||
related_findings: vec![],
|
||||
}
|
||||
|
|
@ -937,6 +942,7 @@ mod tests {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -31,12 +34,24 @@ async fn get_surface(State(state): State<AppState>) -> ApiResult<Json<Value>> {
|
|||
.await
|
||||
.map_err(|e| ApiError::internal(format!("surface map task failed: {e}")))?;
|
||||
|
||||
let mut map =
|
||||
let (mut map, _coverage) =
|
||||
join_result.map_err(|e| ApiError::internal(format!("failed to build surface map: {e}")))?;
|
||||
// Risk is derived from the canonicalised map, so canonicalise (via
|
||||
// `to_json`) first to lock node indices, then assess.
|
||||
let bytes = map
|
||||
.to_json()
|
||||
.map_err(|e| ApiError::internal(format!("encode surface map: {e}")))?;
|
||||
let value: Value = serde_json::from_slice(&bytes)
|
||||
let mut value: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| ApiError::internal(format!("re-parse surface map JSON: {e}")))?;
|
||||
// Attach per-entry-point risk assessment alongside the raw map so the
|
||||
// frontend can render a risk-sorted view without re-deriving scores.
|
||||
let risks = crate::surface::risk::assess_entry_risks(&map);
|
||||
if let Value::Object(obj) = &mut value {
|
||||
obj.insert(
|
||||
"entry_risks".into(),
|
||||
serde_json::to_value(&risks)
|
||||
.map_err(|e| ApiError::internal(format!("encode entry risks: {e}")))?,
|
||||
);
|
||||
}
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,31 +3,39 @@
|
|||
//! Phase 22 dispatch:
|
||||
//!
|
||||
//! 1. Per-file framework probes (one parser per language) emit
|
||||
//! [`SurfaceNode::EntryPoint`](crate::surface::SurfaceNode::EntryPoint) nodes for every recognised route /
|
||||
//! [`SurfaceNode::EntryPoint`] nodes for every recognised route /
|
||||
//! handler.
|
||||
//! 2. [`super::datastore::detect_data_stores`] walks
|
||||
//! [`GlobalSummaries`] and emits [`SurfaceNode::DataStore`](crate::surface::SurfaceNode::DataStore) nodes
|
||||
//! [`GlobalSummaries`] and emits [`SurfaceNode::DataStore`] nodes
|
||||
//! for every recognised driver call.
|
||||
//! 3. [`super::external::detect_external_services`] walks summaries +
|
||||
//! SSRF caps and emits [`SurfaceNode::ExternalService`](crate::surface::SurfaceNode::ExternalService) nodes.
|
||||
//! SSRF caps and emits [`SurfaceNode::ExternalService`] nodes.
|
||||
//! 4. [`super::dangerous::detect_dangerous_locals`] walks summaries
|
||||
//! and emits [`SurfaceNode::DangerousLocal`](crate::surface::SurfaceNode::DangerousLocal) nodes for every
|
||||
//! function whose `sink_caps` include CODE_EXEC / DESERIALIZE /
|
||||
//! SSTI / FMT_STRING.
|
||||
//! 5. [`super::reachability::populate_reaches_edges`] runs a BFS over
|
||||
//! the [`CallGraph`] from each entry-point handler, emitting
|
||||
//! [`super::EdgeKind::Reaches`] edges to every reachable
|
||||
//! DataStore / ExternalService / DangerousLocal.
|
||||
//! and emits [`SurfaceNode::DangerousLocal`] nodes for every
|
||||
//! function whose `sink_caps` include a local-sink class (code-exec,
|
||||
//! deserialize, SSTI, format-string, LDAP / XPath / header /
|
||||
//! open-redirect injection, XXE, prototype pollution), located at the
|
||||
//! real sink span and labelled with the decoded cap class.
|
||||
//! 5. [`super::reachability::populate_reaches_edges`] runs a forward,
|
||||
//! function-level BFS over the [`CallGraph`] from each entry-point
|
||||
//! handler, emitting [`super::EdgeKind::ReadsFrom`] (→ data store),
|
||||
//! [`super::EdgeKind::TalksTo`] (→ external service), and
|
||||
//! [`super::EdgeKind::Reaches`] (→ dangerous local) edges to every
|
||||
//! reachable destination.
|
||||
//! 6. [`SurfaceMap::canonicalize`] sorts nodes + edges so the
|
||||
//! serialised JSON is byte-deterministic across rescans.
|
||||
//!
|
||||
//! Per-file errors (parse failure, unsupported language) are
|
||||
//! swallowed so a single bad file does not kill the whole map.
|
||||
//! Per-file errors (parse failure, unsupported language, unreadable file)
|
||||
//! are swallowed so a single bad file does not kill the whole map, but are
|
||||
//! counted into [`SurfaceCoverage`] so the skip is observable rather than
|
||||
//! silent.
|
||||
|
||||
use crate::auth_analysis::auth_markers::router_auth_markers_for_lang;
|
||||
use crate::callgraph::CallGraph;
|
||||
use crate::entry_points::{EntryKind, HttpMethod};
|
||||
use crate::summary::GlobalSummaries;
|
||||
use crate::surface::{
|
||||
SurfaceMap, dangerous, datastore, external,
|
||||
EntryPoint, Framework, SourceLocation, SurfaceMap, SurfaceNode, dangerous, datastore, external,
|
||||
lang::{
|
||||
go_gin, go_http, java_quarkus, java_servlet, java_spring, js_express, js_koa, php_laravel,
|
||||
php_slim, python_django, python_fastapi, python_flask, ruby_rails, ruby_sinatra,
|
||||
|
|
@ -47,17 +55,63 @@ pub struct SurfaceBuildInputs<'a> {
|
|||
pub config: &'a Config,
|
||||
}
|
||||
|
||||
/// Per-build coverage counters. Turns the previously-silent
|
||||
/// "single bad file is swallowed" behaviour into a number an operator can
|
||||
/// read, so a small attack-surface map can be told apart from "our probes
|
||||
/// did not understand this project's framework / language".
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SurfaceCoverage {
|
||||
/// Total files handed to the builder.
|
||||
pub files_total: usize,
|
||||
/// Files in a language a framework probe exists for.
|
||||
pub files_supported: usize,
|
||||
/// Supported-language files that parsed cleanly.
|
||||
pub files_parsed: usize,
|
||||
/// Supported-language files whose tree-sitter parse failed.
|
||||
pub files_parse_failed: usize,
|
||||
/// Files in a language with no framework probe (`.md`, `.toml`, …).
|
||||
pub files_unsupported: usize,
|
||||
/// Files that could not be read off disk.
|
||||
pub files_unreadable: usize,
|
||||
/// Supported-language files that yielded at least one entry-point node.
|
||||
pub files_with_entry_points: usize,
|
||||
}
|
||||
|
||||
/// Build a [`SurfaceMap`], discarding coverage. Thin wrapper over
|
||||
/// [`build_surface_map_with_coverage`] for callers (the indexed scan
|
||||
/// path, persistence) that do not surface telemetry.
|
||||
pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
||||
build_surface_map_with_coverage(inputs).0
|
||||
}
|
||||
|
||||
/// Build a [`SurfaceMap`] and report [`SurfaceCoverage`]. The `nyx
|
||||
/// surface` CLI uses this variant so parse / unsupported skips become a
|
||||
/// visible number instead of being silently swallowed.
|
||||
pub fn build_surface_map_with_coverage(
|
||||
inputs: &SurfaceBuildInputs<'_>,
|
||||
) -> (SurfaceMap, SurfaceCoverage) {
|
||||
let mut map = SurfaceMap::new();
|
||||
let _ = inputs.config;
|
||||
let mut cov = SurfaceCoverage {
|
||||
files_total: inputs.files.len(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut parsers = Parsers::new();
|
||||
for path in inputs.files {
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
cov.files_unreadable += 1;
|
||||
continue;
|
||||
};
|
||||
let kind = classify_file(path);
|
||||
let nodes = match kind {
|
||||
if kind == FileKind::Other {
|
||||
cov.files_unsupported += 1;
|
||||
continue;
|
||||
}
|
||||
cov.files_supported += 1;
|
||||
// `Some(nodes)` on a clean parse (possibly empty), `None` when the
|
||||
// tree-sitter parse failed — lets coverage distinguish the two.
|
||||
let parsed: Option<Vec<SurfaceNode>> = match kind {
|
||||
FileKind::Python => parsers
|
||||
.python
|
||||
.as_mut()
|
||||
|
|
@ -78,8 +132,7 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
inputs.scan_root,
|
||||
));
|
||||
all
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
FileKind::JavaScript => parsers
|
||||
.javascript
|
||||
.as_mut()
|
||||
|
|
@ -94,8 +147,7 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
inputs.scan_root,
|
||||
));
|
||||
all
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
FileKind::TypeScript => parsers
|
||||
.typescript
|
||||
.as_mut()
|
||||
|
|
@ -116,8 +168,7 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
inputs.scan_root,
|
||||
));
|
||||
all
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
FileKind::Java => parsers
|
||||
.java
|
||||
.as_mut()
|
||||
|
|
@ -138,8 +189,7 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
inputs.scan_root,
|
||||
));
|
||||
all
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
FileKind::Go => parsers
|
||||
.go
|
||||
.as_mut()
|
||||
|
|
@ -154,8 +204,7 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
inputs.scan_root,
|
||||
));
|
||||
all
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
FileKind::Php => parsers
|
||||
.php
|
||||
.as_mut()
|
||||
|
|
@ -170,8 +219,7 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
inputs.scan_root,
|
||||
));
|
||||
all
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
FileKind::Ruby => parsers
|
||||
.ruby
|
||||
.as_mut()
|
||||
|
|
@ -186,8 +234,7 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
inputs.scan_root,
|
||||
));
|
||||
all
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
FileKind::Rust => parsers
|
||||
.rust
|
||||
.as_mut()
|
||||
|
|
@ -202,15 +249,38 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
inputs.scan_root,
|
||||
));
|
||||
all
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
FileKind::Other => Vec::new(),
|
||||
}),
|
||||
// Unreachable: `Other` is filtered out before this match, but
|
||||
// the arm keeps the match exhaustive.
|
||||
FileKind::Other => None,
|
||||
};
|
||||
for n in nodes {
|
||||
map.nodes.push(n);
|
||||
match parsed {
|
||||
Some(nodes) => {
|
||||
cov.files_parsed += 1;
|
||||
if nodes
|
||||
.iter()
|
||||
.any(|n| matches!(n, SurfaceNode::EntryPoint(_)))
|
||||
{
|
||||
cov.files_with_entry_points += 1;
|
||||
}
|
||||
for n in nodes {
|
||||
map.nodes.push(n);
|
||||
}
|
||||
}
|
||||
None => cov.files_parse_failed += 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Entry-point recall fallback: the pass-1 summary extractor tags
|
||||
// handler functions with `FuncSummary::entry_kind` using its own
|
||||
// (independent) framework detection. Any handler it recognised
|
||||
// that the AST probes above missed is synthesised here so the
|
||||
// surface map's entry-point set is always a superset of what the
|
||||
// taint engine treats as adversary-driven. Route strings are not
|
||||
// recoverable from summaries, so these carry `"(unrouted)"`.
|
||||
let synthesised = synth_entry_points_from_summaries(&map.nodes, inputs.global_summaries);
|
||||
map.nodes.extend(synthesised);
|
||||
|
||||
// Phase 22 — Track F.3: data-store / external-service /
|
||||
// dangerous-local detection from summaries.
|
||||
map.nodes
|
||||
|
|
@ -220,6 +290,13 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
map.nodes
|
||||
.extend(dangerous::detect_dangerous_locals(inputs.global_summaries));
|
||||
|
||||
// Auth-detection upgrade: the probes only see router-level evidence
|
||||
// (decorators, annotations, middleware arguments). A handler that
|
||||
// guards itself in its body (`requireAuth(req)` as the first call,
|
||||
// Go-style `if !VerifyToken(...)`) is still auth-gated; lift that
|
||||
// from the handler summary's callee list.
|
||||
upgrade_auth_required_from_summaries(&mut map, inputs.global_summaries);
|
||||
|
||||
// Canonicalise so node indices are stable before reachability
|
||||
// builds edges referring to those indices.
|
||||
map.canonicalize();
|
||||
|
|
@ -230,7 +307,160 @@ pub fn build_surface_map(inputs: &SurfaceBuildInputs<'_>) -> SurfaceMap {
|
|||
// Re-canonicalise: edges added by reachability need to be sorted
|
||||
// so the serialised JSON stays byte-deterministic.
|
||||
map.canonicalize();
|
||||
map
|
||||
(map, cov)
|
||||
}
|
||||
|
||||
/// Route placeholder for entry points synthesised from summaries: the
|
||||
/// pass-1 extractor records *that* a function is a handler but not the
|
||||
/// route string the framework maps to it.
|
||||
pub const UNROUTED: &str = "(unrouted)";
|
||||
|
||||
/// Map a pass-1 [`EntryKind`] tag to the surface [`Framework`] +
|
||||
/// [`HttpMethod`] pair. Kinds with no verb evidence default to `GET`
|
||||
/// except Next.js server actions, which the framework only ever
|
||||
/// invokes via `POST`.
|
||||
fn entry_kind_to_framework(kind: &EntryKind) -> (Framework, HttpMethod) {
|
||||
match kind {
|
||||
EntryKind::UseServerDirective | EntryKind::FormAction => {
|
||||
(Framework::NextServerAction, HttpMethod::POST)
|
||||
}
|
||||
EntryKind::AppRouteHandler { method } => (Framework::NextAppRouter, *method),
|
||||
EntryKind::ExpressRoute { method } => (Framework::Express, *method),
|
||||
EntryKind::DjangoView { method } => (Framework::Django, *method),
|
||||
EntryKind::FastApiRoute { method } => (Framework::FastApi, *method),
|
||||
EntryKind::FlaskRoute { method } => (Framework::Flask, *method),
|
||||
EntryKind::SpringMapping { method } => (Framework::Spring, *method),
|
||||
EntryKind::JaxRsResource => (Framework::JaxRs, HttpMethod::GET),
|
||||
EntryKind::RailsAction => (Framework::Rails, HttpMethod::GET),
|
||||
EntryKind::SinatraRoute { method } => (Framework::Sinatra, *method),
|
||||
EntryKind::AxumHandler => (Framework::Axum, HttpMethod::GET),
|
||||
EntryKind::ActixHandler => (Framework::Actix, HttpMethod::GET),
|
||||
EntryKind::RocketRoute => (Framework::Rocket, HttpMethod::GET),
|
||||
EntryKind::GoNetHttp => (Framework::NetHttp, HttpMethod::GET),
|
||||
EntryKind::GinRoute => (Framework::Gin, HttpMethod::GET),
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthesise [`SurfaceNode::EntryPoint`] nodes for handlers the pass-1
|
||||
/// summary extractor tagged with [`FuncSummary::entry_kind`](crate::summary::FuncSummary::entry_kind)
|
||||
/// but no AST probe emitted. De-duped against existing probe output on
|
||||
/// `(handler file, handler name)` so a probe-detected route always wins
|
||||
/// (it carries the real route string and span). Summaries carry no
|
||||
/// definition span, so synthesised nodes sit at line 0 of the handler
|
||||
/// file; reachability matches on `(file, name)` and is unaffected.
|
||||
fn synth_entry_points_from_summaries(
|
||||
existing: &[SurfaceNode],
|
||||
summaries: &GlobalSummaries,
|
||||
) -> Vec<SurfaceNode> {
|
||||
let mut seen: std::collections::HashSet<(String, String)> = existing
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
SurfaceNode::EntryPoint(ep) => {
|
||||
Some((ep.handler_location.file.clone(), ep.handler_name.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<SurfaceNode> = Vec::new();
|
||||
for (key, summary) in summaries.iter() {
|
||||
let Some(kind) = &summary.entry_kind else {
|
||||
continue;
|
||||
};
|
||||
if key.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let file = crate::surface::namespace_file(&key.namespace).to_string();
|
||||
if !seen.insert((file.clone(), key.name.clone())) {
|
||||
continue;
|
||||
}
|
||||
let (framework, method) = entry_kind_to_framework(kind);
|
||||
let loc = SourceLocation {
|
||||
file,
|
||||
line: 0,
|
||||
col: 0,
|
||||
};
|
||||
out.push(SurfaceNode::EntryPoint(EntryPoint {
|
||||
location: loc.clone(),
|
||||
framework,
|
||||
method,
|
||||
route: UNROUTED.to_string(),
|
||||
handler_name: key.name.clone(),
|
||||
handler_location: loc,
|
||||
auth_required: false,
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Set `auth_required = true` on entry points whose handler *body*
|
||||
/// calls a known auth guard, complementing the probes' router-level
|
||||
/// (decorator / annotation / middleware-argument) detection.
|
||||
///
|
||||
/// The handler summary is located by `(handler file, handler name)`;
|
||||
/// its direct callees' leaf names are matched case-insensitively
|
||||
/// against the per-language router-auth marker registry
|
||||
/// ([`router_auth_markers_for_lang`]). Depth is deliberately 1 — a
|
||||
/// guard buried two helpers deep is a router concern the call graph
|
||||
/// models better than a name list.
|
||||
fn upgrade_auth_required_from_summaries(map: &mut SurfaceMap, summaries: &GlobalSummaries) {
|
||||
use std::collections::HashMap;
|
||||
let needs_upgrade: Vec<usize> = map
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, n)| match n {
|
||||
SurfaceNode::EntryPoint(ep) if !ep.auth_required && !ep.handler_name.is_empty() => {
|
||||
Some(i)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if needs_upgrade.is_empty() {
|
||||
return;
|
||||
}
|
||||
// (file, name) → summaries defining that function. Built once; the
|
||||
// map is small relative to the summary count.
|
||||
let mut by_fn: HashMap<
|
||||
(&str, &str),
|
||||
Vec<(&crate::symbol::FuncKey, &crate::summary::FuncSummary)>,
|
||||
> = HashMap::new();
|
||||
for (key, summary) in summaries.iter() {
|
||||
by_fn
|
||||
.entry((crate::surface::namespace_file(&key.namespace), &key.name))
|
||||
.or_default()
|
||||
.push((key, summary));
|
||||
}
|
||||
let mut marker_cache: HashMap<crate::symbol::Lang, Vec<&'static str>> = HashMap::new();
|
||||
let mut to_set: Vec<usize> = Vec::new();
|
||||
for idx in needs_upgrade {
|
||||
let SurfaceNode::EntryPoint(ep) = &map.nodes[idx] else {
|
||||
continue;
|
||||
};
|
||||
let Some(cands) = by_fn.get(&(ep.handler_location.file.as_str(), ep.handler_name.as_str()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let guarded = cands.iter().any(|(key, summary)| {
|
||||
let markers = marker_cache
|
||||
.entry(key.lang)
|
||||
.or_insert_with(|| router_auth_markers_for_lang(key.lang));
|
||||
if markers.is_empty() {
|
||||
return false;
|
||||
}
|
||||
summary.callees.iter().any(|c| {
|
||||
let leaf = crate::callgraph::normalize_callee_name(&c.name);
|
||||
markers.iter().any(|m| m.eq_ignore_ascii_case(leaf))
|
||||
})
|
||||
});
|
||||
if guarded {
|
||||
to_set.push(idx);
|
||||
}
|
||||
}
|
||||
for idx in to_set {
|
||||
if let SurfaceNode::EntryPoint(ep) = &mut map.nodes[idx] {
|
||||
ep.auth_required = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
|
|
@ -325,6 +555,139 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesises_entry_point_from_summary_entry_kind() {
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::{FuncKey, Lang};
|
||||
// No source file on disk (probes see nothing), but pass-1 tagged
|
||||
// a Gin handler — the fallback must surface it.
|
||||
let dir = tempdir().unwrap();
|
||||
let cfg = Config::default();
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Go, "routes.go", "ListUsers", None);
|
||||
let summary = FuncSummary {
|
||||
name: "ListUsers".into(),
|
||||
file_path: "routes.go".into(),
|
||||
lang: "go".into(),
|
||||
entry_kind: Some(EntryKind::GinRoute),
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let cg = empty_call_graph();
|
||||
let files: Vec<PathBuf> = vec![];
|
||||
let inputs = empty_inputs(&files, Some(dir.path()), &gs, &cg, &cfg);
|
||||
let map = build_surface_map(&inputs);
|
||||
let eps: Vec<_> = map.entry_points().collect();
|
||||
assert_eq!(eps.len(), 1, "fallback entry-point expected");
|
||||
assert_eq!(eps[0].handler_name, "ListUsers");
|
||||
assert_eq!(eps[0].framework, Framework::Gin);
|
||||
assert_eq!(eps[0].route, UNROUTED);
|
||||
assert_eq!(eps[0].handler_location.file, "routes.go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_entry_point_suppresses_summary_fallback() {
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::{FuncKey, Lang};
|
||||
let dir = tempdir().unwrap();
|
||||
let py = dir.path().join("app.py");
|
||||
fs::write(
|
||||
&py,
|
||||
"from flask import Flask\napp = Flask(__name__)\n@app.get('/u')\ndef u(): pass\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = Config::default();
|
||||
let mut gs = GlobalSummaries::new();
|
||||
// Summary tags the same handler the probe sees.
|
||||
let key = FuncKey::new_function(Lang::Python, "app.py", "u", None);
|
||||
let summary = FuncSummary {
|
||||
name: "u".into(),
|
||||
file_path: "app.py".into(),
|
||||
lang: "python".into(),
|
||||
entry_kind: Some(EntryKind::FlaskRoute {
|
||||
method: HttpMethod::GET,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let cg = empty_call_graph();
|
||||
let files = vec![py];
|
||||
let inputs = empty_inputs(&files, Some(dir.path()), &gs, &cg, &cfg);
|
||||
let map = build_surface_map(&inputs);
|
||||
let eps: Vec<_> = map.entry_points().collect();
|
||||
assert_eq!(eps.len(), 1, "no duplicate from the fallback");
|
||||
assert_eq!(eps[0].route, "/u", "probe route (with real path) wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_level_auth_guard_upgrades_auth_required() {
|
||||
use crate::summary::{CalleeSite, FuncSummary};
|
||||
use crate::symbol::{FuncKey, Lang};
|
||||
let dir = tempdir().unwrap();
|
||||
let js = dir.path().join("routes.js");
|
||||
// Express route with NO middleware arg — probe alone says unauth.
|
||||
fs::write(
|
||||
&js,
|
||||
"const express = require('express');\nconst app = express();\napp.get('/admin', function admin(req, res) { requireAuth(req); res.send('x'); });\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = Config::default();
|
||||
let mut gs = GlobalSummaries::new();
|
||||
// Handler summary whose body calls requireAuth.
|
||||
let key = FuncKey::new_function(Lang::JavaScript, "routes.js", "admin", None);
|
||||
let summary = FuncSummary {
|
||||
name: "admin".into(),
|
||||
file_path: "routes.js".into(),
|
||||
lang: "javascript".into(),
|
||||
callees: vec![CalleeSite::bare("requireAuth")],
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let cg = empty_call_graph();
|
||||
let files = vec![js];
|
||||
let inputs = empty_inputs(&files, Some(dir.path()), &gs, &cg, &cfg);
|
||||
let map = build_surface_map(&inputs);
|
||||
let ep = map
|
||||
.entry_points()
|
||||
.find(|ep| ep.handler_name == "admin")
|
||||
.expect("express probe finds the named handler");
|
||||
assert!(
|
||||
ep.auth_required,
|
||||
"body-level requireAuth call should upgrade auth_required"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_callee_does_not_upgrade_auth() {
|
||||
use crate::summary::{CalleeSite, FuncSummary};
|
||||
use crate::symbol::{FuncKey, Lang};
|
||||
let dir = tempdir().unwrap();
|
||||
let py = dir.path().join("app.py");
|
||||
fs::write(
|
||||
&py,
|
||||
"from flask import Flask\napp = Flask(__name__)\n@app.get('/x')\ndef x(): pass\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = Config::default();
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "app.py", "x", None);
|
||||
let summary = FuncSummary {
|
||||
name: "x".into(),
|
||||
file_path: "app.py".into(),
|
||||
lang: "python".into(),
|
||||
// `settings` must not prefix-match any auth marker.
|
||||
callees: vec![CalleeSite::bare("settings"), CalleeSite::bare("render")],
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let cg = empty_call_graph();
|
||||
let files = vec![py];
|
||||
let inputs = empty_inputs(&files, Some(dir.path()), &gs, &cg, &cfg);
|
||||
let map = build_surface_map(&inputs);
|
||||
let ep = map.entry_points().next().expect("entry point");
|
||||
assert!(!ep.auth_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_inputs_produce_empty_map() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -12,17 +12,33 @@
|
|||
//! detection pass is added here; the surface layer just lifts the
|
||||
//! cap-bit information out of the summary.
|
||||
|
||||
use super::{DangerousLocal, SourceLocation, SurfaceNode};
|
||||
use super::{DangerousLocal, SourceLocation, SurfaceNode, cap_label_string, namespace_file};
|
||||
use crate::labels::Cap;
|
||||
use crate::summary::GlobalSummaries;
|
||||
use crate::summary::{FuncSummary, GlobalSummaries};
|
||||
|
||||
/// Cap bits that indicate the function is a *local* sink — code exec,
|
||||
/// unsafe deserialisation, server-side template injection, format
|
||||
/// string injection. Other sink caps (SQL_QUERY → DataStore;
|
||||
/// SSRF → ExternalService) live elsewhere in the surface layer so the
|
||||
/// node taxonomy matches the chain composer's expectations.
|
||||
/// Cap bits that indicate the function is a *local* sink — a sink with no
|
||||
/// externally observable side effect that attacker data flows *into*.
|
||||
/// Other sink caps live elsewhere in the surface layer so the node
|
||||
/// taxonomy matches the chain composer's expectations: `SQL_QUERY` /
|
||||
/// `FILE_IO` → DataStore (see [`super::datastore`]); `SSRF` / `DATA_EXFIL`
|
||||
/// → ExternalService (see [`super::external`]).
|
||||
///
|
||||
/// The set was widened from the original four (code-exec, deserialize,
|
||||
/// SSTI, format-string) to cover every injection-style local sink the
|
||||
/// label registry can classify, so a function that only builds an LDAP
|
||||
/// filter, parses XXE-vulnerable XML, or merges into a prototype is no
|
||||
/// longer absent from the surface map.
|
||||
fn dangerous_caps() -> Cap {
|
||||
Cap::CODE_EXEC | Cap::DESERIALIZE | Cap::SSTI | Cap::FMT_STRING
|
||||
Cap::CODE_EXEC
|
||||
| Cap::DESERIALIZE
|
||||
| Cap::SSTI
|
||||
| Cap::FMT_STRING
|
||||
| Cap::LDAP_INJECTION
|
||||
| Cap::XPATH_INJECTION
|
||||
| Cap::HEADER_INJECTION
|
||||
| Cap::OPEN_REDIRECT
|
||||
| Cap::XXE
|
||||
| Cap::PROTOTYPE_POLLUTION
|
||||
}
|
||||
|
||||
pub fn detect_dangerous_locals(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
||||
|
|
@ -33,19 +49,46 @@ pub fn detect_dangerous_locals(summaries: &GlobalSummaries) -> Vec<SurfaceNode>
|
|||
if caps.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Project-relative POSIX file, keyed off the FuncKey namespace so
|
||||
// a dangerous-local node and the entry-point that reaches it agree
|
||||
// on file identity (FuncSummary.file_path is an absolute path and
|
||||
// would never match an entry-point's relative handler file).
|
||||
let file = namespace_file(&key.namespace).to_string();
|
||||
let (line, col) = sink_line_col(summary, &file, caps);
|
||||
out.push(SurfaceNode::DangerousLocal(DangerousLocal {
|
||||
location: SourceLocation {
|
||||
file: summary.file_path.clone(),
|
||||
line: 0,
|
||||
col: 0,
|
||||
},
|
||||
location: SourceLocation { file, line, col },
|
||||
function_name: key.qualified_name(),
|
||||
cap_bits: caps.bits(),
|
||||
label: cap_label_string(caps.bits()),
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve the `(line, col)` of the dangerous sink inside `summary` by
|
||||
/// scanning its `param_to_sink` [`crate::summary::SinkSite`] records for a
|
||||
/// site whose cap intersects the dangerous mask. Prefers a same-file,
|
||||
/// non-chain-promoted site (the function's own sink) over a deeper
|
||||
/// chain-hop site so the coordinates point at code in `file`. Falls back
|
||||
/// to `(0, 0)` when the summary carries no located sink (pass-2 transient
|
||||
/// summaries, or summaries extracted without tree access).
|
||||
fn sink_line_col(summary: &FuncSummary, file: &str, mask: Cap) -> (u32, u32) {
|
||||
let mut fallback: Option<(u32, u32)> = None;
|
||||
for (_param, sites) in &summary.param_to_sink {
|
||||
for site in sites {
|
||||
if site.line == 0 || (site.cap & mask).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let same_file = site.file_rel.is_empty() || site.file_rel == file;
|
||||
if same_file && !site.from_chain {
|
||||
return (site.line, site.col);
|
||||
}
|
||||
fallback.get_or_insert((site.line, site.col));
|
||||
}
|
||||
}
|
||||
fallback.unwrap_or((0, 0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -64,6 +107,63 @@ mod tests {
|
|||
(key, summary)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carries_real_span_and_label_from_param_to_sink() {
|
||||
use crate::summary::SinkSite;
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "app.py", "render", None);
|
||||
let site = SinkSite {
|
||||
file_rel: "app.py".into(),
|
||||
line: 17,
|
||||
col: 9,
|
||||
snippet: "Template(x).render()".into(),
|
||||
cap: Cap::SSTI,
|
||||
from_chain: false,
|
||||
};
|
||||
let summary = FuncSummary {
|
||||
name: "render".into(),
|
||||
file_path: "/abs/app.py".into(), // absolute on purpose
|
||||
lang: "python".into(),
|
||||
sink_caps: Cap::SSTI.bits(),
|
||||
param_to_sink: vec![(0, vec![site].into())],
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let nodes = detect_dangerous_locals(&gs);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
let SurfaceNode::DangerousLocal(d) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
// Project-relative file (from the namespace), not the absolute path.
|
||||
assert_eq!(d.location.file, "app.py");
|
||||
assert_eq!(d.location.line, 17);
|
||||
assert_eq!(d.location.col, 9);
|
||||
assert_eq!(d.label, "ssti");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_widened_injection_caps() {
|
||||
// The widened mask now covers XXE / LDAP / open-redirect etc., which
|
||||
// the original four-cap mask missed entirely.
|
||||
for cap in [
|
||||
Cap::XXE,
|
||||
Cap::LDAP_INJECTION,
|
||||
Cap::XPATH_INJECTION,
|
||||
Cap::OPEN_REDIRECT,
|
||||
Cap::HEADER_INJECTION,
|
||||
Cap::PROTOTYPE_POLLUTION,
|
||||
] {
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let (k, s) = summary_with_caps("h", "danger.py", cap);
|
||||
gs.insert(k, s);
|
||||
assert_eq!(
|
||||
detect_dangerous_locals(&gs).len(),
|
||||
1,
|
||||
"cap {cap:?} should surface a dangerous-local node"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_eval_sink() {
|
||||
let mut gs = GlobalSummaries::new();
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@
|
|||
//! are forgiving — the surface map is informational, not a finding
|
||||
//! that fires on its own.
|
||||
|
||||
use super::{DataStore, DataStoreKind, SourceLocation, SurfaceNode};
|
||||
use crate::summary::{CalleeSite, FuncSummary, GlobalSummaries};
|
||||
use super::{AccessMode, DataStore, DataStoreKind, SourceLocation, SurfaceNode, namespace_file};
|
||||
use crate::labels::Cap;
|
||||
use crate::summary::GlobalSummaries;
|
||||
|
||||
/// One detection rule: leaf-name pattern → store kind + label. Stored
|
||||
/// as a flat list so adding a new ORM / driver is a one-line edit.
|
||||
|
|
@ -355,9 +356,15 @@ pub fn detect_data_stores(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
|||
let mut seen: std::collections::HashSet<(String, u32, String)> =
|
||||
std::collections::HashSet::new();
|
||||
for (key, summary) in summaries.iter() {
|
||||
// Project-relative POSIX file, keyed off the FuncKey namespace so a
|
||||
// data-store node and the entry-point that reaches it agree on file
|
||||
// identity (FuncSummary.file_path is an absolute path).
|
||||
let file = namespace_file(&key.namespace).to_string();
|
||||
let owner = key.qualified_name();
|
||||
let typed = summaries
|
||||
.get_ssa(key)
|
||||
.map(|s| s.typed_call_receivers.as_slice());
|
||||
let mut matched_for_fn = false;
|
||||
for callee in &summary.callees {
|
||||
let rule = match_rule(&callee.name).or_else(|| {
|
||||
typed
|
||||
|
|
@ -365,7 +372,8 @@ pub fn detect_data_stores(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
|||
.and_then(|c| match_rule(&qualify(c, &callee.name)))
|
||||
});
|
||||
let Some(rule) = rule else { continue };
|
||||
let location = call_site_location(summary, callee);
|
||||
matched_for_fn = true;
|
||||
let location = call_site_location(&file, callee.span);
|
||||
let dedup = (location.file.clone(), location.line, rule.label.to_string());
|
||||
if !seen.insert(dedup) {
|
||||
continue;
|
||||
|
|
@ -374,12 +382,117 @@ pub fn detect_data_stores(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
|||
location,
|
||||
kind: rule.kind,
|
||||
label: rule.label.to_string(),
|
||||
owner: owner.clone(),
|
||||
access: classify_access(leaf_segment(&callee.name)),
|
||||
}));
|
||||
}
|
||||
|
||||
// Cap-driven fallback: a function whose own `sink_caps` include
|
||||
// SQL_QUERY / FILE_IO is a data-store access site even when no
|
||||
// direct callee matched the driver table (custom DAO wrapper,
|
||||
// cross-file-resolved execute). Mirrors external.rs's SSRF
|
||||
// fallback. Skipped when a named driver already fired so the
|
||||
// precise label wins.
|
||||
if !matched_for_fn {
|
||||
let caps = summary.sink_caps();
|
||||
let fallback = if caps.contains(Cap::SQL_QUERY) {
|
||||
Some((DataStoreKind::Sql, "SQL query"))
|
||||
} else if caps.contains(Cap::FILE_IO) {
|
||||
Some((DataStoreKind::Filesystem, "File access"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((kind, label)) = fallback {
|
||||
let dedup = (file.clone(), 0, label.to_string());
|
||||
if seen.insert(dedup) {
|
||||
out.push(SurfaceNode::DataStore(DataStore {
|
||||
location: call_site_location(&file, None),
|
||||
kind,
|
||||
label: label.to_string(),
|
||||
owner: owner.clone(),
|
||||
// Cap bits carry no operation direction; a raw
|
||||
// SQL_QUERY / FILE_IO sink can be either.
|
||||
access: AccessMode::ReadWrite,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Classify the operation direction of a data-store access from the
|
||||
/// callee's leaf name. Whole-prefix match on a lowercase verb table —
|
||||
/// `findOne` / `find_by_id` / `findAll` all classify as reads via the
|
||||
/// `find` prefix. Connect-/client-construction sites and unrecognised
|
||||
/// verbs stay [`AccessMode::Unknown`] so reachability keeps emitting
|
||||
/// the conservative `ReadsFrom` edge for them.
|
||||
fn classify_access(leaf: &str) -> AccessMode {
|
||||
const READ: &[&str] = &[
|
||||
"find",
|
||||
"get",
|
||||
"query",
|
||||
"select",
|
||||
"read",
|
||||
"fetch",
|
||||
"scan",
|
||||
"count",
|
||||
"exists",
|
||||
"aggregate",
|
||||
"lrange",
|
||||
"smembers",
|
||||
"hget",
|
||||
"mget",
|
||||
"keys",
|
||||
"first",
|
||||
"pluck",
|
||||
"all",
|
||||
];
|
||||
const WRITE: &[&str] = &[
|
||||
"insert", "update", "delete", "save", "create", "set", "put", "write", "remove", "drop",
|
||||
"truncate", "upsert", "persist", "destroy", "del", "hset", "lpush", "rpush", "sadd",
|
||||
"zadd", "append", "rename", "unlink", "mkdir", "rmdir", "incr", "decr", "expire",
|
||||
];
|
||||
const READ_WRITE: &[&str] = &[
|
||||
"execute",
|
||||
"executemany",
|
||||
"executescript",
|
||||
"exec",
|
||||
"run",
|
||||
"batch",
|
||||
"transaction",
|
||||
"pipeline",
|
||||
];
|
||||
let l = leaf.trim();
|
||||
// Verb-prefix match with a word boundary: the verb must be the whole
|
||||
// leaf, or be followed by `_` (snake_case), an uppercase letter
|
||||
// (camelCase), or a digit. `findOne` / `find_by_id` → read;
|
||||
// `settings` does NOT match `set`.
|
||||
let has_prefix = |verbs: &[&str]| {
|
||||
verbs.iter().any(|v| {
|
||||
l.get(..v.len())
|
||||
.is_some_and(|head| head.eq_ignore_ascii_case(v))
|
||||
&& l.get(v.len()..)
|
||||
.is_some_and(|rest| match rest.chars().next() {
|
||||
None => true,
|
||||
Some(c) => c == '_' || c.is_ascii_uppercase() || c.is_ascii_digit(),
|
||||
})
|
||||
})
|
||||
};
|
||||
// Order matters: WRITE before READ so `setex`-style verbs with a
|
||||
// read-looking suffix do not misclassify; READ_WRITE checked first
|
||||
// because `execute` would otherwise never match.
|
||||
if has_prefix(READ_WRITE) {
|
||||
AccessMode::ReadWrite
|
||||
} else if has_prefix(WRITE) {
|
||||
AccessMode::Write
|
||||
} else if has_prefix(READ) {
|
||||
AccessMode::Read
|
||||
} else {
|
||||
AccessMode::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Last segment of a callee text after the final `.` or `::`.
|
||||
fn leaf_segment(name: &str) -> &str {
|
||||
let after_colon = name.rsplit("::").next().unwrap_or(name);
|
||||
|
|
@ -422,15 +535,14 @@ fn match_rule(callee: &str) -> Option<&'static DriverRule> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Source location of a call site. Reads the 1-based `(line, col)`
|
||||
/// recorded on the [`CalleeSite`] at CFG-build time (populated for every
|
||||
/// summary produced after the span field landed); for legacy summaries
|
||||
/// loaded from SQLite with no span, falls back to the function's host
|
||||
/// file with line 0.
|
||||
fn call_site_location(summary: &FuncSummary, callee: &CalleeSite) -> SourceLocation {
|
||||
let (line, col) = callee.span.unwrap_or((0, 0));
|
||||
/// Source location of a call site in the project-relative `file`. Reads
|
||||
/// the 1-based `(line, col)` recorded on the [`CalleeSite`] at CFG-build
|
||||
/// time when `span` is `Some`; for legacy summaries loaded from SQLite
|
||||
/// with no span (and the cap-driven fallback path) falls back to line 0.
|
||||
fn call_site_location(file: &str, span: Option<(u32, u32)>) -> SourceLocation {
|
||||
let (line, col) = span.unwrap_or((0, 0));
|
||||
SourceLocation {
|
||||
file: summary.file_path.clone(),
|
||||
file: file.to_string(),
|
||||
line,
|
||||
col,
|
||||
}
|
||||
|
|
@ -439,6 +551,7 @@ fn call_site_location(summary: &FuncSummary, callee: &CalleeSite) -> SourceLocat
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::summary::{CalleeSite, FuncSummary};
|
||||
use crate::symbol::{FuncKey, Lang};
|
||||
|
||||
fn summary_with_callees(name: &str, file: &str, callees: &[&str]) -> (FuncKey, FuncSummary) {
|
||||
|
|
@ -457,6 +570,49 @@ mod tests {
|
|||
(key, summary)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_access_verb_boundaries() {
|
||||
assert_eq!(classify_access("findOne"), AccessMode::Read);
|
||||
assert_eq!(classify_access("find_by_id"), AccessMode::Read);
|
||||
assert_eq!(classify_access("get"), AccessMode::Read);
|
||||
assert_eq!(classify_access("insertMany"), AccessMode::Write);
|
||||
assert_eq!(classify_access("save"), AccessMode::Write);
|
||||
assert_eq!(classify_access("deleteOne"), AccessMode::Write);
|
||||
assert_eq!(classify_access("execute"), AccessMode::ReadWrite);
|
||||
assert_eq!(classify_access("executemany"), AccessMode::ReadWrite);
|
||||
assert_eq!(classify_access("Exec"), AccessMode::ReadWrite);
|
||||
// Boundary safety: a lowercase continuation is NOT a verb match.
|
||||
assert_eq!(classify_access("settings"), AccessMode::Unknown);
|
||||
assert_eq!(classify_access("allocate"), AccessMode::Unknown);
|
||||
assert_eq!(classify_access("connect"), AccessMode::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_store_carries_access_mode() {
|
||||
// `connect`-style driver match → Unknown access; the node still
|
||||
// surfaces and reachability treats it as a conservative read.
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let (key, summary) = summary_with_callees("init", "db.py", &["psycopg2.connect"]);
|
||||
gs.insert(key, summary);
|
||||
let nodes = detect_data_stores(&gs);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
let SurfaceNode::DataStore(ds) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(ds.access, AccessMode::Unknown);
|
||||
|
||||
// `pool.query` driver match → leaf `query` classifies as Read.
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let (key, summary) = summary_with_callees("run", "db.js", &["pool.query"]);
|
||||
gs.insert(key, summary);
|
||||
let nodes = detect_data_stores(&gs);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
let SurfaceNode::DataStore(ds) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(ds.access, AccessMode::Read);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datastore_carries_callee_span_when_present() {
|
||||
// When the CFG populates `CalleeSite.span`, the detected datastore
|
||||
|
|
@ -484,6 +640,56 @@ mod tests {
|
|||
assert_eq!(ds.location.col, 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_fallback_emits_sql_store_with_owner() {
|
||||
// A custom DAO wrapper: no callee matches DRIVER_RULES, but the
|
||||
// function's own sink_caps carry SQL_QUERY. The cap-driven fallback
|
||||
// surfaces a generic Sql node carrying the owning function name.
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "dao.py", "run_query", None);
|
||||
let summary = FuncSummary {
|
||||
name: "run_query".into(),
|
||||
file_path: "dao.py".into(),
|
||||
lang: "python".into(),
|
||||
sink_caps: Cap::SQL_QUERY.bits(),
|
||||
callees: vec![CalleeSite::bare("self._exec")],
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let nodes = detect_data_stores(&gs);
|
||||
assert_eq!(nodes.len(), 1, "got {nodes:?}");
|
||||
let SurfaceNode::DataStore(ds) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(ds.kind, DataStoreKind::Sql);
|
||||
assert_eq!(ds.label, "SQL query");
|
||||
assert_eq!(ds.owner, "run_query");
|
||||
assert_eq!(ds.location.file, "dao.py");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_driver_suppresses_cap_fallback() {
|
||||
// When a named driver call already fired, the precise label wins and
|
||||
// the generic cap fallback does not double-emit.
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "dao.py", "init", None);
|
||||
let summary = FuncSummary {
|
||||
name: "init".into(),
|
||||
file_path: "dao.py".into(),
|
||||
lang: "python".into(),
|
||||
sink_caps: Cap::SQL_QUERY.bits(),
|
||||
callees: vec![CalleeSite::bare("psycopg2.connect")],
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let nodes = detect_data_stores(&gs);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
let SurfaceNode::DataStore(ds) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(ds.label, "PostgreSQL (psycopg2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_psycopg2_connect() {
|
||||
let mut gs = GlobalSummaries::new();
|
||||
|
|
|
|||
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`] 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::edges::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());
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,9 @@
|
|||
//! consulted so a probe with no SSRF cap (DNS resolver, SMTP sender)
|
||||
//! still surfaces as an external service.
|
||||
|
||||
use super::{ExternalService, ExternalServiceKind, SourceLocation, SurfaceNode};
|
||||
use super::{ExternalService, ExternalServiceKind, SourceLocation, SurfaceNode, namespace_file};
|
||||
use crate::labels::Cap;
|
||||
use crate::summary::{CalleeSite, FuncSummary, GlobalSummaries};
|
||||
use crate::summary::GlobalSummaries;
|
||||
|
||||
struct ClientRule {
|
||||
leaf: &'static str,
|
||||
|
|
@ -337,9 +337,15 @@ pub fn detect_external_services(summaries: &GlobalSummaries) -> Vec<SurfaceNode>
|
|||
let mut out: Vec<SurfaceNode> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
|
||||
for (key, summary) in summaries.iter() {
|
||||
// Project-relative POSIX file, keyed off the FuncKey namespace so an
|
||||
// external-service node and the entry-point that reaches it agree on
|
||||
// file identity (FuncSummary.file_path is an absolute path).
|
||||
let file = namespace_file(&key.namespace).to_string();
|
||||
let owner = key.qualified_name();
|
||||
let typed = summaries
|
||||
.get_ssa(key)
|
||||
.map(|s| s.typed_call_receivers.as_slice());
|
||||
let mut matched_for_fn = false;
|
||||
for callee in &summary.callees {
|
||||
let rule = match_rule(&callee.name).or_else(|| {
|
||||
typed
|
||||
|
|
@ -347,7 +353,8 @@ pub fn detect_external_services(summaries: &GlobalSummaries) -> Vec<SurfaceNode>
|
|||
.and_then(|c| match_rule(&qualify(c, &callee.name)))
|
||||
});
|
||||
let Some(rule) = rule else { continue };
|
||||
let location = call_site_location(summary, Some(callee));
|
||||
matched_for_fn = true;
|
||||
let location = call_site_location(&file, callee.span);
|
||||
if !seen.insert((location.file.clone(), rule.label.to_string())) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -355,22 +362,35 @@ pub fn detect_external_services(summaries: &GlobalSummaries) -> Vec<SurfaceNode>
|
|||
location,
|
||||
kind: rule.kind,
|
||||
label: rule.label.to_string(),
|
||||
owner: owner.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Also surface any function whose own sink_caps include SSRF — the
|
||||
// function itself is an outbound network call site even if the
|
||||
// direct callee did not match the rule list. Use the function's
|
||||
// file as the location and synthesise a generic label.
|
||||
for (_key, summary) in summaries.iter() {
|
||||
if summary.sink_caps().contains(Cap::SSRF) {
|
||||
let loc = call_site_location(summary, None);
|
||||
let dedup = (loc.file.clone(), "Outbound HTTP".to_string());
|
||||
|
||||
// Cap-driven fallback: a function whose own sink_caps include SSRF
|
||||
// (outbound request) or DATA_EXFIL (data leaving the system) is an
|
||||
// egress site even when the direct callee did not match the rule
|
||||
// list. Skipped when a named client already fired for this function
|
||||
// so the precise label wins and the generic node does not
|
||||
// double-count the same egress.
|
||||
if matched_for_fn {
|
||||
continue;
|
||||
}
|
||||
let caps = summary.sink_caps();
|
||||
let fallback = if caps.contains(Cap::SSRF) {
|
||||
Some(("Outbound HTTP", ExternalServiceKind::HttpApi))
|
||||
} else if caps.contains(Cap::DATA_EXFIL) {
|
||||
Some(("Data egress", ExternalServiceKind::Unknown))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((label, kind)) = fallback {
|
||||
let dedup = (file.clone(), label.to_string());
|
||||
if seen.insert(dedup) {
|
||||
out.push(SurfaceNode::ExternalService(ExternalService {
|
||||
location: loc,
|
||||
kind: ExternalServiceKind::HttpApi,
|
||||
label: "Outbound HTTP".to_string(),
|
||||
location: call_site_location(&file, None),
|
||||
kind,
|
||||
label: label.to_string(),
|
||||
owner: owner.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -410,14 +430,15 @@ fn match_rule(callee: &str) -> Option<&'static ClientRule> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Source location of an external-service call site. Reads the 1-based
|
||||
/// `(line, col)` recorded on the [`CalleeSite`] at CFG-build time when
|
||||
/// available; otherwise (sink-cap–only fallback path, or legacy summaries
|
||||
/// loaded from SQLite) returns the function's host file with line 0.
|
||||
fn call_site_location(summary: &FuncSummary, callee: Option<&CalleeSite>) -> SourceLocation {
|
||||
let (line, col) = callee.and_then(|c| c.span).unwrap_or((0, 0));
|
||||
/// Source location of an external-service call site in the
|
||||
/// project-relative `file`. Reads the 1-based `(line, col)` recorded on
|
||||
/// the [`crate::summary::CalleeSite`] at CFG-build time when `span` is
|
||||
/// `Some`; otherwise (sink-cap–only fallback path, or legacy summaries
|
||||
/// loaded from SQLite) returns the file with line 0.
|
||||
fn call_site_location(file: &str, span: Option<(u32, u32)>) -> SourceLocation {
|
||||
let (line, col) = span.unwrap_or((0, 0));
|
||||
SourceLocation {
|
||||
file: summary.file_path.clone(),
|
||||
file: file.to_string(),
|
||||
line,
|
||||
col,
|
||||
}
|
||||
|
|
@ -426,7 +447,7 @@ fn call_site_location(summary: &FuncSummary, callee: Option<&CalleeSite>) -> Sou
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::summary::CalleeSite;
|
||||
use crate::summary::{CalleeSite, FuncSummary};
|
||||
use crate::symbol::{FuncKey, Lang};
|
||||
|
||||
#[test]
|
||||
|
|
@ -450,6 +471,48 @@ mod tests {
|
|||
assert_eq!(es.label, "requests (Python)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_cap_fallback_carries_owner() {
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "proxy.py", "forward", None);
|
||||
let summary = FuncSummary {
|
||||
name: "forward".into(),
|
||||
file_path: "/abs/proxy.py".into(),
|
||||
lang: "python".into(),
|
||||
sink_caps: Cap::SSRF.bits(),
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let nodes = detect_external_services(&gs);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
let SurfaceNode::ExternalService(es) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(es.label, "Outbound HTTP");
|
||||
assert_eq!(es.owner, "forward");
|
||||
assert_eq!(es.location.file, "proxy.py");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_exfil_cap_emits_egress_node() {
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "leak.py", "dump", None);
|
||||
let summary = FuncSummary {
|
||||
name: "dump".into(),
|
||||
file_path: "leak.py".into(),
|
||||
lang: "python".into(),
|
||||
sink_caps: Cap::DATA_EXFIL.bits(),
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let nodes = detect_external_services(&gs);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
let SurfaceNode::ExternalService(es) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(es.label, "Data egress");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_fetch_rule_does_not_match_prefetch_or_cachekey() {
|
||||
let mut gs = GlobalSummaries::new();
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ use std::path::Path;
|
|||
pub mod build;
|
||||
pub mod dangerous;
|
||||
pub mod datastore;
|
||||
pub mod exposure;
|
||||
pub mod external;
|
||||
pub mod graph;
|
||||
pub mod lang;
|
||||
pub mod reachability;
|
||||
pub mod risk;
|
||||
|
||||
/// Stable source location used as the primary key for every
|
||||
/// [`SurfaceNode`]. `file` is a project-relative POSIX path so the
|
||||
|
|
@ -85,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,
|
||||
|
|
@ -109,6 +115,53 @@ pub struct DataStore {
|
|||
pub location: SourceLocation,
|
||||
pub kind: DataStoreKind,
|
||||
pub label: String,
|
||||
/// Qualified name of the function that owns this access site
|
||||
/// (`Class::method` or a free function name). Used by reachability
|
||||
/// to connect an entry-point to this store only when the owning
|
||||
/// function is actually on the call-graph frontier, rather than the
|
||||
/// coarse "any node in the same file" match. Empty for legacy maps
|
||||
/// loaded from SQLite before the field landed.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub owner: String,
|
||||
/// Whether the access site reads, writes, or does both, classified
|
||||
/// from the callee name at detection time (`find`/`get`/`select` →
|
||||
/// read, `insert`/`save`/`delete` → write, `execute`/`exec` →
|
||||
/// read-write). Drives the [`EdgeKind::ReadsFrom`] /
|
||||
/// [`EdgeKind::WritesTo`] split in reachability. `Unknown` for
|
||||
/// connect-style sites and legacy maps loaded from SQLite before
|
||||
/// the field landed.
|
||||
#[serde(default, skip_serializing_if = "AccessMode::is_unknown")]
|
||||
pub access: AccessMode,
|
||||
}
|
||||
|
||||
/// Direction of a data-store access site.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccessMode {
|
||||
Read,
|
||||
Write,
|
||||
ReadWrite,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl AccessMode {
|
||||
/// Serde helper: `Unknown` is the default and is omitted from the
|
||||
/// canonical JSON so legacy payloads stay byte-identical.
|
||||
pub fn is_unknown(&self) -> bool {
|
||||
matches!(self, AccessMode::Unknown)
|
||||
}
|
||||
|
||||
/// True when the site can write (Write or ReadWrite).
|
||||
pub fn writes(self) -> bool {
|
||||
matches!(self, AccessMode::Write | AccessMode::ReadWrite)
|
||||
}
|
||||
|
||||
/// True when the site can read (Read, ReadWrite, or Unknown — an
|
||||
/// unclassified site is conservatively treated as a read).
|
||||
pub fn reads(self) -> bool {
|
||||
!matches!(self, AccessMode::Write)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
|
|
@ -130,6 +183,10 @@ pub struct ExternalService {
|
|||
pub location: SourceLocation,
|
||||
pub kind: ExternalServiceKind,
|
||||
pub label: String,
|
||||
/// Qualified name of the function that owns this egress site. See
|
||||
/// [`DataStore::owner`] for why reachability needs it.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
|
|
@ -151,6 +208,13 @@ pub struct DangerousLocal {
|
|||
pub location: SourceLocation,
|
||||
pub function_name: String,
|
||||
pub cap_bits: u32,
|
||||
/// Human-readable sink-class label decoded from `cap_bits`
|
||||
/// (e.g. `"code-exec"`, `"deserialize, ssti"`). Lets the CLI and
|
||||
/// the chain composer name the danger without re-deriving it from
|
||||
/// the raw bitfield. Empty for legacy maps loaded from SQLite
|
||||
/// before the field landed.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// A node in the [`SurfaceMap`]. Every variant carries a
|
||||
|
|
@ -201,36 +265,109 @@ impl SurfaceNode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Semantic kind of an edge in the [`SurfaceMap`]. Encodes the
|
||||
/// seven edge classes the chain composer walks; persistence is via
|
||||
/// JSON so adding a variant is a non-breaking schema change as long
|
||||
/// as the SQLite-level migration drops the old surface_map rows.
|
||||
/// Semantic kind of an edge in the [`SurfaceMap`].
|
||||
///
|
||||
/// Persistence is via JSON so adding a variant is a non-breaking schema
|
||||
/// change as long as the SQLite-level migration drops the old
|
||||
/// surface_map rows.
|
||||
///
|
||||
/// Emission status (kept honest so the next maintainer does not inherit
|
||||
/// a false mental model):
|
||||
///
|
||||
/// * **Emitted today** by [`reachability::populate_reaches_edges`]:
|
||||
/// [`EdgeKind::ReadsFrom`] (entry → data store the entry reads),
|
||||
/// [`EdgeKind::WritesTo`] (entry → data store the entry writes,
|
||||
/// from [`DataStore::access`]), [`EdgeKind::TalksTo`] (entry →
|
||||
/// external service), and [`EdgeKind::Reaches`] (entry →
|
||||
/// dangerous-local sink). These four are [`EdgeKind::is_reach_like`].
|
||||
/// * **Reserved** (no production construction site yet):
|
||||
/// [`EdgeKind::Calls`] (would lift call-graph edges, currently
|
||||
/// redundant with the [`crate::callgraph::CallGraph`] itself),
|
||||
/// [`EdgeKind::Triggers`] (needs job/webhook entry modelling), and
|
||||
/// [`EdgeKind::AuthRequiredOn`] (needs a dedicated auth-check node
|
||||
/// to originate from — today the auth signal rides on
|
||||
/// [`EntryPoint::auth_required`] instead).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EdgeKind {
|
||||
/// Caller → callee. Wraps the call-graph edge so consumers do
|
||||
/// not have to consult [`crate::callgraph::CallGraph`] directly.
|
||||
/// Reserved — not emitted.
|
||||
Calls,
|
||||
/// Function or entry-point reads from a data store / external
|
||||
/// service.
|
||||
/// Entry-point reads from a data store. Emitted by reachability.
|
||||
ReadsFrom,
|
||||
/// Function or entry-point writes to a data store.
|
||||
/// Entry-point writes to a data store. Emitted by reachability
|
||||
/// when [`DataStore::access`] classifies the site as writing.
|
||||
WritesTo,
|
||||
/// Function or entry-point sends a request to an external
|
||||
/// service.
|
||||
/// Entry-point sends a request to an external service. Emitted by
|
||||
/// reachability.
|
||||
TalksTo,
|
||||
/// Entry-point reaches a dangerous-local sink through some
|
||||
/// transitive call chain.
|
||||
/// transitive call chain. Emitted by reachability.
|
||||
Reaches,
|
||||
/// Entry-point triggers a side-effecting action (job, email,
|
||||
/// webhook) other than a direct call.
|
||||
/// webhook) other than a direct call. Reserved.
|
||||
Triggers,
|
||||
/// Entry-point gates downstream access on a successful auth
|
||||
/// check. The `from` is the auth-check node, the `to` is the
|
||||
/// entry-point.
|
||||
/// entry-point. Reserved — needs an auth-check node.
|
||||
AuthRequiredOn,
|
||||
}
|
||||
|
||||
impl EdgeKind {
|
||||
/// True for the edge classes that connect an entry-point to a
|
||||
/// reachable sink / store / external service. The CLI tree and any
|
||||
/// "what does this entry reach" query treat all three uniformly.
|
||||
pub fn is_reach_like(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
EdgeKind::Reaches | EdgeKind::ReadsFrom | EdgeKind::TalksTo | EdgeKind::WritesTo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a [`crate::labels::Cap`] bitfield into a stable, human-readable
|
||||
/// list of sink-class slugs (e.g. `0x400` → `["code-exec"]`). Order is
|
||||
/// fixed (low bit first) so two equal bitfields render identically.
|
||||
/// Used for [`DangerousLocal::label`] and the `nyx surface` CLI so the
|
||||
/// raw `0x{:x}` debug dump never reaches a user.
|
||||
pub fn cap_labels(bits: u32) -> Vec<&'static str> {
|
||||
use crate::labels::Cap;
|
||||
const TABLE: &[(Cap, &str)] = &[
|
||||
(Cap::CODE_EXEC, "code-exec"),
|
||||
(Cap::DESERIALIZE, "deserialize"),
|
||||
(Cap::SSTI, "ssti"),
|
||||
(Cap::FMT_STRING, "format-string"),
|
||||
(Cap::SQL_QUERY, "sql"),
|
||||
(Cap::SSRF, "ssrf"),
|
||||
(Cap::FILE_IO, "file-io"),
|
||||
(Cap::LDAP_INJECTION, "ldap-injection"),
|
||||
(Cap::XPATH_INJECTION, "xpath-injection"),
|
||||
(Cap::HEADER_INJECTION, "header-injection"),
|
||||
(Cap::OPEN_REDIRECT, "open-redirect"),
|
||||
(Cap::XXE, "xxe"),
|
||||
(Cap::PROTOTYPE_POLLUTION, "prototype-pollution"),
|
||||
(Cap::CRYPTO, "weak-crypto"),
|
||||
(Cap::DATA_EXFIL, "data-exfil"),
|
||||
(Cap::UNAUTHORIZED_ID, "unauthorized-id"),
|
||||
];
|
||||
let caps = Cap::from_bits_truncate(bits);
|
||||
let mut out: Vec<&'static str> = TABLE
|
||||
.iter()
|
||||
.filter(|(c, _)| caps.contains(*c))
|
||||
.map(|(_, s)| *s)
|
||||
.collect();
|
||||
if out.is_empty() {
|
||||
out.push("sink");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Comma-joined form of [`cap_labels`].
|
||||
pub fn cap_label_string(bits: u32) -> String {
|
||||
cap_labels(bits).join(", ")
|
||||
}
|
||||
|
||||
/// A single edge in the [`SurfaceMap`]. `from` and `to` are indices
|
||||
/// into [`SurfaceMap::nodes`]; the surface ordering keeps these
|
||||
/// stable across rescans.
|
||||
|
|
@ -337,6 +474,21 @@ impl SurfaceMap {
|
|||
}
|
||||
}
|
||||
|
||||
/// Strip the optional `@pkg/name::` package prefix from a [`crate::symbol::FuncKey`]
|
||||
/// namespace, returning the project-relative POSIX file path part.
|
||||
///
|
||||
/// `namespace_with_package` produces `"@scope/name::src/file.ts"` for
|
||||
/// JS/TS files inside resolved packages; the file part is the
|
||||
/// project-relative path that matches an [`EntryPoint`]'s
|
||||
/// `handler_location.file`. This is the single source of truth the
|
||||
/// detectors and the reachability pass both key on, so a data-store /
|
||||
/// external / dangerous-local node and the entry-point that reaches it
|
||||
/// agree on file identity even though `FuncSummary.file_path` is stored
|
||||
/// as an absolute path.
|
||||
pub fn namespace_file(ns: &str) -> &str {
|
||||
ns.rsplit_once("::").map(|(_, rest)| rest).unwrap_or(ns)
|
||||
}
|
||||
|
||||
/// Convert an absolute path to a project-relative POSIX path string.
|
||||
/// Returns the absolute path verbatim when the file is outside the
|
||||
/// scan root or when path stripping fails.
|
||||
|
|
|
|||
|
|
@ -19,21 +19,49 @@
|
|||
//! calls `eval()` will surface the eval as a `Reaches` of the entry
|
||||
//! point as long as the eval's host file is on the BFS frontier.
|
||||
|
||||
use super::{EdgeKind, SurfaceEdge, SurfaceMap, SurfaceNode};
|
||||
use super::{EdgeKind, SurfaceEdge, SurfaceMap, SurfaceNode, namespace_file};
|
||||
use crate::callgraph::CallGraph;
|
||||
use crate::summary::GlobalSummaries;
|
||||
use petgraph::Direction;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
/// Maximum BFS depth from an entry-point node. Surface chains beyond
|
||||
/// six call-graph hops are rare in practice and the cost of a deeper
|
||||
/// eight call-graph hops are rare in practice and the cost of a deeper
|
||||
/// walk is paid per entry-point per scan. A depth-bounded traversal
|
||||
/// also prevents recursive cycles from blowing up.
|
||||
const MAX_BFS_DEPTH: usize = 8;
|
||||
|
||||
/// Populate [`EdgeKind::Reaches`] edges on `map`. Mutates the edge
|
||||
/// list in place; the caller is expected to follow up with
|
||||
/// [`SurfaceMap::canonicalize`] before serialisation.
|
||||
/// One reachable destination node, keyed for **function-level** matching.
|
||||
struct Dest {
|
||||
idx: usize,
|
||||
/// Project-relative POSIX file the destination lives in.
|
||||
file: String,
|
||||
/// Qualified name (`Class::method` / free function) of the function
|
||||
/// that owns this destination. Empty only for legacy maps loaded
|
||||
/// from SQLite before the `owner` field landed — those fall back to
|
||||
/// file-level matching.
|
||||
owner: String,
|
||||
/// Edge classes to emit when an entry-point reaches this destination:
|
||||
/// [`EdgeKind::ReadsFrom`] / [`EdgeKind::WritesTo`] for a data store
|
||||
/// (driven by [`crate::surface::DataStore::access`]; a read-write
|
||||
/// site emits both), [`EdgeKind::TalksTo`] for an external service,
|
||||
/// [`EdgeKind::Reaches`] for a dangerous local sink.
|
||||
edges: smallvec::SmallVec<[EdgeKind; 2]>,
|
||||
}
|
||||
|
||||
/// Populate entry-point → sink reachability edges on `map`
|
||||
/// ([`EdgeKind::ReadsFrom`] / [`EdgeKind::TalksTo`] / [`EdgeKind::Reaches`]).
|
||||
/// Mutates the edge list in place; the caller is expected to follow up
|
||||
/// with [`SurfaceMap::canonicalize`] before serialisation.
|
||||
///
|
||||
/// Matching is **function-level** when the entry-point's handler resolves
|
||||
/// to a call-graph node: a destination is connected only when the
|
||||
/// function that owns it is actually on the forward BFS frontier from the
|
||||
/// handler, so two unrelated handlers in the same file no longer both
|
||||
/// "reach" a co-located `eval()`. When the handler cannot be resolved in
|
||||
/// the call graph (anonymous closure handler, unresolved seed) the pass
|
||||
/// falls back to the conservative same-file heuristic so connectivity is
|
||||
/// not silently lost.
|
||||
pub fn populate_reaches_edges(
|
||||
map: &mut SurfaceMap,
|
||||
summaries: &GlobalSummaries,
|
||||
|
|
@ -53,40 +81,42 @@ pub fn populate_reaches_edges(
|
|||
let SurfaceNode::EntryPoint(ep) = node else {
|
||||
continue;
|
||||
};
|
||||
let mut reachable_files: HashSet<String> = HashSet::new();
|
||||
// Seed with the handler's host file — the entry-point itself
|
||||
// counts as reachable, so any DataStore / ExternalService /
|
||||
// DangerousLocal in the same file is connected even when the
|
||||
// call graph cannot resolve the seed FuncKey.
|
||||
reachable_files.insert(ep.handler_location.file.clone());
|
||||
|
||||
// Locate seed FuncKeys whose `namespace` (project-relative
|
||||
// POSIX path, optionally prefixed with `@pkg/name::`) matches
|
||||
// the entry's file and whose `name` matches the handler. More
|
||||
// than one seed is possible (overloaded methods, duplicate
|
||||
// definitions).
|
||||
//
|
||||
// Phase 23 follow-up: this used to be an `ends_with` substring
|
||||
// check on both sides, which silently aliased same-basename
|
||||
// files in sibling directories — `subdir/app.py` and
|
||||
// `other/app.py` would both seed when the entry-point pointed
|
||||
// at `app.py`. We now compare the file part exactly so a
|
||||
// handler in `subdir/app.py` only seeds the FuncKey whose
|
||||
// namespace strips to `subdir/app.py`.
|
||||
let seeds = call_graph
|
||||
.index
|
||||
.iter()
|
||||
.filter(|(k, _)| k.name == ep.handler_name)
|
||||
.filter(|(k, _)| file_part_of_namespace(&k.namespace) == ep.handler_location.file)
|
||||
.map(|(_, idx)| *idx)
|
||||
.collect::<Vec<_>>();
|
||||
// Locate seed FuncKeys whose namespace file-part matches the
|
||||
// entry's handler file and whose `name` matches the handler.
|
||||
// More than one seed is possible (overloads, duplicate defs).
|
||||
// Anonymous handlers (empty name) match nothing — handled by the
|
||||
// unresolved fallback below.
|
||||
let seeds = if ep.handler_name.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
call_graph
|
||||
.index
|
||||
.iter()
|
||||
.filter(|(k, _)| k.name == ep.handler_name)
|
||||
.filter(|(k, _)| namespace_file(&k.namespace) == ep.handler_location.file)
|
||||
.map(|(_, idx)| *idx)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let seed_found = !seeds.is_empty();
|
||||
|
||||
// Forward BFS over the call graph, collecting the set of reachable
|
||||
// owner functions as `(file, qualified_name)` keys. Inserting the
|
||||
// *file part* of the namespace (not the raw `@pkg::path` namespace)
|
||||
// fixes the prior bug where packaged JS/TS namespaces never matched
|
||||
// a destination's bare file, silently killing all transitive reach.
|
||||
let mut reachable_fns: HashSet<(String, String)> = HashSet::new();
|
||||
let mut reachable_files: HashSet<String> = HashSet::new();
|
||||
reachable_files.insert(ep.handler_location.file.clone());
|
||||
|
||||
let mut visited: HashSet<_> = seeds.iter().copied().collect();
|
||||
let mut queue: VecDeque<(petgraph::graph::NodeIndex, usize)> =
|
||||
seeds.iter().map(|n| (*n, 0)).collect();
|
||||
while let Some((node_idx, depth)) = queue.pop_front() {
|
||||
if let Some(key) = call_graph.graph.node_weight(node_idx) {
|
||||
reachable_files.insert(key.namespace.clone());
|
||||
let file = namespace_file(&key.namespace).to_string();
|
||||
reachable_fns.insert((file.clone(), key.qualified_name()));
|
||||
reachable_files.insert(file);
|
||||
}
|
||||
if depth >= MAX_BFS_DEPTH {
|
||||
continue;
|
||||
|
|
@ -101,13 +131,24 @@ pub fn populate_reaches_edges(
|
|||
}
|
||||
}
|
||||
|
||||
for (dst_idx, dst_file) in &dst_index {
|
||||
if reachable_files.contains(dst_file) {
|
||||
new_edges.insert(SurfaceEdge {
|
||||
from: entry_idx as u32,
|
||||
to: *dst_idx as u32,
|
||||
kind: EdgeKind::Reaches,
|
||||
});
|
||||
for d in &dst_index {
|
||||
let reached = if seed_found && !d.owner.is_empty() {
|
||||
// Precise: the owning function must be on the BFS frontier.
|
||||
reachable_fns.contains(&(d.file.clone(), d.owner.clone()))
|
||||
} else {
|
||||
// Unresolved seed, or a legacy destination with no owner:
|
||||
// conservative same-file fallback (preserves connectivity
|
||||
// when the call graph cannot resolve the handler).
|
||||
reachable_files.contains(&d.file)
|
||||
};
|
||||
if reached {
|
||||
for kind in &d.edges {
|
||||
new_edges.insert(SurfaceEdge {
|
||||
from: entry_idx as u32,
|
||||
to: d.idx as u32,
|
||||
kind: *kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,27 +156,40 @@ pub fn populate_reaches_edges(
|
|||
map.edges.extend(new_edges);
|
||||
}
|
||||
|
||||
/// Strip the optional `@pkg/name::` package prefix from a `FuncKey`
|
||||
/// namespace, returning the project-relative POSIX file path part.
|
||||
/// `namespace_with_package` produces `"@scope/name::src/file.ts"` for
|
||||
/// JS/TS files inside resolved packages; the file part is what
|
||||
/// matches an entry-point's `handler_location.file`.
|
||||
fn file_part_of_namespace(ns: &str) -> &str {
|
||||
ns.rsplit_once("::").map(|(_, rest)| rest).unwrap_or(ns)
|
||||
}
|
||||
|
||||
/// Build a lookup from destination node index → destination file.
|
||||
/// Restricted to the three reachable-from-entry-point variants.
|
||||
fn build_destination_index(map: &SurfaceMap) -> Vec<(usize, String)> {
|
||||
let mut out: Vec<(usize, String)> = Vec::new();
|
||||
/// Build the destination index: every non-entry-point node tagged with
|
||||
/// its file, owning function, and the edge class to emit.
|
||||
fn build_destination_index(map: &SurfaceMap) -> Vec<Dest> {
|
||||
let mut out: Vec<Dest> = Vec::new();
|
||||
for (idx, node) in map.nodes.iter().enumerate() {
|
||||
let file = match node {
|
||||
SurfaceNode::DataStore(n) => n.location.file.clone(),
|
||||
SurfaceNode::ExternalService(n) => n.location.file.clone(),
|
||||
SurfaceNode::DangerousLocal(n) => n.location.file.clone(),
|
||||
let (file, owner, edges) = match node {
|
||||
SurfaceNode::DataStore(n) => {
|
||||
let mut edges: smallvec::SmallVec<[EdgeKind; 2]> = smallvec::SmallVec::new();
|
||||
if n.access.reads() {
|
||||
edges.push(EdgeKind::ReadsFrom);
|
||||
}
|
||||
if n.access.writes() {
|
||||
edges.push(EdgeKind::WritesTo);
|
||||
}
|
||||
(n.location.file.clone(), n.owner.clone(), edges)
|
||||
}
|
||||
SurfaceNode::ExternalService(n) => (
|
||||
n.location.file.clone(),
|
||||
n.owner.clone(),
|
||||
smallvec::smallvec![EdgeKind::TalksTo],
|
||||
),
|
||||
SurfaceNode::DangerousLocal(n) => (
|
||||
n.location.file.clone(),
|
||||
n.function_name.clone(),
|
||||
smallvec::smallvec![EdgeKind::Reaches],
|
||||
),
|
||||
SurfaceNode::EntryPoint(_) => continue,
|
||||
};
|
||||
out.push((idx, file));
|
||||
out.push(Dest {
|
||||
idx,
|
||||
file,
|
||||
owner,
|
||||
edges,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -164,7 +218,8 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::entry_points::HttpMethod;
|
||||
use crate::surface::{
|
||||
DangerousLocal, EntryPoint, Framework, SourceLocation, SurfaceMap, SurfaceNode,
|
||||
DangerousLocal, DataStore, DataStoreKind, EntryPoint, ExternalService, ExternalServiceKind,
|
||||
Framework, SourceLocation, SurfaceMap, SurfaceNode,
|
||||
};
|
||||
|
||||
fn ep(file: &str, handler: &str) -> SurfaceNode {
|
||||
|
|
@ -184,6 +239,7 @@ mod tests {
|
|||
location: SourceLocation::new(file, 0, 0),
|
||||
function_name: name.into(),
|
||||
cap_bits: 0x1,
|
||||
label: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -207,14 +263,179 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn file_part_of_namespace_strips_package_prefix() {
|
||||
assert_eq!(file_part_of_namespace("app.py"), "app.py");
|
||||
assert_eq!(file_part_of_namespace("src/main.rs"), "src/main.rs");
|
||||
assert_eq!(
|
||||
file_part_of_namespace("@scope/name::src/file.ts"),
|
||||
"src/file.ts"
|
||||
fn emits_typed_edges_for_store_and_external() {
|
||||
// A data store yields ReadsFrom, an external service yields TalksTo
|
||||
// (Reaches is reserved for dangerous-local sinks). Uses the
|
||||
// unresolved-seed same-file fallback (empty call graph).
|
||||
let mut map = SurfaceMap::new();
|
||||
map.nodes.push(ep("app.py", "handler")); // 0
|
||||
map.nodes.push(SurfaceNode::DataStore(DataStore {
|
||||
location: SourceLocation::new("app.py", 4, 1),
|
||||
kind: DataStoreKind::Sql,
|
||||
label: "PostgreSQL".into(),
|
||||
owner: "handler".into(),
|
||||
access: Default::default(),
|
||||
})); // 1
|
||||
map.nodes
|
||||
.push(SurfaceNode::ExternalService(ExternalService {
|
||||
location: SourceLocation::new("app.py", 6, 1),
|
||||
kind: ExternalServiceKind::HttpApi,
|
||||
label: "requests".into(),
|
||||
owner: "handler".into(),
|
||||
})); // 2
|
||||
let gs = GlobalSummaries::new();
|
||||
let cg = CallGraph {
|
||||
graph: petgraph::graph::DiGraph::new(),
|
||||
index: Default::default(),
|
||||
unresolved_not_found: vec![],
|
||||
unresolved_ambiguous: vec![],
|
||||
};
|
||||
populate_reaches_edges(&mut map, &gs, &cg);
|
||||
assert!(
|
||||
map.edges
|
||||
.iter()
|
||||
.any(|e| e.kind == EdgeKind::ReadsFrom && e.to == 1)
|
||||
);
|
||||
assert!(
|
||||
map.edges
|
||||
.iter()
|
||||
.any(|e| e.kind == EdgeKind::TalksTo && e.to == 2)
|
||||
);
|
||||
assert!(map.edges.iter().all(|e| e.kind != EdgeKind::Reaches));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_access_emits_writes_to_edge() {
|
||||
use crate::surface::AccessMode;
|
||||
let mut map = SurfaceMap::new();
|
||||
map.nodes.push(ep("app.py", "handler")); // 0
|
||||
map.nodes.push(SurfaceNode::DataStore(DataStore {
|
||||
location: SourceLocation::new("app.py", 4, 1),
|
||||
kind: DataStoreKind::Sql,
|
||||
label: "PostgreSQL".into(),
|
||||
owner: "handler".into(),
|
||||
access: AccessMode::Write,
|
||||
})); // 1
|
||||
map.nodes.push(SurfaceNode::DataStore(DataStore {
|
||||
location: SourceLocation::new("app.py", 6, 1),
|
||||
kind: DataStoreKind::Sql,
|
||||
label: "PostgreSQL exec".into(),
|
||||
owner: "handler".into(),
|
||||
access: AccessMode::ReadWrite,
|
||||
})); // 2
|
||||
let gs = GlobalSummaries::new();
|
||||
let cg = CallGraph {
|
||||
graph: petgraph::graph::DiGraph::new(),
|
||||
index: Default::default(),
|
||||
unresolved_not_found: vec![],
|
||||
unresolved_ambiguous: vec![],
|
||||
};
|
||||
populate_reaches_edges(&mut map, &gs, &cg);
|
||||
// Write-only store: WritesTo, no ReadsFrom.
|
||||
assert!(
|
||||
map.edges
|
||||
.iter()
|
||||
.any(|e| e.kind == EdgeKind::WritesTo && e.to == 1)
|
||||
);
|
||||
assert!(
|
||||
!map.edges
|
||||
.iter()
|
||||
.any(|e| e.kind == EdgeKind::ReadsFrom && e.to == 1)
|
||||
);
|
||||
// Read-write store: both edges.
|
||||
assert!(
|
||||
map.edges
|
||||
.iter()
|
||||
.any(|e| e.kind == EdgeKind::WritesTo && e.to == 2)
|
||||
);
|
||||
assert!(
|
||||
map.edges
|
||||
.iter()
|
||||
.any(|e| e.kind == EdgeKind::ReadsFrom && e.to == 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_file_strips_package_prefix() {
|
||||
use crate::surface::namespace_file;
|
||||
assert_eq!(namespace_file("app.py"), "app.py");
|
||||
assert_eq!(namespace_file("src/main.rs"), "src/main.rs");
|
||||
assert_eq!(namespace_file("@scope/name::src/file.ts"), "src/file.ts");
|
||||
// Last `::` wins, matching `namespace_with_package`'s shape.
|
||||
assert_eq!(file_part_of_namespace("@a/b::@c/d::lib/x.ts"), "lib/x.ts");
|
||||
assert_eq!(namespace_file("@a/b::@c/d::lib/x.ts"), "lib/x.ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_level_match_skips_unrelated_same_file_sink() {
|
||||
// Two handlers and one dangerous sink live in the same file, but
|
||||
// only `caller` calls `do_eval`. With a resolvable call graph the
|
||||
// unrelated `other` handler must NOT get a Reaches edge — the
|
||||
// file-level heuristic used to connect both.
|
||||
use crate::symbol::{FuncKey, Lang};
|
||||
let mut map = SurfaceMap::new();
|
||||
map.nodes.push(ep("app.py", "caller")); // idx 0
|
||||
map.nodes.push(ep("app.py", "other")); // idx 1
|
||||
// Dangerous sink owned by `do_eval`.
|
||||
map.nodes.push(SurfaceNode::DangerousLocal(DangerousLocal {
|
||||
location: SourceLocation::new("app.py", 12, 1),
|
||||
function_name: "do_eval".into(),
|
||||
cap_bits: 0x1,
|
||||
label: "code-exec".into(),
|
||||
})); // idx 2
|
||||
|
||||
// Call graph: caller -> do_eval ; other is isolated.
|
||||
let mut cg = CallGraph {
|
||||
graph: petgraph::graph::DiGraph::new(),
|
||||
index: Default::default(),
|
||||
unresolved_not_found: vec![],
|
||||
unresolved_ambiguous: vec![],
|
||||
};
|
||||
let caller = cg.graph.add_node(FuncKey::new_function(
|
||||
Lang::Python,
|
||||
"app.py",
|
||||
"caller",
|
||||
None,
|
||||
));
|
||||
let other = cg
|
||||
.graph
|
||||
.add_node(FuncKey::new_function(Lang::Python, "app.py", "other", None));
|
||||
let do_eval = cg.graph.add_node(FuncKey::new_function(
|
||||
Lang::Python,
|
||||
"app.py",
|
||||
"do_eval",
|
||||
None,
|
||||
));
|
||||
cg.graph.add_edge(
|
||||
caller,
|
||||
do_eval,
|
||||
crate::callgraph::CallEdge {
|
||||
call_site: "do_eval".into(),
|
||||
},
|
||||
);
|
||||
cg.index.insert(
|
||||
FuncKey::new_function(Lang::Python, "app.py", "caller", None),
|
||||
caller,
|
||||
);
|
||||
cg.index.insert(
|
||||
FuncKey::new_function(Lang::Python, "app.py", "other", None),
|
||||
other,
|
||||
);
|
||||
cg.index.insert(
|
||||
FuncKey::new_function(Lang::Python, "app.py", "do_eval", None),
|
||||
do_eval,
|
||||
);
|
||||
|
||||
let gs = GlobalSummaries::new();
|
||||
populate_reaches_edges(&mut map, &gs, &cg);
|
||||
// Exactly one Reaches edge: caller(0) -> sink(2). `other`(1) is
|
||||
// excluded by function-level matching.
|
||||
let reaches: Vec<_> = map
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == EdgeKind::Reaches)
|
||||
.collect();
|
||||
assert_eq!(reaches.len(), 1, "got {reaches:?}");
|
||||
assert_eq!(reaches[0].from, 0);
|
||||
assert_eq!(reaches[0].to, 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
376
src/surface/risk.rs
Normal file
376
src/surface/risk.rs
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
//! 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);
|
||||
/// * 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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ fn make_diag(
|
|||
evidence: Some(make_evidence(source_kind, verdict)),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ fn diag_with_caps(path: &str, line: usize, caps: Cap) -> Diag {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ fn fixture_surface_map() -> SurfaceMap {
|
|||
location: loc("app.py", 30),
|
||||
function_name: "shell.exec".into(),
|
||||
cap_bits: Cap::CODE_EXEC.bits(),
|
||||
label: String::new(),
|
||||
}));
|
||||
m
|
||||
}
|
||||
|
|
@ -77,6 +78,7 @@ fn fixture_findings() -> Vec<Diag> {
|
|||
evidence: Some(ev),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -968,6 +968,7 @@ fn make_diag(path: &Path, func: &str, cap: Cap, sink_line: u32) -> Diag {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ fn base_diag() -> Diag {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ fn deny_diag(stable_hash: u64) -> Diag {
|
|||
evidence: Some(ev),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -312,6 +313,7 @@ fn confirmed_run_is_byte_identical_across_runs() {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ mod parity_tests {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ mod verify_e2e {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -111,6 +112,7 @@ mod verify_e2e {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ fn high_confidence_taint_diag(path: &str, line: u32) -> Diag {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -452,6 +452,7 @@ mod go_fixture_tests {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ fn diag(severity: Severity, id: &str, conf: Option<Confidence>) -> Diag {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -450,6 +450,7 @@ mod java_fixture_tests {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -445,6 +445,7 @@ mod js_fixture_tests {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ fn base_diag() -> Diag {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ mod lang_detect {
|
|||
}),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -440,6 +440,7 @@ mod php_fixture_tests {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ fn empty_diag() -> Diag {
|
|||
evidence: Some(Evidence::default()),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -928,6 +928,7 @@ mod python_fixture_tests {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ mod rust_fixture_tests {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -752,6 +752,7 @@ mod hardening_tests {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -947,6 +948,7 @@ mod hardening_tests {
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -647,6 +647,7 @@ finally:
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
@ -787,6 +788,7 @@ finally:
|
|||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ fn base_diag() -> Diag {
|
|||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ fn make_diag(id: &str, path: &str, line: usize) -> Diag {
|
|||
evidence: Some(Evidence::default()),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ mod spec_strategies {
|
|||
evidence: Some(Evidence::default()),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ fn make_diag(path: &str, handler: &str, line: usize, cap: Cap, rule_id: &str) ->
|
|||
evidence: Some(ev),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
exposure: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
triage_state: "open".to_string(),
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ fn load_or_build_falls_back_to_filesystem_when_no_db() {
|
|||
.unwrap();
|
||||
let db_dir = tempfile::tempdir().unwrap();
|
||||
let cfg = Config::default();
|
||||
let map = load_or_build(tmp.path(), db_dir.path(), &cfg).expect("load_or_build");
|
||||
let (map, _cov) = load_or_build(tmp.path(), db_dir.path(), &cfg).expect("load_or_build");
|
||||
assert!(
|
||||
map.entry_points().next().is_some(),
|
||||
"expected at least one entry-point in fallback path"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue