Skip to main content

systemprompt_security/policy/secrets/
patterns.rs

1//! Configured secret-pattern definitions and compilation.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::collections::HashSet;
7
8use regex::Regex;
9use serde::Deserialize;
10use systemprompt_identifiers::SecretPatternId;
11use thiserror::Error;
12
13/// One installation-owned plaintext-credential signature.
14#[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)";