Skip to main content

aptu_core/security/
detection.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Smart detection logic for when to trigger security scans.
4
5/// Determines if a security scan should be performed based on context.
6///
7/// Checks file paths, PR labels, and description keywords to decide if
8/// security scanning is warranted.
9///
10/// # Arguments
11///
12/// * `file_paths` - List of file paths changed in the PR
13/// * `labels` - PR labels
14/// * `description` - PR title and body text
15///
16/// # Returns
17///
18/// `true` if a security scan should be performed.
19#[must_use]
20pub fn needs_security_scan(file_paths: &[String], labels: &[String], description: &str) -> bool {
21    // Check for security-related labels
22    if labels.iter().any(|label| {
23        let lower = label.to_lowercase();
24        lower.contains("security")
25            || lower.contains("vulnerability")
26            || lower.contains("cve")
27            || lower.contains("exploit")
28    }) {
29        return true;
30    }
31
32    // Check for security keywords in description
33    let desc_lower = description.to_lowercase();
34    if desc_lower.contains("security")
35        || desc_lower.contains("vulnerability")
36        || desc_lower.contains("exploit")
37        || desc_lower.contains("injection")
38        || desc_lower.contains("xss")
39        || desc_lower.contains("csrf")
40        || desc_lower.contains("authentication")
41        || desc_lower.contains("authorization")
42        || desc_lower.contains("crypto")
43        || desc_lower.contains("password")
44        || desc_lower.contains("secret")
45        || desc_lower.contains("token")
46        || desc_lower.contains("jwt")
47        || desc_lower.contains("oauth")
48        || desc_lower.contains("session")
49        || desc_lower.contains("mfa")
50    {
51        return true;
52    }
53
54    // Check for sensitive file paths
55    for path in file_paths {
56        let path_lower = path.to_lowercase();
57
58        // Security-related directories
59        if path_lower.contains("/auth")
60            || path_lower.contains("/security")
61            || path_lower.contains("/crypto")
62            || path_lower.contains("/password")
63            || path_lower.contains("/session")
64            || path_lower.contains("/oauth")
65            || path_lower.contains("/jwt")
66        {
67            return true;
68        }
69
70        // Configuration files that might contain secrets
71        let path_obj = std::path::Path::new(&path_lower);
72        if path_obj
73            .extension()
74            .is_some_and(|ext| ext.eq_ignore_ascii_case("env"))
75            || path_lower.ends_with(".env.example")
76            || path_lower.contains("config")
77            || path_lower.contains("secret")
78            || path_lower.contains("credential")
79        {
80            return true;
81        }
82
83        // Database or SQL files
84        if path_obj
85            .extension()
86            .is_some_and(|ext| ext.eq_ignore_ascii_case("sql"))
87            || path_lower.contains("migration")
88            || path_lower.contains("database")
89        {
90            return true;
91        }
92
93        // Authentication/authorization code
94        if path_lower.contains("login")
95            || path_lower.contains("signin")
96            || path_lower.contains("signup")
97            || path_lower.contains("register")
98        {
99            return true;
100        }
101    }
102
103    // Default: no scan needed
104    false
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_security_label_triggers_scan() {
113        assert!(needs_security_scan(&[], &["security".to_string()], ""));
114        assert!(needs_security_scan(&[], &["vulnerability".to_string()], ""));
115        assert!(needs_security_scan(
116            &[],
117            &["bug".to_string(), "Security Fix".to_string()],
118            ""
119        ));
120    }
121
122    #[test]
123    fn test_description_keywords_trigger_scan() {
124        assert!(needs_security_scan(
125            &[],
126            &[],
127            "Fix security vulnerability in auth"
128        ));
129        assert!(needs_security_scan(
130            &[],
131            &[],
132            "Prevent SQL injection attack"
133        ));
134        assert!(needs_security_scan(
135            &[],
136            &[],
137            "Update password hashing algorithm"
138        ));
139        assert!(needs_security_scan(&[], &[], "Remove hardcoded API token"));
140    }
141
142    #[test]
143    fn test_sensitive_file_paths_trigger_scan() {
144        assert!(needs_security_scan(
145            &["src/auth/login.rs".to_string()],
146            &[],
147            ""
148        ));
149        assert!(needs_security_scan(
150            &["config/secrets.yml".to_string()],
151            &[],
152            ""
153        ));
154        assert!(needs_security_scan(&[".env.example".to_string()], &[], ""));
155        assert!(needs_security_scan(
156            &["migrations/001_users.sql".to_string()],
157            &[],
158            ""
159        ));
160        assert!(needs_security_scan(
161            &["src/security/scanner.rs".to_string()],
162            &[],
163            ""
164        ));
165    }
166
167    #[test]
168    fn test_no_scan_for_regular_changes() {
169        assert!(!needs_security_scan(
170            &["README.md".to_string()],
171            &[],
172            "Update documentation"
173        ));
174        assert!(!needs_security_scan(
175            &["src/utils.rs".to_string()],
176            &["enhancement".to_string()],
177            "Add helper function"
178        ));
179        assert!(!needs_security_scan(
180            &["tests/test_utils.rs".to_string()],
181            &["test".to_string()],
182            "Add unit tests"
183        ));
184    }
185
186    #[test]
187    fn test_case_insensitive_matching() {
188        assert!(needs_security_scan(&[], &["SECURITY".to_string()], ""));
189        assert!(needs_security_scan(&[], &[], "SECURITY FIX"));
190        assert!(needs_security_scan(
191            &["SRC/AUTH/LOGIN.RS".to_string()],
192            &[],
193            ""
194        ));
195    }
196
197    #[test]
198    fn test_multiple_conditions() {
199        // Multiple triggers should still return true
200        assert!(needs_security_scan(
201            &["src/auth/login.rs".to_string()],
202            &["security".to_string()],
203            "Fix authentication bug"
204        ));
205    }
206
207    #[test]
208    fn test_crypto_related_changes() {
209        assert!(needs_security_scan(
210            &["src/crypto/hash.rs".to_string()],
211            &[],
212            ""
213        ));
214        assert!(needs_security_scan(
215            &[],
216            &[],
217            "Update cryptographic library"
218        ));
219    }
220
221    #[test]
222    fn test_identity_related_keywords() {
223        assert!(needs_security_scan(&[], &[], "Update JWT token handling"));
224        assert!(needs_security_scan(&[], &[], "Fix OAuth2 flow"));
225        assert!(needs_security_scan(
226            &[],
227            &[],
228            "Session management improvements"
229        ));
230        assert!(needs_security_scan(&[], &[], "Add MFA support"));
231        assert!(needs_security_scan(
232            &["src/session/store.rs".to_string()],
233            &[],
234            ""
235        ));
236        assert!(needs_security_scan(
237            &["src/oauth/provider.rs".to_string()],
238            &[],
239            ""
240        ));
241        assert!(needs_security_scan(
242            &["src/jwt/validator.rs".to_string()],
243            &[],
244            ""
245        ));
246    }
247}