Skip to main content

fallow_security/
lib.rs

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