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/// Number of built-in patterns whose regex compiled. Pinned equal to
45/// `SECRET_PATTERNS.len()` by the test suite so a broken expression cannot
46/// silently disable a pattern.
47#[must_use]
48pub fn compiled_pattern_count() -> usize {
49    COMPILED.len()
50}
51
52fn redacted_snippet(s: &str, match_start: usize) -> String {
53    let mut snippet_end = (match_start + 12).min(s.len());
54    while !s.is_char_boundary(snippet_end) {
55        snippet_end -= 1;
56    }
57    format!("{}...[REDACTED]", &s[match_start..snippet_end])
58}
59
60fn scan_str(s: &str, entropy: &EntropyConfig) -> Option<(&'static SecretPattern, String)> {
61    for (i, re) in COMPILED.iter() {
62        if let Some(m) = re.find(s) {
63            return Some((&SECRET_PATTERNS[*i], redacted_snippet(s, m.start())));
64        }
65    }
66    find_high_entropy_token(s, entropy).map(|token| {
67        let start = token.as_ptr() as usize - s.as_ptr() as usize;
68        (&HIGH_ENTROPY_PATTERN, redacted_snippet(s, start))
69    })
70}
71
72/// String-level entry point for callers with no policy configuration to hand —
73/// the gateway safety scanners. Uses [`EntropyConfig::default`].
74#[must_use]
75pub fn scan_str_for_secret(text: &str) -> Option<String> {
76    scan_str(text, &DEFAULT_ENTROPY).map(|(_, redacted)| redacted)
77}
78
79/// One credential found in a governed input: the pattern that fired, the
80/// dotted JSON path it fired at, and a truncated redacted snippet safe for
81/// deny messages and audit rows.
82#[derive(Debug)]
83pub struct SecretHit {
84    pub pattern: &'static SecretPattern,
85    pub path: String,
86    pub redacted: String,
87}
88
89#[must_use]
90pub fn detect_secrets(input: &GovernedInput) -> Option<SecretHit> {
91    detect_secrets_with(input, &DEFAULT_ENTROPY)
92}
93
94/// [`detect_secrets`] under an operator-supplied entropy configuration. The
95/// vendor pattern list is not tunable and applies either way.
96#[must_use]
97pub fn detect_secrets_with(input: &GovernedInput, entropy: &EntropyConfig) -> Option<SecretHit> {
98    input.strings().into_iter().find_map(|s| {
99        scan_str(s.value, entropy).map(|(pattern, redacted)| SecretHit {
100            pattern,
101            path: s.path,
102            redacted,
103        })
104    })
105}