Skip to main content

fallow_engine/
security.rs

1//! Security metadata helpers owned by the engine boundary.
2
3use fallow_types::results::{SecurityFinding, SecurityRuntimeState, SecuritySeverity};
4
5/// Derive the review-priority severity for a security candidate.
6#[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/// Return the human-readable title for a security catalogue identifier.
44#[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}