Skip to main content

systemprompt_security/policy/secrets/
mod.rs

1//! Installation-configured plaintext secret scanning and recovery.
2//!
3//! [`SecretScanner`] compiles one installation-owned signature catalog and
4//! applies it consistently to governance evaluation, gateway responses, and
5//! prompt recovery. Entropy detection remains an observation-only heuristic.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10mod entropy;
11mod entropy_yaml;
12mod fingerprint;
13mod patterns;
14mod recovery;
15mod signatures;
16
17
18use regex::Captures;
19use systemprompt_identifiers::SecretPatternId;
20
21use super::governed::{GovernedInput, GovernedString};
22pub use entropy::{DEFAULT_MIN_LEN, DEFAULT_THRESHOLD, EntropyConfig, find_high_entropy_token};
23use patterns::{CompiledSecretPattern, HIGH_ENTROPY_PATTERN_NAME, compile_patterns, field_matches};
24pub use patterns::{SecretPattern, SecretPatternError};
25pub use recovery::{
26    MAX_RECOVERY_FINDINGS, REDACTION_MARKER, SecretFinding, SecretSource, redact_spans,
27};
28pub use signatures::SignatureExemptions;
29
30#[derive(Debug, Clone)]
31pub struct SecretScanner {
32    patterns: Vec<CompiledSecretPattern>,
33    entropy: EntropyConfig,
34}
35
36impl SecretScanner {
37    pub fn from_policy_yaml(value: &serde_yaml::Value) -> Result<Self, SecretPatternError> {
38        Ok(Self {
39            patterns: compile_patterns(value.get("patterns"))?,
40            entropy: entropy_yaml::from_yaml(value),
41        })
42    }
43
44    #[must_use]
45    pub const fn pattern_count(&self) -> usize {
46        self.patterns.len()
47    }
48
49    #[must_use]
50    pub const fn entropy(&self) -> &EntropyConfig {
51        &self.entropy
52    }
53
54    #[must_use]
55    pub fn detect(&self, input: &GovernedInput) -> Option<SecretHit> {
56        let strings = input.strings();
57        let exemptions = SignatureExemptions::from_strings(&strings);
58        strings
59            .iter()
60            .find_map(|found| self.detect_confirmed(found))
61            .or_else(|| {
62                strings.iter().find_map(|found| {
63                    if exemptions.exempts_entropy(&found.path) {
64                        return None;
65                    }
66                    entropy::high_entropy_spans(found.value, &self.entropy)
67                        .next()
68                        .map(|(span, _)| SecretHit {
69                            pattern: MatchedSecretPattern {
70                                id: SecretPatternId::high_entropy(),
71                                name: HIGH_ENTROPY_PATTERN_NAME.to_owned(),
72                            },
73                            path: found.path.clone(),
74                            redacted: redacted_snippet(found.value, span.start, span.end),
75                            observation: true,
76                        })
77                })
78            })
79    }
80
81    #[must_use]
82    pub fn findings(&self, input: &GovernedInput) -> Vec<SecretFinding> {
83        recovery::secret_findings(self, input)
84    }
85
86    fn detect_confirmed(&self, found: &GovernedString<'_>) -> Option<SecretHit> {
87        self.patterns.iter().find_map(|pattern| {
88            if !field_matches(&found.path, pattern.definition.field.as_deref()) {
89                return None;
90            }
91            pattern.regex.captures(found.value).and_then(|captures| {
92                selected_match(pattern, &captures).map(|matched| SecretHit {
93                    pattern: MatchedSecretPattern {
94                        id: pattern.definition.id.clone(),
95                        name: pattern.definition.name.clone(),
96                    },
97                    path: found.path.clone(),
98                    redacted: redacted_snippet(found.value, matched.start(), matched.end()),
99                    observation: false,
100                })
101            })
102        })
103    }
104}
105
106fn selected_match<'a>(
107    pattern: &CompiledSecretPattern,
108    captures: &'a Captures<'a>,
109) -> Option<regex::Match<'a>> {
110    pattern
111        .definition
112        .secret_capture
113        .as_deref()
114        .map_or_else(|| captures.get(0), |name| captures.name(name))
115}
116
117fn redacted_snippet(value: &str, start: usize, end: usize) -> String {
118    format!(
119        "fingerprint:{}...[REDACTED]",
120        fingerprint::of(&value[start..end])
121    )
122}
123
124#[derive(Debug)]
125pub struct MatchedSecretPattern {
126    pub id: SecretPatternId,
127    pub name: String,
128}
129
130/// A credential or entropy observation found in governed input.
131#[derive(Debug)]
132pub struct SecretHit {
133    pub pattern: MatchedSecretPattern,
134    pub path: String,
135    pub redacted: String,
136    pub observation: bool,
137}