use std::collections::HashSet;
use regex::Regex;
use serde::Deserialize;
use systemprompt_identifiers::SecretPatternId;
use thiserror::Error;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SecretPattern {
pub id: SecretPatternId,
pub name: String,
pub regex: String,
#[serde(default)]
pub secret_capture: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub redact_whole_value: bool,
}
#[derive(Debug, Error)]
pub enum SecretPatternError {
#[error("secret_scan.patterns must be a sequence")]
InvalidList,
#[error("secret pattern {index} is invalid: {source}")]
InvalidDefinition {
index: usize,
#[source]
source: serde_yaml::Error,
},
#[error("secret pattern {index} is invalid: {field} must not be empty")]
EmptyField { index: usize, field: &'static str },
#[error("duplicate secret pattern id `{id}`")]
DuplicateId { id: SecretPatternId },
#[error("secret pattern `{id}` has invalid regex: {source}")]
InvalidRegex {
id: SecretPatternId,
#[source]
source: regex::Error,
},
#[error("secret pattern `{id}` regex can match an empty value")]
EmptyMatch { id: SecretPatternId },
#[error("secret pattern `{id}` names missing capture `{capture}`")]
MissingCapture {
id: SecretPatternId,
capture: String,
},
#[error(
"secret pattern `{id}` restricts a structured field and must set redact_whole_value: true"
)]
UnsafeFieldRecovery { id: SecretPatternId },
}
#[derive(Debug, Clone)]
pub(super) struct CompiledSecretPattern {
pub definition: SecretPattern,
pub regex: Regex,
}
pub(super) fn compile_patterns(
node: Option<&serde_yaml::Value>,
) -> Result<Vec<CompiledSecretPattern>, SecretPatternError> {
let Some(node) = node else {
return Ok(Vec::new());
};
let sequence = node.as_sequence().ok_or(SecretPatternError::InvalidList)?;
let mut ids = HashSet::with_capacity(sequence.len());
let mut compiled = Vec::with_capacity(sequence.len());
for (index, value) in sequence.iter().enumerate() {
let pattern: SecretPattern = serde_yaml::from_value(value.clone())
.map_err(|source| SecretPatternError::InvalidDefinition { index, source })?;
if !ids.insert(pattern.id.clone()) {
return Err(SecretPatternError::DuplicateId { id: pattern.id });
}
if pattern.name.trim().is_empty() {
return Err(SecretPatternError::EmptyField {
index,
field: "name",
});
}
if pattern
.field
.as_ref()
.is_some_and(|field| field.trim().is_empty())
{
return Err(SecretPatternError::EmptyField {
index,
field: "field",
});
}
if pattern.field.is_some() && !pattern.redact_whole_value {
return Err(SecretPatternError::UnsafeFieldRecovery { id: pattern.id });
}
let regex =
Regex::new(&pattern.regex).map_err(|source| SecretPatternError::InvalidRegex {
id: pattern.id.clone(),
source,
})?;
if regex.is_match("") {
return Err(SecretPatternError::EmptyMatch { id: pattern.id });
}
if let Some(capture) = &pattern.secret_capture
&& !regex.capture_names().flatten().any(|name| name == capture)
{
return Err(SecretPatternError::MissingCapture {
id: pattern.id,
capture: capture.clone(),
});
}
compiled.push(CompiledSecretPattern {
definition: pattern,
regex,
});
}
Ok(compiled)
}
pub(super) fn field_matches(path: &str, field: Option<&str>) -> bool {
field.is_none_or(|field| {
path.rsplit('.')
.next()
.is_some_and(|key| key.eq_ignore_ascii_case(field))
})
}
pub(super) const HIGH_ENTROPY_PATTERN_NAME: &str = "High-entropy token (possible credential)";