Skip to main content

ailint_core/rules/security/
sensitive_data.rs

1//! AIL202 `no-sensitive-data-in-instructions` — flag embedded secret-shaped
2//! strings. Matched text is never included in the violation.
3//!
4//! See: `docs/rules/security/AIL202.md`
5
6use 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
16// Substrings within ±50 chars that mark a match as a documentation/fixture
17// placeholder and should be ignored.
18const ALLOWLIST_MARKERS: &str = include_str!("secret_allowlist_markers.txt");
19
20struct Matchers {
21    regexes: Vec<Regex>,
22    // Single-pass prefilter; None only if the set fails to build.
23    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/// AIL202 no-sensitive-data-in-instructions: flags embedded secrets.
42#[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 description(&self) -> &'static str {
55        "Guidance file appears to contain an embedded secret or API key."
56    }
57
58    fn fix_hint(&self) -> &'static str {
59        "Move the credential to an env var (e.g. AILINT_LLM_API_KEY) or a secret store."
60    }
61
62    fn run(&self, doc: &ParsedDocument, _ctx: &RuleContext<'_>) -> Vec<Violation> {
63        let matchers = matchers();
64        let indices: Vec<usize> = match &matchers.set {
65            Some(s) => s.matches(&doc.raw).into_iter().collect(),
66            None => (0..matchers.regexes.len()).collect(),
67        };
68        let mut out = Vec::new();
69        for idx in indices {
70            let re = &matchers.regexes[idx];
71            for m in re.find_iter(&doc.raw) {
72                if is_allowlisted(&doc.raw, m.start(), m.end()) || is_repeated_char(m.as_str()) {
73                    continue;
74                }
75                let line = line_of_offset(&doc.raw, m.start());
76                let v = Violation::new(
77                    AIL202,
78                    self.default_severity(),
79                    doc.path.clone(),
80                    "possible embedded secret",
81                )
82                .at(line, 1);
83                out.push(v);
84            }
85        }
86        out
87    }
88}
89
90fn is_allowlisted(raw: &str, start: usize, end: usize) -> bool {
91    let ctx_start = start.saturating_sub(50);
92    let ctx_end = (end + 50).min(raw.len());
93    let ctx = &raw[ctx_start..ctx_end];
94    dictionary_lines(ALLOWLIST_MARKERS)
95        .iter()
96        .any(|m| ctx.contains(m))
97}
98
99// Treat 5+ repeated chars in the match body as a placeholder (AAAAA..., 00000...).
100fn is_repeated_char(s: &str) -> bool {
101    let chars: Vec<char> = s.chars().collect();
102    if chars.len() < 5 {
103        return false;
104    }
105    let mut run = 1usize;
106    for w in chars.windows(2) {
107        if w[0] == w[1] {
108            run += 1;
109            if run >= 5 {
110                return true;
111            }
112        } else {
113            run = 1;
114        }
115    }
116    false
117}