Skip to main content

fallow_security/
lib.rs

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