Skip to main content

synapse/guard/
scanners.rs

1//! Name→scanner registry. Turns a `ScannerSpec` into one or more
2//! `llm-guard` scanners. A bundle alias (e.g. `prompt_injection`) yields
3//! several. There is no global registry in `llm-guard`, so this is ours.
4
5use 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
14/// Adapter so a `Box<dyn Scanner>` can be handed to `Pipeline::with`,
15/// which takes `impl Scanner + 'static`. `Box<dyn Scanner>` does not itself
16/// implement `Scanner`, so we delegate through this newtype.
17pub 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
37/// Promote owned config substrings to `'static`. Called once per
38/// `ban_substrings` policy at startup; the pipelines live for the whole
39/// process, so this bounded leak is intentional.
40fn 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
48/// Build the scanner(s) for one spec. Unknown names / missing params error.
49pub 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}