ailint_core/rules/security/
prompt_injection.rs1use 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#[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 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}