use std::{
borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt, hash::Hash, path::Path, str::FromStr,
sync::LazyLock,
};
use anyhow::{Context, Result, anyhow};
use liquid::{
ParserBuilder,
model::{KString, Value},
object,
};
use regex::Regex;
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use tracing::debug;
use xxhash_rust::xxh3::xxh3_64;
use crate::liquid_filters;
fn default_false() -> bool {
false
}
fn default_true() -> bool {
true
}
#[derive(
Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default,
)]
#[serde(rename_all = "lowercase")]
pub enum TlsMode {
#[default]
Strict,
Lax,
Off,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
#[serde(tag = "type", content = "content")]
pub enum Validation {
Assumed,
AWS,
AzureStorage,
Coinbase,
GCP,
MongoDB,
MySQL,
Postgres,
Jdbc,
CredentialUri,
JWT,
Ethereum(EthereumValidation),
Raw(String),
Betterleaks(BetterleaksValidation),
Http(HttpValidation),
Grpc(GrpcValidation),
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct BetterleaksValidation {
#[cfg(debug_assertions)]
#[serde(default)]
pub source: String,
pub expression: BetterleaksExpr,
#[serde(default)]
pub components: BTreeMap<String, String>,
#[serde(default)]
pub capabilities: BetterleaksCapabilities,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Default)]
pub struct BetterleaksCapabilities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_map: Option<BetterleaksAccessMap>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revocation_bindings: Option<BetterleaksRevocationBindings>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct BetterleaksAccessMap {
pub handler: BetterleaksAccessMapHandler,
#[serde(default)]
pub inputs: BTreeMap<String, String>,
#[serde(default)]
pub reachable_2xx: bool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum BetterleaksAccessMapHandler {
Aws,
Gcp,
GcpApiKey,
AzureClientSecret,
AzureStorage,
Algolia,
Alibaba,
Artifactory,
Salesforce,
Airtable,
Anthropic,
Auth0,
Buildkite,
Circleci,
Fastly,
Github,
Gitlab,
Harness,
Huggingface,
IbmCloud,
Monday,
Openai,
Paypal,
Pinecone,
Sendinblue,
Stripe,
WeightsAndBiases,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct BetterleaksRevocationBindings {
#[serde(default = "default_finding_secret_source")]
pub secret: String,
#[serde(default)]
pub variables: BTreeMap<String, String>,
}
fn default_finding_secret_source() -> String {
"finding.secret".to_string()
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BetterleaksExpr {
Nil,
Identifier {
value: String,
},
Integer {
value: i64,
},
Float {
value: String,
},
Bool {
value: bool,
},
String {
value: String,
},
Unary {
operator: String,
node: Box<BetterleaksExpr>,
},
Binary {
operator: String,
left: Box<BetterleaksExpr>,
right: Box<BetterleaksExpr>,
},
Chain {
node: Box<BetterleaksExpr>,
},
Member {
node: Box<BetterleaksExpr>,
property: Box<BetterleaksExpr>,
#[serde(default)]
optional: bool,
#[serde(default)]
method: bool,
},
Slice {
node: Box<BetterleaksExpr>,
from: Box<BetterleaksExpr>,
to: Box<BetterleaksExpr>,
},
Call {
callee: Box<BetterleaksExpr>,
#[serde(default)]
arguments: Vec<BetterleaksExpr>,
},
Builtin {
name: String,
#[serde(default)]
arguments: Vec<BetterleaksExpr>,
},
Conditional {
cond: Box<BetterleaksExpr>,
exp1: Box<BetterleaksExpr>,
exp2: Box<BetterleaksExpr>,
},
VariableDeclarator {
name: String,
value: Box<BetterleaksExpr>,
expr: Box<BetterleaksExpr>,
},
Sequence {
#[serde(default)]
nodes: Vec<BetterleaksExpr>,
},
Array {
#[serde(default)]
nodes: Vec<BetterleaksExpr>,
},
Map {
#[serde(default)]
pairs: Vec<BetterleaksExpr>,
},
Pair {
key: Box<BetterleaksExpr>,
value: Box<BetterleaksExpr>,
},
Predicate {
node: Box<BetterleaksExpr>,
},
Pointer {
name: String,
},
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum EthereumValidation {
PrivateKey,
PublicKey,
Mnemonic,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
#[serde(tag = "type", content = "content")]
pub enum Revocation {
AWS,
GCP,
Http(HttpValidation),
HttpMultiStep(HttpMultiStepRevocation),
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct HttpMultiStepRevocation {
pub steps: Vec<RevocationStep>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct RevocationStep {
#[serde(default)]
pub name: Option<String>,
pub request: HttpRequest,
#[serde(default)]
pub multipart: Option<MultipartConfig>,
#[serde(default)]
pub extract: Option<BTreeMap<String, ResponseExtractor>>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
#[serde(tag = "type")]
pub enum ResponseExtractor {
JsonPath { path: String },
Regex { pattern: String },
Header { name: String },
Body,
StatusCode,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct DependsOnRule {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub verify_candidates: bool,
pub rule_id: String,
pub variable: String,
#[serde(default)]
pub optional: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub within: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct PatternRequirements {
#[serde(default)]
pub min_digits: Option<usize>,
#[serde(default)]
pub min_uppercase: Option<usize>,
#[serde(default)]
pub min_lowercase: Option<usize>,
#[serde(default)]
pub min_special_chars: Option<usize>,
#[serde(default)]
pub special_chars: Option<String>,
#[serde(default)]
pub ignore_if_contains: Option<Vec<String>>,
#[serde(default)]
pub checksum: Option<ChecksumRequirement>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct ChecksumRequirement {
pub actual: ChecksumActual,
pub expected: String,
#[serde(default)]
pub skip_if_missing: bool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct ChecksumActual {
pub template: String,
#[serde(default)]
pub requires_capture: Option<String>,
}
#[derive(Clone, Copy)]
pub struct PatternRequirementContext<'a> {
pub regex: &'a regex::bytes::Regex,
pub captures: &'a regex::bytes::Captures<'a>,
pub full_match: &'a [u8],
}
impl PatternRequirements {
const DEFAULT_SPECIAL_CHARS: &'static str = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/\\`~";
pub fn validate(
&self,
input: &[u8],
context: Option<PatternRequirementContext<'_>>,
respect_ignore_if_contains: bool,
) -> PatternValidationResult {
let s = String::from_utf8_lossy(input);
if let Some(min_digits) = self.min_digits {
let digit_count = s.chars().filter(|c| c.is_ascii_digit()).count();
if digit_count < min_digits {
return PatternValidationResult::Failed;
}
}
if let Some(min_uppercase) = self.min_uppercase {
let uppercase_count = s.chars().filter(|c| c.is_ascii_uppercase()).count();
if uppercase_count < min_uppercase {
return PatternValidationResult::Failed;
}
}
if let Some(min_lowercase) = self.min_lowercase {
let lowercase_count = s.chars().filter(|c| c.is_ascii_lowercase()).count();
if lowercase_count < min_lowercase {
return PatternValidationResult::Failed;
}
}
if let Some(min_special) = self.min_special_chars {
let special_chars =
self.special_chars.as_deref().unwrap_or(Self::DEFAULT_SPECIAL_CHARS);
let special_count = s.chars().filter(|c| special_chars.contains(*c)).count();
if special_count < min_special {
return PatternValidationResult::Failed;
}
}
if respect_ignore_if_contains && let Some(ignore_terms) = self.ignore_if_contains.as_ref() {
let lowercase_input = s.to_lowercase();
if let Some(matched_term) = ignore_terms
.iter()
.filter_map(|term| {
let trimmed = term.trim();
if trimmed.is_empty() { None } else { Some((trimmed, trimmed.to_lowercase())) }
})
.find_map(|(original, lowered)| {
if lowercase_input.contains(&lowered) {
Some(original.to_string())
} else {
None
}
})
{
return PatternValidationResult::IgnoredBySubstring { matched_term };
}
}
if let Some(checksum) = &self.checksum {
let Some(ctx) = context else {
return if checksum.skip_if_missing {
PatternValidationResult::Passed
} else {
PatternValidationResult::Failed
};
};
if let Some(required) = checksum.actual.requires_capture.as_deref()
&& ctx.captures.name(required).is_none()
{
return if checksum.skip_if_missing {
PatternValidationResult::Passed
} else {
PatternValidationResult::Failed
};
}
let mut globals = object!({
"MATCH": s.to_string(),
"FULL_MATCH": String::from_utf8_lossy(ctx.full_match).to_string(),
});
for name in ctx.regex.capture_names().flatten() {
if let Some(capture) = ctx.captures.name(name) {
let value = String::from_utf8_lossy(capture.as_bytes()).to_string();
globals.insert(KString::from_ref(name), Value::scalar(value.clone()));
globals.insert(
KString::from_string(name.to_ascii_uppercase()),
Value::scalar(value),
);
}
}
let actual =
match render_pattern_requirement_template(&checksum.actual.template, &globals) {
Ok(rendered) => rendered,
Err(err) => {
debug!(
"Failed to render checksum actual template '{}': {}",
checksum.actual.template, err
);
return PatternValidationResult::Failed;
}
};
let expected = match render_pattern_requirement_template(&checksum.expected, &globals) {
Ok(rendered) => rendered,
Err(err) => {
debug!(
"Failed to render checksum expected template '{}': {}",
checksum.expected, err
);
return PatternValidationResult::Failed;
}
};
if actual != expected {
let actual_len = actual.chars().count();
let expected_len = expected.chars().count();
return PatternValidationResult::FailedChecksum { actual_len, expected_len };
}
}
PatternValidationResult::Passed
}
}
fn render_pattern_requirement_template(
template: &str,
globals: &liquid::Object,
) -> Result<String, String> {
PATTERN_REQUIREMENTS_TEMPLATE_PARSER
.parse(template)
.map_err(|e| e.to_string())
.and_then(|parsed| parsed.render(globals).map_err(|e| e.to_string()))
}
#[derive(Debug, PartialEq, Eq)]
pub enum PatternValidationResult {
Passed,
Failed,
FailedChecksum { actual_len: usize, expected_len: usize },
IgnoredBySubstring { matched_term: String },
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct HttpValidation {
pub request: HttpRequest,
pub multipart: Option<MultipartConfig>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct GrpcValidation {
pub request: GrpcRequest,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct GrpcRequest {
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub response_matcher: Option<Vec<ResponseMatcher>>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct HttpRequest {
pub method: String,
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub response_matcher: Option<Vec<ResponseMatcher>>,
#[serde(default)]
pub multipart: Option<MultipartConfig>,
#[serde(default = "default_false")]
pub response_is_html: bool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct MultipartConfig {
pub parts: Vec<MultipartPart>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct MultipartPart {
pub name: String,
#[serde(rename = "type")]
pub part_type: String,
pub content: String,
#[serde(default)]
pub content_type: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
#[serde(deny_unknown_fields)]
pub struct ReportResponseData {
#[serde(default = "default_true")]
report_response: bool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
#[serde(untagged)]
pub enum ResponseMatcher {
WordMatch {
r#type: String,
words: Vec<String>,
#[serde(default = "default_false")]
match_all_words: bool,
#[serde(default = "default_false")]
negative: bool, },
StatusMatch {
r#type: String,
status: Vec<u16>,
#[serde(default = "default_false")]
match_all_status: bool,
#[serde(default = "default_false")]
negative: bool, },
HeaderMatch {
r#type: String, header: String, expected: Vec<String>, #[serde(default = "default_false")]
match_all_values: bool,
},
JsonValid {
r#type: String,
},
XmlValid {
r#type: String,
},
ReportResponse(ReportResponseData),
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy, Default)]
pub enum Confidence {
Low,
#[default]
Medium,
High,
}
impl PartialOrd for Confidence {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Confidence {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(Confidence::Low, Confidence::Low) => Ordering::Equal,
(Confidence::Low, _) => Ordering::Less,
(Confidence::Medium, Confidence::Low) => Ordering::Greater,
(Confidence::Medium, Confidence::Medium) => Ordering::Equal,
(Confidence::Medium, Confidence::High) => Ordering::Less,
(Confidence::High, Confidence::High) => Ordering::Equal,
(Confidence::High, _) => Ordering::Greater,
}
}
}
impl Confidence {
pub fn is_at_least(&self, other: &Confidence) -> bool {
self >= other
}
}
impl fmt::Display for Confidence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Confidence::Low => "low",
Confidence::Medium => "medium",
Confidence::High => "high",
};
write!(f, "{}", s)
}
}
impl FromStr for Confidence {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"low" => Ok(Confidence::Low),
"medium" => Ok(Confidence::Medium),
"high" => Ok(Confidence::High),
_ => Err(anyhow!("Invalid confidence level: {}", s)),
}
}
}
impl JsonSchema for Confidence {
fn schema_name() -> Cow<'static, str> {
"Confidence".into()
}
fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
schemars::json_schema!({
"enum": ["Low", "Medium", "High"]
})
}
}
impl Serialize for Confidence {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Confidence {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
pub struct RuleSyntax {
pub name: String,
pub id: String,
pub pattern: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub betterleaks_filter: Option<BetterleaksExpr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub betterleaks_secret_group: Option<usize>,
#[serde(default = "default_true")]
pub authoritative: bool,
#[serde(default = "default_true")]
pub vectorscan_compatible: bool,
#[serde(default)]
pub min_entropy: f32,
#[serde(default)]
pub confidence: Confidence,
#[serde(default = "default_true")]
pub visible: bool,
#[serde(default)]
pub examples: Vec<String>,
#[serde(default)]
pub negative_examples: Vec<String>,
#[serde(default)]
pub references: Vec<String>,
#[serde(default)]
pub validation: Option<Validation>,
#[serde(default)]
pub revocation: Option<Revocation>,
#[serde(default)]
pub depends_on_rule: Vec<Option<DependsOnRule>>,
#[serde(default)]
pub pattern_requirements: Option<PatternRequirements>,
#[serde(default)]
pub tls_mode: Option<TlsMode>,
}
pub static RULE_COMMENTS_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)(\(\?#[^)]*\))|(\s\#[\sa-zA-Z]*$)")
.expect("comment-stripping regex should compile")
});
static PATTERN_REQUIREMENTS_TEMPLATE_PARSER: LazyLock<liquid::Parser> = LazyLock::new(|| {
liquid_filters::register_all(ParserBuilder::with_stdlib())
.build()
.expect("pattern requirement template parser should compile")
});
impl RuleSyntax {
pub fn new(id: impl Into<String>, name: impl Into<String>, pattern: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
pattern: pattern.into(),
path: None,
betterleaks_filter: None,
betterleaks_secret_group: None,
authoritative: true,
vectorscan_compatible: true,
min_entropy: 0.0,
confidence: Confidence::default(),
visible: true,
examples: Vec::new(),
negative_examples: Vec::new(),
references: Vec::new(),
validation: None,
revocation: None,
depends_on_rule: Vec::new(),
pattern_requirements: None,
tls_mode: None,
}
}
const REGEX_SIZE_LIMIT: usize = 16 * 1024 * 1024;
pub fn uncommented_pattern(&self) -> Cow<'_, str> {
RULE_COMMENTS_PATTERN.replace_all(&self.pattern, "")
}
fn build_regex(pattern: &str) -> Result<regex::bytes::Regex> {
regex::bytes::RegexBuilder::new(pattern)
.unicode(false)
.size_limit(Self::REGEX_SIZE_LIMIT)
.build()
.context("Failed to build regex")
}
pub fn as_regex(&self) -> Result<regex::bytes::Regex> {
Self::build_regex(&self.uncommented_pattern())
}
pub fn matches_path(&self, path: &str) -> bool {
self.path
.as_deref()
.is_none_or(|pattern| Regex::new(pattern).is_ok_and(|regex| regex.is_match(path)))
}
pub fn betterleaks_filter(&self) -> Option<&BetterleaksExpr> {
self.betterleaks_filter.as_ref()
}
pub fn betterleaks_secret_group(&self) -> Option<usize> {
self.betterleaks_secret_group
}
pub fn is_authoritative(&self) -> bool {
self.authoritative
}
pub fn vectorscan_compatible(&self) -> bool {
true
}
pub fn as_anchored_regex(&self) -> Result<regex::bytes::Regex> {
Self::build_regex(&format!("{}$", self.uncommented_pattern()))
}
pub fn finding_sha1_fingerprint(&self) -> String {
let hash = xxh3_64(self.pattern.as_bytes());
format!("{:x}", hash)
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("Serialization of rule syntax should succeed")
}
pub fn from_yaml_file<P: AsRef<Path>>(path: P) -> Result<Vec<Self>> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
#[derive(Deserialize)]
#[serde(untagged)]
enum RuleSyntaxDocument {
Sequence(Vec<RuleSyntax>),
Mapped { rules: Vec<RuleSyntax> },
}
let parsed: RuleSyntaxDocument = serde_yaml::from_str(&contents).map_err(|e| {
let context = e.location().map_or(String::new(), |loc| {
format!(" at line {} column {}", loc.line(), loc.column())
});
anyhow!("Failed to parse YAML from {}{}: {}", path.display(), context, e)
})?;
Ok(match parsed {
RuleSyntaxDocument::Sequence(rules) => rules,
RuleSyntaxDocument::Mapped { rules } => rules,
})
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct Rule {
pub syntax: RuleSyntax,
finding_sha1_fingerprint: String,
min_entropy: f32,
visible: bool,
#[serde(skip, default = "default_runtime_minimum_confidence")]
runtime_minimum_confidence: Confidence,
#[serde(skip)]
runtime_dependency_helper: bool,
}
fn default_runtime_minimum_confidence() -> Confidence {
Confidence::Low
}
impl Rule {
pub fn new(syntax: RuleSyntax) -> Self {
Self {
finding_sha1_fingerprint: syntax.finding_sha1_fingerprint(),
min_entropy: syntax.min_entropy,
visible: syntax.visible,
runtime_minimum_confidence: Confidence::Low,
runtime_dependency_helper: false,
syntax,
}
}
pub fn syntax(&self) -> &RuleSyntax {
&self.syntax
}
pub fn json_syntax(&self) -> String {
self.syntax.to_json()
}
pub fn finding_sha1_fingerprint(&self) -> &str {
&self.finding_sha1_fingerprint
}
pub fn name(&self) -> &str {
&self.syntax.name
}
pub fn id(&self) -> &str {
&self.syntax.id
}
pub fn min_entropy(&self) -> f32 {
self.min_entropy
}
pub fn set_entropy(&mut self, new_entropy: f32) -> Result<()> {
if new_entropy < 0.0 {
return Err(anyhow!("Entropy value cannot be negative"));
}
self.min_entropy = new_entropy;
self.syntax.min_entropy = new_entropy;
Ok(())
}
pub fn visible(&self) -> bool {
self.visible
}
pub fn confidence(&self) -> Confidence {
self.syntax.confidence
}
pub fn set_runtime_confidence_filter(
&mut self,
minimum_confidence: Confidence,
dependency_helper: bool,
) {
self.runtime_minimum_confidence = minimum_confidence;
self.runtime_dependency_helper = dependency_helper;
}
pub fn accepts_effective_confidence(&self, confidence: Confidence) -> bool {
self.runtime_dependency_helper || self.reports_effective_confidence(confidence)
}
pub fn reports_effective_confidence(&self, confidence: Confidence) -> bool {
confidence.is_at_least(&self.runtime_minimum_confidence)
}
pub fn is_runtime_dependency_helper(&self) -> bool {
self.runtime_dependency_helper
}
pub fn runtime_minimum_confidence(&self) -> Confidence {
self.runtime_minimum_confidence
}
pub fn suppress_runtime_reporting(&mut self) {
self.visible = false;
self.syntax.visible = false;
}
pub fn pattern_requirements(&self) -> Option<&PatternRequirements> {
self.syntax.pattern_requirements.as_ref()
}
pub fn matches_path(&self, path: &str) -> bool {
self.syntax.matches_path(path)
}
pub fn betterleaks_filter(&self) -> Option<&BetterleaksExpr> {
self.syntax.betterleaks_filter()
}
pub fn betterleaks_secret_group(&self) -> Option<usize> {
self.syntax.betterleaks_secret_group()
}
pub fn vectorscan_compatible(&self) -> bool {
self.syntax.vectorscan_compatible()
}
pub fn tls_mode(&self) -> Option<TlsMode> {
self.syntax.tls_mode
}
}
#[cfg(test)]
mod tests {
use super::*;
use regex::bytes::Regex as BytesRegex;
#[test]
fn test_pattern_requirements_digits() {
let reqs = PatternRequirements {
min_digits: Some(2),
min_uppercase: None,
min_lowercase: None,
min_special_chars: None,
special_chars: None,
ignore_if_contains: None,
checksum: None,
};
assert!(matches!(reqs.validate(b"abc123def", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"abc1def", None, true), PatternValidationResult::Failed));
assert!(matches!(reqs.validate(b"abcdef", None, true), PatternValidationResult::Failed));
}
#[test]
fn test_pattern_requirements_checksum() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: None,
min_lowercase: None,
min_special_chars: None,
special_chars: None,
ignore_if_contains: None,
checksum: Some(ChecksumRequirement {
actual: ChecksumActual {
template: "{{ MATCH | suffix: 6 }}".to_string(),
requires_capture: Some("checksum".to_string()),
},
expected: "{{ BODY | crc32 | base62: 6 }}".to_string(),
skip_if_missing: true,
}),
};
let token = b"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";
let regex =
BytesRegex::new(r"(?x) ghp_(?P<body>[A-Za-z0-9]{30})(?P<checksum>[A-Za-z0-9]{6})")
.unwrap();
let captures = regex.captures(token).expect("token should match");
assert!(matches!(
reqs.validate(
token,
Some(PatternRequirementContext {
regex: ®ex,
captures: &captures,
full_match: token
}),
true
),
PatternValidationResult::Passed
));
let mut invalid = token.to_vec();
*invalid.last_mut().unwrap() = b'0';
let captures_invalid =
regex.captures(&invalid).expect("invalid token should still match pattern");
assert!(matches!(
reqs.validate(
&invalid,
Some(PatternRequirementContext {
regex: ®ex,
captures: &captures_invalid,
full_match: &invalid,
}),
true
),
PatternValidationResult::FailedChecksum { .. }
));
let legacy = b"ghp_legacy_token";
assert!(matches!(reqs.validate(legacy, None, true), PatternValidationResult::Passed));
}
#[test]
fn test_pattern_requirements_uppercase() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: Some(2),
min_lowercase: None,
min_special_chars: None,
special_chars: None,
ignore_if_contains: None,
checksum: None,
};
assert!(matches!(reqs.validate(b"ABCdef", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"Adef", None, true), PatternValidationResult::Failed));
assert!(matches!(reqs.validate(b"abcdef", None, true), PatternValidationResult::Failed));
}
#[test]
fn test_pattern_requirements_lowercase() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: None,
min_lowercase: Some(2),
min_special_chars: None,
special_chars: None,
ignore_if_contains: None,
checksum: None,
};
assert!(matches!(reqs.validate(b"ABCdef", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"ABCd", None, true), PatternValidationResult::Failed));
assert!(matches!(reqs.validate(b"ABC123", None, true), PatternValidationResult::Failed));
}
#[test]
fn test_pattern_requirements_special_chars() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: None,
min_lowercase: None,
min_special_chars: Some(2),
special_chars: None, ignore_if_contains: None,
checksum: None,
};
assert!(matches!(reqs.validate(b"abc!@def", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"abc!def", None, true), PatternValidationResult::Failed));
assert!(matches!(reqs.validate(b"abcdef", None, true), PatternValidationResult::Failed));
}
#[test]
fn test_pattern_requirements_custom_special_chars() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: None,
min_lowercase: None,
min_special_chars: Some(2),
special_chars: Some("$%^".to_string()),
ignore_if_contains: None,
checksum: None,
};
assert!(matches!(reqs.validate(b"abc$%def", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"abc!@def", None, true), PatternValidationResult::Failed));
assert!(matches!(reqs.validate(b"abc$def", None, true), PatternValidationResult::Failed));
}
#[test]
fn test_pattern_requirements_combined() {
let reqs = PatternRequirements {
min_digits: Some(1),
min_uppercase: Some(1),
min_lowercase: Some(1),
min_special_chars: Some(1),
special_chars: None,
ignore_if_contains: None,
checksum: None,
};
assert!(matches!(reqs.validate(b"Abc1!", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"Abc!", None, true), PatternValidationResult::Failed));
assert!(matches!(reqs.validate(b"abc1!", None, true), PatternValidationResult::Failed));
assert!(matches!(reqs.validate(b"ABC1!", None, true), PatternValidationResult::Failed));
assert!(matches!(reqs.validate(b"Abc1", None, true), PatternValidationResult::Failed));
}
#[test]
fn test_pattern_requirements_ignore_if_contains() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: None,
min_lowercase: None,
min_special_chars: None,
special_chars: None,
ignore_if_contains: Some(vec!["test".to_string(), "Demo".to_string()]),
checksum: None,
};
assert!(matches!(
reqs.validate(b"MyTestToken", None, true),
PatternValidationResult::IgnoredBySubstring { .. }
));
assert!(matches!(
reqs.validate(b"example-demo-value", None, true),
PatternValidationResult::IgnoredBySubstring { .. }
));
assert!(matches!(
reqs.validate(b"example-value", None, true),
PatternValidationResult::Passed
));
}
#[test]
fn test_pattern_requirements_ignore_if_contains_ignores_empty_entries() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: None,
min_lowercase: None,
min_special_chars: None,
special_chars: None,
ignore_if_contains: Some(vec![" ".to_string(), "".to_string(), "BLOCK".to_string()]),
checksum: None,
};
assert!(matches!(
reqs.validate(b"needs-blocking", None, true),
PatternValidationResult::IgnoredBySubstring { .. }
));
assert!(matches!(reqs.validate(b"allowed", None, true), PatternValidationResult::Passed));
}
#[test]
fn test_pattern_requirements_ignore_if_contains_can_be_disabled() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: None,
min_lowercase: None,
min_special_chars: None,
special_chars: None,
ignore_if_contains: Some(vec!["ignoreme".to_string()]),
checksum: None,
};
assert!(matches!(
reqs.validate(b"value-ignoreme", None, true),
PatternValidationResult::IgnoredBySubstring { .. }
));
assert!(matches!(
reqs.validate(b"value-ignoreme", None, false),
PatternValidationResult::Passed
));
}
#[test]
fn test_pattern_requirements_none() {
let reqs = PatternRequirements {
min_digits: None,
min_uppercase: None,
min_lowercase: None,
min_special_chars: None,
special_chars: None,
ignore_if_contains: None,
checksum: None,
};
assert!(matches!(reqs.validate(b"anything", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"123", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"!@#", None, true), PatternValidationResult::Passed));
}
}