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