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    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
56    for byte in fingerprint.bytes() {
57        hash ^= u64::from(byte);
58        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
59    }
60    format!("{hash:016x}")
61}
62
63#[cfg(test)]
64mod tests {
65    use std::path::{Path, PathBuf};
66
67    use fallow_types::{
68        output::IssueAction,
69        results::{
70            SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink, SecurityFinding,
71            SecurityFindingKind, SecuritySeverity, TraceHop, TraceHopRole,
72        },
73    };
74
75    use super::{security_finding_id, security_rule_id};
76
77    fn finding(kind: SecurityFindingKind, category: Option<&str>) -> SecurityFinding {
78        let path = PathBuf::from("/repo/src/a.ts");
79        SecurityFinding {
80            finding_id: String::new(),
81            kind,
82            category: category.map(str::to_owned),
83            cwe: Some(79),
84            path: path.clone(),
85            line: 12,
86            col: 0,
87            evidence: "candidate".to_owned(),
88            source_backed: false,
89            source_read: None,
90            severity: SecuritySeverity::Low,
91            trace: vec![TraceHop {
92                path: path.clone(),
93                line: 12,
94                col: 0,
95                role: TraceHopRole::Sink,
96            }],
97            actions: Vec::<IssueAction>::new(),
98            dead_code: None,
99            reachability: None,
100            candidate: SecurityCandidate {
101                source_kind: None,
102                sink: SecurityCandidateSink {
103                    path,
104                    line: 12,
105                    col: 0,
106                    category: category.map(str::to_owned),
107                    cwe: Some(79),
108                    callee: None,
109                    url_shape: None,
110                },
111                boundary: SecurityCandidateBoundary::default(),
112                network: None,
113            },
114            taint_flow: None,
115            runtime: None,
116            attack_surface: None,
117        }
118    }
119
120    #[test]
121    fn rule_id_separates_the_two_client_server_leak_variants() {
122        assert_eq!(
123            security_rule_id(&finding(SecurityFindingKind::ClientServerLeak, None)),
124            "security/client-server-leak"
125        );
126        assert_eq!(
127            security_rule_id(&finding(
128                SecurityFindingKind::ClientServerLeak,
129                Some("server-only-import"),
130            )),
131            "security/server-only-import"
132        );
133        assert_eq!(
134            security_rule_id(&finding(
135                SecurityFindingKind::TaintedSink,
136                Some("dangerous-html"),
137            )),
138            "security/dangerous-html"
139        );
140        assert_eq!(
141            security_rule_id(&finding(SecurityFindingKind::TaintedSink, None)),
142            "security/tainted-sink"
143        );
144    }
145
146    #[test]
147    fn finding_id_is_deterministic_and_16_hex_digits() {
148        let finding = finding(SecurityFindingKind::ClientServerLeak, None);
149        let id = security_finding_id(&finding, Path::new("src/app.tsx"));
150
151        assert_eq!(id, security_finding_id(&finding, Path::new("src/app.tsx")));
152        assert_eq!(id.len(), 16);
153        assert!(id.chars().all(|character| character.is_ascii_hexdigit()));
154        assert_ne!(id, security_finding_id(&finding, Path::new("src/b.tsx")));
155    }
156
157    #[test]
158    fn finding_id_distinguishes_same_rule_sinks_on_one_line() {
159        let mut first = finding(SecurityFindingKind::TaintedSink, Some("dynamic-regex"));
160        first.col = 12;
161        let mut second = first.clone();
162        second.col = 48;
163
164        assert_ne!(
165            security_finding_id(&first, Path::new("src/patterns.ts")),
166            security_finding_id(&second, Path::new("src/patterns.ts"))
167        );
168    }
169
170    #[test]
171    fn finding_id_normalizes_windows_separators() {
172        let finding = finding(SecurityFindingKind::TaintedSink, Some("dangerous-html"));
173
174        assert_eq!(
175            security_finding_id(&finding, Path::new("src\\app.tsx")),
176            security_finding_id(&finding, Path::new("src/app.tsx"))
177        );
178    }
179}