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