1use 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 fallow_types::identity::fnv1a64_hex(fingerprint.as_bytes())
56}
57
58pub 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 #[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}