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