Skip to main content

ailint_core/rules/security/
prompt_injection.rs

1//! AIL200 `no-prompt-injection-marker` — flag known prompt-injection sentinels.
2//!
3//! See: `docs/rules/security/AIL200.md`
4
5use regex::{Regex, RegexBuilder, RegexSetBuilder};
6use serde::Deserialize;
7
8use crate::file_type::FileType;
9use crate::parser::ParsedDocument;
10use crate::rules::security::{line_containing, line_of_offset, truncate_chars, AIL200};
11use crate::rules::{dictionary_lines, Rule, RuleContext, RuleId, Severity, Violation};
12
13const BUILTIN_PATTERNS: &str = include_str!("prompt_injection_patterns.txt");
14
15#[derive(Debug, Default, Deserialize)]
16struct Options {
17    #[serde(default)]
18    patterns: Option<Vec<String>>,
19    #[serde(default)]
20    extra_patterns: Option<Vec<String>>,
21}
22
23/// AIL200 no-prompt-injection-marker: flags known injection markers in text.
24#[derive(Debug, Default)]
25pub struct NoPromptInjectionMarkerRule;
26
27impl Rule for NoPromptInjectionMarkerRule {
28    fn id(&self) -> RuleId {
29        AIL200
30    }
31
32    fn default_severity(&self) -> Severity {
33        Severity::Error
34    }
35
36    fn description(&self) -> &'static str {
37        "Guidance file contains a phrase commonly used to override system prompts."
38    }
39
40    fn fix_hint(&self) -> &'static str {
41        "Remove the marker; injected content could exploit it."
42    }
43
44    fn applies_to(&self, file_type: FileType) -> bool {
45        file_type.has_prose_content()
46    }
47
48    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
49        let opts: Options = ctx
50            .options
51            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
52            .unwrap_or_default();
53
54        let base: Vec<String> = match opts.patterns {
55            Some(p) => p,
56            None => dictionary_lines(BUILTIN_PATTERNS)
57                .into_iter()
58                .map(String::from)
59                .collect(),
60        };
61        let extras = opts.extra_patterns.unwrap_or_default();
62
63        // Compile individually (silently skipping invalid user patterns), then
64        // use a RegexSet as a single-pass prefilter over the document.
65        let compiled: Vec<(&String, Regex)> = base
66            .iter()
67            .chain(extras.iter())
68            .filter_map(|p| {
69                RegexBuilder::new(p)
70                    .case_insensitive(true)
71                    .build()
72                    .ok()
73                    .map(|re| (p, re))
74            })
75            .collect();
76        let set = RegexSetBuilder::new(compiled.iter().map(|(p, _)| p.as_str()))
77            .case_insensitive(true)
78            .build()
79            .ok();
80        let matched: Vec<usize> = match &set {
81            Some(s) => s.matches(&doc.raw).into_iter().collect(),
82            None => (0..compiled.len()).collect(),
83        };
84
85        let mut out = Vec::new();
86        for idx in matched {
87            let (_, re) = &compiled[idx];
88            for m in re.find_iter(&doc.raw) {
89                let line = line_of_offset(&doc.raw, m.start());
90                let matched = truncate_chars(m.as_str().trim_end(), 60);
91                let mut v = Violation::new(
92                    AIL200,
93                    self.default_severity(),
94                    doc.path.clone(),
95                    "prompt-injection marker",
96                )
97                .at(line, 1)
98                .with_detail(matched);
99                v.snippet = Some(line_containing(&doc.raw, m.start()));
100                out.push(v);
101            }
102        }
103        out
104    }
105}