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