Skip to main content

clawft_kernel/assessment/analyzers/
security.rs

1//! Security analyzer — scans for hardcoded secrets, .env files, and unsafe blocks.
2
3use std::path::{Path, PathBuf};
4
5use crate::assessment::analyzer::Analyzer;
6use crate::assessment::{Finding, analyzer::AnalysisContext};
7
8/// Analyzer that detects common security anti-patterns.
9pub struct SecurityAnalyzer;
10
11/// Patterns that suggest hardcoded secrets (case-insensitive prefix match).
12const SECRET_PATTERNS: &[&str] = &[
13    "api_key=",
14    "api_key =",
15    "apikey=",
16    "apikey =",
17    "password=",
18    "password =",
19    "passwd=",
20    "passwd =",
21    "secret=",
22    "secret =",
23    "token=",
24    "token =",
25    "aws_secret",
26    "private_key",
27];
28
29impl Analyzer for SecurityAnalyzer {
30    fn id(&self) -> &str {
31        "security"
32    }
33
34    fn name(&self) -> &str {
35        "Security Analyzer"
36    }
37
38    fn categories(&self) -> &[&str] {
39        &["security"]
40    }
41
42    fn analyze(
43        &self,
44        project: &Path,
45        files: &[PathBuf],
46        _context: &AnalysisContext,
47    ) -> Vec<Finding> {
48        let mut findings = Vec::new();
49
50        for path in files {
51            let rel = path.strip_prefix(project).unwrap_or(path);
52            let rel_str = rel.display().to_string();
53            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
54
55            // Detect committed .env files
56            if name == ".env" || name.starts_with(".env.") {
57                findings.push(Finding {
58                    severity: "error".into(),
59                    category: "security".into(),
60                    file: rel_str.clone(),
61                    line: None,
62                    message: "Environment file should not be committed to version control".into(),
63                });
64                continue;
65            }
66
67            // Skip test files for secret detection to reduce noise
68            let is_test = rel_str.contains("test")
69                || rel_str.contains("spec")
70                || rel_str.contains("fixture")
71                || rel_str.contains("mock");
72
73            let content = match std::fs::read_to_string(path) {
74                Ok(c) => c,
75                Err(_) => continue,
76            };
77
78            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
79
80            // Hardcoded secret patterns (skip test files)
81            if !is_test {
82                for (i, line) in content.lines().enumerate() {
83                    let lower = line.to_lowercase();
84                    // Skip comment lines
85                    let trimmed = lower.trim();
86                    if trimmed.starts_with("//")
87                        || trimmed.starts_with('#')
88                        || trimmed.starts_with("/*")
89                        || trimmed.starts_with('*')
90                    {
91                        continue;
92                    }
93
94                    for pattern in SECRET_PATTERNS {
95                        if lower.contains(pattern) {
96                            // Verify there is an actual value (not just the key name)
97                            if let Some(pos) = lower.find('=') {
98                                let after = lower[pos + 1..].trim();
99                                // Skip empty assignments and placeholder values
100                                if !after.is_empty()
101                                    && after != "\"\""
102                                    && after != "''"
103                                    && !after.starts_with("env")
104                                    && !after.starts_with("std::env")
105                                    && !after.starts_with("process.env")
106                                    && !after.starts_with("os.environ")
107                                {
108                                    findings.push(Finding {
109                                        severity: "error".into(),
110                                        category: "security".into(),
111                                        file: rel_str.clone(),
112                                        line: Some(i + 1),
113                                        message: format!(
114                                            "Possible hardcoded secret (matched '{pattern}')"
115                                        ),
116                                    });
117                                    break; // one finding per line
118                                }
119                            }
120                        }
121                    }
122                }
123            }
124
125            // Detect unsafe blocks in Rust files
126            if ext == "rs" {
127                for (i, line) in content.lines().enumerate() {
128                    let trimmed = line.trim();
129                    if trimmed.contains("unsafe {")
130                        || trimmed.contains("unsafe{")
131                        || trimmed == "unsafe {"
132                    {
133                        findings.push(Finding {
134                            severity: "warning".into(),
135                            category: "security".into(),
136                            file: rel_str.clone(),
137                            line: Some(i + 1),
138                            message: "Unsafe block detected — review for memory safety".into(),
139                        });
140                    }
141                }
142            }
143        }
144
145        findings
146    }
147}