synapse/guard/
scanners.rs1use anyhow::{bail, Context};
6use llm_guard::patterns::COMMON_INJECTION_PATTERNS;
7use llm_guard::scanners::{
8 BanSubstrings, InvisibleText, PiiPatterns, RoleOverride, ScriptMix, Secrets, TokenLimit,
9};
10use llm_guard::{ScanResult, Scanner, Severity};
11
12use super::policy::{DetailedScanner, ScannerSpec};
13
14pub struct BoxedScanner(pub Box<dyn Scanner>);
18
19impl Scanner for BoxedScanner {
20 fn name(&self) -> &'static str {
21 self.0.name()
22 }
23 fn scan<'a>(&self, input: &'a str) -> ScanResult<'a> {
24 self.0.scan(input)
25 }
26}
27
28fn parse_severity(s: &str) -> anyhow::Result<Severity> {
29 match s.to_ascii_lowercase().as_str() {
30 "block" => Ok(Severity::Block),
31 "warn" => Ok(Severity::Warn),
32 "info" => Ok(Severity::Info),
33 other => bail!("unknown severity '{other}' (expected block|warn|info)"),
34 }
35}
36
37fn leak_substrings(subs: &[String]) -> &'static [&'static str] {
41 let leaked: Vec<&'static str> = subs
42 .iter()
43 .map(|s| &*Box::leak(s.clone().into_boxed_str()))
44 .collect();
45 Box::leak(leaked.into_boxed_slice())
46}
47
48pub fn build_scanners(spec: &ScannerSpec) -> anyhow::Result<Vec<Box<dyn Scanner>>> {
50 match spec {
51 ScannerSpec::Named(name) => build(name, None),
52 ScannerSpec::Detailed(d) => build(&d.kind, Some(d)),
53 }
54}
55
56fn build(name: &str, d: Option<&DetailedScanner>) -> anyhow::Result<Vec<Box<dyn Scanner>>> {
57 let scanners: Vec<Box<dyn Scanner>> = match name {
58 "secrets" => vec![Box::new(Secrets::new())],
59 "pii" => vec![Box::new(PiiPatterns::new())],
60 "invisible_text" => vec![Box::new(InvisibleText::new())],
61 "role_override" => vec![Box::new(RoleOverride::new())],
62 "script_mix" => {
63 let threshold = d.and_then(|d| d.threshold).unwrap_or(2);
64 vec![Box::new(ScriptMix::new(threshold))]
65 }
66 "token_limit" => {
67 let max = d
68 .and_then(|d| d.max_chars)
69 .context("scanner 'token_limit' requires 'max_chars'")?;
70 vec![Box::new(TokenLimit::new(max))]
71 }
72 "ban_substrings" => {
73 let d = d.context("scanner 'ban_substrings' requires a table with 'substrings'")?;
74 if d.substrings.is_empty() {
75 bail!("scanner 'ban_substrings' requires a non-empty 'substrings' list");
76 }
77 let severity = match &d.severity {
78 Some(s) => parse_severity(s)?,
79 None => Severity::Block,
80 };
81 let patterns = leak_substrings(&d.substrings);
82 vec![Box::new(
83 BanSubstrings::new("ban_substrings", patterns).with_severity(severity),
84 )]
85 }
86 "prompt_injection" => vec![
87 Box::new(
88 BanSubstrings::new("injection", COMMON_INJECTION_PATTERNS)
89 .with_severity(Severity::Block),
90 ),
91 Box::new(RoleOverride::new()),
92 ],
93 other => bail!("unknown scanner '{other}'"),
94 };
95 Ok(scanners)
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::guard::policy::GuardrailsConfig;
102
103 fn spec(toml: &str) -> ScannerSpec {
104 let cfg = GuardrailsConfig::from_toml_str(toml).unwrap();
105 cfg.guardrails.get("p").unwrap().scanners[0].clone()
106 }
107
108 #[test]
109 fn builds_known_named_scanner() {
110 let s = spec(
111 r#"[guardrails.p]
112 scanners = ["secrets"]"#,
113 );
114 let built = build_scanners(&s).unwrap();
115 assert_eq!(built.len(), 1);
116 assert_eq!(built[0].name(), "secrets");
117 }
118
119 #[test]
120 fn prompt_injection_expands_to_bundle() {
121 let s = spec(
122 r#"[guardrails.p]
123 scanners = ["prompt_injection"]"#,
124 );
125 assert_eq!(build_scanners(&s).unwrap().len(), 2);
126 }
127
128 #[test]
129 fn ban_substrings_blocks_by_default_and_is_case_insensitive() {
130 let s = spec(
131 r#"[guardrails.p]
132 scanners = [{ type = "ban_substrings", substrings = ["forbidden"] }]"#,
133 );
134 let built = build_scanners(&s).unwrap();
135 let r = built[0].scan("This is FORBIDDEN text");
136 assert!(r.should_refuse(), "default severity must be Block");
137 }
138
139 #[test]
140 fn token_limit_requires_max_chars() {
141 let s = spec(
142 r#"[guardrails.p]
143 scanners = [{ type = "token_limit" }]"#,
144 );
145 let err = build_scanners(&s).err().expect("expected an error");
146 assert!(err.to_string().contains("max_chars"));
147 }
148
149 #[test]
150 fn unknown_scanner_errors() {
151 let s = spec(
152 r#"[guardrails.p]
153 scanners = ["nope"]"#,
154 );
155 let err = build_scanners(&s).err().expect("expected an error");
156 assert!(err.to_string().contains("unknown scanner"));
157 }
158}