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