new capacity bits (#67)

This commit is contained in:
Eli Peter 2026-05-07 01:29:31 -04:00 committed by GitHub
parent afaffc0df6
commit 7d0e7320e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
261 changed files with 10591 additions and 231 deletions

View file

@ -67,6 +67,30 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::SSRF),
case_sensitive: false,
},
// ─── LDAP injection sinks ───
//
// OpenLDAP / libldap surface: `ldap_search_s(ld, base, scope, filter, ...)`
// and the asynchronous variant `ldap_search_ext_s(ld, base, scope, filter,
// attrs, attrsonly, serverctrls, clientctrls, timeout, sizelimit, *res)`.
// The filter argument (position 3) is the LDAP-injection vector. No
// standard libldap escape helper exists in the C surface; sanitisation is
// typically caller-implemented (`sanitize_*` covers the developer-named
// case via the existing prefix rule above).
LabelRule {
matchers: &["ldap_search_s", "ldap_search_ext_s"],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: false,
},
// ─── XPath injection sinks ───
//
// libxml2 evaluation entry points: `xmlXPathEvalExpression(expr, ctx)`,
// `xmlXPathEval(expr, ctx)`, `xmlXPathCompile(expr)`. The expression
// string is arg 0 and is the canonical XPath-injection vector.
LabelRule {
matchers: &["xmlXPathEvalExpression", "xmlXPathEval", "xmlXPathCompile"],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: false,
},
];
/// Gated sinks for C.

View file

@ -89,6 +89,24 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::SSRF),
case_sensitive: false,
},
// ─── LDAP injection sinks ───
//
// OpenLDAP / libldap C interface (also used from C++ wrappers): the filter
// argument carries attacker-controlled data unless explicitly escaped.
LabelRule {
matchers: &["ldap_search_s", "ldap_search_ext_s"],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: false,
},
// ─── XPath injection sinks ───
//
// libxml2 (the dominant C++ XML parser surface): `xmlXPathEvalExpression`,
// `xmlXPathEval`, `xmlXPathCompile` accept the expression string as arg 0.
LabelRule {
matchers: &["xmlXPathEvalExpression", "xmlXPathEval", "xmlXPathCompile"],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: false,
},
];
/// Gated sinks for C++.

View file

@ -148,6 +148,97 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::CRYPTO),
case_sensitive: false,
},
// ─── LDAP injection sinks ───
//
// go-ldap (`github.com/go-ldap/ldap/v3`): `conn, _ := ldap.DialURL(url);
// req := ldap.NewSearchRequest(base, scope, deref, sizeLimit, timeLimit,
// typesOnly, filter, attrs, controls)`. The filter argument (position 6)
// is the LDAP-injection vector; passing the request to `conn.Search(req)`
// executes the filter. Type-qualified resolution rewrites `conn.Search`
// → `LdapClient.Search` when the receiver was returned by
// `ldap.DialURL` / `ldap.Dial` / `ldap.DialTLS` (see
// [`crate::ssa::type_facts::constructor_type`]). We also tag
// `ldap.NewSearchRequest` directly so taint reaching the filter argument
// surfaces at the construction call (matches the typical FP-free shape
// where the request is built once and passed straight to `Search`).
LabelRule {
matchers: &[
"LdapClient.Search",
"LdapClient.SearchWithPaging",
"ldap.NewSearchRequest",
],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── LDAP-filter sanitizer ───
//
// go-ldap exposes `ldap.EscapeFilter(s string) string` (RFC 4515 metachar
// escaping). Treat any call as clearing the LDAP_INJECTION cap.
LabelRule {
matchers: &["ldap.EscapeFilter"],
label: DataLabel::Sanitizer(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── Header / CRLF injection sinks ───
//
// `net/http` `ResponseWriter.Header()` returns a `Header` map; calls to
// `Set(name, val)` / `Add(name, val)` write a single header value.
// After paren-group stripping the chain text becomes
// `w.Header.Set` / `w.Header.Add`, so suffix matchers on `Header.Set` /
// `Header.Add` cover both the bound-receiver form (`w.Header().Set(...)`)
// and the documentation-style class-qualified form (`Header.Set`).
// Tainted strings without `\r\n` stripping enable response splitting.
LabelRule {
matchers: &["Header.Set", "Header.Add"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: true,
},
// ─── Header / CRLF sanitizers ───
//
// Project-local `stripCRLF` / `escapeHeader` helpers that strip `\r` and
// `\n` from a value before it is written to a response header.
LabelRule {
matchers: &["stripCRLF", "stripCrlf", "escapeHeader", "sanitizeHeader"],
label: DataLabel::Sanitizer(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Open redirect sinks ───
//
// `net/http` `http.Redirect(w, r, url, code)` writes a `Location` header
// and a 3xx status from the supplied URL. Without an allowlist check,
// a tainted `url` is the canonical Go open-redirect vector.
LabelRule {
matchers: &["http.Redirect"],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
LabelRule {
matchers: &[
"validateRedirectUrl",
"isSafeRedirect",
"stripScheme",
"ensureRelativeUrl",
"assertRelativePath",
"isRelativeUrl",
],
label: DataLabel::Sanitizer(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
// ─── SSTI sinks ───
//
// `text/template` and `html/template` parse a template source string via
// `template.New(name).Parse(src)`. After paren-group stripping the chain
// text becomes `template.New.Parse`, so the suffix matcher catches both
// packages (`text/template`, `html/template`) regardless of import alias.
// `template.ParseFiles` / `ParseGlob` take file paths (path-traversal,
// not SSTI) and are intentionally excluded. `html/template`'s auto-
// escaping applies during `Execute`, not `Parse`, so a tainted source
// string still yields SSTI.
LabelRule {
matchers: &["template.New.Parse"],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: false,
},
];
/// Argument-role-aware Go sinks. Two classes coexist on the outbound HTTP

View file

@ -1,4 +1,6 @@
use crate::labels::{Cap, DataLabel, Kind, LabelRule, ParamConfig, RuntimeLabelRule};
use crate::labels::{
Cap, DataLabel, GateActivation, Kind, LabelRule, ParamConfig, RuntimeLabelRule, SinkGate,
};
use crate::utils::project::{DetectedFramework, FrameworkContext};
use phf::{Map, phf_map};
@ -265,6 +267,223 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::CODE_EXEC),
case_sensitive: false,
},
// ─── LDAP injection sinks ───
//
// JNDI / Spring LDAP search APIs accept an attacker-influenceable filter
// expression as either the second positional argument (`DirContext.search(name,
// filter, controls)` / `LdapTemplate.search(base, filter, mapper)`). Without
// RFC 4515 escaping the filter can be rewritten to bypass authentication or
// exfiltrate directory entries. Type-qualified resolution rewrites
// `ctx.search(...)` → `LdapClient.search` when the receiver carries a
// `TypeKind::LdapClient` fact (set by `class_name_to_type_kind` for the
// declared types `DirContext`, `InitialDirContext`, `LdapContext`,
// `LdapTemplate`, or by `constructor_type` for `new InitialDirContext(...)`
// / `new InitialLdapContext(...)`). Direct flat matchers cover the
// documentation-style class-qualified call forms that bypass receiver
// typing.
LabelRule {
matchers: &[
"LdapClient.search",
"LdapClient.searchByEntity",
"LdapClient.searchForObject",
"LdapClient.searchForContext",
"DirContext.search",
"LdapTemplate.search",
"LdapTemplate.searchByEntity",
"LdapTemplate.searchForObject",
"LdapTemplate.searchForContext",
"ctx.search",
],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── LDAP-filter sanitizers ───
//
// Spring LDAP's `LdapEncoder.filterEncode(s)` applies RFC 4515 escaping to
// metacharacters (`\`, `*`, `(`, `)`, ``). `nameEncode` performs the
// companion DN-component escaping. Both fully clear the LDAP_INJECTION
// cap; downstream sinks see a sanitised value.
LabelRule {
matchers: &["LdapEncoder.filterEncode", "LdapEncoder.nameEncode"],
label: DataLabel::Sanitizer(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── XPath injection sinks ───
//
// `javax.xml.xpath.XPath.evaluate(expr, source, ...)` and the matching
// `XPathExpression.evaluate(source)` accept an attacker-influenceable
// expression string. Without parameterisation via
// `XPathVariableResolver` the expression can be rewritten to bypass
// authentication or exfiltrate document subtrees. `XPath.compile(expr)`
// is the equivalent pre-compile entry point. Direct flat matchers cover
// the documentation-style class-qualified call forms.
LabelRule {
matchers: &[
"XPath.evaluate",
"XPath.compile",
"XPathExpression.evaluate",
"xpath.evaluate",
"xpath.compile",
],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// ─── XPath escape sanitizers ───
//
// OWASP ESAPI's `Encoder.encodeForXPath(s)` escapes the XPath
// metacharacters (`'`, `"`, `[`, `]`, `(`, `)`, `,`, `=`, `<`, `>`,
// `*`). Project-local `xpathEscape` / `escapeXpath` are the common
// developer-named equivalents.
LabelRule {
matchers: &["Encoder.encodeForXPath", "xpathEscape", "escapeXpath"],
label: DataLabel::Sanitizer(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// Parameterised XPath via `XPath.setXPathVariableResolver(resolver)`
// suppression is implemented as a receiver-config sidecar in
// [`crate::ssa::xpath_config::XPathConfigResult`]: a
// `setXPathVariableResolver` call on a receiver carrying
// `TypeKind::XPathClient` flips the receiver's `has_resolver` flag,
// and the SSA sink-emission site strips `Cap::XPATH_INJECTION` from
// any later `xpath.evaluate(taintedExpr, ...)` whose receiver is
// provably bound. No flat sanitizer rule is needed (and a
// name-only rule would clear the wrong call site).
// ─── Header / CRLF injection sinks ───
//
// `HttpServletResponse.setHeader(name, val)` / `addHeader(name, val)`
// accept a single header value; tainted strings without `\r\n` stripping
// let an attacker inject extra headers (response splitting).
// `addCookie(c)` carries a `Cookie` whose constructor takes a value
// string; track at the higher-level setHeader / addHeader entry points.
LabelRule {
matchers: &["setHeader", "addHeader", "addCookie"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF sanitizers ───
LabelRule {
matchers: &["stripCRLF", "stripCrlf", "escapeHeader", "sanitizeHeader"],
label: DataLabel::Sanitizer(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Open redirect sinks ───
//
// Servlet API: `HttpServletResponse.sendRedirect(url)`. Spring MVC
// controllers can also return a `"redirect:"` prefixed string but that
// sink shape is not modelled here.
LabelRule {
matchers: &["sendRedirect"],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
LabelRule {
matchers: &[
"validateRedirectUrl",
"isSafeRedirect",
"stripScheme",
"ensureRelativeUrl",
"assertRelativePath",
"isRelativeUrl",
],
label: DataLabel::Sanitizer(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
// ─── SSTI sinks ───
//
// Apache FreeMarker `Template.process(model, writer)` renders an
// already-parsed template; the SSTI vector is when the template source
// is attacker-influenced (e.g. `new Template(name, new StringReader(src), cfg)`).
// The flat matcher fires only when the receiver chain text resolves to
// `Template.process` — typically through a `Template`-typed declared
// receiver routed via type-qualified resolution. Without a `Template`
// TypeKind, idiomatic `Template tpl = new Template(...); tpl.process(...)`
// shapes are not recognised; tracked under deferred phases.
//
// Apache Velocity `Velocity.evaluate(ctx, writer, tag, src)` is modelled
// as a gated sink in `GATED_SINKS` below so only the template-source
// arg (index 3) activates SSTI; tainted variables in the `ctx` arg
// (data) stay clean.
LabelRule {
matchers: &["Template.process"],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: true,
},
// ─── XXE sinks ───
//
// Java's stock XML parsers (JAXP) are XXE-vulnerable by default: the
// factories ship with external-entity / DTD resolution enabled and only
// become safe after `setFeature(FEATURE_SECURE_PROCESSING, true)` /
// disabling `external-general-entities` / `external-parameter-entities`.
// Tainted XML reaching any of these parser entry points is treated as
// an XXE flow; a config-check sanitizer pass (Phase XXE Layer 2) is
// out of scope for this rule and is the follow-up listed in
// `.pitboss/play/deferred.md`.
//
// Class-qualified suffix matching covers both the documentation-style
// `javax.xml.parsers.DocumentBuilder.parse(...)` form and the bound-
// receiver `XmlParser.parse(...)` form (when the receiver's TypeKind
// resolves to `XmlParser`). Bare `parse` is intentionally avoided to
// prevent collisions with `Integer.parseInt`, `LocalDate.parse`,
// generic JSON parsers, etc.
LabelRule {
matchers: &[
"DocumentBuilder.parse",
"SAXParser.parse",
"XMLReader.parse",
"SAXBuilder.build",
"XmlParser.parse",
"XmlParser.build",
],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
},
// ─── XXE config-setter sanitizers ───
//
// Phase 07: a JAXP `setFeature(...)` / `setExpandEntityReferences(...)`
// call is itself a label-level Sanitizer for `Cap::XXE` so that the
// *call's return value* (rare but exists for fluent factory APIs)
// does not carry XXE through it. The real load-bearing suppression
// is the receiver-fact path in
// [`crate::ssa::xml_config::XmlParserConfigResult`], which the SSA
// sink emission consults at every parse-class sink site. This rule
// is conservative noise reduction for downstream sinks that consume
// the setter call's value.
LabelRule {
matchers: &[
"setFeature",
"setExpandEntityReferences",
"setXIncludeAware",
"setValidating",
],
label: DataLabel::Sanitizer(Cap::XXE),
case_sensitive: true,
},
];
/// Java gated sinks. Argument-position-aware classification for callees
/// where the SSTI activation is restricted to the template-source arg
/// rather than every positional argument.
pub static GATED_SINKS: &[SinkGate] = &[
// Apache Velocity static API: `Velocity.evaluate(ctx, writer, logTag, src)`.
// Arg 3 carries the inline template source; tainted text at that
// position is SSTI. Tainted data in the context (arg 0) is rendered
// through Velocity's escape policy, not parsed as template source, so
// those flows must not activate SSTI. Activation is unconditional;
// payload_args narrows the cap to the template-source position.
SinkGate {
callee_matcher: "Velocity.evaluate",
arg_index: 3,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: true,
payload_args: &[3],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {

View file

@ -310,6 +310,178 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::SQL_QUERY),
case_sensitive: true,
},
// ─── LDAP injection sinks ───
//
// `ldapjs`: both the bound-variable idiom
// `const client = ldap.createClient({...}); client.search(...)` and the
// chained idiom `ldap.createClient({...}).search(...)` are covered by
// type-qualified receiver resolution. The receiver of the inner call is
// typed `TypeKind::LdapClient` via `ssa::type_facts::constructor_type`,
// and (for the bound-variable form) closure-captured types are forwarded
// into the per-function type-fact result by
// [`crate::taint::inject_external_type_facts`], so the qualified callee
// text resolves to `LdapClient.search` in both shapes.
LabelRule {
matchers: &["LdapClient.search"],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── LDAP-filter sanitizers ───
//
// The `ldap-escape` package exports `filter` and `dn` tagged-template
// helpers (`filter`\`(uid=${input})\``). After tree-sitter lifts the
// template-tag identifier, the callee text is the function name; suffix
// matching on `ldapEscape` / `ldapescape` covers `const ldapEscape =
// require('ldap-escape')` plus default-import shapes.
LabelRule {
matchers: &[
"ldapEscape",
"ldap-escape",
"ldapescape.filter",
"ldapescape.dn",
],
label: DataLabel::Sanitizer(Cap::LDAP_INJECTION),
case_sensitive: false,
},
// ─── XPath injection sinks ───
//
// `document.evaluate(expr, contextNode, ...)` (DOM) and the npm `xpath`
// package's `xpath.select(expr, doc)` / `xpath.evaluate(expr, doc, ...)`
// accept the expression string as arg 0; concatenated user input there
// is the canonical XPath-injection vector.
LabelRule {
matchers: &[
"document.evaluate",
"xpath.select",
"xpath.evaluate",
"xpath.select1",
],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// ─── XPath escape sanitizers ───
//
// No standard library helper escapes XPath metacharacters; project-local
// `escapeXpath` / `xpathEscape` are the developer-named equivalents.
LabelRule {
matchers: &["escapeXpath", "xpathEscape", "escape_xpath"],
label: DataLabel::Sanitizer(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF injection sinks ───
//
// Express/Fastify/Node `http` response APIs that write a single header
// value: `res.setHeader(name, val)` (case-insensitive verb), `res.set`,
// `res.header`, `res.append`. Tainted strings here without `\r\n`
// stripping let an attacker inject extra headers (response splitting).
LabelRule {
matchers: &["setHeader", "res.set", "res.header", "res.append"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// Subscript-set form: `res.headers["X-Foo"] = bar` /
// `response.headers["X-Foo"] = bar`. The LHS-subscript classification
// path in `cfg/mod.rs::push_node` walks into the subscript's `object`
// and classifies its member-expression text, so the bare bracket form
// fires alongside `setHeader` / `res.set` / `res.header` / `res.append`.
LabelRule {
matchers: &["res.headers", "response.headers", "self.response.headers"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF sanitizers ───
//
// Project-local `stripCRLF` / `escapeHeader` helpers that strip `\r` and
// `\n` from a value before it is written to a response header.
LabelRule {
matchers: &["stripCRLF", "stripCrlf", "escapeHeader", "sanitizeHeader"],
label: DataLabel::Sanitizer(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Prototype pollution sinks (library-mediated) ───
//
// Recursive merge / deep-assign helpers from lodash / common bundles.
// Argument-role gating (target vs src) is enforced via Destination
// activation in `GATED_SINKS` below: only taint flowing into the
// source-object arguments (positions 1+) activates; tainted-target-
// only is benign because writes to a tainted target object don't
// pollute `Object.prototype`. Flat rules here are intentionally
// empty for the merge family; see GATED_SINKS for the per-call
// gating. `_.template` is excluded — it is handled separately as
// a gated CODE_EXEC sink (Strapi CVE-2023-22621 evaluate:false
// suppression).
// ─── Open redirect sinks ───
//
// Express response redirect: `res.redirect(url)`. Browser-side
// navigation: `location.replace` / `location.assign` fire as direct
// calls; `window.location = url` / `window.location.href = url` /
// `location.href = url` fire as assignment-LHS sinks via the
// `member_expr_text` classification path in `cfg::push_node`.
// `router.navigate` covers the Angular Router (`Router.navigate`,
// `Router.navigateByUrl`) and the React-Router `useNavigate`-returned
// `navigate` function; suffix matching catches both the bound-receiver
// and direct-call shapes.
LabelRule {
matchers: &[
"res.redirect",
"location.replace",
"location.assign",
"router.navigate",
"router.navigateByUrl",
"window.location",
"window.location.href",
"location.href",
],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
// ─── Open-redirect URL allowlist sanitizers ───
//
// Project-local helpers that allowlist hosts or enforce relative-only
// URLs. `validateRedirectUrl` / `isSafeRedirect` are the canonical
// developer-named allowlist helpers; `stripScheme` clears any absolute
// scheme and degrades the URL to a relative path. `ensureRelativeUrl`
// / `assertRelativePath` cover the leading-slash / no-scheme idiom.
LabelRule {
matchers: &[
"validateRedirectUrl",
"isSafeRedirect",
"stripScheme",
"ensureRelativeUrl",
"assertRelativePath",
"isRelativeUrl",
],
label: DataLabel::Sanitizer(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
// ─── SSTI sinks ───
//
// Template-engine entry points that accept the template *source string*
// as the first argument: tainted arg 0 lets the attacker drive
// arbitrary template execution. `_.template` is excluded — it has
// its own gated CODE_EXEC classifier (Strapi CVE-2023-22621) that
// respects the `evaluate:false` opt-out. `nunjucks.renderString` is
// also excluded — see GATED_SINKS below for arg-0-only payload
// gating (suppresses tainted-`ctx`-only flows).
LabelRule {
matchers: &["Handlebars.compile"],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: false,
},
// ─── XXE sinks ───
//
// libxmljs `parseXmlString` / `parseXml` resolve external entities by
// default when called with `{ noent: true }` or
// `{ replaceEntities: true }`. The flat-rule modeling treats any call
// as a sink, the safe path requires explicit option suppression.
// libxmljs's own default ignores entities so the sink is conservative
// here; xml2js / fast-xml-parser are gated below in GATED_SINKS to
// suppress the safe-default case.
LabelRule {
matchers: &["libxmljs.parseXmlString", "libxmljs.parseXml"],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
},
];
/// Callee patterns that must never be classified as source/sanitizer/sink.
@ -420,6 +592,33 @@ pub static GATED_SINKS: &[SinkGate] = &[
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// ── XML XXE gates ─────────────────────────────────────────────────────
//
// `xml2js.parseString(xml, opts, cb)` is XXE-safe by default; opts
// `{ explicitChildren: true, charkey: '__cdata' }` are benign, but
// resolving entities at the underlying sax-js layer requires user
// intent. The gate fires only when the option object literal carries
// an entity-resolution kwarg with a truthy value (or is dynamic). Only
// the XML payload (arg 0) is the protected position.
SinkGate {
callee_matcher: "xml2js.parseString",
arg_index: 1,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[
("processEntities", &["true"]),
("explicitEntities", &["true"]),
("strict", &["false"]),
],
activation: GateActivation::ValueMatch,
},
// Note: `fast-xml-parser` (`new XMLParser({...}).parse(xml)`) is XXE-safe
// by default; flagging it would require constructor-option tracking via
// TypeFacts (XmlParser type with config carry). Deferred to Layer 2.
// ── Outbound HTTP clients (SSRF) ──────────────────────────────────────
//
// Policy: SSRF fires only when taint reaches the destination-bearing
@ -797,6 +996,282 @@ pub static GATED_SINKS: &[SinkGate] = &[
object_destination_fields: &[],
},
},
// `nunjucks.renderString(src, ctx)` — Nunjucks SSTI sink. Only the
// template *source* (arg 0) lets an attacker drive template execution;
// the `ctx` data object (arg 1) is rendered via the template's escape
// policy and is not itself a code-injection vector. Gate via
// Destination-style activation with `payload_args: &[0]` so taint
// flowing only into `ctx` is suppressed.
SinkGate {
callee_matcher: "nunjucks.renderString",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: false,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// ── Prototype pollution gates ────────────────────────────────────────
//
// Library-mediated recursive merge / deep-assign helpers. Argument-
// role gating: `(target, src1, src2, ...)` — only taint reaching a
// *source* position (index 1+) can pollute `Object.prototype` via
// `__proto__` / `constructor` keys on attacker-controlled input.
// Tainted target alone is benign (it just mutates that object).
// `payload_args: &[1, 2, 3, 4, 5]` covers the canonical 1-target +
// up-to-5-source signatures used by lodash / Object.assign / jQuery
// extend; arity beyond 5 is rare in practice and would over-suppress
// only at the long tail.
SinkGate {
callee_matcher: "_.merge",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "_.mergeWith",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "_.defaultsDeep",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// `_.set(obj, path, value)` — both `path` (arg 1) and `value` (arg 2)
// can drive prototype pollution: a tainted path of `__proto__.foo`
// mutates `Object.prototype`, and a tainted value into `obj.__proto__`
// does the same. Object (arg 0) is the canonical target.
SinkGate {
callee_matcher: "_.set",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "_.setWith",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// Generic project-local deep-merge helpers. Suffix-matched so any
// `*.deepMerge` / `*.defaultsDeep` qualified call also resolves.
SinkGate {
callee_matcher: "deepMerge",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "defaultsDeep",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// `Object.assign(target, ...sources)` is safe with constant-literal
// sources (`{a: 1, b: 2}`) but dangerous with attacker-controlled
// input (`req.body`). Gate target out of payload_args so tainted-
// target alone does not fire.
SinkGate {
callee_matcher: "Object.assign",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// jQuery / Zepto `$.extend(target, ...sources)` and `jQuery.extend`.
// Arg 0 may be a deep-flag boolean (`true`) when the deep-merge form
// is in use, in which case sources start at arg 2. Cover both
// shapes by listing arg 1, 2, 3, 4 in `payload_args`: a `true` first
// arg never carries taint, so its inclusion is harmless; for the
// shallow `$.extend(target, src)` form, src at arg 1 still fires.
SinkGate {
callee_matcher: "$.extend",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2, 3, 4],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "jQuery.extend",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2, 3, 4],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// Bare `extend` (suffix-matched) for jQuery's deep form imported as a
// bound name: `const { extend } = require('jquery'); extend(true, t, s)`.
// Suffix `extend` would over-fire on Backbone's `Model.extend(proto)` /
// `View.extend({...})` class-extension idiom, so this gate uses
// `LiteralOnly` activation: it fires only when arg 0 is the literal
// boolean `true` (the deep-flag form, never used by Backbone subclassing).
// Sources start at arg 2 because arg 0 is the flag and arg 1 is the
// target; tainting the target alone is benign.
SinkGate {
callee_matcher: "extend",
arg_index: 0,
dangerous_values: &["true"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::LiteralOnly,
},
// `set-value` standalone helper: `setValue(obj, key, val)` — historic
// CVE-2019-10747 (set-value <2.0.1) and CVE-2021-23440 (set-value <4.0.1)
// recursive set-by-path helper that did not block `__proto__` keys.
// Suffix-matched so qualified imports (`require('set-value')`) bound to
// `setValue` still resolve.
SinkGate {
callee_matcher: "setValue",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// `dot-prop` standalone helper: `dotProp.set(obj, path, val)` —
// CVE-2020-8116. Path is a dotted-string with prototype-key support;
// a tainted `path` of `__proto__.x` mutates Object.prototype.
SinkGate {
callee_matcher: "dotProp.set",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// `JSONPath` / `jsonpath-plus` `JSONPath({path: p, json: o, callback: fn})`
// historically supported a `resultType: 'value'` mode that, combined with
// `parent`/`parentProperty` writes inside the callback, can mutate the
// prototype chain. Recognise the `jp.set(obj, path, value)` family
// (jsonpath, jsonpath-plus) on the same shape as `_.set`.
SinkGate {
callee_matcher: "jp.set",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "jsonpath.set",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {

View file

@ -66,6 +66,17 @@ pub enum GateActivation {
/// selects which attribute is being set) and `parseFromString` (activation
/// arg selects the MIME type).
ValueMatch,
/// Strict literal-value activation. The gate fires only when the
/// activation arg is a literal that matches `dangerous_values` /
/// `dangerous_prefixes`. Unknown/dynamic activation arg suppresses
/// (no conservative ALL_ARGS_PAYLOAD push).
///
/// Used for ambiguously-named matchers where the dangerous shape is
/// only identifiable by an explicit literal flag — e.g. bare `extend`
/// where the deep-merge form is `extend(true, target, src)` but
/// Backbone's `Model.extend({proto})` shares the suffix. Conservative
/// fallback would over-fire on the class-extension form.
LiteralOnly,
/// Destination-bearing flow activation. The gate fires when taint reaches
/// a declared destination location at the call site, no literal
/// inspection, no prefix heuristic.
@ -156,53 +167,83 @@ bitflags! {
/// In practice: a finding fires when a tainted value reaches a sink and
/// `(value_caps & sink_caps) != 0`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cap: u16 {
pub struct Cap: u32 {
/// Taint that originated from an environment variable read.
/// Used as a source-origin marker for env-injection rules.
const ENV_VAR = 0b0000_0000_0000_0001; // bit 0
const ENV_VAR = 1 << 0;
/// Sanitizer: the value has passed through HTML entity escaping.
/// Strips XSS risk from values that reach HTML output sinks.
const HTML_ESCAPE = 0b0000_0000_0000_0010; // bit 1
const HTML_ESCAPE = 1 << 1;
/// Sanitizer: the value has been shell-argument escaped.
/// Strips command-injection risk before shell sinks.
const SHELL_ESCAPE = 0b0000_0000_0000_0100; // bit 2
const SHELL_ESCAPE = 1 << 2;
/// Sanitizer: the value has been percent-encoded for use in a URL.
const URL_ENCODE = 0b0000_0000_0000_1000; // bit 3
const URL_ENCODE = 1 << 3;
/// Sanitizer: the value was parsed through a structured JSON decoder
/// (as opposed to `eval`-based or regex parsing).
const JSON_PARSE = 0b0000_0000_0001_0000; // bit 4
const JSON_PARSE = 1 << 4;
/// Sink: file system read or write operation (path traversal, arbitrary
/// file read/write).
const FILE_IO = 0b0000_0000_0010_0000; // bit 5
const FILE_IO = 1 << 5;
/// Sink: format string injection (e.g. `printf`-family, `String.format`).
const FMT_STRING = 0b0000_0000_0100_0000; // bit 6
const FMT_STRING = 1 << 6;
/// Sink: SQL query construction. Fires for string-concatenated queries
/// and parameterized-query builders where the query text itself is tainted.
const SQL_QUERY = 0b0000_0000_1000_0000; // bit 7
const SQL_QUERY = 1 << 7;
/// Sink: unsafe object deserialization (Java `ObjectInputStream`,
/// Python `pickle`, Ruby `Marshal`, PHP `unserialize`, etc.).
const DESERIALIZE = 0b0000_0001_0000_0000; // bit 8
const DESERIALIZE = 1 << 8;
/// Sink: server-side request forgery. Fires when attacker-controlled
/// data reaches the destination URL of an outbound HTTP request.
const SSRF = 0b0000_0010_0000_0000; // bit 9
const SSRF = 1 << 9;
/// Sink: code or command execution (shell injection, `eval`, `exec`,
/// dynamic `require`/`import`, template injection).
const CODE_EXEC = 0b0000_0100_0000_0000; // bit 10
const CODE_EXEC = 1 << 10;
/// Sink: cryptographic operation with a tainted algorithm name or seed
/// (weak-crypto / predictable-randomness patterns).
const CRYPTO = 0b0000_1000_0000_0000; // bit 11
const CRYPTO = 1 << 11;
/// Request-bound, caller-supplied identifier that has not yet been
/// validated against an ownership/membership check. Used as the
/// carrier cap for folding `auth_analysis` into the SSA/taint
/// engine.
const UNAUTHORIZED_ID = 0b0001_0000_0000_0000; // bit 12
const UNAUTHORIZED_ID = 1 << 12;
/// Cross-boundary data-exfiltration: tainted sensitive data flowing
/// into outbound request bodies, headers, or other payload-bearing
/// fields of network egress APIs. Distinct from `SSRF` (attacker
/// control over the destination URL), `DATA_EXFIL` fires when the
/// destination is fixed but attacker-influenced data leaves the
/// process via the request payload.
const DATA_EXFIL = 0b0010_0000_0000_0000; // bit 13
const DATA_EXFIL = 1 << 13;
/// Sink: LDAP search/query construction. Fires when attacker-controlled
/// data reaches a directory-service filter or DN argument without
/// LDAP-filter escaping.
const LDAP_INJECTION = 1 << 14;
/// Sink: XPath expression construction. Fires when attacker-controlled
/// data is concatenated into an XPath query rather than passed via
/// XPath variable bindings.
const XPATH_INJECTION = 1 << 15;
/// Sink: HTTP response header value (or any CRLF-sensitive output).
/// Fires when attacker-controlled data lands in a `Set-Header` /
/// header-add call without `\r\n` stripping (response splitting).
const HEADER_INJECTION = 1 << 16;
/// Sink: redirect / `Location` header destination. Fires when an
/// attacker-controlled URL reaches a redirect call without an
/// allowlist or relative-URL check.
const OPEN_REDIRECT = 1 << 17;
/// Sink: server-side template injection. Fires when the **template
/// source string** itself is attacker-controlled (e.g.
/// `Template(user_input).render()`), distinct from rendering a
/// trusted template with tainted variables.
const SSTI = 1 << 18;
/// Sink: XML external entity resolution. Fires when attacker-controlled
/// XML reaches a parser configured to resolve external entities (or
/// missing the secure-processing feature).
const XXE = 1 << 19;
/// Sink: prototype pollution. Fires when an attacker-controlled key
/// reaches an object property assignment that can mutate
/// `Object.prototype` (`__proto__`, `constructor.prototype`, deep-merge
/// helpers).
const PROTOTYPE_POLLUTION = 1 << 20;
}
}
@ -214,14 +255,18 @@ impl Default for Cap {
impl serde::Serialize for Cap {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u16(self.bits())
s.serialize_u32(self.bits())
}
}
impl<'de> serde::Deserialize<'de> for Cap {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let bits = u16::deserialize(d)?;
Ok(Cap::from_bits_truncate(bits))
// Accept any unsigned integer width (existing JSON written with the
// u16 representation must continue to deserialise into the widened
// u32 cap field). serde-json hands these through `deserialize_u64`;
// the truncating cast preserves all currently-defined cap bits.
let bits = u64::deserialize(d)?;
Ok(Cap::from_bits_truncate(bits as u32))
}
}
@ -370,16 +415,46 @@ static GATED_REGISTRY: Lazy<HashMap<&'static str, &'static [SinkGate]>> = Lazy::
m.insert("js", javascript::GATED_SINKS);
m.insert("typescript", typescript::GATED_SINKS);
m.insert("ts", typescript::GATED_SINKS);
m.insert("python", python::GATED_SINKS);
m.insert("py", python::GATED_SINKS);
// Python prototype-pollution gates are opt-in: `dict.update(target,
// src)` overlaps too broadly with non-pollution use of `update`
// (Counter, namespaced state mutation) to ship as a default sink.
// The `NYX_PYTHON_PROTO_POLLUTION` env var enables them; when set
// the merged slice is leaked into a `'static` reference so the
// registry's lifetime invariant holds.
let python_gates: &'static [SinkGate] = if env_python_proto_pollution() {
let mut combined: Vec<SinkGate> = python::GATED_SINKS.to_vec();
combined.extend_from_slice(python::PROTO_POLLUTION_GATES);
Box::leak(combined.into_boxed_slice())
} else {
python::GATED_SINKS
};
m.insert("python", python_gates);
m.insert("py", python_gates);
m.insert("go", go::GATED_SINKS);
m.insert("php", php::GATED_SINKS);
m.insert("c", c::GATED_SINKS);
m.insert("cpp", cpp::GATED_SINKS);
m.insert("c++", cpp::GATED_SINKS);
m.insert("ruby", ruby::GATED_SINKS);
m.insert("rb", ruby::GATED_SINKS);
m.insert("java", java::GATED_SINKS);
m.insert("rust", rust::GATED_SINKS);
m.insert("rs", rust::GATED_SINKS);
m
});
/// Feature flag for the Python prototype-pollution gates. Disabled by
/// default; set `NYX_PYTHON_PROTO_POLLUTION=1` (or `true`) to enable
/// `dict.update` / `__dict__.update` proto-pollution detection.
fn env_python_proto_pollution() -> bool {
matches!(
std::env::var("NYX_PYTHON_PROTO_POLLUTION").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("on")
)
}
/// Per-language exclusion patterns: callee text that must never be classified.
static EXCLUDES: Lazy<HashMap<&'static str, &'static [&'static str]>> = Lazy::new(|| {
let mut m = HashMap::new();
@ -725,6 +800,13 @@ pub fn parse_cap(s: &str) -> Option<Cap> {
"crypto" => Some(Cap::CRYPTO),
"unauthorized_id" => Some(Cap::UNAUTHORIZED_ID),
"data_exfil" | "data_exfiltration" => Some(Cap::DATA_EXFIL),
"ldap_injection" | "ldapi" => Some(Cap::LDAP_INJECTION),
"xpath_injection" | "xpathi" => Some(Cap::XPATH_INJECTION),
"header_injection" | "crlf" | "response_splitting" => Some(Cap::HEADER_INJECTION),
"open_redirect" | "redirect" => Some(Cap::OPEN_REDIRECT),
"ssti" | "template_injection" => Some(Cap::SSTI),
"xxe" => Some(Cap::XXE),
"prototype_pollution" | "proto_pollution" => Some(Cap::PROTOTYPE_POLLUTION),
"all" => Some(Cap::all()),
_ => None,
}
@ -1274,7 +1356,15 @@ pub fn classify_gated_sink(
// where `userAttr` is user-controlled) is itself a vulnerability
// path. Return ALL_ARGS_PAYLOAD so downstream sink scanning
// considers every positional argument.
//
// `LiteralOnly` opts out of this conservative branch: the gate
// requires positive literal evidence to fire, so unknown
// activation suppresses entirely (avoids false positives on
// ambiguously-named suffix matchers like bare `extend`).
None => {
if matches!(gate.activation, GateActivation::LiteralOnly) {
continue;
}
out.push(GateMatch {
label: gate.label,
payload_args: ALL_ARGS_PAYLOAD,
@ -1396,10 +1486,283 @@ pub fn cap_to_name(cap: Cap) -> &'static str {
Cap::CODE_EXEC => "code_exec",
Cap::CRYPTO => "crypto",
Cap::UNAUTHORIZED_ID => "unauthorized_id",
Cap::DATA_EXFIL => "data_exfil",
Cap::LDAP_INJECTION => "ldap_injection",
Cap::XPATH_INJECTION => "xpath_injection",
Cap::HEADER_INJECTION => "header_injection",
Cap::OPEN_REDIRECT => "open_redirect",
Cap::SSTI => "ssti",
Cap::XXE => "xxe",
Cap::PROTOTYPE_POLLUTION => "prototype_pollution",
_ => "unknown",
}
}
// ── Cap rule registry ────────────────────────────────────────────────────
//
// Static, single-source-of-truth metadata table keyed by [`Cap`]. Every
// vulnerability class with its own canonical rule id appears here; the
// per-language `RULES` arrays only carry the language-specific match shapes.
// Sink-cap fields on a finding (or `Cap::DATA_EXFIL` carried alongside) feed
// `cap_rule_meta()` to pick the rule id surfaced to SARIF, the dashboard,
// and `enumerate_builtin_rules()` for `nyx rules list`.
/// Static metadata for one cap-defined vulnerability class.
#[derive(Debug, Clone, Copy)]
pub struct CapRuleMeta {
pub cap: Cap,
/// Canonical rule id surfaced by finding emission (no source-suffix).
pub rule_id: &'static str,
/// Display title for `nyx rules list` and dashboard.
pub title: &'static str,
pub severity: crate::patterns::Severity,
/// OWASP 2021 code (e.g. `"A03"`).
pub owasp_code: &'static str,
/// OWASP 2021 long label (e.g. `"Injection"`).
pub owasp_label: &'static str,
pub description: &'static str,
/// `false` only for caps gated behind a config flag (e.g.
/// `Cap::UNAUTHORIZED_ID`, which still defers to the standalone
/// `auth_analysis` subsystem unless `enable_auth_as_taint` is on).
pub default_enabled: bool,
/// Whether the diag-id emission path in `ast.rs` actually surfaces
/// findings under [`Self::rule_id`]. When `false`, sink findings
/// for this cap currently surface under the legacy
/// `taint-unsanitised-flow` id (the per-language family-token
/// dispatch in [`crate::server::owasp::owasp_bucket_for`] still
/// buckets them correctly). Dashboards and `nyx rules list` consume
/// this flag to decide whether to surface the synthetic class entry
/// alongside live findings or hide it as forward-declared.
///
/// Migrating a cap from `false` → `true` requires adding it to the
/// cap-specific routing list in `ast.rs::diag_for_finding`; tests
/// that pin the legacy `taint-unsanitised-flow` rule id for that
/// cap must be updated to the cap-specific id.
pub emission_active: bool,
}
/// Registry of cap-class metadata. Keyed in cap-bit order so additions
/// stay clustered with their bitflag declarations.
pub static CAP_RULE_REGISTRY: &[CapRuleMeta] = &[
CapRuleMeta {
cap: Cap::FILE_IO,
rule_id: "taint-path-traversal",
title: "Path Traversal / Arbitrary File Access",
severity: crate::patterns::Severity::High,
owasp_code: "A01",
owasp_label: "Broken Access Control",
description: "Attacker-controlled data flows into a filesystem path without canonicalisation \
or root-confinement, allowing reads or writes outside the intended directory.",
default_enabled: true,
emission_active: false,
},
CapRuleMeta {
cap: Cap::FMT_STRING,
rule_id: "taint-format-string",
title: "Format String Injection",
severity: crate::patterns::Severity::High,
owasp_code: "A03",
owasp_label: "Injection",
description: "Attacker-controlled data is used as a format string argument (printf-family, \
String.format) and can leak memory or crash the process.",
default_enabled: true,
emission_active: false,
},
CapRuleMeta {
cap: Cap::SQL_QUERY,
rule_id: "taint-sql-injection",
title: "SQL Injection",
severity: crate::patterns::Severity::High,
owasp_code: "A03",
owasp_label: "Injection",
description: "Attacker-controlled data is concatenated into a SQL query string instead of \
being bound through a parameterised statement.",
default_enabled: true,
emission_active: false,
},
CapRuleMeta {
cap: Cap::DESERIALIZE,
rule_id: "taint-deserialization",
title: "Unsafe Deserialization",
severity: crate::patterns::Severity::High,
owasp_code: "A08",
owasp_label: "Software and Data Integrity Failures",
description: "Attacker-controlled bytes are fed to an unsafe object deserialiser \
(pickle, ObjectInputStream, Marshal, unserialize) enabling arbitrary code \
execution via crafted payloads.",
default_enabled: true,
emission_active: false,
},
CapRuleMeta {
cap: Cap::SSRF,
rule_id: "taint-ssrf",
title: "Server-Side Request Forgery",
severity: crate::patterns::Severity::High,
owasp_code: "A10",
owasp_label: "Server-Side Request Forgery",
description: "Attacker-controlled URL reaches the destination of an outbound HTTP request \
without an allowlist or scheme/host restriction.",
default_enabled: true,
emission_active: false,
},
CapRuleMeta {
cap: Cap::CODE_EXEC,
rule_id: "taint-code-execution",
title: "Code / Command Execution",
severity: crate::patterns::Severity::High,
owasp_code: "A03",
owasp_label: "Injection",
description: "Attacker-controlled data reaches an `eval`/`exec`/shell sink, dynamic \
require/import, or other arbitrary-code construct.",
default_enabled: true,
emission_active: false,
},
CapRuleMeta {
cap: Cap::CRYPTO,
rule_id: "taint-crypto-misuse",
title: "Tainted Cryptographic Parameter",
severity: crate::patterns::Severity::Medium,
owasp_code: "A02",
owasp_label: "Cryptographic Failures",
description: "Attacker-controlled data drives the algorithm name, key, or seed of a \
cryptographic primitive (weak-crypto / predictable-randomness).",
default_enabled: true,
emission_active: false,
},
CapRuleMeta {
cap: Cap::UNAUTHORIZED_ID,
rule_id: "rs.auth.missing_ownership_check.taint",
title: "Missing Ownership Check (taint variant)",
severity: crate::patterns::Severity::High,
owasp_code: "A01",
owasp_label: "Broken Access Control",
description: "Request-bound identifier reaches a privileged sink without an intervening \
ownership/membership check. Companion to the standalone `auth_analysis` \
rule; gated by `scanner.enable_auth_as_taint`.",
default_enabled: false,
emission_active: true,
},
CapRuleMeta {
cap: Cap::DATA_EXFIL,
rule_id: "taint-data-exfiltration",
title: "Sensitive Data Exfiltration",
severity: crate::patterns::Severity::High,
owasp_code: "A04",
owasp_label: "Insecure Design",
description: "Sensitive data (cookies, headers, env, db rows, files) flows into the body, \
headers, or other payload field of an outbound network request to a fixed \
destination.",
default_enabled: true,
emission_active: true,
},
// ── Cap-specific rule ids ────────────────────────────────────────────
CapRuleMeta {
cap: Cap::LDAP_INJECTION,
rule_id: "taint-ldap-injection",
title: "LDAP Injection",
severity: crate::patterns::Severity::High,
owasp_code: "A03",
owasp_label: "Injection",
description: "Attacker-controlled data is concatenated into an LDAP filter or DN without \
RFC 4515 escaping, letting the attacker rewrite the directory query.",
default_enabled: true,
emission_active: true,
},
CapRuleMeta {
cap: Cap::XPATH_INJECTION,
rule_id: "taint-xpath-injection",
title: "XPath Injection",
severity: crate::patterns::Severity::High,
owasp_code: "A03",
owasp_label: "Injection",
description: "Attacker-controlled data is concatenated into an XPath expression instead of \
passed through XPath variable bindings, letting the attacker rewrite the \
query.",
default_enabled: true,
emission_active: true,
},
CapRuleMeta {
cap: Cap::HEADER_INJECTION,
rule_id: "taint-header-injection",
title: "HTTP Header / Response Splitting",
severity: crate::patterns::Severity::High,
owasp_code: "A03",
owasp_label: "Injection",
description: "Attacker-controlled data lands in an HTTP response header without `\\r\\n` \
stripping, enabling response splitting and cache-poisoning attacks.",
default_enabled: true,
emission_active: true,
},
CapRuleMeta {
cap: Cap::OPEN_REDIRECT,
rule_id: "taint-open-redirect",
title: "Open Redirect",
severity: crate::patterns::Severity::Medium,
owasp_code: "A01",
owasp_label: "Broken Access Control",
description: "Attacker-controlled URL drives a redirect / `Location` header without an \
allowlist or relative-URL check, enabling phishing pivots.",
default_enabled: true,
emission_active: true,
},
CapRuleMeta {
cap: Cap::SSTI,
rule_id: "taint-template-injection",
title: "Server-Side Template Injection",
severity: crate::patterns::Severity::High,
owasp_code: "A03",
owasp_label: "Injection",
description: "Attacker controls the template *source string* (not just template variables) \
passed to a server-side renderer (Jinja2, Twig, Handlebars, ERB), enabling \
arbitrary expression evaluation.",
default_enabled: true,
emission_active: true,
},
CapRuleMeta {
cap: Cap::XXE,
rule_id: "taint-xxe",
title: "XML External Entity Resolution",
severity: crate::patterns::Severity::High,
owasp_code: "A05",
owasp_label: "Security Misconfiguration",
description: "Attacker-controlled XML reaches a parser configured to resolve external \
entities (or missing the secure-processing feature), enabling SSRF, file \
read, and DoS.",
default_enabled: true,
emission_active: true,
},
CapRuleMeta {
cap: Cap::PROTOTYPE_POLLUTION,
rule_id: "taint-prototype-pollution",
title: "Prototype Pollution",
severity: crate::patterns::Severity::High,
owasp_code: "A05",
owasp_label: "Security Misconfiguration",
description: "Attacker-controlled key reaches an object property assignment that can mutate \
`Object.prototype` (deep-merge / `__proto__` / dynamic subscript).",
default_enabled: true,
emission_active: true,
},
];
/// Resolve a cap to its canonical rule metadata. Returns `None` for caps
/// without a rule-emission role (origin / sanitizer markers like
/// [`Cap::ENV_VAR`], [`Cap::HTML_ESCAPE`]).
pub fn cap_rule_meta(cap: Cap) -> Option<&'static CapRuleMeta> {
CAP_RULE_REGISTRY.iter().find(|m| m.cap == cap)
}
/// Resolve any subset of `effective_caps` to a single rule id. When
/// multiple bits are set, picks the first registry entry that intersects
/// (registry order is bit-position). Returns `None` when no bit in the
/// set has a registered rule id.
pub fn rule_id_for_caps(effective_caps: Cap) -> Option<&'static str> {
CAP_RULE_REGISTRY
.iter()
.find(|m| effective_caps.contains(m.cap))
.map(|m| m.rule_id)
}
/// Generate a stable rule ID from language, kind, and matchers.
pub fn rule_id(lang: &str, kind: &str, matchers: &[&str]) -> String {
let mut sorted: Vec<&str> = matchers.to_vec();
@ -1418,11 +1781,25 @@ pub struct RuleInfo {
pub language: String,
pub kind: String,
pub cap: String,
pub cap_bits: u16,
pub cap_bits: u32,
pub matchers: Vec<String>,
pub case_sensitive: bool,
pub is_custom: bool,
pub is_gated: bool,
/// Cap-class registry entry (one per `Cap` with a canonical rule id),
/// distinct from per-language sink/source/sanitizer match rules. The
/// dashboard groups these separately so the rules surface does not mix
/// "the LDAP injection class exists" with "Java's `DirContext.search`
/// is a sink for that class".
pub is_class: bool,
/// For class entries (`is_class == true`), whether the diag-id
/// emission path in `ast.rs` actually surfaces findings under
/// [`Self::id`]. When `false`, the class is registered but live
/// findings still emerge under the legacy `taint-unsanitised-flow`
/// rule id; dashboards can use this flag to suppress the synthetic
/// entry until the cap is migrated to its specific rule id.
/// Always `true` for non-class label rules.
pub emission_active: bool,
pub enabled: bool,
}
@ -1430,6 +1807,27 @@ pub struct RuleInfo {
pub fn enumerate_builtin_rules() -> Vec<RuleInfo> {
let mut out = Vec::new();
// Cap-class entries (one per registered vulnerability class). Kind
// `class` so dashboards can distinguish them from per-language
// sink/source/sanitizer entries.
for meta in CAP_RULE_REGISTRY {
out.push(RuleInfo {
id: meta.rule_id.to_string(),
title: meta.title.to_string(),
language: "all".to_string(),
kind: "class".to_string(),
cap: cap_to_name(meta.cap).to_string(),
cap_bits: meta.cap.bits(),
matchers: Vec::new(),
case_sensitive: false,
is_custom: false,
is_gated: false,
is_class: true,
emission_active: meta.emission_active,
enabled: meta.default_enabled,
});
}
for &lang in CANONICAL_LANGS {
if let Some(rules) = REGISTRY.get(lang) {
for rule in *rules {
@ -1453,6 +1851,8 @@ pub fn enumerate_builtin_rules() -> Vec<RuleInfo> {
case_sensitive: rule.case_sensitive,
is_custom: false,
is_gated: false,
is_class: false,
emission_active: true,
enabled: true,
});
}
@ -1479,6 +1879,8 @@ pub fn enumerate_builtin_rules() -> Vec<RuleInfo> {
case_sensitive: gate.case_sensitive,
is_custom: false,
is_gated: true,
is_class: false,
emission_active: true,
enabled: true,
});
}
@ -1498,6 +1900,65 @@ pub fn custom_rule_id(lang: &str, kind: &str, matchers: &[String]) -> String {
mod tests {
use super::*;
/// Pin the current set of caps whose `rule_id` is reachable via the
/// diag-id routing in `ast.rs::diag_for_finding`. When migrating a
/// legacy cap (e.g. SQL_QUERY → `taint-sql-injection`), update both
/// `ast.rs` (add the cap to the cap-specific routing list) and the
/// `emission_active: true` flag in `CAP_RULE_REGISTRY`, then update
/// this assertion. The split exists because legacy taint findings
/// historically all surfaced under the generic `taint-unsanitised-flow`
/// rule id; the seven cap-specific routes (LDAP / XPath / header /
/// open redirect / SSTI / XXE / prototype pollution) plus
/// `unauthorized_id` and `data_exfil` are the only ones wired through.
#[test]
fn cap_rule_registry_emission_active_set_is_pinned() {
let active: Vec<Cap> = CAP_RULE_REGISTRY
.iter()
.filter(|m| m.emission_active)
.map(|m| m.cap)
.collect();
let expected = [
Cap::UNAUTHORIZED_ID,
Cap::DATA_EXFIL,
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
Cap::SSTI,
Cap::XXE,
Cap::PROTOTYPE_POLLUTION,
];
for c in expected {
assert!(
active.contains(&c),
"cap {:?} expected to be emission_active in CAP_RULE_REGISTRY",
c
);
}
let inactive: Vec<Cap> = CAP_RULE_REGISTRY
.iter()
.filter(|m| !m.emission_active)
.map(|m| m.cap)
.collect();
let expected_inactive = [
Cap::FILE_IO,
Cap::FMT_STRING,
Cap::SQL_QUERY,
Cap::DESERIALIZE,
Cap::SSRF,
Cap::CODE_EXEC,
Cap::CRYPTO,
];
for c in expected_inactive {
assert!(
inactive.contains(&c),
"cap {:?} expected to be emission_inactive in CAP_RULE_REGISTRY (legacy \
finding still emits as taint-unsanitised-flow)",
c
);
}
}
#[test]
fn receiver_validator_python_relative_to() {
// Bare method name fires.
@ -1781,6 +2242,33 @@ mod tests {
// from `File.open` / `IO.open` / `URI.open`, each of which has its
// own non-piping semantics. Without the sigil, the suffix-with-
// boundary matcher would over-fire on every `X.open` call.
#[test]
fn classify_javascript_set_value_is_proto_pollution_gate() {
let no_kw = |_: &str| None;
let no_kw_present = |_: &str| false;
let result = classify_gated_sink("javascript", "setValue", |_| None, no_kw, no_kw_present);
assert!(
result
.iter()
.any(|m| m.label == DataLabel::Sink(Cap::PROTOTYPE_POLLUTION)),
"expected PROTOTYPE_POLLUTION gate match for bare `setValue`, got {result:?}"
);
}
#[test]
fn classify_javascript_dot_prop_set_is_proto_pollution_gate() {
let no_kw = |_: &str| None;
let no_kw_present = |_: &str| false;
let result =
classify_gated_sink("javascript", "dotProp.set", |_| None, no_kw, no_kw_present);
assert!(
result
.iter()
.any(|m| m.label == DataLabel::Sink(Cap::PROTOTYPE_POLLUTION)),
"expected PROTOTYPE_POLLUTION gate match for `dotProp.set`, got {result:?}"
);
}
#[test]
fn classify_ruby_bare_open_is_shell_escape_sink() {
let result = classify("ruby", "open", None);
@ -2419,7 +2907,7 @@ mod tests {
);
assert_eq!(
classify("rust", "Redirect::to(next)", Some(&extras)),
Some(DataLabel::Sink(Cap::SSRF)),
Some(DataLabel::Sink(Cap::OPEN_REDIRECT)),
);
let empty = rust::framework_rules(&FrameworkContext::default());
@ -2470,7 +2958,7 @@ mod tests {
);
assert_eq!(
classify("rust", "Redirect::to(next)", Some(&extras)),
Some(DataLabel::Sink(Cap::SSRF)),
Some(DataLabel::Sink(Cap::OPEN_REDIRECT)),
);
}
}

View file

@ -178,6 +178,143 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::DATA_EXFIL),
case_sensitive: true,
},
// ─── LDAP injection sinks ───
//
// PHP's procedural LDAP API: `ldap_search($ds, $base, $filter)`,
// `ldap_list($ds, $base, $filter)`, `ldap_read($ds, $base, $filter)`.
// The filter argument is the LDAP-injection vector when concatenated
// with attacker-controlled input.
LabelRule {
matchers: &["ldap_search", "ldap_list", "ldap_read"],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: false,
},
// ─── LDAP-filter sanitizer ───
//
// `ldap_escape($value, $ignore, LDAP_ESCAPE_FILTER)` applies RFC 4515
// escaping; treat any `ldap_escape` call as clearing the LDAP_INJECTION
// cap (the no-flag default also escapes filter metacharacters
// conservatively).
LabelRule {
matchers: &["ldap_escape"],
label: DataLabel::Sanitizer(Cap::LDAP_INJECTION),
case_sensitive: false,
},
// ─── XPath injection sinks ───
//
// `DOMXPath::query($expr, $ctx)` and `DOMXPath::evaluate($expr, $ctx)`
// accept the expression string as arg 0; concatenated user input there
// is the canonical PHP XPath-injection vector. `SimpleXMLElement::xpath`
// takes the same shape. Direct flat matchers cover the
// class-qualified call forms.
// Type-qualified rewrites: `$xp = new DOMXPath($doc)` tags `$xp` as
// `TypeKind::XPathClient`, so `$xp->query(...)` / `$xp->evaluate(...)`
// resolve to `XPathClient.query` / `XPathClient.evaluate`. Without
// the distinct TypeKind, bare `query` would match the SQL_QUERY sink.
LabelRule {
matchers: &[
"XPathClient.query",
"XPathClient.evaluate",
"DOMXPath::query",
"DOMXPath::evaluate",
"SimpleXMLElement::xpath",
],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// Bare `xpath` method: SimpleXMLElement instances expose `->xpath($expr)`
// and Symfony / DOMCrawler wrappers do the same. Suffix matching on
// `xpath` covers `$xml->xpath(...)` and similar bound-receiver shapes
// where the receiver type is not statically known. Case-sensitive to
// avoid collisions with the `XPath` capitalisation used by qualified
// names.
LabelRule {
matchers: &["xpath"],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: true,
},
// ─── XPath escape sanitizers ───
//
// No PHP standard library helper escapes XPath metacharacters; project-
// local `escape_xpath` / `xpath_escape` are the developer-named
// equivalents.
LabelRule {
matchers: &["escape_xpath", "xpath_escape"],
label: DataLabel::Sanitizer(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF injection sinks ───
//
// PHP's `header($line)` writes a raw header line. Tainted strings
// without `\r\n` stripping let an attacker inject extra headers
// (response splitting); see GATED_SINKS for the corresponding
// OPEN_REDIRECT co-tag on `Location: ...` forms.
//
// The HEADER_INJECTION sink is intentionally implemented as a gate
// (not a flat rule) so the multi-gate SSA dispatch can co-emit it
// alongside the OPEN_REDIRECT gate on the same call site, producing
// separate findings for each cap with their canonical rule ids.
// ─── Header / CRLF sanitizers ───
LabelRule {
matchers: &["strip_crlf", "escape_header", "sanitize_header"],
label: DataLabel::Sanitizer(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Open-redirect URL allowlist sanitizers ───
//
// Mirrors the JS/TS rule. Developer-named functions that allowlist
// / scheme-strip a redirect URL clear OPEN_REDIRECT taint before it
// reaches `header("Location: …")`. PHP also commonly uses
// `snake_case` variants.
LabelRule {
matchers: &[
"validateRedirectUrl",
"isSafeRedirect",
"stripScheme",
"validate_redirect_url",
"is_safe_redirect",
"strip_scheme",
"ensure_relative_url",
"ensureRelativeUrl",
"assert_relative_path",
"assertRelativePath",
"is_relative_url",
"isRelativeUrl",
],
label: DataLabel::Sanitizer(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
// ─── SSTI sinks ───
//
// Twig `\Twig\Environment::createTemplate(string $template)` parses an
// arbitrary template source string at runtime; a tainted source yields
// SSTI when the resulting template is rendered. `Environment::render`
// / `Environment::load` take a *template name* (file lookup, not source)
// and are intentionally excluded. After PHP scope-resolution stripping
// the chain text covers both `$twig->createTemplate($src)` and
// `Twig\Environment::createTemplate(...)` shapes.
LabelRule {
matchers: &["Environment.createTemplate", "Twig.createTemplate"],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: true,
},
// ─── XXE sanitizers ───
//
// `libxml_disable_entity_loader(true)` (PHP <8) / `libxml_set_external_entity_loader($cb)`
// disable external-entity expansion process-wide. Treat their return
// value as XXE-cleared so config-style fixtures (`libxml_disable_entity_loader(true);
// simplexml_load_string($xml, ...)`) suppress the gate when the call is
// present in the same SSA scope. The flat-rule sanitizer is a coarse
// approximation, the real config-check pattern would track parser-instance
// hardening (deferred Layer 2).
LabelRule {
matchers: &[
"libxml_disable_entity_loader",
"libxml_set_external_entity_loader",
],
label: DataLabel::Sanitizer(Cap::XXE),
case_sensitive: false,
},
];
/// Gated sinks for PHP.
@ -193,18 +330,157 @@ pub static RULES: &[LabelRule] = &[
///
/// Identifier-based activation is enabled via the macro-arg fallback in
/// `cfg::mod::classify_gated_sink` for `lang == "php"`.
pub static GATED_SINKS: &[SinkGate] = &[SinkGate {
callee_matcher: "curl_setopt",
arg_index: 1,
dangerous_values: &["CURLOPT_POSTFIELDS", "CURLOPT_COPYPOSTFIELDS"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::DATA_EXFIL),
case_sensitive: true,
payload_args: &[2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
}];
pub static GATED_SINKS: &[SinkGate] = &[
SinkGate {
callee_matcher: "curl_setopt",
arg_index: 1,
dangerous_values: &["CURLOPT_POSTFIELDS", "CURLOPT_COPYPOSTFIELDS"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::DATA_EXFIL),
case_sensitive: true,
payload_args: &[2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// PHP `header($line)` HEADER_INJECTION sink. Modelled as a gate so
// it can coexist with the OPEN_REDIRECT gate below: the multi-gate
// SSA dispatch needs each capability declared on its own gate filter
// to emit one finding per cap. Always activates (Destination), with
// payload arg 0 only (`header()` only accepts the line as arg 0;
// arg 1 is `replace`/`response_code`, not the line content).
SinkGate {
callee_matcher: "=header",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// PHP `simplexml_load_string($xml, $class, $options)` —
// XXE sink gated on the `LIBXML_NOENT` flag (or `LIBXML_DTDLOAD`,
// `LIBXML_DTDATTR`). PHP's libxml is XXE-safe by default since 2.9.0;
// the gate fires only when the `$options` literal includes one of the
// dangerous flags. Identifier-based activation works via the macro-arg
// fallback in `cfg::mod::classify_gated_sink` for `lang == "php"`.
SinkGate {
callee_matcher: "simplexml_load_string",
arg_index: 2,
dangerous_values: &["LIBXML_NOENT", "LIBXML_DTDLOAD", "LIBXML_DTDATTR"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
SinkGate {
callee_matcher: "simplexml_load_file",
arg_index: 2,
dangerous_values: &["LIBXML_NOENT", "LIBXML_DTDLOAD", "LIBXML_DTDATTR"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// DOMDocument::loadXML($xml, $options) — same gating as
// simplexml_load_string. The chain-normalised callee text for
// `$dom->loadXML(...)` is `dom.loadXML`; suffix matching on
// `loadXML` covers the bound-receiver form.
SinkGate {
callee_matcher: "loadXML",
arg_index: 1,
dangerous_values: &["LIBXML_NOENT", "LIBXML_DTDLOAD", "LIBXML_DTDATTR"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// PHP `header($line)` co-tag for OPEN_REDIRECT.
//
// The flat HEADER_INJECTION sink (`=header`) above already fires for
// any `header(...)` call regardless of the line content. This gate
// adds the OPEN_REDIRECT co-tag specifically when the first argument
// is a `Location: ...` header, so the dashboard / OWASP bucket
// correctly classifies redirect-class flows independently of CRLF.
//
// Activation: arg 0 prefix `Location:` (case-insensitive). When arg
// 0 is a constant string starting with `Location:` the gate fires and
// checks payload arg 0 for taint; constants like `Content-Type: ...`
// are suppressed by the safe-literal branch. When arg 0 is a binary
// expression (`"Location: " . $url`) or otherwise dynamic, the
// value-extraction returns `None` and the gate fires conservatively
// — matching the existing convention in `setAttribute`/`parseFromString`.
SinkGate {
callee_matcher: "=header",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &["Location:"],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: false,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// Smarty `$smarty->fetch($name)` — only the `string:` resource prefix
// accepts an inline template *source*; the bare form (`page.tpl`) is a
// file lookup (not SSTI). Gate activates only when arg 0's leading
// literal segment is the `string:` prefix; the constant-string suffix
// and concat (`"string:" . $src`) shapes both reach `extract_const_string_arg`'s
// leading-literal path and trigger activation. Payload is arg 0
// itself — taint reaching the template source string is the SSTI flow.
// Suffix matching catches both `Smarty.fetch` and the bound-receiver
// `$smarty->fetch(...)` forms.
SinkGate {
callee_matcher: "Smarty.fetch",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &["string:"],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: false,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// Twig `\Twig\Environment::createTemplate(string $template)` —
// gated SSTI sink. Activation is unconditional (no value gate);
// payload arg 0 is the template source string. Bare suffix
// `createTemplate` matches the idiomatic instance shape
// `$twig->createTemplate($src)` (chain text `twig.createTemplate`)
// as well as the static `Environment::createTemplate(...)` form;
// `createTemplate` is Twig-specific terminology so over-fire risk
// is low. The matching flat rule remains for documentation-style
// class-qualified call shapes.
SinkGate {
callee_matcher: "createTemplate",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: false,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
// control-flow

View file

@ -61,7 +61,7 @@ pub static RULES: &[LabelRule] = &[
// pattern that follows `from flask import session`. The `=session`
// exact-match form fires only when the call is the bare top-level
// `session(...)` so accidental field projections like
// `obj.client.session` (Phase 2 chained-receiver lowering) don't get
// `obj.client.session` (chained-receiver lowering) don't get
// mis-labelled as sources.
LabelRule {
matchers: &[
@ -284,6 +284,212 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::DESERIALIZE),
case_sensitive: false,
},
// ─── LDAP injection sinks ───
//
// python-ldap exposes module-level `ldap.search_s` / `ldap.search_ext_s`
// and method-style `conn.search_s(base, scope, filter)` after `conn =
// ldap.initialize(url)`. Suffix matching on the method names catches both
// the qualified form (`ldap.search_s`, matched as a literal) and the
// bound-receiver form (`conn.search_s` ends with `search_s`). ldap3 uses
// `Connection(server, ...)` whose `.search(...)` accepts a filter kwarg /
// positional; receiver typing tags the connection as `TypeKind::LdapClient`
// so type-qualified resolution rewrites `conn.search` → `LdapClient.search`.
LabelRule {
matchers: &[
"ldap.search_s",
"ldap.search_ext_s",
"search_s",
"search_ext_s",
"LdapClient.search",
"ldap3.Connection.search",
],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── LDAP-filter sanitizers ───
//
// python-ldap: `ldap.filter.escape_filter_chars(s)` and ldap3's
// `ldap3.utils.conv.escape_filter_chars(s)` both apply RFC 4515 escaping
// to filter metacharacters. Suffix matching on `escape_filter_chars`
// covers both the fully-qualified import and the bare-name destructured
// import (`from ldap.filter import escape_filter_chars`).
LabelRule {
matchers: &[
"escape_filter_chars",
"ldap.filter.escape_filter_chars",
"ldap3.utils.conv.escape_filter_chars",
],
label: DataLabel::Sanitizer(Cap::LDAP_INJECTION),
case_sensitive: false,
},
// ─── XPath injection sinks ───
//
// lxml: `tree.xpath(expr)` / `etree.XPath(expr)` accept an
// attacker-influenceable expression string. ElementTree's
// `find` / `findall` / `findtext` accept the same kind of XPath subset
// and admit injection when the path is built by string concatenation.
// Suffix matching on the bare method names catches both
// `lxml.etree._Element.xpath(...)` and `tree.xpath(...)` shapes.
LabelRule {
matchers: &[
"xpath",
"lxml.etree.XPath",
"etree.XPath",
"ElementTree.find",
"ElementTree.findall",
"ElementTree.findtext",
],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: true,
},
// ─── XPath escape sanitizers ───
//
// No standard library helper escapes XPath metacharacters; project-local
// `escape_xpath` / `xpath_escape` are the developer-named equivalents.
LabelRule {
matchers: &["escape_xpath", "xpath_escape"],
label: DataLabel::Sanitizer(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF injection sinks ───
//
// Flask / Werkzeug response APIs that write a single header value:
// `response.headers.add(name, val)`, `response.set_cookie(name, val)`,
// and the bare subscript-set form `response.headers[name] = val`.
// The subscript-set form is picked up via the LHS-subscript
// classification path in `cfg/mod.rs::push_node`: the LHS object's
// member-expression text matches `response.headers` /
// `self.response.headers` and tags the assignment as a HEADER_INJECTION
// sink.
LabelRule {
matchers: &["headers.add", "headers.set", "set_cookie"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
LabelRule {
matchers: &["response.headers", "self.response.headers", "resp.headers"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF sanitizers ───
LabelRule {
matchers: &["strip_crlf", "escape_header", "sanitize_header"],
label: DataLabel::Sanitizer(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Open redirect sinks ───
//
// Flask `redirect(url)`, Django `HttpResponseRedirect(url)`, FastAPI /
// Starlette `RedirectResponse(url=...)`. Tainted URL flowing to any of
// these without an allowlist check is an open-redirect vector.
LabelRule {
matchers: &[
"redirect",
"flask.redirect",
"django.shortcuts.redirect",
"HttpResponseRedirect",
"RedirectResponse",
],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: true,
},
LabelRule {
matchers: &[
"validate_redirect_url",
"is_safe_redirect",
"strip_scheme",
"ensure_relative_url",
"assert_relative_path",
"is_relative_url",
],
label: DataLabel::Sanitizer(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
// ─── SSTI sinks ───
//
// Template-engine constructors / `from_string` factories that accept the
// template *source string* as arg 0. `flask.render_template` takes a
// file PATH (not source) so does NOT match here — the safe API stays
// clean by name.
LabelRule {
matchers: &[
"=Template",
"jinja2.Template",
"jinja2.Environment.from_string",
"Environment.from_string",
// `compile_expression` is jinja2-specific terminology (it returns a
// callable from an inline expression source). Bare suffix lets the
// rule fire on idiomatic instance shapes (`env.compile_expression(s)`)
// without a `jinja2.Environment` TypeKind.
"compile_expression",
"mako.template.Template",
"Template.render",
],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: true,
},
// Template-loader paths: a tainted `name` lets the attacker swap the
// resolved template behind the renderer. Mako's `TemplateLookup.get_template`
// and Jinja2's `Environment.get_template` / `select_template` /
// `loader.get_source` all take a template name (path-like) as arg 0.
// Modeling these as SSTI sinks captures the loader-path attack — the
// file resolver itself becomes the gadget when the name is attacker-controlled.
LabelRule {
matchers: &[
"TemplateLookup.get_template",
"Environment.get_template",
"Environment.select_template",
"loader.get_source",
// Bare-suffix forms for the idiomatic instance shapes
// (`env.get_template(name)`, `lookup.get_template(name)`).
"get_template",
"select_template",
],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: true,
},
// ─── XXE sinks ───
//
// Python's stock `xml.sax.parseString` / `xml.sax.parse` parsers are
// XXE-vulnerable by default; `xml.dom.minidom.parseString` /
// `xml.dom.minidom.parse` likewise resolve external entities through
// the underlying expat parser unless the entity-loader is hardened.
// Each entry is the dotted-module suffix; bare `parseString` / `parse`
// are intentionally avoided to prevent collisions with JSON parsers
// (`json.loads`), `lxml.etree.fromstring` is excluded — modern lxml
// disables external entities by default and would over-fire here.
LabelRule {
matchers: &[
"xml.sax.parseString",
"xml.sax.parse",
"xml.dom.minidom.parseString",
"xml.dom.minidom.parse",
"xml.dom.pulldom.parseString",
"xml.dom.pulldom.parse",
],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
},
// `defusedxml.*` is the canonical hardened drop-in: every parser in
// the package strips external-entity / DTD resolution and raises on
// the patterns that would otherwise XXE. Treat any defusedxml
// call as an XXE sanitizer.
LabelRule {
matchers: &[
"defusedxml.ElementTree.fromstring",
"defusedxml.ElementTree.parse",
"defusedxml.minidom.parseString",
"defusedxml.minidom.parse",
"defusedxml.sax.parseString",
"defusedxml.sax.parse",
"defusedxml.pulldom.parseString",
"defusedxml.pulldom.parse",
"defusedxml.lxml.fromstring",
"defusedxml.lxml.parse",
],
label: DataLabel::Sanitizer(Cap::XXE),
case_sensitive: true,
},
];
/// Method-call validators that strip caps from their *receiver* (and
@ -1041,6 +1247,55 @@ pub static GATED_SINKS: &[SinkGate] = &[
},
];
/// Prototype-pollution-style gates for Python. Opt-in via the
/// `NYX_PYTHON_PROTO_POLLUTION` env var (see
/// `super::env_python_proto_pollution`); when enabled they are merged
/// into the language's `GATED_REGISTRY` slice at startup.
///
/// Coverage is deliberately narrow: the `dict.update(target, src)`
/// class-method form (where the first arg is the target and the second
/// is the source) is the canonical attack shape for `__class__` /
/// `__dict__` pollution in Python frameworks that thread user input
/// through configuration objects. The bound-method form
/// (`config.update(req_data)`) is handled by the suffix-matched
/// `dict.update` callee text only when the receiver text literally
/// equals `dict`, keeping the gate from over-firing on every `update`
/// method in the codebase.
pub static PROTO_POLLUTION_GATES: &[SinkGate] = &[
// `dict.update(target, src)` — class-method form. Argument-role
// gating: only `src` (arg 1) taint activates; tainted target alone
// is benign.
SinkGate {
callee_matcher: "dict.update",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// `obj.__dict__.update(src)` — instance-attribute pollution shape.
SinkGate {
callee_matcher: "__dict__.update",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
// control-flow
"if_statement" => Kind::If,

View file

@ -1,4 +1,6 @@
use crate::labels::{Cap, DataLabel, Kind, LabelRule, ParamConfig, RuntimeLabelRule};
use crate::labels::{
Cap, DataLabel, GateActivation, Kind, LabelRule, ParamConfig, RuntimeLabelRule, SinkGate,
};
use crate::utils::project::{DetectedFramework, FrameworkContext};
use phf::{Map, phf_map};
@ -226,10 +228,30 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::SQL_QUERY),
case_sensitive: true,
},
// Open redirect: redirect_to with user-controlled destination.
// Open redirect: redirect_to (Rails) / redirect (Sinatra) with
// user-controlled destination. `redirect` is a top-level Sinatra
// helper; case-sensitive matching keeps it from over-firing on
// unrelated identifiers. `redirect_to` is the Rails canonical.
LabelRule {
matchers: &["redirect_to"],
label: DataLabel::Sink(Cap::SSRF),
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
LabelRule {
matchers: &["redirect"],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: true,
},
LabelRule {
matchers: &[
"validate_redirect_url",
"is_safe_redirect",
"strip_scheme",
"ensure_relative_url",
"assert_relative_path",
"is_relative_url",
],
label: DataLabel::Sanitizer(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
// Path traversal: file serving with user-controlled path.
@ -244,6 +266,173 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::HTML_ESCAPE),
case_sensitive: false,
},
// ─── LDAP injection sinks ───
//
// `Net::LDAP.new(host:, ...).search(base:, filter:, ...)` is the canonical
// ruby-ldap shape. Type-qualified resolution rewrites `ldap.search` →
// `LdapClient.search` when the receiver was constructed via `Net::LDAP.new`
// / `Net::LDAP.open` (see [`crate::ssa::type_facts::constructor_type`]).
// The chained literal form `Net::LDAP.new(...).search(...)` is also caught
// by the suffix matcher `Net::LDAP.search` after `()` stripping (the
// post-strip text is `Net::LDAP.new.search`, which ends in `.search`; the
// explicit `LDAP.search` keyword form `Net::LDAP.search(filter)` matches
// the same matcher directly).
LabelRule {
matchers: &["LdapClient.search", "Net::LDAP.search"],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── LDAP-filter sanitizer ───
//
// `Net::LDAP::Filter.escape(value)` applies RFC 4515 escaping; treat any
// call as clearing the LDAP_INJECTION cap.
LabelRule {
matchers: &["Net::LDAP::Filter.escape"],
label: DataLabel::Sanitizer(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── XPath injection sinks ───
//
// `Nokogiri::XML::Node#xpath(expr)`, `at_xpath(expr)`, and `search(expr)`
// accept the expression string as arg 0; concatenated user input there is
// the canonical Nokogiri XPath-injection vector. Suffix matching on the
// bare method names catches the bound-receiver form (`doc.xpath(expr)`).
LabelRule {
matchers: &["xpath", "at_xpath"],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: true,
},
// ─── XPath escape sanitizers ───
//
// No Nokogiri / stdlib helper escapes XPath metacharacters; project-local
// `escape_xpath` / `xpath_escape` are the developer-named equivalents.
LabelRule {
matchers: &["escape_xpath", "xpath_escape"],
label: DataLabel::Sanitizer(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF injection sinks ───
//
// Rack `Response#set_header(name, value)` / `add_header(name, value)`
// and `ActionDispatch::Response#headers[]=` write a single header value.
// The subscript-set form `response.headers["X-Foo"] = bar` is picked up
// via the LHS-subscript classification path in `cfg/mod.rs`: when the
// LHS object's member-expression text matches `response.headers` (or a
// synonym), the assignment is tagged as a HEADER_INJECTION sink.
// Tainted strings without `\r\n` stripping enable response splitting.
LabelRule {
matchers: &["set_header", "add_header"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
LabelRule {
matchers: &["response.headers", "res.headers", "self.response.headers"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
LabelRule {
matchers: &["strip_crlf", "escape_header", "sanitize_header"],
label: DataLabel::Sanitizer(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── SSTI sinks ───
//
// `ERB.new(template_source)` and `Liquid::Template.parse(source)` accept
// the template *source string* as arg 0; tainted source there yields
// arbitrary template execution at the corresponding `result(binding)` /
// `render` step. `=ERB.new` exact-matcher syntax limits the rule to the
// direct call (the leading `=` is the same convention used elsewhere in
// this file for Kernel-style globals like `=open`).
LabelRule {
matchers: &["=ERB.new", "Liquid::Template.parse"],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: true,
},
// ─── XXE sinks ───
//
// `REXML::Document.new(xml)` instantiates the (legacy, default-vulnerable)
// pure-Ruby XML parser; an attacker-controlled `xml` is XXE.
//
// Nokogiri (`Nokogiri::XML(xml)` / `Nokogiri::XML::Document.parse(xml)`)
// is XXE-safe by default since 1.10, but resolving external entities
// requires explicitly opting in via `Nokogiri::XML::ParseOptions::NOENT`
// (or `DTDLOAD` / `DTDATTR`). Option-flagged detection lives in
// `GATED_SINKS` below.
LabelRule {
matchers: &["REXML::Document.new"],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
},
];
/// Ruby gated sinks. Argument-role-aware classification for callees that
/// are XXE-safe by default but become unsafe when the caller passes an
/// option flag that re-enables external-entity resolution.
///
/// Activation uses the bare-leaf comparison: scope-qualified constants like
/// `Nokogiri::XML::ParseOptions::NOENT` are reduced to the rightmost
/// `name` segment by the `scope_resolution` branch in
/// `cfg::literals::extract_const_macro_arg`, so the
/// `dangerous_values` list stays identifier-bare.
///
/// Default-arg semantics: Ruby `Nokogiri::XML(xml)` with no options arg
/// reaches the gate's `None` activation branch (the activation arg
/// position simply doesn't exist), which falls through to a conservative
/// fire. Callers wishing to suppress the gate explicitly should pass a
/// safe options literal at the activation position (e.g.
/// `Nokogiri::XML::ParseOptions::DEFAULT_XML`); any non-dangerous
/// scope-qualified constant disables the gate.
pub static GATED_SINKS: &[SinkGate] = &[
// `Nokogiri::XML(xml, url=nil, encoding=nil, options=NIL)` — top-level
// module method. arg 3 carries the parse-option flag literal.
//
// tree-sitter-ruby parses `Nokogiri::XML(args)` as a `call` whose
// `receiver` field is the `Nokogiri` constant and `method` field is
// the `XML` constant (with `::` as the call operator). `push_node`'s
// `CallMethod` path joins these as `{receiver}.{method}` → matchable
// suffix `Nokogiri.XML`.
SinkGate {
callee_matcher: "Nokogiri.XML",
arg_index: 3,
dangerous_values: &["NOENT", "DTDLOAD", "DTDATTR"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// `Nokogiri::XML::Document.parse(xml, url=nil, encoding=nil, options=NIL)`
// — receiver is the scope_resolution `Nokogiri::XML::Document` (text of
// the whole receiver is preserved verbatim) and method is `parse`, so
// the constructed callee text is `Nokogiri::XML::Document.parse`.
SinkGate {
callee_matcher: "Nokogiri::XML::Document.parse",
arg_index: 3,
dangerous_values: &["NOENT", "DTDLOAD", "DTDATTR"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// `Nokogiri::HTML(html, ..., options)` shares the same option flags as
// the XML helper. Same callee normalization as `Nokogiri.XML`.
SinkGate {
callee_matcher: "Nokogiri.HTML",
arg_index: 3,
dangerous_values: &["NOENT", "DTDLOAD", "DTDATTR"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {

View file

@ -1,4 +1,6 @@
use crate::labels::{Cap, DataLabel, Kind, LabelRule, ParamConfig, RuntimeLabelRule};
use crate::labels::{
Cap, DataLabel, GateActivation, Kind, LabelRule, ParamConfig, RuntimeLabelRule, SinkGate,
};
use crate::utils::project::{DetectedFramework, FrameworkContext};
use phf::{Map, phf_map};
@ -245,6 +247,89 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::DESERIALIZE),
case_sensitive: false,
},
// ─── Header / CRLF injection sinks ───
//
// `http::HeaderMap::insert(name, val)` / `append(...)` write a single
// header value. The canonical idiom is `response.headers_mut().insert(...)`
// (axum, actix-web `HttpResponse.headers_mut`, hyper `Response::headers_mut`).
// After paren-group stripping the chain text becomes
// `response.headers_mut.insert`, so suffix matchers on
// `headers_mut.insert` / `headers_mut.append` cover the bound-receiver
// form regardless of the response builder's concrete type. Tainted
// strings without CRLF stripping enable response splitting.
LabelRule {
matchers: &["headers_mut.insert", "headers_mut.append"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
LabelRule {
matchers: &["strip_crlf", "escape_header", "sanitize_header"],
label: DataLabel::Sanitizer(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Open redirect sinks ───
//
// axum / rocket `Redirect::to(url)` / `Redirect::permanent(url)` /
// `Redirect::temporary(url)` build a 3xx response with the URL in the
// `Location` header. Without an allowlist check, a tainted `url` is
// the canonical Rust open-redirect vector. Listed unconditionally (not
// gated on framework detection) so non-framework helpers / re-exports
// still surface; the framework-conditional rules below are
// intentionally not duplicating this label. Actix
// `HttpResponse::Found().header("Location", x)` is covered by the
// existing `header` HEADER_INJECTION sink and any Location-line
// co-tagging is deferred to the abstract-string-domain pattern hook.
LabelRule {
matchers: &["Redirect::to", "Redirect::permanent", "Redirect::temporary"],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: true,
},
LabelRule {
matchers: &[
"validate_redirect_url",
"is_safe_redirect",
"strip_scheme",
"ensure_relative_url",
"assert_relative_path",
"is_relative_url",
],
label: DataLabel::Sanitizer(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
];
/// Rust gated sinks. Argument-position-aware classification for callees
/// where activation depends on a literal arg value rather than the bare
/// callee name.
pub static GATED_SINKS: &[SinkGate] = &[
// actix-web `HttpResponse::Found().header("Location", url)` (and other
// builder variants like `Ok().header(...)`, `MovedPermanently().header(...)`).
// After chain normalisation the callee text is e.g.
// `HttpResponse.Found.header`; suffix matching on `header` covers every
// builder variant.
//
// Activation: arg 0 case-insensitive equality with `"Location"`. When
// arg 0 is a constant string equal to `Location` the gate fires and
// checks payload arg 1 for taint; constants like `"Content-Type"` are
// suppressed by the safe-literal branch. When arg 0 is dynamic the
// gate fires conservatively (per the existing `setAttribute` /
// `parseFromString` convention).
//
// Mirrors PHP's `=header` Location gate; the Rust analog is split
// across two args (`name`, `value`) instead of PHP's single `Location: ...`
// line.
SinkGate {
callee_matcher: "header",
arg_index: 0,
dangerous_values: &["Location"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: true,
payload_args: &[1],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
@ -337,11 +422,8 @@ pub fn framework_rules(ctx: &FrameworkContext) -> Vec<RuntimeLabelRule> {
label: DataLabel::Sink(Cap::HTML_ESCAPE),
case_sensitive: true,
});
rules.push(RuntimeLabelRule {
matchers: vec!["Redirect::to".into()],
label: DataLabel::Sink(Cap::SSRF),
case_sensitive: true,
});
// `Redirect::to` is declared unconditionally as Sink(OPEN_REDIRECT)
// in `RULES` above; no framework-conditional duplicate needed.
}
if ctx.has(DetectedFramework::ActixWeb) {
@ -395,11 +477,8 @@ pub fn framework_rules(ctx: &FrameworkContext) -> Vec<RuntimeLabelRule> {
label: DataLabel::Sink(Cap::HTML_ESCAPE),
case_sensitive: true,
});
rules.push(RuntimeLabelRule {
matchers: vec!["Redirect::to".into()],
label: DataLabel::Sink(Cap::SSRF),
case_sensitive: true,
});
// `Redirect::to` is declared unconditionally as Sink(OPEN_REDIRECT)
// in `RULES` above; no framework-conditional duplicate needed.
}
rules

View file

@ -255,6 +255,113 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::SQL_QUERY),
case_sensitive: true,
},
// ─── LDAP injection sinks ───
//
// Mirror of `labels/javascript.rs`; ldapjs / ts-ldapjs has the same
// `client.search(...)` shape. Type-qualified resolution covers both
// `const client = ldap.createClient({...}); client.search(...)` (bound
// variable, type forwarded from the parent body via
// [`crate::taint::inject_external_type_facts`]) and the chained
// `ldap.createClient({...}).search(...)` form.
LabelRule {
matchers: &["LdapClient.search"],
label: DataLabel::Sink(Cap::LDAP_INJECTION),
case_sensitive: true,
},
// ─── LDAP-filter sanitizers ───
LabelRule {
matchers: &[
"ldapEscape",
"ldap-escape",
"ldapescape.filter",
"ldapescape.dn",
],
label: DataLabel::Sanitizer(Cap::LDAP_INJECTION),
case_sensitive: false,
},
// ─── XPath injection sinks ─── (mirrors `labels/javascript.rs`)
LabelRule {
matchers: &[
"document.evaluate",
"xpath.select",
"xpath.evaluate",
"xpath.select1",
],
label: DataLabel::Sink(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// ─── XPath escape sanitizers ─── (mirrors `labels/javascript.rs`)
LabelRule {
matchers: &["escapeXpath", "xpathEscape", "escape_xpath"],
label: DataLabel::Sanitizer(Cap::XPATH_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF injection sinks ─── (mirrors `labels/javascript.rs`)
LabelRule {
matchers: &["setHeader", "res.set", "res.header", "res.append"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// Subscript-set form (mirrors `labels/javascript.rs`).
LabelRule {
matchers: &["res.headers", "response.headers", "self.response.headers"],
label: DataLabel::Sink(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Header / CRLF sanitizers ─── (mirrors `labels/javascript.rs`)
LabelRule {
matchers: &["stripCRLF", "stripCrlf", "escapeHeader", "sanitizeHeader"],
label: DataLabel::Sanitizer(Cap::HEADER_INJECTION),
case_sensitive: false,
},
// ─── Prototype pollution sinks ─── (mirrors `labels/javascript.rs`)
//
// Argument-role gating is enforced via Destination activation in
// `GATED_SINKS` below: only taint flowing into source-object
// arguments (positions 1+) activates; tainted-target alone is
// benign. Flat rules here are intentionally empty for the merge
// family.
// ─── Open redirect sinks ─── (mirrors `labels/javascript.rs`)
LabelRule {
matchers: &[
"res.redirect",
"location.replace",
"location.assign",
"router.navigate",
"router.navigateByUrl",
"window.location",
"window.location.href",
"location.href",
],
label: DataLabel::Sink(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
LabelRule {
matchers: &[
"validateRedirectUrl",
"isSafeRedirect",
"stripScheme",
"ensureRelativeUrl",
"assertRelativePath",
"isRelativeUrl",
],
label: DataLabel::Sanitizer(Cap::OPEN_REDIRECT),
case_sensitive: false,
},
// ─── SSTI sinks ─── (mirrors `labels/javascript.rs`; `_.template`
// and `nunjucks.renderString` excluded — gated classifiers in
// GATED_SINKS)
LabelRule {
matchers: &["Handlebars.compile"],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: false,
},
// ─── XXE sinks ─── (mirrors `labels/javascript.rs`)
LabelRule {
matchers: &["libxmljs.parseXmlString", "libxmljs.parseXml"],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
},
];
/// Callee patterns that must never be classified as source/sanitizer/sink.
@ -309,6 +416,23 @@ pub static GATED_SINKS: &[SinkGate] = &[
dangerous_kwargs: &[],
activation: GateActivation::ValueMatch,
},
// ── XML XXE gates, mirrors `labels/javascript.rs` ────────────────────
SinkGate {
callee_matcher: "xml2js.parseString",
arg_index: 1,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::XXE),
case_sensitive: true,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[
("processEntities", &["true"]),
("explicitEntities", &["true"]),
("strict", &["false"]),
],
activation: GateActivation::ValueMatch,
},
// ── Outbound HTTP clients (SSRF), see javascript.rs for rationale ────
SinkGate {
callee_matcher: "fetch",
@ -603,6 +727,189 @@ pub static GATED_SINKS: &[SinkGate] = &[
object_destination_fields: &[],
},
},
// `nunjucks.renderString(src, ctx)` — Nunjucks SSTI sink. Only the
// template *source* (arg 0) lets an attacker drive template
// execution; the `ctx` data object (arg 1) is rendered via the
// template's escape policy and is not itself a code-injection
// vector. Gate via Destination-style activation with
// `payload_args: &[0]` so taint flowing only into `ctx` is
// suppressed. Mirrors `labels/javascript.rs`.
SinkGate {
callee_matcher: "nunjucks.renderString",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::SSTI),
case_sensitive: false,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// ── Prototype pollution gates ────────────────────────────────────────
//
// Mirrors `labels/javascript.rs` GATED_SINKS proto-pollution block.
// Argument-role gating: `(target, src1, src2, ...)`, only source
// positions trigger. See the JS module for the rationale and the
// `payload_args` width choice.
SinkGate {
callee_matcher: "_.merge",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "_.mergeWith",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "_.defaultsDeep",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "_.set",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "_.setWith",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "deepMerge",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "defaultsDeep",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: false,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "Object.assign",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "$.extend",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2, 3, 4],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "jQuery.extend",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[1, 2, 3, 4],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
// Bare `extend` (suffix-matched) — see labels/javascript.rs for full
// rationale. `LiteralOnly` activation requires arg 0 to be literal `true`
// so Backbone's `Model.extend({proto})` class-extension form does not
// fire (its arg 0 is an object literal, not a boolean).
SinkGate {
callee_matcher: "extend",
arg_index: 0,
dangerous_values: &["true"],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::PROTOTYPE_POLLUTION),
case_sensitive: true,
payload_args: &[2, 3, 4, 5],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::LiteralOnly,
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {