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 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
33/// Embedded catalogue source. Because it is `include_str!`-embedded at compile
34/// time, a green `security_catalogue_parses` test guarantees the released
35/// binary parses.
36const 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/// A raw untrusted-source row (issue #859). Names member-access paths that carry
47/// attacker-controlled input; the analyze layer matches captured tainted-binding
48/// source paths against these to mark source-tainted locals.
49#[derive(serde::Deserialize)]
50struct RawSource {
51    id: String,
52    title: String,
53    /// Optional framework enabler, same semantics as matcher enablers.
54    #[serde(default)]
55    enabler: Option<String>,
56    path_patterns: Vec<String>,
57    /// Optional allowlist of receiver names for leading-`*.` wildcard patterns
58    /// (issue #1092). When non-empty, a wildcard pattern fires only if the
59    /// matched member's receiver is one of these (case-insensitive), so
60    /// `*.query` matches `req.query` but not `db.query`. Empty / absent leaves
61    /// the row ungated (every receiver matches). Has no effect on exact
62    /// patterns, whose receiver is fixed in the pattern itself.
63    #[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    /// Kebab-case shape string, validated into [`SinkShape`].
74    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    /// Optional framework enabler: a package name that gates this row on the
81    /// active framework (issue #861). The plugin system already activates on the
82    /// declared dependency set, so a row carrying `enabler = "@angular/platform-browser"`
83    /// fires only when that package (or, with a trailing `/`, any package under
84    /// that prefix) is present in the project's declared dependencies. Lets a
85    /// framework-specific idiom (`bypassSecurityTrustHtml`, `dangerouslySetInnerHTML`)
86    /// be recognized with higher precision without a new enum variant. Unset means
87    /// the row is global (the prior behavior).
88    #[serde(default)]
89    enabler: Option<String>,
90    /// Optional allowlist of argument shapes. When set, the captured sink site's
91    /// `arg_kind` must be one of the listed kebab-case kinds for the matcher to
92    /// fire. Lets a matcher require the unsafe SQL shapes (`concat`,
93    /// `template-with-subst`) and exclude the safely-parameterized forms
94    /// (`object` for `.execute({ sql, args })`, the bare `sql` tag). Unset means
95    /// any non-literal argument shape matches (the prior behavior).
96    #[serde(default)]
97    arg_kinds: Option<Vec<String>>,
98    /// Optional string-literal equality predicates for literal-aware rows.
99    #[serde(default)]
100    literal_values: Option<Vec<String>>,
101    /// Optional string-literal substring predicates for literal-aware rows.
102    #[serde(default)]
103    literal_contains: Option<Vec<String>>,
104    /// Optional integer-literal equality predicates for literal-aware rows.
105    #[serde(default)]
106    literal_integers: Option<Vec<i64>>,
107    /// Optional object-literal property equality predicates.
108    #[serde(default)]
109    object_properties: Option<Vec<RawObjectPropertyPredicate>>,
110    /// Optional object-literal flags that are unsafe when missing or `false`.
111    #[serde(default)]
112    object_missing_or_false: Option<Vec<String>>,
113    /// Optional object-literal keys that are unsafe when absent. Unlike
114    /// `object_missing_or_false`, this checks key presence only and refuses
115    /// incomplete object shapes.
116    #[serde(default)]
117    object_missing: Option<Vec<String>>,
118    /// Optional context-name keywords for zero-arg sinks like `Math.random()`.
119    #[serde(default)]
120    context_keywords: Option<Vec<String>>,
121    /// Optional precision gate: require the captured sink argument to reference
122    /// a local binding that came from a configured untrusted source.
123    #[serde(default)]
124    requires_source: bool,
125    /// Optional precision gate narrowing `requires_source` to SPECIFIC source
126    /// kinds by catalogue source id (issue #890). Empty (default) admits any
127    /// matched source (the prior behavior); when set, the matched source's id
128    /// must be one of these. Lets `secret-to-network` fire only when backed by a
129    /// SECRET source (`process-env` / `import-meta-env`), not request input
130    /// (which the `ssrf` rows already cover).
131    #[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/// A pre-segmented callee pattern. Matching is segment-aware (NOT substring):
163/// the pattern is split on `.`, a leading `*` segment means "any object"
164/// (`*.innerHTML` matches `el.innerHTML` and `this.node.innerHTML` by
165/// suffix-matching the trailing non-`*` segments), and a trailing `*` segment
166/// means "any member" (`child_process.*` matches `child_process.exec` by
167/// prefix-matching the leading non-`*` segments). The security catalogue uses
168/// exact and leading-wildcard rows; the trailing form serves the boundary
169/// forbidden-call detector.
170#[derive(Debug, Clone)]
171pub struct CalleePattern {
172    /// The literal source pattern (`"*.innerHTML"`, `"child_process.exec"`),
173    /// surfaced in evidence rendering as `{pattern}`.
174    raw: String,
175    /// Segments between any leading and trailing `*` (e.g. `["innerHTML"]`
176    /// for `*.innerHTML`, `["child_process"]` for `child_process.*`,
177    /// `["child_process", "exec"]` for the exact dotted form).
178    suffix_segments: Vec<String>,
179    /// Whether the pattern began with a `*` wildcard object segment.
180    leading_wildcard: bool,
181    /// Whether the pattern ended with a `*` wildcard member segment.
182    trailing_wildcard: bool,
183}
184
185impl CalleePattern {
186    /// Parse a raw pattern string into its segmented form. Returns `None` for
187    /// an empty or whitespace-only pattern. Public constructor for non-security
188    /// reusers of the segment-aware matcher (the boundary forbidden-call
189    /// detector); the catalogue's own rows go through the same parser.
190    #[must_use]
191    pub fn parse(raw: &str) -> Option<Self> {
192        parse_callee_pattern(raw)
193    }
194
195    /// The original pattern text, for evidence templating.
196    #[must_use]
197    pub fn raw(&self) -> &str {
198        &self.raw
199    }
200
201    /// Segment-aware match against a captured dotted/bare callee path.
202    ///
203    /// With a leading `*`, the trailing segments must equal the tail of the
204    /// candidate's segments (suffix match), so `*.innerHTML` matches
205    /// `el.innerHTML` but not `el.innerHTMLFoo`. With a trailing `*`, the
206    /// leading segments must equal the head of the candidate's segments
207    /// (prefix match), so `child_process.*` matches `child_process.exec` but
208    /// not the bare `child_process`. Without either, the whole segment list
209    /// must match exactly, so `fetch` matches `fetch` but not `myfetch`.
210    /// Patterns carrying BOTH wildcards match nothing (rejected by the config
211    /// layer; never produced by catalogue rows).
212    #[must_use]
213    pub fn matches(&self, callee_path: &str) -> bool {
214        // With only wildcards and no concrete segments, match nothing.
215        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            // A leading `*.` requires at least one object segment before the
221            // suffix, so the candidate must have strictly more segments than
222            // the suffix (`*.innerHTML` matches `el.innerHTML`, not `innerHTML`).
223            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            // A trailing `.*` requires at least one member segment after the
233            // prefix (`child_process.*` matches `child_process.exec`, not the
234            // bare `child_process`).
235            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    /// The receiver segment immediately before this pattern's matched suffix,
254    /// for a leading-`*.` wildcard pattern: `*.query` against `db.query` returns
255    /// `Some("db")`, against `ctx.req.query` returns `Some("req")` (the segment
256    /// right before `query`, which is the receiver of the matched member). Used
257    /// by a source row's receiver allowlist to keep HTTP-input patterns from
258    /// firing on ORM / data-access receivers (issue #1092). Returns `None` for
259    /// an exact (non-wildcard) pattern, whose receiver is fixed in the pattern
260    /// itself, and for any `callee_path` this pattern does not match.
261    #[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        // `matches` guarantees `candidate.len() > suffix_segments.len()` for a
268        // leading-wildcard hit, so the receiver index is always in range.
269        let recv_idx = candidate.len() - self.suffix_segments.len() - 1;
270        candidate.get(recv_idx).copied()
271    }
272}
273
274/// Parse a raw pattern string into its segmented form. Returns `None` for an
275/// empty or whitespace-only pattern (rejected at parse time).
276fn 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/// A parsed, validated matcher with the sink shape resolved to the typed enum
298/// and callee patterns pre-segmented for O(1)-ish matching.
299#[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    /// Framework enabler package gate (issue #861). `None` = global row.
311    /// `Some("pkg")` requires an exact dependency match; `Some("@scope/")`
312    /// (trailing slash) requires any dependency under that prefix.
313    pub enabler: Option<String>,
314    /// Resolved allowlist of admitted argument shapes. `None` admits any
315    /// non-literal shape; `Some` requires the captured `arg_kind` to be listed.
316    pub arg_kinds: Option<Vec<SinkArgKind>>,
317    /// Whether this matcher only fires when the sink argument traces to a
318    /// configured untrusted source binding.
319    pub requires_source: bool,
320    /// When non-empty, narrows `requires_source` to these catalogue source ids
321    /// (issue #890): the matched source's id must be one of these. Empty admits
322    /// any matched source.
323    pub requires_source_kinds: Vec<String>,
324    /// String-literal values admitted by this row.
325    pub literal_values: Vec<String>,
326    /// String fragments admitted by this row.
327    pub literal_contains: Vec<String>,
328    /// Integer literal values admitted by this row.
329    pub literal_integers: Vec<i64>,
330    /// Required literal object properties.
331    pub object_properties: Vec<ObjectPropertyPredicate>,
332    /// Object properties whose absence or boolean `false` makes the row match.
333    pub object_missing_or_false: Vec<String>,
334    /// Object keys whose absence makes the row match.
335    pub object_missing: Vec<String>,
336    /// Context-name keywords admitted by this row.
337    pub context_keywords: Vec<String>,
338}
339
340/// A parsed, validated untrusted-source matcher (issue #859). Its
341/// `path_patterns` reuse the segment-aware [`CalleePattern`] engine: a leading
342/// `*.` matches any object prefix (`*.query` matches `req.query` and
343/// `ctx.req.query`); a bare path matches exactly.
344#[derive(Debug, Clone)]
345pub struct SourceMatcher {
346    id: String,
347    title: String,
348    enabler: Option<String>,
349    path_patterns: Vec<CalleePattern>,
350    /// Lowercased receiver allowlist for leading-wildcard patterns (issue
351    /// #1092). Empty leaves the row ungated.
352    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    /// Whether `pattern`'s match on `source_path` is admitted by the receiver
368    /// allowlist. An empty allowlist admits everything. For a leading-wildcard
369    /// pattern the matched receiver must be in the allowlist (case-insensitive);
370    /// an exact pattern (receiver fixed in the pattern) is always admitted.
371    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    /// Whether this source row's framework enabler is satisfied by the
392    /// project's declared dependency set. Unset means global.
393    #[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/// The parsed catalogue: an ordered list of sink matchers plus untrusted-source
400/// matchers. Order is preserved from the TOML so the detector can break on the
401/// first match deterministically.
402#[derive(Debug)]
403pub struct Catalogue {
404    matchers: Vec<Matcher>,
405    sources: Vec<SourceMatcher>,
406}
407
408impl Matcher {
409    /// The first callee pattern that matches the given path, if any. The first
410    /// match wins, matching the deterministic declaration order.
411    #[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    /// Whether a captured argument shape is admitted by this matcher. `None`
417    /// `arg_kinds` admits any shape; `Some` requires the kind to be listed.
418    #[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    /// Whether this row has opted into matching a literal, object-property, or
426    /// context-only sink that is not covered by the default non-literal model.
427    #[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    /// Whether captured literal metadata satisfies this row's literal gates.
444    #[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    /// Whether captured object-literal metadata satisfies this row's object
478    /// property gates.
479    #[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    /// Whether missing-key predicates are satisfied by complete static object
504    /// key metadata.
505    #[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    /// Whether captured context names satisfy this row's context keyword gate.
514    #[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    /// Whether this matcher's framework enabler is satisfied by the project's
528    /// declared dependency set (issue #861). `None` enabler is always satisfied
529    /// (a global row). A `Some` enabler matches by exact package name, or, when
530    /// it ends with `/`, by prefix (`@angular/` matches `@angular/platform-browser`),
531    /// mirroring the plugin-system `enablers()` semantics so framework rows
532    /// activate on exactly the dependency universe the plugins do.
533    #[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        // Trailing-slash prefix match, e.g. `@fastify/` -> `@fastify/static`.
545        // Also admit the bare scope name itself (`@fastify`).
546        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    /// All matchers in declaration order.
570    #[must_use]
571    pub fn matchers(&self) -> &[Matcher] {
572        &self.matchers
573    }
574
575    /// The id + human title of the first untrusted-source matcher whose pattern,
576    /// optional framework enabler, and configured request-receiver extension
577    /// match the given source path.
578    #[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    /// The human-readable title for a category id, if any matcher declares it.
601    #[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/// The human-readable title for a category id, used by the CLI renderer.
611#[must_use]
612pub fn catalogue_title(id: &str) -> Option<&'static str> {
613    catalogue().title_for(id)
614}
615
616/// The catalogue id of the secret-to-network exfil category (CWE-201). Like
617/// [`HARDCODED_SECRET_CATEGORY_ID`], it is include-required: it runs only when
618/// listed in `security.categories.include`.
619const SECRET_TO_NETWORK_CATEGORY_ID: &str = "secret-to-network";
620
621/// Whether a `security.categories` id is include-required, i.e. it stays off
622/// even when no include list is set and fires only when named in
623/// `categories.include`. Both the standalone hardcoded-secret detector and the
624/// secret-to-network catalogue category are include-required.
625#[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/// A user-facing security candidate category, valid in `security.categories`
631/// `include` / `exclude`.
632#[derive(Debug, Clone)]
633pub struct SecurityCategory {
634    /// The category id used in `security.categories.include` / `exclude`.
635    pub id: String,
636    /// Human-readable title.
637    pub title: String,
638    /// The CWE number, when the category maps to one (`None` for the
639    /// entropy-based hardcoded-secret detector).
640    pub cwe: Option<u32>,
641    /// Whether the category runs only when explicitly named in
642    /// `categories.include`.
643    pub include_required: bool,
644}
645
646/// Every security candidate category an agent can name in
647/// `security.categories.include` / `exclude`, deduped by id and sorted.
648///
649/// This is the canonical, machine-readable vocabulary for the `security`
650/// config surface: the embedded catalogue's distinct sink categories plus the
651/// standalone hardcoded-secret detector. Because the catalogue is
652/// `include_str!`-embedded, the set is deterministic per build.
653#[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
679/// Resolve a kebab-case sink-shape string into the typed [`SinkShape`].
680fn 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
692/// Resolve a kebab-case arg-kind string into the typed [`SinkArgKind`].
693fn 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
747/// Parse + validate the catalogue source. Returns a `Result` (NOT a panic) so
748/// the validation tests can assert on error messages; `catalogue()` unwraps it.
749///
750/// Validates: non-empty id; cwe > 0; sink_shape resolves; callee_patterns
751/// non-empty and every pattern non-empty/non-whitespace; non-empty
752/// evidence_template.
753fn 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
771/// Validate one raw matcher entry and convert it to a `Matcher`. Validates a
772/// non-empty id, cwe > 0, a resolvable sink_shape, non-empty callee_patterns /
773/// arg_kinds / evidence_template, and a non-empty enabler when present.
774fn 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
803/// Validate a matcher's scalar fields (id, cwe, evidence_template) and parse its
804/// sink_shape plus non-empty callee_patterns.
805fn 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
844/// Validate the optional `enabler`: present but empty / whitespace is rejected;
845/// absent or non-empty passes through unchanged.
846fn 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
855/// Parse the optional `arg_kinds` list: `None` admits any shape, an empty list
856/// is rejected, and each entry must resolve to a known `ArgKind`.
857fn 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/// Parse and cache the embedded catalogue once. Unwraps the parse `Result`; in
945/// a released binary this is unreachable because the bytes are compile-time
946/// embedded and gated by `security_catalogue_parses`.
947///
948/// # Panics
949///
950/// Panics if the embedded matcher catalogue does not parse.
951#[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    /// Source lookup through the production method, with no extra request
976    /// receivers.
977    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    /// Whether the production lookup finds a source with no declared dependencies.
990    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        // deduped by id
999        let mut ids = FxHashSet::default();
1000        for c in &cats {
1001            assert!(ids.insert(c.id.clone()), "duplicate category id {}", c.id);
1002        }
1003        // sorted by id
1004        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        // both include-required categories present and flagged; hardcoded-secret
1015        // carries no CWE (entropy detector).
1016        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        // a normal category is NOT include-required
1025        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        // Multiple rows legitimately share an `id` (dangerous-html spans three
1055        // shapes), so uniqueness is keyed on the FULL row: id + sink_shape +
1056        // callee_patterns + gates. No two identical matcher rows. Keyed off the
1057        // raw source so the test does not require `SinkShape: Hash`.
1058        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            // Uniqueness includes the enabler: framework-scoped rows (#861) may
1063            // legitimately share id + shape + patterns and differ only by their
1064            // framework gate (e.g. one `route-send-file` row per framework).
1065            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        // Every parsed matcher already carries a typed SinkShape, so re-parse
1130        // the raw source to assert the kebab strings all resolve.
1131        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")); // wildcard requires an object
1307
1308        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        // Guard against a degenerate `*` pattern matching every callee.
1318        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")); // requires a member
1329        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        // `*.x.*` and `*.*` are rejected by config validation; the matcher
1340        // guards against them anyway.
1341        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        // A matcher with no arg_kinds (e.g. dangerous-html) admits every shape.
1350        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        // The `.query` / `.execute` matchers must require unsafe shapes (concat /
1371        // interpolated template) and reject the parameterized object-literal form
1372        // (`.execute({ sql, args })`). The separate `sql.raw` escape-hatch row is
1373        // intentionally shape-agnostic and is excluded from this check.
1374        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        // A matcher with no enabler is satisfied by ANY (even empty) dep set.
1500        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        // Exact match.
1519        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        // Trailing-slash prefix match, plus the bare scope name.
1526        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        // A near-miss exact name does not satisfy a prefix-less enabler.
1539        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        // The framework-scoped rows added in #861 carry an enabler.
1551        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        // Issue #859: the embedded catalogue ships at least one [[source]] row,
1592        // each with a non-empty id, title, and path_patterns.
1593        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        // Wildcard object prefix matches common framework request accessors.
1609        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        // A plain object path that is not an untrusted source does not match.
1620        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        // Leading-wildcard `*.query`: the receiver is the segment right before
1641        // the matched `query`, regardless of how many object segments precede.
1642        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        // Hono `c.req.query` flattens so the receiver of `.query` is `req`.
1646        assert_eq!(pat.matched_receiver("ctx.req.query"), Some("req"));
1647        // A non-matching path has no receiver.
1648        assert_eq!(pat.matched_receiver("req.body"), None);
1649        // An exact (non-wildcard) pattern's receiver is fixed in the pattern, so
1650        // `matched_receiver` returns None even on a match.
1651        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        // Issue #1092: the global HTTP-input row is receiver-gated. ORM /
1658        // data-access receivers no longer classify their module as a source...
1659        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        // ...nor do non-request receivers that merely happen to have a `.query`
1666        // member (a sibling-collision check: `dbConn` is not `db`).
1667        assert!(!is_source(cat, "dbConn.query"));
1668        assert!(!is_source(cat, "database.params"));
1669        // A genuine request receiver still classifies as a source.
1670        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        // Hono `c.req.query`: the matched receiver is `req`, which is allowed.
1676        assert!(is_source(cat, "ctx.req.query"));
1677        // The allowlist is case-insensitive.
1678        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        // Issue #1092: `*.searchParams` is intentionally NOT receiver-gated, so a
1708        // `new URL(...).searchParams` binding on an arbitrary local still counts.
1709        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}