Skip to main content

cha_core/plugins/
hardcoded_secret.rs

1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2use regex::Regex;
3use std::sync::LazyLock;
4
5pub struct HardcodedSecretAnalyzer;
6
7impl Default for HardcodedSecretAnalyzer {
8    fn default() -> Self {
9        Self
10    }
11}
12
13static PATTERNS: LazyLock<Vec<(&str, Regex)>> = LazyLock::new(|| {
14    // String-literal nodes are fed in pre-stripped, so patterns match raw secrets.
15    [
16        ("AWS Access Key", r#"(?i)AKIA[0-9A-Z]{16,}"#),
17        (
18            "Private Key",
19            r#"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"#,
20        ),
21        ("GitHub Token", r#"gh[ps]_[A-Za-z0-9_]{36,}"#),
22        ("Slack Token", r#"xox[bpors]-[A-Za-z0-9-]{10,}"#),
23        (
24            "JWT",
25            r#"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"#,
26        ),
27        ("Hex Secret", r#"^[0-9a-fA-F]{32,}$"#),
28        ("Long Base64-ish Secret", r#"^[A-Za-z0-9+/=_-]{40,}$"#),
29    ]
30    .iter()
31    .map(|(name, pat)| (*name, Regex::new(pat).unwrap()))
32    .collect()
33});
34
35impl Plugin for HardcodedSecretAnalyzer {
36    fn name(&self) -> &str {
37        "hardcoded_secret"
38    }
39
40    fn smells(&self) -> Vec<String> {
41        vec!["hardcoded_secret".into()]
42    }
43
44    fn description(&self) -> &str {
45        "Hardcoded API keys, tokens, passwords"
46    }
47
48    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
49        let (Some(tree), Some(lang)) = (ctx.tree, ctx.ts_language) else {
50            return Vec::new();
51        };
52        let source = ctx.file.content.as_bytes();
53
54        // Pull every string-literal node. Each grammar names them slightly
55        // differently — query for the union; misses on a grammar are silent
56        // (run_query returns empty for invalid patterns).
57        let queries = [
58            "(string_literal) @s",
59            "(raw_string_literal) @s",
60            "(interpreted_string_literal) @s",
61            "(string) @s",
62            "(string_fragment) @s",
63        ];
64        let mut findings = Vec::new();
65        for q in queries {
66            for matches in crate::query::run_query(tree, lang, source, q) {
67                for cap in matches {
68                    if cap.capture_name != "s" {
69                        continue;
70                    }
71                    if let Some((label, _)) = pick_pattern(&cap.text) {
72                        findings.push(make_finding(ctx, &cap, label));
73                    }
74                }
75            }
76        }
77        findings
78    }
79}
80
81fn pick_pattern(text: &str) -> Option<(&'static str, &Regex)> {
82    let stripped = strip_quotes(text);
83    for (label, re) in PATTERNS.iter() {
84        if re.is_match(stripped) {
85            return Some((label, re));
86        }
87    }
88    None
89}
90
91fn strip_quotes(s: &str) -> &str {
92    let bytes = s.as_bytes();
93    if bytes.len() >= 2 {
94        let first = bytes[0];
95        let last = bytes[bytes.len() - 1];
96        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
97            return &s[1..s.len() - 1];
98        }
99    }
100    s
101}
102
103fn make_finding(ctx: &AnalysisContext, cap: &crate::query::QueryMatch, label: &str) -> Finding {
104    Finding {
105        smell_name: "hardcoded_secret".into(),
106        category: SmellCategory::Security,
107        severity: Severity::Warning,
108        location: Location {
109            path: ctx.file.path.clone(),
110            start_line: cap.start_line as usize,
111            start_col: cap.start_col as usize,
112            end_line: cap.end_line as usize,
113            end_col: cap.end_col as usize,
114            name: Some(label.to_string()),
115        },
116        message: format!("Possible hardcoded {label} detected"),
117        suggested_refactorings: vec![
118            "Use environment variables".into(),
119            "Use a secrets manager".into(),
120        ],
121        ..Default::default()
122    }
123}