1mod load;
5mod validate;
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10pub use load::{
11 load_detector_cache, load_detectors, load_detectors_with_gate, save_detector_cache,
12};
13pub use validate::{QualityIssue, validate_detector};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DetectorFile {
44 pub detector: DetectorSpec,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct DetectorSpec {
74 pub id: String,
76 pub name: String,
78 pub service: String,
80 pub severity: Severity,
82 pub patterns: Vec<PatternSpec>,
84 #[serde(default)]
85 pub companion: Option<CompanionSpec>,
87 #[serde(default)]
88 pub verify: Option<VerifySpec>,
90 #[serde(default)]
91 pub keywords: Vec<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct PatternSpec {
112 pub regex: String,
114 #[serde(default)]
115 pub description: Option<String>,
117 #[serde(default)]
118 pub group: Option<usize>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct CompanionSpec {
140 pub regex: String,
142 #[serde(default = "default_within_lines")]
143 pub within_lines: usize,
145 pub name: String,
147}
148
149fn default_within_lines() -> usize {
150 5
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct VerifySpec {
185 pub method: HttpMethod,
187 pub url: String,
189 pub auth: AuthSpec,
191 #[serde(default)]
192 pub headers: Vec<HeaderSpec>,
194 #[serde(default)]
195 pub body: Option<String>,
197 pub success: SuccessSpec,
199 #[serde(default)]
200 pub metadata: Vec<MetadataSpec>,
202 #[serde(default)]
203 pub timeout_ms: Option<u64>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct HeaderSpec {
223 pub name: String,
225 pub value: String,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(tag = "type", rename_all = "snake_case")]
245pub enum AuthSpec {
246 None,
248 Bearer {
250 field: String,
252 },
253 Basic {
255 username: String,
257 password: String,
259 },
260 Header {
262 name: String,
264 template: String,
266 },
267 Query {
269 param: String,
271 field: String,
273 },
274 AwsV4 {
276 access_key: String,
278 secret_key: String,
280 #[serde(default = "default_aws_region")]
281 region: String,
283 service: String,
285 },
286}
287
288fn default_aws_region() -> String {
289 "us-east-1".to_string()
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct SuccessSpec {
313 #[serde(default)]
314 pub status: Option<u16>,
316 #[serde(default)]
317 pub status_not: Option<u16>,
319 #[serde(default)]
320 pub body_contains: Option<String>,
322 #[serde(default)]
323 pub body_not_contains: Option<String>,
325 #[serde(default)]
326 pub json_path: Option<String>,
328 #[serde(default)]
329 pub equals: Option<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct MetadataSpec {
352 pub name: String,
354 #[serde(default)]
355 pub json_path: Option<String>,
357 #[serde(default)]
358 pub header: Option<String>,
360 #[serde(default)]
361 pub regex: Option<String>,
363 #[serde(default)]
364 pub group: Option<usize>,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
378#[serde(rename_all = "lowercase")]
379pub enum Severity {
380 Info,
382 Low,
384 Medium,
386 High,
388 Critical,
390}
391
392impl std::fmt::Display for Severity {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 match self {
395 Self::Info => write!(f, "info"),
396 Self::Low => write!(f, "low"),
397 Self::Medium => write!(f, "medium"),
398 Self::High => write!(f, "high"),
399 Self::Critical => write!(f, "critical"),
400 }
401 }
402}
403
404#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
414#[serde(rename_all = "UPPERCASE")]
415pub enum HttpMethod {
416 Get,
418 Post,
420 Put,
422 Delete,
424 Head,
426 Patch,
428}
429
430#[derive(Debug, Error)]
444pub enum SpecError {
445 #[error(
446 "failed to read detector file {path}: {source}. Fix: check the detector path exists and that the file is readable TOML"
447 )]
448 ReadFile {
449 path: String,
450 source: std::io::Error,
451 },
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use regex::Regex;
458
459 #[test]
460 fn parse_bearer_auth() {
461 let toml_str = r#"
462[detector]
463id = "slack-bot-token"
464name = "Slack Bot Token"
465service = "slack"
466severity = "critical"
467
468[[detector.patterns]]
469regex = "xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}"
470
471[detector.verify]
472method = "POST"
473url = "https://slack.com/api/auth.test"
474
475[detector.verify.auth]
476type = "bearer"
477field = "match"
478
479[detector.verify.success]
480status = 200
481json_path = "ok"
482equals = "true"
483
484[[detector.verify.metadata]]
485name = "team"
486json_path = "team"
487"#;
488 let file: DetectorFile = toml::from_str(toml_str).unwrap();
489 assert_eq!(file.detector.id, "slack-bot-token");
490 assert_eq!(file.detector.severity, Severity::Critical);
491 assert!(file.detector.verify.is_some());
492 let verify = file.detector.verify.unwrap();
493 assert!(matches!(verify.auth, AuthSpec::Bearer { .. }));
494 }
495
496 #[test]
497 fn parse_basic_auth() {
498 let toml_str = r#"
499[detector]
500id = "stripe-secret-key"
501name = "Stripe Secret Key"
502service = "stripe"
503severity = "critical"
504
505[[detector.patterns]]
506regex = "sk_live_[a-zA-Z0-9]{24,}"
507
508[detector.verify]
509method = "GET"
510url = "https://api.stripe.com/v1/charges?limit=1"
511
512[detector.verify.auth]
513type = "basic"
514username = "match"
515password = ""
516
517[detector.verify.success]
518status = 200
519"#;
520 let file: DetectorFile = toml::from_str(toml_str).unwrap();
521 assert_eq!(file.detector.id, "stripe-secret-key");
522 assert!(matches!(
523 file.detector.verify.unwrap().auth,
524 AuthSpec::Basic { .. }
525 ));
526 }
527
528 #[test]
529 fn parse_companion_spec() {
530 let toml_str = r#"
531[detector]
532id = "aws-access-key"
533name = "AWS Access Key"
534service = "aws"
535severity = "critical"
536
537[[detector.patterns]]
538regex = "(AKIA|ASIA)[0-9A-Z]{16}"
539
540[detector.companion]
541regex = "[0-9a-zA-Z/+=]{40}"
542within_lines = 5
543name = "secret_key"
544
545[detector.verify]
546method = "GET"
547url = "https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15"
548
549[detector.verify.auth]
550type = "aws_v4"
551access_key = "match"
552secret_key = "companion.secret_key"
553region = "us-east-1"
554service = "sts"
555
556[detector.verify.success]
557status = 200
558"#;
559 let file: DetectorFile = toml::from_str(toml_str).unwrap();
560 assert!(file.detector.companion.is_some());
561 let comp = file.detector.companion.unwrap();
562 assert_eq!(comp.name, "secret_key");
563 assert_eq!(comp.within_lines, 5);
564 }
565
566 #[test]
567 fn injects_github_classic_pat_compat_detector() {
568 let mut detectors = vec![DetectorSpec {
569 id: "github-pat-fine-grained".into(),
570 name: "GitHub Fine-Grained PAT".into(),
571 service: "github".into(),
572 severity: Severity::Critical,
573 patterns: vec![PatternSpec {
574 regex: "github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}".into(),
575 description: None,
576 group: None,
577 }],
578 companion: None,
579 verify: None,
580 keywords: vec!["github_pat_".into(), "github".into()],
581 }];
582
583 load::inject_github_classic_pat_detector(&mut detectors);
584
585 let compat = detectors
586 .iter()
587 .find(|d| d.id == "github-classic-pat")
588 .expect("compat detector missing");
589 assert_eq!(compat.service, "github");
590 assert_eq!(compat.patterns[0].regex, "ghp_[a-zA-Z0-9]{36,40}");
591 }
592
593 #[test]
594 fn supabase_anon_detector_requires_context_anchor() {
595 let file: DetectorFile =
596 toml::from_str(include_str!("../../../detectors/supabase-anon-key.toml"))
597 .expect("supabase detector should parse");
598 assert_eq!(file.detector.patterns.len(), 1);
599 let regex = Regex::new(&file.detector.patterns[0].regex).unwrap();
600 assert!(
601 regex.is_match("SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYW5vbiJ9.signature")
602 );
603 assert!(!regex.is_match("eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYW5vbiJ9.signature"));
604 }
605
606 #[test]
607 fn ceph_companion_requires_ceph_secret_context() {
608 let file: DetectorFile = toml::from_str(include_str!(
609 "../../../detectors/ceph-rados-gateway-credentials.toml"
610 ))
611 .expect("ceph detector should parse");
612 let companion = file.detector.companion.expect("ceph companion missing");
613 let regex = Regex::new(&companion.regex).unwrap();
614 assert!(regex.is_match("CEPH_SECRET_KEY=abcdEFGHijklMNOPqrstUVWXyz0123456789/+=="));
615 assert!(!regex.is_match("abcdEFGHijklMNOPqrstUVWXyz0123456789/+=="));
616 }
617
618 #[test]
619 fn lepton_secondary_pattern_needs_lepton_specific_context() {
620 let file: DetectorFile =
621 toml::from_str(include_str!("../../../detectors/leptonai-api-token.toml"))
622 .expect("lepton detector should parse");
623 let regex = Regex::new(&file.detector.patterns[1].regex).unwrap();
624 assert!(regex.is_match("LEPTON_TOKEN=abcdefghijklmnopqrstuvwxyz123456 lepton.ai"));
625 assert!(!regex.is_match("token=abcdefghijklmnopqrstuvwxyz123456 example.com"));
626 }
627
628 #[test]
629 fn infura_detector_uses_basic_auth_with_companion_secret() {
630 let file: DetectorFile = toml::from_str(include_str!(
631 "../../../detectors/infura-project-credentials.toml"
632 ))
633 .expect("infura detector should parse");
634 let verify = file.detector.verify.expect("infura verify missing");
635 match verify.auth {
636 AuthSpec::Basic { username, password } => {
637 assert_eq!(username, "match");
638 assert_eq!(password, "companion.infura_project_secret");
639 }
640 other => panic!("unexpected auth spec: {other:?}"),
641 }
642 }
643
644 #[test]
645 fn retool_detector_is_unverifiable_without_deployment_domain() {
646 let file: DetectorFile =
647 toml::from_str(include_str!("../../../detectors/retool-api-key.toml"))
648 .expect("retool detector should parse");
649 assert!(file.detector.verify.is_none());
650 }
651
652 #[test]
653 fn aws_session_token_detector_requires_aws_specific_anchors() {
654 let file: DetectorFile =
655 toml::from_str(include_str!("../../../detectors/aws-session-token.toml"))
656 .expect("aws session token detector should parse");
657 assert!(file.detector.verify.is_none());
658 let env_regex = Regex::new(&file.detector.patterns[0].regex).unwrap();
659 assert!(env_regex.is_match(
660 "AWS_SESSION_TOKEN=IQoJb3JpZ2luX2VjENP//////////wEaCXVzLWVhc3QtMSJGMEQCIBexampleTOKENexampleTOKENexampleTOKENexampleTOKEN"
661 ));
662 assert!(!env_regex.is_match(
663 "IQoJb3JpZ2luX2VjENP//////////wEaCXVzLWVhc3QtMSJGMEQCIBexampleTOKENexampleTOKENexampleTOKENexampleTOKEN"
664 ));
665 }
666
667 #[test]
668 fn aws_secrets_manager_arn_detector_is_info_only_and_unverified() {
669 let file: DetectorFile = toml::from_str(include_str!(
670 "../../../detectors/aws-secrets-manager-arn.toml"
671 ))
672 .expect("aws secrets manager arn detector should parse");
673 assert_eq!(file.detector.id, "aws-secrets-manager-arn");
674 assert_eq!(file.detector.severity, Severity::Info);
675 assert!(file.detector.verify.is_none());
676 }
677
678 #[test]
679 fn tightened_companion_detectors_require_service_specific_context() {
680 let vonage: DetectorFile =
681 toml::from_str(include_str!("../../../detectors/vonage-video-api.toml"))
682 .expect("vonage detector should parse");
683 let vonage_companion = Regex::new(
684 &vonage
685 .detector
686 .companion
687 .as_ref()
688 .expect("vonage companion missing")
689 .regex,
690 )
691 .unwrap();
692 assert!(vonage_companion.is_match("VONAGE_API_SECRET=abcdef0123456789"));
693 assert!(!vonage_companion.is_match("abcdef0123456789"));
694
695 let wix: DetectorFile =
696 toml::from_str(include_str!("../../../detectors/wix-api-credentials.toml"))
697 .expect("wix detector should parse");
698 let wix_companion = Regex::new(
699 &wix.detector
700 .companion
701 .as_ref()
702 .expect("wix companion missing")
703 .regex,
704 )
705 .unwrap();
706 assert!(wix_companion.is_match("wix instance_id=123e4567-e89b-12d3-a456-426614174000"));
707 assert!(!wix_companion.is_match("123e4567-e89b-12d3-a456-426614174000"));
708
709 let codecommit: DetectorFile = toml::from_str(include_str!(
710 "../../../detectors/aws-codecommit-credentials.toml"
711 ))
712 .expect("codecommit detector should parse");
713 let codecommit_companion = Regex::new(
714 &codecommit
715 .detector
716 .companion
717 .as_ref()
718 .expect("codecommit companion missing")
719 .regex,
720 )
721 .unwrap();
722 assert!(
723 codecommit_companion
724 .is_match("CODECOMMIT_PASSWORD=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789/+==")
725 );
726 assert!(!codecommit_companion.is_match("AbCdEfGhIjKlMnOpQrStUvWxYz0123456789/+=="));
727 }
728}