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