fallow_security/
identity.rs1use std::path::Path;
8
9use fallow_types::results::{SecurityFinding, SecurityFindingKind};
10
11const SERVER_ONLY_CATEGORY: &str = "server-only-import";
16
17#[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#[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}