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 );
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}