cha_core/plugins/
hardcoded_secret.rs1use 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 [
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 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}