systemprompt_security/policy/secrets/
mod.rs1mod 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#[derive(Debug)]
132pub struct SecretHit {
133 pub pattern: MatchedSecretPattern,
134 pub path: String,
135 pub redacted: String,
136 pub observation: bool,
137}