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