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