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 regex::{Regex, RegexSet};
7use serde::Deserialize;
8
9use crate::file_type::FileType;
10use crate::parser::ParsedDocument;
11use crate::rules::security::{line_of_offset, AIL202};
12use crate::rules::{dictionary_lines, Rule, RuleContext, RuleId, Severity, Violation};
13
14const DEFAULT_SECRET_PATTERNS: &str = include_str!("secret_patterns.txt");
15// Substrings within ±50 chars that mark a match as a documentation/fixture
16// placeholder and should be ignored.
17const DEFAULT_ALLOWLIST_MARKERS: &str = include_str!("secret_allowlist_markers.txt");
18
19#[derive(Debug, Default, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21struct Options {
22    patterns: Option<Vec<String>>,
23    extra_patterns: Option<Vec<String>>,
24    allowlist_markers: Option<Vec<String>>,
25    extra_allowlist_markers: Option<Vec<String>>,
26}
27
28/// AIL202 no-sensitive-data-in-instructions: flags embedded secrets.
29#[derive(Debug, Default)]
30pub struct NoSensitiveDataInInstructionsRule;
31
32impl Rule for NoSensitiveDataInInstructionsRule {
33    fn id(&self) -> RuleId {
34        AIL202
35    }
36
37    fn default_severity(&self) -> Severity {
38        Severity::Error
39    }
40
41    fn description(&self) -> &'static str {
42        "Guidance file appears to contain an embedded secret or API key."
43    }
44
45    fn fix_hint(&self) -> &'static str {
46        "Move the credential to an env var (e.g. AILINT_LLM_API_KEY) or a secret store."
47    }
48
49    fn applies_to(&self, file_type: FileType) -> bool {
50        file_type.has_prose_content()
51    }
52
53    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
54        let opts: Options = ctx
55            .options
56            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
57            .unwrap_or_default();
58
59        let mut patterns: Vec<String> = match opts.patterns {
60            Some(p) => p,
61            None => dictionary_lines(DEFAULT_SECRET_PATTERNS)
62                .into_iter()
63                .map(String::from)
64                .collect(),
65        };
66        if let Some(extra) = opts.extra_patterns {
67            patterns.extend(extra);
68        }
69        let mut markers: Vec<String> = match opts.allowlist_markers {
70            Some(m) => m,
71            None => dictionary_lines(DEFAULT_ALLOWLIST_MARKERS)
72                .into_iter()
73                .map(String::from)
74                .collect(),
75        };
76        if let Some(extra) = opts.extra_allowlist_markers {
77            markers.extend(extra);
78        }
79
80        // Compile individually (silently skipping invalid user patterns), then
81        // use a RegexSet as a single-pass prefilter over the document.
82        let compiled: Vec<Regex> = patterns.iter().filter_map(|p| Regex::new(p).ok()).collect();
83        let valid_patterns: Vec<&str> = patterns
84            .iter()
85            .filter(|p| Regex::new(p).is_ok())
86            .map(String::as_str)
87            .collect();
88        let set = RegexSet::new(&valid_patterns).ok();
89        let indices: Vec<usize> = match &set {
90            Some(s) => s.matches(&doc.raw).into_iter().collect(),
91            None => (0..compiled.len()).collect(),
92        };
93
94        let mut out = Vec::new();
95        for idx in indices {
96            let re = &compiled[idx];
97            for m in re.find_iter(&doc.raw) {
98                if is_allowlisted(&doc.raw, m.start(), m.end(), &markers)
99                    || is_repeated_char(m.as_str())
100                {
101                    continue;
102                }
103                let line = line_of_offset(&doc.raw, m.start());
104                let v = Violation::new(
105                    AIL202,
106                    ctx.severity,
107                    doc.path.clone(),
108                    "possible embedded secret",
109                )
110                .at(line, 1);
111                out.push(v);
112            }
113        }
114        out
115    }
116}
117
118fn is_allowlisted(raw: &str, start: usize, end: usize, markers: &[String]) -> bool {
119    let ctx_start = start.saturating_sub(50);
120    let ctx_end = (end + 50).min(raw.len());
121    let ctx = &raw[ctx_start..ctx_end];
122    markers.iter().any(|m| ctx.contains(m.as_str()))
123}
124
125// Treat 5+ repeated chars in the match body as a placeholder (AAAAA..., 00000...).
126fn is_repeated_char(s: &str) -> bool {
127    let chars: Vec<char> = s.chars().collect();
128    if chars.len() < 5 {
129        return false;
130    }
131    let mut run = 1usize;
132    for w in chars.windows(2) {
133        if w[0] == w[1] {
134            run += 1;
135            if run >= 5 {
136                return true;
137            }
138        } else {
139            run = 1;
140        }
141    }
142    false
143}