1use fallow_config::EffectKind;
16use fallow_types::extract::{SinkArgKind, SinkLiteralValue, SinkObjectProperty, SinkShape};
17use rustc_hash::FxHashSet;
18
19mod identity;
20mod provenance;
21mod rules;
22mod severity;
23
24pub use identity::{security_finding_id, security_rule_id, stamp_security_finding_ids};
25pub use provenance::{ProvenanceBindings, import_source_matches};
26pub use rules::{
27 enable_security_rules, resolve_security_finding_severity, retain_enabled_security_findings,
28 security_rules_can_error,
29};
30pub use severity::{derive_security_severity, security_catalogue_title};
31
32pub const HARDCODED_SECRET_CATEGORY_ID: &str = "hardcoded-secret";
33pub const HARDCODED_SECRET_CATEGORY_TITLE: &str = "Hardcoded secret candidate";
34
35const CATALOGUE_TOML: &str = include_str!("../data/security_matchers.toml");
39
40#[derive(serde::Deserialize)]
41struct RawCatalogue {
42 #[serde(default)]
43 matcher: Vec<RawMatcher>,
44 #[serde(default)]
45 source: Vec<RawSource>,
46}
47
48#[derive(serde::Deserialize)]
52struct RawSource {
53 id: String,
54 title: String,
55 #[serde(default)]
57 enabler: Option<String>,
58 path_patterns: Vec<String>,
59 #[serde(default)]
66 receiver_allowlist: Vec<String>,
67}
68
69#[derive(serde::Deserialize)]
70struct RawMatcher {
71 id: String,
72 cwe: u32,
73 title: String,
74 effect: EffectKind,
75 sink_shape: String,
77 callee_patterns: Vec<String>,
78 arg_index: u32,
79 #[serde(default)]
83 variadic: bool,
84 evidence_template: String,
85 #[serde(default)]
86 import_provenance: Option<String>,
87 #[serde(default)]
96 enabler: Option<String>,
97 #[serde(default)]
104 arg_kinds: Option<Vec<String>>,
105 #[serde(default)]
107 literal_values: Option<Vec<String>>,
108 #[serde(default)]
110 literal_contains: Option<Vec<String>>,
111 #[serde(default)]
113 literal_integers: Option<Vec<i64>>,
114 #[serde(default)]
116 object_properties: Option<Vec<RawObjectPropertyPredicate>>,
117 #[serde(default)]
119 object_missing_or_false: Option<Vec<String>>,
120 #[serde(default)]
124 object_missing: Option<Vec<String>>,
125 #[serde(default)]
127 context_keywords: Option<Vec<String>>,
128 #[serde(default)]
131 requires_source: bool,
132 #[serde(default)]
139 requires_source_kinds: Vec<String>,
140}
141
142#[derive(Debug, serde::Deserialize)]
143struct RawObjectPropertyPredicate {
144 key: String,
145 #[serde(default)]
146 string: Option<String>,
147 #[serde(default)]
148 boolean: Option<bool>,
149 #[serde(default)]
150 integer: Option<i64>,
151 #[serde(default)]
152 null: bool,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum LiteralPredicate {
157 String(String),
158 Integer(i64),
159 Boolean(bool),
160 Null,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ObjectPropertyPredicate {
165 key: String,
166 value: LiteralPredicate,
167}
168
169#[derive(Debug, Clone)]
178pub struct CalleePattern {
179 raw: String,
182 suffix_segments: Vec<String>,
186 leading_wildcard: bool,
188 trailing_wildcard: bool,
190}
191
192impl CalleePattern {
193 #[must_use]
198 pub fn parse(raw: &str) -> Option<Self> {
199 parse_callee_pattern(raw)
200 }
201
202 #[must_use]
204 pub fn raw(&self) -> &str {
205 &self.raw
206 }
207
208 #[must_use]
220 pub fn matches(&self, callee_path: &str) -> bool {
221 if self.suffix_segments.is_empty() || (self.leading_wildcard && self.trailing_wildcard) {
223 return false;
224 }
225 let candidate: Vec<&str> = callee_path.split('.').collect();
226 if self.leading_wildcard {
227 if self.suffix_segments.len() >= candidate.len() {
231 return false;
232 }
233 let tail = &candidate[candidate.len() - self.suffix_segments.len()..];
234 self.suffix_segments
235 .iter()
236 .zip(tail)
237 .all(|(pat, seg)| pat == seg)
238 } else if self.trailing_wildcard {
239 if self.suffix_segments.len() >= candidate.len() {
243 return false;
244 }
245 let head = &candidate[..self.suffix_segments.len()];
246 self.suffix_segments
247 .iter()
248 .zip(head)
249 .all(|(pat, seg)| pat == seg)
250 } else {
251 self.suffix_segments.len() == candidate.len()
252 && self
253 .suffix_segments
254 .iter()
255 .zip(&candidate)
256 .all(|(pat, seg)| pat == seg)
257 }
258 }
259
260 #[must_use]
269 fn matched_receiver<'p>(&self, callee_path: &'p str) -> Option<&'p str> {
270 if !self.leading_wildcard || !self.matches(callee_path) {
271 return None;
272 }
273 let candidate: Vec<&str> = callee_path.split('.').collect();
274 let recv_idx = candidate.len() - self.suffix_segments.len() - 1;
277 candidate.get(recv_idx).copied()
278 }
279}
280
281fn parse_callee_pattern(raw: &str) -> Option<CalleePattern> {
284 if raw.trim().is_empty() {
285 return None;
286 }
287 let mut segments: Vec<&str> = raw.split('.').collect();
288 let leading_wildcard = segments.first() == Some(&"*");
289 if leading_wildcard {
290 segments.remove(0);
291 }
292 let trailing_wildcard = segments.last() == Some(&"*");
293 if trailing_wildcard {
294 segments.pop();
295 }
296 Some(CalleePattern {
297 raw: raw.to_string(),
298 suffix_segments: segments.into_iter().map(str::to_string).collect(),
299 leading_wildcard,
300 trailing_wildcard,
301 })
302}
303
304#[derive(Debug, Clone)]
307pub struct Matcher {
308 pub id: String,
309 pub cwe: u32,
310 pub title: String,
311 pub effect: EffectKind,
312 pub sink_shape: SinkShape,
313 pub callee_patterns: Vec<CalleePattern>,
314 pub arg_index: u32,
315 pub variadic: bool,
318 pub evidence_template: String,
319 pub import_provenance: Option<String>,
320 pub enabler: Option<String>,
324 pub arg_kinds: Option<Vec<SinkArgKind>>,
327 pub requires_source: bool,
330 pub requires_source_kinds: Vec<String>,
334 pub literal_values: Vec<String>,
336 pub literal_contains: Vec<String>,
338 pub literal_integers: Vec<i64>,
340 pub object_properties: Vec<ObjectPropertyPredicate>,
342 pub object_missing_or_false: Vec<String>,
344 pub object_missing: Vec<String>,
346 pub context_keywords: Vec<String>,
348}
349
350#[derive(Debug, Clone)]
355pub struct SourceMatcher {
356 id: String,
357 title: String,
358 enabler: Option<String>,
359 path_patterns: Vec<CalleePattern>,
360 receiver_allowlist: Vec<String>,
363}
364
365impl SourceMatcher {
366 #[must_use]
367 fn matches_with_extra_receivers(
368 &self,
369 source_path: &str,
370 extra_receivers: &FxHashSet<String>,
371 ) -> bool {
372 self.path_patterns.iter().any(|p| {
373 p.matches(source_path) && self.receiver_allowed(p, source_path, extra_receivers)
374 })
375 }
376
377 fn receiver_allowed(
382 &self,
383 pattern: &CalleePattern,
384 source_path: &str,
385 extra_receivers: &FxHashSet<String>,
386 ) -> bool {
387 if self.receiver_allowlist.is_empty() {
388 return true;
389 }
390 match pattern.matched_receiver(source_path) {
391 Some(receiver) => {
392 self.receiver_allowlist
393 .iter()
394 .any(|allowed| allowed.eq_ignore_ascii_case(receiver))
395 || extra_receivers.contains(&receiver.to_ascii_lowercase())
396 }
397 None => true,
398 }
399 }
400
401 #[must_use]
404 fn enabler_satisfied(&self, declared_deps: &rustc_hash::FxHashSet<String>) -> bool {
405 enabler_satisfied(self.enabler.as_deref(), declared_deps)
406 }
407}
408
409#[derive(Debug)]
413pub struct Catalogue {
414 matchers: Vec<Matcher>,
415 sources: Vec<SourceMatcher>,
416}
417
418impl Matcher {
419 #[must_use]
422 pub fn first_matching_pattern(&self, callee_path: &str) -> Option<&CalleePattern> {
423 self.callee_patterns.iter().find(|p| p.matches(callee_path))
424 }
425
426 #[must_use]
429 pub fn admits_arg_index(&self, arg_index: u32) -> bool {
430 arg_index == self.arg_index || (self.variadic && arg_index > self.arg_index)
431 }
432
433 #[must_use]
436 pub fn admits_arg_kind(&self, arg_kind: SinkArgKind) -> bool {
437 self.arg_kinds
438 .as_ref()
439 .is_none_or(|kinds| kinds.contains(&arg_kind))
440 }
441
442 #[must_use]
445 pub fn is_literal_aware(&self) -> bool {
446 !self.literal_values.is_empty()
447 || !self.literal_contains.is_empty()
448 || !self.literal_integers.is_empty()
449 || !self.object_properties.is_empty()
450 || !self.object_missing_or_false.is_empty()
451 || !self.object_missing.is_empty()
452 || !self.context_keywords.is_empty()
453 || self.arg_kinds.as_ref().is_some_and(|kinds| {
454 kinds
455 .iter()
456 .any(|kind| matches!(kind, SinkArgKind::Literal | SinkArgKind::NoArg))
457 })
458 }
459
460 #[must_use]
462 pub fn literal_value_satisfied(&self, literal: Option<&SinkLiteralValue>) -> bool {
463 if self.literal_values.is_empty()
464 && self.literal_contains.is_empty()
465 && self.literal_integers.is_empty()
466 {
467 return true;
468 }
469 let string_satisfied = (self.literal_values.is_empty() && self.literal_contains.is_empty())
470 || match literal {
471 Some(SinkLiteralValue::String(value)) => {
472 let lower = value.to_ascii_lowercase();
473 (self.literal_values.is_empty()
474 || self
475 .literal_values
476 .iter()
477 .any(|expected| lower == expected.to_ascii_lowercase()))
478 && (self.literal_contains.is_empty()
479 || self
480 .literal_contains
481 .iter()
482 .any(|needle| lower.contains(&needle.to_ascii_lowercase())))
483 }
484 _ => false,
485 };
486 let integer_satisfied = self.literal_integers.is_empty()
487 || match literal {
488 Some(SinkLiteralValue::Integer(value)) => self.literal_integers.contains(value),
489 _ => false,
490 };
491 string_satisfied && integer_satisfied
492 }
493
494 #[must_use]
497 pub fn object_properties_satisfied(&self, properties: &[SinkObjectProperty]) -> bool {
498 if self.object_properties.is_empty() && self.object_missing_or_false.is_empty() {
499 return true;
500 }
501 for predicate in &self.object_properties {
502 let Some(property) = properties.iter().find(|p| p.key == predicate.key) else {
503 return false;
504 };
505 if !predicate.value.matches(&property.value) {
506 return false;
507 }
508 }
509 if self.object_missing_or_false.is_empty() {
510 return true;
511 }
512 self.object_missing_or_false.iter().any(|key| {
513 properties
514 .iter()
515 .find(|p| p.key == *key)
516 .is_none_or(|property| matches!(property.value, SinkLiteralValue::Boolean(false)))
517 })
518 }
519
520 #[must_use]
523 pub fn object_missing_satisfied(&self, keys: &[String], keys_complete: bool) -> bool {
524 if self.object_missing.is_empty() {
525 return true;
526 }
527 keys_complete && self.object_missing.iter().any(|key| !keys.contains(key))
528 }
529
530 #[must_use]
532 pub fn context_satisfied(&self, context_names: &[String]) -> bool {
533 if self.context_keywords.is_empty() {
534 return true;
535 }
536 context_names.iter().any(|name| {
537 let lower = name.to_ascii_lowercase();
538 self.context_keywords
539 .iter()
540 .any(|keyword| lower.contains(&keyword.to_ascii_lowercase()))
541 })
542 }
543
544 #[must_use]
551 pub fn enabler_satisfied(&self, declared_deps: &rustc_hash::FxHashSet<String>) -> bool {
552 enabler_satisfied(self.enabler.as_deref(), declared_deps)
553 }
554}
555
556fn enabler_satisfied(enabler: Option<&str>, declared_deps: &rustc_hash::FxHashSet<String>) -> bool {
557 let Some(enabler) = enabler else {
558 return true;
559 };
560 if let Some(prefix) = enabler.strip_suffix('/') {
561 declared_deps
564 .iter()
565 .any(|d| d == prefix || d.starts_with(enabler))
566 } else {
567 declared_deps.contains(enabler)
568 }
569}
570
571impl LiteralPredicate {
572 fn matches(&self, value: &SinkLiteralValue) -> bool {
573 match (self, value) {
574 (Self::String(expected), SinkLiteralValue::String(actual)) => {
575 expected.eq_ignore_ascii_case(actual)
576 }
577 (Self::Integer(expected), SinkLiteralValue::Integer(actual)) => expected == actual,
578 (Self::Boolean(expected), SinkLiteralValue::Boolean(actual)) => expected == actual,
579 (Self::Null, SinkLiteralValue::Null) => true,
580 _ => false,
581 }
582 }
583}
584
585impl Catalogue {
586 #[must_use]
588 pub fn matchers(&self) -> &[Matcher] {
589 &self.matchers
590 }
591
592 #[must_use]
596 pub fn matching_source_for_deps_with_receivers(
597 &self,
598 source_path: &str,
599 declared_deps: &FxHashSet<String>,
600 request_receivers: &FxHashSet<String>,
601 ) -> Option<(&str, &str)> {
602 let empty_receivers = FxHashSet::default();
603 self.sources
604 .iter()
605 .find(|s| {
606 let extra_receivers = if s.id == "http-request-input" {
607 request_receivers
608 } else {
609 &empty_receivers
610 };
611 s.enabler_satisfied(declared_deps)
612 && s.matches_with_extra_receivers(source_path, extra_receivers)
613 })
614 .map(|s| (s.id.as_str(), s.title.as_str()))
615 }
616
617 #[must_use]
619 fn title_for(&self, id: &str) -> Option<&str> {
620 self.matchers
621 .iter()
622 .find(|m| m.id == id)
623 .map(|m| m.title.as_str())
624 }
625}
626
627#[must_use]
629pub fn catalogue_title(id: &str) -> Option<&'static str> {
630 catalogue().title_for(id)
631}
632
633const SECRET_TO_NETWORK_CATEGORY_ID: &str = "secret-to-network";
637
638#[must_use]
643fn is_include_required_category(id: &str) -> bool {
644 id == HARDCODED_SECRET_CATEGORY_ID || id == SECRET_TO_NETWORK_CATEGORY_ID
645}
646
647#[derive(Debug, Clone)]
650pub struct SecurityCategory {
651 pub id: String,
653 pub title: String,
655 pub cwe: Option<u32>,
658 pub include_required: bool,
661}
662
663#[must_use]
671pub fn security_categories() -> Vec<SecurityCategory> {
672 let mut seen = FxHashSet::default();
673 let mut out = Vec::new();
674 for matcher in catalogue().matchers() {
675 if seen.insert(matcher.id.clone()) {
676 out.push(SecurityCategory {
677 id: matcher.id.clone(),
678 title: matcher.title.clone(),
679 cwe: Some(matcher.cwe),
680 include_required: is_include_required_category(&matcher.id),
681 });
682 }
683 }
684 if seen.insert(HARDCODED_SECRET_CATEGORY_ID.to_owned()) {
685 out.push(SecurityCategory {
686 id: HARDCODED_SECRET_CATEGORY_ID.to_owned(),
687 title: HARDCODED_SECRET_CATEGORY_TITLE.to_owned(),
688 cwe: None,
689 include_required: true,
690 });
691 }
692 out.sort_by(|a, b| a.id.cmp(&b.id));
693 out
694}
695
696fn parse_sink_shape(s: &str) -> Option<SinkShape> {
698 match s {
699 "call" => Some(SinkShape::Call),
700 "member-call" => Some(SinkShape::MemberCall),
701 "member-assign" => Some(SinkShape::MemberAssign),
702 "tagged-template" => Some(SinkShape::TaggedTemplate),
703 "jsx-attr" => Some(SinkShape::JsxAttr),
704 "new-expression" => Some(SinkShape::NewExpression),
705 _ => None,
706 }
707}
708
709fn parse_arg_kind(s: &str) -> Option<SinkArgKind> {
711 match s {
712 "template-with-subst" => Some(SinkArgKind::TemplateWithSubst),
713 "concat" => Some(SinkArgKind::Concat),
714 "object" => Some(SinkArgKind::Object),
715 "call" => Some(SinkArgKind::Call),
716 "literal" => Some(SinkArgKind::Literal),
717 "no-arg" => Some(SinkArgKind::NoArg),
718 "other" => Some(SinkArgKind::Other),
719 _ => None,
720 }
721}
722
723fn parse_object_property_predicates(
724 id: &str,
725 raw: Option<Vec<RawObjectPropertyPredicate>>,
726) -> Result<Vec<ObjectPropertyPredicate>, String> {
727 let Some(raw_predicates) = raw else {
728 return Ok(Vec::new());
729 };
730 let mut predicates = Vec::with_capacity(raw_predicates.len());
731 for predicate in raw_predicates {
732 if predicate.key.trim().is_empty() {
733 return Err(format!(
734 "matcher {id:?} has an object_properties predicate with an empty key"
735 ));
736 }
737 let value_count = usize::from(predicate.string.is_some())
738 + usize::from(predicate.boolean.is_some())
739 + usize::from(predicate.integer.is_some())
740 + usize::from(predicate.null);
741 if value_count != 1 {
742 return Err(format!(
743 "matcher {id:?} object_properties predicate for {:?} must set exactly one of string | boolean | integer | null",
744 predicate.key
745 ));
746 }
747 let value = if let Some(string) = predicate.string {
748 LiteralPredicate::String(string)
749 } else if let Some(boolean) = predicate.boolean {
750 LiteralPredicate::Boolean(boolean)
751 } else if let Some(integer) = predicate.integer {
752 LiteralPredicate::Integer(integer)
753 } else {
754 LiteralPredicate::Null
755 };
756 predicates.push(ObjectPropertyPredicate {
757 key: predicate.key,
758 value,
759 });
760 }
761 Ok(predicates)
762}
763
764fn parse_catalogue(src: &str) -> Result<Catalogue, String> {
771 let raw: RawCatalogue =
772 toml::from_str(src).map_err(|e| format!("security_matchers.toml parse error: {e}"))?;
773
774 let mut matchers = Vec::with_capacity(raw.matcher.len());
775 for entry in raw.matcher {
776 matchers.push(parse_matcher_entry(entry)?);
777 }
778
779 if matchers.is_empty() {
780 return Err("security_matchers.toml has no [[matcher]] entries".to_string());
781 }
782
783 let sources = parse_source_catalogue(raw.source)?;
784
785 Ok(Catalogue { matchers, sources })
786}
787
788fn parse_matcher_entry(entry: RawMatcher) -> Result<Matcher, String> {
792 let (sink_shape, callee_patterns) = validate_matcher_core(&entry)?;
793 let arg_kinds = parse_matcher_arg_kinds(&entry.id, entry.arg_kinds.as_deref())?;
794 let enabler = validate_matcher_enabler(&entry.id, entry.enabler)?;
795 let object_properties = parse_object_property_predicates(&entry.id, entry.object_properties)?;
796 Ok(Matcher {
797 id: entry.id,
798 cwe: entry.cwe,
799 title: entry.title,
800 effect: entry.effect,
801 sink_shape,
802 callee_patterns,
803 arg_index: entry.arg_index,
804 variadic: entry.variadic,
805 evidence_template: entry.evidence_template,
806 import_provenance: entry.import_provenance,
807 enabler,
808 arg_kinds,
809 requires_source: entry.requires_source,
810 requires_source_kinds: entry.requires_source_kinds,
811 literal_values: entry.literal_values.unwrap_or_default(),
812 literal_contains: entry.literal_contains.unwrap_or_default(),
813 literal_integers: entry.literal_integers.unwrap_or_default(),
814 object_properties,
815 object_missing_or_false: entry.object_missing_or_false.unwrap_or_default(),
816 object_missing: entry.object_missing.unwrap_or_default(),
817 context_keywords: entry.context_keywords.unwrap_or_default(),
818 })
819}
820
821fn validate_matcher_core(entry: &RawMatcher) -> Result<(SinkShape, Vec<CalleePattern>), String> {
824 if entry.id.trim().is_empty() {
825 return Err("matcher id must be non-empty / non-whitespace".to_string());
826 }
827 if entry.cwe == 0 {
828 return Err(format!("matcher {:?} has cwe 0; cwe must be > 0", entry.id));
829 }
830 let sink_shape = parse_sink_shape(&entry.sink_shape).ok_or_else(|| {
831 format!(
832 "matcher {:?} has unknown sink_shape {:?}; expected one of \
833 call | member-call | member-assign | tagged-template | jsx-attr | new-expression",
834 entry.id, entry.sink_shape
835 )
836 })?;
837 if entry.callee_patterns.is_empty() {
838 return Err(format!(
839 "matcher {:?} has no callee_patterns; at least one is required",
840 entry.id
841 ));
842 }
843 if entry.evidence_template.trim().is_empty() {
844 return Err(format!(
845 "matcher {:?} has an empty evidence_template",
846 entry.id
847 ));
848 }
849 let mut callee_patterns = Vec::with_capacity(entry.callee_patterns.len());
850 for pat in &entry.callee_patterns {
851 let parsed = parse_callee_pattern(pat).ok_or_else(|| {
852 format!(
853 "matcher {:?} has an empty / whitespace callee_pattern {pat:?}",
854 entry.id
855 )
856 })?;
857 callee_patterns.push(parsed);
858 }
859 Ok((sink_shape, callee_patterns))
860}
861
862fn validate_matcher_enabler(id: &str, enabler: Option<String>) -> Result<Option<String>, String> {
865 match enabler {
866 Some(e) if e.trim().is_empty() => Err(format!(
867 "matcher {id:?} has an empty / whitespace enabler; omit the key for a global row"
868 )),
869 other => Ok(other),
870 }
871}
872
873fn parse_matcher_arg_kinds(
876 id: &str,
877 raw_kinds: Option<&[String]>,
878) -> Result<Option<Vec<SinkArgKind>>, String> {
879 let Some(raw_kinds) = raw_kinds else {
880 return Ok(None);
881 };
882 if raw_kinds.is_empty() {
883 return Err(format!(
884 "matcher {id:?} has an empty arg_kinds list; omit the key to admit any shape"
885 ));
886 }
887 let mut kinds = Vec::with_capacity(raw_kinds.len());
888 for raw in raw_kinds {
889 let kind = parse_arg_kind(raw).ok_or_else(|| {
890 format!(
891 "matcher {id:?} has unknown arg_kind {raw:?}; expected one of \
892 template-with-subst | concat | object | call | literal | no-arg | other"
893 )
894 })?;
895 kinds.push(kind);
896 }
897 Ok(Some(kinds))
898}
899
900fn parse_source_catalogue(raw_sources: Vec<RawSource>) -> Result<Vec<SourceMatcher>, String> {
901 let mut sources = Vec::with_capacity(raw_sources.len());
902 for entry in raw_sources {
903 if entry.id.trim().is_empty() {
904 return Err("source id must be non-empty / non-whitespace".to_string());
905 }
906 if entry.path_patterns.is_empty() {
907 return Err(format!(
908 "source {:?} has no path_patterns; at least one is required",
909 entry.id
910 ));
911 }
912 let path_patterns = parse_source_path_patterns(&entry)?;
913 let receiver_allowlist = parse_source_receiver_allowlist(&entry)?;
914 let enabler = match entry.enabler {
915 Some(e) if e.trim().is_empty() => {
916 return Err(format!(
917 "source {:?} has an empty / whitespace enabler; omit the key for a global row",
918 entry.id
919 ));
920 }
921 other => other,
922 };
923 sources.push(SourceMatcher {
924 id: entry.id,
925 title: entry.title,
926 enabler,
927 path_patterns,
928 receiver_allowlist,
929 });
930 }
931 Ok(sources)
932}
933
934fn parse_source_path_patterns(entry: &RawSource) -> Result<Vec<CalleePattern>, String> {
935 let mut path_patterns = Vec::with_capacity(entry.path_patterns.len());
936 for pattern in &entry.path_patterns {
937 let parsed = parse_callee_pattern(pattern).ok_or_else(|| {
938 format!(
939 "source {:?} has an empty / whitespace path_pattern {pattern:?}",
940 entry.id
941 )
942 })?;
943 path_patterns.push(parsed);
944 }
945 Ok(path_patterns)
946}
947
948fn parse_source_receiver_allowlist(entry: &RawSource) -> Result<Vec<String>, String> {
949 let mut receiver_allowlist = Vec::with_capacity(entry.receiver_allowlist.len());
950 for receiver in &entry.receiver_allowlist {
951 if receiver.trim().is_empty() {
952 return Err(format!(
953 "source {:?} has an empty / whitespace receiver_allowlist entry; omit the key for an ungated row",
954 entry.id
955 ));
956 }
957 receiver_allowlist.push(receiver.to_ascii_lowercase());
958 }
959 Ok(receiver_allowlist)
960}
961
962#[expect(
970 clippy::expect_used,
971 reason = "compile-time-embedded catalogue pinned by security_catalogue_parses"
972)]
973pub fn catalogue() -> &'static Catalogue {
974 static CATALOGUE: std::sync::OnceLock<Catalogue> = std::sync::OnceLock::new();
975 CATALOGUE.get_or_init(|| {
976 parse_catalogue(CATALOGUE_TOML).expect(
977 "embedded crates/security/data/security_matchers.toml must parse; run \
978 `cargo test -p fallow-security security_catalogue_parses` to see the error",
979 )
980 })
981}
982
983#[cfg(test)]
984#[allow(
985 clippy::expect_used,
986 clippy::unwrap_used,
987 reason = "catalogue parser tests assert fixture invariants directly"
988)]
989mod tests {
990 use super::*;
991 use rustc_hash::FxHashSet;
992
993 fn source_for<'a>(
996 cat: &'a Catalogue,
997 source_path: &str,
998 declared_deps: &FxHashSet<String>,
999 ) -> Option<(&'a str, &'a str)> {
1000 cat.matching_source_for_deps_with_receivers(
1001 source_path,
1002 declared_deps,
1003 &FxHashSet::default(),
1004 )
1005 }
1006
1007 fn is_source(cat: &Catalogue, source_path: &str) -> bool {
1009 source_for(cat, source_path, &FxHashSet::default()).is_some()
1010 }
1011
1012 #[test]
1013 fn security_categories_are_deduped_and_flag_include_required() {
1014 let cats = security_categories();
1015 assert!(!cats.is_empty(), "catalogue must yield categories");
1016 let mut ids = FxHashSet::default();
1018 for c in &cats {
1019 assert!(ids.insert(c.id.clone()), "duplicate category id {}", c.id);
1020 }
1021 let sorted: Vec<&String> = {
1023 let mut v: Vec<&String> = cats.iter().map(|c| &c.id).collect();
1024 v.sort();
1025 v
1026 };
1027 assert_eq!(
1028 cats.iter().map(|c| &c.id).collect::<Vec<_>>(),
1029 sorted,
1030 "categories must be sorted by id"
1031 );
1032 let by_id = |id: &str| cats.iter().find(|c| c.id == id);
1035 let hs = by_id(HARDCODED_SECRET_CATEGORY_ID).expect("hardcoded-secret present");
1036 assert!(hs.include_required && hs.cwe.is_none());
1037 let stn = by_id(SECRET_TO_NETWORK_CATEGORY_ID).expect("secret-to-network present");
1038 assert!(
1039 stn.include_required,
1040 "secret-to-network must be include-required"
1041 );
1042 assert!(
1044 cats.iter().any(|c| !c.include_required),
1045 "most categories are admitted by default"
1046 );
1047 }
1048
1049 #[test]
1050 fn secret_to_network_const_matches_catalogue() {
1051 assert!(
1052 catalogue()
1053 .matchers()
1054 .iter()
1055 .any(|m| m.id == SECRET_TO_NETWORK_CATEGORY_ID),
1056 "SECRET_TO_NETWORK_CATEGORY_ID must name a real catalogue category"
1057 );
1058 }
1059
1060 #[test]
1061 fn security_catalogue_parses() {
1062 let cat = catalogue();
1063 assert!(!cat.matchers().is_empty(), "catalogue must have matchers");
1064 assert!(
1065 cat.matchers().iter().any(|m| m.id == "dangerous-html"),
1066 "catalogue must contain the dangerous-html seed"
1067 );
1068 }
1069
1070 #[test]
1071 fn catalogue_rows_are_unique() {
1072 let raw: RawCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
1077 let mut seen = FxHashSet::default();
1078 for m in &raw.matcher {
1079 let pats = m.callee_patterns.join("|");
1080 let enabler = m.enabler.as_deref().unwrap_or("");
1084 let import_provenance = m.import_provenance.as_deref().unwrap_or("");
1085 let arg_kinds = m
1086 .arg_kinds
1087 .as_ref()
1088 .map_or_else(String::new, |kinds| kinds.join("|"));
1089 let literal_values = m
1090 .literal_values
1091 .as_ref()
1092 .map_or_else(String::new, |values| values.join("|"));
1093 let literal_contains = m
1094 .literal_contains
1095 .as_ref()
1096 .map_or_else(String::new, |values| values.join("|"));
1097 let literal_integers = m
1098 .literal_integers
1099 .as_ref()
1100 .map_or_else(String::new, |values| {
1101 values
1102 .iter()
1103 .map(i64::to_string)
1104 .collect::<Vec<_>>()
1105 .join("|")
1106 });
1107 let object_properties = format!("{:?}", m.object_properties);
1108 let object_missing_or_false = m
1109 .object_missing_or_false
1110 .as_ref()
1111 .map_or_else(String::new, |keys| keys.join("|"));
1112 let object_missing = m
1113 .object_missing
1114 .as_ref()
1115 .map_or_else(String::new, |keys| keys.join("|"));
1116 let context_keywords = m
1117 .context_keywords
1118 .as_ref()
1119 .map_or_else(String::new, |keywords| keywords.join("|"));
1120 let key = format!(
1121 "{}::{}::{pats}::{enabler}::{import_provenance}::{}::{arg_kinds}::{literal_values}::{literal_contains}::{literal_integers}::{object_properties}::{object_missing_or_false}::{object_missing}::{context_keywords}",
1122 m.id, m.sink_shape, m.requires_source
1123 );
1124 assert!(seen.insert(key.clone()), "duplicate matcher row: {key}");
1125 }
1126 }
1127
1128 #[test]
1129 fn path_join_row_admits_every_path_segment() {
1130 let row = catalogue()
1131 .matchers()
1132 .iter()
1133 .find(|m| m.id == "path-traversal" && m.first_matching_pattern("path.join").is_some())
1134 .expect("path.join row exists");
1135 assert!(row.variadic, "path.join must inspect every segment (#3712)");
1136 assert!(row.admits_arg_index(0));
1137 assert!(row.admits_arg_index(3));
1138
1139 let rename_destination = catalogue()
1140 .matchers()
1141 .iter()
1142 .find(|m| m.id == "path-traversal" && m.arg_index == 1)
1143 .expect("fs.rename destination row exists");
1144 assert!(!rename_destination.admits_arg_index(0));
1145 assert!(!rename_destination.admits_arg_index(2));
1146 }
1147
1148 #[test]
1149 fn catalogue_ids_non_empty() {
1150 for m in catalogue().matchers() {
1151 assert!(
1152 !m.id.trim().is_empty(),
1153 "matcher id must be non-empty / non-whitespace"
1154 );
1155 }
1156 }
1157
1158 #[test]
1159 fn catalogue_cwe_valid() {
1160 for m in catalogue().matchers() {
1161 assert!(m.cwe > 0, "matcher {:?} has cwe 0", m.id);
1162 }
1163 }
1164
1165 #[test]
1166 fn catalogue_sink_shapes_known() {
1167 let raw: RawCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
1170 for m in &raw.matcher {
1171 assert!(
1172 parse_sink_shape(&m.sink_shape).is_some(),
1173 "matcher {:?} has unknown sink_shape {:?}",
1174 m.id,
1175 m.sink_shape
1176 );
1177 }
1178 }
1179
1180 #[test]
1181 fn catalogue_callee_patterns_non_empty() {
1182 for m in catalogue().matchers() {
1183 assert!(
1184 !m.callee_patterns.is_empty(),
1185 "matcher {:?} has no callee_patterns",
1186 m.id
1187 );
1188 for p in &m.callee_patterns {
1189 assert!(
1190 !p.raw().trim().is_empty(),
1191 "matcher {:?} has an empty callee_pattern",
1192 m.id
1193 );
1194 }
1195 }
1196 }
1197
1198 #[test]
1199 fn catalogue_evidence_templates_non_empty() {
1200 for m in catalogue().matchers() {
1201 assert!(
1202 !m.evidence_template.trim().is_empty(),
1203 "matcher {:?} has an empty evidence_template",
1204 m.id
1205 );
1206 }
1207 }
1208
1209 #[test]
1210 fn parse_rejects_empty_id() {
1211 let toml = r#"
1212[[matcher]]
1213id = ""
1214cwe = 79
1215title = "x"
1216effect = "unknown"
1217sink_shape = "member-assign"
1218callee_patterns = ["*.innerHTML"]
1219arg_index = 0
1220evidence_template = "x"
1221"#;
1222 let err = parse_catalogue(toml).unwrap_err();
1223 assert!(err.contains("id must be non-empty"), "got: {err}");
1224 }
1225
1226 #[test]
1227 fn parse_rejects_zero_cwe() {
1228 let toml = r#"
1229[[matcher]]
1230id = "x"
1231cwe = 0
1232title = "x"
1233effect = "unknown"
1234sink_shape = "member-assign"
1235callee_patterns = ["*.innerHTML"]
1236arg_index = 0
1237evidence_template = "x"
1238"#;
1239 let err = parse_catalogue(toml).unwrap_err();
1240 assert!(err.contains("cwe"), "got: {err}");
1241 }
1242
1243 #[test]
1244 fn parse_rejects_missing_effect() {
1245 let toml = r#"
1246[[matcher]]
1247id = "x"
1248cwe = 79
1249title = "x"
1250sink_shape = "member-assign"
1251callee_patterns = ["*.innerHTML"]
1252arg_index = 0
1253evidence_template = "x"
1254"#;
1255 let err = parse_catalogue(toml).unwrap_err();
1256 assert!(err.contains("missing field `effect`"), "got: {err}");
1257 }
1258
1259 #[test]
1260 fn parse_rejects_unknown_sink_shape() {
1261 let toml = r#"
1262[[matcher]]
1263id = "x"
1264cwe = 79
1265title = "x"
1266effect = "unknown"
1267sink_shape = "not-a-shape"
1268callee_patterns = ["*.innerHTML"]
1269arg_index = 0
1270evidence_template = "x"
1271"#;
1272 let err = parse_catalogue(toml).unwrap_err();
1273 assert!(err.contains("unknown sink_shape"), "got: {err}");
1274 }
1275
1276 #[test]
1277 fn parse_rejects_empty_callee_patterns() {
1278 let toml = r#"
1279[[matcher]]
1280id = "x"
1281cwe = 79
1282title = "x"
1283effect = "unknown"
1284sink_shape = "member-assign"
1285callee_patterns = []
1286arg_index = 0
1287evidence_template = "x"
1288"#;
1289 let err = parse_catalogue(toml).unwrap_err();
1290 assert!(err.contains("callee_patterns"), "got: {err}");
1291 }
1292
1293 #[test]
1294 fn parse_rejects_empty_pattern_string() {
1295 let toml = r#"
1296[[matcher]]
1297id = "x"
1298cwe = 79
1299title = "x"
1300effect = "unknown"
1301sink_shape = "member-assign"
1302callee_patterns = [" "]
1303arg_index = 0
1304evidence_template = "x"
1305"#;
1306 let err = parse_catalogue(toml).unwrap_err();
1307 assert!(err.contains("empty"), "got: {err}");
1308 }
1309
1310 #[test]
1311 fn parse_rejects_empty_evidence_template() {
1312 let toml = r#"
1313[[matcher]]
1314id = "x"
1315cwe = 79
1316title = "x"
1317effect = "unknown"
1318sink_shape = "member-assign"
1319callee_patterns = ["*.innerHTML"]
1320arg_index = 0
1321evidence_template = " "
1322"#;
1323 let err = parse_catalogue(toml).unwrap_err();
1324 assert!(err.contains("evidence_template"), "got: {err}");
1325 }
1326
1327 #[test]
1328 fn parse_rejects_no_matchers() {
1329 let err = parse_catalogue("").unwrap_err();
1330 assert!(err.contains("no [[matcher]]"), "got: {err}");
1331 }
1332
1333 #[test]
1334 fn segment_match_is_not_substring() {
1335 let bare = parse_callee_pattern("fetch").unwrap();
1336 assert!(bare.matches("fetch"));
1337 assert!(!bare.matches("myfetch"));
1338 assert!(!bare.matches("fetcher"));
1339
1340 let wildcard = parse_callee_pattern("*.innerHTML").unwrap();
1341 assert!(wildcard.matches("el.innerHTML"));
1342 assert!(wildcard.matches("this.node.innerHTML"));
1343 assert!(!wildcard.matches("el.innerHTMLFoo"));
1344 assert!(!wildcard.matches("innerHTML")); let dotted = parse_callee_pattern("child_process.exec").unwrap();
1347 assert!(dotted.matches("child_process.exec"));
1348 assert!(!dotted.matches("exec"));
1349 assert!(!dotted.matches("child_process.execSync"));
1350 assert!(!dotted.matches("my_child_process.exec"));
1351 }
1352
1353 #[test]
1354 fn wildcard_only_pattern_matches_nothing() {
1355 let star = parse_callee_pattern("*").unwrap();
1357 assert!(!star.matches("el.innerHTML"));
1358 assert!(!star.matches("anything"));
1359 }
1360
1361 #[test]
1362 fn trailing_wildcard_prefix_matches() {
1363 let trailing = parse_callee_pattern("child_process.*").unwrap();
1364 assert!(trailing.matches("child_process.exec"));
1365 assert!(trailing.matches("child_process.exec.call"));
1366 assert!(!trailing.matches("child_process")); assert!(!trailing.matches("my_child_process.exec"));
1368 assert!(!trailing.matches("exec"));
1369
1370 let console = parse_callee_pattern("console.*").unwrap();
1371 assert!(console.matches("console.log"));
1372 assert!(!console.matches("myconsole.log"));
1373 }
1374
1375 #[test]
1376 fn double_wildcard_pattern_matches_nothing() {
1377 let both = parse_callee_pattern("*.query.*").unwrap();
1380 assert!(!both.matches("db.query.run"));
1381 let stars = parse_callee_pattern("*.*").unwrap();
1382 assert!(!stars.matches("a.b"));
1383 }
1384
1385 #[test]
1386 fn arg_kinds_unset_admits_any_shape() {
1387 let html = catalogue()
1389 .matchers()
1390 .iter()
1391 .find(|m| m.id == "dangerous-html")
1392 .expect("dangerous-html present");
1393 for kind in [
1394 SinkArgKind::TemplateWithSubst,
1395 SinkArgKind::Concat,
1396 SinkArgKind::Object,
1397 SinkArgKind::Call,
1398 SinkArgKind::Literal,
1399 SinkArgKind::NoArg,
1400 SinkArgKind::Other,
1401 ] {
1402 assert!(html.admits_arg_kind(kind), "html admits {kind:?}");
1403 }
1404 }
1405
1406 #[test]
1407 fn sql_injection_query_execute_excludes_object_arg_kind() {
1408 let query_matchers: Vec<&Matcher> = catalogue()
1413 .matchers()
1414 .iter()
1415 .filter(|m| {
1416 m.id == "sql-injection"
1417 && m.callee_patterns
1418 .iter()
1419 .any(|p| p.raw() == "*.query" || p.raw() == "*.execute")
1420 })
1421 .collect();
1422 assert!(
1423 !query_matchers.is_empty(),
1424 "sql-injection .query/.execute rows present"
1425 );
1426 for m in query_matchers {
1427 let kinds = m
1428 .arg_kinds
1429 .as_ref()
1430 .unwrap_or_else(|| panic!("sql-injection query/execute must constrain arg_kinds"));
1431 assert!(
1432 !kinds.contains(&SinkArgKind::Object),
1433 "sql-injection .query/.execute must not admit the object (parameterized) form"
1434 );
1435 assert!(
1436 !m.admits_arg_kind(SinkArgKind::Object),
1437 "admits_arg_kind agrees: object excluded"
1438 );
1439 assert!(
1440 m.admits_arg_kind(SinkArgKind::Concat),
1441 "sql-injection .query/.execute admits the concat (unsafe) form"
1442 );
1443 }
1444 }
1445
1446 #[test]
1447 fn source_required_matchers_are_explicit() {
1448 let mass_assignment = catalogue()
1449 .matchers()
1450 .iter()
1451 .find(|m| m.id == "mass-assignment")
1452 .expect("mass-assignment row present");
1453 assert!(
1454 mass_assignment.requires_source,
1455 "mass-assignment should only fire for source-backed arguments"
1456 );
1457 }
1458
1459 #[test]
1460 fn literal_integer_predicate_matches_integer_literals() {
1461 let chmod = catalogue()
1462 .matchers()
1463 .iter()
1464 .find(|m| m.id == "world-writable-permission" && m.sink_shape == SinkShape::MemberCall)
1465 .expect("world-writable permission row present");
1466
1467 assert!(chmod.literal_value_satisfied(Some(&SinkLiteralValue::Integer(511))));
1468 assert!(!chmod.literal_value_satisfied(Some(&SinkLiteralValue::Integer(420))));
1469 assert!(
1470 !chmod.literal_value_satisfied(Some(&SinkLiteralValue::String("0o777".to_string())))
1471 );
1472 }
1473
1474 #[test]
1475 fn object_property_predicate_matches_nested_integer_values() {
1476 let toml = r#"
1477[[matcher]]
1478id = "x"
1479cwe = 732
1480title = "x"
1481effect = "unknown"
1482sink_shape = "member-call"
1483callee_patterns = ["fs.chmod"]
1484arg_index = 0
1485arg_kinds = ["object"]
1486object_properties = [{ key = "mode.value", integer = 511 }]
1487evidence_template = "x"
1488"#;
1489 let cat = parse_catalogue(toml).expect("catalogue parses");
1490 let matcher = cat.matchers().first().expect("matcher present");
1491 let properties = vec![SinkObjectProperty {
1492 key: "mode.value".to_string(),
1493 value: SinkLiteralValue::Integer(511),
1494 }];
1495
1496 assert!(matcher.object_properties_satisfied(&properties));
1497 }
1498
1499 #[test]
1500 fn object_missing_requires_complete_key_metadata() {
1501 let jwt_verify = catalogue()
1502 .matchers()
1503 .iter()
1504 .find(|m| m.id == "jwt-verify-missing-algorithms")
1505 .expect("jwt verify missing algorithms row present");
1506
1507 assert!(
1508 jwt_verify.is_literal_aware(),
1509 "object_missing rows opt into literal-aware matching"
1510 );
1511 assert!(jwt_verify.object_missing_satisfied(&[], true));
1512 assert!(jwt_verify.object_missing_satisfied(&["audience".to_string()], true));
1513 assert!(!jwt_verify.object_missing_satisfied(&["algorithms".to_string()], true));
1514 assert!(!jwt_verify.object_missing_satisfied(&["audience".to_string()], false));
1515 }
1516
1517 #[test]
1518 fn parse_rejects_unknown_arg_kind() {
1519 let toml = r#"
1520[[matcher]]
1521id = "x"
1522cwe = 89
1523title = "x"
1524effect = "unknown"
1525sink_shape = "member-call"
1526callee_patterns = ["*.query"]
1527arg_index = 0
1528arg_kinds = ["not-a-kind"]
1529evidence_template = "x"
1530"#;
1531 let err = parse_catalogue(toml).unwrap_err();
1532 assert!(err.contains("unknown arg_kind"), "got: {err}");
1533 }
1534
1535 #[test]
1536 fn enabler_unset_is_global() {
1537 let html = catalogue()
1539 .matchers()
1540 .iter()
1541 .find(|m| m.id == "dangerous-html")
1542 .expect("dangerous-html present");
1543 assert!(html.enabler.is_none(), "dangerous-html is a global row");
1544 assert!(html.enabler_satisfied(&FxHashSet::default()));
1545 }
1546
1547 #[test]
1548 fn enabler_satisfied_exact_and_prefix() {
1549 let mut m = catalogue()
1550 .matchers()
1551 .iter()
1552 .find(|m| m.id == "dangerous-html")
1553 .cloned()
1554 .expect("dangerous-html present");
1555
1556 m.enabler = Some("jquery".to_string());
1558 let mut deps = FxHashSet::default();
1559 assert!(!m.enabler_satisfied(&deps), "absent dep is not satisfied");
1560 deps.insert("jquery".to_string());
1561 assert!(m.enabler_satisfied(&deps), "present exact dep satisfies");
1562
1563 m.enabler = Some("@angular/".to_string());
1565 let mut scoped = FxHashSet::default();
1566 assert!(!m.enabler_satisfied(&scoped));
1567 scoped.insert("@angular/platform-browser".to_string());
1568 assert!(m.enabler_satisfied(&scoped), "prefix dep satisfies");
1569 let mut bare_scope = FxHashSet::default();
1570 bare_scope.insert("@angular".to_string());
1571 assert!(
1572 m.enabler_satisfied(&bare_scope),
1573 "bare scope name satisfies the prefix form"
1574 );
1575
1576 m.enabler = Some("react".to_string());
1578 let mut reactish = FxHashSet::default();
1579 reactish.insert("react-dom".to_string());
1580 assert!(
1581 !m.enabler_satisfied(&reactish),
1582 "exact enabler must not prefix-match"
1583 );
1584 }
1585
1586 #[test]
1587 fn framework_scoped_rows_are_present() {
1588 let cat = catalogue();
1590 let angular = cat
1591 .matchers()
1592 .iter()
1593 .find(|m| m.id == "angular-trusted-html")
1594 .expect("angular-trusted-html present");
1595 assert_eq!(
1596 angular.enabler.as_deref(),
1597 Some("@angular/platform-browser")
1598 );
1599 assert!(
1600 cat.matchers().iter().any(|m| m.id == "jquery-html"),
1601 "jquery-html present"
1602 );
1603 assert!(
1604 cat.matchers().iter().any(|m| m.id == "dom-document-write"),
1605 "dom-document-write present"
1606 );
1607 }
1608
1609 #[test]
1610 fn parse_rejects_empty_enabler() {
1611 let toml = r#"
1612[[matcher]]
1613id = "x"
1614cwe = 79
1615title = "x"
1616effect = "unknown"
1617sink_shape = "member-call"
1618callee_patterns = ["*.html"]
1619arg_index = 0
1620enabler = " "
1621evidence_template = "x"
1622"#;
1623 let err = parse_catalogue(toml).unwrap_err();
1624 assert!(err.contains("empty / whitespace enabler"), "got: {err}");
1625 }
1626
1627 #[test]
1628 fn catalogue_has_untrusted_sources() {
1629 let cat = catalogue();
1632 assert!(
1633 !cat.sources.is_empty(),
1634 "catalogue must ship untrusted-source rows"
1635 );
1636 for s in &cat.sources {
1637 assert!(!s.id.trim().is_empty(), "source id non-empty");
1638 assert!(!s.title.trim().is_empty(), "source title non-empty");
1639 assert!(!s.path_patterns.is_empty(), "source has path patterns");
1640 }
1641 }
1642
1643 #[test]
1644 fn source_paths_match_expected_request_inputs() {
1645 let cat = catalogue();
1646 assert!(is_source(cat, "req.query"));
1648 assert!(is_source(cat, "ctx.req.query"));
1649 assert!(is_source(cat, "request.body"));
1650 assert!(is_source(cat, "req.params"));
1651 assert!(is_source(cat, "process.argv"));
1652 assert!(is_source(cat, "event.data"));
1653 assert!(is_source(cat, "request.rawBody"));
1654 assert!(is_source(cat, "document.referrer"));
1655 assert!(is_source(cat, "window.name"));
1656 assert!(is_source(cat, "document.cookie"));
1657 assert!(!is_source(cat, "config.value"));
1659 assert!(!is_source(cat, "user.name"));
1660 assert!(!is_source(cat, "profile.name"));
1661 assert!(!is_source(cat, "jar.cookie"));
1662 }
1663
1664 #[test]
1665 fn source_matcher_matches_helper() {
1666 let cat = catalogue();
1667 let http = cat
1668 .sources
1669 .iter()
1670 .find(|s| s.id == "http-request-input")
1671 .expect("http-request-input source present");
1672 assert!(http.matches_with_extra_receivers("req.query", &FxHashSet::default()));
1673 assert!(!http.matches_with_extra_receivers("process.argv", &FxHashSet::default()));
1674 }
1675
1676 #[test]
1677 fn matched_receiver_returns_segment_before_suffix() {
1678 let pat = parse_callee_pattern("*.query").expect("pattern parses");
1681 assert_eq!(pat.matched_receiver("db.query"), Some("db"));
1682 assert_eq!(pat.matched_receiver("req.query"), Some("req"));
1683 assert_eq!(pat.matched_receiver("ctx.req.query"), Some("req"));
1685 assert_eq!(pat.matched_receiver("req.body"), None);
1687 let exact = parse_callee_pattern("process.env").expect("pattern parses");
1690 assert_eq!(exact.matched_receiver("process.env"), None);
1691 }
1692
1693 #[test]
1694 fn receiver_allowlist_rejects_orm_query_builders_keeps_request_objects() {
1695 let cat = catalogue();
1698 assert!(!is_source(cat, "db.query"), "Drizzle db.query");
1699 assert!(!is_source(cat, "prisma.query"), "Prisma prisma.query");
1700 assert!(!is_source(cat, "drizzle.query"));
1701 assert!(!is_source(cat, "knex.body"));
1702 assert!(!is_source(cat, "client.query"));
1703 assert!(!is_source(cat, "dbConn.query"));
1706 assert!(!is_source(cat, "database.params"));
1707 assert!(is_source(cat, "req.query"), "Express req.query");
1709 assert!(is_source(cat, "request.body"));
1710 assert!(is_source(cat, "ctx.params"), "Koa/Elysia ctx.params");
1711 assert!(is_source(cat, "context.body"));
1712 assert!(is_source(cat, "event.query"), "SvelteKit event.query");
1713 assert!(is_source(cat, "ctx.req.query"));
1715 assert!(is_source(cat, "Req.query"));
1717 }
1718
1719 #[test]
1720 fn configured_request_receivers_extend_http_request_source_allowlist() {
1721 let cat = catalogue();
1722 let deps = FxHashSet::default();
1723 let receivers = FxHashSet::from_iter(["h".to_string(), "httpreq".to_string()]);
1724
1725 assert!(
1726 cat.matching_source_for_deps_with_receivers("h.query", &deps, &receivers)
1727 .is_some()
1728 );
1729 assert!(
1730 cat.matching_source_for_deps_with_receivers("HttpReq.body", &deps, &receivers)
1731 .is_some()
1732 );
1733 assert!(
1734 cat.matching_source_for_deps_with_receivers("req.params", &deps, &receivers)
1735 .is_some()
1736 );
1737 assert!(
1738 cat.matching_source_for_deps_with_receivers("db.query", &deps, &receivers)
1739 .is_none()
1740 );
1741 }
1742
1743 #[test]
1744 fn search_params_source_stays_ungated() {
1745 let cat = catalogue();
1748 assert!(is_source(cat, "u.searchParams"));
1749 assert!(is_source(cat, "url.searchParams"));
1750 assert!(is_source(cat, "params.searchParams"));
1751 }
1752
1753 #[test]
1754 fn parse_rejects_empty_receiver_allowlist_entry() {
1755 let toml = r#"
1756[[matcher]]
1757id = "x"
1758cwe = 79
1759title = "x"
1760effect = "unknown"
1761sink_shape = "member-assign"
1762callee_patterns = ["*.innerHTML"]
1763arg_index = 0
1764evidence_template = "x"
1765
1766[[source]]
1767id = "http"
1768title = "HTTP"
1769path_patterns = ["*.query"]
1770receiver_allowlist = ["req", " "]
1771"#;
1772 let err = parse_catalogue(toml).unwrap_err();
1773 assert!(err.contains("receiver_allowlist"), "got: {err}");
1774 }
1775
1776 #[test]
1777 fn source_enabler_gates_framework_param_sources() {
1778 let cat = catalogue();
1779 let source = cat
1780 .sources
1781 .iter()
1782 .find(|s| s.id == "framework-handler-input" && s.enabler.as_deref() == Some("express"))
1783 .expect("express handler source present");
1784 assert!(source.matches_with_extra_receivers("framework.request", &FxHashSet::default()));
1785
1786 let empty = FxHashSet::default();
1787 assert!(!source.enabler_satisfied(&empty));
1788 assert!(
1789 source_for(cat, "framework.request", &empty).is_none(),
1790 "framework handler params require an enabler"
1791 );
1792
1793 let mut deps = FxHashSet::default();
1794 deps.insert("express".to_string());
1795 assert!(source.enabler_satisfied(&deps));
1796 assert_eq!(
1797 source_for(cat, "framework.request", &deps),
1798 Some(("framework-handler-input", "Framework handler input"))
1799 );
1800 }
1801
1802 #[test]
1803 fn source_enabler_gates_graphql_and_trpc_param_sources() {
1804 let cat = catalogue();
1805 let empty = FxHashSet::default();
1806 assert!(
1807 source_for(cat, "graphql.args", &empty).is_none(),
1808 "GraphQL resolver args require a matching package"
1809 );
1810 assert!(
1811 source_for(cat, "trpc.input", &empty).is_none(),
1812 "tRPC procedure input requires a matching package"
1813 );
1814
1815 let mut graphql_deps = FxHashSet::default();
1816 graphql_deps.insert("@apollo/server".to_string());
1817 assert_eq!(
1818 source_for(cat, "graphql.args", &graphql_deps),
1819 Some(("graphql-resolver-args", "GraphQL resolver args"))
1820 );
1821
1822 let mut trpc_deps = FxHashSet::default();
1823 trpc_deps.insert("@trpc/server".to_string());
1824 assert_eq!(
1825 source_for(cat, "trpc.input", &trpc_deps),
1826 Some(("trpc-procedure-input", "tRPC procedure input"))
1827 );
1828 }
1829
1830 #[test]
1831 fn parse_rejects_source_without_patterns() {
1832 let toml = r#"
1833[[matcher]]
1834id = "x"
1835cwe = 79
1836title = "x"
1837effect = "unknown"
1838sink_shape = "member-assign"
1839callee_patterns = ["*.innerHTML"]
1840arg_index = 0
1841evidence_template = "x"
1842
1843[[source]]
1844id = "bad"
1845title = "bad"
1846path_patterns = []
1847"#;
1848 let err = parse_catalogue(toml).unwrap_err();
1849 assert!(err.contains("path_patterns"), "got: {err}");
1850 }
1851
1852 #[test]
1853 fn parse_rejects_empty_arg_kinds() {
1854 let toml = r#"
1855[[matcher]]
1856id = "x"
1857cwe = 89
1858title = "x"
1859effect = "unknown"
1860sink_shape = "member-call"
1861callee_patterns = ["*.query"]
1862arg_index = 0
1863arg_kinds = []
1864evidence_template = "x"
1865"#;
1866 let err = parse_catalogue(toml).unwrap_err();
1867 assert!(err.contains("empty arg_kinds"), "got: {err}");
1868 }
1869}