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