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