fallow_security/
severity.rs1use fallow_types::results::{SecurityFinding, SecurityRuntimeState, SecuritySeverity};
8
9use crate::{HARDCODED_SECRET_CATEGORY_ID, HARDCODED_SECRET_CATEGORY_TITLE, catalogue_title};
10
11#[must_use]
14pub fn derive_security_severity(finding: &SecurityFinding) -> SecuritySeverity {
15 if finding
16 .runtime
17 .as_ref()
18 .is_some_and(|runtime| runtime.state == SecurityRuntimeState::RuntimeHot)
19 || finding.candidate.boundary.client_server
20 || finding
21 .candidate
22 .boundary
23 .architecture_zone
24 .as_ref()
25 .is_some()
26 || finding
27 .reachability
28 .as_ref()
29 .is_some_and(|reach| reach.crosses_boundary)
30 || finding
31 .reachability
32 .as_ref()
33 .is_some_and(|reach| reach.reachable_from_entry && finding.source_backed)
34 {
35 return SecuritySeverity::High;
36 }
37
38 if finding.source_backed
39 || finding
40 .reachability
41 .as_ref()
42 .is_some_and(|reach| reach.reachable_from_untrusted_source)
43 {
44 return SecuritySeverity::Medium;
45 }
46
47 SecuritySeverity::Low
48}
49
50#[must_use]
54pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
55 if kind == HARDCODED_SECRET_CATEGORY_ID {
56 Some(HARDCODED_SECRET_CATEGORY_TITLE)
57 } else {
58 catalogue_title(kind)
59 }
60}
61
62#[cfg(test)]
63mod tests {
64 use std::path::PathBuf;
65
66 use fallow_types::{
67 output::IssueAction,
68 results::{
69 SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink, SecurityFinding,
70 SecurityFindingKind, SecurityReachability, SecurityRuntimeContext,
71 SecurityRuntimeState, SecuritySeverity, SecurityZoneCrossing, TraceHop, TraceHopRole,
72 },
73 };
74
75 use super::derive_security_severity;
76
77 fn finding(name: &str) -> SecurityFinding {
78 let path = PathBuf::from("/repo").join(name);
79 SecurityFinding {
80 finding_id: String::new(),
81 kind: SecurityFindingKind::TaintedSink,
82 category: Some("dangerous-html".to_string()),
83 cwe: Some(79),
84 path: path.clone(),
85 line: 1,
86 col: 0,
87 evidence: "candidate".to_string(),
88 source_backed: false,
89 source_read: None,
90 severity: SecuritySeverity::Low,
91 trace: vec![TraceHop {
92 path: path.clone(),
93 line: 1,
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: 1,
105 col: 0,
106 category: Some("dangerous-html".to_string()),
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 fn reachability(
121 reachable_from_entry: bool,
122 reachable_from_untrusted_source: bool,
123 crosses_boundary: bool,
124 ) -> SecurityReachability {
125 SecurityReachability {
126 reachable_from_entry,
127 reachable_from_untrusted_source,
128 taint_confidence: None,
129 untrusted_source_hop_count: None,
130 untrusted_source_trace: vec![],
131 blast_radius: 1,
132 crosses_boundary,
133 }
134 }
135
136 #[test]
137 fn derives_low_severity_for_baseline_candidate() {
138 assert_eq!(
139 derive_security_severity(&finding("sink.ts")),
140 SecuritySeverity::Low
141 );
142 }
143
144 #[test]
145 fn derives_medium_severity_for_source_signals() {
146 let mut source_backed = finding("source-backed.ts");
147 source_backed.source_backed = true;
148
149 let mut source_reachable = finding("source-reachable.ts");
150 source_reachable.reachability = Some(reachability(false, true, false));
151
152 assert_eq!(
153 derive_security_severity(&source_backed),
154 SecuritySeverity::Medium
155 );
156 assert_eq!(
157 derive_security_severity(&source_reachable),
158 SecuritySeverity::Medium
159 );
160 }
161
162 #[test]
163 fn derives_high_severity_for_boundary_entry_and_runtime_signals() {
164 let mut client_boundary = finding("client-boundary.ts");
165 client_boundary.candidate.boundary.client_server = true;
166
167 let mut architecture_boundary = finding("architecture-boundary.ts");
168 architecture_boundary.candidate.boundary.architecture_zone = Some(SecurityZoneCrossing {
169 from: "web".to_string(),
170 to: "server".to_string(),
171 });
172
173 let mut crossed_boundary = finding("crossed-boundary.ts");
174 crossed_boundary.reachability = Some(reachability(false, false, true));
175
176 let mut source_backed_entry = finding("source-backed-entry.ts");
177 source_backed_entry.source_backed = true;
178 source_backed_entry.reachability = Some(reachability(true, false, false));
179
180 let mut runtime_hot = finding("runtime-hot.ts");
181 runtime_hot.runtime = Some(SecurityRuntimeContext {
182 state: SecurityRuntimeState::RuntimeHot,
183 function: "handler".to_string(),
184 line: 1,
185 invocations: Some(500),
186 stable_id: Some("fallow:fn:test".to_string()),
187 evidence: Some("runtime hot path".to_string()),
188 });
189
190 for finding in [
191 client_boundary,
192 architecture_boundary,
193 crossed_boundary,
194 source_backed_entry,
195 runtime_hot,
196 ] {
197 assert_eq!(derive_security_severity(&finding), SecuritySeverity::High);
198 }
199 }
200}