ailint_core/rules/security/
sensitive_data.rs1use std::sync::OnceLock;
7
8use regex::{Regex, RegexSet};
9
10use crate::parser::ParsedDocument;
11use crate::rules::security::{line_of_offset, AIL202};
12use crate::rules::{dictionary_lines, Rule, RuleContext, RuleId, Severity, Violation};
13
14const SECRET_PATTERNS: &str = include_str!("secret_patterns.txt");
15
16const ALLOWLIST_MARKERS: &str = include_str!("secret_allowlist_markers.txt");
19
20struct Matchers {
21 regexes: Vec<Regex>,
22 set: Option<RegexSet>,
24}
25
26fn matchers() -> &'static Matchers {
27 static MATCHERS: OnceLock<Matchers> = OnceLock::new();
28 MATCHERS.get_or_init(|| {
29 let patterns: Vec<&str> = dictionary_lines(SECRET_PATTERNS)
30 .into_iter()
31 .filter(|p| Regex::new(p).is_ok())
32 .collect();
33 let regexes = patterns.iter().filter_map(|p| Regex::new(p).ok()).collect();
34 Matchers {
35 regexes,
36 set: RegexSet::new(&patterns).ok(),
37 }
38 })
39}
40
41#[derive(Debug, Default)]
43pub struct NoSensitiveDataInInstructionsRule;
44
45impl Rule for NoSensitiveDataInInstructionsRule {
46 fn id(&self) -> RuleId {
47 AIL202
48 }
49
50 fn default_severity(&self) -> Severity {
51 Severity::Error
52 }
53
54 fn run(&self, doc: &ParsedDocument, _ctx: &RuleContext<'_>) -> Vec<Violation> {
55 let matchers = matchers();
56 let indices: Vec<usize> = match &matchers.set {
57 Some(s) => s.matches(&doc.raw).into_iter().collect(),
58 None => (0..matchers.regexes.len()).collect(),
59 };
60 let mut out = Vec::new();
61 for idx in indices {
62 let re = &matchers.regexes[idx];
63 for m in re.find_iter(&doc.raw) {
64 if is_allowlisted(&doc.raw, m.start(), m.end()) || is_repeated_char(m.as_str()) {
65 continue;
66 }
67 let line = line_of_offset(&doc.raw, m.start());
68 let mut v = Violation::new(
69 AIL202,
70 self.default_severity(),
71 doc.path.clone(),
72 "possible embedded secret / API key detected",
73 )
74 .at(line, 1);
75 v.fix_hint = Some(
76 "remove the credential and reference an env var like AILINT_LLM_API_KEY instead"
77 .to_string(),
78 );
79 out.push(v);
80 }
81 }
82 out
83 }
84}
85
86fn is_allowlisted(raw: &str, start: usize, end: usize) -> bool {
87 let ctx_start = start.saturating_sub(50);
88 let ctx_end = (end + 50).min(raw.len());
89 let ctx = &raw[ctx_start..ctx_end];
90 dictionary_lines(ALLOWLIST_MARKERS)
91 .iter()
92 .any(|m| ctx.contains(m))
93}
94
95fn is_repeated_char(s: &str) -> bool {
97 let chars: Vec<char> = s.chars().collect();
98 if chars.len() < 5 {
99 return false;
100 }
101 let mut run = 1usize;
102 for w in chars.windows(2) {
103 if w[0] == w[1] {
104 run += 1;
105 if run >= 5 {
106 return true;
107 }
108 } else {
109 run = 1;
110 }
111 }
112 false
113}