Skip to main content

systemprompt_security/policy/secrets/
mod.rs

1//! Built-in plaintext secret-pattern registry and scanner.
2//!
3//! [`SECRET_PATTERNS`] holds the vendor-prefix ruleset (seeded from the
4//! gitleaks MIT ruleset); [`find_high_entropy_token`] backstops it: a
5//! credential with no recognisable vendor prefix — a random base64 blob pasted
6//! into a prompt — matches no pattern but still reads as machine-generated key
7//! material, and is reported under the pseudo-pattern id `high-entropy-token`.
8//!
9//! [`detect_secrets`] drives the `secret_scan` builtin policy;
10//! [`scan_str_for_secret`] is the string-level entry point shared with gateway
11//! safety scanners so every enforcement surface flags the same credentials.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16mod entropy;
17mod patterns;
18
19use std::sync::LazyLock;
20
21use regex::Regex;
22
23use super::governed::GovernedInput;
24pub use entropy::{DEFAULT_MIN_LEN, DEFAULT_THRESHOLD, EntropyConfig, find_high_entropy_token};
25use patterns::HIGH_ENTROPY_PATTERN;
26pub use patterns::{SECRET_PATTERNS, SecretPattern};
27
28static DEFAULT_ENTROPY: LazyLock<EntropyConfig> = LazyLock::new(EntropyConfig::default);
29
30static COMPILED: LazyLock<Vec<(usize, Regex)>> = LazyLock::new(|| {
31    SECRET_PATTERNS
32        .iter()
33        .enumerate()
34        .filter_map(|(i, p)| match Regex::new(p.expr) {
35            Ok(re) => Some((i, re)),
36            Err(e) => {
37                tracing::error!(pattern_id = %p.id, error = %e, "secret pattern disabled: regex failed to compile");
38                None
39            },
40        })
41        .collect()
42});
43
44#[must_use]
45pub fn compiled_pattern_count() -> usize {
46    COMPILED.len()
47}
48
49fn redacted_snippet(s: &str, match_start: usize) -> String {
50    let mut snippet_end = (match_start + 12).min(s.len());
51    while !s.is_char_boundary(snippet_end) {
52        snippet_end -= 1;
53    }
54    format!("{}...[REDACTED]", &s[match_start..snippet_end])
55}
56
57fn scan_str(s: &str, entropy: &EntropyConfig) -> Option<(&'static SecretPattern, String)> {
58    for (i, re) in COMPILED.iter() {
59        if let Some(m) = re.find(s) {
60            return Some((&SECRET_PATTERNS[*i], redacted_snippet(s, m.start())));
61        }
62    }
63    find_high_entropy_token(s, entropy).map(|token| {
64        let start = token.as_ptr() as usize - s.as_ptr() as usize;
65        (&HIGH_ENTROPY_PATTERN, redacted_snippet(s, start))
66    })
67}
68
69#[must_use]
70pub fn scan_str_for_secret(text: &str) -> Option<String> {
71    scan_str(text, &DEFAULT_ENTROPY).map(|(_, redacted)| redacted)
72}
73
74/// One credential found in a governed input: the pattern that fired, the
75/// dotted JSON path it fired at, and a truncated redacted snippet safe for
76/// deny messages and audit rows.
77#[derive(Debug)]
78pub struct SecretHit {
79    pub pattern: &'static SecretPattern,
80    pub path: String,
81    pub redacted: String,
82}
83
84#[must_use]
85pub fn detect_secrets(input: &GovernedInput) -> Option<SecretHit> {
86    detect_secrets_with(input, &DEFAULT_ENTROPY)
87}
88
89#[must_use]
90pub fn detect_secrets_with(input: &GovernedInput, entropy: &EntropyConfig) -> Option<SecretHit> {
91    input.strings().into_iter().find_map(|s| {
92        scan_str(s.value, entropy).map(|(pattern, redacted)| SecretHit {
93            pattern,
94            path: s.path,
95            redacted,
96        })
97    })
98}