mod load;
mod validate;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub use load::{
load_detector_cache, load_detectors, load_detectors_with_gate, save_detector_cache,
};
pub use validate::{QualityIssue, validate_detector};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectorFile {
pub detector: DetectorSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectorSpec {
pub id: String,
pub name: String,
pub service: String,
pub severity: Severity,
pub patterns: Vec<PatternSpec>,
#[serde(default)]
pub companion: Option<CompanionSpec>,
#[serde(default)]
pub verify: Option<VerifySpec>,
#[serde(default)]
pub keywords: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatternSpec {
pub regex: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub group: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompanionSpec {
pub regex: String,
#[serde(default = "default_within_lines")]
pub within_lines: usize,
pub name: String,
}
fn default_within_lines() -> usize {
5
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifySpec {
pub method: HttpMethod,
pub url: String,
pub auth: AuthSpec,
#[serde(default)]
pub headers: Vec<HeaderSpec>,
#[serde(default)]
pub body: Option<String>,
pub success: SuccessSpec,
#[serde(default)]
pub metadata: Vec<MetadataSpec>,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderSpec {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthSpec {
None,
Bearer {
field: String,
},
Basic {
username: String,
password: String,
},
Header {
name: String,
template: String,
},
Query {
param: String,
field: String,
},
AwsV4 {
access_key: String,
secret_key: String,
#[serde(default = "default_aws_region")]
region: String,
service: String,
},
}
fn default_aws_region() -> String {
"us-east-1".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
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, Serialize, Deserialize)]
pub struct MetadataSpec {
pub name: String,
#[serde(default)]
pub json_path: Option<String>,
#[serde(default)]
pub header: Option<String>,
#[serde(default)]
pub regex: Option<String>,
#[serde(default)]
pub group: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Info => write!(f, "info"),
Self::Low => write!(f, "low"),
Self::Medium => write!(f, "medium"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Head,
Patch,
}
#[derive(Debug, Error)]
pub enum SpecError {
#[error(
"failed to read detector file {path}: {source}. Fix: check the detector path exists and that the file is readable TOML"
)]
ReadFile {
path: String,
source: std::io::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
use regex::Regex;
#[test]
fn parse_bearer_auth() {
let toml_str = r#"
[detector]
id = "slack-bot-token"
name = "Slack Bot Token"
service = "slack"
severity = "critical"
[[detector.patterns]]
regex = "xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}"
[detector.verify]
method = "POST"
url = "https://slack.com/api/auth.test"
[detector.verify.auth]
type = "bearer"
field = "match"
[detector.verify.success]
status = 200
json_path = "ok"
equals = "true"
[[detector.verify.metadata]]
name = "team"
json_path = "team"
"#;
let file: DetectorFile = toml::from_str(toml_str).unwrap();
assert_eq!(file.detector.id, "slack-bot-token");
assert_eq!(file.detector.severity, Severity::Critical);
assert!(file.detector.verify.is_some());
let verify = file.detector.verify.unwrap();
assert!(matches!(verify.auth, AuthSpec::Bearer { .. }));
}
#[test]
fn parse_basic_auth() {
let toml_str = r#"
[detector]
id = "stripe-secret-key"
name = "Stripe Secret Key"
service = "stripe"
severity = "critical"
[[detector.patterns]]
regex = "sk_live_[a-zA-Z0-9]{24,}"
[detector.verify]
method = "GET"
url = "https://api.stripe.com/v1/charges?limit=1"
[detector.verify.auth]
type = "basic"
username = "match"
password = ""
[detector.verify.success]
status = 200
"#;
let file: DetectorFile = toml::from_str(toml_str).unwrap();
assert_eq!(file.detector.id, "stripe-secret-key");
assert!(matches!(
file.detector.verify.unwrap().auth,
AuthSpec::Basic { .. }
));
}
#[test]
fn parse_companion_spec() {
let toml_str = r#"
[detector]
id = "aws-access-key"
name = "AWS Access Key"
service = "aws"
severity = "critical"
[[detector.patterns]]
regex = "(AKIA|ASIA)[0-9A-Z]{16}"
[detector.companion]
regex = "[0-9a-zA-Z/+=]{40}"
within_lines = 5
name = "secret_key"
[detector.verify]
method = "GET"
url = "https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15"
[detector.verify.auth]
type = "aws_v4"
access_key = "match"
secret_key = "companion.secret_key"
region = "us-east-1"
service = "sts"
[detector.verify.success]
status = 200
"#;
let file: DetectorFile = toml::from_str(toml_str).unwrap();
assert!(file.detector.companion.is_some());
let comp = file.detector.companion.unwrap();
assert_eq!(comp.name, "secret_key");
assert_eq!(comp.within_lines, 5);
}
#[test]
fn injects_github_classic_pat_compat_detector() {
let mut detectors = vec![DetectorSpec {
id: "github-pat-fine-grained".into(),
name: "GitHub Fine-Grained PAT".into(),
service: "github".into(),
severity: Severity::Critical,
patterns: vec![PatternSpec {
regex: "github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}".into(),
description: None,
group: None,
}],
companion: None,
verify: None,
keywords: vec!["github_pat_".into(), "github".into()],
}];
load::inject_github_classic_pat_detector(&mut detectors);
let compat = detectors
.iter()
.find(|d| d.id == "github-classic-pat")
.expect("compat detector missing");
assert_eq!(compat.service, "github");
assert_eq!(compat.patterns[0].regex, "ghp_[a-zA-Z0-9]{36,40}");
}
#[test]
fn supabase_anon_detector_requires_context_anchor() {
let file: DetectorFile =
toml::from_str(include_str!("../../../detectors/supabase-anon-key.toml"))
.expect("supabase detector should parse");
assert_eq!(file.detector.patterns.len(), 1);
let regex = Regex::new(&file.detector.patterns[0].regex).unwrap();
assert!(
regex.is_match("SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYW5vbiJ9.signature")
);
assert!(!regex.is_match("eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYW5vbiJ9.signature"));
}
#[test]
fn ceph_companion_requires_ceph_secret_context() {
let file: DetectorFile = toml::from_str(include_str!(
"../../../detectors/ceph-rados-gateway-credentials.toml"
))
.expect("ceph detector should parse");
let companion = file.detector.companion.expect("ceph companion missing");
let regex = Regex::new(&companion.regex).unwrap();
assert!(regex.is_match("CEPH_SECRET_KEY=abcdEFGHijklMNOPqrstUVWXyz0123456789/+=="));
assert!(!regex.is_match("abcdEFGHijklMNOPqrstUVWXyz0123456789/+=="));
}
#[test]
fn lepton_secondary_pattern_needs_lepton_specific_context() {
let file: DetectorFile =
toml::from_str(include_str!("../../../detectors/leptonai-api-token.toml"))
.expect("lepton detector should parse");
let regex = Regex::new(&file.detector.patterns[1].regex).unwrap();
assert!(regex.is_match("LEPTON_TOKEN=abcdefghijklmnopqrstuvwxyz123456 lepton.ai"));
assert!(!regex.is_match("token=abcdefghijklmnopqrstuvwxyz123456 example.com"));
}
#[test]
fn infura_detector_uses_basic_auth_with_companion_secret() {
let file: DetectorFile = toml::from_str(include_str!(
"../../../detectors/infura-project-credentials.toml"
))
.expect("infura detector should parse");
let verify = file.detector.verify.expect("infura verify missing");
match verify.auth {
AuthSpec::Basic { username, password } => {
assert_eq!(username, "match");
assert_eq!(password, "companion.infura_project_secret");
}
other => panic!("unexpected auth spec: {other:?}"),
}
}
#[test]
fn retool_detector_is_unverifiable_without_deployment_domain() {
let file: DetectorFile =
toml::from_str(include_str!("../../../detectors/retool-api-key.toml"))
.expect("retool detector should parse");
assert!(file.detector.verify.is_none());
}
#[test]
fn aws_session_token_detector_requires_aws_specific_anchors() {
let file: DetectorFile =
toml::from_str(include_str!("../../../detectors/aws-session-token.toml"))
.expect("aws session token detector should parse");
assert!(file.detector.verify.is_none());
let env_regex = Regex::new(&file.detector.patterns[0].regex).unwrap();
assert!(env_regex.is_match(
"AWS_SESSION_TOKEN=IQoJb3JpZ2luX2VjENP//////////wEaCXVzLWVhc3QtMSJGMEQCIBexampleTOKENexampleTOKENexampleTOKENexampleTOKEN"
));
assert!(!env_regex.is_match(
"IQoJb3JpZ2luX2VjENP//////////wEaCXVzLWVhc3QtMSJGMEQCIBexampleTOKENexampleTOKENexampleTOKENexampleTOKEN"
));
}
#[test]
fn aws_secrets_manager_arn_detector_is_info_only_and_unverified() {
let file: DetectorFile = toml::from_str(include_str!(
"../../../detectors/aws-secrets-manager-arn.toml"
))
.expect("aws secrets manager arn detector should parse");
assert_eq!(file.detector.id, "aws-secrets-manager-arn");
assert_eq!(file.detector.severity, Severity::Info);
assert!(file.detector.verify.is_none());
}
#[test]
fn tightened_companion_detectors_require_service_specific_context() {
let vonage: DetectorFile =
toml::from_str(include_str!("../../../detectors/vonage-video-api.toml"))
.expect("vonage detector should parse");
let vonage_companion = Regex::new(
&vonage
.detector
.companion
.as_ref()
.expect("vonage companion missing")
.regex,
)
.unwrap();
assert!(vonage_companion.is_match("VONAGE_API_SECRET=abcdef0123456789"));
assert!(!vonage_companion.is_match("abcdef0123456789"));
let wix: DetectorFile =
toml::from_str(include_str!("../../../detectors/wix-api-credentials.toml"))
.expect("wix detector should parse");
let wix_companion = Regex::new(
&wix.detector
.companion
.as_ref()
.expect("wix companion missing")
.regex,
)
.unwrap();
assert!(wix_companion.is_match("wix instance_id=123e4567-e89b-12d3-a456-426614174000"));
assert!(!wix_companion.is_match("123e4567-e89b-12d3-a456-426614174000"));
let codecommit: DetectorFile = toml::from_str(include_str!(
"../../../detectors/aws-codecommit-credentials.toml"
))
.expect("codecommit detector should parse");
let codecommit_companion = Regex::new(
&codecommit
.detector
.companion
.as_ref()
.expect("codecommit companion missing")
.regex,
)
.unwrap();
assert!(
codecommit_companion
.is_match("CODECOMMIT_PASSWORD=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789/+==")
);
assert!(!codecommit_companion.is_match("AbCdEfGhIjKlMnOpQrStUvWxYz0123456789/+=="));
}
}