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, 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)";