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