Skip to main content

fallow_security/
lib.rs

1//! Data-driven catalogue of syntactic security-sink candidate matchers.
2//!
3//! The catalogue is community-maintainable: every matcher lives in
4//! `crates/security/data/security_matchers.toml`, embedded via `include_str!` and
5//! parsed once behind a `OnceLock`. There is NO regeneration step. Adding a
6//! category is a single `[[matcher]]` TOML edit plus ZERO Rust enum or
7//! discriminant churn (the `tainted_sink` detector matches captured
8//! category-blind `SinkSite`s against the loaded catalogue).
9//!
10//! Findings are CANDIDATES for downstream agent verification, NOT verified
11//! vulnerabilities: fallow is deterministic and syntactic, never taint-proof.
12//! Matchers default to non-literal arguments. A row can opt into narrowly
13//! captured literal or context predicates when the literal itself is the signal.
14
15use fallow_config::EffectKind;
16use fallow_types::extract::{SinkArgKind, SinkLiteralValue, SinkObjectProperty, SinkShape};
17use rustc_hash::FxHashSet;
18
19mod identity;
20mod provenance;
21mod rules;
22mod severity;
23
24pub use identity::{security_finding_id, security_rule_id, stamp_security_finding_ids};
25pub use provenance::{ProvenanceBindings, import_source_matches};
26pub use rules::{
27    enable_security_rules, resolve_security_finding_severity, retain_enabled_security_findings,
28    security_rules_can_error,
29};
30pub use severity::{derive_security_severity, security_catalogue_title};
31
32pub const HARDCODED_SECRET_CATEGORY_ID: &str = "hardcoded-secret";
33pub const HARDCODED_SECRET_CATEGORY_TITLE: &str = "Hardcoded secret candidate";
34
35/// Embedded catalogue source. Because it is `include_str!`-embedded at compile
36/// time, a green `security_catalogue_parses` test guarantees the released
37/// binary parses.
38const CATALOGUE_TOML: &str = include_str!("../data/security_matchers.toml");
39
40#[derive(serde::Deserialize)]
41struct RawCatalogue {
42    #[serde(default)]
43    matcher: Vec<RawMatcher>,
44    #[serde(default)]
45    source: Vec<RawSource>,
46}
47
48/// A raw untrusted-source row (issue #859). Names member-access paths that carry
49/// attacker-controlled input; the analyze layer matches captured tainted-binding
50/// source paths against these to mark source-tainted locals.
51#[derive(serde::Deserialize)]
52struct RawSource {
53    id: String,
54    title: String,
55    /// Optional framework enabler, same semantics as matcher enablers.
56    #[serde(default)]
57    enabler: Option<String>,
58    path_patterns: Vec<String>,
59    /// Optional allowlist of receiver names for leading-`*.` wildcard patterns
60    /// (issue #1092). When non-empty, a wildcard pattern fires only if the
61    /// matched member's receiver is one of these (case-insensitive), so
62    /// `*.query` matches `req.query` but not `db.query`. Empty / absent leaves
63    /// the row ungated (every receiver matches). Has no effect on exact
64    /// patterns, whose receiver is fixed in the pattern itself.
65    #[serde(default)]
66    receiver_allowlist: Vec<String>,
67}
68
69#[derive(serde::Deserialize)]
70struct RawMatcher {
71    id: String,
72    cwe: u32,
73    title: String,
74    effect: EffectKind,
75    /// Kebab-case shape string, validated into [`SinkShape`].
76    sink_shape: String,
77    callee_patterns: Vec<String>,
78    arg_index: u32,
79    /// Optional variadic gate: when `true`, the row also matches every
80    /// argument after `arg_index`. Lets `path.join` / `path.resolve` inspect
81    /// each path segment, not only the first one (issue #3712).
82    #[serde(default)]
83    variadic: bool,
84    evidence_template: String,
85    #[serde(default)]
86    import_provenance: Option<String>,
87    /// Optional framework enabler: a package name that gates this row on the
88    /// active framework (issue #861). The plugin system already activates on the
89    /// declared dependency set, so a row carrying `enabler = "@angular/platform-browser"`
90    /// fires only when that package (or, with a trailing `/`, any package under
91    /// that prefix) is present in the project's declared dependencies. Lets a
92    /// framework-specific idiom (`bypassSecurityTrustHtml`, `dangerouslySetInnerHTML`)
93    /// be recognized with higher precision without a new enum variant. Unset means
94    /// the row is global (the prior behavior).
95    #[serde(default)]
96    enabler: Option<String>,
97    /// Optional allowlist of argument shapes. When set, the captured sink site's
98    /// `arg_kind` must be one of the listed kebab-case kinds for the matcher to
99    /// fire. Lets a matcher require the unsafe SQL shapes (`concat`,
100    /// `template-with-subst`) and exclude the safely-parameterized forms
101    /// (`object` for `.execute({ sql, args })`, the bare `sql` tag). Unset means
102    /// any non-literal argument shape matches (the prior behavior).
103    #[serde(default)]
104    arg_kinds: Option<Vec<String>>,
105    /// Optional string-literal equality predicates for literal-aware rows.
106    #[serde(default)]
107    literal_values: Option<Vec<String>>,
108    /// Optional string-literal substring predicates for literal-aware rows.
109    #[serde(default)]
110    literal_contains: Option<Vec<String>>,
111    /// Optional integer-literal equality predicates for literal-aware rows.
112    #[serde(default)]
113    literal_integers: Option<Vec<i64>>,
114    /// Optional object-literal property equality predicates.
115    #[serde(default)]
116    object_properties: Option<Vec<RawObjectPropertyPredicate>>,
117    /// Optional object-literal flags that are unsafe when missing or `false`.
118    #[serde(default)]
119    object_missing_or_false: Option<Vec<String>>,
120    /// Optional object-literal keys that are unsafe when absent. Unlike
121    /// `object_missing_or_false`, this checks key presence only and refuses
122    /// incomplete object shapes.
123    #[serde(default)]
124    object_missing: Option<Vec<String>>,
125    /// Optional context-name keywords for zero-arg sinks like `Math.random()`.
126    #[serde(default)]
127    context_keywords: Option<Vec<String>>,
128    /// Optional precision gate: require the captured sink argument to reference
129    /// a local binding that came from a configured untrusted source.
130    #[serde(default)]
131    requires_source: bool,
132    /// Optional precision gate narrowing `requires_source` to SPECIFIC source
133    /// kinds by catalogue source id (issue #890). Empty (default) admits any
134    /// matched source (the prior behavior); when set, the matched source's id
135    /// must be one of these. Lets `secret-to-network` fire only when backed by a
136    /// SECRET source (`process-env` / `import-meta-env`), not request input
137    /// (which the `ssrf` rows already cover).
138    #[serde(default)]
139    requires_source_kinds: Vec<String>,
140}
141
142#[derive(Debug, serde::Deserialize)]
143struct RawObjectPropertyPredicate {
144    key: String,
145    #[serde(default)]
146    string: Option<String>,
147    #[serde(default)]
148    boolean: Option<bool>,
149    #[serde(default)]
150    integer: Option<i64>,
151    #[serde(default)]
152    null: bool,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum LiteralPredicate {
157    String(String),
158    Integer(i64),
159    Boolean(bool),
160    Null,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ObjectPropertyPredicate {
165    key: String,
166    value: LiteralPredicate,
167}
168
169/// A pre-segmented callee pattern. Matching is segment-aware (NOT substring):
170/// the pattern is split on `.`, a leading `*` segment means "any object"
171/// (`*.innerHTML` matches `el.innerHTML` and `this.node.innerHTML` by
172/// suffix-matching the trailing non-`*` segments), and a trailing `*` segment
173/// means "any member" (`child_process.*` matches `child_process.exec` by
174/// prefix-matching the leading non-`*` segments). The security catalogue uses
175/// exact and leading-wildcard rows; the trailing form serves the boundary
176/// forbidden-call detector.
177#[derive(Debug, Clone)]
178pub struct CalleePattern {
179    /// The literal source pattern (`"*.innerHTML"`, `"child_process.exec"`),
180    /// surfaced in evidence rendering as `{pattern}`.
181    raw: String,
182    /// Segments between any leading and trailing `*` (e.g. `["innerHTML"]`
183    /// for `*.innerHTML`, `["child_process"]` for `child_process.*`,
184    /// `["child_process", "exec"]` for the exact dotted form).
185    suffix_segments: Vec<String>,
186    /// Whether the pattern began with a `*` wildcard object segment.
187    leading_wildcard: bool,
188    /// Whether the pattern ended with a `*` wildcard member segment.
189    trailing_wildcard: bool,
190}
191
192impl CalleePattern {
193    /// Parse a raw pattern string into its segmented form. Returns `None` for
194    /// an empty or whitespace-only pattern. Public constructor for non-security
195    /// reusers of the segment-aware matcher (the boundary forbidden-call
196    /// detector); the catalogue's own rows go through the same parser.
197    #[must_use]
198    pub fn parse(raw: &str) -> Option<Self> {
199        parse_callee_pattern(raw)
200    }
201
202    /// The original pattern text, for evidence templating.
203    #[must_use]
204    pub fn raw(&self) -> &str {
205        &self.raw
206    }
207
208    /// Segment-aware match against a captured dotted/bare callee path.
209    ///
210    /// With a leading `*`, the trailing segments must equal the tail of the
211    /// candidate's segments (suffix match), so `*.innerHTML` matches
212    /// `el.innerHTML` but not `el.innerHTMLFoo`. With a trailing `*`, the
213    /// leading segments must equal the head of the candidate's segments
214    /// (prefix match), so `child_process.*` matches `child_process.exec` but
215    /// not the bare `child_process`. Without either, the whole segment list
216    /// must match exactly, so `fetch` matches `fetch` but not `myfetch`.
217    /// Patterns carrying BOTH wildcards match nothing (rejected by the config
218    /// layer; never produced by catalogue rows).
219    #[must_use]
220    pub fn matches(&self, callee_path: &str) -> bool {
221        // With only wildcards and no concrete segments, match nothing.
222        if self.suffix_segments.is_empty() || (self.leading_wildcard && self.trailing_wildcard) {
223            return false;
224        }
225        let candidate: Vec<&str> = callee_path.split('.').collect();
226        if self.leading_wildcard {
227            // A leading `*.` requires at least one object segment before the
228            // suffix, so the candidate must have strictly more segments than
229            // the suffix (`*.innerHTML` matches `el.innerHTML`, not `innerHTML`).
230            if self.suffix_segments.len() >= candidate.len() {
231                return false;
232            }
233            let tail = &candidate[candidate.len() - self.suffix_segments.len()..];
234            self.suffix_segments
235                .iter()
236                .zip(tail)
237                .all(|(pat, seg)| pat == seg)
238        } else if self.trailing_wildcard {
239            // A trailing `.*` requires at least one member segment after the
240            // prefix (`child_process.*` matches `child_process.exec`, not the
241            // bare `child_process`).
242            if self.suffix_segments.len() >= candidate.len() {
243                return false;
244            }
245            let head = &candidate[..self.suffix_segments.len()];
246            self.suffix_segments
247                .iter()
248                .zip(head)
249                .all(|(pat, seg)| pat == seg)
250        } else {
251            self.suffix_segments.len() == candidate.len()
252                && self
253                    .suffix_segments
254                    .iter()
255                    .zip(&candidate)
256                    .all(|(pat, seg)| pat == seg)
257        }
258    }
259
260    /// The receiver segment immediately before this pattern's matched suffix,
261    /// for a leading-`*.` wildcard pattern: `*.query` against `db.query` returns
262    /// `Some("db")`, against `ctx.req.query` returns `Some("req")` (the segment
263    /// right before `query`, which is the receiver of the matched member). Used
264    /// by a source row's receiver allowlist to keep HTTP-input patterns from
265    /// firing on ORM / data-access receivers (issue #1092). Returns `None` for
266    /// an exact (non-wildcard) pattern, whose receiver is fixed in the pattern
267    /// itself, and for any `callee_path` this pattern does not match.
268    #[must_use]
269    fn matched_receiver<'p>(&self, callee_path: &'p str) -> Option<&'p str> {
270        if !self.leading_wildcard || !self.matches(callee_path) {
271            return None;
272        }
273        let candidate: Vec<&str> = callee_path.split('.').collect();
274        // `matches` guarantees `candidate.len() > suffix_segments.len()` for a
275        // leading-wildcard hit, so the receiver index is always in range.
276        let recv_idx = candidate.len() - self.suffix_segments.len() - 1;
277        candidate.get(recv_idx).copied()
278    }
279}
280
281/// Parse a raw pattern string into its segmented form. Returns `None` for an
282/// empty or whitespace-only pattern (rejected at parse time).
283fn parse_callee_pattern(raw: &str) -> Option<CalleePattern> {
284    if raw.trim().is_empty() {
285        return None;
286    }
287    let mut segments: Vec<&str> = raw.split('.').collect();
288    let leading_wildcard = segments.first() == Some(&"*");
289    if leading_wildcard {
290        segments.remove(0);
291    }
292    let trailing_wildcard = segments.last() == Some(&"*");
293    if trailing_wildcard {
294        segments.pop();
295    }
296    Some(CalleePattern {
297        raw: raw.to_string(),
298        suffix_segments: segments.into_iter().map(str::to_string).collect(),
299        leading_wildcard,
300        trailing_wildcard,
301    })
302}
303
304/// A parsed, validated matcher with the sink shape resolved to the typed enum
305/// and callee patterns pre-segmented for O(1)-ish matching.
306#[derive(Debug, Clone)]
307pub struct Matcher {
308    pub id: String,
309    pub cwe: u32,
310    pub title: String,
311    pub effect: EffectKind,
312    pub sink_shape: SinkShape,
313    pub callee_patterns: Vec<CalleePattern>,
314    pub arg_index: u32,
315    /// Whether the row also matches every argument after `arg_index`. A
316    /// variadic row reports at most one candidate per call.
317    pub variadic: bool,
318    pub evidence_template: String,
319    pub import_provenance: Option<String>,
320    /// Framework enabler package gate (issue #861). `None` = global row.
321    /// `Some("pkg")` requires an exact dependency match; `Some("@scope/")`
322    /// (trailing slash) requires any dependency under that prefix.
323    pub enabler: Option<String>,
324    /// Resolved allowlist of admitted argument shapes. `None` admits any
325    /// non-literal shape; `Some` requires the captured `arg_kind` to be listed.
326    pub arg_kinds: Option<Vec<SinkArgKind>>,
327    /// Whether this matcher only fires when the sink argument traces to a
328    /// configured untrusted source binding.
329    pub requires_source: bool,
330    /// When non-empty, narrows `requires_source` to these catalogue source ids
331    /// (issue #890): the matched source's id must be one of these. Empty admits
332    /// any matched source.
333    pub requires_source_kinds: Vec<String>,
334    /// String-literal values admitted by this row.
335    pub literal_values: Vec<String>,
336    /// String fragments admitted by this row.
337    pub literal_contains: Vec<String>,
338    /// Integer literal values admitted by this row.
339    pub literal_integers: Vec<i64>,
340    /// Required literal object properties.
341    pub object_properties: Vec<ObjectPropertyPredicate>,
342    /// Object properties whose absence or boolean `false` makes the row match.
343    pub object_missing_or_false: Vec<String>,
344    /// Object keys whose absence makes the row match.
345    pub object_missing: Vec<String>,
346    /// Context-name keywords admitted by this row.
347    pub context_keywords: Vec<String>,
348}
349
350/// A parsed, validated untrusted-source matcher (issue #859). Its
351/// `path_patterns` reuse the segment-aware [`CalleePattern`] engine: a leading
352/// `*.` matches any object prefix (`*.query` matches `req.query` and
353/// `ctx.req.query`); a bare path matches exactly.
354#[derive(Debug, Clone)]
355pub struct SourceMatcher {
356    id: String,
357    title: String,
358    enabler: Option<String>,
359    path_patterns: Vec<CalleePattern>,
360    /// Lowercased receiver allowlist for leading-wildcard patterns (issue
361    /// #1092). Empty leaves the row ungated.
362    receiver_allowlist: Vec<String>,
363}
364
365impl SourceMatcher {
366    #[must_use]
367    fn matches_with_extra_receivers(
368        &self,
369        source_path: &str,
370        extra_receivers: &FxHashSet<String>,
371    ) -> bool {
372        self.path_patterns.iter().any(|p| {
373            p.matches(source_path) && self.receiver_allowed(p, source_path, extra_receivers)
374        })
375    }
376
377    /// Whether `pattern`'s match on `source_path` is admitted by the receiver
378    /// allowlist. An empty allowlist admits everything. For a leading-wildcard
379    /// pattern the matched receiver must be in the allowlist (case-insensitive);
380    /// an exact pattern (receiver fixed in the pattern) is always admitted.
381    fn receiver_allowed(
382        &self,
383        pattern: &CalleePattern,
384        source_path: &str,
385        extra_receivers: &FxHashSet<String>,
386    ) -> bool {
387        if self.receiver_allowlist.is_empty() {
388            return true;
389        }
390        match pattern.matched_receiver(source_path) {
391            Some(receiver) => {
392                self.receiver_allowlist
393                    .iter()
394                    .any(|allowed| allowed.eq_ignore_ascii_case(receiver))
395                    || extra_receivers.contains(&receiver.to_ascii_lowercase())
396            }
397            None => true,
398        }
399    }
400
401    /// Whether this source row's framework enabler is satisfied by the
402    /// project's declared dependency set. Unset means global.
403    #[must_use]
404    fn enabler_satisfied(&self, declared_deps: &rustc_hash::FxHashSet<String>) -> bool {
405        enabler_satisfied(self.enabler.as_deref(), declared_deps)
406    }
407}
408
409/// The parsed catalogue: an ordered list of sink matchers plus untrusted-source
410/// matchers. Order is preserved from the TOML so the detector can break on the
411/// first match deterministically.
412#[derive(Debug)]
413pub struct Catalogue {
414    matchers: Vec<Matcher>,
415    sources: Vec<SourceMatcher>,
416}
417
418impl Matcher {
419    /// The first callee pattern that matches the given path, if any. The first
420    /// match wins, matching the deterministic declaration order.
421    #[must_use]
422    pub fn first_matching_pattern(&self, callee_path: &str) -> Option<&CalleePattern> {
423        self.callee_patterns.iter().find(|p| p.matches(callee_path))
424    }
425
426    /// Whether a captured argument position is admitted by this matcher. A
427    /// variadic row admits `arg_index` and every later position.
428    #[must_use]
429    pub fn admits_arg_index(&self, arg_index: u32) -> bool {
430        arg_index == self.arg_index || (self.variadic && arg_index > self.arg_index)
431    }
432
433    /// Whether a captured argument shape is admitted by this matcher. `None`
434    /// `arg_kinds` admits any shape; `Some` requires the kind to be listed.
435    #[must_use]
436    pub fn admits_arg_kind(&self, arg_kind: SinkArgKind) -> bool {
437        self.arg_kinds
438            .as_ref()
439            .is_none_or(|kinds| kinds.contains(&arg_kind))
440    }
441
442    /// Whether this row has opted into matching a literal, object-property, or
443    /// context-only sink that is not covered by the default non-literal model.
444    #[must_use]
445    pub fn is_literal_aware(&self) -> bool {
446        !self.literal_values.is_empty()
447            || !self.literal_contains.is_empty()
448            || !self.literal_integers.is_empty()
449            || !self.object_properties.is_empty()
450            || !self.object_missing_or_false.is_empty()
451            || !self.object_missing.is_empty()
452            || !self.context_keywords.is_empty()
453            || self.arg_kinds.as_ref().is_some_and(|kinds| {
454                kinds
455                    .iter()
456                    .any(|kind| matches!(kind, SinkArgKind::Literal | SinkArgKind::NoArg))
457            })
458    }
459
460    /// Whether captured literal metadata satisfies this row's literal gates.
461    #[must_use]
462    pub fn literal_value_satisfied(&self, literal: Option<&SinkLiteralValue>) -> bool {
463        if self.literal_values.is_empty()
464            && self.literal_contains.is_empty()
465            && self.literal_integers.is_empty()
466        {
467            return true;
468        }
469        let string_satisfied = (self.literal_values.is_empty() && self.literal_contains.is_empty())
470            || match literal {
471                Some(SinkLiteralValue::String(value)) => {
472                    let lower = value.to_ascii_lowercase();
473                    (self.literal_values.is_empty()
474                        || self
475                            .literal_values
476                            .iter()
477                            .any(|expected| lower == expected.to_ascii_lowercase()))
478                        && (self.literal_contains.is_empty()
479                            || self
480                                .literal_contains
481                                .iter()
482                                .any(|needle| lower.contains(&needle.to_ascii_lowercase())))
483                }
484                _ => false,
485            };
486        let integer_satisfied = self.literal_integers.is_empty()
487            || match literal {
488                Some(SinkLiteralValue::Integer(value)) => self.literal_integers.contains(value),
489                _ => false,
490            };
491        string_satisfied && integer_satisfied
492    }
493
494    /// Whether captured object-literal metadata satisfies this row's object
495    /// property gates.
496    #[must_use]
497    pub fn object_properties_satisfied(&self, properties: &[SinkObjectProperty]) -> bool {
498        if self.object_properties.is_empty() && self.object_missing_or_false.is_empty() {
499            return true;
500        }
501        for predicate in &self.object_properties {
502            let Some(property) = properties.iter().find(|p| p.key == predicate.key) else {
503                return false;
504            };
505            if !predicate.value.matches(&property.value) {
506                return false;
507            }
508        }
509        if self.object_missing_or_false.is_empty() {
510            return true;
511        }
512        self.object_missing_or_false.iter().any(|key| {
513            properties
514                .iter()
515                .find(|p| p.key == *key)
516                .is_none_or(|property| matches!(property.value, SinkLiteralValue::Boolean(false)))
517        })
518    }
519
520    /// Whether missing-key predicates are satisfied by complete static object
521    /// key metadata.
522    #[must_use]
523    pub fn object_missing_satisfied(&self, keys: &[String], keys_complete: bool) -> bool {
524        if self.object_missing.is_empty() {
525            return true;
526        }
527        keys_complete && self.object_missing.iter().any(|key| !keys.contains(key))
528    }
529
530    /// Whether captured context names satisfy this row's context keyword gate.
531    #[must_use]
532    pub fn context_satisfied(&self, context_names: &[String]) -> bool {
533        if self.context_keywords.is_empty() {
534            return true;
535        }
536        context_names.iter().any(|name| {
537            let lower = name.to_ascii_lowercase();
538            self.context_keywords
539                .iter()
540                .any(|keyword| lower.contains(&keyword.to_ascii_lowercase()))
541        })
542    }
543
544    /// Whether this matcher's framework enabler is satisfied by the project's
545    /// declared dependency set (issue #861). `None` enabler is always satisfied
546    /// (a global row). A `Some` enabler matches by exact package name, or, when
547    /// it ends with `/`, by prefix (`@angular/` matches `@angular/platform-browser`),
548    /// mirroring the plugin-system `enablers()` semantics so framework rows
549    /// activate on exactly the dependency universe the plugins do.
550    #[must_use]
551    pub fn enabler_satisfied(&self, declared_deps: &rustc_hash::FxHashSet<String>) -> bool {
552        enabler_satisfied(self.enabler.as_deref(), declared_deps)
553    }
554}
555
556fn enabler_satisfied(enabler: Option<&str>, declared_deps: &rustc_hash::FxHashSet<String>) -> bool {
557    let Some(enabler) = enabler else {
558        return true;
559    };
560    if let Some(prefix) = enabler.strip_suffix('/') {
561        // Trailing-slash prefix match, e.g. `@fastify/` -> `@fastify/static`.
562        // Also admit the bare scope name itself (`@fastify`).
563        declared_deps
564            .iter()
565            .any(|d| d == prefix || d.starts_with(enabler))
566    } else {
567        declared_deps.contains(enabler)
568    }
569}
570
571impl LiteralPredicate {
572    fn matches(&self, value: &SinkLiteralValue) -> bool {
573        match (self, value) {
574            (Self::String(expected), SinkLiteralValue::String(actual)) => {
575                expected.eq_ignore_ascii_case(actual)
576            }
577            (Self::Integer(expected), SinkLiteralValue::Integer(actual)) => expected == actual,
578            (Self::Boolean(expected), SinkLiteralValue::Boolean(actual)) => expected == actual,
579            (Self::Null, SinkLiteralValue::Null) => true,
580            _ => false,
581        }
582    }
583}
584
585impl Catalogue {
586    /// All matchers in declaration order.
587    #[must_use]
588    pub fn matchers(&self) -> &[Matcher] {
589        &self.matchers
590    }
591
592    /// The id + human title of the first untrusted-source matcher whose pattern,
593    /// optional framework enabler, and configured request-receiver extension
594    /// match the given source path.
595    #[must_use]
596    pub fn matching_source_for_deps_with_receivers(
597        &self,
598        source_path: &str,
599        declared_deps: &FxHashSet<String>,
600        request_receivers: &FxHashSet<String>,
601    ) -> Option<(&str, &str)> {
602        let empty_receivers = FxHashSet::default();
603        self.sources
604            .iter()
605            .find(|s| {
606                let extra_receivers = if s.id == "http-request-input" {
607                    request_receivers
608                } else {
609                    &empty_receivers
610                };
611                s.enabler_satisfied(declared_deps)
612                    && s.matches_with_extra_receivers(source_path, extra_receivers)
613            })
614            .map(|s| (s.id.as_str(), s.title.as_str()))
615    }
616
617    /// The human-readable title for a category id, if any matcher declares it.
618    #[must_use]
619    fn title_for(&self, id: &str) -> Option<&str> {
620        self.matchers
621            .iter()
622            .find(|m| m.id == id)
623            .map(|m| m.title.as_str())
624    }
625}
626
627/// The human-readable title for a category id, used by the CLI renderer.
628#[must_use]
629pub fn catalogue_title(id: &str) -> Option<&'static str> {
630    catalogue().title_for(id)
631}
632
633/// The catalogue id of the secret-to-network exfil category (CWE-201). Like
634/// [`HARDCODED_SECRET_CATEGORY_ID`], it is include-required: it runs only when
635/// listed in `security.categories.include`.
636const SECRET_TO_NETWORK_CATEGORY_ID: &str = "secret-to-network";
637
638/// Whether a `security.categories` id is include-required, i.e. it stays off
639/// even when no include list is set and fires only when named in
640/// `categories.include`. Both the standalone hardcoded-secret detector and the
641/// secret-to-network catalogue category are include-required.
642#[must_use]
643fn is_include_required_category(id: &str) -> bool {
644    id == HARDCODED_SECRET_CATEGORY_ID || id == SECRET_TO_NETWORK_CATEGORY_ID
645}
646
647/// A user-facing security candidate category, valid in `security.categories`
648/// `include` / `exclude`.
649#[derive(Debug, Clone)]
650pub struct SecurityCategory {
651    /// The category id used in `security.categories.include` / `exclude`.
652    pub id: String,
653    /// Human-readable title.
654    pub title: String,
655    /// The CWE number, when the category maps to one (`None` for the
656    /// entropy-based hardcoded-secret detector).
657    pub cwe: Option<u32>,
658    /// Whether the category runs only when explicitly named in
659    /// `categories.include`.
660    pub include_required: bool,
661}
662
663/// Every security candidate category an agent can name in
664/// `security.categories.include` / `exclude`, deduped by id and sorted.
665///
666/// This is the canonical, machine-readable vocabulary for the `security`
667/// config surface: the embedded catalogue's distinct sink categories plus the
668/// standalone hardcoded-secret detector. Because the catalogue is
669/// `include_str!`-embedded, the set is deterministic per build.
670#[must_use]
671pub fn security_categories() -> Vec<SecurityCategory> {
672    let mut seen = FxHashSet::default();
673    let mut out = Vec::new();
674    for matcher in catalogue().matchers() {
675        if seen.insert(matcher.id.clone()) {
676            out.push(SecurityCategory {
677                id: matcher.id.clone(),
678                title: matcher.title.clone(),
679                cwe: Some(matcher.cwe),
680                include_required: is_include_required_category(&matcher.id),
681            });
682        }
683    }
684    if seen.insert(HARDCODED_SECRET_CATEGORY_ID.to_owned()) {
685        out.push(SecurityCategory {
686            id: HARDCODED_SECRET_CATEGORY_ID.to_owned(),
687            title: HARDCODED_SECRET_CATEGORY_TITLE.to_owned(),
688            cwe: None,
689            include_required: true,
690        });
691    }
692    out.sort_by(|a, b| a.id.cmp(&b.id));
693    out
694}
695
696/// Resolve a kebab-case sink-shape string into the typed [`SinkShape`].
697fn parse_sink_shape(s: &str) -> Option<SinkShape> {
698    match s {
699        "call" => Some(SinkShape::Call),
700        "member-call" => Some(SinkShape::MemberCall),
701        "member-assign" => Some(SinkShape::MemberAssign),
702        "tagged-template" => Some(SinkShape::TaggedTemplate),
703        "jsx-attr" => Some(SinkShape::JsxAttr),
704        "new-expression" => Some(SinkShape::NewExpression),
705        _ => None,
706    }
707}
708
709/// Resolve a kebab-case arg-kind string into the typed [`SinkArgKind`].
710fn parse_arg_kind(s: &str) -> Option<SinkArgKind> {
711    match s {
712        "template-with-subst" => Some(SinkArgKind::TemplateWithSubst),
713        "concat" => Some(SinkArgKind::Concat),
714        "object" => Some(SinkArgKind::Object),
715        "call" => Some(SinkArgKind::Call),
716        "literal" => Some(SinkArgKind::Literal),
717        "no-arg" => Some(SinkArgKind::NoArg),
718        "other" => Some(SinkArgKind::Other),
719        _ => None,
720    }
721}
722
723fn parse_object_property_predicates(
724    id: &str,
725    raw: Option<Vec<RawObjectPropertyPredicate>>,
726) -> Result<Vec<ObjectPropertyPredicate>, String> {
727    let Some(raw_predicates) = raw else {
728        return Ok(Vec::new());
729    };
730    let mut predicates = Vec::with_capacity(raw_predicates.len());
731    for predicate in raw_predicates {
732        if predicate.key.trim().is_empty() {
733            return Err(format!(
734                "matcher {id:?} has an object_properties predicate with an empty key"
735            ));
736        }
737        let value_count = usize::from(predicate.string.is_some())
738            + usize::from(predicate.boolean.is_some())
739            + usize::from(predicate.integer.is_some())
740            + usize::from(predicate.null);
741        if value_count != 1 {
742            return Err(format!(
743                "matcher {id:?} object_properties predicate for {:?} must set exactly one of string | boolean | integer | null",
744                predicate.key
745            ));
746        }
747        let value = if let Some(string) = predicate.string {
748            LiteralPredicate::String(string)
749        } else if let Some(boolean) = predicate.boolean {
750            LiteralPredicate::Boolean(boolean)
751        } else if let Some(integer) = predicate.integer {
752            LiteralPredicate::Integer(integer)
753        } else {
754            LiteralPredicate::Null
755        };
756        predicates.push(ObjectPropertyPredicate {
757            key: predicate.key,
758            value,
759        });
760    }
761    Ok(predicates)
762}
763
764/// Parse + validate the catalogue source. Returns a `Result` (NOT a panic) so
765/// the validation tests can assert on error messages; `catalogue()` unwraps it.
766///
767/// Validates: non-empty id; cwe > 0; sink_shape resolves; callee_patterns
768/// non-empty and every pattern non-empty/non-whitespace; non-empty
769/// evidence_template.
770fn parse_catalogue(src: &str) -> Result<Catalogue, String> {
771    let raw: RawCatalogue =
772        toml::from_str(src).map_err(|e| format!("security_matchers.toml parse error: {e}"))?;
773
774    let mut matchers = Vec::with_capacity(raw.matcher.len());
775    for entry in raw.matcher {
776        matchers.push(parse_matcher_entry(entry)?);
777    }
778
779    if matchers.is_empty() {
780        return Err("security_matchers.toml has no [[matcher]] entries".to_string());
781    }
782
783    let sources = parse_source_catalogue(raw.source)?;
784
785    Ok(Catalogue { matchers, sources })
786}
787
788/// Validate one raw matcher entry and convert it to a `Matcher`. Validates a
789/// non-empty id, cwe > 0, a resolvable sink_shape, non-empty callee_patterns /
790/// arg_kinds / evidence_template, and a non-empty enabler when present.
791fn parse_matcher_entry(entry: RawMatcher) -> Result<Matcher, String> {
792    let (sink_shape, callee_patterns) = validate_matcher_core(&entry)?;
793    let arg_kinds = parse_matcher_arg_kinds(&entry.id, entry.arg_kinds.as_deref())?;
794    let enabler = validate_matcher_enabler(&entry.id, entry.enabler)?;
795    let object_properties = parse_object_property_predicates(&entry.id, entry.object_properties)?;
796    Ok(Matcher {
797        id: entry.id,
798        cwe: entry.cwe,
799        title: entry.title,
800        effect: entry.effect,
801        sink_shape,
802        callee_patterns,
803        arg_index: entry.arg_index,
804        variadic: entry.variadic,
805        evidence_template: entry.evidence_template,
806        import_provenance: entry.import_provenance,
807        enabler,
808        arg_kinds,
809        requires_source: entry.requires_source,
810        requires_source_kinds: entry.requires_source_kinds,
811        literal_values: entry.literal_values.unwrap_or_default(),
812        literal_contains: entry.literal_contains.unwrap_or_default(),
813        literal_integers: entry.literal_integers.unwrap_or_default(),
814        object_properties,
815        object_missing_or_false: entry.object_missing_or_false.unwrap_or_default(),
816        object_missing: entry.object_missing.unwrap_or_default(),
817        context_keywords: entry.context_keywords.unwrap_or_default(),
818    })
819}
820
821/// Validate a matcher's scalar fields (id, cwe, evidence_template) and parse its
822/// sink_shape plus non-empty callee_patterns.
823fn validate_matcher_core(entry: &RawMatcher) -> Result<(SinkShape, Vec<CalleePattern>), String> {
824    if entry.id.trim().is_empty() {
825        return Err("matcher id must be non-empty / non-whitespace".to_string());
826    }
827    if entry.cwe == 0 {
828        return Err(format!("matcher {:?} has cwe 0; cwe must be > 0", entry.id));
829    }
830    let sink_shape = parse_sink_shape(&entry.sink_shape).ok_or_else(|| {
831        format!(
832            "matcher {:?} has unknown sink_shape {:?}; expected one of \
833             call | member-call | member-assign | tagged-template | jsx-attr | new-expression",
834            entry.id, entry.sink_shape
835        )
836    })?;
837    if entry.callee_patterns.is_empty() {
838        return Err(format!(
839            "matcher {:?} has no callee_patterns; at least one is required",
840            entry.id
841        ));
842    }
843    if entry.evidence_template.trim().is_empty() {
844        return Err(format!(
845            "matcher {:?} has an empty evidence_template",
846            entry.id
847        ));
848    }
849    let mut callee_patterns = Vec::with_capacity(entry.callee_patterns.len());
850    for pat in &entry.callee_patterns {
851        let parsed = parse_callee_pattern(pat).ok_or_else(|| {
852            format!(
853                "matcher {:?} has an empty / whitespace callee_pattern {pat:?}",
854                entry.id
855            )
856        })?;
857        callee_patterns.push(parsed);
858    }
859    Ok((sink_shape, callee_patterns))
860}
861
862/// Validate the optional `enabler`: present but empty / whitespace is rejected;
863/// absent or non-empty passes through unchanged.
864fn validate_matcher_enabler(id: &str, enabler: Option<String>) -> Result<Option<String>, String> {
865    match enabler {
866        Some(e) if e.trim().is_empty() => Err(format!(
867            "matcher {id:?} has an empty / whitespace enabler; omit the key for a global row"
868        )),
869        other => Ok(other),
870    }
871}
872
873/// Parse the optional `arg_kinds` list: `None` admits any shape, an empty list
874/// is rejected, and each entry must resolve to a known `ArgKind`.
875fn parse_matcher_arg_kinds(
876    id: &str,
877    raw_kinds: Option<&[String]>,
878) -> Result<Option<Vec<SinkArgKind>>, String> {
879    let Some(raw_kinds) = raw_kinds else {
880        return Ok(None);
881    };
882    if raw_kinds.is_empty() {
883        return Err(format!(
884            "matcher {id:?} has an empty arg_kinds list; omit the key to admit any shape"
885        ));
886    }
887    let mut kinds = Vec::with_capacity(raw_kinds.len());
888    for raw in raw_kinds {
889        let kind = parse_arg_kind(raw).ok_or_else(|| {
890            format!(
891                "matcher {id:?} has unknown arg_kind {raw:?}; expected one of \
892                 template-with-subst | concat | object | call | literal | no-arg | other"
893            )
894        })?;
895        kinds.push(kind);
896    }
897    Ok(Some(kinds))
898}
899
900fn parse_source_catalogue(raw_sources: Vec<RawSource>) -> Result<Vec<SourceMatcher>, String> {
901    let mut sources = Vec::with_capacity(raw_sources.len());
902    for entry in raw_sources {
903        if entry.id.trim().is_empty() {
904            return Err("source id must be non-empty / non-whitespace".to_string());
905        }
906        if entry.path_patterns.is_empty() {
907            return Err(format!(
908                "source {:?} has no path_patterns; at least one is required",
909                entry.id
910            ));
911        }
912        let path_patterns = parse_source_path_patterns(&entry)?;
913        let receiver_allowlist = parse_source_receiver_allowlist(&entry)?;
914        let enabler = match entry.enabler {
915            Some(e) if e.trim().is_empty() => {
916                return Err(format!(
917                    "source {:?} has an empty / whitespace enabler; omit the key for a global row",
918                    entry.id
919                ));
920            }
921            other => other,
922        };
923        sources.push(SourceMatcher {
924            id: entry.id,
925            title: entry.title,
926            enabler,
927            path_patterns,
928            receiver_allowlist,
929        });
930    }
931    Ok(sources)
932}
933
934fn parse_source_path_patterns(entry: &RawSource) -> Result<Vec<CalleePattern>, String> {
935    let mut path_patterns = Vec::with_capacity(entry.path_patterns.len());
936    for pattern in &entry.path_patterns {
937        let parsed = parse_callee_pattern(pattern).ok_or_else(|| {
938            format!(
939                "source {:?} has an empty / whitespace path_pattern {pattern:?}",
940                entry.id
941            )
942        })?;
943        path_patterns.push(parsed);
944    }
945    Ok(path_patterns)
946}
947
948fn parse_source_receiver_allowlist(entry: &RawSource) -> Result<Vec<String>, String> {
949    let mut receiver_allowlist = Vec::with_capacity(entry.receiver_allowlist.len());
950    for receiver in &entry.receiver_allowlist {
951        if receiver.trim().is_empty() {
952            return Err(format!(
953                "source {:?} has an empty / whitespace receiver_allowlist entry; omit the key for an ungated row",
954                entry.id
955            ));
956        }
957        receiver_allowlist.push(receiver.to_ascii_lowercase());
958    }
959    Ok(receiver_allowlist)
960}
961
962/// Parse and cache the embedded catalogue once. Unwraps the parse `Result`; in
963/// a released binary this is unreachable because the bytes are compile-time
964/// embedded and gated by `security_catalogue_parses`.
965///
966/// # Panics
967///
968/// Panics if the embedded matcher catalogue does not parse.
969#[expect(
970    clippy::expect_used,
971    reason = "compile-time-embedded catalogue pinned by security_catalogue_parses"
972)]
973pub fn catalogue() -> &'static Catalogue {
974    static CATALOGUE: std::sync::OnceLock<Catalogue> = std::sync::OnceLock::new();
975    CATALOGUE.get_or_init(|| {
976        parse_catalogue(CATALOGUE_TOML).expect(
977            "embedded crates/security/data/security_matchers.toml must parse; run \
978             `cargo test -p fallow-security security_catalogue_parses` to see the error",
979        )
980    })
981}
982
983#[cfg(test)]
984#[allow(
985    clippy::expect_used,
986    clippy::unwrap_used,
987    reason = "catalogue parser tests assert fixture invariants directly"
988)]
989mod tests {
990    use super::*;
991    use rustc_hash::FxHashSet;
992
993    /// Source lookup through the production method, with no extra request
994    /// receivers.
995    fn source_for<'a>(
996        cat: &'a Catalogue,
997        source_path: &str,
998        declared_deps: &FxHashSet<String>,
999    ) -> Option<(&'a str, &'a str)> {
1000        cat.matching_source_for_deps_with_receivers(
1001            source_path,
1002            declared_deps,
1003            &FxHashSet::default(),
1004        )
1005    }
1006
1007    /// Whether the production lookup finds a source with no declared dependencies.
1008    fn is_source(cat: &Catalogue, source_path: &str) -> bool {
1009        source_for(cat, source_path, &FxHashSet::default()).is_some()
1010    }
1011
1012    #[test]
1013    fn security_categories_are_deduped_and_flag_include_required() {
1014        let cats = security_categories();
1015        assert!(!cats.is_empty(), "catalogue must yield categories");
1016        // deduped by id
1017        let mut ids = FxHashSet::default();
1018        for c in &cats {
1019            assert!(ids.insert(c.id.clone()), "duplicate category id {}", c.id);
1020        }
1021        // sorted by id
1022        let sorted: Vec<&String> = {
1023            let mut v: Vec<&String> = cats.iter().map(|c| &c.id).collect();
1024            v.sort();
1025            v
1026        };
1027        assert_eq!(
1028            cats.iter().map(|c| &c.id).collect::<Vec<_>>(),
1029            sorted,
1030            "categories must be sorted by id"
1031        );
1032        // both include-required categories present and flagged; hardcoded-secret
1033        // carries no CWE (entropy detector).
1034        let by_id = |id: &str| cats.iter().find(|c| c.id == id);
1035        let hs = by_id(HARDCODED_SECRET_CATEGORY_ID).expect("hardcoded-secret present");
1036        assert!(hs.include_required && hs.cwe.is_none());
1037        let stn = by_id(SECRET_TO_NETWORK_CATEGORY_ID).expect("secret-to-network present");
1038        assert!(
1039            stn.include_required,
1040            "secret-to-network must be include-required"
1041        );
1042        // a normal category is NOT include-required
1043        assert!(
1044            cats.iter().any(|c| !c.include_required),
1045            "most categories are admitted by default"
1046        );
1047    }
1048
1049    #[test]
1050    fn secret_to_network_const_matches_catalogue() {
1051        assert!(
1052            catalogue()
1053                .matchers()
1054                .iter()
1055                .any(|m| m.id == SECRET_TO_NETWORK_CATEGORY_ID),
1056            "SECRET_TO_NETWORK_CATEGORY_ID must name a real catalogue category"
1057        );
1058    }
1059
1060    #[test]
1061    fn security_catalogue_parses() {
1062        let cat = catalogue();
1063        assert!(!cat.matchers().is_empty(), "catalogue must have matchers");
1064        assert!(
1065            cat.matchers().iter().any(|m| m.id == "dangerous-html"),
1066            "catalogue must contain the dangerous-html seed"
1067        );
1068    }
1069
1070    #[test]
1071    fn catalogue_rows_are_unique() {
1072        // Multiple rows legitimately share an `id` (dangerous-html spans three
1073        // shapes), so uniqueness is keyed on the FULL row: id + sink_shape +
1074        // callee_patterns + gates. No two identical matcher rows. Keyed off the
1075        // raw source so the test does not require `SinkShape: Hash`.
1076        let raw: RawCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
1077        let mut seen = FxHashSet::default();
1078        for m in &raw.matcher {
1079            let pats = m.callee_patterns.join("|");
1080            // Uniqueness includes the enabler: framework-scoped rows (#861) may
1081            // legitimately share id + shape + patterns and differ only by their
1082            // framework gate (e.g. one `route-send-file` row per framework).
1083            let enabler = m.enabler.as_deref().unwrap_or("");
1084            let import_provenance = m.import_provenance.as_deref().unwrap_or("");
1085            let arg_kinds = m
1086                .arg_kinds
1087                .as_ref()
1088                .map_or_else(String::new, |kinds| kinds.join("|"));
1089            let literal_values = m
1090                .literal_values
1091                .as_ref()
1092                .map_or_else(String::new, |values| values.join("|"));
1093            let literal_contains = m
1094                .literal_contains
1095                .as_ref()
1096                .map_or_else(String::new, |values| values.join("|"));
1097            let literal_integers = m
1098                .literal_integers
1099                .as_ref()
1100                .map_or_else(String::new, |values| {
1101                    values
1102                        .iter()
1103                        .map(i64::to_string)
1104                        .collect::<Vec<_>>()
1105                        .join("|")
1106                });
1107            let object_properties = format!("{:?}", m.object_properties);
1108            let object_missing_or_false = m
1109                .object_missing_or_false
1110                .as_ref()
1111                .map_or_else(String::new, |keys| keys.join("|"));
1112            let object_missing = m
1113                .object_missing
1114                .as_ref()
1115                .map_or_else(String::new, |keys| keys.join("|"));
1116            let context_keywords = m
1117                .context_keywords
1118                .as_ref()
1119                .map_or_else(String::new, |keywords| keywords.join("|"));
1120            let key = format!(
1121                "{}::{}::{pats}::{enabler}::{import_provenance}::{}::{arg_kinds}::{literal_values}::{literal_contains}::{literal_integers}::{object_properties}::{object_missing_or_false}::{object_missing}::{context_keywords}",
1122                m.id, m.sink_shape, m.requires_source
1123            );
1124            assert!(seen.insert(key.clone()), "duplicate matcher row: {key}");
1125        }
1126    }
1127
1128    #[test]
1129    fn path_join_row_admits_every_path_segment() {
1130        let row = catalogue()
1131            .matchers()
1132            .iter()
1133            .find(|m| m.id == "path-traversal" && m.first_matching_pattern("path.join").is_some())
1134            .expect("path.join row exists");
1135        assert!(row.variadic, "path.join must inspect every segment (#3712)");
1136        assert!(row.admits_arg_index(0));
1137        assert!(row.admits_arg_index(3));
1138
1139        let rename_destination = catalogue()
1140            .matchers()
1141            .iter()
1142            .find(|m| m.id == "path-traversal" && m.arg_index == 1)
1143            .expect("fs.rename destination row exists");
1144        assert!(!rename_destination.admits_arg_index(0));
1145        assert!(!rename_destination.admits_arg_index(2));
1146    }
1147
1148    #[test]
1149    fn catalogue_ids_non_empty() {
1150        for m in catalogue().matchers() {
1151            assert!(
1152                !m.id.trim().is_empty(),
1153                "matcher id must be non-empty / non-whitespace"
1154            );
1155        }
1156    }
1157
1158    #[test]
1159    fn catalogue_cwe_valid() {
1160        for m in catalogue().matchers() {
1161            assert!(m.cwe > 0, "matcher {:?} has cwe 0", m.id);
1162        }
1163    }
1164
1165    #[test]
1166    fn catalogue_sink_shapes_known() {
1167        // Every parsed matcher already carries a typed SinkShape, so re-parse
1168        // the raw source to assert the kebab strings all resolve.
1169        let raw: RawCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
1170        for m in &raw.matcher {
1171            assert!(
1172                parse_sink_shape(&m.sink_shape).is_some(),
1173                "matcher {:?} has unknown sink_shape {:?}",
1174                m.id,
1175                m.sink_shape
1176            );
1177        }
1178    }
1179
1180    #[test]
1181    fn catalogue_callee_patterns_non_empty() {
1182        for m in catalogue().matchers() {
1183            assert!(
1184                !m.callee_patterns.is_empty(),
1185                "matcher {:?} has no callee_patterns",
1186                m.id
1187            );
1188            for p in &m.callee_patterns {
1189                assert!(
1190                    !p.raw().trim().is_empty(),
1191                    "matcher {:?} has an empty callee_pattern",
1192                    m.id
1193                );
1194            }
1195        }
1196    }
1197
1198    #[test]
1199    fn catalogue_evidence_templates_non_empty() {
1200        for m in catalogue().matchers() {
1201            assert!(
1202                !m.evidence_template.trim().is_empty(),
1203                "matcher {:?} has an empty evidence_template",
1204                m.id
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn parse_rejects_empty_id() {
1211        let toml = r#"
1212[[matcher]]
1213id = ""
1214cwe = 79
1215title = "x"
1216effect = "unknown"
1217sink_shape = "member-assign"
1218callee_patterns = ["*.innerHTML"]
1219arg_index = 0
1220evidence_template = "x"
1221"#;
1222        let err = parse_catalogue(toml).unwrap_err();
1223        assert!(err.contains("id must be non-empty"), "got: {err}");
1224    }
1225
1226    #[test]
1227    fn parse_rejects_zero_cwe() {
1228        let toml = r#"
1229[[matcher]]
1230id = "x"
1231cwe = 0
1232title = "x"
1233effect = "unknown"
1234sink_shape = "member-assign"
1235callee_patterns = ["*.innerHTML"]
1236arg_index = 0
1237evidence_template = "x"
1238"#;
1239        let err = parse_catalogue(toml).unwrap_err();
1240        assert!(err.contains("cwe"), "got: {err}");
1241    }
1242
1243    #[test]
1244    fn parse_rejects_missing_effect() {
1245        let toml = r#"
1246[[matcher]]
1247id = "x"
1248cwe = 79
1249title = "x"
1250sink_shape = "member-assign"
1251callee_patterns = ["*.innerHTML"]
1252arg_index = 0
1253evidence_template = "x"
1254"#;
1255        let err = parse_catalogue(toml).unwrap_err();
1256        assert!(err.contains("missing field `effect`"), "got: {err}");
1257    }
1258
1259    #[test]
1260    fn parse_rejects_unknown_sink_shape() {
1261        let toml = r#"
1262[[matcher]]
1263id = "x"
1264cwe = 79
1265title = "x"
1266effect = "unknown"
1267sink_shape = "not-a-shape"
1268callee_patterns = ["*.innerHTML"]
1269arg_index = 0
1270evidence_template = "x"
1271"#;
1272        let err = parse_catalogue(toml).unwrap_err();
1273        assert!(err.contains("unknown sink_shape"), "got: {err}");
1274    }
1275
1276    #[test]
1277    fn parse_rejects_empty_callee_patterns() {
1278        let toml = r#"
1279[[matcher]]
1280id = "x"
1281cwe = 79
1282title = "x"
1283effect = "unknown"
1284sink_shape = "member-assign"
1285callee_patterns = []
1286arg_index = 0
1287evidence_template = "x"
1288"#;
1289        let err = parse_catalogue(toml).unwrap_err();
1290        assert!(err.contains("callee_patterns"), "got: {err}");
1291    }
1292
1293    #[test]
1294    fn parse_rejects_empty_pattern_string() {
1295        let toml = r#"
1296[[matcher]]
1297id = "x"
1298cwe = 79
1299title = "x"
1300effect = "unknown"
1301sink_shape = "member-assign"
1302callee_patterns = ["   "]
1303arg_index = 0
1304evidence_template = "x"
1305"#;
1306        let err = parse_catalogue(toml).unwrap_err();
1307        assert!(err.contains("empty"), "got: {err}");
1308    }
1309
1310    #[test]
1311    fn parse_rejects_empty_evidence_template() {
1312        let toml = r#"
1313[[matcher]]
1314id = "x"
1315cwe = 79
1316title = "x"
1317effect = "unknown"
1318sink_shape = "member-assign"
1319callee_patterns = ["*.innerHTML"]
1320arg_index = 0
1321evidence_template = "   "
1322"#;
1323        let err = parse_catalogue(toml).unwrap_err();
1324        assert!(err.contains("evidence_template"), "got: {err}");
1325    }
1326
1327    #[test]
1328    fn parse_rejects_no_matchers() {
1329        let err = parse_catalogue("").unwrap_err();
1330        assert!(err.contains("no [[matcher]]"), "got: {err}");
1331    }
1332
1333    #[test]
1334    fn segment_match_is_not_substring() {
1335        let bare = parse_callee_pattern("fetch").unwrap();
1336        assert!(bare.matches("fetch"));
1337        assert!(!bare.matches("myfetch"));
1338        assert!(!bare.matches("fetcher"));
1339
1340        let wildcard = parse_callee_pattern("*.innerHTML").unwrap();
1341        assert!(wildcard.matches("el.innerHTML"));
1342        assert!(wildcard.matches("this.node.innerHTML"));
1343        assert!(!wildcard.matches("el.innerHTMLFoo"));
1344        assert!(!wildcard.matches("innerHTML")); // wildcard requires an object
1345
1346        let dotted = parse_callee_pattern("child_process.exec").unwrap();
1347        assert!(dotted.matches("child_process.exec"));
1348        assert!(!dotted.matches("exec"));
1349        assert!(!dotted.matches("child_process.execSync"));
1350        assert!(!dotted.matches("my_child_process.exec"));
1351    }
1352
1353    #[test]
1354    fn wildcard_only_pattern_matches_nothing() {
1355        // Guard against a degenerate `*` pattern matching every callee.
1356        let star = parse_callee_pattern("*").unwrap();
1357        assert!(!star.matches("el.innerHTML"));
1358        assert!(!star.matches("anything"));
1359    }
1360
1361    #[test]
1362    fn trailing_wildcard_prefix_matches() {
1363        let trailing = parse_callee_pattern("child_process.*").unwrap();
1364        assert!(trailing.matches("child_process.exec"));
1365        assert!(trailing.matches("child_process.exec.call"));
1366        assert!(!trailing.matches("child_process")); // requires a member
1367        assert!(!trailing.matches("my_child_process.exec"));
1368        assert!(!trailing.matches("exec"));
1369
1370        let console = parse_callee_pattern("console.*").unwrap();
1371        assert!(console.matches("console.log"));
1372        assert!(!console.matches("myconsole.log"));
1373    }
1374
1375    #[test]
1376    fn double_wildcard_pattern_matches_nothing() {
1377        // `*.x.*` and `*.*` are rejected by config validation; the matcher
1378        // guards against them anyway.
1379        let both = parse_callee_pattern("*.query.*").unwrap();
1380        assert!(!both.matches("db.query.run"));
1381        let stars = parse_callee_pattern("*.*").unwrap();
1382        assert!(!stars.matches("a.b"));
1383    }
1384
1385    #[test]
1386    fn arg_kinds_unset_admits_any_shape() {
1387        // A matcher with no arg_kinds (e.g. dangerous-html) admits every shape.
1388        let html = catalogue()
1389            .matchers()
1390            .iter()
1391            .find(|m| m.id == "dangerous-html")
1392            .expect("dangerous-html present");
1393        for kind in [
1394            SinkArgKind::TemplateWithSubst,
1395            SinkArgKind::Concat,
1396            SinkArgKind::Object,
1397            SinkArgKind::Call,
1398            SinkArgKind::Literal,
1399            SinkArgKind::NoArg,
1400            SinkArgKind::Other,
1401        ] {
1402            assert!(html.admits_arg_kind(kind), "html admits {kind:?}");
1403        }
1404    }
1405
1406    #[test]
1407    fn sql_injection_query_execute_excludes_object_arg_kind() {
1408        // The `.query` / `.execute` matchers must require unsafe shapes (concat /
1409        // interpolated template) and reject the parameterized object-literal form
1410        // (`.execute({ sql, args })`). The separate `sql.raw` escape-hatch row is
1411        // intentionally shape-agnostic and is excluded from this check.
1412        let query_matchers: Vec<&Matcher> = catalogue()
1413            .matchers()
1414            .iter()
1415            .filter(|m| {
1416                m.id == "sql-injection"
1417                    && m.callee_patterns
1418                        .iter()
1419                        .any(|p| p.raw() == "*.query" || p.raw() == "*.execute")
1420            })
1421            .collect();
1422        assert!(
1423            !query_matchers.is_empty(),
1424            "sql-injection .query/.execute rows present"
1425        );
1426        for m in query_matchers {
1427            let kinds = m
1428                .arg_kinds
1429                .as_ref()
1430                .unwrap_or_else(|| panic!("sql-injection query/execute must constrain arg_kinds"));
1431            assert!(
1432                !kinds.contains(&SinkArgKind::Object),
1433                "sql-injection .query/.execute must not admit the object (parameterized) form"
1434            );
1435            assert!(
1436                !m.admits_arg_kind(SinkArgKind::Object),
1437                "admits_arg_kind agrees: object excluded"
1438            );
1439            assert!(
1440                m.admits_arg_kind(SinkArgKind::Concat),
1441                "sql-injection .query/.execute admits the concat (unsafe) form"
1442            );
1443        }
1444    }
1445
1446    #[test]
1447    fn source_required_matchers_are_explicit() {
1448        let mass_assignment = catalogue()
1449            .matchers()
1450            .iter()
1451            .find(|m| m.id == "mass-assignment")
1452            .expect("mass-assignment row present");
1453        assert!(
1454            mass_assignment.requires_source,
1455            "mass-assignment should only fire for source-backed arguments"
1456        );
1457    }
1458
1459    #[test]
1460    fn literal_integer_predicate_matches_integer_literals() {
1461        let chmod = catalogue()
1462            .matchers()
1463            .iter()
1464            .find(|m| m.id == "world-writable-permission" && m.sink_shape == SinkShape::MemberCall)
1465            .expect("world-writable permission row present");
1466
1467        assert!(chmod.literal_value_satisfied(Some(&SinkLiteralValue::Integer(511))));
1468        assert!(!chmod.literal_value_satisfied(Some(&SinkLiteralValue::Integer(420))));
1469        assert!(
1470            !chmod.literal_value_satisfied(Some(&SinkLiteralValue::String("0o777".to_string())))
1471        );
1472    }
1473
1474    #[test]
1475    fn object_property_predicate_matches_nested_integer_values() {
1476        let toml = r#"
1477[[matcher]]
1478id = "x"
1479cwe = 732
1480title = "x"
1481effect = "unknown"
1482sink_shape = "member-call"
1483callee_patterns = ["fs.chmod"]
1484arg_index = 0
1485arg_kinds = ["object"]
1486object_properties = [{ key = "mode.value", integer = 511 }]
1487evidence_template = "x"
1488"#;
1489        let cat = parse_catalogue(toml).expect("catalogue parses");
1490        let matcher = cat.matchers().first().expect("matcher present");
1491        let properties = vec![SinkObjectProperty {
1492            key: "mode.value".to_string(),
1493            value: SinkLiteralValue::Integer(511),
1494        }];
1495
1496        assert!(matcher.object_properties_satisfied(&properties));
1497    }
1498
1499    #[test]
1500    fn object_missing_requires_complete_key_metadata() {
1501        let jwt_verify = catalogue()
1502            .matchers()
1503            .iter()
1504            .find(|m| m.id == "jwt-verify-missing-algorithms")
1505            .expect("jwt verify missing algorithms row present");
1506
1507        assert!(
1508            jwt_verify.is_literal_aware(),
1509            "object_missing rows opt into literal-aware matching"
1510        );
1511        assert!(jwt_verify.object_missing_satisfied(&[], true));
1512        assert!(jwt_verify.object_missing_satisfied(&["audience".to_string()], true));
1513        assert!(!jwt_verify.object_missing_satisfied(&["algorithms".to_string()], true));
1514        assert!(!jwt_verify.object_missing_satisfied(&["audience".to_string()], false));
1515    }
1516
1517    #[test]
1518    fn parse_rejects_unknown_arg_kind() {
1519        let toml = r#"
1520[[matcher]]
1521id = "x"
1522cwe = 89
1523title = "x"
1524effect = "unknown"
1525sink_shape = "member-call"
1526callee_patterns = ["*.query"]
1527arg_index = 0
1528arg_kinds = ["not-a-kind"]
1529evidence_template = "x"
1530"#;
1531        let err = parse_catalogue(toml).unwrap_err();
1532        assert!(err.contains("unknown arg_kind"), "got: {err}");
1533    }
1534
1535    #[test]
1536    fn enabler_unset_is_global() {
1537        // A matcher with no enabler is satisfied by ANY (even empty) dep set.
1538        let html = catalogue()
1539            .matchers()
1540            .iter()
1541            .find(|m| m.id == "dangerous-html")
1542            .expect("dangerous-html present");
1543        assert!(html.enabler.is_none(), "dangerous-html is a global row");
1544        assert!(html.enabler_satisfied(&FxHashSet::default()));
1545    }
1546
1547    #[test]
1548    fn enabler_satisfied_exact_and_prefix() {
1549        let mut m = catalogue()
1550            .matchers()
1551            .iter()
1552            .find(|m| m.id == "dangerous-html")
1553            .cloned()
1554            .expect("dangerous-html present");
1555
1556        // Exact match.
1557        m.enabler = Some("jquery".to_string());
1558        let mut deps = FxHashSet::default();
1559        assert!(!m.enabler_satisfied(&deps), "absent dep is not satisfied");
1560        deps.insert("jquery".to_string());
1561        assert!(m.enabler_satisfied(&deps), "present exact dep satisfies");
1562
1563        // Trailing-slash prefix match, plus the bare scope name.
1564        m.enabler = Some("@angular/".to_string());
1565        let mut scoped = FxHashSet::default();
1566        assert!(!m.enabler_satisfied(&scoped));
1567        scoped.insert("@angular/platform-browser".to_string());
1568        assert!(m.enabler_satisfied(&scoped), "prefix dep satisfies");
1569        let mut bare_scope = FxHashSet::default();
1570        bare_scope.insert("@angular".to_string());
1571        assert!(
1572            m.enabler_satisfied(&bare_scope),
1573            "bare scope name satisfies the prefix form"
1574        );
1575
1576        // A near-miss exact name does not satisfy a prefix-less enabler.
1577        m.enabler = Some("react".to_string());
1578        let mut reactish = FxHashSet::default();
1579        reactish.insert("react-dom".to_string());
1580        assert!(
1581            !m.enabler_satisfied(&reactish),
1582            "exact enabler must not prefix-match"
1583        );
1584    }
1585
1586    #[test]
1587    fn framework_scoped_rows_are_present() {
1588        // The framework-scoped rows added in #861 carry an enabler.
1589        let cat = catalogue();
1590        let angular = cat
1591            .matchers()
1592            .iter()
1593            .find(|m| m.id == "angular-trusted-html")
1594            .expect("angular-trusted-html present");
1595        assert_eq!(
1596            angular.enabler.as_deref(),
1597            Some("@angular/platform-browser")
1598        );
1599        assert!(
1600            cat.matchers().iter().any(|m| m.id == "jquery-html"),
1601            "jquery-html present"
1602        );
1603        assert!(
1604            cat.matchers().iter().any(|m| m.id == "dom-document-write"),
1605            "dom-document-write present"
1606        );
1607    }
1608
1609    #[test]
1610    fn parse_rejects_empty_enabler() {
1611        let toml = r#"
1612[[matcher]]
1613id = "x"
1614cwe = 79
1615title = "x"
1616effect = "unknown"
1617sink_shape = "member-call"
1618callee_patterns = ["*.html"]
1619arg_index = 0
1620enabler = "   "
1621evidence_template = "x"
1622"#;
1623        let err = parse_catalogue(toml).unwrap_err();
1624        assert!(err.contains("empty / whitespace enabler"), "got: {err}");
1625    }
1626
1627    #[test]
1628    fn catalogue_has_untrusted_sources() {
1629        // Issue #859: the embedded catalogue ships at least one [[source]] row,
1630        // each with a non-empty id, title, and path_patterns.
1631        let cat = catalogue();
1632        assert!(
1633            !cat.sources.is_empty(),
1634            "catalogue must ship untrusted-source rows"
1635        );
1636        for s in &cat.sources {
1637            assert!(!s.id.trim().is_empty(), "source id non-empty");
1638            assert!(!s.title.trim().is_empty(), "source title non-empty");
1639            assert!(!s.path_patterns.is_empty(), "source has path patterns");
1640        }
1641    }
1642
1643    #[test]
1644    fn source_paths_match_expected_request_inputs() {
1645        let cat = catalogue();
1646        // Wildcard object prefix matches common framework request accessors.
1647        assert!(is_source(cat, "req.query"));
1648        assert!(is_source(cat, "ctx.req.query"));
1649        assert!(is_source(cat, "request.body"));
1650        assert!(is_source(cat, "req.params"));
1651        assert!(is_source(cat, "process.argv"));
1652        assert!(is_source(cat, "event.data"));
1653        assert!(is_source(cat, "request.rawBody"));
1654        assert!(is_source(cat, "document.referrer"));
1655        assert!(is_source(cat, "window.name"));
1656        assert!(is_source(cat, "document.cookie"));
1657        // A plain object path that is not an untrusted source does not match.
1658        assert!(!is_source(cat, "config.value"));
1659        assert!(!is_source(cat, "user.name"));
1660        assert!(!is_source(cat, "profile.name"));
1661        assert!(!is_source(cat, "jar.cookie"));
1662    }
1663
1664    #[test]
1665    fn source_matcher_matches_helper() {
1666        let cat = catalogue();
1667        let http = cat
1668            .sources
1669            .iter()
1670            .find(|s| s.id == "http-request-input")
1671            .expect("http-request-input source present");
1672        assert!(http.matches_with_extra_receivers("req.query", &FxHashSet::default()));
1673        assert!(!http.matches_with_extra_receivers("process.argv", &FxHashSet::default()));
1674    }
1675
1676    #[test]
1677    fn matched_receiver_returns_segment_before_suffix() {
1678        // Leading-wildcard `*.query`: the receiver is the segment right before
1679        // the matched `query`, regardless of how many object segments precede.
1680        let pat = parse_callee_pattern("*.query").expect("pattern parses");
1681        assert_eq!(pat.matched_receiver("db.query"), Some("db"));
1682        assert_eq!(pat.matched_receiver("req.query"), Some("req"));
1683        // Hono `c.req.query` flattens so the receiver of `.query` is `req`.
1684        assert_eq!(pat.matched_receiver("ctx.req.query"), Some("req"));
1685        // A non-matching path has no receiver.
1686        assert_eq!(pat.matched_receiver("req.body"), None);
1687        // An exact (non-wildcard) pattern's receiver is fixed in the pattern, so
1688        // `matched_receiver` returns None even on a match.
1689        let exact = parse_callee_pattern("process.env").expect("pattern parses");
1690        assert_eq!(exact.matched_receiver("process.env"), None);
1691    }
1692
1693    #[test]
1694    fn receiver_allowlist_rejects_orm_query_builders_keeps_request_objects() {
1695        // Issue #1092: the global HTTP-input row is receiver-gated. ORM /
1696        // data-access receivers no longer classify their module as a source...
1697        let cat = catalogue();
1698        assert!(!is_source(cat, "db.query"), "Drizzle db.query");
1699        assert!(!is_source(cat, "prisma.query"), "Prisma prisma.query");
1700        assert!(!is_source(cat, "drizzle.query"));
1701        assert!(!is_source(cat, "knex.body"));
1702        assert!(!is_source(cat, "client.query"));
1703        // ...nor do non-request receivers that merely happen to have a `.query`
1704        // member (a sibling-collision check: `dbConn` is not `db`).
1705        assert!(!is_source(cat, "dbConn.query"));
1706        assert!(!is_source(cat, "database.params"));
1707        // A genuine request receiver still classifies as a source.
1708        assert!(is_source(cat, "req.query"), "Express req.query");
1709        assert!(is_source(cat, "request.body"));
1710        assert!(is_source(cat, "ctx.params"), "Koa/Elysia ctx.params");
1711        assert!(is_source(cat, "context.body"));
1712        assert!(is_source(cat, "event.query"), "SvelteKit event.query");
1713        // Hono `c.req.query`: the matched receiver is `req`, which is allowed.
1714        assert!(is_source(cat, "ctx.req.query"));
1715        // The allowlist is case-insensitive.
1716        assert!(is_source(cat, "Req.query"));
1717    }
1718
1719    #[test]
1720    fn configured_request_receivers_extend_http_request_source_allowlist() {
1721        let cat = catalogue();
1722        let deps = FxHashSet::default();
1723        let receivers = FxHashSet::from_iter(["h".to_string(), "httpreq".to_string()]);
1724
1725        assert!(
1726            cat.matching_source_for_deps_with_receivers("h.query", &deps, &receivers)
1727                .is_some()
1728        );
1729        assert!(
1730            cat.matching_source_for_deps_with_receivers("HttpReq.body", &deps, &receivers)
1731                .is_some()
1732        );
1733        assert!(
1734            cat.matching_source_for_deps_with_receivers("req.params", &deps, &receivers)
1735                .is_some()
1736        );
1737        assert!(
1738            cat.matching_source_for_deps_with_receivers("db.query", &deps, &receivers)
1739                .is_none()
1740        );
1741    }
1742
1743    #[test]
1744    fn search_params_source_stays_ungated() {
1745        // Issue #1092: `*.searchParams` is intentionally NOT receiver-gated, so a
1746        // `new URL(...).searchParams` binding on an arbitrary local still counts.
1747        let cat = catalogue();
1748        assert!(is_source(cat, "u.searchParams"));
1749        assert!(is_source(cat, "url.searchParams"));
1750        assert!(is_source(cat, "params.searchParams"));
1751    }
1752
1753    #[test]
1754    fn parse_rejects_empty_receiver_allowlist_entry() {
1755        let toml = r#"
1756[[matcher]]
1757id = "x"
1758cwe = 79
1759title = "x"
1760effect = "unknown"
1761sink_shape = "member-assign"
1762callee_patterns = ["*.innerHTML"]
1763arg_index = 0
1764evidence_template = "x"
1765
1766[[source]]
1767id = "http"
1768title = "HTTP"
1769path_patterns = ["*.query"]
1770receiver_allowlist = ["req", "  "]
1771"#;
1772        let err = parse_catalogue(toml).unwrap_err();
1773        assert!(err.contains("receiver_allowlist"), "got: {err}");
1774    }
1775
1776    #[test]
1777    fn source_enabler_gates_framework_param_sources() {
1778        let cat = catalogue();
1779        let source = cat
1780            .sources
1781            .iter()
1782            .find(|s| s.id == "framework-handler-input" && s.enabler.as_deref() == Some("express"))
1783            .expect("express handler source present");
1784        assert!(source.matches_with_extra_receivers("framework.request", &FxHashSet::default()));
1785
1786        let empty = FxHashSet::default();
1787        assert!(!source.enabler_satisfied(&empty));
1788        assert!(
1789            source_for(cat, "framework.request", &empty).is_none(),
1790            "framework handler params require an enabler"
1791        );
1792
1793        let mut deps = FxHashSet::default();
1794        deps.insert("express".to_string());
1795        assert!(source.enabler_satisfied(&deps));
1796        assert_eq!(
1797            source_for(cat, "framework.request", &deps),
1798            Some(("framework-handler-input", "Framework handler input"))
1799        );
1800    }
1801
1802    #[test]
1803    fn source_enabler_gates_graphql_and_trpc_param_sources() {
1804        let cat = catalogue();
1805        let empty = FxHashSet::default();
1806        assert!(
1807            source_for(cat, "graphql.args", &empty).is_none(),
1808            "GraphQL resolver args require a matching package"
1809        );
1810        assert!(
1811            source_for(cat, "trpc.input", &empty).is_none(),
1812            "tRPC procedure input requires a matching package"
1813        );
1814
1815        let mut graphql_deps = FxHashSet::default();
1816        graphql_deps.insert("@apollo/server".to_string());
1817        assert_eq!(
1818            source_for(cat, "graphql.args", &graphql_deps),
1819            Some(("graphql-resolver-args", "GraphQL resolver args"))
1820        );
1821
1822        let mut trpc_deps = FxHashSet::default();
1823        trpc_deps.insert("@trpc/server".to_string());
1824        assert_eq!(
1825            source_for(cat, "trpc.input", &trpc_deps),
1826            Some(("trpc-procedure-input", "tRPC procedure input"))
1827        );
1828    }
1829
1830    #[test]
1831    fn parse_rejects_source_without_patterns() {
1832        let toml = r#"
1833[[matcher]]
1834id = "x"
1835cwe = 79
1836title = "x"
1837effect = "unknown"
1838sink_shape = "member-assign"
1839callee_patterns = ["*.innerHTML"]
1840arg_index = 0
1841evidence_template = "x"
1842
1843[[source]]
1844id = "bad"
1845title = "bad"
1846path_patterns = []
1847"#;
1848        let err = parse_catalogue(toml).unwrap_err();
1849        assert!(err.contains("path_patterns"), "got: {err}");
1850    }
1851
1852    #[test]
1853    fn parse_rejects_empty_arg_kinds() {
1854        let toml = r#"
1855[[matcher]]
1856id = "x"
1857cwe = 89
1858title = "x"
1859effect = "unknown"
1860sink_shape = "member-call"
1861callee_patterns = ["*.query"]
1862arg_index = 0
1863arg_kinds = []
1864evidence_template = "x"
1865"#;
1866        let err = parse_catalogue(toml).unwrap_err();
1867        assert!(err.contains("empty arg_kinds"), "got: {err}");
1868    }
1869}