#![allow(missing_docs)]
mod evidence;
pub(crate) mod load;
mod validate;
use std::fmt;
use serde::ser::Error as _;
use serde::{Deserialize, Serialize};
pub use evidence::{ProviderEvidenceRole, ProviderEvidenceSensitivity};
pub use load::{load_detectors, read_detector_toml_file, SpecError, DETECTOR_TOML_FILE_BYTES};
pub use validate::{validate_detector, QualityIssue};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetadataSpec {
pub name: String,
pub json_path: String,
#[serde(default)]
pub sensitivity: ProviderEvidenceSensitivity,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct DetectorSpec {
pub id: String,
pub name: String,
pub service: String,
pub severity: Severity,
#[serde(default)]
pub kind: DetectorKind,
pub ml: DetectorMlPolicySpec,
#[serde(default)]
pub match_confidence: Option<DetectorMatchConfidenceSpec>,
#[serde(default)]
pub validators: Vec<DetectorValidatorSpec>,
#[serde(default)]
pub decode_transforms: DetectorDecodeTransformSpec,
#[serde(default)]
pub patterns: Vec<PatternSpec>,
#[serde(default)]
pub companions: Vec<CompanionSpec>,
pub verify: Option<VerifySpec>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub simdsieve_prefixes: Vec<String>,
#[serde(default)]
pub min_confidence: Option<f64>,
#[serde(default)]
pub entropy_floor: Vec<EntropyFloorBucket>,
#[serde(default)]
pub entropy_high: Option<f64>,
#[serde(default)]
pub entropy_low: Option<f64>,
#[serde(default)]
pub entropy_very_high: Option<f64>,
#[serde(default)]
pub entropy_fallback: Option<EntropyFallbackMetadata>,
#[serde(default)]
pub entropy_fallback_confidence: Option<EntropyFallbackConfidenceSpec>,
#[serde(default)]
pub generic_assignment_confidence: Option<GenericAssignmentConfidenceSpec>,
#[serde(default)]
pub entropy_roles: Vec<EntropyDetectionRole>,
#[serde(default)]
pub sensitive_path_entropy_very_high: Option<f64>,
#[serde(default)]
pub entropy_shapes: Vec<EntropyShapeSpec>,
#[serde(default)]
pub plausibility: Option<DetectorPlausibilityPolicySpec>,
#[serde(default)]
pub entropy_policy_priority: Option<u16>,
#[serde(default)]
pub bpe_max_bytes_per_token: Option<f64>,
#[serde(default)]
pub bpe_enabled: Option<bool>,
#[serde(default)]
pub decoded_hex_key_material_lengths: Vec<usize>,
#[serde(default)]
pub canonical_hex_key_material: Vec<CanonicalHexKeyMaterialSpec>,
#[serde(default)]
pub keyword_free_min_len: Option<usize>,
#[serde(default)]
pub min_len: Option<usize>,
#[serde(default)]
pub max_len: Option<usize>,
#[serde(default)]
pub generic_vendor_suffixes: Vec<String>,
#[serde(default)]
pub generic_assignment_tail_suffixes: Vec<String>,
#[serde(default)]
pub allowlist_paths: Vec<String>,
#[serde(default)]
pub allowlist_values: Vec<String>,
#[serde(default)]
pub stopwords: Vec<String>,
#[serde(default)]
pub public_identifier_assignment_markers: Vec<String>,
#[serde(default)]
pub structural_password_slot: bool,
#[serde(default)]
pub weak_anchor: bool,
#[serde(default)]
pub private_key_block: bool,
#[serde(default)]
pub resolution_priority: i16,
#[serde(default)]
pub credential_shape: Option<CredentialShape>,
#[serde(default)]
pub tests: Vec<DetectorTestSpec>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DetectorKind {
#[default]
Regex,
Phase2Generic,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DetectorDecodeTransformSpec {
#[serde(default)]
pub reverse_prefixes: Vec<String>,
#[serde(default)]
pub caesar_prefixes: Vec<String>,
}
impl DetectorDecodeTransformSpec {
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
validate_decode_transform_prefixes(
"reverse_prefixes",
&self.reverse_prefixes,
true,
&mut issues,
);
validate_decode_transform_prefixes(
"caesar_prefixes",
&self.caesar_prefixes,
false,
&mut issues,
);
issues
}
}
fn validate_decode_transform_prefixes(
field: &str,
prefixes: &[String],
reverse: bool,
issues: &mut Vec<String>,
) {
let mut seen = std::collections::HashSet::with_capacity(prefixes.len());
for prefix in prefixes {
if prefix.is_empty() || !prefix.is_ascii() {
issues.push(format!("{field} entry {prefix:?} must be non-empty ASCII"));
continue;
}
if reverse && prefix.len() < 3 {
issues.push(format!(
"{field} entry {prefix:?} must contain at least three bytes"
));
}
if !reverse && !prefix.bytes().any(|byte| byte.is_ascii_alphabetic()) {
issues.push(format!(
"{field} entry {prefix:?} must contain an ASCII letter"
));
}
if !seen.insert(prefix.as_str()) {
issues.push(format!("{field} contains duplicate prefix {prefix:?}"));
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DetectorMlMode {
#[default]
Disabled,
Lift,
Blend,
Authoritative,
}
impl DetectorMlMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Lift => "lift",
Self::Blend => "blend",
Self::Authoritative => "authoritative",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DetectorMlPolicySpec {
pub match_mode: DetectorMlMode,
pub entropy_mode: DetectorMlMode,
pub weight: f64,
pub context_radius_lines: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DetectorMatchConfidenceSpec {
pub literal_prefix_weight: f64,
pub context_anchor_weight: f64,
pub entropy_weight: f64,
pub high_entropy_partial_weight: f64,
pub moderate_entropy_threshold: f64,
pub moderate_entropy_weight: f64,
pub low_entropy_penalty_floor: f64,
pub low_entropy_min_match_length: usize,
pub low_entropy_penalty_multiplier: f64,
pub keyword_nearby_weight: f64,
pub sensitive_file_weight: f64,
pub companion_weight: f64,
pub very_high_entropy_margin: f64,
pub named_anchor_floor: Option<f64>,
pub low_promise_confidence: Option<f64>,
pub assignment_context_multiplier: f64,
pub string_literal_context_multiplier: f64,
pub unknown_context_multiplier: f64,
pub documentation_context_multiplier: f64,
pub comment_context_multiplier: f64,
pub test_context_multiplier: f64,
pub encrypted_context_multiplier: f64,
pub soft_context_suppression_threshold: f64,
pub encrypted_context_suppression_threshold: f64,
pub post_match: DetectorPostMatchConfidenceSpec,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DetectorPostMatchConfidenceSpec {
pub placeholder_multiplier: f64,
pub minimum_byte_diversity: f64,
pub low_diversity_multiplier: f64,
pub maximum_repeat_ratio: f64,
pub degenerate_run_min_length: usize,
pub degenerate_repeat_multiplier: f64,
pub data_envelope_multiplier: Option<f64>,
pub fixture_path_multiplier: f64,
pub ml_context_reapply_below: f64,
}
impl DetectorPostMatchConfidenceSpec {
pub fn validate(self) -> Result<(), &'static str> {
if [
self.placeholder_multiplier,
self.minimum_byte_diversity,
self.low_diversity_multiplier,
self.maximum_repeat_ratio,
self.degenerate_repeat_multiplier,
self.fixture_path_multiplier,
self.ml_context_reapply_below,
]
.into_iter()
.chain(self.data_envelope_multiplier)
.any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
{
return Err("post-match ratios and multipliers must be finite values in [0.0, 1.0]");
}
if self.degenerate_run_min_length == 0 {
return Err("post-match degenerate_run_min_length must be greater than zero");
}
Ok(())
}
}
impl DetectorMatchConfidenceSpec {
pub fn validate(self) -> Result<(), &'static str> {
self.post_match.validate()?;
let probabilities = [
self.literal_prefix_weight,
self.context_anchor_weight,
self.entropy_weight,
self.high_entropy_partial_weight,
self.moderate_entropy_weight,
self.low_entropy_penalty_multiplier,
self.keyword_nearby_weight,
self.sensitive_file_weight,
self.companion_weight,
self.assignment_context_multiplier,
self.string_literal_context_multiplier,
self.unknown_context_multiplier,
self.documentation_context_multiplier,
self.comment_context_multiplier,
self.test_context_multiplier,
self.encrypted_context_multiplier,
self.soft_context_suppression_threshold,
self.encrypted_context_suppression_threshold,
];
if probabilities
.into_iter()
.chain(self.named_anchor_floor)
.chain(self.low_promise_confidence)
.any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
{
return Err("weights, multipliers, floors, and final scores must be finite values in [0.0, 1.0]");
}
if [
self.moderate_entropy_threshold,
self.low_entropy_penalty_floor,
self.very_high_entropy_margin,
]
.into_iter()
.any(|value| !value.is_finite() || !(0.0..=u8::BITS as f64).contains(&value))
{
return Err("entropy thresholds and margins must be finite values in [0.0, 8.0]");
}
if self.low_entropy_penalty_floor > self.moderate_entropy_threshold {
return Err("low_entropy_penalty_floor must not exceed moderate_entropy_threshold");
}
if self.moderate_entropy_weight > self.high_entropy_partial_weight
|| self.high_entropy_partial_weight > self.entropy_weight
{
return Err("entropy weights must satisfy moderate <= high partial <= full");
}
if self.literal_prefix_weight
+ self.context_anchor_weight
+ self.entropy_weight
+ self.keyword_nearby_weight
+ self.sensitive_file_weight
+ self.companion_weight
<= 0.0
{
return Err("at least one maximum signal weight must be positive");
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DetectorBase64Alphabet {
Standard,
StandardNoPad,
UrlSafe,
UrlSafeNoPad,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
pub enum DetectorValidatorSpec {
Crc32Base62 {
prefixes: Vec<String>,
entropy_len: usize,
checksum_len: usize,
reject_overlong: bool,
confidence_floor: f64,
},
GithubFineGrainedCrc32 {
prefixes: Vec<String>,
left_len: usize,
right_len: usize,
checksum_len: usize,
confidence_floor: f64,
},
Base64Payload {
prefixes: Vec<String>,
alphabet: DetectorBase64Alphabet,
min_encoded_len: usize,
max_encoded_len: usize,
min_decoded_len: usize,
confidence_floor: f64,
},
PatternShape {
prefixes: Vec<String>,
allow_overlong: bool,
},
}
impl DetectorValidatorSpec {
pub fn prefixes(&self) -> &[String] {
match self {
Self::Crc32Base62 { prefixes, .. }
| Self::GithubFineGrainedCrc32 { prefixes, .. }
| Self::Base64Payload { prefixes, .. }
| Self::PatternShape { prefixes, .. } => prefixes,
}
}
pub fn confidence_floor(&self) -> Option<f64> {
match self {
Self::Crc32Base62 {
confidence_floor, ..
}
| Self::GithubFineGrainedCrc32 {
confidence_floor, ..
}
| Self::Base64Payload {
confidence_floor, ..
} => Some(*confidence_floor),
Self::PatternShape { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DetectorPlausibilityPolicySpec {
pub mixed_alnum_floor: f64,
pub symbolic_entropy_floor: f64,
pub second_half_entropy_floor: f64,
pub second_half_min_len: usize,
pub unique_chars_min_len: usize,
pub min_unique_chars: usize,
pub unanchored_hex_max_len: usize,
pub identical_char_max_len: usize,
pub structured_dotted_min_len: usize,
pub mixed_alnum_min_len: usize,
pub isolated_mixed_entropy_floor: f64,
pub isolated_symbolic_min_len: usize,
pub isolated_symbolic_min_symbols: usize,
pub isolated_symbolic_requires_non_underscore: bool,
pub isolated_alpha_only_min_symbols: usize,
pub isolated_alpha_only_min_alpha_ratio: f64,
pub min_alnum_ratio: f64,
pub source_type_name_max_len: usize,
pub source_type_name_min_uppercase: usize,
pub url_path_high_entropy_min_len: usize,
pub isolated_colon_left_min_len: usize,
pub isolated_colon_right_min_len: usize,
pub leading_slash_base64_entropy_floor: f64,
pub leading_slash_base64_min_len: usize,
#[serde(default)]
pub keyword_free_operator_margin: Option<f64>,
pub reject_repeated_blocks: bool,
pub allow_alphabetic_credential: bool,
pub reject_program_identifiers: bool,
pub reject_source_symbol_identifiers: bool,
pub reject_dash_segmented_alnum: bool,
}
impl Default for DetectorMlPolicySpec {
fn default() -> Self {
Self {
match_mode: DetectorMlMode::Disabled,
entropy_mode: DetectorMlMode::Disabled,
weight: 0.0,
context_radius_lines: 0,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct EntropyFloorBucket {
#[serde(default)]
pub max_len: Option<usize>,
pub floor: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct EntropyFallbackMetadata {
pub class: EntropyFallbackClass,
pub id: String,
pub name: String,
pub service: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EntropyFallbackConfidenceSpec {
pub low_entropy_max: f64,
pub high_entropy: f64,
pub very_high_entropy: f64,
pub keyword_lift: f64,
pub max_confidence: f64,
}
impl EntropyFallbackConfidenceSpec {
pub fn validate(self) -> Result<(), &'static str> {
let values = [
self.low_entropy_max,
self.high_entropy,
self.very_high_entropy,
self.keyword_lift,
self.max_confidence,
];
if values
.into_iter()
.any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
{
return Err("all values must be finite probabilities in [0.0, 1.0]");
}
if self.low_entropy_max > self.high_entropy
|| self.high_entropy > self.very_high_entropy
|| self.very_high_entropy > self.max_confidence
{
return Err(
"confidence tiers must satisfy low_entropy_max <= high_entropy <= very_high_entropy <= max_confidence",
);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GenericAssignmentConfidenceSpec {
pub ordinary_base: f64,
pub test_base: f64,
pub documentation_base: f64,
pub comment_base: f64,
pub scanned_comment_base: f64,
pub entropy_reference: f64,
pub entropy_gain_per_bit: f64,
pub entropy_lift_max: f64,
pub length_reference: usize,
pub length_gain_per_byte: f64,
pub length_lift_max: f64,
pub max_confidence: f64,
}
impl GenericAssignmentConfidenceSpec {
pub fn validate(self) -> Result<(), &'static str> {
let probabilities = [
self.ordinary_base,
self.test_base,
self.documentation_base,
self.comment_base,
self.scanned_comment_base,
self.entropy_gain_per_bit,
self.entropy_lift_max,
self.length_gain_per_byte,
self.length_lift_max,
self.max_confidence,
];
if probabilities
.into_iter()
.any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
{
return Err(
"bases, gains, lift caps, and max_confidence must be finite values in [0.0, 1.0]",
);
}
if !self.entropy_reference.is_finite()
|| !(0.0..=u8::BITS as f64).contains(&self.entropy_reference)
{
return Err("entropy_reference must be finite and in [0.0, 8.0]");
}
if [
self.ordinary_base,
self.test_base,
self.documentation_base,
self.comment_base,
self.scanned_comment_base,
]
.into_iter()
.any(|base| base > self.max_confidence)
{
return Err("every context base must be less than or equal to max_confidence");
}
Ok(())
}
}
impl EntropyFallbackMetadata {
pub fn has_valid_identity(&self) -> bool {
let Some(suffix) = self.id.strip_prefix("entropy-") else {
return false;
};
!suffix.is_empty()
&& suffix
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& !self.name.trim().is_empty()
&& !self.service.trim().is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum EntropyFallbackClass {
#[default]
Generic,
Password,
Token,
ApiKey,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EntropyDetectionRole {
KeywordFree,
IsolatedBare,
UnclaimedKeyword,
}
impl EntropyDetectionRole {
pub const fn as_str(self) -> &'static str {
match self {
Self::KeywordFree => "keyword-free",
Self::IsolatedBare => "isolated-bare",
Self::UnclaimedKeyword => "unclaimed-keyword",
}
}
}
impl EntropyFallbackClass {
pub const fn as_str(self) -> &'static str {
match self {
Self::Generic => "generic",
Self::Password => "password",
Self::Token => "token",
Self::ApiKey => "api-key",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ShapeCharset {
LowerAlnum,
Hex,
Base64Standard,
Base64Url,
}
fn default_shape_separator() -> char {
'-'
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ShapeGrouping {
pub group_count: usize,
pub group_length: usize,
#[serde(default = "default_shape_separator")]
pub separator: char,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EntropyShapeSpec {
pub charset: ShapeCharset,
pub entropy_floor: f64,
pub special_min_length: usize,
#[serde(default)]
pub grouping: Option<ShapeGrouping>,
#[serde(default)]
pub require_mixed_case: bool,
#[serde(default)]
pub require_digit: bool,
#[serde(default)]
pub min_symbols: usize,
#[serde(default)]
pub require_non_hex_alpha: bool,
#[serde(default)]
pub require_group_alpha_digit: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CanonicalHexKeyMaterialSpec {
#[serde(default)]
pub lengths: Vec<usize>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub suffixes: Vec<String>,
#[serde(default)]
pub excluded_keywords: Vec<String>,
}
impl DetectorSpec {
pub fn owns_entropy_policy(&self) -> bool {
self.kind == DetectorKind::Phase2Generic || self.entropy_policy_priority.is_some()
}
pub fn introspection(&self) -> DetectorIntrospection<'_> {
DetectorIntrospection { detector: self }
}
pub fn allows_decoded_hex_key_material(&self, value: &str) -> bool {
value.bytes().all(|byte| byte.is_ascii_hexdigit())
&& self.decoded_hex_key_material_lengths.contains(&value.len())
}
pub fn allows_decoded_hex_key_material_len(&self, decoded_len: Option<usize>) -> bool {
decoded_len.is_some_and(|length| self.decoded_hex_key_material_lengths.contains(&length))
}
pub fn allows_canonical_hex_key_material(&self, keyword: &str, value: &str) -> bool {
if !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return false;
}
self.canonical_hex_key_material.iter().any(|policy| {
if !policy.lengths.contains(&value.len()) {
return false;
}
if policy
.excluded_keywords
.iter()
.any(|excluded| compact_assignment_keywords_equal(keyword, excluded))
{
return false;
}
policy
.keywords
.iter()
.any(|owned_keyword| compact_assignment_keywords_equal(keyword, owned_keyword))
|| policy
.suffixes
.iter()
.any(|suffix| compact_assignment_keyword_ends_with(keyword, suffix))
})
}
}
pub struct DetectorIntrospection<'a> {
detector: &'a DetectorSpec,
}
impl Serialize for DetectorIntrospection<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let serialized = serde_json::to_value(self.detector).map_err(S::Error::custom)?;
let serde_json::Value::Object(mut declared) = serialized else {
return Err(S::Error::custom(
"DetectorSpec serialization must produce a JSON object",
));
};
let tests = declared
.remove("tests")
.ok_or_else(|| S::Error::custom("DetectorSpec serialization omitted tests"))?
.as_array()
.cloned()
.ok_or_else(|| S::Error::custom("DetectorSpec tests must serialize as an array"))?;
let test_contracts = tests
.into_iter()
.map(|test| {
let positive = test
.get("test_positive")
.is_some_and(|value| !value.is_null());
let negative = test
.get("test_negative")
.is_some_and(|value| !value.is_null());
serde_json::json!({
"positive": positive,
"negative": negative,
})
})
.collect();
let verification = declared
.remove("verify")
.ok_or_else(|| S::Error::custom("DetectorSpec serialization omitted verify"))?;
let has_verification = !verification.is_null();
let mut output = serde_json::Map::new();
for field in [
"id",
"name",
"service",
"severity",
"keywords",
"simdsieve_prefixes",
"patterns",
"companions",
] {
let Some(value) = declared.remove(field) else {
return Err(S::Error::custom(format!(
"DetectorSpec serialization omitted required field {field:?}"
)));
};
output.insert(field.to_string(), value);
}
output.insert(
"verify".to_string(),
serde_json::Value::Bool(has_verification),
);
output.insert("verification".to_string(), verification);
output.insert(
"test_contracts".to_string(),
serde_json::Value::Array(test_contracts),
);
output.insert("policy".to_string(), serde_json::Value::Object(declared));
serde_json::Value::Object(output).serialize(serializer)
}
}
fn compact_assignment_keywords_equal(left: &str, right: &str) -> bool {
compact_assignment_keyword_bytes(left).eq(compact_assignment_keyword_bytes(right))
}
fn compact_assignment_keyword_ends_with(value: &str, suffix: &str) -> bool {
let value_len = compact_assignment_keyword_bytes(value).count();
let suffix_len = compact_assignment_keyword_bytes(suffix).count();
suffix_len > 0
&& value_len > suffix_len
&& compact_assignment_keyword_bytes(value)
.skip(value_len - suffix_len)
.eq(compact_assignment_keyword_bytes(suffix))
}
fn compact_assignment_keyword_bytes(value: &str) -> impl Iterator<Item = u8> + '_ {
value
.bytes()
.filter(|byte| !matches!(byte, b'_' | b'-' | b'.'))
.map(|byte| byte.to_ascii_lowercase())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CredentialShape {
#[serde(default)]
pub exact_length: Option<usize>,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub body_min_length: Option<usize>,
#[serde(default)]
pub body_max_length: Option<usize>,
}
impl CredentialShape {
pub fn validate(&self, detector_id: &str) -> Result<(), String> {
let has_constraint = self.exact_length.is_some()
|| self.prefix.is_some()
|| self.body_min_length.is_some()
|| self.body_max_length.is_some();
if !has_constraint {
return Err(format!(
"credential shape for '{detector_id}' has no shape constraints"
));
}
if self.prefix.is_some()
&& self.exact_length.is_none()
&& self.body_min_length.is_none()
&& self.body_max_length.is_none()
{
return Err(format!(
"credential shape for '{detector_id}' has a prefix but no length constraint"
));
}
if self.exact_length == Some(0) {
return Err(format!(
"credential shape for '{detector_id}' has exact_length=0"
));
}
if self.prefix.as_deref() == Some("") {
return Err(format!(
"credential shape for '{detector_id}' has an empty prefix"
));
}
if let (Some(minimum), Some(maximum)) = (self.body_min_length, self.body_max_length) {
if minimum > maximum {
return Err(format!(
"credential shape for '{detector_id}' has body_min_length greater than body_max_length"
));
}
}
if (self.body_min_length.is_some() || self.body_max_length.is_some())
&& self.prefix.is_none()
{
return Err(format!(
"credential shape for '{detector_id}' sets body length without a prefix"
));
}
if let (Some(exact_length), Some(prefix)) = (self.exact_length, self.prefix.as_deref()) {
if let Some(minimum) = self.body_min_length {
let minimum_total = prefix.len().checked_add(minimum).ok_or_else(|| {
format!("credential shape for '{detector_id}' overflows prefix plus body_min_length")
})?;
if exact_length < minimum_total {
return Err(format!(
"credential shape for '{detector_id}' has exact_length below prefix plus body_min_length"
));
}
}
if let Some(maximum) = self.body_max_length {
let maximum_total = prefix.len().checked_add(maximum).ok_or_else(|| {
format!("credential shape for '{detector_id}' overflows prefix plus body_max_length")
})?;
if exact_length > maximum_total {
return Err(format!(
"credential shape for '{detector_id}' has exact_length above prefix plus body_max_length"
));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct DetectorTestSpec {
#[serde(default)]
pub test_positive: Option<String>,
#[serde(default)]
pub test_negative: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct PatternSpec {
pub regex: String,
pub description: Option<String>,
pub group: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_literals: Vec<String>,
#[serde(default)]
pub client_safe: bool,
#[serde(default)]
pub weak_anchor: bool,
#[serde(default)]
pub structural_password_slot: bool,
}
impl PatternSpec {
pub fn validate_required_literals(&self) -> Result<(), String> {
use regex_syntax::ast::{parse::Parser, Ast, RepetitionKind, RepetitionRange};
if self.required_literals.is_empty() {
return Ok(());
}
if self
.required_literals
.iter()
.any(|literal| literal.is_empty() || !literal.is_ascii())
{
return Err("must contain only non-empty ASCII strings".into());
}
let mut literals = self
.required_literals
.iter()
.map(|literal| literal.to_ascii_lowercase())
.collect::<Vec<_>>();
literals.sort_unstable();
if literals.windows(2).any(|pair| pair[0] == pair[1]) {
return Err("contains a duplicate ASCII-insensitive literal".into());
}
fn repetition_min(kind: &RepetitionKind) -> u32 {
match kind {
RepetitionKind::ZeroOrOne | RepetitionKind::ZeroOrMore => 0,
RepetitionKind::OneOrMore => 1,
RepetitionKind::Range(RepetitionRange::Exactly(min))
| RepetitionKind::Range(RepetitionRange::AtLeast(min))
| RepetitionKind::Range(RepetitionRange::Bounded(min, _)) => *min,
}
}
fn guarantees(ast: &Ast, literals: &[String]) -> bool {
fn run_contains(run: &str, literals: &[String]) -> bool {
let folded = run.to_ascii_lowercase();
literals.iter().any(|literal| folded.contains(literal))
}
match ast {
Ast::Literal(literal) => run_contains(&literal.c.to_string(), literals),
Ast::Group(group) => guarantees(&group.ast, literals),
Ast::Alternation(alternation) => {
!alternation.asts.is_empty()
&& alternation
.asts
.iter()
.all(|branch| guarantees(branch, literals))
}
Ast::Repetition(repetition) => {
repetition_min(&repetition.op.kind) > 0 && guarantees(&repetition.ast, literals)
}
Ast::Concat(concat) => {
let mut run = String::new();
for node in &concat.asts {
if let Ast::Literal(literal) = node {
run.push(literal.c);
continue;
}
if run_contains(&run, literals) || guarantees(node, literals) {
return true;
}
run.clear();
}
run_contains(&run, literals)
}
Ast::Empty(_)
| Ast::Dot(_)
| Ast::Assertion(_)
| Ast::ClassUnicode(_)
| Ast::ClassPerl(_)
| Ast::ClassBracketed(_)
| Ast::Flags(_) => false,
}
}
let ast = Parser::new()
.parse(&self.regex)
.map_err(|error| format!("cannot prove literals against invalid regex: {error}"))?;
if guarantees(&ast, &literals) {
Ok(())
} else {
Err("is not a proven necessary OR-condition of every regex match".into())
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CompanionSpec {
pub name: String,
pub regex: String,
pub within_lines: usize,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VerifySpec {
#[serde(default)]
pub service: String,
pub method: Option<HttpMethod>,
pub url: Option<String>,
pub auth: Option<AuthSpec>,
#[serde(default)]
pub headers: Vec<HeaderSpec>,
pub body: Option<String>,
pub success: Option<SuccessSpec>,
#[serde(default)]
pub metadata: Vec<MetadataSpec>,
pub timeout_ms: Option<u64>,
#[serde(default)]
pub steps: Vec<StepSpec>,
#[serde(default)]
pub allowed_domains: Vec<String>,
pub oob: Option<OobSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OobSpec {
pub protocol: OobProtocol,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub policy: OobPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OobProtocol {
Dns,
Http,
Smtp,
Any,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OobPolicy {
#[default]
OobAndHttp,
OobOnly,
OobOptional,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StepSpec {
pub name: String,
pub method: HttpMethod,
pub url: String,
pub auth: AuthSpec,
#[serde(default)]
pub headers: Vec<HeaderSpec>,
pub body: Option<String>,
pub success: SuccessSpec,
#[serde(default)]
pub extract: Vec<MetadataSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HeaderSpec {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum AuthSpec {
None {},
Bearer {
field: String,
},
Basic {
username: String,
password: String,
},
Header {
name: String,
template: String,
},
Query {
param: String,
field: String,
},
#[serde(rename = "aws_v4")]
AwsV4 {
access_key: String,
secret_key: String,
region: String,
service: String,
session_token: Option<String>,
},
Script {
engine: ScriptEngine,
code: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptEngine {
Python3,
Python,
Node,
Other(String),
}
impl ScriptEngine {
pub const ALLOWED_FOR_VERIFY: &'static [&'static str] = &["python3", "python", "node"];
pub fn as_str(&self) -> &str {
match self {
Self::Python3 => "python3",
Self::Python => "python",
Self::Node => "node",
Self::Other(engine) => engine,
}
}
pub fn is_allowed_for_verify(&self) -> bool {
matches!(self, Self::Python3 | Self::Python | Self::Node)
}
}
impl From<String> for ScriptEngine {
fn from(engine: String) -> Self {
match engine.as_str() {
"python3" => Self::Python3,
"python" => Self::Python,
"node" => Self::Node,
_ => Self::Other(engine),
}
}
}
impl From<&str> for ScriptEngine {
fn from(engine: &str) -> Self {
Self::from(engine.to_owned())
}
}
impl fmt::Display for ScriptEngine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for ScriptEngine {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ScriptEngine {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(String::deserialize(deserializer)?.into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct SuccessSpec {
#[serde(default)]
pub status: Option<u16>,
#[serde(default)]
pub status_not: Option<u16>,
#[serde(default)]
pub body_contains: Option<String>,
#[serde(default)]
pub body_not_contains: Option<String>,
#[serde(default)]
pub json_path: Option<String>,
#[serde(default)]
pub equals: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Severity {
#[default]
Info,
ClientSafe,
Low,
Medium,
High,
Critical,
}
const SEVERITY_CANONICAL_WIRE_FORMS: [&str; Severity::ORDERED.len()] = {
let mut out = [""; Severity::ORDERED.len()];
let mut i = 0;
while i < Severity::ORDERED.len() {
out[i] = Severity::ORDERED[i].as_str();
i += 1;
}
out
};
impl<'de> serde::Deserialize<'de> for Severity {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SeverityVisitor;
impl serde::de::Visitor<'_> for SeverityVisitor {
type Value = Severity;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(
"a severity string (one of info, client-safe, low, medium, high, critical)",
)
}
fn visit_str<E>(self, value: &str) -> Result<Severity, E>
where
E: serde::de::Error,
{
if value == "client_safe" {
return Ok(Severity::ClientSafe);
}
Severity::ORDERED
.iter()
.find(|variant| variant.as_str() == value)
.copied()
.ok_or_else(|| E::unknown_variant(value, &SEVERITY_CANONICAL_WIRE_FORMS))
}
}
deserializer.deserialize_str(SeverityVisitor)
}
}
impl Severity {
pub(crate) const FILTER_EXPECTED_LABELS: &'static str =
"info|client-safe|low|medium|high|critical";
pub(crate) const ORDERED: [Severity; 6] = [
Severity::Info,
Severity::ClientSafe,
Severity::Low,
Severity::Medium,
Severity::High,
Severity::Critical,
];
pub fn downgrade_one(self) -> Self {
match self {
Severity::Critical => Severity::High,
Severity::High => Severity::Medium,
Severity::Medium => Severity::Low,
Severity::Low => Severity::ClientSafe,
Severity::ClientSafe => Severity::Info,
Severity::Info => Severity::Info,
}
}
pub const fn as_str(&self) -> &'static str {
match self {
Severity::Info => "info",
Severity::ClientSafe => "client-safe",
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
pub(crate) fn from_filter_label(label: &str) -> Option<Self> {
let normalized = label.trim().to_ascii_lowercase();
if normalized == "client_safe" {
return Some(Severity::ClientSafe);
}
Severity::ORDERED
.iter()
.find(|variant| variant.as_str() == normalized)
.copied()
}
pub(crate) fn rank(self) -> usize {
match Self::ORDERED
.iter()
.position(|candidate| *candidate == self)
{
Some(rank) => rank,
None => Self::ORDERED.len() - 1, }
}
pub(crate) fn label_for_rank(rank: usize) -> &'static str {
match Self::ORDERED.get(rank) {
Some(severity) => severity.as_str(),
None => Severity::Critical.as_str(), }
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HttpMethod {
#[serde(rename = "GET")]
Get,
#[serde(rename = "POST")]
Post,
#[serde(rename = "PUT")]
Put,
#[serde(rename = "DELETE")]
Delete,
#[serde(rename = "PATCH")]
Patch,
#[serde(rename = "HEAD")]
Head,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DetectorFile {
pub detector: DetectorSpec,
}