Skip to main content

fallow_security/
identity.rs

1//! Stable identifiers for security candidates.
2//!
3//! JSON, SARIF, and the viz Security lens all join on these two strings, so the
4//! rule id and the per-finding correlation id live here rather than in any one
5//! consumer. A second implementation would let the surfaces drift apart.
6
7use std::path::Path;
8
9use fallow_types::results::{SecurityFinding, SecurityFindingKind};
10
11/// The `category` string distinguishing the server-only-import sink from the
12/// secret-leak sink. Both carry the `ClientServerLeak` kind, so the category is
13/// the only thing that tells them apart. Matches the constant in
14/// `crates/core/src/analyze/security/mod.rs`.
15const SERVER_ONLY_CATEGORY: &str = "server-only-import";
16
17/// The stable rule identifier for a finding.
18///
19/// The secret-leak `ClientServerLeak` keeps its bespoke id; the server-only
20/// variant gets `security/server-only-import` so a SARIF consumer tells
21/// "reaches server-only code" apart from "reads a secret". Each `TaintedSink`
22/// category gets `security/<category>` so candidates group per CWE class.
23#[must_use]
24pub fn security_rule_id(finding: &SecurityFinding) -> String {
25    match finding.kind {
26        SecurityFindingKind::ClientServerLeak
27            if finding.category.as_deref() == Some(SERVER_ONLY_CATEGORY) =>
28        {
29            "security/server-only-import".to_owned()
30        }
31        SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
32        SecurityFindingKind::TaintedSink => format!(
33            "security/{}",
34            finding.category.as_deref().unwrap_or("tainted-sink")
35        ),
36    }
37}
38
39/// The stable per-finding correlation id: an FNV-1a hex digest of
40/// `rule:path:line:col`.
41///
42/// This is the single source of truth for both the JSON `finding_id` field and
43/// the SARIF `partialFingerprints` value, so an agent can join the two and they
44/// never drift. The digest is computed on the project-relative path, so callers
45/// must pass the relativized path (issue #900).
46#[must_use]
47pub fn security_finding_id(finding: &SecurityFinding, relative_path: &Path) -> String {
48    let fingerprint = format!(
49        "{}:{}:{}:{}",
50        security_rule_id(finding),
51        relative_path.to_string_lossy().replace('\\', "/"),
52        finding.line,
53        finding.col,
54    );
55    fallow_types::identity::fnv1a64_hex(fingerprint.as_bytes())
56}
57
58/// Set the `finding_id` of each finding in `findings`.
59///
60/// The shared analysis pipeline calls this once, directly after detection, so
61/// the CLI, MCP and the LSP all read the same id. A path
62/// under `root` is made root-relative before the digest. A path outside `root`
63/// stays as it is, as in the CLI JSON output.
64pub fn stamp_security_finding_ids(findings: &mut [SecurityFinding], root: &Path) {
65    for finding in findings {
66        let relative = finding.path.strip_prefix(root).unwrap_or(&finding.path);
67        finding.finding_id = security_finding_id(finding, relative);
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use std::path::{Path, PathBuf};
74
75    use fallow_types::{
76        output::IssueAction,
77        results::{
78            SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink, SecurityFinding,
79            SecurityFindingKind, SecuritySeverity, TraceHop, TraceHopRole,
80        },
81    };
82
83    use super::{security_finding_id, security_rule_id, stamp_security_finding_ids};
84
85    fn finding(kind: SecurityFindingKind, category: Option<&str>) -> SecurityFinding {
86        let path = PathBuf::from("/repo/src/a.ts");
87        SecurityFinding {
88            finding_id: String::new(),
89            kind,
90            category: category.map(str::to_owned),
91            cwe: Some(79),
92            path: path.clone(),
93            line: 12,
94            col: 0,
95            evidence: "candidate".to_owned(),
96            source_backed: false,
97            source_read: None,
98            severity: SecuritySeverity::Low,
99            trace: vec![TraceHop {
100                path: path.clone(),
101                line: 12,
102                col: 0,
103                role: TraceHopRole::Sink,
104            }],
105            actions: Vec::<IssueAction>::new(),
106            dead_code: None,
107            reachability: None,
108            candidate: SecurityCandidate {
109                source_kind: None,
110                sink: SecurityCandidateSink {
111                    path,
112                    line: 12,
113                    col: 0,
114                    category: category.map(str::to_owned),
115                    cwe: Some(79),
116                    callee: None,
117                    url_shape: None,
118                },
119                boundary: SecurityCandidateBoundary::default(),
120                network: None,
121            },
122            taint_flow: None,
123            runtime: None,
124            attack_surface: None,
125        }
126    }
127
128    #[test]
129    fn rule_id_separates_the_two_client_server_leak_variants() {
130        assert_eq!(
131            security_rule_id(&finding(SecurityFindingKind::ClientServerLeak, None)),
132            "security/client-server-leak"
133        );
134        assert_eq!(
135            security_rule_id(&finding(
136                SecurityFindingKind::ClientServerLeak,
137                Some("server-only-import"),
138            )),
139            "security/server-only-import"
140        );
141        assert_eq!(
142            security_rule_id(&finding(
143                SecurityFindingKind::TaintedSink,
144                Some("dangerous-html"),
145            )),
146            "security/dangerous-html"
147        );
148        assert_eq!(
149            security_rule_id(&finding(SecurityFindingKind::TaintedSink, None)),
150            "security/tainted-sink"
151        );
152    }
153
154    #[test]
155    fn finding_id_is_deterministic_and_16_hex_digits() {
156        let finding = finding(SecurityFindingKind::ClientServerLeak, None);
157        let id = security_finding_id(&finding, Path::new("src/app.tsx"));
158
159        assert_eq!(id, security_finding_id(&finding, Path::new("src/app.tsx")));
160        assert_eq!(id.len(), 16);
161        assert!(id.chars().all(|character| character.is_ascii_hexdigit()));
162        assert_ne!(id, security_finding_id(&finding, Path::new("src/b.tsx")));
163    }
164
165    /// Pins the exact digest. SARIF `fallowSecurity/v2` and the JSON
166    /// `finding_id` carry it, so a change breaks every saved id. The expected
167    /// value comes from an independent FNV-1a 64 script over
168    /// `security/client-server-leak:src/app.tsx:12:0`.
169    #[test]
170    fn finding_id_golden_value() {
171        let finding = finding(SecurityFindingKind::ClientServerLeak, None);
172
173        assert_eq!(
174            security_finding_id(&finding, Path::new("src/app.tsx")),
175            "ea8fc221d62fda15"
176        );
177    }
178
179    #[test]
180    fn finding_id_distinguishes_same_rule_sinks_on_one_line() {
181        let mut first = finding(SecurityFindingKind::TaintedSink, Some("dynamic-regex"));
182        first.col = 12;
183        let mut second = first.clone();
184        second.col = 48;
185
186        assert_ne!(
187            security_finding_id(&first, Path::new("src/patterns.ts")),
188            security_finding_id(&second, Path::new("src/patterns.ts"))
189        );
190    }
191
192    #[test]
193    fn finding_id_normalizes_windows_separators() {
194        let finding = finding(SecurityFindingKind::TaintedSink, Some("dangerous-html"));
195
196        assert_eq!(
197            security_finding_id(&finding, Path::new("src\\app.tsx")),
198            security_finding_id(&finding, Path::new("src/app.tsx"))
199        );
200    }
201
202    #[test]
203    fn stamp_uses_the_root_relative_path() {
204        let mut findings = vec![finding(SecurityFindingKind::ClientServerLeak, None)];
205        stamp_security_finding_ids(&mut findings, Path::new("/repo"));
206
207        assert_eq!(
208            findings[0].finding_id,
209            security_finding_id(&findings[0], Path::new("src/a.ts"))
210        );
211    }
212
213    #[test]
214    fn stamp_keeps_a_path_outside_the_root() {
215        let mut findings = vec![finding(SecurityFindingKind::ClientServerLeak, None)];
216        stamp_security_finding_ids(&mut findings, Path::new("/elsewhere"));
217
218        assert_eq!(
219            findings[0].finding_id,
220            security_finding_id(&findings[0], Path::new("/repo/src/a.ts"))
221        );
222    }
223}