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 patterns;
17
18use std::sync::LazyLock;
19
20use regex::Regex;
21
22use super::governed::GovernedInput;
23use patterns::HIGH_ENTROPY_PATTERN;
24pub use patterns::{SECRET_PATTERNS, SecretPattern};
25
26static COMPILED: LazyLock<Vec<(usize, Regex)>> = LazyLock::new(|| {
27    SECRET_PATTERNS
28        .iter()
29        .enumerate()
30        .filter_map(|(i, p)| match Regex::new(p.expr) {
31            Ok(re) => Some((i, re)),
32            // Why: a test pins compiled_pattern_count() == SECRET_PATTERNS.len(),
33            // so this arm is a per-pattern guard for the release binary only.
34            Err(e) => {
35                tracing::error!(pattern_id = %p.id, error = %e, "secret pattern disabled: regex failed to compile");
36                None
37            },
38        })
39        .collect()
40});
41
42/// Number of built-in patterns whose regex compiled. Pinned equal to
43/// `SECRET_PATTERNS.len()` by the test suite so a broken expression cannot
44/// silently disable a pattern.
45#[must_use]
46pub fn compiled_pattern_count() -> usize {
47    COMPILED.len()
48}
49
50const ENTROPY_MIN_LEN: usize = 32;
51
52// Why: bits per character. Random base64 of 32+ chars sits around 4.4-4.8;
53// English-ish identifiers stay under 4.0.
54const ENTROPY_THRESHOLD: f64 = 4.0;
55
56fn shannon_entropy(s: &str) -> f64 {
57    let mut counts = [0u32; 256];
58    let bytes = s.as_bytes();
59    for &b in bytes {
60        counts[usize::from(b)] += 1;
61    }
62    let len = bytes.len() as f64;
63    counts
64        .iter()
65        .filter(|&&c| c > 0)
66        .map(|&c| {
67            let p = f64::from(c) / len;
68            -p * p.log2()
69        })
70        .sum()
71}
72
73// Why: the mixed-class requirement (upper AND lower AND digit) is what keeps
74// git SHAs, UUIDs and hex digests out; relaxing it reintroduces those false
75// positives.
76#[must_use]
77pub fn find_high_entropy_token(text: &str) -> Option<&str> {
78    text.split(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;:".contains(c))
79        .find(|token| {
80            token.len() >= ENTROPY_MIN_LEN
81                && token
82                    .chars()
83                    .all(|c| c.is_ascii_alphanumeric() || "+/=_-".contains(c))
84                && token.chars().any(|c| c.is_ascii_uppercase())
85                && token.chars().any(|c| c.is_ascii_lowercase())
86                && token.chars().any(|c| c.is_ascii_digit())
87                && shannon_entropy(token) >= ENTROPY_THRESHOLD
88        })
89}
90
91fn redacted_snippet(s: &str, match_start: usize) -> String {
92    let mut snippet_end = (match_start + 12).min(s.len());
93    while !s.is_char_boundary(snippet_end) {
94        snippet_end -= 1;
95    }
96    format!("{}...[REDACTED]", &s[match_start..snippet_end])
97}
98
99fn scan_str(s: &str) -> Option<(&'static SecretPattern, String)> {
100    for (i, re) in COMPILED.iter() {
101        if let Some(m) = re.find(s) {
102            return Some((&SECRET_PATTERNS[*i], redacted_snippet(s, m.start())));
103        }
104    }
105    find_high_entropy_token(s).map(|token| {
106        let start = token.as_ptr() as usize - s.as_ptr() as usize;
107        (&HIGH_ENTROPY_PATTERN, redacted_snippet(s, start))
108    })
109}
110
111// Why: shares [`SECRET_PATTERNS`] with the governance chain so gateway safety
112// scanners and the tool-use governor flag the same credentials.
113#[must_use]
114pub fn scan_str_for_secret(text: &str) -> Option<String> {
115    scan_str(text).map(|(_, redacted)| redacted)
116}
117
118/// One credential found in a governed input: the pattern that fired, the
119/// dotted JSON path it fired at, and a truncated redacted snippet safe for
120/// deny messages and audit rows.
121#[derive(Debug)]
122pub struct SecretHit {
123    pub pattern: &'static SecretPattern,
124    pub path: String,
125    pub redacted: String,
126}
127
128#[must_use]
129pub fn detect_secrets(input: &GovernedInput) -> Option<SecretHit> {
130    input.strings().into_iter().find_map(|s| {
131        scan_str(s.value).map(|(pattern, redacted)| SecretHit {
132            pattern,
133            path: s.path,
134            redacted,
135        })
136    })
137}