1use fallow_config::EffectKind;
16use fallow_types::extract::{SinkArgKind, SinkLiteralValue, SinkObjectProperty, SinkShape};
17use rustc_hash::FxHashSet;
18
19mod identity;
20mod provenance;
21mod rules;
22mod severity;
23
24pub use identity::{security_finding_id, security_rule_id, stamp_security_finding_ids};
25pub use provenance::{ProvenanceBindings, import_source_matches};
26pub use rules::{
27 enable_security_rules, resolve_security_finding_severity, retain_enabled_security_findings,
28 security_rules_can_error,
29};
30pub use severity::{derive_security_severity, security_catalogue_title};
31
32pub const HARDCODED_SECRET_CATEGORY_ID: &str = "hardcoded-secret";
33pub const HARDCODED_SECRET_CATEGORY_TITLE: &str = "Hardcoded secret candidate";
34
35const CATALOGUE_TOML: &str = include_str!("../data/security_matchers.toml");
39
40#[derive(serde::Deserialize)]
41struct RawCatalogue {
42 #[serde(default)]
43 matcher: Vec<RawMatcher>,
44 #[serde(default)]
45 source: Vec<RawSource>,
46}
47
48#[derive(serde::Deserialize)]
52struct RawSource {
53 id: String,
54 title: String,
55 #[serde(default)]
57 enabler: Option<String>,
58 path_patterns: Vec<String>,
59 #[serde(default)]
66 receiver_allowlist: Vec<String>,
67}
68
69#[derive(serde::Deserialize)]
70struct RawMatcher {
71 id: String,
72 cwe: u32,
73 title: String,
74 effect: EffectKind,
75 sink_shape: String,
77 callee_patterns: Vec<String>,
78 arg_index: u32,
79 evidence_template: String,
80 #[serde(default)]
81 import_provenance: Option<String>,
82 #[serde(default)]
91 enabler: Option<String>,
92 #[serde(default)]
99 arg_kinds: Option<Vec<String>>,
100 #[serde(default)]
102 literal_values: Option<Vec<String>>,
103 #[serde(default)]
105 literal_contains: Option<Vec<String>>,
106 #[serde(default)]
108 literal_integers: Option<Vec<i64>>,
109 #[serde(default)]
111 object_properties: Option<Vec<RawObjectPropertyPredicate>>,
112 #[serde(default)]
114 object_missing_or_false: Option<Vec<String>>,
115 #[serde(default)]
119 object_missing: Option<Vec<String>>,
120 #[serde(default)]
122 context_keywords: Option<Vec<String>>,
123 #[serde(default)]
126 requires_source: bool,
127 #[serde(default)]
134 requires_source_kinds: Vec<String>,
135}
136
137#[derive(Debug, serde::Deserialize)]
138struct RawObjectPropertyPredicate {
139 key: String,
140 #[serde(default)]
141 string: Option<String>,
142 #[serde(default)]
143 boolean: Option<bool>,
144 #[serde(default)]
145 integer: Option<i64>,
146 #[serde(default)]
147 null: bool,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum LiteralPredicate {
152 String(String),
153 Integer(i64),
154 Boolean(bool),
155 Null,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct ObjectPropertyPredicate {
160 key: String,
161 value: LiteralPredicate,
162}
163
164#[derive(Debug, Clone)]
173pub struct CalleePattern {
174 raw: String,
177 suffix_segments: Vec<String>,
181 leading_wildcard: bool,
183 trailing_wildcard: bool,
185}
186
187impl CalleePattern {
188 #[must_use]
193 pub fn parse(raw: &str) -> Option<Self> {
194 parse_callee_pattern(raw)
195 }
196
197 #[must_use]
199 pub fn raw(&self) -> &str {
200 &self.raw
201 }
202
203 #[must_use]
215 pub fn matches(&self, callee_path: &str) -> bool {
216 if self.suffix_segments.is_empty() || (self.leading_wildcard && self.trailing_wildcard) {
218 return false;
219 }
220 let candidate: Vec<&str> = callee_path.split('.').collect();
221 if self.leading_wildcard {
222 if self.suffix_segments.len() >= candidate.len() {
226 return false;
227 }
228 let tail = &candidate[candidate.len() - self.suffix_segments.len()..];
229 self.suffix_segments
230 .iter()
231 .zip(tail)
232 .all(|(pat, seg)| pat == seg)
233 } else if self.trailing_wildcard {
234 if self.suffix_segments.len() >= candidate.len() {
238 return false;
239 }
240 let head = &candidate[..self.suffix_segments.len()];
241 self.suffix_segments
242 .iter()
243 .zip(head)
244 .all(|(pat, seg)| pat == seg)
245 } else {
246 self.suffix_segments.len() == candidate.len()
247 && self
248 .suffix_segments
249 .iter()
250 .zip(&candidate)
251 .all(|(pat, seg)| pat == seg)
252 }
253 }
254
255 #[must_use]
264 fn matched_receiver<'p>(&self, callee_path: &'p str) -> Option<&'p str> {
265 if !self.leading_wildcard || !self.matches(callee_path) {
266 return None;
267 }
268 let candidate: Vec<&str> = callee_path.split('.').collect();
269 let recv_idx = candidate.len() - self.suffix_segments.len() - 1;
272 candidate.get(recv_idx).copied()
273 }
274}
275
276fn parse_callee_pattern(raw: &str) -> Option<CalleePattern> {
279 if raw.trim().is_empty() {
280 return None;
281 }
282 let mut segments: Vec<&str> = raw.split('.').collect();
283 let leading_wildcard = segments.first() == Some(&"*");
284 if leading_wildcard {
285 segments.remove(0);
286 }
287 let trailing_wildcard = segments.last() == Some(&"*");
288 if trailing_wildcard {
289 segments.pop();
290 }
291 Some(CalleePattern {
292 raw: raw.to_string(),
293 suffix_segments: segments.into_iter().map(str::to_string).collect(),
294 leading_wildcard,
295 trailing_wildcard,
296 })
297}
298
299#[derive(Debug, Clone)]
302pub struct Matcher {
303 pub id: String,
304 pub cwe: u32,
305 pub title: String,
306 pub effect: EffectKind,
307 pub sink_shape: SinkShape,
308 pub callee_patterns: Vec<CalleePattern>,
309 pub arg_index: u32,
310 pub evidence_template: String,
311 pub import_provenance: Option<String>,
312 pub enabler: Option<String>,
316 pub arg_kinds: Option<Vec<SinkArgKind>>,
319 pub requires_source: bool,
322 pub requires_source_kinds: Vec<String>,
326 pub literal_values: Vec<String>,
328 pub literal_contains: Vec<String>,
330 pub literal_integers: Vec<i64>,
332 pub object_properties: Vec<ObjectPropertyPredicate>,
334 pub object_missing_or_false: Vec<String>,
336 pub object_missing: Vec<String>,
338 pub context_keywords: Vec<String>,
340}
341
342#[derive(Debug, Clone)]
347pub struct SourceMatcher {
348 id: String,
349 title: String,
350 enabler: Option<String>,
351 path_patterns: Vec<CalleePattern>,
352 receiver_allowlist: Vec<String>,
355}
356
357impl SourceMatcher {
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 #[must_use]
581 pub fn matching_source_for_deps_with_receivers(
582 &self,
583 source_path: &str,
584 declared_deps: &FxHashSet<String>,
585 request_receivers: &FxHashSet<String>,
586 ) -> Option<(&str, &str)> {
587 let empty_receivers = FxHashSet::default();
588 self.sources
589 .iter()
590 .find(|s| {
591 let extra_receivers = if s.id == "http-request-input" {
592 request_receivers
593 } else {
594 &empty_receivers
595 };
596 s.enabler_satisfied(declared_deps)
597 && s.matches_with_extra_receivers(source_path, extra_receivers)
598 })
599 .map(|s| (s.id.as_str(), s.title.as_str()))
600 }
601
602 #[must_use]
604 fn title_for(&self, id: &str) -> Option<&str> {
605 self.matchers
606 .iter()
607 .find(|m| m.id == id)
608 .map(|m| m.title.as_str())
609 }
610}
611
612#[must_use]
614pub fn catalogue_title(id: &str) -> Option<&'static str> {
615 catalogue().title_for(id)
616}
617
618const SECRET_TO_NETWORK_CATEGORY_ID: &str = "secret-to-network";
622
623#[must_use]
628fn is_include_required_category(id: &str) -> bool {
629 id == HARDCODED_SECRET_CATEGORY_ID || id == SECRET_TO_NETWORK_CATEGORY_ID
630}
631
632#[derive(Debug, Clone)]
635pub struct SecurityCategory {
636 pub id: String,
638 pub title: String,
640 pub cwe: Option<u32>,
643 pub include_required: bool,
646}
647
648#[must_use]
656pub fn security_categories() -> Vec<SecurityCategory> {
657 let mut seen = FxHashSet::default();
658 let mut out = Vec::new();
659 for matcher in catalogue().matchers() {
660 if seen.insert(matcher.id.clone()) {
661 out.push(SecurityCategory {
662 id: matcher.id.clone(),
663 title: matcher.title.clone(),
664 cwe: Some(matcher.cwe),
665 include_required: is_include_required_category(&matcher.id),
666 });
667 }
668 }
669 if seen.insert(HARDCODED_SECRET_CATEGORY_ID.to_owned()) {
670 out.push(SecurityCategory {
671 id: HARDCODED_SECRET_CATEGORY_ID.to_owned(),
672 title: HARDCODED_SECRET_CATEGORY_TITLE.to_owned(),
673 cwe: None,
674 include_required: true,
675 });
676 }
677 out.sort_by(|a, b| a.id.cmp(&b.id));
678 out
679}
680
681fn parse_sink_shape(s: &str) -> Option<SinkShape> {
683 match s {
684 "call" => Some(SinkShape::Call),
685 "member-call" => Some(SinkShape::MemberCall),
686 "member-assign" => Some(SinkShape::MemberAssign),
687 "tagged-template" => Some(SinkShape::TaggedTemplate),
688 "jsx-attr" => Some(SinkShape::JsxAttr),
689 "new-expression" => Some(SinkShape::NewExpression),
690 _ => None,
691 }
692}
693
694fn parse_arg_kind(s: &str) -> Option<SinkArgKind> {
696 match s {
697 "template-with-subst" => Some(SinkArgKind::TemplateWithSubst),
698 "concat" => Some(SinkArgKind::Concat),
699 "object" => Some(SinkArgKind::Object),
700 "call" => Some(SinkArgKind::Call),
701 "literal" => Some(SinkArgKind::Literal),
702 "no-arg" => Some(SinkArgKind::NoArg),
703 "other" => Some(SinkArgKind::Other),
704 _ => None,
705 }
706}
707
708fn parse_object_property_predicates(
709 id: &str,
710 raw: Option<Vec<RawObjectPropertyPredicate>>,
711) -> Result<Vec<ObjectPropertyPredicate>, String> {
712 let Some(raw_predicates) = raw else {
713 return Ok(Vec::new());
714 };
715 let mut predicates = Vec::with_capacity(raw_predicates.len());
716 for predicate in raw_predicates {
717 if predicate.key.trim().is_empty() {
718 return Err(format!(
719 "matcher {id:?} has an object_properties predicate with an empty key"
720 ));
721 }
722 let value_count = usize::from(predicate.string.is_some())
723 + usize::from(predicate.boolean.is_some())
724 + usize::from(predicate.integer.is_some())
725 + usize::from(predicate.null);
726 if value_count != 1 {
727 return Err(format!(
728 "matcher {id:?} object_properties predicate for {:?} must set exactly one of string | boolean | integer | null",
729 predicate.key
730 ));
731 }
732 let value = if let Some(string) = predicate.string {
733 LiteralPredicate::String(string)
734 } else if let Some(boolean) = predicate.boolean {
735 LiteralPredicate::Boolean(boolean)
736 } else if let Some(integer) = predicate.integer {
737 LiteralPredicate::Integer(integer)
738 } else {
739 LiteralPredicate::Null
740 };
741 predicates.push(ObjectPropertyPredicate {
742 key: predicate.key,
743 value,
744 });
745 }
746 Ok(predicates)
747}
748
749fn parse_catalogue(src: &str) -> Result<Catalogue, String> {
756 let raw: RawCatalogue =
757 toml::from_str(src).map_err(|e| format!("security_matchers.toml parse error: {e}"))?;
758
759 let mut matchers = Vec::with_capacity(raw.matcher.len());
760 for entry in raw.matcher {
761 matchers.push(parse_matcher_entry(entry)?);
762 }
763
764 if matchers.is_empty() {
765 return Err("security_matchers.toml has no [[matcher]] entries".to_string());
766 }
767
768 let sources = parse_source_catalogue(raw.source)?;
769
770 Ok(Catalogue { matchers, sources })
771}
772
773fn parse_matcher_entry(entry: RawMatcher) -> Result<Matcher, String> {
777 let (sink_shape, callee_patterns) = validate_matcher_core(&entry)?;
778 let arg_kinds = parse_matcher_arg_kinds(&entry.id, entry.arg_kinds.as_deref())?;
779 let enabler = validate_matcher_enabler(&entry.id, entry.enabler)?;
780 let object_properties = parse_object_property_predicates(&entry.id, entry.object_properties)?;
781 Ok(Matcher {
782 id: entry.id,
783 cwe: entry.cwe,
784 title: entry.title,
785 effect: entry.effect,
786 sink_shape,
787 callee_patterns,
788 arg_index: entry.arg_index,
789 evidence_template: entry.evidence_template,
790 import_provenance: entry.import_provenance,
791 enabler,
792 arg_kinds,
793 requires_source: entry.requires_source,
794 requires_source_kinds: entry.requires_source_kinds,
795 literal_values: entry.literal_values.unwrap_or_default(),
796 literal_contains: entry.literal_contains.unwrap_or_default(),
797 literal_integers: entry.literal_integers.unwrap_or_default(),
798 object_properties,
799 object_missing_or_false: entry.object_missing_or_false.unwrap_or_default(),
800 object_missing: entry.object_missing.unwrap_or_default(),
801 context_keywords: entry.context_keywords.unwrap_or_default(),
802 })
803}
804
805fn validate_matcher_core(entry: &RawMatcher) -> Result<(SinkShape, Vec<CalleePattern>), String> {
808 if entry.id.trim().is_empty() {
809 return Err("matcher id must be non-empty / non-whitespace".to_string());
810 }
811 if entry.cwe == 0 {
812 return Err(format!("matcher {:?} has cwe 0; cwe must be > 0", entry.id));
813 }
814 let sink_shape = parse_sink_shape(&entry.sink_shape).ok_or_else(|| {
815 format!(
816 "matcher {:?} has unknown sink_shape {:?}; expected one of \
817 call | member-call | member-assign | tagged-template | jsx-attr | new-expression",
818 entry.id, entry.sink_shape
819 )
820 })?;
821 if entry.callee_patterns.is_empty() {
822 return Err(format!(
823 "matcher {:?} has no callee_patterns; at least one is required",
824 entry.id
825 ));
826 }
827 if entry.evidence_template.trim().is_empty() {
828 return Err(format!(
829 "matcher {:?} has an empty evidence_template",
830 entry.id
831 ));
832 }
833 let mut callee_patterns = Vec::with_capacity(entry.callee_patterns.len());
834 for pat in &entry.callee_patterns {
835 let parsed = parse_callee_pattern(pat).ok_or_else(|| {
836 format!(
837 "matcher {:?} has an empty / whitespace callee_pattern {pat:?}",
838 entry.id
839 )
840 })?;
841 callee_patterns.push(parsed);
842 }
843 Ok((sink_shape, callee_patterns))
844}
845
846fn validate_matcher_enabler(id: &str, enabler: Option<String>) -> Result<Option<String>, String> {
849 match enabler {
850 Some(e) if e.trim().is_empty() => Err(format!(
851 "matcher {id:?} has an empty / whitespace enabler; omit the key for a global row"
852 )),
853 other => Ok(other),
854 }
855}
856
857fn parse_matcher_arg_kinds(
860 id: &str,
861 raw_kinds: Option<&[String]>,
862) -> Result<Option<Vec<SinkArgKind>>, String> {
863 let Some(raw_kinds) = raw_kinds else {
864 return Ok(None);
865 };
866 if raw_kinds.is_empty() {
867 return Err(format!(
868 "matcher {id:?} has an empty arg_kinds list; omit the key to admit any shape"
869 ));
870 }
871 let mut kinds = Vec::with_capacity(raw_kinds.len());
872 for raw in raw_kinds {
873 let kind = parse_arg_kind(raw).ok_or_else(|| {
874 format!(
875 "matcher {id:?} has unknown arg_kind {raw:?}; expected one of \
876 template-with-subst | concat | object | call | literal | no-arg | other"
877 )
878 })?;
879 kinds.push(kind);
880 }
881 Ok(Some(kinds))
882}
883
884fn parse_source_catalogue(raw_sources: Vec<RawSource>) -> Result<Vec<SourceMatcher>, String> {
885 let mut sources = Vec::with_capacity(raw_sources.len());
886 for entry in raw_sources {
887 if entry.id.trim().is_empty() {
888 return Err("source id must be non-empty / non-whitespace".to_string());
889 }
890 if entry.path_patterns.is_empty() {
891 return Err(format!(
892 "source {:?} has no path_patterns; at least one is required",
893 entry.id
894 ));
895 }
896 let path_patterns = parse_source_path_patterns(&entry)?;
897 let receiver_allowlist = parse_source_receiver_allowlist(&entry)?;
898 let enabler = match entry.enabler {
899 Some(e) if e.trim().is_empty() => {
900 return Err(format!(
901 "source {:?} has an empty / whitespace enabler; omit the key for a global row",
902 entry.id
903 ));
904 }
905 other => other,
906 };
907 sources.push(SourceMatcher {
908 id: entry.id,
909 title: entry.title,
910 enabler,
911 path_patterns,
912 receiver_allowlist,
913 });
914 }
915 Ok(sources)
916}
917
918fn parse_source_path_patterns(entry: &RawSource) -> Result<Vec<CalleePattern>, String> {
919 let mut path_patterns = Vec::with_capacity(entry.path_patterns.len());
920 for pattern in &entry.path_patterns {
921 let parsed = parse_callee_pattern(pattern).ok_or_else(|| {
922 format!(
923 "source {:?} has an empty / whitespace path_pattern {pattern:?}",
924 entry.id
925 )
926 })?;
927 path_patterns.push(parsed);
928 }
929 Ok(path_patterns)
930}
931
932fn parse_source_receiver_allowlist(entry: &RawSource) -> Result<Vec<String>, String> {
933 let mut receiver_allowlist = Vec::with_capacity(entry.receiver_allowlist.len());
934 for receiver in &entry.receiver_allowlist {
935 if receiver.trim().is_empty() {
936 return Err(format!(
937 "source {:?} has an empty / whitespace receiver_allowlist entry; omit the key for an ungated row",
938 entry.id
939 ));
940 }
941 receiver_allowlist.push(receiver.to_ascii_lowercase());
942 }
943 Ok(receiver_allowlist)
944}
945
946#[expect(
954 clippy::expect_used,
955 reason = "compile-time-embedded catalogue pinned by security_catalogue_parses"
956)]
957pub fn catalogue() -> &'static Catalogue {
958 static CATALOGUE: std::sync::OnceLock<Catalogue> = std::sync::OnceLock::new();
959 CATALOGUE.get_or_init(|| {
960 parse_catalogue(CATALOGUE_TOML).expect(
961 "embedded crates/security/data/security_matchers.toml must parse; run \
962 `cargo test -p fallow-security security_catalogue_parses` to see the error",
963 )
964 })
965}
966
967#[cfg(test)]
968#[allow(
969 clippy::expect_used,
970 clippy::unwrap_used,
971 reason = "catalogue parser tests assert fixture invariants directly"
972)]
973mod tests {
974 use super::*;
975 use rustc_hash::FxHashSet;
976
977 fn source_for<'a>(
980 cat: &'a Catalogue,
981 source_path: &str,
982 declared_deps: &FxHashSet<String>,
983 ) -> Option<(&'a str, &'a str)> {
984 cat.matching_source_for_deps_with_receivers(
985 source_path,
986 declared_deps,
987 &FxHashSet::default(),
988 )
989 }
990
991 fn is_source(cat: &Catalogue, source_path: &str) -> bool {
993 source_for(cat, source_path, &FxHashSet::default()).is_some()
994 }
995
996 #[test]
997 fn security_categories_are_deduped_and_flag_include_required() {
998 let cats = security_categories();
999 assert!(!cats.is_empty(), "catalogue must yield categories");
1000 let mut ids = FxHashSet::default();
1002 for c in &cats {
1003 assert!(ids.insert(c.id.clone()), "duplicate category id {}", c.id);
1004 }
1005 let sorted: Vec<&String> = {
1007 let mut v: Vec<&String> = cats.iter().map(|c| &c.id).collect();
1008 v.sort();
1009 v
1010 };
1011 assert_eq!(
1012 cats.iter().map(|c| &c.id).collect::<Vec<_>>(),
1013 sorted,
1014 "categories must be sorted by id"
1015 );
1016 let by_id = |id: &str| cats.iter().find(|c| c.id == id);
1019 let hs = by_id(HARDCODED_SECRET_CATEGORY_ID).expect("hardcoded-secret present");
1020 assert!(hs.include_required && hs.cwe.is_none());
1021 let stn = by_id(SECRET_TO_NETWORK_CATEGORY_ID).expect("secret-to-network present");
1022 assert!(
1023 stn.include_required,
1024 "secret-to-network must be include-required"
1025 );
1026 assert!(
1028 cats.iter().any(|c| !c.include_required),
1029 "most categories are admitted by default"
1030 );
1031 }
1032
1033 #[test]
1034 fn secret_to_network_const_matches_catalogue() {
1035 assert!(
1036 catalogue()
1037 .matchers()
1038 .iter()
1039 .any(|m| m.id == SECRET_TO_NETWORK_CATEGORY_ID),
1040 "SECRET_TO_NETWORK_CATEGORY_ID must name a real catalogue category"
1041 );
1042 }
1043
1044 #[test]
1045 fn security_catalogue_parses() {
1046 let cat = catalogue();
1047 assert!(!cat.matchers().is_empty(), "catalogue must have matchers");
1048 assert!(
1049 cat.matchers().iter().any(|m| m.id == "dangerous-html"),
1050 "catalogue must contain the dangerous-html seed"
1051 );
1052 }
1053
1054 #[test]
1055 fn catalogue_rows_are_unique() {
1056 let raw: RawCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
1061 let mut seen = FxHashSet::default();
1062 for m in &raw.matcher {
1063 let pats = m.callee_patterns.join("|");
1064 let enabler = m.enabler.as_deref().unwrap_or("");
1068 let import_provenance = m.import_provenance.as_deref().unwrap_or("");
1069 let arg_kinds = m
1070 .arg_kinds
1071 .as_ref()
1072 .map_or_else(String::new, |kinds| kinds.join("|"));
1073 let literal_values = m
1074 .literal_values
1075 .as_ref()
1076 .map_or_else(String::new, |values| values.join("|"));
1077 let literal_contains = m
1078 .literal_contains
1079 .as_ref()
1080 .map_or_else(String::new, |values| values.join("|"));
1081 let literal_integers = m
1082 .literal_integers
1083 .as_ref()
1084 .map_or_else(String::new, |values| {
1085 values
1086 .iter()
1087 .map(i64::to_string)
1088 .collect::<Vec<_>>()
1089 .join("|")
1090 });
1091 let object_properties = format!("{:?}", m.object_properties);
1092 let object_missing_or_false = m
1093 .object_missing_or_false
1094 .as_ref()
1095 .map_or_else(String::new, |keys| keys.join("|"));
1096 let object_missing = m
1097 .object_missing
1098 .as_ref()
1099 .map_or_else(String::new, |keys| keys.join("|"));
1100 let context_keywords = m
1101 .context_keywords
1102 .as_ref()
1103 .map_or_else(String::new, |keywords| keywords.join("|"));
1104 let key = format!(
1105 "{}::{}::{pats}::{enabler}::{import_provenance}::{}::{arg_kinds}::{literal_values}::{literal_contains}::{literal_integers}::{object_properties}::{object_missing_or_false}::{object_missing}::{context_keywords}",
1106 m.id, m.sink_shape, m.requires_source
1107 );
1108 assert!(seen.insert(key.clone()), "duplicate matcher row: {key}");
1109 }
1110 }
1111
1112 #[test]
1113 fn catalogue_ids_non_empty() {
1114 for m in catalogue().matchers() {
1115 assert!(
1116 !m.id.trim().is_empty(),
1117 "matcher id must be non-empty / non-whitespace"
1118 );
1119 }
1120 }
1121
1122 #[test]
1123 fn catalogue_cwe_valid() {
1124 for m in catalogue().matchers() {
1125 assert!(m.cwe > 0, "matcher {:?} has cwe 0", m.id);
1126 }
1127 }
1128
1129 #[test]
1130 fn catalogue_sink_shapes_known() {
1131 let raw: RawCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
1134 for m in &raw.matcher {
1135 assert!(
1136 parse_sink_shape(&m.sink_shape).is_some(),
1137 "matcher {:?} has unknown sink_shape {:?}",
1138 m.id,
1139 m.sink_shape
1140 );
1141 }
1142 }
1143
1144 #[test]
1145 fn catalogue_callee_patterns_non_empty() {
1146 for m in catalogue().matchers() {
1147 assert!(
1148 !m.callee_patterns.is_empty(),
1149 "matcher {:?} has no callee_patterns",
1150 m.id
1151 );
1152 for p in &m.callee_patterns {
1153 assert!(
1154 !p.raw().trim().is_empty(),
1155 "matcher {:?} has an empty callee_pattern",
1156 m.id
1157 );
1158 }
1159 }
1160 }
1161
1162 #[test]
1163 fn catalogue_evidence_templates_non_empty() {
1164 for m in catalogue().matchers() {
1165 assert!(
1166 !m.evidence_template.trim().is_empty(),
1167 "matcher {:?} has an empty evidence_template",
1168 m.id
1169 );
1170 }
1171 }
1172
1173 #[test]
1174 fn parse_rejects_empty_id() {
1175 let toml = r#"
1176[[matcher]]
1177id = ""
1178cwe = 79
1179title = "x"
1180effect = "unknown"
1181sink_shape = "member-assign"
1182callee_patterns = ["*.innerHTML"]
1183arg_index = 0
1184evidence_template = "x"
1185"#;
1186 let err = parse_catalogue(toml).unwrap_err();
1187 assert!(err.contains("id must be non-empty"), "got: {err}");
1188 }
1189
1190 #[test]
1191 fn parse_rejects_zero_cwe() {
1192 let toml = r#"
1193[[matcher]]
1194id = "x"
1195cwe = 0
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("cwe"), "got: {err}");
1205 }
1206
1207 #[test]
1208 fn parse_rejects_missing_effect() {
1209 let toml = r#"
1210[[matcher]]
1211id = "x"
1212cwe = 79
1213title = "x"
1214sink_shape = "member-assign"
1215callee_patterns = ["*.innerHTML"]
1216arg_index = 0
1217evidence_template = "x"
1218"#;
1219 let err = parse_catalogue(toml).unwrap_err();
1220 assert!(err.contains("missing field `effect`"), "got: {err}");
1221 }
1222
1223 #[test]
1224 fn parse_rejects_unknown_sink_shape() {
1225 let toml = r#"
1226[[matcher]]
1227id = "x"
1228cwe = 79
1229title = "x"
1230effect = "unknown"
1231sink_shape = "not-a-shape"
1232callee_patterns = ["*.innerHTML"]
1233arg_index = 0
1234evidence_template = "x"
1235"#;
1236 let err = parse_catalogue(toml).unwrap_err();
1237 assert!(err.contains("unknown sink_shape"), "got: {err}");
1238 }
1239
1240 #[test]
1241 fn parse_rejects_empty_callee_patterns() {
1242 let toml = r#"
1243[[matcher]]
1244id = "x"
1245cwe = 79
1246title = "x"
1247effect = "unknown"
1248sink_shape = "member-assign"
1249callee_patterns = []
1250arg_index = 0
1251evidence_template = "x"
1252"#;
1253 let err = parse_catalogue(toml).unwrap_err();
1254 assert!(err.contains("callee_patterns"), "got: {err}");
1255 }
1256
1257 #[test]
1258 fn parse_rejects_empty_pattern_string() {
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("empty"), "got: {err}");
1272 }
1273
1274 #[test]
1275 fn parse_rejects_empty_evidence_template() {
1276 let toml = r#"
1277[[matcher]]
1278id = "x"
1279cwe = 79
1280title = "x"
1281effect = "unknown"
1282sink_shape = "member-assign"
1283callee_patterns = ["*.innerHTML"]
1284arg_index = 0
1285evidence_template = " "
1286"#;
1287 let err = parse_catalogue(toml).unwrap_err();
1288 assert!(err.contains("evidence_template"), "got: {err}");
1289 }
1290
1291 #[test]
1292 fn parse_rejects_no_matchers() {
1293 let err = parse_catalogue("").unwrap_err();
1294 assert!(err.contains("no [[matcher]]"), "got: {err}");
1295 }
1296
1297 #[test]
1298 fn segment_match_is_not_substring() {
1299 let bare = parse_callee_pattern("fetch").unwrap();
1300 assert!(bare.matches("fetch"));
1301 assert!(!bare.matches("myfetch"));
1302 assert!(!bare.matches("fetcher"));
1303
1304 let wildcard = parse_callee_pattern("*.innerHTML").unwrap();
1305 assert!(wildcard.matches("el.innerHTML"));
1306 assert!(wildcard.matches("this.node.innerHTML"));
1307 assert!(!wildcard.matches("el.innerHTMLFoo"));
1308 assert!(!wildcard.matches("innerHTML")); let dotted = parse_callee_pattern("child_process.exec").unwrap();
1311 assert!(dotted.matches("child_process.exec"));
1312 assert!(!dotted.matches("exec"));
1313 assert!(!dotted.matches("child_process.execSync"));
1314 assert!(!dotted.matches("my_child_process.exec"));
1315 }
1316
1317 #[test]
1318 fn wildcard_only_pattern_matches_nothing() {
1319 let star = parse_callee_pattern("*").unwrap();
1321 assert!(!star.matches("el.innerHTML"));
1322 assert!(!star.matches("anything"));
1323 }
1324
1325 #[test]
1326 fn trailing_wildcard_prefix_matches() {
1327 let trailing = parse_callee_pattern("child_process.*").unwrap();
1328 assert!(trailing.matches("child_process.exec"));
1329 assert!(trailing.matches("child_process.exec.call"));
1330 assert!(!trailing.matches("child_process")); assert!(!trailing.matches("my_child_process.exec"));
1332 assert!(!trailing.matches("exec"));
1333
1334 let console = parse_callee_pattern("console.*").unwrap();
1335 assert!(console.matches("console.log"));
1336 assert!(!console.matches("myconsole.log"));
1337 }
1338
1339 #[test]
1340 fn double_wildcard_pattern_matches_nothing() {
1341 let both = parse_callee_pattern("*.query.*").unwrap();
1344 assert!(!both.matches("db.query.run"));
1345 let stars = parse_callee_pattern("*.*").unwrap();
1346 assert!(!stars.matches("a.b"));
1347 }
1348
1349 #[test]
1350 fn arg_kinds_unset_admits_any_shape() {
1351 let html = catalogue()
1353 .matchers()
1354 .iter()
1355 .find(|m| m.id == "dangerous-html")
1356 .expect("dangerous-html present");
1357 for kind in [
1358 SinkArgKind::TemplateWithSubst,
1359 SinkArgKind::Concat,
1360 SinkArgKind::Object,
1361 SinkArgKind::Call,
1362 SinkArgKind::Literal,
1363 SinkArgKind::NoArg,
1364 SinkArgKind::Other,
1365 ] {
1366 assert!(html.admits_arg_kind(kind), "html admits {kind:?}");
1367 }
1368 }
1369
1370 #[test]
1371 fn sql_injection_query_execute_excludes_object_arg_kind() {
1372 let query_matchers: Vec<&Matcher> = catalogue()
1377 .matchers()
1378 .iter()
1379 .filter(|m| {
1380 m.id == "sql-injection"
1381 && m.callee_patterns
1382 .iter()
1383 .any(|p| p.raw() == "*.query" || p.raw() == "*.execute")
1384 })
1385 .collect();
1386 assert!(
1387 !query_matchers.is_empty(),
1388 "sql-injection .query/.execute rows present"
1389 );
1390 for m in query_matchers {
1391 let kinds = m
1392 .arg_kinds
1393 .as_ref()
1394 .unwrap_or_else(|| panic!("sql-injection query/execute must constrain arg_kinds"));
1395 assert!(
1396 !kinds.contains(&SinkArgKind::Object),
1397 "sql-injection .query/.execute must not admit the object (parameterized) form"
1398 );
1399 assert!(
1400 !m.admits_arg_kind(SinkArgKind::Object),
1401 "admits_arg_kind agrees: object excluded"
1402 );
1403 assert!(
1404 m.admits_arg_kind(SinkArgKind::Concat),
1405 "sql-injection .query/.execute admits the concat (unsafe) form"
1406 );
1407 }
1408 }
1409
1410 #[test]
1411 fn source_required_matchers_are_explicit() {
1412 let mass_assignment = catalogue()
1413 .matchers()
1414 .iter()
1415 .find(|m| m.id == "mass-assignment")
1416 .expect("mass-assignment row present");
1417 assert!(
1418 mass_assignment.requires_source,
1419 "mass-assignment should only fire for source-backed arguments"
1420 );
1421 }
1422
1423 #[test]
1424 fn literal_integer_predicate_matches_integer_literals() {
1425 let chmod = catalogue()
1426 .matchers()
1427 .iter()
1428 .find(|m| m.id == "world-writable-permission" && m.sink_shape == SinkShape::MemberCall)
1429 .expect("world-writable permission row present");
1430
1431 assert!(chmod.literal_value_satisfied(Some(&SinkLiteralValue::Integer(511))));
1432 assert!(!chmod.literal_value_satisfied(Some(&SinkLiteralValue::Integer(420))));
1433 assert!(
1434 !chmod.literal_value_satisfied(Some(&SinkLiteralValue::String("0o777".to_string())))
1435 );
1436 }
1437
1438 #[test]
1439 fn object_property_predicate_matches_nested_integer_values() {
1440 let toml = r#"
1441[[matcher]]
1442id = "x"
1443cwe = 732
1444title = "x"
1445effect = "unknown"
1446sink_shape = "member-call"
1447callee_patterns = ["fs.chmod"]
1448arg_index = 0
1449arg_kinds = ["object"]
1450object_properties = [{ key = "mode.value", integer = 511 }]
1451evidence_template = "x"
1452"#;
1453 let cat = parse_catalogue(toml).expect("catalogue parses");
1454 let matcher = cat.matchers().first().expect("matcher present");
1455 let properties = vec![SinkObjectProperty {
1456 key: "mode.value".to_string(),
1457 value: SinkLiteralValue::Integer(511),
1458 }];
1459
1460 assert!(matcher.object_properties_satisfied(&properties));
1461 }
1462
1463 #[test]
1464 fn object_missing_requires_complete_key_metadata() {
1465 let jwt_verify = catalogue()
1466 .matchers()
1467 .iter()
1468 .find(|m| m.id == "jwt-verify-missing-algorithms")
1469 .expect("jwt verify missing algorithms row present");
1470
1471 assert!(
1472 jwt_verify.is_literal_aware(),
1473 "object_missing rows opt into literal-aware matching"
1474 );
1475 assert!(jwt_verify.object_missing_satisfied(&[], true));
1476 assert!(jwt_verify.object_missing_satisfied(&["audience".to_string()], true));
1477 assert!(!jwt_verify.object_missing_satisfied(&["algorithms".to_string()], true));
1478 assert!(!jwt_verify.object_missing_satisfied(&["audience".to_string()], false));
1479 }
1480
1481 #[test]
1482 fn parse_rejects_unknown_arg_kind() {
1483 let toml = r#"
1484[[matcher]]
1485id = "x"
1486cwe = 89
1487title = "x"
1488effect = "unknown"
1489sink_shape = "member-call"
1490callee_patterns = ["*.query"]
1491arg_index = 0
1492arg_kinds = ["not-a-kind"]
1493evidence_template = "x"
1494"#;
1495 let err = parse_catalogue(toml).unwrap_err();
1496 assert!(err.contains("unknown arg_kind"), "got: {err}");
1497 }
1498
1499 #[test]
1500 fn enabler_unset_is_global() {
1501 let html = catalogue()
1503 .matchers()
1504 .iter()
1505 .find(|m| m.id == "dangerous-html")
1506 .expect("dangerous-html present");
1507 assert!(html.enabler.is_none(), "dangerous-html is a global row");
1508 assert!(html.enabler_satisfied(&FxHashSet::default()));
1509 }
1510
1511 #[test]
1512 fn enabler_satisfied_exact_and_prefix() {
1513 let mut m = catalogue()
1514 .matchers()
1515 .iter()
1516 .find(|m| m.id == "dangerous-html")
1517 .cloned()
1518 .expect("dangerous-html present");
1519
1520 m.enabler = Some("jquery".to_string());
1522 let mut deps = FxHashSet::default();
1523 assert!(!m.enabler_satisfied(&deps), "absent dep is not satisfied");
1524 deps.insert("jquery".to_string());
1525 assert!(m.enabler_satisfied(&deps), "present exact dep satisfies");
1526
1527 m.enabler = Some("@angular/".to_string());
1529 let mut scoped = FxHashSet::default();
1530 assert!(!m.enabler_satisfied(&scoped));
1531 scoped.insert("@angular/platform-browser".to_string());
1532 assert!(m.enabler_satisfied(&scoped), "prefix dep satisfies");
1533 let mut bare_scope = FxHashSet::default();
1534 bare_scope.insert("@angular".to_string());
1535 assert!(
1536 m.enabler_satisfied(&bare_scope),
1537 "bare scope name satisfies the prefix form"
1538 );
1539
1540 m.enabler = Some("react".to_string());
1542 let mut reactish = FxHashSet::default();
1543 reactish.insert("react-dom".to_string());
1544 assert!(
1545 !m.enabler_satisfied(&reactish),
1546 "exact enabler must not prefix-match"
1547 );
1548 }
1549
1550 #[test]
1551 fn framework_scoped_rows_are_present() {
1552 let cat = catalogue();
1554 let angular = cat
1555 .matchers()
1556 .iter()
1557 .find(|m| m.id == "angular-trusted-html")
1558 .expect("angular-trusted-html present");
1559 assert_eq!(
1560 angular.enabler.as_deref(),
1561 Some("@angular/platform-browser")
1562 );
1563 assert!(
1564 cat.matchers().iter().any(|m| m.id == "jquery-html"),
1565 "jquery-html present"
1566 );
1567 assert!(
1568 cat.matchers().iter().any(|m| m.id == "dom-document-write"),
1569 "dom-document-write present"
1570 );
1571 }
1572
1573 #[test]
1574 fn parse_rejects_empty_enabler() {
1575 let toml = r#"
1576[[matcher]]
1577id = "x"
1578cwe = 79
1579title = "x"
1580effect = "unknown"
1581sink_shape = "member-call"
1582callee_patterns = ["*.html"]
1583arg_index = 0
1584enabler = " "
1585evidence_template = "x"
1586"#;
1587 let err = parse_catalogue(toml).unwrap_err();
1588 assert!(err.contains("empty / whitespace enabler"), "got: {err}");
1589 }
1590
1591 #[test]
1592 fn catalogue_has_untrusted_sources() {
1593 let cat = catalogue();
1596 assert!(
1597 !cat.sources.is_empty(),
1598 "catalogue must ship untrusted-source rows"
1599 );
1600 for s in &cat.sources {
1601 assert!(!s.id.trim().is_empty(), "source id non-empty");
1602 assert!(!s.title.trim().is_empty(), "source title non-empty");
1603 assert!(!s.path_patterns.is_empty(), "source has path patterns");
1604 }
1605 }
1606
1607 #[test]
1608 fn source_paths_match_expected_request_inputs() {
1609 let cat = catalogue();
1610 assert!(is_source(cat, "req.query"));
1612 assert!(is_source(cat, "ctx.req.query"));
1613 assert!(is_source(cat, "request.body"));
1614 assert!(is_source(cat, "req.params"));
1615 assert!(is_source(cat, "process.argv"));
1616 assert!(is_source(cat, "event.data"));
1617 assert!(is_source(cat, "request.rawBody"));
1618 assert!(is_source(cat, "document.referrer"));
1619 assert!(is_source(cat, "window.name"));
1620 assert!(is_source(cat, "document.cookie"));
1621 assert!(!is_source(cat, "config.value"));
1623 assert!(!is_source(cat, "user.name"));
1624 assert!(!is_source(cat, "profile.name"));
1625 assert!(!is_source(cat, "jar.cookie"));
1626 }
1627
1628 #[test]
1629 fn source_matcher_matches_helper() {
1630 let cat = catalogue();
1631 let http = cat
1632 .sources
1633 .iter()
1634 .find(|s| s.id == "http-request-input")
1635 .expect("http-request-input source present");
1636 assert!(http.matches_with_extra_receivers("req.query", &FxHashSet::default()));
1637 assert!(!http.matches_with_extra_receivers("process.argv", &FxHashSet::default()));
1638 }
1639
1640 #[test]
1641 fn matched_receiver_returns_segment_before_suffix() {
1642 let pat = parse_callee_pattern("*.query").expect("pattern parses");
1645 assert_eq!(pat.matched_receiver("db.query"), Some("db"));
1646 assert_eq!(pat.matched_receiver("req.query"), Some("req"));
1647 assert_eq!(pat.matched_receiver("ctx.req.query"), Some("req"));
1649 assert_eq!(pat.matched_receiver("req.body"), None);
1651 let exact = parse_callee_pattern("process.env").expect("pattern parses");
1654 assert_eq!(exact.matched_receiver("process.env"), None);
1655 }
1656
1657 #[test]
1658 fn receiver_allowlist_rejects_orm_query_builders_keeps_request_objects() {
1659 let cat = catalogue();
1662 assert!(!is_source(cat, "db.query"), "Drizzle db.query");
1663 assert!(!is_source(cat, "prisma.query"), "Prisma prisma.query");
1664 assert!(!is_source(cat, "drizzle.query"));
1665 assert!(!is_source(cat, "knex.body"));
1666 assert!(!is_source(cat, "client.query"));
1667 assert!(!is_source(cat, "dbConn.query"));
1670 assert!(!is_source(cat, "database.params"));
1671 assert!(is_source(cat, "req.query"), "Express req.query");
1673 assert!(is_source(cat, "request.body"));
1674 assert!(is_source(cat, "ctx.params"), "Koa/Elysia ctx.params");
1675 assert!(is_source(cat, "context.body"));
1676 assert!(is_source(cat, "event.query"), "SvelteKit event.query");
1677 assert!(is_source(cat, "ctx.req.query"));
1679 assert!(is_source(cat, "Req.query"));
1681 }
1682
1683 #[test]
1684 fn configured_request_receivers_extend_http_request_source_allowlist() {
1685 let cat = catalogue();
1686 let deps = FxHashSet::default();
1687 let receivers = FxHashSet::from_iter(["h".to_string(), "httpreq".to_string()]);
1688
1689 assert!(
1690 cat.matching_source_for_deps_with_receivers("h.query", &deps, &receivers)
1691 .is_some()
1692 );
1693 assert!(
1694 cat.matching_source_for_deps_with_receivers("HttpReq.body", &deps, &receivers)
1695 .is_some()
1696 );
1697 assert!(
1698 cat.matching_source_for_deps_with_receivers("req.params", &deps, &receivers)
1699 .is_some()
1700 );
1701 assert!(
1702 cat.matching_source_for_deps_with_receivers("db.query", &deps, &receivers)
1703 .is_none()
1704 );
1705 }
1706
1707 #[test]
1708 fn search_params_source_stays_ungated() {
1709 let cat = catalogue();
1712 assert!(is_source(cat, "u.searchParams"));
1713 assert!(is_source(cat, "url.searchParams"));
1714 assert!(is_source(cat, "params.searchParams"));
1715 }
1716
1717 #[test]
1718 fn parse_rejects_empty_receiver_allowlist_entry() {
1719 let toml = r#"
1720[[matcher]]
1721id = "x"
1722cwe = 79
1723title = "x"
1724effect = "unknown"
1725sink_shape = "member-assign"
1726callee_patterns = ["*.innerHTML"]
1727arg_index = 0
1728evidence_template = "x"
1729
1730[[source]]
1731id = "http"
1732title = "HTTP"
1733path_patterns = ["*.query"]
1734receiver_allowlist = ["req", " "]
1735"#;
1736 let err = parse_catalogue(toml).unwrap_err();
1737 assert!(err.contains("receiver_allowlist"), "got: {err}");
1738 }
1739
1740 #[test]
1741 fn source_enabler_gates_framework_param_sources() {
1742 let cat = catalogue();
1743 let source = cat
1744 .sources
1745 .iter()
1746 .find(|s| s.id == "framework-handler-input" && s.enabler.as_deref() == Some("express"))
1747 .expect("express handler source present");
1748 assert!(source.matches_with_extra_receivers("framework.request", &FxHashSet::default()));
1749
1750 let empty = FxHashSet::default();
1751 assert!(!source.enabler_satisfied(&empty));
1752 assert!(
1753 source_for(cat, "framework.request", &empty).is_none(),
1754 "framework handler params require an enabler"
1755 );
1756
1757 let mut deps = FxHashSet::default();
1758 deps.insert("express".to_string());
1759 assert!(source.enabler_satisfied(&deps));
1760 assert_eq!(
1761 source_for(cat, "framework.request", &deps),
1762 Some(("framework-handler-input", "Framework handler input"))
1763 );
1764 }
1765
1766 #[test]
1767 fn source_enabler_gates_graphql_and_trpc_param_sources() {
1768 let cat = catalogue();
1769 let empty = FxHashSet::default();
1770 assert!(
1771 source_for(cat, "graphql.args", &empty).is_none(),
1772 "GraphQL resolver args require a matching package"
1773 );
1774 assert!(
1775 source_for(cat, "trpc.input", &empty).is_none(),
1776 "tRPC procedure input requires a matching package"
1777 );
1778
1779 let mut graphql_deps = FxHashSet::default();
1780 graphql_deps.insert("@apollo/server".to_string());
1781 assert_eq!(
1782 source_for(cat, "graphql.args", &graphql_deps),
1783 Some(("graphql-resolver-args", "GraphQL resolver args"))
1784 );
1785
1786 let mut trpc_deps = FxHashSet::default();
1787 trpc_deps.insert("@trpc/server".to_string());
1788 assert_eq!(
1789 source_for(cat, "trpc.input", &trpc_deps),
1790 Some(("trpc-procedure-input", "tRPC procedure input"))
1791 );
1792 }
1793
1794 #[test]
1795 fn parse_rejects_source_without_patterns() {
1796 let toml = r#"
1797[[matcher]]
1798id = "x"
1799cwe = 79
1800title = "x"
1801effect = "unknown"
1802sink_shape = "member-assign"
1803callee_patterns = ["*.innerHTML"]
1804arg_index = 0
1805evidence_template = "x"
1806
1807[[source]]
1808id = "bad"
1809title = "bad"
1810path_patterns = []
1811"#;
1812 let err = parse_catalogue(toml).unwrap_err();
1813 assert!(err.contains("path_patterns"), "got: {err}");
1814 }
1815
1816 #[test]
1817 fn parse_rejects_empty_arg_kinds() {
1818 let toml = r#"
1819[[matcher]]
1820id = "x"
1821cwe = 89
1822title = "x"
1823effect = "unknown"
1824sink_shape = "member-call"
1825callee_patterns = ["*.query"]
1826arg_index = 0
1827arg_kinds = []
1828evidence_template = "x"
1829"#;
1830 let err = parse_catalogue(toml).unwrap_err();
1831 assert!(err.contains("empty arg_kinds"), "got: {err}");
1832 }
1833}