ailint_core/rules/security/
sensitive_data.rs1use 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");
15const 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#[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 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
125fn 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}