Skip to main content

fallow_security/
severity.rs

1//! Shared severity policy for security candidates.
2//!
3//! Both the core analyze ranking pass and the engine dead-code boundary derive
4//! severities from the same typed signals; keeping the policy here guarantees
5//! the two surfaces can never diverge.
6
7use fallow_types::results::{SecurityFinding, SecurityRuntimeState, SecuritySeverity};
8
9use crate::{HARDCODED_SECRET_CATEGORY_ID, HARDCODED_SECRET_CATEGORY_TITLE, catalogue_title};
10
11/// Derive the verification-priority tier from existing security signals. This is
12/// ranking only, not a vulnerability verdict.
13#[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/// Return the human-readable title for a security catalogue identifier,
51/// covering the standalone hardcoded-secret detector that has no catalogue
52/// matcher row.
53#[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}