systemprompt_security/policy/secrets/
patterns.rs1use std::collections::HashSet;
7
8use regex::Regex;
9use serde::Deserialize;
10use systemprompt_identifiers::SecretPatternId;
11use thiserror::Error;
12
13#[derive(Debug, Clone, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct SecretPattern {
17 pub id: SecretPatternId,
18 pub name: String,
19 pub regex: String,
20 #[serde(default)]
21 pub secret_capture: Option<String>,
22 #[serde(default)]
23 pub field: Option<String>,
24 #[serde(default)]
25 pub redact_whole_value: bool,
26}
27
28#[derive(Debug, Error)]
29pub enum SecretPatternError {
30 #[error("secret_scan.patterns must be a sequence")]
31 InvalidList,
32 #[error("secret pattern {index} is invalid: {source}")]
33 InvalidDefinition {
34 index: usize,
35 #[source]
36 source: serde_yaml::Error,
37 },
38 #[error("secret pattern {index} is invalid: {field} must not be empty")]
39 EmptyField { index: usize, field: &'static str },
40 #[error("duplicate secret pattern id `{id}`")]
41 DuplicateId { id: SecretPatternId },
42 #[error("secret pattern `{id}` has invalid regex: {source}")]
43 InvalidRegex {
44 id: SecretPatternId,
45 #[source]
46 source: regex::Error,
47 },
48 #[error("secret pattern `{id}` regex can match an empty value")]
49 EmptyMatch { id: SecretPatternId },
50 #[error("secret pattern `{id}` names missing capture `{capture}`")]
51 MissingCapture {
52 id: SecretPatternId,
53 capture: String,
54 },
55 #[error(
56 "secret pattern `{id}` restricts a structured field and must set redact_whole_value: true"
57 )]
58 UnsafeFieldRecovery { id: SecretPatternId },
59}
60
61#[derive(Debug, Clone)]
62pub(super) struct CompiledSecretPattern {
63 pub definition: SecretPattern,
64 pub regex: Regex,
65}
66
67pub(super) fn compile_patterns(
68 node: Option<&serde_yaml::Value>,
69) -> Result<Vec<CompiledSecretPattern>, SecretPatternError> {
70 let Some(node) = node else {
71 return Ok(Vec::new());
72 };
73 let sequence = node.as_sequence().ok_or(SecretPatternError::InvalidList)?;
74 let mut ids = HashSet::with_capacity(sequence.len());
75 let mut compiled = Vec::with_capacity(sequence.len());
76 for (index, value) in sequence.iter().enumerate() {
77 let pattern: SecretPattern = serde_yaml::from_value(value.clone())
78 .map_err(|source| SecretPatternError::InvalidDefinition { index, source })?;
79 if !ids.insert(pattern.id.clone()) {
80 return Err(SecretPatternError::DuplicateId { id: pattern.id });
81 }
82 if pattern.name.trim().is_empty() {
83 return Err(SecretPatternError::EmptyField {
84 index,
85 field: "name",
86 });
87 }
88 if pattern
89 .field
90 .as_ref()
91 .is_some_and(|field| field.trim().is_empty())
92 {
93 return Err(SecretPatternError::EmptyField {
94 index,
95 field: "field",
96 });
97 }
98 if pattern.field.is_some() && !pattern.redact_whole_value {
99 return Err(SecretPatternError::UnsafeFieldRecovery { id: pattern.id });
100 }
101 let regex =
102 Regex::new(&pattern.regex).map_err(|source| SecretPatternError::InvalidRegex {
103 id: pattern.id.clone(),
104 source,
105 })?;
106 if regex.is_match("") {
107 return Err(SecretPatternError::EmptyMatch { id: pattern.id });
108 }
109 if let Some(capture) = &pattern.secret_capture
110 && !regex.capture_names().flatten().any(|name| name == capture)
111 {
112 return Err(SecretPatternError::MissingCapture {
113 id: pattern.id,
114 capture: capture.clone(),
115 });
116 }
117 compiled.push(CompiledSecretPattern {
118 definition: pattern,
119 regex,
120 });
121 }
122 Ok(compiled)
123}
124
125pub(super) fn field_matches(path: &str, field: Option<&str>) -> bool {
126 field.is_none_or(|field| {
127 path.rsplit('.')
128 .next()
129 .is_some_and(|key| key.eq_ignore_ascii_case(field))
130 })
131}
132
133pub(super) const HIGH_ENTROPY_PATTERN_NAME: &str = "High-entropy token (possible credential)";