Skip to main content

fleetreach_cli/
static_reach.rs

1//! `--reachability=static`: the sound call-graph engine (NOT the grep heuristic
2//! in [`crate::reach`]).
3//!
4//! For each repo that has findings naming specific functions, this builds the
5//! repo once under the reach-driver and resolves every such finding's affected
6//! functions against the whole-closure call graph. A finding is annotated with a
7//! [`Reachability`] verdict (and the legacy `reachable` bool, so `--reachable-
8//! only` drops a sound `NotReachable`).
9//!
10//! Soundness discipline (spec §1): only a definite, uncontested `NotReachable`
11//! across *all* of a finding's occurrences yields `NotReachable`. A build
12//! failure, an unresolved sink, an opaque boundary, or a function we cannot
13//! attribute to a verdict all resolve to `Unknown` — never `NotReachable`.
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::path::Path;
17
18use fleetreach_core::{FleetReport, Occurrence, ReachVerdict, Reachability, VulnFinding};
19use fleetreach_reach::{
20    analyze_project_cached, BuildConfig, FeatureSelection, SandboxPolicy, Verdict,
21};
22use sha2::{Digest, Sha256};
23
24use crate::config::Config;
25
26/// The pinned nightly the reach-driver was built against. The verdict is scoped
27/// to it (recorded in `Reachability::config`).
28pub const TOOLCHAIN: &str = "nightly-2026-06-27";
29
30/// Inputs for the static engine.
31pub struct Options<'a> {
32    /// Path to the built `fleetreach-reach-driver` binary.
33    pub driver: &'a Path,
34    /// Which cargo features to build each repo with (part of the cache key).
35    pub features: FeatureSelection,
36    /// How to confine the untrusted build (defense-in-depth).
37    pub sandbox: SandboxPolicy,
38    /// Per-repo progress to stderr.
39    pub verbose: bool,
40}
41
42/// A successful per-repo analysis: verdicts plus the cache key of the analyzed
43/// closure (the witness anchor, §9.2).
44struct RepoData {
45    verdicts: BTreeMap<String, Verdict>,
46    cache_key: Option<String>,
47}
48
49/// Per-repo analysis outcome: the analysis, or a build/setup failure.
50type RepoOutcome = Result<RepoData, String>;
51
52/// Annotate findings with static reachability. One cargo build per affected repo.
53pub fn assess(report: &mut FleetReport, config: &Config, opts: &Options) {
54    let engine = format!("static-mir-rta@{}", env!("CARGO_PKG_VERSION"));
55
56    // Sinks per repo: the union of affected functions of findings occurring there.
57    let mut sinks_by_repo: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
58    for v in &report.vulnerabilities {
59        if v.affected_functions.is_empty() {
60            continue; // whole-crate advisory: no function-level sink to resolve
61        }
62        for repo in repos_of(v) {
63            sinks_by_repo
64                .entry(repo)
65                .or_default()
66                .extend(v.affected_functions.iter().cloned());
67        }
68    }
69    if sinks_by_repo.is_empty() {
70        return;
71    }
72
73    // Build + analyze each repo once.
74    let mut by_repo: BTreeMap<String, RepoOutcome> = BTreeMap::new();
75    for (repo_id, sinks) in &sinks_by_repo {
76        let outcome = match config.repos.iter().find(|r| r.id.0 == **repo_id) {
77            None => Err("repo is not in the fleet config".to_string()),
78            Some(repo) => {
79                if opts.verbose {
80                    eprintln!("reachability(static): analyzing {repo_id} …");
81                }
82                let sink_vec: Vec<String> = sinks.iter().cloned().collect();
83                let build = BuildConfig {
84                    toolchain: TOOLCHAIN,
85                    features: opts.features.clone(),
86                    sandbox: opts.sandbox,
87                };
88                analyze_project_cached(&repo.path, opts.driver, &build, &sink_vec)
89                    .map(|c| {
90                        if opts.verbose {
91                            eprintln!(
92                                "reachability(static): {repo_id} graph {}",
93                                if c.from_cache {
94                                    "(cache hit)"
95                                } else {
96                                    "(rebuilt)"
97                                }
98                            );
99                        }
100                        RepoData {
101                            verdicts: c.verdicts,
102                            cache_key: c.cache_key,
103                        }
104                    })
105                    .map_err(|e| e.to_string())
106            }
107        };
108        if opts.verbose {
109            if let Err(e) = &outcome {
110                eprintln!("reachability(static): {repo_id} could not be analyzed: {e}");
111            }
112        }
113        by_repo.insert((*repo_id).to_string(), outcome);
114    }
115
116    // The target the closure was built for — the host default (no `--target`).
117    let host = host_triple();
118
119    // Annotate each finding from the verdicts of the repos it occurs in.
120    for v in &mut report.vulnerabilities {
121        if v.affected_functions.is_empty() {
122            continue;
123        }
124        let verdict = combine(v, &by_repo);
125        // A `NotReachable` carries a witness binding it to its inputs (§9.2) and
126        // names the analyzed target (§7 edge 3); other verdicts carry neither.
127        let (targets, witness) = match &verdict {
128            ReachVerdict::NotReachable => {
129                let cache_keys: BTreeSet<String> = repos_of(v)
130                    .filter_map(|repo| by_repo.get(repo))
131                    .filter_map(|outcome| outcome.as_ref().ok())
132                    .filter_map(|data| data.cache_key.clone())
133                    .collect();
134                (
135                    vec![host.clone()],
136                    Some(witness_hash(&engine, &v.affected_functions, &cache_keys)),
137                )
138            }
139            _ => (Vec::new(), None),
140        };
141        let reachability = Reachability {
142            verdict,
143            config: TOOLCHAIN.to_string(),
144            engine: engine.clone(),
145            targets,
146            witness,
147        };
148        v.reachable = reachability.as_legacy_bool();
149        v.reachability = Some(reachability);
150    }
151}
152
153/// The host target triple the closure was built for, from `rustc -vV`; falls back
154/// to the architecture name when `rustc` cannot be queried.
155fn host_triple() -> String {
156    std::process::Command::new("rustc")
157        .arg("-vV")
158        .output()
159        .ok()
160        .and_then(|out| String::from_utf8(out.stdout).ok())
161        .and_then(|text| {
162            text.lines()
163                .find_map(|line| line.strip_prefix("host: ").map(str::to_string))
164        })
165        .unwrap_or_else(|| std::env::consts::ARCH.to_string())
166}
167
168/// A content-addressed witness for a `NotReachable` verdict (§9.2): SHA-256 over
169/// the toolchain, engine, sinks, and the repos' cache keys (which bind lockfile,
170/// features, and source). Any input change moves the hash, so `vex verify` re-derives.
171fn witness_hash(engine: &str, sinks: &[String], cache_keys: &BTreeSet<String>) -> String {
172    let mut hasher = Sha256::new();
173    hasher.update(TOOLCHAIN.as_bytes());
174    hasher.update([0]);
175    hasher.update(engine.as_bytes());
176    let mut sorted: Vec<&str> = sinks.iter().map(String::as_str).collect();
177    sorted.sort_unstable();
178    sorted.dedup();
179    for sink in sorted {
180        hasher.update([0]);
181        hasher.update(sink.as_bytes());
182    }
183    // `cache_keys` is a BTreeSet, so iteration is already sorted + deduped.
184    for key in cache_keys {
185        hasher.update([0]);
186        hasher.update(key.as_bytes());
187    }
188    let digest = hasher.finalize();
189    let mut out = String::with_capacity(7 + 64);
190    out.push_str("sha256:");
191    for byte in digest {
192        out.push_str(&format!("{byte:02x}"));
193    }
194    out
195}
196
197/// The repo ids a finding occurs in.
198fn repos_of(v: &VulnFinding) -> impl Iterator<Item = &str> {
199    v.occurrences.iter().filter_map(|o| match o {
200        Occurrence::InRepo { repo, .. } => Some(repo.0.as_str()),
201        Occurrence::Toolchain { .. } => None,
202    })
203}
204
205/// Combine the per-(repo, function, monomorphization) verdicts for one finding.
206/// `Reachable` wins (with the first witness); `NotReachable` only if every
207/// occurrence is a definite, uncontested `NotReachable`; otherwise `Unknown`.
208fn combine(v: &VulnFinding, by_repo: &BTreeMap<String, RepoOutcome>) -> ReachVerdict {
209    let mut witness: Option<Vec<String>> = None;
210    let mut saw_unknown = false;
211    let mut saw_not_reachable = false;
212
213    for repo in repos_of(v) {
214        match by_repo.get(repo) {
215            // Repo not analyzed, or its build failed → cannot rule anything out.
216            None | Some(Err(_)) => saw_unknown = true,
217            Some(Ok(data)) => {
218                for func in &v.affected_functions {
219                    // Verdicts are keyed by the exact requested path.
220                    match data.verdicts.get(func) {
221                        Some(Verdict::Reachable { witness: w }) => {
222                            witness.get_or_insert_with(|| w.clone());
223                        }
224                        Some(Verdict::NotReachable) => saw_not_reachable = true,
225                        Some(Verdict::Unknown { .. }) => saw_unknown = true,
226                        // The function did not resolve to a node in this build
227                        // (e.g. a version where it does not exist) → fail closed.
228                        None => saw_unknown = true,
229                    }
230                }
231            }
232        }
233    }
234
235    if let Some(w) = witness {
236        ReachVerdict::Reachable { witness: w }
237    } else if saw_unknown {
238        ReachVerdict::Unknown {
239            reason: "could not prove unreachable for every occurrence (build failure, \
240                     opaque boundary, or unresolved sink)"
241                .to_string(),
242        }
243    } else if saw_not_reachable {
244        ReachVerdict::NotReachable
245    } else {
246        ReachVerdict::Unknown {
247            reason: "no affected function resolved to a call-graph node".to_string(),
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use fleetreach_core::{Exploitability, RepoId, Severity};
256
257    fn finding(funcs: &[&str], repos: &[&str]) -> VulnFinding {
258        VulnFinding {
259            advisory_id: "RUSTSEC-2099-0001".into(),
260            aliases: vec![],
261            ecosystem: Default::default(),
262            title: "t".into(),
263            severity: Severity::High,
264            cvss_score: None,
265            url: None,
266            occurrences: repos
267                .iter()
268                .map(|r| Occurrence::InRepo {
269                    repo: RepoId((*r).into()),
270                    package: "p".into(),
271                    installed: fleetreach_core::semver::Version::new(1, 0, 0),
272                    patched: vec![],
273                    dependency_kind: fleetreach_core::DependencyKind::Direct,
274                    dependency_path: vec![],
275                    active: None,
276                    source: Default::default(),
277                })
278                .collect(),
279            affected_functions: funcs.iter().map(|s| (*s).into()).collect(),
280            reachable: None,
281            reachability: None,
282            exploit: Exploitability::default(),
283        }
284    }
285
286    /// A successful repo outcome: a verdict per sink path.
287    fn ok(pairs: Vec<(&str, Verdict)>) -> RepoOutcome {
288        Ok(RepoData {
289            verdicts: pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
290            cache_key: Some("reach-test".to_string()),
291        })
292    }
293
294    fn repos(pairs: Vec<(&str, RepoOutcome)>) -> BTreeMap<String, RepoOutcome> {
295        pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
296    }
297
298    #[test]
299    fn reachable_wins_with_witness() {
300        let f = finding(&["k::v"], &["a"]);
301        let by_repo = repos(vec![(
302            "a",
303            ok(vec![(
304                "k::v",
305                Verdict::Reachable {
306                    witness: vec!["main".into(), "k::v".into()],
307                },
308            )]),
309        )]);
310        assert_eq!(
311            combine(&f, &by_repo),
312            ReachVerdict::Reachable {
313                witness: vec!["main".into(), "k::v".into()]
314            }
315        );
316    }
317
318    #[test]
319    fn all_definite_not_reachable_is_not_reachable() {
320        let f = finding(&["k::v"], &["a", "b"]);
321        let by_repo = repos(vec![
322            ("a", ok(vec![("k::v", Verdict::NotReachable)])),
323            ("b", ok(vec![("k::v", Verdict::NotReachable)])),
324        ]);
325        assert_eq!(combine(&f, &by_repo), ReachVerdict::NotReachable);
326    }
327
328    #[test]
329    fn one_unknown_repo_makes_it_unknown_not_notreachable() {
330        // NotReachable in one repo, but the other's build failed → Unknown.
331        let f = finding(&["k::v"], &["a", "b"]);
332        let by_repo = repos(vec![
333            ("a", ok(vec![("k::v", Verdict::NotReachable)])),
334            ("b", Err("build failed".into())),
335        ]);
336        assert!(matches!(
337            combine(&f, &by_repo),
338            ReachVerdict::Unknown { .. }
339        ));
340    }
341
342    #[test]
343    fn reachable_in_any_repo_beats_notreachable_elsewhere() {
344        let f = finding(&["k::v"], &["a", "b"]);
345        let by_repo = repos(vec![
346            ("a", ok(vec![("k::v", Verdict::NotReachable)])),
347            (
348                "b",
349                ok(vec![(
350                    "k::v",
351                    Verdict::Reachable {
352                        witness: vec!["x".into()],
353                    },
354                )]),
355            ),
356        ]);
357        assert!(matches!(
358            combine(&f, &by_repo),
359            ReachVerdict::Reachable { .. }
360        ));
361    }
362
363    #[test]
364    fn witness_hash_is_deterministic_and_order_independent() {
365        let keys: BTreeSet<String> = ["reach-a", "reach-b"]
366            .iter()
367            .map(|s| s.to_string())
368            .collect();
369        let a = witness_hash("eng", &["a::x".into(), "b::y".into()], &keys);
370        let b = witness_hash("eng", &["b::y".into(), "a::x".into()], &keys);
371        assert_eq!(a, b, "sink order must not change the witness");
372        assert!(a.starts_with("sha256:"));
373        assert_eq!(a.len(), "sha256:".len() + 64);
374        // A different input yields a different witness.
375        assert_ne!(a, witness_hash("eng2", &["a::x".into()], &keys));
376    }
377
378    #[test]
379    fn unresolved_function_fails_closed_to_unknown() {
380        // The repo analyzed fine but produced no verdict for the function.
381        let f = finding(&["k::v"], &["a"]);
382        let by_repo = repos(vec![("a", ok(vec![("other::fn", Verdict::NotReachable)]))]);
383        assert!(matches!(
384            combine(&f, &by_repo),
385            ReachVerdict::Unknown { .. }
386        ));
387    }
388}