diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 531ea6f5..5637b153 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -41,7 +41,7 @@ body: attributes: label: Nyx version description: Output of `nyx --version`. - placeholder: "nyx 0.6.0" + placeholder: "nyx 0.7.0" validations: required: true - type: input diff --git a/CHANGELOG.md b/CHANGELOG.md index cb537cf5..c85b51bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,90 +2,111 @@ All notable changes to Nyx are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). For where Nyx is going, see the [Roadmap](ROADMAP.md). -## [Unreleased] +## [0.7.0] - 2026-05-11 -A round of cross-file FastAPI auth, two new sink/validator classes, a ~957-FP Go DAO helper precision pass, four CVE corpus pairs, a local web UI visual refresh, and a performance pass on the auth extractor pipeline plus SCCP and the global summaries hash map. +A focused release that adds seven new vulnerability classes, ships two SSA sidecars for XML and XPath parser hardening, deepens cross-file authorization for FastAPI, trims roughly a thousand auth false positives on Go DAO helpers along with the dominant Hibernate Criteria SQL cluster, and runs a performance pass on the auth extractor, SCCP, and the global summaries map. A `nyx rules list` CLI surfaces the rule registry, the web UI gets a brand-aligned visual refresh, and the CVE corpus grows across Python, PHP, JavaScript, and C. -This branch also adds seven new vulnerability classes (LDAP injection, XPath injection, header / CRLF injection, open redirect, server-side template injection, XXE, prototype pollution), a `nyx rules` CLI subcommand, two SSA configuration sidecars (XML parser hardening, XPath variable resolver), two new path-state predicates for inline open-redirect sanitisers, and a flow-sensitive `Object.create(null)` recogniser for prototype-pollution suppression. +### Highlights + +- New caps for LDAP injection, XPath injection, header / CRLF injection, open redirect, server-side template injection, XXE, and prototype pollution, with per-language label rules across all eight supported languages. +- Cross-file FastAPI authorization: `include_router` chains and module-level `APIRouter(dependencies=[…])` now lift onto every attached route, with `Security(..., scopes=[...])` recognised distinctly from `Depends(...)`. +- Type-tracked XML and XPath hardening through two new SSA sidecars: parser bodies that set `secure_processing` / `processEntities: false` / `resolve_entities=False`, and `XPath` instances bound to `setXPathVariableResolver(...)`, are recognised as safe. +- ~957 `go.auth.missing_ownership_check` findings closed on gitea-shaped DAO helpers (id-scalar precision pass), 169 of 216 openmrs `cfg-unguarded-sink` findings closed on Hibernate Criteria-API receivers, joomla and drupal `php.deser.unserialize` closed on `Serializable::unserialize($input)` magic-method bodies. +- `nyx rules list` CLI subcommand, brand-aligned `nyx serve` visual refresh, and regenerated README / docs screenshots and GIFs. ### Detector classes -- New `Cap` bits and canonical rule ids: `Cap::LDAP_INJECTION` / `taint-ldap-injection`, `Cap::XPATH_INJECTION` / `taint-xpath-injection`, `Cap::HEADER_INJECTION` / `taint-header-injection`, `Cap::OPEN_REDIRECT` / `taint-open-redirect`, `Cap::SSTI` / `taint-template-injection`, `Cap::XXE` / `taint-xxe`, `Cap::PROTOTYPE_POLLUTION` / `taint-prototype-pollution`. Each ships with per-language sink, sanitizer, and (where applicable) gated-sink rules across JS/TS, Python, Java, PHP, Go, Ruby, Rust, and C/C++. Severity, OWASP 2021 mapping, and human-readable description live in a single `CAP_RULE_REGISTRY` table in `src/labels/mod.rs`; `cap_rule_meta()` and `rule_id_for_caps()` are the public lookups. -- `Cap` widened from `u16` to `u32` to fit the new bits. `Evidence.sink_caps` is now `u32`; `RuleInfo.cap_bits` is also `u32`. The serde decoder accepts any unsigned integer width so caches written before the bump still load. SQLite schema bumped 3 to 4 to force a rescan, since older `source_caps` / `sanitizer_caps` / `sink_caps` blobs were emitted before any of the new bits could appear. -- `owasp_bucket_for` consults `CAP_RULE_REGISTRY` first so adding a new cap class does not require a second-table edit. The match requires an exact rule id or a recognised separator (` `, `(`, `.`) so a future `taint-ssrf-allowlist-violation` can no longer silently inherit `taint-ssrf`'s OWASP bucket. The legacy family-token table now also routes `xpath`, `header`, and `xxe` to A03 / A05. +- New `Cap` bits and canonical rule ids: `Cap::LDAP_INJECTION` / `taint-ldap-injection`, `Cap::XPATH_INJECTION` / `taint-xpath-injection`, `Cap::HEADER_INJECTION` / `taint-header-injection`, `Cap::OPEN_REDIRECT` / `taint-open-redirect`, `Cap::SSTI` / `taint-template-injection`, `Cap::XXE` / `taint-xxe`, `Cap::PROTOTYPE_POLLUTION` / `taint-prototype-pollution`. Each ships per-language sink, sanitizer, and gated-sink rules across JS/TS, Python, Java, PHP, Go, Ruby, Rust, and C/C++. Severity, OWASP 2021 mapping, and human-readable description live in `CAP_RULE_REGISTRY` in `src/labels/mod.rs`; `cap_rule_meta()` and `rule_id_for_caps()` are the public lookups. +- `Cap` widened from `u16` to `u32` to fit the new bits. `Evidence.sink_caps` and `RuleInfo.cap_bits` follow. The serde decoder accepts any unsigned integer width so caches written before the bump still load. SQLite schema bumped from 3 to 4 to force a rescan, since older `source_caps` / `sanitizer_caps` / `sink_caps` blobs were emitted before any of the new bits could appear. +- `owasp_bucket_for` consults `CAP_RULE_REGISTRY` first so adding a cap class no longer requires a second-table edit. The match requires an exact rule id or a recognised separator (` `, `(`, `.`) so a future `taint-ssrf-allowlist-violation` cannot silently inherit `taint-ssrf`'s bucket. The legacy family-token table now also routes `xpath`, `header`, and `xxe` to A03 / A05. - `issue_category_label` (dashboard badge) routes the seven new rule-id prefixes to dedicated labels: LDAP Injection, XPath Injection, Header Injection, Open Redirect, Template Injection, XXE, Prototype Pollution. -### Changed +### Engine -- Refreshed the local web UI visual system around the mint-cyan Nyx brand: warmer light surfaces, deep green accents, updated severity/confidence colors, tighter typography, smaller radii, denser cards, table, badge, button, header, and sidebar styling, and matched graph/code-viewer colors. -- Reworked the main `nyx serve` surfaces for a more operational layout. Overview now uses the refreshed health-score card and chart grid; Scans has a fixed compact table with capped language badges; Scan Detail places summary and timing data side by side; Triage, Rules, Config, Explorer, Finding Detail, Scan Compare, and Debug pages received focused spacing, overflow, and density fixes. -- Updated the SPA and embedded server bundle to share branded assets: PNG favicons, Apple touch icon, sidebar logo image, refreshed SVG favicon, and Rust static handlers for the new `/logo.png` and favicon files. -- Regenerated README/docs screenshots and GIFs against the new UI treatment at 1600x992, saving raw originals before framing and adding CLI GIF plus combined CLI-to-serve demo GIF capture support. -- Extended the screenshot capture workflow with mint-led framing copy, optional `nyxscan.dev` asset mirroring, WebP regeneration for mirrored PNGs, and raw `_raw` image/GIF outputs for downstream reuse. +- **XML-parser configuration tracking.** `src/ssa/xml_config.rs` runs alongside type-fact analysis and carries per-receiver `secure_processing` / `disallow_doctype` / `external_entities` flags forward through copy assignments and phi joins (meet for safe flags, sticky union for the unsafe `external_entities` polarity). `xxe_safe()` queries the result at the type-qualified `XmlParser.parse` sink and strips `Cap::XXE` when the parser was provably hardened (JAXP `setFeature(FEATURE_SECURE_PROCESSING, true)`, lxml `XMLParser(resolve_entities=False, no_network=True)`, fast-xml-parser `processEntities: false`). Persisted to `OptimizeResult.xml_parser_config`. +- **XPath-receiver configuration tracking.** `src/ssa/xpath_config.rs` mirrors the XML sidecar for Java's `XPath` instances: `setXPathVariableResolver(...)` flips the receiver's `has_resolver` flag, copy assignments union, phi joins meet. `xpath_safe()` strips `Cap::XPATH_INJECTION` at `xpath.evaluate(expr, ...)` / `xpath.compile(expr)` sinks when the receiver was provably bound to a resolver. Persisted to `OptimizeResult.xpath_config`. +- **Five new `TypeKind` variants.** `LdapClient` (JNDI `InitialDirContext` / `InitialLdapContext`, Spring `LdapTemplate`, ldapjs `createClient`, python-ldap `initialize`, ldap3 `Connection`), `XPathClient` (JAXP `newXPath`, lxml `etree.XPath`, npm `xpath`), `XmlParser` (JAXP factory products: `newDocumentBuilder`, `newSAXParser`, `getXMLReader`), `Template` (FreeMarker `new Template(...)` / `Configuration.getTemplate`), and `NullPrototypeObject` for JS/TS values produced by `Object.create(null)`. Wired into `constructor_type` for return-type inference and `TypeKind::label_prefix()` for type-qualified callee resolution. `XPathClient` is kept distinct from `DatabaseConnection` so a generic `pdo->query` SQL_QUERY sink does not collide with `xpath.query`. +- **`GateActivation::LiteralOnly`.** Strict literal-value activation: the gate fires only when the activation argument is a literal that matches `dangerous_values` / `dangerous_prefixes`. Unknown or dynamic activation argument suppresses (no conservative `ALL_ARGS_PAYLOAD` push). Used where the dangerous shape is identifiable only by an explicit literal flag, e.g. `jQuery.extend(true, target, src)` deep-merge against Backbone's `Model.extend({proto})`. +- **Two new path-state predicates for inline open-redirect sanitisers.** `RelativeUrlValidated` covers `x.startsWith("/")`, `x.starts_with("/")`, `x.startswith("/")`, PHP `strpos($x, "/") === 0`, and direct `x[0] === "/"`. `HostAllowlistValidated` covers `new URL(x).host === ALLOWED`, `urlparse(x).netloc == ALLOWED`, multi-statement `parsed.host_str() == "..."` for Rust, and `parsed.Host == "..."` / `parsed.Hostname() == "..."` for Go. Both clear `Cap::OPEN_REDIRECT` only on the validated branch, leaving any non-redirect taint downstream to fire on its own caps. The Go form gates on case-sensitive capital `H` so a lowercase `u.host == X` field comparison falls through to the generic `Comparison` predicate. +- **`Object.create(null)` recogniser.** `is_object_create_null_call` in `cfg/literals.rs` matches `Object.create(null)` (and parenthesised, awaited, or TS type-cast wrappers) and tags `CallMeta.produces_null_proto = true`. Type-fact analysis lifts the flag to `TypeKind::NullPrototypeObject` on the returned SSA value so the synthetic `__index_set__` sink is suppressed flow-sensitively. Phi joins drop the tag back to `Unknown` so a partial null-proto receiver still fires on the unsafe path. +- **CFG-layer prototype-pollution suppression** at the synthetic `__index_set__` sink (JS/TS, recognised by the existing `try_lower_subscript_write` lowering). Three flow-insensitive shapes elide the `Sink(PROTOTYPE_POLLUTION)` label before SSA sees the node: constant-key fold (literal key not in `__proto__` / `constructor` / `prototype`), reject pattern (sibling `if (idx === "__proto__" || ...) return / throw / break;`), and allowlist pattern (ancestor `if (idx === "name" || idx === "id") { obj[idx] = v }`). Walks stop at the enclosing function so closure-captured guards in an outer scope cannot silently authorise inner assignments. +- **Spring MVC `return "redirect:" + tainted` recogniser** (Java). `try_lower_spring_redirect_return` in `cfg/mod.rs` matches the leftmost `+`-chain whose root is a `redirect:` string literal and emits a synthetic `__spring_redirect__` Call sink with `Sink(Cap::OPEN_REDIRECT)` between the predecessors and the Return node. Concatenated identifiers from anywhere in the right-hand chain feed the synthetic node's `arg_uses[0]`, so the taint pipeline carries any tainted suffix through OPEN_REDIRECT. +- **Subscript-set form classification for header sinks.** `response.headers["X-Foo"] = bar` / `headers["X-Foo"] = bar` (Ruby `element_reference`, JS/TS `subscript_expression`, Python `subscript`) had no `property` field on the LHS. `push_node` now walks into the subscript's `object` and classifies its member-expression text, so `Cap::HEADER_INJECTION` fires on the bare bracket form alongside `setHeader` / `res.set` / `headers_mut.insert`. +- **PHP literal extraction** extended in `cfg/literals.rs`: PHP `encapsed_string` (double-quoted) when every child is a pure-literal segment; boolean literals (`true` / `false`) for the jQuery `extend(true, ...)` `LiteralOnly` gate; leading-string `binary_expression` concat (`"Location: " . $url`, JS/TS `"Location: " + url`) so `dangerous_prefixes` matching activates on partially dynamic concatenations. +- **PHP receiver-text strip** in `helpers::root_receiver_text` drops the leading `$` from `variable_name` nodes so `$smarty->fetch(...)` / `$twig->createTemplate(...)` reconstruct as `Smarty.fetch` / `Environment.createTemplate` for suffix-matcher gates. +- **Gate-callee resolution hardening for member-source rewrites.** When `first_member_label` rewrites a call's `text` to a Source like `req.body`, the gate matcher now reads the call's `function` / `method` / `name` field instead, so `setValue(target, req.body, ...)` matches the `setValue` proto-pollution gate. Whitespace stripped from the function field so multi-line chains still match flat gate matchers. +- **Ruby option-constant lookup in gate activation.** Bare `scope_resolution` / `constant` nodes (`Nokogiri::XML::ParseOptions::NOENT`) now fall back to the macro-arg extractor used by C/C++/PHP, so Nokogiri XXE gates activate on idiomatic option-flag arguments. +- **PHP `unary_op_expression` negation recognition.** tree-sitter-php emits `unary_op_expression` for unary `!`; CFG `detect_negation` and condition-chain decomposition now match it, so `if (!validate($x))` no longer carries `condition_negated=false` and the surviving branch is the rejection arm, not the validated one. +- **PHP container kinds.** `declaration_list`, `interface_declaration`, `trait_declaration`, `enum_declaration`, `enum_declaration_list` mapped to `Kind::Block` so methods inside them participate in CFG construction. +- **Go variadic `parameter_declaration` named-field handling** for `collect_param_names`. `name` and `type` named fields read directly so type-segment identifiers no longer pollute the param-name set (`info *PackageInfo` no longer contributes `PackageInfo`). +- **Empty-formals SSA lowering signal.** Per-parameter summary probing now seeds via `BodyMeta.param_destructured_fields`; JS/TS arrow `() => {…}` lowers with `with_params=true` so it is treated as "explicitly zero formals" rather than "no formals info". -### Added +### Authorization -- `nyx rules list` CLI subcommand. Surfaces the same registry the dashboard's `/api/rules` page reads from: built-in cap-class entries (one per `Cap` with a canonical rule id), per-language label rules (sink / source / sanitizer), gated sinks, and any custom rules from config. Filters: `--lang `, `--kind `, `--class-only` for registry entries only, `--no-class` for per-language rules only. `--json` for machine output. Cap-class entries carry `language = "all"` so a language filter still surfaces them unless `--no-class` is set. -- `RuleInfo.is_class` / `RuleInfo.emission_active` flags. Cap-class entries carry `is_class = true` so dashboards can group them separately from per-language label rules. `emission_active = false` marks legacy classes (SQL_QUERY, SSRF, FILE_IO, FMT_STRING, DESERIALIZE, CODE_EXEC, CRYPTO) whose findings still surface under the catch-all `taint-unsanitised-flow` rule id; the seven new classes plus `unauthorized_id` and `data_exfil` are `emission_active = true`. The active set is pinned in `cap_rule_registry_emission_active_set_is_pinned` so a future migration of a legacy cap to its specific rule id can't drift silently. -- XML-parser configuration tracking. New `src/ssa/xml_config.rs` runs alongside type-fact analysis and carries per-receiver `secure_processing` / `disallow_doctype` / `external_entities` flags forward through copy assignments and phi joins (meet for safe flags, sticky union for the unsafe `external_entities` polarity). `xxe_safe()` queries the result at the type-qualified `XmlParser.parse` sink and strips `Cap::XXE` when the parser was provably hardened (JAXP `setFeature(FEATURE_SECURE_PROCESSING, true)`, lxml `XMLParser(resolve_entities=False, no_network=True)`, fast-xml-parser `processEntities: false`). Persisted to `OptimizeResult.xml_parser_config`. -- XPath-receiver configuration tracking. New `src/ssa/xpath_config.rs` mirrors the XML sidecar for Java's `XPath` instances: `setXPathVariableResolver(...)` flips the receiver's `has_resolver` flag, copy assignments union, phi joins meet. `xpath_safe()` strips `Cap::XPATH_INJECTION` at `xpath.evaluate(expr, ...)` / `xpath.compile(expr)` sinks when the receiver was provably bound to a resolver (parameterised XPath shape). Persisted to `OptimizeResult.xpath_config`. -- Five new `TypeKind` variants: `LdapClient` (JNDI `InitialDirContext` / `InitialLdapContext`, Spring `LdapTemplate`, ldapjs `createClient`, python-ldap `initialize`, ldap3 `Connection`), `XPathClient` (JAXP `newXPath`, lxml `etree.XPath`, npm `xpath`), `XmlParser` (JAXP factory products: `newDocumentBuilder`, `newSAXParser`, `getXMLReader`), `Template` (Apache FreeMarker `new Template(...)` / `Configuration.getTemplate`), and `NullPrototypeObject` for JS/TS values produced by `Object.create(null)`. Each is wired into `constructor_type` for return-type inference and into `TypeKind::label_prefix()` for type-qualified callee resolution. `XPathClient` is kept distinct from `DatabaseConnection` so a generic `pdo->query` SQL_QUERY sink does not collide with `xpath.query`. -- `GateActivation::LiteralOnly`. Strict literal-value activation: the gate fires only when the activation argument is a literal that matches `dangerous_values` / `dangerous_prefixes`. Unknown or dynamic activation argument suppresses (no conservative `ALL_ARGS_PAYLOAD` push). Used for ambiguously named matchers where the dangerous shape is identifiable only by an explicit literal flag, e.g. bare `extend` where `jQuery.extend(true, target, src)` is the deep-merge prototype-pollution form but Backbone's `Model.extend({proto})` shares the suffix. -- Two new `PredicateKind` variants in `src/taint/path_state.rs` for inline open-redirect sanitisers. `RelativeUrlValidated` covers `x.startsWith("/")`, `x.starts_with("/")`, `x.startswith("/")`, PHP `strpos($x, "/") === 0`, and direct `x[0] === "/"`. `HostAllowlistValidated` covers `new URL(x).host === ALLOWED`, `urlparse(x).netloc == ALLOWED`, multi-statement `parsed.host_str() == "..."` for Rust, and `parsed.Host == "..."` / `parsed.Hostname() == "..."` for Go. Both are cap-aware: they clear `Cap::OPEN_REDIRECT` only on the validated branch, leaving any non-redirect taint downstream to fire on its own caps. The Go form gates on case-sensitive capital `H` so a lowercase `u.host == X` field comparison falls through to the generic `Comparison` predicate. -- `Object.create(null)` recogniser. New `is_object_create_null_call` in `cfg/literals.rs` matches `Object.create(null)` (and parenthesised, awaited, or TS type-cast wrappers) and tags `CallMeta.produces_null_proto = true` for JS/TS calls. Type-fact analysis lifts the flag to `TypeKind::NullPrototypeObject` on the returned SSA value so the synthetic `__index_set__` sink is suppressed flow-sensitively. Phi joins drop the tag back to `Unknown` so a partial null-proto receiver still fires on the unsafe path. -- CFG-layer prototype-pollution suppression on the synthetic `__index_set__` sink (JS/TS only, recognised by the existing `try_lower_subscript_write` lowering). Three flow-insensitive shapes elide the `Sink(PROTOTYPE_POLLUTION)` label before SSA sees the node: constant-key fold (literal key not in `__proto__` / `constructor` / `prototype`); reject pattern (an enclosing-block sibling `if (idx === "__proto__" || ...) return / throw / break;`); allowlist pattern (an ancestor `if (idx === "name" || idx === "id") { obj[idx] = v }`). Walks stop at the enclosing function so closure-captured guards in an outer scope can't silently authorise inner assignments. -- Spring MVC `return "redirect:" + tainted` open-redirect recogniser (Java only). New `try_lower_spring_redirect_return` in `cfg/mod.rs` matches the leftmost `+`-chain whose root is a `redirect:` string literal and emits a synthetic `__spring_redirect__` Call sink with `Sink(Cap::OPEN_REDIRECT)` between the predecessors and the Return node. Concatenated identifiers from anywhere in the right-hand chain feed the synthetic node's `arg_uses[0]`, so the existing taint pipeline carries any tainted suffix through OPEN_REDIRECT. -- Subscript-set form classification for header sinks. `response.headers["X-Foo"] = bar` / `headers["X-Foo"] = bar` (Ruby `element_reference`, JS/TS `subscript_expression`, Python `subscript`) had no `property` field on the LHS, so the existing classification path skipped it. `push_node` now walks into the subscript's `object` and classifies its member-expression text (`response.headers`, `res.headers`, `self.response.headers`), so `Cap::HEADER_INJECTION` fires on the bare bracket form alongside `setHeader` / `res.set` / `headers_mut.insert`. -- PHP literal extraction extended in `cfg/literals.rs`. `extract_const_string_arg` now folds: PHP `encapsed_string` (double-quoted) when every child is a pure-literal segment; boolean literals (`true` / `false`) so jQuery's `extend(true, target, src)` deep-merge marker activates the `LiteralOnly` gate; leading-string `binary_expression` concat (PHP `"Location: " . $url`, JS/TS `"Location: " + url`) so `dangerous_prefixes` matching activates on partially dynamic concatenations. -- PHP receiver-text strip for chain construction. `helpers::root_receiver_text` now drops the leading `$` from `variable_name` nodes so `$smarty->fetch(...)` / `$twig->createTemplate(...)` reconstruct as `Smarty.fetch` / `Environment.createTemplate` for suffix-matcher gates instead of carrying a `$smarty.fetch` form that fails the boundary rule. -- Gate-callee resolution hardening for member-source rewrites. When `first_member_label` rewrites a call's `text` to a Source like `req.body` (because the wrapper carries a member-source argument), the gate matcher now reads the call's `function` / `method` / `name` field instead, so `setValue(target, req.body, ...)` matches the `setValue` proto-pollution gate instead of the rewritten `req.body` text. Whitespace stripped from the function field so multi-line chains still match flat gate matchers. -- Ruby option-constant lookup in gate activation. Bare `scope_resolution` / `constant` nodes (`Nokogiri::XML::ParseOptions::NOENT`) now fall back to the macro-arg extractor used by C/C++/PHP, so Nokogiri XXE gates activate on idiomatic option-flag arguments rather than firing conservatively on every positional arg. -- Per-language label rules expanded to cover the seven new caps: - - JavaScript / TypeScript: ldapjs `LdapClient.search`, `escapeXpath` / `xpathEscape`, `document.evaluate` / npm `xpath.select`, `setHeader` / `res.set` / `res.append` / `res.headers[]=`, `stripCRLF` / `escapeHeader`, lodash / dot-prop / object-path deep-merge prototype-pollution gates, Handlebars / EJS / Mustache template sinks, fast-xml-parser / xml2js with `processEntities`-aware activation, `redirect` / `Location` open-redirect sinks. - - Python: python-ldap `LDAPObject.search_s`, ldap3 `Connection.search`, lxml `etree.XPath` / `lxml.etree.parse` with parser-config awareness, Flask `response.headers[]=` / `make_response`, Jinja2 `Template(...)` and Mako `Template(...)` SSTI sinks, `flask.redirect` / `aiohttp HTTPFound` open-redirect. - - Java / Kotlin: `DirContext.search`, `XPath.evaluate` / `XPath.compile`, JAXP `DocumentBuilder.parse` / `SAXParser.parse` / `XMLReader.parse`, FreeMarker `Template.process`, Spring `redirect:` view-name synthetic sink, `HttpServletResponse.setHeader` / `addHeader`. - - PHP: `ldap_search` / `ldap_list` / `ldap_read`, `DOMXPath::query` / `DOMXPath::evaluate`, `header()` with leading-prefix activation, Smarty `fetch` / Twig `createTemplate` / Blade compile + `eval` template forms, `loadXML` / `simplexml_load_string` with `LIBXML_NOENT` activation. - - Go: `go-ldap conn.Search`, `etree.Path` / `xmlpath.Compile`, `http.Header.Set` / `Response.Header().Set`, `html/template` and `text/template` `Parse(...)`, `encoding/xml.Unmarshal` / `Decoder.Decode`, `http.Redirect` with relative-URL / host-allowlist gating. - - Ruby: `Net::LDAP#search`, `Nokogiri::XML::Document#xpath`, `response.headers[]=`, `ERB.new` SSTI, `Nokogiri::XML.parse` with `NOENT` / `DTDLOAD` activation, `redirect_to` with relative-URL gate. - - C / C++: libldap `ldap_search_ext_s`, libxml2 `xmlXPathEval`, `curl_easy_setopt` with header-list activation, libxml2 `xmlReadFile` / `xmlReadMemory` with `XML_PARSE_NOENT` activation. - - Rust: actix-web `HeaderMap.insert` / `HeaderValue::from_str` header-injection gates. `Redirect::to` retagged from `Cap::SSRF` to `Cap::OPEN_REDIRECT` so the open-redirect rule fires distinctly from the SSRF rule. -- `NYX_PYTHON_PROTO_POLLUTION` env var flag. Python `dict.update` / `__dict__.update` proto-pollution gates are opt-in: bare `update` overlaps too broadly with `Counter.update` and ordinary state-mutation patterns to ship as a default sink. When the var is set to `1` / `true` / `yes` / `on` the merged slice is leaked into a `'static` reference so the registry's lifetime invariant holds. -- New per-cap integration suites: `tests/{xpath_injection,xxe,ssti,prototype_pollution,header_injection,open_redirect,ldap_injection}_tests.rs`, plus `python_proto_pollution_tests.rs` for the env-gated Python form. Per-cap fixture trees under `tests/fixtures///` cover safe, unsafe, and irrelevant-baseline shapes for every supported language. -- FastAPI cross-file `include_router` dependency tracking. New `auth_analysis/router_facts.rs` captures per-file router declarations (` = X(deps=[…])`) and `.include_router(.)` edges in pass 1, persists them into `GlobalSummaries::router_facts_by_module`, and resolves them into the active file's `AuthorizationModel::cross_file_router_deps` at pass 2 entry. Transitive lifts (`grandparent → parent → child`) handled by iterative index walk. Module identity is the file basename without `.py` (approximate, but sufficient for airflow-style `task_instances.router` naming). Closes the airflow execution-API shape where a child router lives in `routes/task_instances.py` and its auth is declared on the parent in `routes/__init__.py`. -- FastAPI router-level `dependencies=[...]` propagation. Module-level `router = APIRouter(dependencies=[Security(...)])` declarations are pre-walked once per file, then merged onto every `@.(...)` route attached in the same file. Closes airflow's execution-API routes that re-use a single `ti_id_router` declared once at module scope. -- FastAPI `Security(callable, scopes=[...])` recognised distinctly from `Depends(callable)`. Scoped Security promotes the synthetic `AuthCheck` to `AuthCheckKind::Other` (route-level scope-checked authorization), not just Login. New scope-tracking boolean threaded through `expand_decorator_calls` and `extract_fastapi_dependencies`. -- SQLAlchemy query-builder chained-call recognition. `select(X).filter_by(...)`, `query(X).filter(...)`, `select().join().where()` chains now anchor through the chain root primitive when the chain receiver type is opaque. New `db_query_builder_roots` config (Python defaults: `select`, `query`). Closes airflow `session.scalar(select(C).filter_by(conn_id=user_input))` shapes that previously dropped under the chained-call suppression in `classify_sink_class`. -- Python non-sink container constructor recognition. Bare-callee form `set()` / `dict()` / `list()` / `tuple()` / `frozenset()` / `defaultdict(...)` is now treated as a non-sink constructor, so `verified_ids = set(); verified_ids.update(myteams)` does not classify the `.update` call as `DbMutation`. Type-annotation hint form `set[int]` / `dict[str, int]` recognised via PEP 585 generic suffix strip alongside the existing angle-bracket strip. Closes the sentry `api/helpers/teams.py` shape. -- Python `request.match_info` source label (aiohttp path-parameter source). -- Receiver-side validator registry. New `labels::lookup_receiver_validator(lang, callee)` clears `Cap` from the receiver value (and call equivalents) on success, distinct from `Sanitizer` which clears caps from the return value. Python registers `relative_to → Cap::FILE_IO` so `path.relative_to(base)` (raises `ValueError` when `path` escapes `base`) drops the file-IO cap on the path. Closes the CVE-2024-23334 patched aiohttp `static_root_path.joinpath(filename).resolve().relative_to(static_root_path)` shape. -- JS/TS Array-method validator-callback narrowing. `arr.filter(isSafeIdentifier)`, `arr.find(isValidId)`, `arr.findLast(...)` with a `BooleanTrueIsValid` callback (`isValid…`, `isSafe…`, `hasValid…` and snake-case variants) propagate `validated_must` through the call's return value. Resolves callback name from both `info.arg_callees` (call-shape arguments) and SSA `value_defs[v].var_name` (bare-identifier callbacks, the dominant patched-CVE form). Strict-additive: anonymous arrows / opaque identifiers leave existing propagation untouched. `findIndex` / `every` / `some` excluded (scalar return shape). Motivated by CVE-2026-42353 (i18next-http-middleware path traversal). -- JS/TS ternary-branch source classification. `let arr = cond ? req.query.lng : "";` previously lowered each branch to a labelless Assign with empty uses, the join phi saw no taint, and downstream sinks missed the flow. `lower_ternary_branch` now runs `first_member_label` (segment-strip-and-retry classifier) on the branch AST when no `Source` label is already attached. New `cfg/cfg_tests.rs` covers the lowering shape. -- Java JPA / Hibernate Criteria API as structural SQL. New `TypeKind::JpaCriteriaQuery` for `CriteriaQuery`, `CriteriaUpdate`, `CriteriaDelete`, `Subquery`, `TypedQuery`. New `cfg-unguarded-sink` SQL_QUERY suppression `sink_args_jpa_criteria_query_safe` clears the finding when any positional argument to the sink call is JpaCriteriaQuery-typed (receiver excluded; receiver of `session.createQuery(cq)` is the Session/EntityManager channel, never the SQL payload). Closes the dominant FP cluster on openmrs (169 of 216 cfg-unguarded-sink), xwiki, keycloak Hibernate DAO methods that build `cb.createQuery(Foo.class)` + Root/Predicate API queries. -- Java/Kotlin `cb.createQuery(...)`, `em.getCriteriaBuilder()`, and the JpaCriteriaQuery type chain inferred via constructor/factory return-type hints (extends the existing type-inference pipeline in `type_facts.rs`). -- PHP `fopen` modeled as `Sink(Cap::SSRF)`. Same SSRF/LFI dual-vector shape as `file_get_contents` — fires only on tainted argument. Closes CVE-2026-33486 (roadiz/documents `DownloadedFile::fromUrl` static method wrapping `fopen($url, 'r')`). -- PHP unary-op-expression negation recognition. tree-sitter-php emits `unary_op_expression` for unary `!` (and `-`/`+`/`~`); CFG `detect_negation` and condition-chain decomposition now match it. Without this, `if (!validate($x))` carried `condition_negated=false` and the True branch was treated as the validated path even though it is the rejection path. New PHP fixture `safe_camelcase_validator_negated.php` pins the lowering. -- PHP `Serializable::unserialize($input)` magic-method passthrough recognition. The legacy `Serializable` interface contract (deprecated since PHP 8.1) requires the implementation to call `\unserialize($input)` on the formal parameter inside `public function unserialize($x) { ... }`. PHP itself invokes the method when restoring an instance, so the body's call cannot be removed without breaking the interface. `php.deser.unserialize` now suppresses inside this exact shape (method named `unserialize`, single formal, bare-parameter argument). Class-level `Serializable` implementation is the actionable signal (fix is migration to `__serialize` / `__unserialize`). Closes joomla / drupal Serializable-implementing class FPs. -- PHP container kinds: `declaration_list`, `interface_declaration`, `trait_declaration`, `enum_declaration`, `enum_declaration_list` mapped to `Kind::Block` so methods inside them participate in CFG construction. -- Go DAO-helper id-scalar precision pass. For non-route Go units, a parameter whose declared type is a bounded primitive scalar (`int64`, `uint32`, `string`, `bool`, `byte`, `rune`, `float64`, …) and whose name is id-shaped (`id`, `*Id`, `*_id`, `*ids`) is dropped from `unit.params` before ownership-check evaluation. Real Go HTTP handlers always carry a framework-request-typed param (`*http.Request`, `*gin.Context`, `echo.Context`, `*fiber.Ctx`); per-framework route extractors set `include_id_like_typed=true` so id-shaped path params survive on real routes. Mirrors the existing Python `is_python_id_like_typed_param` filter. Closes ~957 `go.auth.missing_ownership_check` findings on gitea backend DAO helpers (`func GetRunByRepoAndID(ctx, repoID, runID int64)`, `func DeleteRunner(ctx, id int64)`, the entire `models/...` layer where the ownership check sits in the calling route handler) and equivalent shapes in minio / Go ORM codebases. -- Bare-callee verb-name fallback gate. `list(...)`, `filter(...)`, `update(...)`, `create_audit_entry(...)`, `update_coding_agent_state(...)` (no receiver dot at all) no longer classify as `DbMutation` / `DbCrossTenantRead` via the loose verb-name fallback. Real ORM/DB calls always carry a receiver (`User.find(id)`, `Model.objects.filter`, `repo.save(x)`); a bare `list(events)` is the Python builtin and `filter(fn, xs)` is `Iterable.filter`. The realtime / outbound / cache prefix dispatches still match by chain root. New helper `receiver_is_simple_chain(callee)` requires a non-chained receiver dot. -- Go variadic `parameter_declaration` named-field handling for `collect_param_names`. `name` and `type` named fields read directly so type-segment identifiers no longer pollute the param-name set (`info *PackageInfo` no longer contributes `PackageInfo`). -- Phase 1 caller-scope IPA: same-file route-handler-to-helper auth lift. New `apply_caller_scope_propagation` walks every non-route helper unit; if its in-file callers are non-empty AND every caller is itself an authorized route handler (route-level non-Login auth check) or already authorized via this same propagation, the caller's checks lift onto the helper as synthetic `is_route_level=true` `AuthCheck`s. Iterated to a small fixpoint so transitive helper chains (`route → mid_helper → leaf_helper`) are covered. Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers. Cross-file equivalent deferred (see `deep_engine_fixes.md`). Closes the dominant FastAPI / Django / Flask "route authenticates via decorator/dependency, then delegates to a private helper that performs the sink" FP shape on sentry / saleor / airflow. -- New Python pattern `py.xss.make_response_format` (Tier B). Flask `make_response()` reflection. Recognises both bare `make_response(...)` and `flask.make_response(...)`. Closes CVE-2023-6568 (mlflow auth `create_user` reflected the attacker-controlled `Content-Type` header into the response body via `make_response(f"Invalid content type: '{content_type}'", 400)`). -- C CVE corpus extended. CVE-2017-1000117 (git argv injection via `ssh://-oProxyCommand=…`) vulnerable + patched fixtures under `tests/benchmark/cve_corpus/c/CVE-2017-1000117/`. Three-layer engine gap deferred (array-element taint propagation, `c.cmdi.exec*` AST patterns, dash-prefix-byte sanitizer recognition). -- Python CVE corpus extended. CVE-2023-6568 (mlflow XSS), CVE-2024-21513 (langchain SQL/JINJA), CVE-2024-23334 (aiohttp static-file path traversal) vulnerable + patched fixtures. -- PHP CVE corpus extended. CVE-2026-33486 (roadiz/documents SSRF) vulnerable + patched fixtures. -- JavaScript CVE corpus extended. CVE-2026-42353 (i18next-http-middleware path traversal) vulnerable + patched fixtures. -- Cross-file FastAPI integration test `tests/fastapi_cross_file_include_router_tests.rs` with airflow-shaped fixture tree under `tests/fixtures/auth_cross_file/airflow_execution_api_includes/`. -- Per-language safe / vuln Python auth fixtures: `safe_local_set_update_no_orm.py`, `vuln_local_set_with_user_id_query.py`, `vuln_fastapi_route_no_dependencies_sqla.py`, `vuln_fastapi_route_security_no_scopes.py`, `safe_fastapi_route_security_scopes.py`, `vuln_fastapi_router_no_dependencies.py`, `safe_fastapi_router_level_security_scopes.py`, `safe_bare_callee_no_receiver.py`, `vuln_caller_scope_helper_under_bare_route.py`, `safe_caller_scope_helper_under_authorized_route.py`, `safe_relative_to_validator.py`, `path_traversal_no_relative_to.py`. Java `SafeJpaCriteriaQuery.java`. Go `safe_dao_helper_id_scalar.go`, `vuln_repo_findbyid_no_auth.go`. PHP `ssrf_class_method_fopen.php`, `safe_camelcase_validator_negated.php`, `safe_serializable_magic_method_unserialize.php`, `vuln_serialize_method_named_unserialize_with_user_input.php`. JS `path_traversal_ternary_source.js`, `safe_ternary_const_branches.js`. TS `safe_session_user_id_copy.ts`, `vuln_target_user_id_no_check.ts`. +- **FastAPI cross-file `include_router` dependency tracking.** `auth_analysis/router_facts.rs` captures per-file router declarations (` = X(deps=[…])`) and `.include_router(.)` edges in pass 1, persists them into `GlobalSummaries::router_facts_by_module`, and resolves them into the active file's `AuthorizationModel::cross_file_router_deps` at pass 2 entry. Transitive lifts (grandparent to parent to child) handled by iterative index walk. Module identity is the file basename without `.py`. Closes the airflow execution-API shape where a child router lives in `routes/task_instances.py` and its auth is declared on the parent in `routes/__init__.py`. +- **FastAPI router-level `dependencies=[...]` propagation.** Module-level `router = APIRouter(dependencies=[Security(...)])` is pre-walked once per file and merged onto every `@.(...)` route attached in the same file. Closes airflow execution-API routes that re-use a single `ti_id_router` declared once at module scope. +- **FastAPI `Security(callable, scopes=[...])` recognised distinctly from `Depends(callable)`.** Scoped Security promotes the synthetic `AuthCheck` to `AuthCheckKind::Other` (route-level scope-checked authorization), not Login. New scope-tracking boolean threaded through `expand_decorator_calls` and `extract_fastapi_dependencies`. +- **Caller-scope IPA: same-file route-handler-to-helper auth lift.** `apply_caller_scope_propagation` walks every non-route helper unit; if its in-file callers are non-empty AND every caller is itself an authorized route handler (route-level non-Login auth check) or already authorized via this same propagation, the caller's checks lift onto the helper as synthetic `is_route_level=true` `AuthCheck`s. Iterated to a small fixpoint so transitive helper chains (route to mid_helper to leaf_helper) are covered. Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers. Cross-file equivalent deferred. Closes the dominant FastAPI / Django / Flask "route authenticates via decorator/dependency, then delegates to a private helper that performs the sink" FP shape on sentry / saleor / airflow. +- **Go DAO-helper id-scalar precision pass.** For non-route Go units, a parameter whose declared type is a bounded primitive scalar (`int64`, `uint32`, `string`, `bool`, `byte`, `rune`, `float64`, …) and whose name is id-shaped (`id`, `*Id`, `*_id`, `*ids`) is dropped from `unit.params` before ownership-check evaluation. Real Go HTTP handlers always carry a framework-request-typed param (`*http.Request`, `*gin.Context`, `echo.Context`, `*fiber.Ctx`); per-framework route extractors set `include_id_like_typed=true` so id-shaped path params survive on real routes. Mirrors the existing Python `is_python_id_like_typed_param` filter. Closes ~957 `go.auth.missing_ownership_check` findings on gitea backend DAO helpers (`func GetRunByRepoAndID(ctx, repoID, runID int64)`, `func DeleteRunner(ctx, id int64)`, the entire `models/...` layer where the ownership check sits in the calling route handler) and equivalent shapes in minio / Go ORM codebases. +- **Bare-callee verb-name fallback gate.** `list(...)`, `filter(...)`, `update(...)`, `create_audit_entry(...)`, `update_coding_agent_state(...)` (no receiver dot at all) no longer classify as `DbMutation` / `DbCrossTenantRead` via the loose verb-name fallback. Real ORM/DB calls carry a receiver (`User.find(id)`, `Model.objects.filter`, `repo.save(x)`); a bare `list(events)` is the Python builtin and `filter(fn, xs)` is `Iterable.filter`. New helper `receiver_is_simple_chain(callee)` requires a non-chained receiver dot. The realtime / outbound / cache prefix dispatches still match by chain root. + +### Type-aware sinks and validators + +- **Java JPA / Hibernate Criteria API as structural SQL.** `TypeKind::JpaCriteriaQuery` covers `CriteriaQuery`, `CriteriaUpdate`, `CriteriaDelete`, `Subquery`, `TypedQuery`. `sink_args_jpa_criteria_query_safe` clears `cfg-unguarded-sink` SQL_QUERY when any positional argument to the sink call is JpaCriteriaQuery-typed (receiver excluded; receiver of `session.createQuery(cq)` is the Session/EntityManager channel, never the SQL payload). `cb.createQuery(...)`, `em.getCriteriaBuilder()`, and the JpaCriteriaQuery type chain inferred via constructor / factory return-type hints in `type_facts.rs`. Closes the dominant FP cluster on openmrs (169 of 216 cfg-unguarded-sink), xwiki, and keycloak Hibernate DAO methods. +- **Receiver-side validator registry.** `labels::lookup_receiver_validator(lang, callee)` clears `Cap` from the receiver value (and call equivalents) on success, distinct from `Sanitizer` which clears caps from the return value. Python registers `relative_to => Cap::FILE_IO` so `path.relative_to(base)` drops the file-IO cap on the path. Closes the CVE-2024-23334 patched aiohttp `static_root_path.joinpath(filename).resolve().relative_to(static_root_path)` shape. +- **JS/TS Array-method validator-callback narrowing.** `arr.filter(isSafeIdentifier)`, `arr.find(isValidId)`, `arr.findLast(...)` with a `BooleanTrueIsValid` callback (`isValid…`, `isSafe…`, `hasValid…` and snake-case variants) propagate `validated_must` through the call's return value. Resolves callback name from `info.arg_callees` (call-shape arguments) and SSA `value_defs[v].var_name` (bare-identifier callbacks, the dominant patched-CVE form). Strict-additive: anonymous arrows / opaque identifiers leave existing propagation untouched. `findIndex` / `every` / `some` excluded (scalar return shape). Motivated by CVE-2026-42353. +- **JS/TS ternary-branch source classification.** `let arr = cond ? req.query.lng : "";` previously lowered each branch to a labelless Assign with empty uses; the join phi saw no taint. `lower_ternary_branch` now runs `first_member_label` on the branch AST when no `Source` label is already attached. +- **PHP `fopen` modeled as `Sink(Cap::SSRF)`** (same dual SSRF / LFI shape as `file_get_contents`; fires only on tainted argument). Closes CVE-2026-33486 (roadiz/documents `DownloadedFile::fromUrl` wrapping `fopen($url, 'r')`). +- **PHP `Serializable::unserialize($input)` magic-method passthrough recognition.** The legacy `Serializable` interface contract (deprecated since PHP 8.1) requires the implementation to call `\unserialize($input)` on the formal parameter inside `public function unserialize($x) { ... }`. PHP itself invokes the method when restoring an instance, so the body's call cannot be removed without breaking the interface. `php.deser.unserialize` now suppresses inside this exact shape (method named `unserialize`, single formal, bare-parameter argument). Class-level `Serializable` implementation is the actionable signal (fix is migration to `__serialize` / `__unserialize`). Closes joomla / drupal Serializable-implementing class FPs. +- **SQLAlchemy query-builder chained-call recognition.** `select(X).filter_by(...)`, `query(X).filter(...)`, `select().join().where()` chains now anchor through the chain root primitive when the chain receiver type is opaque. New `db_query_builder_roots` config (Python defaults: `select`, `query`). Closes airflow `session.scalar(select(C).filter_by(conn_id=user_input))` shapes that previously dropped under the chained-call suppression in `classify_sink_class`. +- **Python non-sink container constructor recognition.** Bare-callee `set()` / `dict()` / `list()` / `tuple()` / `frozenset()` / `defaultdict(...)` is treated as a non-sink constructor, so `verified_ids = set(); verified_ids.update(myteams)` does not classify the `.update` call as `DbMutation`. Type-annotation hint form `set[int]` / `dict[str, int]` recognised via PEP 585 generic suffix strip alongside the existing angle-bracket strip. +- **Python `request.match_info` source label** (aiohttp path-parameter source). +- **New Python pattern `py.xss.make_response_format` (Tier B).** Flask `make_response()` reflection. Recognises both bare `make_response(...)` and `flask.make_response(...)`. Closes CVE-2023-6568 (mlflow auth `create_user` reflecting attacker-controlled `Content-Type` header into the response body). + +### Language coverage + +Per-language label rules expanded for the seven new caps. + +- **JavaScript / TypeScript:** ldapjs `LdapClient.search`, `escapeXpath` / `xpathEscape`, `document.evaluate` / npm `xpath.select`, `setHeader` / `res.set` / `res.append` / `res.headers[]=`, `stripCRLF` / `escapeHeader`, lodash / dot-prop / object-path deep-merge prototype-pollution gates, Handlebars / EJS / Mustache template sinks, fast-xml-parser / xml2js with `processEntities`-aware activation, `redirect` / `Location` open-redirect sinks. +- **Python:** python-ldap `LDAPObject.search_s`, ldap3 `Connection.search`, lxml `etree.XPath` / `lxml.etree.parse` with parser-config awareness, Flask `response.headers[]=` / `make_response`, Jinja2 `Template(...)` and Mako `Template(...)` SSTI sinks, `flask.redirect` / `aiohttp HTTPFound` open-redirect. +- **Java / Kotlin:** `DirContext.search`, `XPath.evaluate` / `XPath.compile`, JAXP `DocumentBuilder.parse` / `SAXParser.parse` / `XMLReader.parse`, FreeMarker `Template.process`, Spring `redirect:` view-name synthetic sink, `HttpServletResponse.setHeader` / `addHeader`. +- **PHP:** `ldap_search` / `ldap_list` / `ldap_read`, `DOMXPath::query` / `DOMXPath::evaluate`, `header()` with leading-prefix activation, Smarty `fetch` / Twig `createTemplate` / Blade compile + `eval` template forms, `loadXML` / `simplexml_load_string` with `LIBXML_NOENT` activation. +- **Go:** `go-ldap conn.Search`, `etree.Path` / `xmlpath.Compile`, `http.Header.Set` / `Response.Header().Set`, `html/template` and `text/template` `Parse(...)`, `encoding/xml.Unmarshal` / `Decoder.Decode`, `http.Redirect` with relative-URL / host-allowlist gating. +- **Ruby:** `Net::LDAP#search`, `Nokogiri::XML::Document#xpath`, `response.headers[]=`, `ERB.new` SSTI, `Nokogiri::XML.parse` with `NOENT` / `DTDLOAD` activation, `redirect_to` with relative-URL gate. +- **C / C++:** libldap `ldap_search_ext_s`, libxml2 `xmlXPathEval`, `curl_easy_setopt` with header-list activation, libxml2 `xmlReadFile` / `xmlReadMemory` with `XML_PARSE_NOENT` activation. +- **Rust:** actix-web `HeaderMap.insert` / `HeaderValue::from_str` header-injection gates. `Redirect::to` retagged from `Cap::SSRF` to `Cap::OPEN_REDIRECT` so the open-redirect rule fires distinctly from the SSRF rule. + +`NYX_PYTHON_PROTO_POLLUTION` opt-in flag: Python `dict.update` / `__dict__.update` proto-pollution gates are off by default because bare `update` overlaps too broadly with `Counter.update` and ordinary state-mutation patterns to ship as a default sink. + +### CVE corpus + +- **C.** CVE-2017-1000117 (git argv injection via `ssh://-oProxyCommand=…`) vulnerable + patched fixtures under `tests/benchmark/cve_corpus/c/CVE-2017-1000117/`. Three-layer engine gap deferred (array-element taint propagation, `c.cmdi.exec*` AST patterns, dash-prefix-byte sanitizer recognition). +- **Python.** CVE-2023-6568 (mlflow reflected XSS), CVE-2024-21513 (langchain SQL / Jinja), CVE-2024-23334 (aiohttp static-file path traversal) vulnerable + patched fixtures. +- **PHP.** CVE-2026-33486 (roadiz/documents SSRF) vulnerable + patched fixtures. +- **JavaScript.** CVE-2026-42353 (i18next-http-middleware path traversal) vulnerable + patched fixtures. + +### CLI + +- **`nyx rules list`** subcommand. Surfaces the same registry the dashboard's `/api/rules` page reads from: built-in cap-class entries (one per `Cap` with a canonical rule id), per-language label rules (sink / source / sanitizer), gated sinks, and any custom rules from config. Filters: `--lang `, `--kind `, `--class-only` for registry entries only, `--no-class` for per-language rules only. `--json` for machine output. Cap-class entries carry `language = "all"` so a language filter still surfaces them unless `--no-class` is set. +- **`RuleInfo.is_class` / `RuleInfo.emission_active` flags.** Cap-class entries carry `is_class = true` so dashboards can group them separately. `emission_active = false` marks legacy classes (SQL_QUERY, SSRF, FILE_IO, FMT_STRING, DESERIALIZE, CODE_EXEC, CRYPTO) whose findings still surface under the catch-all `taint-unsanitised-flow` rule id; the seven new classes plus `unauthorized_id` and `data_exfil` are `emission_active = true`. The active set is pinned in `cap_rule_registry_emission_active_set_is_pinned` so a future migration of a legacy cap cannot drift silently. +- **`parse_cap` and `CapName::FromStr`** accept the new short names: `ldap_injection` / `ldapi`, `xpath_injection` / `xpathi`, `header_injection` / `crlf` / `response_splitting`, `open_redirect` / `redirect`, `ssti` / `template_injection`, `xxe`, `prototype_pollution` / `proto_pollution`, plus the existing `data_exfil` alias. The `nyx config add-rule --cap` flag and `[analysis.languages.*.rules]` entries take any of these. + +### Frontend + +- **Refreshed local web UI visual system** around the mint-cyan Nyx brand: warmer light surfaces, deep green accents, updated severity / confidence colors, tighter typography, smaller radii, denser cards, table, badge, button, header, and sidebar styling, and matched graph / code-viewer colors. +- **Reworked `nyx serve` surfaces** for a more operational layout. Overview uses the refreshed health-score card and chart grid; Scans has a fixed compact table with capped language badges; Scan Detail places summary and timing data side by side; Triage, Rules, Config, Explorer, Finding Detail, Scan Compare, and Debug pages received focused spacing, overflow, and density fixes. +- **Branded asset set** shared between the SPA and the embedded server bundle: PNG favicons, Apple touch icon, sidebar logo image, refreshed SVG favicon, and Rust static handlers for the new `/logo.png` and favicon files. +- **Frontend `RuleListItem` and `RuleDetailView`** carry the new `is_class` flag so the dashboard's Rules page can group cap-class entries separately. +- **Regenerated README and docs screenshots and GIFs** against the new UI at 1600x992, saving raw originals before framing and adding CLI GIF plus combined CLI-to-serve demo GIF capture support. Extended the screenshot capture workflow with mint-led framing copy, optional `nyxscan.dev` asset mirroring, WebP regeneration for mirrored PNGs, and raw `_raw` image / GIF outputs for downstream reuse. ### Performance -- Hoisted `collect_top_level_units` out of the per-extractor loop in `extract_authorization_model`. Multi-extractor languages (Go gin+echo, JS/TS express+koa+fastify, Python flask+django, Rust axum+actix_web+rocket, Ruby sinatra) re-walked the entire AST and rebuilt the `Function`-kind unit set per extractor (then deduped by span). New `AuthExtractor::requires_top_level_units()` opt-out for Spring / Rails which build their own. Was 46% of `extract_authorization_model` wall-clock on the mattermost/server/channels/app subtree. -- Single `AuthorizationModel` build per file in fused mode. Pre-fix the diag path and the per-file summary path each ran their own `extract_authorization_model`, duplicating the hoisted unit pass + every framework extractor's AST walk. Auth summaries extracted from the base model (pre var-types, pre-helper-lifting) so the persisted per-file summary matches the legacy `extract_auth_summaries_by_key` path bit-for-bit. -- O(N) shallow value-ref emission in `collect_unit_state`. Previous per-node `extract_value_refs(node, bytes)` walked the entire subtree on every recursion level (O(N²) per body); the recursion below already visits every descendant once. New `append_shallow_value_ref` emits the node's own ref and lets recursion handle the descent. Public callers of `extract_value_refs` (`collect_call`, `collect_condition`, assignment-side extraction) keep the deep walk. Was ~17%+15%+11% of wall-clock split across `build_function_unit_with_meta`, `collect_unit_state`, and `extract_value_refs` on mattermost/server/channels/app. -- Per-`ParsedFile` `body_const_facts_cache: OnceCell`. SSA + const-prop + type-fact build was running 2-3× per body across `run_cfg_analyses_with_lowered`, `run_auth_analyses`, and `collect_file_var_types`. Single-pass cache; gin profile dropped from 13.6% to ~4.5%. -- Sparse Conditional Constant Propagation switched from `HashMap` and `HashSet<(BlockId, BlockId)>` to dense `Vec` per-value lattice and per-destination predecessor `SmallVec<[BlockId; 2]>`. The inner SCCP fixed-point loop no longer SipHashes a 64-bit pair for every operand of every phi. Public `ConstPropResult` shape unchanged (one final O(num_values) HashMap conversion). -- `GlobalSummaries.by_key` switched from stdlib SipHash `HashMap` to `FxHashMap` (rustc-hash 2.1). `FuncKey` carries 3 String fields, so any HashMap operation hashes ≥30 bytes; FxHash is ~5× faster on this workload. Seed is fixed (no DoS hardening), fine for an in-process index keyed by program-derived names. +- **Hoisted `collect_top_level_units` out of the per-extractor loop** in `extract_authorization_model`. Multi-extractor languages (Go gin+echo, JS/TS express+koa+fastify, Python flask+django, Rust axum+actix_web+rocket, Ruby sinatra) had been re-walking the entire AST and rebuilding the `Function`-kind unit set per extractor, then deduping by span. New `AuthExtractor::requires_top_level_units()` opt-out for Spring / Rails which build their own. Was 46% of `extract_authorization_model` wall-clock on the mattermost/server/channels/app subtree. +- **Single `AuthorizationModel` build per file in fused mode.** The diag path and the per-file summary path each ran their own `extract_authorization_model`, duplicating the hoisted unit pass and every framework extractor's AST walk. Auth summaries now extract from the base model (pre var-types, pre helper-lifting) so the persisted per-file summary matches the legacy `extract_auth_summaries_by_key` path bit-for-bit. +- **O(N) shallow value-ref emission in `collect_unit_state`.** The previous per-node `extract_value_refs(node, bytes)` walked the entire subtree on every recursion level (O(N²) per body) even though the recursion below already visits every descendant once. New `append_shallow_value_ref` emits the node's own ref and lets recursion handle the descent. Public callers of `extract_value_refs` (`collect_call`, `collect_condition`, assignment-side extraction) keep the deep walk. Was ~17% + 15% + 11% of wall-clock split across `build_function_unit_with_meta`, `collect_unit_state`, and `extract_value_refs` on mattermost. +- **Per-`ParsedFile` `body_const_facts_cache: OnceCell`.** SSA + const-prop + type-fact build was running 2-3× per body across `run_cfg_analyses_with_lowered`, `run_auth_analyses`, and `collect_file_var_types`. Single-pass cache; gin profile dropped from 13.6% to ~4.5%. +- **SCCP switched from `HashMap` and `HashSet<(BlockId, BlockId)>`** to dense `Vec` per-value lattice and per-destination predecessor `SmallVec<[BlockId; 2]>`. The inner fixed-point loop no longer SipHashes a 64-bit pair for every operand of every phi. Public `ConstPropResult` shape unchanged (one final O(num_values) HashMap conversion). +- **`GlobalSummaries.by_key` switched to `FxHashMap`** (rustc-hash 2.1) from stdlib SipHash. `FuncKey` carries 3 String fields, so any HashMap operation hashes at least 30 bytes; FxHash is ~5× faster on this workload. Seed is fixed (no DoS hardening), fine for an in-process index keyed by program-derived names. - `large_go_module.go` perf fixture (1493 lines) added to `benches/perf_fixtures/`; `benches/scan_bench.rs` extended with auth-extractor, SCCP, and summary-resolution rows. ### Fixed (false positives) @@ -93,33 +114,38 @@ This branch also adds seven new vulnerability classes (LDAP injection, XPath inj - `Object.create(null)` receivers no longer fire prototype-pollution at the synthetic `__index_set__` sink. Suppression is flow-sensitive via `TypeKind::NullPrototypeObject` so a phi join that only sometimes resolves to a null-proto receiver still fires on the unsafe path. - `cfg-unguarded-sink` over-fires on JS/TS object-literal property writes guarded by an explicit `__proto__` / `constructor` / `prototype` reject `if` (early `return` / `throw` / `break`) or by an allowlist `if` whose true arm contains the assignment. Resolved at the CFG layer before the SSA sink scan. - Spring MVC `return "redirect:" + url` flagged generic `taint-unsanitised-flow` even when the redirect destination was the load-bearing taint. Now routed through the synthetic `__spring_redirect__` sink so the finding emerges as `taint-open-redirect`. -- `$smarty->fetch(...)` / `$twig->createTemplate(...)` no longer drop their SSTI gate match on idiomatic PHP receiver shapes. Receiver text strip in `helpers::root_receiver_text` rebuilds the chain text with `.` separators. -- `setValue(target, req.body, ...)` and similar wrappers no longer gate-match on the rewritten Source `req.body` text. Gate matcher now reads the call's `function` / `method` / `name` field when a Source label override has clobbered the call text. -- Nokogiri / lxml / fast-xml-parser parser bodies hardened with `setFeature` / `processEntities: false` / `XMLParser(resolve_entities=False)` no longer fire `taint-xxe`. Suppression runs through the new `xml_parser_config` sidecar. -- `XPath` instances bound to `setXPathVariableResolver(...)` no longer fire `taint-xpath-injection` on subsequent `xpath.evaluate(expr, ...)` sinks. Suppression runs through the new `xpath_config` sidecar. -- Inline `if (!url.startsWith("/")) reject` and `if (new URL(url).host !== ALLOWED) reject` open-redirect sanitisers now narrow the `Cap::OPEN_REDIRECT` bit on the validated branch instead of falling through to the generic `Comparison` predicate. Cap-aware: other taint downstream still fires on its own caps. -- Rust `Redirect::to` no longer fires `taint-ssrf` for what is structurally an open redirect. Retagged to `Cap::OPEN_REDIRECT` so the report classifies the issue under the correct cap. -- ~957 gitea backend DAO `go.auth.missing_ownership_check` findings (id-scalar precision pass, see Added). -- 169 of 216 openmrs `cfg-unguarded-sink` findings (JpaCriteriaQuery type, see Added). Equivalent reductions on xwiki / keycloak Hibernate DAO clusters. -- joomla and drupal `php.deser.unserialize` flagged inside `Serializable::unserialize($input)` magic-method bodies (passthrough recognition, see Added). -- airflow execution-API routes flagged `missing_ownership_check` despite being authorized via cross-file `include_router` chains and module-level `APIRouter(dependencies=[…])` declarations (router_facts + router-level dep propagation, see Added). -- sentry `verified_ids = set(); verified_ids.update(myteams)` flagged as `DbMutation` (Python container constructor recognition, see Added). -- aiohttp `path.relative_to(static_root_path)` rejected as a path-traversal validator (receiver-side validator registry, see Added). -- i18next-http-middleware `arr.filter(utils.isSafeIdentifier)` not narrowing taint on the result (Array-method validator-callback narrowing, see Added). -- `cond ? req.query.lng : ""` ternary lost `Source` label on the truthy branch (ternary-branch source classification, see Added). -- `if (!validate($x))` rejection-arm narrowing flipped on PHP unary `!` (unary_op_expression recognition, see Added). -- mlflow `make_response(f"Invalid content type: '{content_type}'")` (Tier B pattern, see Added). -- Bare-callee verb-name dispatch on Python builtins / locally-defined helpers (`list`, `filter`, `update`, `create_audit_entry`, `update_coding_agent_state`, see Added). +- `$smarty->fetch(...)` / `$twig->createTemplate(...)` no longer drop their SSTI gate match on idiomatic PHP receiver shapes. +- `setValue(target, req.body, ...)` and similar wrappers no longer gate-match on the rewritten Source `req.body` text. +- Nokogiri / lxml / fast-xml-parser parser bodies hardened with `setFeature` / `processEntities: false` / `XMLParser(resolve_entities=False)` no longer fire `taint-xxe`. +- `XPath` instances bound to `setXPathVariableResolver(...)` no longer fire `taint-xpath-injection` on subsequent `xpath.evaluate(expr, ...)` sinks. +- Inline `if (!url.startsWith("/")) reject` and `if (new URL(url).host !== ALLOWED) reject` open-redirect sanitisers narrow `Cap::OPEN_REDIRECT` on the validated branch instead of falling through to the generic `Comparison` predicate. Other taint downstream still fires on its own caps. +- Rust `Redirect::to` no longer fires `taint-ssrf` for what is structurally an open redirect; retagged to `Cap::OPEN_REDIRECT`. +- ~957 gitea backend DAO `go.auth.missing_ownership_check` findings (id-scalar precision pass). +- 169 of 216 openmrs `cfg-unguarded-sink` findings (JpaCriteriaQuery type). Equivalent reductions on xwiki / keycloak Hibernate DAO clusters. +- joomla and drupal `php.deser.unserialize` flagged inside `Serializable::unserialize($input)` magic-method bodies. +- airflow execution-API routes flagged `missing_ownership_check` despite being authorized via cross-file `include_router` chains and module-level `APIRouter(dependencies=[…])` declarations. +- sentry `verified_ids = set(); verified_ids.update(myteams)` flagged as `DbMutation`. +- aiohttp `path.relative_to(static_root_path)` not recognised as a path-traversal validator. +- i18next-http-middleware `arr.filter(utils.isSafeIdentifier)` not narrowing taint on the result. +- `cond ? req.query.lng : ""` ternary lost `Source` label on the truthy branch. +- `if (!validate($x))` rejection-arm narrowing flipped on PHP unary `!`. +- mlflow `make_response(f"Invalid content type: '{content_type}'")` (Tier B pattern). +- Bare-callee verb-name dispatch on Python builtins / locally-defined helpers (`list`, `filter`, `update`, `create_audit_entry`, `update_coding_agent_state`). - FastAPI `Depends(...)` / `Security(...)` deps declared on a module-level `APIRouter` no longer dropped on every attached route. - FastAPI `Security(callable, scopes=[...])` no longer downgraded to a Login-only check. -### Other +### Tests +- New per-cap integration suites: `tests/{xpath_injection,xxe,ssti,prototype_pollution,header_injection,open_redirect,ldap_injection}_tests.rs`, plus `python_proto_pollution_tests.rs` for the env-gated Python form. Per-cap fixture trees under `tests/fixtures///` cover safe, unsafe, and irrelevant-baseline shapes for every supported language. +- Cross-file FastAPI integration test `tests/fastapi_cross_file_include_router_tests.rs` with airflow-shaped fixture tree under `tests/fixtures/auth_cross_file/airflow_execution_api_includes/`. - New `cfg/cfg_tests.rs` covers ternary-branch CFG lowering shapes. - New `summary/tests.rs` covers cross-file `include_router` summary persistence and resolution. +- Per-language safe / vuln auth and detector fixtures across Python, Java, Go, PHP, JS, TS. + +### Other + - Refactor passes across `auth_analysis`, `ssa/const_prop`, `ssa/type_facts`, `summary`, and the per-framework auth extractors (cleaner conditional checks, simpler function signatures, deduplicated assertions). No behaviour change. -- `parse_cap` and `CapName::FromStr` accept the new short names (`ldap_injection` / `ldapi`, `xpath_injection` / `xpathi`, `header_injection` / `crlf` / `response_splitting`, `open_redirect` / `redirect`, `ssti` / `template_injection`, `xxe`, `prototype_pollution` / `proto_pollution`, plus the existing `data_exfil` alias). The `nyx config add-rule --cap` flag and `[analysis.languages.*.rules]` entries take any of these. -- Frontend `RuleListItem` carries the new `is_class` flag so the dashboard's Rules page can group cap-class entries separately. `RuleDetailView` adds the same field. +- README links to a Simplified Chinese translation (`README.zh-CN.md`). ## [0.6.1] - 2026-05-03 @@ -171,17 +197,17 @@ A focused release that splits data-exfiltration off from SSRF and ships sinks fo - Lodash `_.template` modeled as a gated `Cap::CODE_EXEC` sink. Activates on the template-string argument; suppresses when arg-1 carries a literal `{ evaluate: false }`. Closes Strapi CVE-2023-22621 (server-side template injection → RCE via `<% … %>` evaluate blocks). Vulnerable + patched fixtures added under `tests/benchmark/cve_corpus/javascript/CVE-2023-22621/`. - JS/TS gated-sink kwarg extractor falls back to inspecting arg-1 object literals (`fn(x, { evaluate: false })`) when the language has no `keyword_argument` node. Required so the lodash gate can read its options object. - Lodash double-call form (`_.template(t)(data)`) routes through `find_chained_inner_call` so the outer call's gated-sink rebinding fires. -- Cross-function helper-validation propagation. New `SsaFuncSummary.validated_params_to_return` field records parameter indices whose taint flow to the return value is fully validated by a dominating predicate (regex allowlist, type check, validation call) on every return path. At call sites, each tainted argument passed to a validated position — and the call's own return value — are marked `validated_must` / `validated_may` in the caller's SSA taint state, the same way an inline `if (!regex.test(x)) throw` would. Closes the helper-validator gap behind PayloadCMS CVE-2026-25544 (Drizzle SQL injection in `sanitizeValue`). Vulnerable + patched TypeScript fixtures added. +- Cross-function helper-validation propagation. New `SsaFuncSummary.validated_params_to_return` field records parameter indices whose taint flow to the return value is fully validated by a dominating predicate (regex allowlist, type check, validation call) on every return path. At call sites, each tainted argument passed to a validated position, and the call's own return value, are marked `validated_must` / `validated_may` in the caller's SSA taint state, the same way an inline `if (!regex.test(x)) throw` would. Closes the helper-validator gap behind PayloadCMS CVE-2026-25544 (Drizzle SQL injection in `sanitizeValue`). Vulnerable + patched TypeScript fixtures added. - Destructured-arg sibling expansion in per-parameter taint summary probing. JS/TS object-pattern formals (`({ column, operator, value }) => …`) now seed every binding sharing the slot, and any sibling reaching `validated_must` counts as the slot being validated. New `BodyMeta.param_destructured_fields` carries sibling lists alongside `params` and `param_types`. JS `PARAM_CONFIG` accepts `assignment_pattern` (default-value formals) and `object_pattern` (destructured formals). - Regex-allowlist branch narrowing. `.test(value)` / `.match(value)` / `.matches(value)` where the receiver name contains `regex` or `pattern` classifies as a `ValidationCall` and narrows the call's first argument, not the regex receiver. Was also extended to `extract_validation_target` so the surviving branch validates `value`, not the regex object. Motivated by Payload CVE-2026-25544 (`if (!SAFE_STRING_REGEX.test(value)) throw …`). - TypeScript template-substring (`${fn(arg)}`) call-resolution arity-hint fallback. When CFG lowering drops `arg_uses` but `args` is non-empty, the resolver passes `None` so the unique-name fallback can still pick up the lone candidate. - Caller-scope-entity exemption in `rs.auth.missing_ownership_check`. `.id` / `.pk` no longer fires when `` is a unit parameter named after a multi-tenant scope primitive: `organization` / `org`, `project`, `team`, `workspace`, `tenant`, `account`, `community`, `group`, `repository` / `repo`, `company`. Other field names (`.name`, `.slug`) still flag, and `user` / `member` / `actor` are deliberately excluded (handled by `is_actor_context_subject`). Closes a flood of FPs in Sentry / Saleor / Discourse / Mastodon-shaped multi-tenant helpers (`get_environments(request, organization)`, `_filter_releases_by_query(qs, organization, …)`). -- Auth value-ref walker recurses into the `value` child of `keyword_argument` / `keyword_arg` / `named_argument` nodes. `Model.objects.filter(organization_id=org.id)` no longer surfaces the kwarg key (`organization_id`) as a bare-identifier user-input subject — the schema column name is fixed at call time. +- Auth value-ref walker recurses into the `value` child of `keyword_argument` / `keyword_arg` / `named_argument` nodes. `Model.objects.filter(organization_id=org.id)` no longer surfaces the kwarg key (`organization_id`) as a bare-identifier user-input subject. The schema column name is fixed at call time. - Test-decorator denylist for Flask route extraction. `mock.patch`, `mock.patch.object` / `.dict` / `.multiple`, `unittest.mock.*`, `monkeypatch.setattr` / `setenv` / `delattr` / `delenv`, and `pytest.mark.parametrize` no longer collide with `.patch` route registration. Stops every `@mock.patch("…")`-decorated test method from being attached as a Flask PATCH handler and flagged as `missing_ownership_check`. - Typed-extractor route-level guard injection for axum and actix-web. Handlers registered via attribute macros (`#[get("/path")]`, `#[routes::path(…)]`) or via external service-config builders previously never had their typed-extractor guards seeded. New `apply_typed_extractor_guards_to_units` walks every `Function`-kind unit and injects guard checks from typed-extractor params, complementing the route-walk path that already covered `.route(...)` registration. - New auth config key `policy_guard_names`. Typed-extractor wrappers that prove route-level capability/policy enforcement (e.g. meilisearch's `GuardedData, _>`) are recognised distinctly from authentication-only wrappers. Matched as last-segment + case-insensitive `starts_with`. Rust default: `["Guarded"]`. Distinct from `login_guard_names` so the pattern doesn't pollute regular call recognition (a function like `guarded_load(..)` is not a login guard). - Outer-wrapper-aware classification of typed extractors. `GuardedData, Data>` is classified by the outer `GuardedData` (policy-bearing → `AuthCheckKind::Other`), not by whether an inner generic arg substring-matches `auth`. Bare data-only extractors (`Path`, `Query`, `Json`, `Form`, `State`, `Extension`, `Data`) outer-name-match early-return to `None` regardless of inner type tokens. Reference-marker (`&`, `&mut`, `&'a`) and module-path (`std::collections::`) prefixes stripped before matching. -- Project-level web-framework signal in Rust auth analysis. New `FrameworkContext::lang_has_web_framework(lang)` is three-valued: `Some(true)` when manifest names a framework, `Some(false)` when the manifest was inspected and named none, `None` when no manifest was inspected. New `rust_file_imports_web_framework` does a per-file `axum::` / `actix_web::` / `rocket::` / `axum_extra::` import probe (8 KB head). When the project's Cargo.toml is inspected and lists no Rust web framework AND the file does not directly import one, the `context_inputs` and param-name-heuristic arms of `unit_has_user_input_evidence` are suppressed. `RouteHandler` classification (concrete route-registration evidence) still bypasses the gate. Closes a flood of `missing_ownership_check` FPs in non-web Rust crates — e.g. zed-style desktop / GUI codebases where a debug-session handle named `session` would trip `matches_session_context` on `session.update(cx, …)`. Currently Rust-only; other languages keep prior behavior (`None`). +- Project-level web-framework signal in Rust auth analysis. New `FrameworkContext::lang_has_web_framework(lang)` is three-valued: `Some(true)` when manifest names a framework, `Some(false)` when the manifest was inspected and named none, `None` when no manifest was inspected. New `rust_file_imports_web_framework` does a per-file `axum::` / `actix_web::` / `rocket::` / `axum_extra::` import probe (8 KB head). When the project's Cargo.toml is inspected and lists no Rust web framework AND the file does not directly import one, the `context_inputs` and param-name-heuristic arms of `unit_has_user_input_evidence` are suppressed. `RouteHandler` classification (concrete route-registration evidence) still bypasses the gate. Closes a flood of `missing_ownership_check` FPs in non-web Rust crates such as zed-style desktop / GUI codebases where a debug-session handle named `session` would trip `matches_session_context` on `session.update(cx, …)`. Currently Rust-only; other languages keep prior behavior (`None`). - Rust auth corpus extended with `safe_actix_guarded_data_extractor.rs` and `unsafe_actix_no_guarded_data_extractor.rs` (typed-extractor guard injection); `safe_non_web_rust_project/` and `unsafe_actix_web_project_no_check/` (full Cargo.toml + src/lib.rs project shapes for the framework-signal gate). - Python auth corpus extended with `vuln_user_id_param_no_auth.py`, `safe_django_orm_caller_scoped_entity.py` (caller-scope-entity exemption), `safe_mock_patch_test_method.py` (test-decorator denylist). - Go safe corpus extended with `safe_inner_call_close_in_arg.go` (`require.NoError(t, f.Close())` shape), `safe_struct_field_resource_owned_by_struct.go` (field-LHS ownership transfer), and a `vuln_resource_leak_no_close.go` regression guard. @@ -193,8 +219,8 @@ A focused release that splits data-exfiltration off from SSRF and ships sinks fo - JS and TS `secrets.fallback_secret` no longer fire on empty-string fallbacks (`process.env.X || ""`). Developers write `|| ""` to satisfy non-undefined string types without committing a real secret. Non-empty literal fallbacks still fire. - Path-traversal sink suppression accepts canonicalised-and-rooted shapes. New `PathFact::is_path_traversal_safe` predicate clears `Cap::FILE_IO` when the path is dotdot-free and either non-absolute or carries a verified prefix-lock. New `OPAQUE_PREFIX_LOCK` marker records the structural invariant ("rooted under SOME prefix") when the `starts_with`-style guard's argument is a method call, field access, or configured root rather than a string literal. Closes the Ruby `File.expand_path + start_with?(root)` shape (rswag CVE-2023-38337 patched counterpart), the Python `os.path.realpath + .startswith(root)` shape, and the JS `path.resolve + .startsWith(root)` shape. `classify_path_assertion` extended to JS `.startsWith(...)`, Python `.startswith(...)`, Ruby `.start_with?(...)` (paren and paren-less), and Go `strings.HasPrefix(...)`. - Branch narrowing now flips prefix-lock attachment under condition negation. For `if !target.startsWith(ROOT) { return; }` the lock attaches to the surviving block, not the rejection arm. Rejection-axis narrowing is unchanged because the rejection classifier is text-level and already accounts for leading `!`. -- Go field-LHS resource acquires no longer counted as local resource leaks. `b.cpuprof = os.Create(...)` transfers ownership to the containing struct; closure responsibility belongs to a paired `Stop()` / `Release()` method on the struct's lifecycle. Gated in both `state/transfer.rs::apply_call` and `cfg_analysis/resources.rs::run`. Restricted to Go (`Lang::Go` check) — JS/TS class-field acquires (`this.fd = fs.openSync(...)`) keep being tracked because the leak fixtures rely on it. Production trigger: prometheus `cmd/promtool/tsdb.go::startProfiling` cluster (`b.cpuprof`, `b.memprof`, `b.blockprof`, `b.mtxprof`). -- Go inner-call release in argument position. `require.NoError(t, f.Close())`, `errs = append(errs, f.Close())`, JUnit `assertEquals(0, in.read())` — releases that live in argument position now mark the receiver `CLOSED`. Bare-receiver inner calls only (chained-receiver releases stay owned by `chain_proxies`); marks `CLOSED` only with no `DoubleClose` attribution; respects `in_defer` for symmetry. +- Go field-LHS resource acquires no longer counted as local resource leaks. `b.cpuprof = os.Create(...)` transfers ownership to the containing struct; closure responsibility belongs to a paired `Stop()` / `Release()` method on the struct's lifecycle. Gated in both `state/transfer.rs::apply_call` and `cfg_analysis/resources.rs::run`. Restricted to Go (`Lang::Go` check). JS/TS class-field acquires (`this.fd = fs.openSync(...)`) keep being tracked because the leak fixtures rely on it. Production trigger: prometheus `cmd/promtool/tsdb.go::startProfiling` cluster (`b.cpuprof`, `b.memprof`, `b.blockprof`, `b.mtxprof`). +- Go inner-call release in argument position. `require.NoError(t, f.Close())`, `errs = append(errs, f.Close())`, JUnit `assertEquals(0, in.read())`: releases that live in argument position now mark the receiver `CLOSED`. Bare-receiver inner calls only (chained-receiver releases stay owned by `chain_proxies`); marks `CLOSED` only with no `DoubleClose` attribution; respects `in_defer` for symmetry. ### Other @@ -229,7 +255,7 @@ The biggest release since launch. The taint engine was rebuilt on top of an SSA - Direction-aware engine notes (`UnderReport`, `OverReport`, `Bail`) flow into confidence scoring, ranking, and the new `--require-converged` strict mode. - Synthetic field-write inheritance: `u.Path = "/foo"` no longer drops taint carried by other fields of `u`. Fixes Owncast CVE-2023-3188 (SSRF). - Phantom-Param-aware field suppression skips method/function references that share a base name with a tainted variable. -- Validation err-check narrowing for the two-statement Go idiom `_, err := strconv.Atoi(input); if err != nil { return }` — `input` is marked validated on the surviving `err == nil` branch. +- Validation err-check narrowing for the two-statement Go idiom `_, err := strconv.Atoi(input); if err != nil { return }`: `input` is marked validated on the surviving `err == nil` branch. - Go: `strings.Replace` / `strings.ReplaceAll` recognised as a sanitizer when the OLD literal contains a known-dangerous payload (shell metachars, path-traversal, HTML, SQL) and the NEW literal does not reintroduce one. - Go: literal-strip cap detection extended to shell metachars (`;`, `|`, `&`, `$`, backtick) and SQL metachars (`'`, `"`, `--`). - Go: `interpreted_string_literal` / `raw_string_literal` handled in tree-sitter so const-string arg extraction works for Go's double-quoted and backtick forms. diff --git a/Cargo.lock b/Cargo.lock index 875a1622..91a67215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,9 +111,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "assert_cmd" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -257,9 +257,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -778,9 +778,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -936,7 +936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -977,9 +977,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1136,7 +1136,7 @@ dependencies = [ [[package]] name = "nyx-scanner" -version = "0.6.1" +version = "0.7.0" dependencies = [ "assert_cmd", "axum", @@ -1441,9 +1441,9 @@ dependencies = [ [[package]] name = "r2d2_sqlite" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5576df16239e4e422c4835c8ed00be806d4491855c7847dba60b7aa8408b469b" +checksum = "f9a289c0a3bf56505c470efa2366e76010f1d892e2492a2f96b223386d63b7e2" dependencies = [ "r2d2", "rusqlite", @@ -1921,9 +1921,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "libc", "mio", @@ -2027,9 +2027,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.9" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "async-compression", "bitflags", @@ -2353,9 +2353,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2366,9 +2366,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2376,9 +2376,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2389,9 +2389,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2432,9 +2432,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 2b39957d..d8995414 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nyx-scanner" -version = "0.6.1" +version = "0.7.0" edition = "2024" rust-version = "1.88" description = "A multi-language static analysis tool for detecting security vulnerabilities" @@ -65,29 +65,29 @@ name = "scan_bench" harness = false [dev-dependencies] -tempfile = "3.26.0" -criterion = { version = "0.8", features = ["html_reports"] } -assert_cmd = "2" -predicates = "3" -glob = "0.3" -tower = { version = "0.5", features = ["util"] } +tempfile = "3.27.0" +criterion = { version = "0.8.2", features = ["html_reports"] } +assert_cmd = "2.2.2" +predicates = "3.1.4" +glob = "0.3.3" +tower = { version = "0.5.3", features = ["util"] } [dependencies] directories = "6.0.0" -clap = { version = "4.5.60", features = ["derive"] } +clap = { version = "4.6.1", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0" -rmp-serde = "1.3" -toml = "1.0.3" -tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json", "ansi","time"] } +serde_json = "1.0.149" +rmp-serde = "1.3.1" +toml = "1.1.2" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json", "ansi","time"] } tracing = "0.1.44" num_cpus = "1.17.0" rusqlite = { version = "0.39.0", features = ["bundled"] } -r2d2_sqlite = { version = "0.33.0", features = ["bundled"] } +r2d2_sqlite = { version = "0.34.0", features = ["bundled"] } ignore = "0.4.25" -tree-sitter = "0.26.6" -tree-sitter-rust = "0.24.0" -tree-sitter-c = "0.24.1" +tree-sitter = "0.26.8" +tree-sitter-rust = "0.24.2" +tree-sitter-c = "0.24.2" tree-sitter-cpp = "0.23.4" tree-sitter-java = "0.23.5" tree-sitter-typescript = "0.23.2" @@ -98,27 +98,27 @@ tree-sitter-python = "0.25.0" tree-sitter-ruby = "0.23.1" crossbeam-channel = "0.5.15" blake3 = "1.8.5" -once_cell = "1.21.3" -console = "0.16.2" -terminal_size = "0.4" -rayon = "1.11.0" +once_cell = "1.21.4" +console = "0.16.3" +terminal_size = "0.4.4" +rayon = "1.12.0" r2d2 = "0.8.10" bytesize = "2.3.1" chrono = { version = "0.4.44", default-features = false, features = ["std", "clock", "serde"] } thiserror = "2.0.18" dashmap = "6.1.0" -parking_lot = "0.12" +parking_lot = "0.12.5" petgraph = { version = "0.8.3", features = ["serde-1"] } -bitflags = "2.11.0" +bitflags = "2.11.1" phf = { version = "0.13.1", features = ["macros"] } indicatif = "0.18.4" -smallvec = { version = "1.15", features = ["serde"] } -rustc-hash = "2.1" -uuid = { version = "1", features = ["v4"] } -axum = { version = "0.8", optional = true } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync"], optional = true } -tokio-stream = { version = "0.1", features = ["sync"], optional = true } -tower-http = { version = "0.6", features = ["cors", "compression-gzip", "trace", "set-header", "limit"], optional = true } +smallvec = { version = "1.15.1", features = ["serde"] } +rustc-hash = "2.1.2" +uuid = { version = "1.23.1", features = ["v4"] } +axum = { version = "0.8.9", optional = true } +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "signal", "sync"], optional = true } +tokio-stream = { version = "0.1.18", features = ["sync"], optional = true } +tower-http = { version = "0.6.10", features = ["cors", "compression-gzip", "trace", "set-header", "limit"], optional = true } z3 = { version = "0.20.0", optional = true} [profile.release] diff --git a/README.md b/README.md index f0c021bc..cbda3276 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ [![Rust 1.88+](https://img.shields.io/badge/rust-1.88%2B-orange)](https://www.rust-lang.org) [![CI](https://img.shields.io/github/actions/workflow/status/elicpeter/nyx/ci.yml?branch=master)](https://github.com/elicpeter/nyx/actions) [![Docs](https://img.shields.io/badge/docs-elicpeter.github.io%2Fnyx-blue)](https://elicpeter.github.io/nyx/) + +English · [简体中文](./README.zh-CN.md)

Nyx UI walkthrough: empty Welcome state, kicking off a scan, the populated overview with Health Score, drilling into a HIGH finding's flow visualizer, then the triage flow

@@ -74,7 +76,7 @@ Forward cross-file taint runs in every profile. Symex and the demand-driven back ### GitHub Action ```yaml -- uses: elicpeter/nyx@v0.6.1 +- uses: elicpeter/nyx@v0.7.0 with: format: sarif fail-on: MEDIUM diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..a4825f8e --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,263 @@ +
+ nyx + +**本地优先的安全扫描器,自带浏览器 UI。在本地扫描代码仓库并在浏览器中分诊处理,无需云端、无需账号。** + +[![crates.io](https://img.shields.io/crates/v/nyx-scanner.svg)](https://crates.io/crates/nyx-scanner) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Rust 1.88+](https://img.shields.io/badge/rust-1.88%2B-orange)](https://www.rust-lang.org) +[![CI](https://img.shields.io/github/actions/workflow/status/elicpeter/nyx/ci.yml?branch=master)](https://github.com/elicpeter/nyx/actions) +[![Docs](https://img.shields.io/badge/docs-elicpeter.github.io%2Fnyx-blue)](https://elicpeter.github.io/nyx/) + +[English](./README.md) · 简体中文 +
+ +

Nyx UI 演示:从空欢迎页开始扫描,查看含健康分的总览页,钻入一条 HIGH 级发现的流可视化,再到分诊流程

+ +--- + +## 本地扫描,本地浏览 + +Nyx 在你的代码仓库上运行跨语言污点分析,然后将结果通过绑定到 `127.0.0.1` 的 React UI 提供给你。你会得到一份带严重等级、证据、以及分步**流可视化**的发现列表,从源 → 净化器 → 汇逐步呈现数据流。分诊决策持久化在 `.nyx/triage.json` 中,与代码一同提交,团队共享同一份分诊状态。 + +```bash +cargo install nyx-scanner +nyx scan # 运行分析器,把发现缓存到 .nyx/ +nyx serve # 在浏览器中打开 http://localhost:9700 +``` + +一切都留在你本地:仅回环绑定、强制 host 头校验、所有变更操作均带 CSRF、无遥测、无登录。 + +

一个小型 JS 应用的总览仪表盘:健康分 C 78,五项分量分解(严重度压力、置信度质量、趋势、分诊覆盖、回归抗性),3 条发现,OWASP A03 与 A02 类别,置信度分布与问题类别条形图,受影响最多的文件

+ +--- + +## UI 中包含什么 + +| 页面 | 显示内容 | +|---|---| +| **总览** | 仪表盘:按严重等级分类的发现计数、热点文件、引擎画像摘要 | +| **发现** | 可浏览列表,含严重度徽章、分诊状态、规则筛选、语言筛选 | +| **发现详情** | 流路径可视化,带编号步骤(源 → 净化器 → 汇)、代码片段、证据、跨文件标记、分诊下拉框 | +| **分诊** | 批量更新状态(open、investigating、fixed、false_positive、accepted_risk、suppressed),审计日志,JSON 导入/导出 | +| **资源管理器** | 文件树,含每个文件的符号列表与发现叠加层 | +| **扫描** | 历史记录、指标,对比两次扫描查看差异 | +| **规则** | 各语言的内置与自定义规则;可在 UI 中添加规则 | +| **配置** | 实时配置编辑器;无需重启即可重载 | + + +`nyx serve` 参数:`--port `(默认 `9700`)、`--host `(仅回环:`127.0.0.1`、`localhost`、`::1`)、`--no-browser`。持久化设置见 `nyx.conf` 的 `[server]` 段,分页面 UI 介绍与安全模型详见 [Browser UI 指南](https://elicpeter.github.io/nyx/serve.html)。 + +--- + +## 用于 CI 的 CLI + +同一个引擎可以无头运行用于 CI 流水线。SARIF 输出可直接上传到 GitHub Code Scanning。 + +

nyx scan 终端输出:JS 与 Python 文件中的 HIGH 级污点发现及 source → sink 箭头

+ +```bash +# 在 medium 及以上等级让 CI 失败,并输出 SARIF +nyx scan --format sarif --fail-on MEDIUM > results.sarif + +# 临时 JSON,无索引 +nyx scan ./server --format json --index off + +# 仅 AST 模式(最快;跳过 CFG + 污点) +nyx scan --mode ast + +# 引擎深度快捷方式:fast | balanced(默认) | deep +# `deep` 增加 symex 与按需后向污点,精度更高,开销约 2-3 倍 +nyx scan --engine-profile deep +``` + +正向跨文件污点在所有画像下都会运行。Symex 与按需后向遍历是可选项,可通过 `--engine-profile deep` 一次性开启,或单独开启(`--symex`、`--backwards-analysis`)。完整开关矩阵见 [CLI 参考](https://elicpeter.github.io/nyx/cli.html#engine-depth-profile)。 + +### GitHub Action + +```yaml +- uses: elicpeter/nyx@v0.7.0 + with: + format: sarif + fail-on: MEDIUM +- uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: nyx-results.sarif +``` + +输入:`path`、`version`、`format`(`sarif`|`json`|`console`)、`fail-on`、`args`、`token`。输出:`finding-count`、`sarif-file`、`exit-code`、`nyx-version`。支持 Linux 与 macOS runner(x86_64、ARM64)。 + +--- + +## 安装 + +**Cargo(推荐):** +```bash +cargo install nyx-scanner +``` + +**预编译二进制:** 从 [Releases](https://github.com/elicpeter/nyx/releases) 下载对应平台的归档包,对照 `SHA256SUMS`(以及随附的 `SHA256SUMS.asc` GPG 签名,如有提供)校验,解压并把 `nyx` 放到 `PATH` 中。 + +```bash +# 可选:校验校验文件的 GPG 签名(当 SHA256SUMS.asc 已发布时) +gpg --verify SHA256SUMS.asc SHA256SUMS +sha256sum -c SHA256SUMS --ignore-missing +unzip nyx-x86_64-unknown-linux-gnu.zip && chmod +x nyx && sudo mv nyx /usr/local/bin/ +``` + +**从源码编译:** +```bash +git clone https://github.com/elicpeter/nyx.git +cd nyx && cargo build --release +``` + +需要 stable Rust 1.88+。前端会在编译期被打包嵌入二进制中,因此 `nyx serve` 没有单独的安装步骤。 + +--- + +## 语言支持 + +全部 10 种语言都通过 tree-sitter 解析并跑完整流水线,但规则深度与引擎覆盖并不均衡。在 [`tests/benchmark/ground_truth.json`](tests/benchmark/ground_truth.json) 的 507 案例语料上,所有十种语言的基准 F1 均为 100%,因此 F1 已无法单独区分梯度。分级反映规则深度、门控汇覆盖、以及合成语料未充分覆盖的结构性惯用法: + +| 梯度 | 语言 | F1 | 适合用作 CI 门禁吗? | +|---|---|---|---| +| **稳定** | Python、JavaScript、TypeScript | 100% | 适合 | +| **Beta** | Java、PHP、Ruby、Rust、Go | 100% | 适合,需轻度 FP 分诊 | +| **预览** | C、C++ | 合成语料 100% | 不适合。已跟踪 STL 容器流、builder 链、内联类成员函数;尚未覆盖深度指针别名与函数指针。建议与 clang-tidy 或 Clang Static Analyzer 搭配使用 | + +聚合规则级 F1:100.0%(P=1.000,R=1.000)。所有真实 CVE 用例均触发,语料无未关闭的 FP。各维度详情与已知盲区见 [语言成熟度页面](https://elicpeter.github.io/nyx/language-maturity.html)。 + +### 通过真实 CVE 验证 + +语料中还包含一小批从公开公告中提取的「漏洞 / 已修复」配对,因此基准下限不仅由合成的同形测例守护,还由对真实 bug 的回归保护守护。每个配对 Nyx 都在漏洞文件上触发、在已修复文件上零发现。 + +| CVE | 项目 | 语言 | 类别 | +|---|---|---|---| +| [CVE-2023-48022](https://nvd.nist.gov/vuln/detail/CVE-2023-48022) | Ray | Python | 命令注入 | +| [CVE-2017-18342](https://nvd.nist.gov/vuln/detail/CVE-2017-18342) | PyYAML | Python | 反序列化 | +| [CVE-2019-14939](https://nvd.nist.gov/vuln/detail/CVE-2019-14939) | mongo-express | JavaScript | 代码执行(`eval`) | +| [CVE-2023-22621](https://nvd.nist.gov/vuln/detail/CVE-2023-22621) | Strapi | JavaScript | 代码执行(SSTI) | +| [CVE-2025-64430](https://nvd.nist.gov/vuln/detail/CVE-2025-64430) | Parse Server | JavaScript | SSRF | +| [CVE-2023-26159](https://nvd.nist.gov/vuln/detail/CVE-2023-26159) | follow-redirects | TypeScript | SSRF | +| [GHSA-4x48-cgf9-q33f](https://github.com/advisories/GHSA-4x48-cgf9-q33f) | Novu | TypeScript | SSRF | +| [CVE-2026-25544](https://nvd.nist.gov/vuln/detail/CVE-2026-25544) | Payload CMS | TypeScript | SQL 注入 | +| [CVE-2022-30323](https://nvd.nist.gov/vuln/detail/CVE-2022-30323) | hashicorp/go-getter | Go | 命令注入 | +| [CVE-2024-31450](https://nvd.nist.gov/vuln/detail/CVE-2024-31450) | owncast | Go | 路径穿越 | +| [CVE-2023-3188](https://nvd.nist.gov/vuln/detail/CVE-2023-3188) | owncast | Go | SSRF | +| [CVE-2026-41422](https://github.com/daptin/daptin/security/advisories/GHSA-rw2c-8rfq-gwfv) | daptin | Go | SQL 注入 | +| [CVE-2015-7501](https://nvd.nist.gov/vuln/detail/CVE-2015-7501) | Apache Commons Collections | Java | 反序列化 | +| [CVE-2017-12629](https://nvd.nist.gov/vuln/detail/CVE-2017-12629) | Apache Solr | Java | 命令注入 | +| [CVE-2022-1471](https://nvd.nist.gov/vuln/detail/CVE-2022-1471) | SnakeYAML | Java | 反序列化 | +| [CVE-2022-42889](https://nvd.nist.gov/vuln/detail/CVE-2022-42889) | Apache Commons Text | Java | 代码执行 | +| [GHSA-h8cj-hpmg-636v](https://github.com/advisories/GHSA-h8cj-hpmg-636v) | Appsmith | Java | SQL 注入 | +| [CVE-2013-0156](https://nvd.nist.gov/vuln/detail/CVE-2013-0156) | Ruby on Rails | Ruby | 反序列化 | +| [CVE-2020-8130](https://nvd.nist.gov/vuln/detail/CVE-2020-8130) | Rake | Ruby | 命令注入 | +| [CVE-2021-21288](https://nvd.nist.gov/vuln/detail/CVE-2021-21288) | CarrierWave | Ruby | SSRF | +| [CVE-2023-38337](https://nvd.nist.gov/vuln/detail/CVE-2023-38337) | rswag-api | Ruby | 路径穿越 | +| [CVE-2017-9841](https://nvd.nist.gov/vuln/detail/CVE-2017-9841) | PHPUnit | PHP | 代码执行(`eval`) | +| [CVE-2018-15133](https://nvd.nist.gov/vuln/detail/CVE-2018-15133) | Laravel | PHP | 反序列化 | +| [CVE-2018-20997](https://nvd.nist.gov/vuln/detail/CVE-2018-20997) | tar-rs | Rust | 路径穿越 | +| [CVE-2022-36113](https://nvd.nist.gov/vuln/detail/CVE-2022-36113) | cargo | Rust | 路径穿越 | +| [CVE-2024-24576](https://nvd.nist.gov/vuln/detail/CVE-2024-24576) | Rust stdlib | Rust | 命令注入 | +| [CVE-2023-42456](https://rustsec.org/advisories/RUSTSEC-2023-0069.html) | sudo-rs | Rust | 路径穿越 | +| [CVE-2024-32884](https://rustsec.org/advisories/RUSTSEC-2024-0335.html) | gitoxide | Rust | 命令注入 | +| [CVE-2025-53549](https://rustsec.org/advisories/RUSTSEC-2025-0043.html) | matrix-rust-sdk | Rust | SQL 注入 | +| [CVE-2016-3714](https://nvd.nist.gov/vuln/detail/CVE-2016-3714) | ImageMagick (ImageTragick) | C | 命令注入 | +| [CVE-2019-18634](https://nvd.nist.gov/vuln/detail/CVE-2019-18634) | sudo (pwfeedback) | C | 内存安全 | +| [CVE-2019-13132](https://nvd.nist.gov/vuln/detail/CVE-2019-13132) | ZeroMQ libzmq | C++ | 内存安全 | +| [CVE-2022-1941](https://nvd.nist.gov/vuln/detail/CVE-2022-1941) | Protocol Buffers | C++ | 内存安全 | +| [CVE-2025-69662](https://nvd.nist.gov/vuln/detail/CVE-2025-69662) | geopandas | Python | SQL 注入 | +| [CVE-2026-33626](https://nvd.nist.gov/vuln/detail/CVE-2026-33626) | LMDeploy | Python | SSRF | + +用例文件位于 [`tests/benchmark/cve_corpus/`](tests/benchmark/cve_corpus/),并附上游归属头注释。 + +--- + +## 工作原理 + +对文件系统进行两遍扫描,可选用 SQLite 索引跳过未变更文件: + +1. **Pass 1**:用 tree-sitter 解析每个文件,构建过程内 CFG(petgraph),下降到剪枝后的 SSA(在支配边界上做 Cytron phi 插入),并导出每函数摘要(source/sanitizer/sink 能力位、污点变换、指向集、被调集合)。 +2. **摘要合并**:将每文件摘要并集合并为 `GlobalSummaries` 映射。 +3. **Pass 2**:在跨文件上下文与有限上下文敏感(文件内被调用 k=1 内联,SCC 不动点上限 64 次迭代,超过内联体大小阈值的被调用走摘要回退)下重新分析每个文件。正向数据流工作表通过 SSA 格传播污点,保证收敛。调用图 SCC 迭代到不动点(在上限内),使相互递归函数能拿到准确摘要。 +4. **排序、去重、输出**:按 严重度 × 证据强度 × 源类可利用性 打分,并输出到控制台、JSON 或 SARIF。 + +检测器家族:污点(跨文件 source→sink,含 SQLi、XSS、命令/代码执行、反序列化、SSRF、路径穿越、格式串、加密、LDAP 注入、XPath 注入、HTTP 头/响应拆分、开放重定向、服务端模板注入、XXE、原型污染、数据外泄、以及 auth 折入的能力位类规则)、CFG 结构(鉴权缺失、未守卫汇、资源泄漏)、状态模型(use-after-close、double-close、must-leak、unauthed-access)、AST 模式(tree-sitter 结构匹配)。完整检测器文档:[Detectors](https://elicpeter.github.io/nyx/detectors.html)。 + +--- + +## 配置 + +配置由 `nyx.conf`(默认值)与 `nyx.local`(你的覆写)合并而成,从平台配置目录读取(Linux 为 `~/.config/nyx/`,macOS 为 `~/Library/Application Support/nyx/`,Windows 为 `%APPDATA%\elicpeter\nyx\config\`)。 + +```toml +[scanner] +mode = "full" # full | ast | cfg | taint +min_severity = "Medium" + +[server] +host = "127.0.0.1" +port = 9700 +open_browser = true + +# 项目专属净化器 +[[analysis.languages.javascript.rules]] +matchers = ["escapeHtml"] +kind = "sanitizer" +cap = "html_escape" +``` + +或交互式添加规则:`nyx config add-rule --lang javascript --matcher escapeHtml --kind sanitizer --cap html_escape`。能力位(caps):`env_var`、`html_escape`、`shell_escape`、`url_encode`、`json_parse`、`file_io`、`fmt_string`、`sql_query`、`deserialize`、`ssrf`、`data_exfil`、`code_exec`、`crypto`、`unauthorized_id`、`ldap_injection`、`xpath_injection`、`header_injection`、`open_redirect`、`ssti`、`xxe`、`prototype_pollution`、`all`。完整 schema:[Configuration](https://elicpeter.github.io/nyx/configuration.html)。运行 `nyx rules list` 可在终端浏览注册表。 + +--- + +## 状态 + +正在积极开发中。API、检测器行为、配置项可能在版本间发生变化。507 案例语料上的规则级 F1 是 CI 回归下限;分语言详情见 [`tests/benchmark/RESULTS.md`](tests/benchmark/RESULTS.md)。 + +污点分析是过程间的。持久化的每函数 SSA 摘要带有按返回路径的变换与参数粒度的指向集,调用图 SCC(包括跨文件 SCC)迭代到联合不动点。默认 `balanced` 画像还会对文件内被调用做 k=1 上下文敏感内联。Symex(含跨文件与过程间帧)以及按需后向遍历是可选项。可分别用 `--symex` 与 `--backwards-analysis` 单独开启,或通过 `--engine-profile deep` 一并开启。 + +局限: +- 过程间精度是有界而非无限的。上下文敏感内联为 k=1 且有被调用体大小上限,SCC 不动点有迭代上限。引擎触达上限时回退到摘要,并在发现上记录 `engine_note`。 +- 不跨语言追踪调用(FFI、子进程、WASM)。每种语言独立分析。 +- 几项语言特性未建模:宏、大多数动态分派、别名导入、反射。 +- C/C++ 处于预览梯度。当前已跟踪 STL 容器流、builder 链、内联类成员函数;深度指针别名与函数指针未跟踪。干净报告不应被理解为干净审计。在作为硬性 CI 门禁之前,请与基于 clang 的工具搭配使用。 +- 结果可能含误报或漏报;预期需要人工复核。 + +--- + +## 文档 + +完整文档站点:**[elicpeter.github.io/nyx](https://elicpeter.github.io/nyx/)**。 + +- [Quick Start](https://elicpeter.github.io/nyx/quickstart.html) · [CLI Reference](https://elicpeter.github.io/nyx/cli.html) · [Installation](https://elicpeter.github.io/nyx/installation.html) +- [`nyx serve`](https://elicpeter.github.io/nyx/serve.html) · [Output Formats](https://elicpeter.github.io/nyx/output.html) · [Configuration](https://elicpeter.github.io/nyx/configuration.html) +- [How it works](https://elicpeter.github.io/nyx/how-it-works.html) · [Detectors](https://elicpeter.github.io/nyx/detectors.html)([Taint](https://elicpeter.github.io/nyx/detectors/taint.html)、[CFG](https://elicpeter.github.io/nyx/detectors/cfg.html)、[State](https://elicpeter.github.io/nyx/detectors/state.html)、[AST Patterns](https://elicpeter.github.io/nyx/detectors/patterns.html)) +- [Rule Reference](https://elicpeter.github.io/nyx/rules.html) · [Language Maturity](https://elicpeter.github.io/nyx/language-maturity.html) · [Advanced Analysis](https://elicpeter.github.io/nyx/advanced-analysis.html) · [Auth Analysis](https://elicpeter.github.io/nyx/auth.html) + +--- + +## 参与贡献 + +欢迎贡献。 + +Nyx 是开源项目,并将永远保有完全开源的核心。为了支持长期开发并使项目可持续,贡献者在首次合入前可能会被要求签署 Contributor License Agreement。 + +提交前请运行 `sh scripts/check.sh`。完整指南(包括如何添加规则与支持新语言)见 [`CONTRIBUTING.md`](CONTRIBUTING.md)。崩溃、panic 或可疑结果请提 issue,附最小复现片段与 Nyx 版本号。 + +--- + +## AI 披露 + +- **引擎代码**(taint、SSA、CFG、调用图、抽象解释、符号执行):以人工编写为主。AI 仅用于有选择的重构与样板代码,所有合入均经人工审阅。 +- **文档与本 README 的大部分内容**:由 AI 基于代码生成并经人工编辑。文档与代码漂移请作为 bug 上报。 +- **测试用例与 `expected.yaml` 文件**:AI 协助起草,落库前经人工审核。 +- **前端 UI**(React 应用):在 AI 协助下构建,经人工审阅。 + +与任何静态分析器一样,在把 Nyx 用作 CI 门禁前,请基于你自己的语料验证发现。 + +--- + +## 许可证 + +GNU General Public License v3.0 或更高版本(GPL-3.0-or-later)。可选的 `smt` 特性会捆绑 Z3(MIT 许可);分发以 `--features smt` 构建的二进制时,应在归属信息中包含 Z3 的许可证。完整文本见 [LICENSE](./LICENSE);第三方依赖见 [THIRDPARTY-LICENSES.html](./THIRDPARTY-LICENSES.html)。 diff --git a/SECURITY.md b/SECURITY.md index 5b392047..fedd4cda 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,9 +39,9 @@ Out of scope: | Version | Status | |---------|-----------------------| -| 0.6.x | Supported | -| 0.5.x | Critical fixes only | -| < 0.5 | End of life | +| 0.7.x | Supported | +| 0.6.x | Critical fixes only | +| < 0.6 | End of life | The project follows [Semantic Versioning](https://semver.org) once it reaches 1.0.0. Until then, breaking changes can land in any minor release. diff --git a/THIRDPARTY-LICENSES.html b/THIRDPARTY-LICENSES.html index 11469794..a545c7c5 100644 --- a/THIRDPARTY-LICENSES.html +++ b/THIRDPARTY-LICENSES.html @@ -1542,7 +1542,7 @@
  • anstyle-query 1.1.5
  • anstyle-wincon 3.0.11
  • anstyle 1.0.14
  • -
  • assert_cmd 2.2.1
  • +
  • assert_cmd 2.2.2
  • bytesize 2.3.1
  • clap 4.6.1
  • clap_builder 4.6.0
  • @@ -2621,7 +2621,7 @@ limitations under the License.
  • bitflags 2.11.1
  • bstr 1.12.1
  • cast 0.3.0
  • -
  • cc 1.2.61
  • +
  • cc 1.2.62
  • cfg-if 1.0.4
  • compression-codecs 0.4.38
  • compression-core 0.4.32
  • @@ -2643,7 +2643,7 @@ limitations under the License.
  • hashbrown 0.14.5
  • hashbrown 0.15.5
  • hashbrown 0.16.1
  • -
  • hashbrown 0.17.0
  • +
  • hashbrown 0.17.1
  • heck 0.5.0
  • httparse 1.10.1
  • indexmap 2.14.0
  • @@ -4557,7 +4557,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

    GNU General Public License v3.0 only

    Used by:

     GNU GENERAL PUBLIC LICENSE
    @@ -5126,7 +5126,7 @@ DEALINGS IN THE SOFTWARE.
                     

    MIT License

    Used by:

    Copyright (c) 2019-2021 Tower Contributors
     
    @@ -5524,7 +5524,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE.
                     
                     
    MIT License
     
    @@ -5973,7 +5973,7 @@ SOFTWARE.
                     

    MIT License

    Used by:

    The MIT License (MIT)
     
    diff --git a/action-scripts/download.sh b/action-scripts/download.sh
    index 9401135a..7ba62964 100755
    --- a/action-scripts/download.sh
    +++ b/action-scripts/download.sh
    @@ -27,7 +27,7 @@ esac
     
     # ── Resolve "latest" to an actual release tag ────────────────────────────────
     if [[ "$VERSION" == "latest" ]]; then
    -  echo "::warning::version: latest follows a mutable tag. Pin to a specific release (e.g. v0.6.0) for supply-chain safety."
    +  echo "::warning::version: latest follows a mutable tag. Pin to a specific release (e.g. v0.7.0) for supply-chain safety."
       API_URL="https://api.github.com/repos/${REPO}/releases/latest"
       CURL_ARGS=(-fsSL)
       if [[ -n "${GITHUB_TOKEN:-}" ]]; then
    diff --git a/action.yml b/action.yml
    index 4877d290..6e4d1f46 100644
    --- a/action.yml
    +++ b/action.yml
    @@ -12,9 +12,9 @@ inputs:
         required: false
         default: '.'
       version:
    -    description: 'Nyx release tag (e.g. v0.6.0). "latest" is accepted but discouraged, pinning to a specific tag protects against upstream compromise.'
    +    description: 'Nyx release tag (e.g. v0.7.0). "latest" is accepted but discouraged, pinning to a specific tag protects against upstream compromise.'
         required: false
    -    default: 'v0.6.0'
    +    default: 'v0.7.0'
       format:
         description: 'Output format: sarif, json, or console'
         required: false
    diff --git a/docs/advanced-analysis.md b/docs/advanced-analysis.md
    index b6f1bfb1..11211657 100644
    --- a/docs/advanced-analysis.md
    +++ b/docs/advanced-analysis.md
    @@ -101,7 +101,7 @@ origin-attribution.
     taint flow to the return value is fully validated by a dominating
     predicate (regex allowlist, type check, validation call) on every
     return path. At call sites, each tainted argument passed to a
    -validated position — and the call's own return value — are marked
    +validated position, and the call's own return value, are marked
     `validated_must` / `validated_may` in the caller's SSA taint state,
     the same way an inline `if (!regex.test(x)) throw …` would validate
     the surviving branch. Sound because the summary is recorded only when
    diff --git a/docs/auth.md b/docs/auth.md
    index c60303f7..7b86bc60 100644
    --- a/docs/auth.md
    +++ b/docs/auth.md
    @@ -1,6 +1,6 @@
     # Auth analysis
     
    -**Rust today.** Other languages have rule scaffolding in [`src/auth_analysis/config.rs`](https://github.com/elicpeter/nyx/blob/master/src/auth_analysis/config.rs) (Python, Ruby, Go, Java, JavaScript, TypeScript), but only Rust has benchmark corpus coverage and the precision work to back it. Treat findings on other languages as preview; the rule prefix (`py.auth.*`, `js.auth.*`, `rb.auth.*`, `go.auth.*`, `java.auth.*`) is reserved but the matchers haven't been validated against real codebases yet.
    +**Rust is the stable target.** Python and Go have shipped precision work as of 0.7.0 (FastAPI cross-file dependencies, Go DAO-helper filtering, same-file caller-scope IPA) and are usable on real codebases. Ruby, Java, JavaScript, and TypeScript have rule scaffolding in [`src/auth_analysis/config.rs`](https://github.com/elicpeter/nyx/blob/master/src/auth_analysis/config.rs) but no benchmark corpus yet; treat findings there as preview.
     
     ## What it catches
     
    @@ -19,18 +19,44 @@ Handlers registered through attribute macros (`#[get("/path")]`, `#[routes::path
     
     ## Caller-scope-entity exemption
     
    -`.id` / `.pk` is not flagged when `` is a unit parameter named after a multi-tenant scope primitive: `organization` / `org`, `project`, `team`, `workspace`, `tenant`, `account`, `community`, `group`, `repository` / `repo`, `company`. The argument represents the caller's scope, not a user-controlled target, so internal helpers like `def get_environments(request, organization): Environment.objects.filter(organization_id=organization.id, …)` inherit the caller's authorization. Other field names (`.name`, `.slug`) still flag, and `user` / `member` / `actor` are deliberately excluded — those are handled by the actor-context recogniser.
    +`.id` / `.pk` is not flagged when `` is a unit parameter named after a multi-tenant scope primitive: `organization` / `org`, `project`, `team`, `workspace`, `tenant`, `account`, `community`, `group`, `repository` / `repo`, `company`. The argument represents the caller's scope, not a user-controlled target, so internal helpers like `def get_environments(request, organization): Environment.objects.filter(organization_id=organization.id, …)` inherit the caller's authorization. Other field names (`.name`, `.slug`) still flag, and `user` / `member` / `actor` are deliberately excluded; those are handled by the actor-context recogniser.
     
     ## Project-level web-framework gate (Rust)
     
     In Rust, the `context_inputs` and param-name arms of the user-input heuristic are gated by a project-level web-framework signal. The signal is three-valued:
     
    -- `Some(true)` — the project's `Cargo.toml` names `axum`, `actix-web`, or `rocket`, OR the file directly imports one (`axum::`, `actix_web::`, `rocket::`, `axum_extra::`). Heuristics stay on.
    -- `Some(false)` — `Cargo.toml` was inspected and named no web framework, AND the file does not directly import one. Heuristics off; only `RouteHandler` classification (concrete route-registration evidence) survives.
    -- `None` — no detection ran (single-file scan with no project root). Heuristics on; behavior unchanged.
    +- `Some(true)`: the project's `Cargo.toml` names `axum`, `actix-web`, or `rocket`, OR the file directly imports one (`axum::`, `actix_web::`, `rocket::`, `axum_extra::`). Heuristics stay on.
    +- `Some(false)`: `Cargo.toml` was inspected and named no web framework, AND the file does not directly import one. Heuristics off; only `RouteHandler` classification (concrete route-registration evidence) survives.
    +- `None`: no detection ran (single-file scan with no project root). Heuristics on; behavior unchanged.
     
     This avoids a class of FPs in non-web Rust crates where a debug-session handle named `session` would trip on `session.update(cx, …)`-style desktop-app code. Other languages keep prior behavior; the gate is currently Rust-only.
     
    +## Python: FastAPI cross-file dependencies
    +
    +FastAPI's `include_router` chain is resolved across files. A child router declared in `routes/task_instances.py` and attached on a parent in `routes/__init__.py` inherits the parent's `dependencies=[...]`.
    +
    +- Module-level `router = APIRouter(dependencies=[Security(...)])` is pre-walked once per file and merged onto every `@.(...)` route attached in the same file.
    +- `.include_router(.)` edges are captured per file in pass 1, persisted into `GlobalSummaries::router_facts_by_module`, and lifted onto the active file's `AuthorizationModel::cross_file_router_deps` at pass 2 entry. Transitive lifts (grandparent to parent to child) iterate to fixpoint.
    +- `Security(callable, scopes=[...])` is recognised distinctly from `Depends(callable)` and promotes the synthetic `AuthCheck` to `AuthCheckKind::Other` (route-level scope-checked authorization). Bare `Depends(callable)` is still a Login-only check.
    +
    +Module identity is the file basename without `.py`. This is sufficient for airflow-style `task_instances.router` naming; a project with two files of the same name in different subtrees will currently collide.
    +
    +## Go: DAO-helper id-scalar precision pass
    +
    +For non-route Go units, a parameter whose declared type is a bounded primitive scalar (`int64`, `uint32`, `string`, `bool`, `byte`, `rune`, `float64`, etc.) and whose name is id-shaped (`id`, `*Id`, `*_id`, `*ids`) is dropped from `unit.params` before ownership-check evaluation.
    +
    +Real Go HTTP handlers always carry a framework-request-typed param (`*http.Request`, `*gin.Context`, `echo.Context`, `*fiber.Ctx`); per-framework route extractors set `include_id_like_typed=true` so id-shaped path params survive on real routes. The filter only fires when the unit was not classified as a route handler, so helpers like `func GetRunByRepoAndID(ctx, repoID, runID int64)` are recognised as DAO callees and the ownership check is expected at the calling route handler, not inside the helper.
    +
    +## Same-file caller-scope IPA
    +
    +When a private helper is called only from authorized route handlers in the same file, the caller's auth checks lift onto the helper as synthetic `is_route_level=true` `AuthCheck` entries.
    +
    +- Iterated to a small fixpoint so transitive chains (route to mid_helper to leaf_helper) are covered.
    +- Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers.
    +- Cross-file equivalent is deferred.
    +
    +This closes the FastAPI / Django / Flask shape where a route authenticates via decorator or dependency, then delegates to a private helper that performs the sink.
    +
     ## Sink classification
     
     The same call name can be safe on a local collection and dangerous on a database. The detector categorises each candidate sink before deciding whether to flag:
    diff --git a/docs/detectors/taint.md b/docs/detectors/taint.md
    index 2f8eebe1..95618c84 100644
    --- a/docs/detectors/taint.md
    +++ b/docs/detectors/taint.md
    @@ -160,7 +160,7 @@ Some detector classes need to know not just *that* a value is attacker-influence
     | `Sensitive` | `Cookie`, `Header`, `EnvironmentConfig`, `FileSystem`, `Database`, `CaughtException`, `Unknown` | Operator-bound state that should not leak across boundaries. |
     | `Secret` | (reserved for explicit credential sources) | Highest tier; treated identically to `Sensitive` today. |
     
    -`Cap::DATA_EXFIL` only fires when the contributing source is at least `Sensitive`. Plain user input flowing into an outbound `fetch` body is suppressed at finding-emission time — the canonical false-positive class for API gateways and telemetry forwarders that proxy `req.body`. SSRF and other classes are unaffected; the gate is scoped to `DATA_EXFIL`.
    +`Cap::DATA_EXFIL` only fires when the contributing source is at least `Sensitive`. Plain user input flowing into an outbound `fetch` body is suppressed at finding-emission time. That is the canonical false-positive class for API gateways and telemetry forwarders that proxy `req.body`. SSRF and other classes are unaffected; the gate is scoped to `DATA_EXFIL`.
     
     If a project legitimately classifies a request body as sensitive (e.g. an internal forwarder where `req.body` carries a pre-authenticated user token), override via custom rules in `nyx.conf`:
     
    @@ -177,7 +177,7 @@ Or re-classify the source itself with a custom Source rule whose name matches on
     
     ## DATA_EXFIL suppression layers
     
    -Three knobs ship out of the box so projects can match the cap to their architecture without per-call suppressions.
    +Three suppression knobs ship by default so projects can match the cap to their architecture without per-call suppressions.
     
     ### 1. Forwarding-wrapper sanitizer convention
     
    @@ -215,7 +215,7 @@ trusted_destinations = [
     ]
     ```
     
    -Use full origins or origin-pinned paths so a partial-host match across unrelated origins cannot occur. `https://api.` would also match `https://api.evil.example.com/` — the entry must include the path separator (`/`) at the end of the host.
    +Use full origins or origin-pinned paths so a partial-host match across unrelated origins cannot occur. `https://api.` would also match `https://api.evil.example.com/`, so the entry must include the path separator (`/`) at the end of the host.
     
     The match consults the abstract string domain: a literal URL is a static prefix; a template literal `\`https://api.internal/${id}\`` exposes the prefix `https://api.internal/`; a fully dynamic URL has no prefix and the cap fires as usual.
     
    @@ -228,7 +228,7 @@ Some projects forward user-bound payloads as a matter of architecture. Turn the
     enabled = false
     ```
     
    -`enabled = false` strips `Cap::DATA_EXFIL` from sink caps before event emission, so no `taint-data-exfiltration` finding reaches the report. The decision is per-project — other projects loaded by the same `nyx serve` instance keep their own settings.
    +`enabled = false` strips `Cap::DATA_EXFIL` from sink caps before event emission, so no `taint-data-exfiltration` finding reaches the report. The decision is per-project; other projects loaded by the same `nyx serve` instance keep their own settings.
     
     ## DATA_EXFIL sinks per language
     
    diff --git a/docs/recall-validation.md b/docs/recall-validation.md
    new file mode 100644
    index 00000000..5db678a6
    --- /dev/null
    +++ b/docs/recall-validation.md
    @@ -0,0 +1,237 @@
    +# Recall validation runbook
    +
    +The recall-validation harness freezes a finding-shape baseline against
    +real-world OSS targets so future engine work can prove "actually lifts
    +recall on real code", not just "tests pass". This runbook covers
    +re-running the validation against a fresh OSS release.
    +
    +## Targets
    +
    +| Target            | Clone URL                                  | Recall items exercised |
    +|-------------------|--------------------------------------------|------------------------|
    +| `cal_com`         | https://github.com/calcom/cal.com          | 1, 5, 6, 7             |
    +| `vercel_commerce` | https://github.com/vercel/commerce         | 1, 4, 7                |
    +| `shadcn_examples` | https://github.com/shadcn-ui/ui            | 4, 7                   |
    +| `blitz_apps`      | https://github.com/blitz-js/blitz          | 1, 3, 6                |
    +
    +Item numbering is from `.pitboss/RECALL_GAPS.md`.
    +
    +## Files
    +
    +| File                                          | Role                                    |
    +|-----------------------------------------------|-----------------------------------------|
    +| `scripts/validate_recall.sh`                  | runner (capture + diff modes)           |
    +| `tests/recall_targets/.json`          | per-target baseline                     |
    +| `tests/recall_gaps.rs::validate_real_world_targets` | schema-validity test (`#[ignore]`)|
    +| `tests/recall_gaps_baseline.json`             | corpus regression baseline              |
    +
    +Baselines live next to the harness rather than under `.pitboss/`:
    +pitboss implementer agents are forbidden to write under `.pitboss/`,
    +so the baseline files were placed beside the test that consumes them.
    +
    +## Baseline schema
    +
    +```json
    +{
    +  "_doc": "...",
    +  "target": "cal_com",
    +  "clone_url": "https://github.com/calcom/cal.com",
    +  "exercises_recall_items": [1, 5, 6, 7],
    +  "captured_against": "real-scan @ ",
    +  "captured_on": "YYYY-MM-DD",
    +  "pinned_commit": "",
    +  "findings": [
    +    {
    +      "rule_id": "taint-unsanitised-flow",
    +      "path_suffix": "packages/...",
    +      "line": 130,
    +      "severity": "High",
    +      "verdict": "TP" | "FP" | "needs_review",
    +      "note": "..."
    +    }
    +  ]
    +}
    +```
    +
    +The diff key is `(rule_id, path_suffix, line)`. The `verdict` field
    +must be one of `TP`, `FP`, or `needs_review`; unknown verdicts are
    +rejected by the schema test.
    +
    +## Usage
    +
    +### Diff a fresh scan against the frozen baseline
    +
    +```bash
    +scripts/validate_recall.sh cal_com /path/to/cal.com
    +```
    +
    +Output is a JSON object `{ added, removed, unchanged, *_total }`
    +keyed by `rule_id`. Use this to spot intentional recall lift
    +(`added`) and regressions (`removed`).
    +
    +### Refresh the baseline after an intentional recall lift
    +
    +```bash
    +scripts/validate_recall.sh cal_com /path/to/cal.com --capture
    +```
    +
    +This overwrites `tests/recall_targets/cal_com.json` with the current
    +scan output. Every finding is re-marked `verdict: "needs_review"`;
    +hand-label `TP`/`FP` afterwards as you triage.
    +
    +### Schema-validity check
    +
    +```bash
    +cargo test --release --test recall_gaps -- --ignored validate_real_world_targets
    +```
    +
    +Loads each per-target JSON, asserts the required keys exist, and
    +asserts every finding carries a valid verdict label.
    +
    +## Refresh procedure
    +
    +1. Clone or pull the target repo into `~/oss/` (or wherever).
    +2. Build nyx: `cargo build --release`.
    +3. Run the diff in plain mode to see what changed:
    +   `scripts/validate_recall.sh  ~/oss/`.
    +4. If the lift is intentional, recapture:
    +   `scripts/validate_recall.sh  ~/oss/ --capture`.
    +5. Spot-check a handful of new findings. Open the file at
    +   `path_suffix:line` and confirm the source-to-sink flow is real.
    +   Hand-label them `TP`/`FP`.
    +6. Commit the updated `tests/recall_targets/.json`.
    +
    +## Known captured baselines (2026-05-08)
    +
    +| Target            | Pinned commit | Findings | TP | FP | needs_review |
    +|-------------------|---------------|----------|----|----|--------------|
    +| `cal_com`         | `d278d6c9`    | 662      | 0  | 4  | 658          |
    +| `vercel_commerce` | unknown       | 0 (placeholder) |    |    |              |
    +| `shadcn_examples` | unknown       | 0 (placeholder) |    |    |              |
    +| `blitz_apps`      | unknown       | 0 (placeholder) |    |    |              |
    +
    +The `cal_com` capture used commit `d278d6c9bc535bf3f2c6ba0607654f78dd74d6ee`
    +(`refactor: remove dead insights references (#29029)`). The 4 `FP`
    +labels are `ts.crypto.math_random` hits inside `apps/web/playwright/`
    +test fixtures, which are not a security context.
    +
    +The other three targets ship as placeholders (empty `findings`).
    +Nobody has cloned them locally yet. Run `validate_recall.sh
    +  --capture` to populate. The schema test still passes
    +because `[]` is a valid `findings` array with zero entries to check.
    +
    +## Perf baseline
    +
    +The frozen JS-target perf snapshot lives in
    +`tests/recall_targets/perf_after.txt`. Compare against the
    +`captured_against` snapshot in `tests/recall_gaps_baseline.json`
    +(`corpus_finding_lines.findings_total` = 1121, captured at master
    +`ea82ea98`). The acceptance bar: scanner throughput on the existing
    +`tests/fixtures/` corpus must regress by no more than 15%. Future
    +recall work uses the same corpus and the same record file to measure
    +its own perf delta.
    +
    +## Cross-language runbook
    +
    +The JS-target baselines above only cover JS/TS. Cross-language
    +baselines mirror that work against real-world non-JS targets so
    +multi-language engine changes can be measured against actual code,
    +not just synthetic fixtures. Per-lang baselines live under
    +`tests/recall_targets/xlang//.json` and the runner
    +accepts a `--lang` flag to select the target set.
    +
    +### Cross-language targets
    +
    +| Lang   | Target       | Clone URL                                    | Pinned commit (capture) | Findings | Notes |
    +|--------|--------------|----------------------------------------------|-------------------------|----------|-------|
    +| php    | phpmyadmin   | https://github.com/phpmyadmin/phpmyadmin     | `ddf4e993`              | 119      | DBA UI; XSS / `php.deser` / `cfg-unguarded-sink` heavy. |
    +| php    | joomla       | https://github.com/joomla/joomla-cms         | `7e8527d0`              | 83       | CMS; `php.deser.unserialize` and `php.path.include_variable` clusters. |
    +| php    | drupal       | https://github.com/drupal/drupal             | `92aa759e`              | 635      | CMS / DI container; `cfg-unguarded-sink` (198) and `taint-prototype-pollution` (121) dominant. |
    +| php    | nextcloud    | https://github.com/nextcloud/server          | `5c0fe4c3`              | 262      | File-sync platform; `cfg-resource-leak` / `state-resource-leak` heavy. |
    +| java   | openmrs      | https://github.com/openmrs/openmrs-core      | `f9c76db2`              | 273      | Hibernate-heavy; JPA Criteria fix from `project_realrepo_openmrs.md` already applied. |
    +| python | airflow      | https://github.com/apache/airflow            | `3d42610a`              | 892      | Scheduler / DAG runner; `cfg-unguarded-sink` (252) and `taint-unsanitised-flow` (179) lead. |
    +| python | flask        | https://github.com/pallets/flask             | placeholder             | 0        | Smaller-surface Python framework; capture deferred. |
    +| go     | gin          | https://github.com/gin-gonic/gin             | `d3ffc998`              | 20       | HTTP framework test corpus; `taint-header-injection` and TLS skip-verify in tests. |
    +| rust   | axum         | https://github.com/tokio-rs/axum             | placeholder             | 0        | Not cloned in pitboss sandbox at capture time; populate locally. |
    +| ruby   | rails        | https://github.com/rails/rails               | placeholder             | 0        | Capture against the `actionpack/` subtree once cloned. |
    +
    +Captures dated `2026-05-09` (UTC). Counts are deduplicated tuples
    +`(rule_id, path_suffix, line)`. Duplicate raw findings collapse on
    +the diff key, so the schema-test count and diff-mode `unchanged_total`
    +may differ from the `findings | length` total by a handful of
    +duplicate sites. The diff key is what matters for regression
    +detection.
    +
    +### Per-lang TP/FP splits
    +
    +Every captured finding ships with `verdict: "needs_review"` from
    +`--capture`. Hand-triage is bounded but pending; none of the cross-
    +language captures are sweep-labelled yet. Use the per-lang dominant
    +rule_id clusters above as the priority queue:
    +
    +- **PHP**: `cfg-unguarded-sink` and `taint-prototype-pollution` are
    +  the FP-dominant clusters across drupal / nextcloud / phpmyadmin
    +  (CMS routing + JS object construction). `php.deser.unserialize` is
    +  the highest-value TP cluster on joomla (17) and drupal (83). See
    +  `project_realrepo_joomla.md` 2026-05-03 for the magic-method
    +  passthrough fix that already filters one shape.
    +- **Java**: `taint-unsanitised-flow` (61) and `state-resource-leak`
    +  (60) are openmrs's leading clusters. The JPA Criteria-API fix
    +  already absorbed the `cfg-unguarded-sink` cluster (216 to 24);
    +  remaining Hibernate / Spring resource-management FPs are the next
    +  triage target.
    +- **Python**: `cfg-unguarded-sink` (252) on airflow is dominated by
    +  Airflow's scheduler / DB plumbing; `py.auth.token_override_*`
    +  (83) and `py.auth.missing_ownership_check` (61) are the auth-rule
    +  noise typical of an admin/operator codebase.
    +- **Go**: gin's 20 findings are mostly test-corpus artifacts
    +  (`gin_test.go`, `routes_test.go`); 4 of 4 `go.transport.insecure_skip_verify`
    +  hits are inside `gin*_test.go` and are legitimate test setup.
    +- **Rust / Ruby**: placeholder. Capture once a local clone exists.
    +
    +### `--lang` runner usage
    +
    +```bash
    +# diff mode (default)
    +scripts/validate_recall.sh --lang php drupal /Users/me/oss/drupal
    +scripts/validate_recall.sh --lang java openmrs /Users/me/oss/openmrs
    +
    +# capture / refresh
    +scripts/validate_recall.sh --lang go gin /Users/me/oss/gin --capture
    +```
    +
    +Output is the same `{ added, removed, unchanged, *_total }` JSON shape
    +as the JS-target diff. The diff key is `(rule_id, path_suffix, line)`.
    +
    +### Cross-language refresh procedure
    +
    +1. Clone or update the target into `~/oss/` (or wherever).
    +2. Build nyx: `cargo build --release`.
    +3. Diff vs the frozen baseline:
    +   `scripts/validate_recall.sh --lang   ~/oss/`.
    +4. If the lift is intentional, recapture with `--capture`.
    +5. Spot-check new findings; hand-label `TP`/`FP`.
    +6. Commit the updated `tests/recall_targets/xlang//.json`.
    +
    +### Sandbox-capture caveat
    +
    +Pitboss implementer agents run sandboxed without network egress, so
    +target repos that are not already present under `~/oss/` ship as
    +placeholders (`pinned_commit: "unknown"`, `findings: []`). The
    +current cross-language baselines cover php / java / python / go
    +(every target whose repo was already cloned locally) and ship
    +placeholders for `rust/axum`, `ruby/rails`, and `python/flask`. The
    +schema test in `validate_real_world_targets` passes against
    +placeholders because `[]` is a valid `findings` array.
    +
    +## What lives where (quick reference)
    +
    +- Targets list and recall-item mapping in this file.
    +- Per-target JS findings under `tests/recall_targets/.json`.
    +- Per-target cross-lang findings under `tests/recall_targets/xlang//.json`.
    +- Diff/capture runner at `scripts/validate_recall.sh` (accepts `--lang`).
    +- Schema-validity test at `tests/recall_gaps.rs::validate_real_world_targets`.
    +- Corpus regression baseline at `tests/recall_gaps_baseline.json`.
    +- Perf records at `tests/recall_targets/perf_after.txt` (JS-target
    +  snapshot) and `tests/recall_targets/perf_after_xlang.txt`
    +  (cross-language delta).
    diff --git a/frontend/package-lock.json b/frontend/package-lock.json
    index 2d4dc97c..0017d99e 100644
    --- a/frontend/package-lock.json
    +++ b/frontend/package-lock.json
    @@ -1,19 +1,19 @@
     {
       "name": "nyx-frontend",
    -  "version": "0.6.1",
    +  "version": "0.7.0",
       "lockfileVersion": 3,
       "requires": true,
       "packages": {
         "": {
           "name": "nyx-frontend",
    -      "version": "0.6.1",
    +      "version": "0.7.0",
           "license": "GPL-3.0-or-later",
           "dependencies": {
    -        "@tanstack/react-query": "^5.100.9",
    +        "@tanstack/react-query": "^5.100.10",
             "elkjs": "^0.11.1",
             "graphology": "^0.26.0",
    -        "react": "^19.2.5",
    -        "react-dom": "^19.2.5",
    +        "react": "^19.2.6",
    +        "react-dom": "^19.2.6",
             "react-router-dom": "^7.15.0",
             "sigma": "^3.0.3"
           },
    @@ -25,7 +25,7 @@
             "@types/react": "^19.2.14",
             "@types/react-dom": "^19.2.3",
             "@vitejs/plugin-react": "^6.0.1",
    -        "@vitest/coverage-v8": "^4.1.5",
    +        "@vitest/coverage-v8": "^4.1.6",
             "eslint": "^10.3.0",
             "eslint-plugin-react-hooks": "^7.1.1",
             "eslint-plugin-react-refresh": "^0.5.2",
    @@ -35,8 +35,8 @@
             "prettier": "^3.8.3",
             "typescript": "~6.0.3",
             "typescript-eslint": "^8.59.2",
    -        "vite": "^8.0.10",
    -        "vitest": "^4.1.5"
    +        "vite": "^8.0.12",
    +        "vitest": "^4.1.6"
           }
         },
         "node_modules/@adobe/css-tools": {
    @@ -870,9 +870,9 @@
           }
         },
         "node_modules/@oxc-project/types": {
    -      "version": "0.127.0",
    -      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
    -      "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
    +      "version": "0.129.0",
    +      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
    +      "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
           "dev": true,
           "license": "MIT",
           "funding": {
    @@ -891,9 +891,9 @@
           }
         },
         "node_modules/@rolldown/binding-android-arm64": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
    +      "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
           "cpu": [
             "arm64"
           ],
    @@ -908,9 +908,9 @@
           }
         },
         "node_modules/@rolldown/binding-darwin-arm64": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
    +      "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
           "cpu": [
             "arm64"
           ],
    @@ -925,9 +925,9 @@
           }
         },
         "node_modules/@rolldown/binding-darwin-x64": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
    +      "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
           "cpu": [
             "x64"
           ],
    @@ -942,9 +942,9 @@
           }
         },
         "node_modules/@rolldown/binding-freebsd-x64": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
    +      "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
           "cpu": [
             "x64"
           ],
    @@ -959,9 +959,9 @@
           }
         },
         "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
    +      "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
           "cpu": [
             "arm"
           ],
    @@ -976,9 +976,9 @@
           }
         },
         "node_modules/@rolldown/binding-linux-arm64-gnu": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
    +      "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
           "cpu": [
             "arm64"
           ],
    @@ -996,9 +996,9 @@
           }
         },
         "node_modules/@rolldown/binding-linux-arm64-musl": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
    +      "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
           "cpu": [
             "arm64"
           ],
    @@ -1016,9 +1016,9 @@
           }
         },
         "node_modules/@rolldown/binding-linux-ppc64-gnu": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
    +      "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
           "cpu": [
             "ppc64"
           ],
    @@ -1036,9 +1036,9 @@
           }
         },
         "node_modules/@rolldown/binding-linux-s390x-gnu": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
    +      "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
           "cpu": [
             "s390x"
           ],
    @@ -1056,9 +1056,9 @@
           }
         },
         "node_modules/@rolldown/binding-linux-x64-gnu": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
    +      "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
           "cpu": [
             "x64"
           ],
    @@ -1076,9 +1076,9 @@
           }
         },
         "node_modules/@rolldown/binding-linux-x64-musl": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
    +      "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
           "cpu": [
             "x64"
           ],
    @@ -1096,9 +1096,9 @@
           }
         },
         "node_modules/@rolldown/binding-openharmony-arm64": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
    +      "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
           "cpu": [
             "arm64"
           ],
    @@ -1113,9 +1113,9 @@
           }
         },
         "node_modules/@rolldown/binding-wasm32-wasi": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
    +      "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
           "cpu": [
             "wasm32"
           ],
    @@ -1132,9 +1132,9 @@
           }
         },
         "node_modules/@rolldown/binding-win32-arm64-msvc": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
    +      "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
           "cpu": [
             "arm64"
           ],
    @@ -1149,9 +1149,9 @@
           }
         },
         "node_modules/@rolldown/binding-win32-x64-msvc": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
    +      "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
           "cpu": [
             "x64"
           ],
    @@ -1180,9 +1180,9 @@
           "license": "MIT"
         },
         "node_modules/@tanstack/query-core": {
    -      "version": "5.100.9",
    -      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
    -      "integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==",
    +      "version": "5.100.10",
    +      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz",
    +      "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==",
           "license": "MIT",
           "funding": {
             "type": "github",
    @@ -1190,12 +1190,12 @@
           }
         },
         "node_modules/@tanstack/react-query": {
    -      "version": "5.100.9",
    -      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz",
    -      "integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==",
    +      "version": "5.100.10",
    +      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz",
    +      "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==",
           "license": "MIT",
           "dependencies": {
    -        "@tanstack/query-core": "5.100.9"
    +        "@tanstack/query-core": "5.100.10"
           },
           "funding": {
             "type": "github",
    @@ -1643,14 +1643,14 @@
           }
         },
         "node_modules/@vitest/coverage-v8": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
    -      "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
    +      "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
             "@bcoe/v8-coverage": "^1.0.2",
    -        "@vitest/utils": "4.1.5",
    +        "@vitest/utils": "4.1.6",
             "ast-v8-to-istanbul": "^1.0.0",
             "istanbul-lib-coverage": "^3.2.2",
             "istanbul-lib-report": "^3.0.1",
    @@ -1664,8 +1664,8 @@
             "url": "https://opencollective.com/vitest"
           },
           "peerDependencies": {
    -        "@vitest/browser": "4.1.5",
    -        "vitest": "4.1.5"
    +        "@vitest/browser": "4.1.6",
    +        "vitest": "4.1.6"
           },
           "peerDependenciesMeta": {
             "@vitest/browser": {
    @@ -1674,16 +1674,16 @@
           }
         },
         "node_modules/@vitest/expect": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
    -      "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
    +      "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
             "@standard-schema/spec": "^1.1.0",
             "@types/chai": "^5.2.2",
    -        "@vitest/spy": "4.1.5",
    -        "@vitest/utils": "4.1.5",
    +        "@vitest/spy": "4.1.6",
    +        "@vitest/utils": "4.1.6",
             "chai": "^6.2.2",
             "tinyrainbow": "^3.1.0"
           },
    @@ -1692,13 +1692,13 @@
           }
         },
         "node_modules/@vitest/mocker": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
    -      "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
    +      "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
    -        "@vitest/spy": "4.1.5",
    +        "@vitest/spy": "4.1.6",
             "estree-walker": "^3.0.3",
             "magic-string": "^0.30.21"
           },
    @@ -1719,9 +1719,9 @@
           }
         },
         "node_modules/@vitest/pretty-format": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
    -      "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
    +      "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
    @@ -1732,13 +1732,13 @@
           }
         },
         "node_modules/@vitest/runner": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
    -      "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
    +      "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
    -        "@vitest/utils": "4.1.5",
    +        "@vitest/utils": "4.1.6",
             "pathe": "^2.0.3"
           },
           "funding": {
    @@ -1746,14 +1746,14 @@
           }
         },
         "node_modules/@vitest/snapshot": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
    -      "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
    +      "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
    -        "@vitest/pretty-format": "4.1.5",
    -        "@vitest/utils": "4.1.5",
    +        "@vitest/pretty-format": "4.1.6",
    +        "@vitest/utils": "4.1.6",
             "magic-string": "^0.30.21",
             "pathe": "^2.0.3"
           },
    @@ -1762,9 +1762,9 @@
           }
         },
         "node_modules/@vitest/spy": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
    -      "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
    +      "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
           "dev": true,
           "license": "MIT",
           "funding": {
    @@ -1772,13 +1772,13 @@
           }
         },
         "node_modules/@vitest/utils": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
    -      "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
    +      "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
    -        "@vitest/pretty-format": "4.1.5",
    +        "@vitest/pretty-format": "4.1.6",
             "convert-source-map": "^2.0.0",
             "tinyrainbow": "^3.1.0"
           },
    @@ -3913,24 +3913,24 @@
           }
         },
         "node_modules/react": {
    -      "version": "19.2.5",
    -      "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
    -      "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
    +      "version": "19.2.6",
    +      "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
    +      "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
           "license": "MIT",
           "engines": {
             "node": ">=0.10.0"
           }
         },
         "node_modules/react-dom": {
    -      "version": "19.2.5",
    -      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
    -      "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
    +      "version": "19.2.6",
    +      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
    +      "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
           "license": "MIT",
           "dependencies": {
             "scheduler": "^0.27.0"
           },
           "peerDependencies": {
    -        "react": "^19.2.5"
    +        "react": "^19.2.6"
           }
         },
         "node_modules/react-is": {
    @@ -4041,14 +4041,14 @@
           }
         },
         "node_modules/rolldown": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
    +      "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
    -        "@oxc-project/types": "=0.127.0",
    -        "@rolldown/pluginutils": "1.0.0-rc.17"
    +        "@oxc-project/types": "=0.129.0",
    +        "@rolldown/pluginutils": "1.0.0"
           },
           "bin": {
             "rolldown": "bin/cli.mjs"
    @@ -4057,27 +4057,27 @@
             "node": "^20.19.0 || >=22.12.0"
           },
           "optionalDependencies": {
    -        "@rolldown/binding-android-arm64": "1.0.0-rc.17",
    -        "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
    -        "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
    -        "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
    -        "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
    -        "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
    -        "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
    -        "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
    -        "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
    -        "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
    -        "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
    -        "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
    -        "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
    -        "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
    -        "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
    +        "@rolldown/binding-android-arm64": "1.0.0",
    +        "@rolldown/binding-darwin-arm64": "1.0.0",
    +        "@rolldown/binding-darwin-x64": "1.0.0",
    +        "@rolldown/binding-freebsd-x64": "1.0.0",
    +        "@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
    +        "@rolldown/binding-linux-arm64-gnu": "1.0.0",
    +        "@rolldown/binding-linux-arm64-musl": "1.0.0",
    +        "@rolldown/binding-linux-ppc64-gnu": "1.0.0",
    +        "@rolldown/binding-linux-s390x-gnu": "1.0.0",
    +        "@rolldown/binding-linux-x64-gnu": "1.0.0",
    +        "@rolldown/binding-linux-x64-musl": "1.0.0",
    +        "@rolldown/binding-openharmony-arm64": "1.0.0",
    +        "@rolldown/binding-wasm32-wasi": "1.0.0",
    +        "@rolldown/binding-win32-arm64-msvc": "1.0.0",
    +        "@rolldown/binding-win32-x64-msvc": "1.0.0"
           }
         },
         "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
    -      "version": "1.0.0-rc.17",
    -      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
    -      "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
    +      "version": "1.0.0",
    +      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
    +      "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
           "dev": true,
           "license": "MIT"
         },
    @@ -4635,16 +4635,16 @@
           }
         },
         "node_modules/vite": {
    -      "version": "8.0.10",
    -      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
    -      "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
    +      "version": "8.0.12",
    +      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
    +      "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
             "lightningcss": "^1.32.0",
             "picomatch": "^4.0.4",
    -        "postcss": "^8.5.10",
    -        "rolldown": "1.0.0-rc.17",
    +        "postcss": "^8.5.14",
    +        "rolldown": "1.0.0",
             "tinyglobby": "^0.2.16"
           },
           "bin": {
    @@ -4661,7 +4661,7 @@
           },
           "peerDependencies": {
             "@types/node": "^20.19.0 || >=22.12.0",
    -        "@vitejs/devtools": "^0.1.0",
    +        "@vitejs/devtools": "^0.1.18",
             "esbuild": "^0.27.0 || ^0.28.0",
             "jiti": ">=1.21.0",
             "less": "^4.0.0",
    @@ -4713,19 +4713,19 @@
           }
         },
         "node_modules/vitest": {
    -      "version": "4.1.5",
    -      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
    -      "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
    +      "version": "4.1.6",
    +      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
    +      "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
           "dev": true,
           "license": "MIT",
           "dependencies": {
    -        "@vitest/expect": "4.1.5",
    -        "@vitest/mocker": "4.1.5",
    -        "@vitest/pretty-format": "4.1.5",
    -        "@vitest/runner": "4.1.5",
    -        "@vitest/snapshot": "4.1.5",
    -        "@vitest/spy": "4.1.5",
    -        "@vitest/utils": "4.1.5",
    +        "@vitest/expect": "4.1.6",
    +        "@vitest/mocker": "4.1.6",
    +        "@vitest/pretty-format": "4.1.6",
    +        "@vitest/runner": "4.1.6",
    +        "@vitest/snapshot": "4.1.6",
    +        "@vitest/spy": "4.1.6",
    +        "@vitest/utils": "4.1.6",
             "es-module-lexer": "^2.0.0",
             "expect-type": "^1.3.0",
             "magic-string": "^0.30.21",
    @@ -4753,12 +4753,12 @@
             "@edge-runtime/vm": "*",
             "@opentelemetry/api": "^1.9.0",
             "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
    -        "@vitest/browser-playwright": "4.1.5",
    -        "@vitest/browser-preview": "4.1.5",
    -        "@vitest/browser-webdriverio": "4.1.5",
    -        "@vitest/coverage-istanbul": "4.1.5",
    -        "@vitest/coverage-v8": "4.1.5",
    -        "@vitest/ui": "4.1.5",
    +        "@vitest/browser-playwright": "4.1.6",
    +        "@vitest/browser-preview": "4.1.6",
    +        "@vitest/browser-webdriverio": "4.1.6",
    +        "@vitest/coverage-istanbul": "4.1.6",
    +        "@vitest/coverage-v8": "4.1.6",
    +        "@vitest/ui": "4.1.6",
             "happy-dom": "*",
             "jsdom": "*",
             "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
    diff --git a/frontend/package.json b/frontend/package.json
    index 1b5e1f0b..e61dfb4b 100644
    --- a/frontend/package.json
    +++ b/frontend/package.json
    @@ -1,7 +1,7 @@
     {
       "name": "nyx-frontend",
       "private": true,
    -  "version": "0.6.1",
    +  "version": "0.7.0",
       "license": "GPL-3.0-or-later",
       "type": "module",
       "scripts": {
    @@ -18,11 +18,11 @@
         "test:coverage": "vitest run --coverage"
       },
       "dependencies": {
    -    "@tanstack/react-query": "^5.100.9",
    +    "@tanstack/react-query": "^5.100.10",
         "elkjs": "^0.11.1",
         "graphology": "^0.26.0",
    -    "react": "^19.2.5",
    -    "react-dom": "^19.2.5",
    +    "react": "^19.2.6",
    +    "react-dom": "^19.2.6",
         "react-router-dom": "^7.15.0",
         "sigma": "^3.0.3"
       },
    @@ -34,7 +34,7 @@
         "@types/react": "^19.2.14",
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^6.0.1",
    -    "@vitest/coverage-v8": "^4.1.5",
    +    "@vitest/coverage-v8": "^4.1.6",
         "eslint": "^10.3.0",
         "eslint-plugin-react-hooks": "^7.1.1",
         "eslint-plugin-react-refresh": "^0.5.2",
    @@ -44,7 +44,7 @@
         "prettier": "^3.8.3",
         "typescript": "~6.0.3",
         "typescript-eslint": "^8.59.2",
    -    "vite": "^8.0.10",
    -    "vitest": "^4.1.5"
    +    "vite": "^8.0.12",
    +    "vitest": "^4.1.6"
       }
     }
    diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock
    index 6a4d62a6..01ed7be2 100644
    --- a/fuzz/Cargo.lock
    +++ b/fuzz/Cargo.lock
    @@ -226,9 +226,9 @@ checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
     
     [[package]]
     name = "cc"
    -version = "1.2.61"
    +version = "1.2.62"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
    +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
     dependencies = [
      "find-msvc-tools",
      "jobserver",
    @@ -652,9 +652,9 @@ dependencies = [
     
     [[package]]
     name = "hashbrown"
    -version = "0.17.0"
    +version = "0.17.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
    +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
     
     [[package]]
     name = "hashlink"
    @@ -810,7 +810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
     dependencies = [
      "equivalent",
    - "hashbrown 0.17.0",
    + "hashbrown 0.17.1",
      "serde",
      "serde_core",
     ]
    @@ -852,9 +852,9 @@ dependencies = [
     
     [[package]]
     name = "js-sys"
    -version = "0.3.97"
    +version = "0.3.98"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
    +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
     dependencies = [
      "cfg-if",
      "futures-util",
    @@ -1023,7 +1023,7 @@ dependencies = [
     
     [[package]]
     name = "nyx-scanner"
    -version = "0.6.1"
    +version = "0.7.0"
     dependencies = [
      "axum",
      "bitflags",
    @@ -1047,6 +1047,7 @@ dependencies = [
      "rayon",
      "rmp-serde",
      "rusqlite",
    + "rustc-hash",
      "serde",
      "serde_json",
      "smallvec",
    @@ -1252,9 +1253,9 @@ dependencies = [
     
     [[package]]
     name = "r2d2_sqlite"
    -version = "0.33.0"
    +version = "0.34.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "5576df16239e4e422c4835c8ed00be806d4491855c7847dba60b7aa8408b469b"
    +checksum = "f9a289c0a3bf56505c470efa2366e76010f1d892e2492a2f96b223386d63b7e2"
     dependencies = [
      "r2d2",
      "rusqlite",
    @@ -1391,6 +1392,12 @@ dependencies = [
      "sqlite-wasm-rs",
     ]
     
    +[[package]]
    +name = "rustc-hash"
    +version = "2.1.2"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
    +
     [[package]]
     name = "rustix"
     version = "1.1.4"
    @@ -1555,9 +1562,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
     
     [[package]]
     name = "siphasher"
    -version = "1.0.2"
    +version = "1.0.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
    +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
     
     [[package]]
     name = "slab"
    @@ -1697,9 +1704,9 @@ dependencies = [
     
     [[package]]
     name = "tokio"
    -version = "1.52.1"
    +version = "1.52.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
    +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
     dependencies = [
      "libc",
      "mio",
    @@ -1803,9 +1810,9 @@ dependencies = [
     
     [[package]]
     name = "tower-http"
    -version = "0.6.8"
    +version = "0.6.10"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
    +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
     dependencies = [
      "async-compression",
      "bitflags",
    @@ -2120,9 +2127,9 @@ dependencies = [
     
     [[package]]
     name = "wasm-bindgen"
    -version = "0.2.120"
    +version = "0.2.121"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
    +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
     dependencies = [
      "cfg-if",
      "once_cell",
    @@ -2133,9 +2140,9 @@ dependencies = [
     
     [[package]]
     name = "wasm-bindgen-macro"
    -version = "0.2.120"
    +version = "0.2.121"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
    +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
     dependencies = [
      "quote",
      "wasm-bindgen-macro-support",
    @@ -2143,9 +2150,9 @@ dependencies = [
     
     [[package]]
     name = "wasm-bindgen-macro-support"
    -version = "0.2.120"
    +version = "0.2.121"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
    +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
     dependencies = [
      "bumpalo",
      "proc-macro2",
    @@ -2156,9 +2163,9 @@ dependencies = [
     
     [[package]]
     name = "wasm-bindgen-shared"
    -version = "0.2.120"
    +version = "0.2.121"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
    +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
     dependencies = [
      "unicode-ident",
     ]
    diff --git a/scripts/validate_recall.sh b/scripts/validate_recall.sh
    new file mode 100755
    index 00000000..10f61e84
    --- /dev/null
    +++ b/scripts/validate_recall.sh
    @@ -0,0 +1,316 @@
    +#!/usr/bin/env bash
    +# validate_recall.sh — run `nyx scan --format json` against a real OSS
    +# checkout and diff the result against a frozen baseline.
    +#
    +# Phase 11 of the JS/TS recall-gap engine plan owns the JS targets.
    +# Phase 17 adds cross-language targets (php/java/python/rust/go/ruby)
    +# under `tests/recall_targets/xlang//.json`. JS-era
    +# baselines stay at `tests/recall_targets/.json` for backwards
    +# compatibility.
    +#
    +# Baseline files were relocated out of `.pitboss/` per the Phase 01
    +# precedent — pitboss implementer agents must not write under
    +# `.pitboss/`.
    +#
    +# Usage:
    +#   scripts/validate_recall.sh   [--capture]
    +#   scripts/validate_recall.sh --lang    [--capture]
    +#   scripts/validate_recall.sh [--lang ]  --from-snapshot 
    +#
    +#           php | java | python | rust | go | ruby
    +#                 Selects the per-language target set under
    +#                 `tests/recall_targets/xlang//`.
    +#         Without --lang: cal_com | vercel_commerce |
    +#                 shadcn_examples | blitz_apps (Phase 11 JS targets).
    +#                 With --lang: any baseline shipped under
    +#                 `tests/recall_targets/xlang//.json`.
    +#     path to a local clone of the OSS repo (omitted when
    +#                 --from-snapshot is supplied).
    +#   --capture     overwrite the baseline with the current scan output
    +#                 (every finding marked `needs_review`); use this when
    +#                 the baseline file is a placeholder or when intentional
    +#                 recall lift is being frozen.
    +#   --from-snapshot 
    +#                 skip the scan; load `.findings` (a previously
    +#                 captured baseline JSON) as the current finding set
    +#                 and diff it against ``'s baseline. Mutually
    +#                 exclusive with --capture.
    +#
    +# Default mode (no --capture, no --from-snapshot) loads the baseline,
    +# re-scans the clone, and prints `{ added, removed, unchanged }`
    +# finding counts per rule_id. Findings are matched on the tuple
    +# `(rule_id, path_suffix, line)`; `path_suffix` is the clone-relative
    +# path so the diff is robust against absolute-path differences.
    +#
    +# Dependencies: bash, jq. Nothing else.
    +
    +set -euo pipefail
    +
    +usage() {
    +    cat >&2 <  [--capture]
    +       $(basename "$0") --lang    [--capture]
    +       $(basename "$0") [--lang ]  --from-snapshot 
    +
    +  lang             php | java | python | rust | go | ruby
    +  target           JS targets: cal_com | vercel_commerce | shadcn_examples |
    +                   blitz_apps. With --lang: any name shipped under
    +                   tests/recall_targets/xlang//.
    +  clone_path       path to local checkout of the target repo (omitted with
    +                   --from-snapshot)
    +  --capture        overwrite the baseline JSON with the current scan output
    +  --from-snapshot  diff a previously captured baseline JSON against 
    +                   without rescanning; mutually exclusive with --capture.
    +EOF
    +    exit 2
    +}
    +
    +LANG_FLAG=""
    +POSITIONAL=()
    +CAPTURE=0
    +SNAPSHOT=""
    +while [ $# -gt 0 ]; do
    +    case "$1" in
    +        --lang)
    +            if [ $# -lt 2 ]; then
    +                echo "--lang requires an argument" >&2
    +                usage
    +            fi
    +            LANG_FLAG="$2"
    +            shift 2
    +            ;;
    +        --lang=*)
    +            LANG_FLAG="${1#--lang=}"
    +            shift
    +            ;;
    +        --capture)
    +            CAPTURE=1
    +            shift
    +            ;;
    +        --from-snapshot)
    +            if [ $# -lt 2 ]; then
    +                echo "--from-snapshot requires a path argument" >&2
    +                usage
    +            fi
    +            SNAPSHOT="$2"
    +            shift 2
    +            ;;
    +        --from-snapshot=*)
    +            SNAPSHOT="${1#--from-snapshot=}"
    +            shift
    +            ;;
    +        -h|--help)
    +            usage
    +            ;;
    +        --*)
    +            echo "unknown flag: $1" >&2
    +            usage
    +            ;;
    +        *)
    +            POSITIONAL+=("$1")
    +            shift
    +            ;;
    +    esac
    +done
    +
    +if [ "$CAPTURE" -eq 1 ] && [ -n "$SNAPSHOT" ]; then
    +    echo "--capture and --from-snapshot are mutually exclusive" >&2
    +    usage
    +fi
    +
    +if [ -n "$SNAPSHOT" ]; then
    +    if [ ${#POSITIONAL[@]} -lt 1 ]; then
    +        usage
    +    fi
    +    TARGET="${POSITIONAL[0]}"
    +    CLONE_PATH=""
    +else
    +    if [ ${#POSITIONAL[@]} -lt 2 ]; then
    +        usage
    +    fi
    +    TARGET="${POSITIONAL[0]}"
    +    CLONE_PATH="${POSITIONAL[1]}"
    +fi
    +
    +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
    +
    +if [ -n "$LANG_FLAG" ]; then
    +    XLANG_DIR="$REPO_ROOT/tests/recall_targets/xlang/${LANG_FLAG}"
    +    if [ ! -d "$XLANG_DIR" ]; then
    +        AVAILABLE="$(find "$REPO_ROOT/tests/recall_targets/xlang" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2>/dev/null | sort | paste -sd ' ' -)"
    +        echo "unknown lang: $LANG_FLAG (available: ${AVAILABLE:-none})" >&2
    +        usage
    +    fi
    +    BASELINE="$XLANG_DIR/${TARGET}.json"
    +else
    +    case "$TARGET" in
    +        cal_com|vercel_commerce|shadcn_examples|blitz_apps) ;;
    +        *) echo "unknown target: $TARGET (use --lang for cross-lang targets)" >&2; usage ;;
    +    esac
    +    BASELINE="$REPO_ROOT/tests/recall_targets/${TARGET}.json"
    +fi
    +
    +if [ -n "$SNAPSHOT" ]; then
    +    if [ ! -f "$SNAPSHOT" ]; then
    +        echo "snapshot file not found: $SNAPSHOT" >&2
    +        exit 1
    +    fi
    +else
    +    if [ ! -d "$CLONE_PATH" ]; then
    +        echo "clone path is not a directory: $CLONE_PATH" >&2
    +        exit 1
    +    fi
    +fi
    +
    +if ! command -v jq >/dev/null 2>&1; then
    +    echo "jq is required but not installed" >&2
    +    exit 1
    +fi
    +
    +if [ ! -f "$BASELINE" ]; then
    +    echo "baseline not found: $BASELINE" >&2
    +    exit 1
    +fi
    +
    +# Locate the nyx binary — prefer a release build, fall back to debug.
    +# Skipped under --from-snapshot since no scan is performed.
    +if [ -z "$SNAPSHOT" ]; then
    +    if [ -n "${NYX_BIN:-}" ] && [ -x "$NYX_BIN" ]; then
    +        NYX="$NYX_BIN"
    +    elif [ -x "$REPO_ROOT/target/release/nyx" ]; then
    +        NYX="$REPO_ROOT/target/release/nyx"
    +    elif [ -x "$REPO_ROOT/target/debug/nyx" ]; then
    +        NYX="$REPO_ROOT/target/debug/nyx"
    +    elif command -v nyx >/dev/null 2>&1; then
    +        NYX="$(command -v nyx)"
    +    else
    +        echo "nyx binary not found; build with 'cargo build --release' first" >&2
    +        exit 1
    +    fi
    +fi
    +
    +if [ -n "$SNAPSHOT" ]; then
    +    CLONE_ABS=""
    +    if [ -n "$LANG_FLAG" ]; then
    +        echo "[validate_recall] lang=$LANG_FLAG target=$TARGET snapshot=$SNAPSHOT" >&2
    +    else
    +        echo "[validate_recall] target=$TARGET snapshot=$SNAPSHOT" >&2
    +    fi
    +    echo "[validate_recall] baseline=$BASELINE (no scan; --from-snapshot)" >&2
    +
    +    # Snapshot mode: load the previously captured baseline JSON's
    +    # `findings` array verbatim. Both snapshot and baseline are stored
    +    # in the same diff-tuple shape (`rule_id` / `path_suffix` / `line`)
    +    # so no path normalization is needed.
    +    CURRENT="$(jq '.findings // [] | [ .[] | {
    +        rule_id: (.rule_id // ""),
    +        path_suffix: (.path_suffix // ""),
    +        line: (.line // 0),
    +        severity: (.severity // "Unknown")
    +    } ]' "$SNAPSHOT")"
    +else
    +    CLONE_ABS="$(cd "$CLONE_PATH" && pwd)"
    +    TMP_OUT="$(mktemp -t nyx_recall_${TARGET}.XXXXXX.json)"
    +    trap 'rm -f "$TMP_OUT"' EXIT
    +
    +    if [ -n "$LANG_FLAG" ]; then
    +        echo "[validate_recall] lang=$LANG_FLAG target=$TARGET clone=$CLONE_ABS" >&2
    +    else
    +        echo "[validate_recall] target=$TARGET clone=$CLONE_ABS" >&2
    +    fi
    +    echo "[validate_recall] nyx=$NYX baseline=$BASELINE" >&2
    +    echo "[validate_recall] scanning..." >&2
    +
    +    "$NYX" scan "$CLONE_ABS" --format json --index off >"$TMP_OUT"
    +
    +    # Strip the clone-absolute prefix off each finding's path so the diff
    +    # tuple `(rule_id, path_suffix, line)` is portable across machines.
    +    # Also drop the trailing ` (source N:M)` suffix on `id` so taint
    +    # findings group under their canonical rule_id.
    +    CURRENT="$(jq --arg root "$CLONE_ABS/" '
    +        [ .[] | {
    +            rule_id: ((.id // "") | sub(" \\(source [^)]*\\)$"; "")),
    +            path_suffix: ((.path // "") | ltrimstr($root)),
    +            line: (.line // 0),
    +            severity: (.severity // "Unknown")
    +        } ]
    +    ' "$TMP_OUT")"
    +fi
    +
    +if [ "$CAPTURE" -eq 1 ]; then
    +    PIN="$(cd "$CLONE_ABS" && git log -1 --format=%H 2>/dev/null || echo "unknown")"
    +    # Preserve any verdict / note labels from the prior baseline whose
    +    # (rule_id, path_suffix, line) tuple still appears in the current
    +    # scan. New findings get the placeholder verdict; vanished findings
    +    # are dropped.
    +    PRIOR_FINDINGS="$(jq '.findings // []' "$BASELINE")"
    +    UPDATED="$(jq --argjson findings "$CURRENT" \
    +                  --argjson prior "$PRIOR_FINDINGS" \
    +                  --arg pin "$PIN" \
    +                  --arg captured_on "$(date -u +%Y-%m-%d)" \
    +                  '
    +                  def key(f): [f.rule_id, f.path_suffix, f.line];
    +                  def prior_idx:
    +                      reduce $prior[] as $f ({}; .[(key($f) | tojson)] = $f);
    +                  prior_idx as $pidx
    +                  | . + {
    +                        captured_against: ("real-scan @ " + $pin),
    +                        captured_on: $captured_on,
    +                        pinned_commit: $pin,
    +                        findings: ($findings | map(
    +                            . as $f
    +                            | ($pidx[(key($f) | tojson)] // null) as $prev
    +                            | if $prev != null and ($prev.verdict // "needs_review") != "needs_review"
    +                              then . + {
    +                                  verdict: $prev.verdict,
    +                                  note: ($prev.note // "carried from prior baseline")
    +                              }
    +                              else . + {
    +                                  verdict: "needs_review",
    +                                  note: "captured by validate_recall.sh --capture"
    +                              }
    +                              end
    +                        ))
    +                    }
    +                  ' "$BASELINE")"
    +    printf '%s\n' "$UPDATED" >"$BASELINE"
    +    KEPT_LABELS="$(echo "$UPDATED" | jq '[.findings[] | select((.verdict // "needs_review") != "needs_review")] | length')"
    +    echo "[validate_recall] wrote $(echo "$CURRENT" | jq 'length') findings to $BASELINE (preserved $KEPT_LABELS prior verdicts)" >&2
    +    exit 0
    +fi
    +
    +# Diff mode: compare current scan to baseline.
    +BASELINE_FINDINGS="$(jq '.findings // []' "$BASELINE")"
    +
    +DIFF_REPORT="$(jq -n \
    +    --argjson cur "$CURRENT" \
    +    --argjson base "$BASELINE_FINDINGS" '
    +    def key(f): [f.rule_id, f.path_suffix, f.line];
    +
    +    def index_set(arr):
    +        reduce arr[] as $f ({}; .[(key($f) | tojson)] = $f);
    +
    +    (index_set($cur))   as $cidx
    +    | (index_set($base)) as $bidx
    +    | ($cidx | keys_unsorted) as $ckeys
    +    | ($bidx | keys_unsorted) as $bkeys
    +    | ($ckeys - $bkeys) as $added_keys
    +    | ($bkeys - $ckeys) as $removed_keys
    +    | ($ckeys - $added_keys) as $unchanged_keys
    +    | def by_rule(keys; idx):
    +        keys
    +        | map(idx[.])
    +        | group_by(.rule_id)
    +        | map({(.[0].rule_id): length}) | add // {};
    +
    +    {
    +        added:     by_rule($added_keys; $cidx),
    +        removed:   by_rule($removed_keys; $bidx),
    +        unchanged: by_rule($unchanged_keys; $cidx),
    +        added_total:     ($added_keys | length),
    +        removed_total:   ($removed_keys | length),
    +        unchanged_total: ($unchanged_keys | length)
    +    }
    +')"
    +
    +printf '%s\n' "$DIFF_REPORT"
    diff --git a/src/abstract_interp/path_domain.rs b/src/abstract_interp/path_domain.rs
    index 8ab78ff5..5981b9d5 100644
    --- a/src/abstract_interp/path_domain.rs
    +++ b/src/abstract_interp/path_domain.rs
    @@ -1368,11 +1368,15 @@ fn truncate_prefix_lock(s: &str) -> String {
         }
     }
     
    +/// Longest common prefix, char-aligned so multi-byte UTF-8 sequences are
    +/// kept whole. The earlier byte-iteration form re-encoded continuation
    +/// bytes as Latin-1 chars and produced mojibake; the same fix lives at
    +/// `crate::abstract_interp::string_domain::longest_common_prefix`.
     fn longest_common_prefix(a: &str, b: &str) -> String {
    -    a.bytes()
    -        .zip(b.bytes())
    +    a.chars()
    +        .zip(b.chars())
             .take_while(|(x, y)| x == y)
    -        .map(|(x, _)| x as char)
    +        .map(|(x, _)| x)
             .collect()
     }
     
    @@ -1380,6 +1384,24 @@ fn longest_common_prefix(a: &str, b: &str) -> String {
     mod tests {
         use super::*;
     
    +    // ── LCP helper ──────────────────────────────────────────────────────
    +
    +    #[test]
    +    fn lcp_basic() {
    +        assert_eq!(longest_common_prefix("abcdef", "abcxyz"), "abc");
    +        assert_eq!(longest_common_prefix("abc", "abc"), "abc");
    +        assert_eq!(longest_common_prefix("", "abc"), "");
    +    }
    +
    +    #[test]
    +    fn lcp_keeps_utf8_codepoints_whole() {
    +        // Without char-alignment, byte iteration would emit the
    +        // continuation byte 0xA9 as a separate char and corrupt the
    +        // prefix.  Both the 2-byte and 3-byte UTF-8 cases must survive.
    +        assert_eq!(longest_common_prefix("héllo", "héllo!"), "héllo");
    +        assert_eq!(longest_common_prefix("名前.json", "名前.txt"), "名前.");
    +    }
    +
         // ── Tri lattice laws ────────────────────────────────────────────────
     
         #[test]
    diff --git a/src/abstract_interp/string_domain.rs b/src/abstract_interp/string_domain.rs
    index 3220da0a..4a804763 100644
    --- a/src/abstract_interp/string_domain.rs
    +++ b/src/abstract_interp/string_domain.rs
    @@ -350,6 +350,25 @@ impl StringFact {
                 is_bottom: false,
             }
         }
    +
    +    /// SSRF helper: build a fact for `new URL(path, base)` where `base` is a
    +    /// literal origin (`https://api.example.com`).  The result behaves as
    +    /// `base ++ path`, the locked-host prefix survives even when the path
    +    /// component carries arbitrary taint, and the fact's `prefix` is what
    +    /// `is_string_safe_for_ssrf` consults to suppress the SSRF sink.
    +    ///
    +    /// `path` carries any string knowledge for the path component (typically
    +    /// `StringFact::top()`).  When the base already ends in `/`, the helper
    +    /// keeps it as-is; otherwise appends a `/` so the prefix unambiguously
    +    /// includes the path separator (the SSRF check looks for
    +    /// `scheme://host/`).
    +    pub fn from_url_with_base(base: &str, path: &Self) -> Self {
    +        let mut anchor = base.to_string();
    +        if !anchor.ends_with('/') {
    +            anchor.push('/');
    +        }
    +        StringFact::exact(&anchor).concat(path)
    +    }
     }
     
     impl Lattice for StringFact {
    @@ -943,6 +962,40 @@ mod tests {
             assert!(suffix.ends_with('好'));
         }
     
    +    /// Phase 08: a URL prefix-lock obtained from `new URL(path, base)`
    +    /// must survive concatenation with a tainted (Top-suffix) path
    +    /// component. The `is_string_safe_for_ssrf` check only consults the
    +    /// `prefix`, so the locked-host base must remain intact even when the
    +    /// path-side fact carries no knowledge.
    +    #[test]
    +    fn from_url_with_base_locks_prefix_under_tainted_suffix() {
    +        let base = "https://api.cal.com";
    +        let tainted_path = StringFact::top();
    +        let f = StringFact::from_url_with_base(base, &tainted_path);
    +        assert_eq!(
    +            f.prefix.as_deref(),
    +            Some("https://api.cal.com/"),
    +            "prefix lock must include the path separator"
    +        );
    +        // The path component contributes no suffix knowledge, the result
    +        // must mirror that without losing the prefix lock.
    +        assert!(
    +            f.suffix.is_none(),
    +            "suffix is unknown when path-side fact is Top"
    +        );
    +    }
    +
    +    /// A concrete path component contributes its suffix knowledge to the
    +    /// concatenated URL fact while the base prefix stays locked.
    +    #[test]
    +    fn from_url_with_base_keeps_prefix_with_concrete_path_suffix() {
    +        let base = "https://api.cal.com/";
    +        let path = StringFact::from_suffix(".json");
    +        let f = StringFact::from_url_with_base(base, &path);
    +        assert_eq!(f.prefix.as_deref(), Some("https://api.cal.com/"));
    +        assert_eq!(f.suffix.as_deref(), Some(".json"));
    +    }
    +
         /// Concat with empty-string `exact("")` should preserve the other
         /// side's prefix/suffix knowledge (empty is the identity).
         #[test]
    diff --git a/src/ast.rs b/src/ast.rs
    index 26b4130f..0480ec2b 100644
    --- a/src/ast.rs
    +++ b/src/ast.rs
    @@ -30,11 +30,11 @@ use crate::evidence::{Evidence, FlowStep, SpanEvidence, StateEvidence};
     use crate::labels::{
         Cap, DataLabel, LangAnalysisRules, build_lang_rules, severity_for_source_kind,
     };
    -use crate::patterns::{FindingCategory, Severity};
    +use crate::patterns::{FindingCategory, PatternCategory, Severity};
     use crate::state;
     use crate::summary::ssa_summary::SsaFuncSummary;
     use crate::summary::{FuncSummary, GlobalSummaries};
    -use crate::symbol::{Lang, normalize_namespace};
    +use crate::symbol::Lang;
     use crate::utils::config::AnalysisMode;
     use crate::utils::ext::lowercase_ext;
     use crate::utils::{Config, query_cache};
    @@ -370,22 +370,30 @@ fn build_taint_diag(
             });
         }
     
    -    let sink_evidence_snippet = primary_snippet_hint
    -        .clone()
    -        .or_else(|| Some(short_call_site.clone()));
    +    let sink_evidence_snippet = primary_snippet_hint.or(Some(short_call_site));
     
         // Resolved sink capability bits, used by deduplication to distinguish
         // sinks with different cap types on the same source line (e.g.
         // `sink_sql(x); sink_shell(x);`).
    -    let sink_caps_bits: u32 = cfg_graph[finding.sink]
    -        .taint
    -        .labels
    -        .iter()
    -        .filter_map(|l| match l {
    -            crate::labels::DataLabel::Sink(c) => Some(c.bits()),
    -            _ => None,
    -        })
    -        .fold(0u32, |acc, b| acc | b);
    +    //
    +    // Prefer the per-finding `effective_sink_caps` (set by the SSA dispatch
    +    // when receiver-type qualification, gated rules, or other late-binding
    +    // resolvers contribute caps that the CFG node's static labels do not
    +    // carry).  Fall back to the union of `Sink(cap)` labels on the CFG
    +    // node when the SSA dispatch did not narrow.
    +    let sink_caps_bits: u32 = if !finding.effective_sink_caps.is_empty() {
    +        finding.effective_sink_caps.bits()
    +    } else {
    +        cfg_graph[finding.sink]
    +            .taint
    +            .labels
    +            .iter()
    +            .filter_map(|l| match l {
    +                crate::labels::DataLabel::Sink(c) => Some(c.bits()),
    +                _ => None,
    +            })
    +            .fold(0u32, |acc, b| acc | b)
    +    };
     
         // Cap-specific rule-id routing.
         //
    @@ -652,11 +660,11 @@ fn build_taint_diag(
             confidence: None,
             evidence: Some(Evidence {
                 source: Some(SpanEvidence {
    -                path: file_path_owned.clone(),
    +                path: file_path_owned,
                     line: (source_point.row + 1) as u32,
                     col: (source_point.column + 1) as u32,
                     kind: "source".into(),
    -                snippet: Some(short_source.clone()),
    +                snippet: Some(short_source),
                 }),
                 sink: Some(SpanEvidence {
                     path: primary_path.clone(),
    @@ -721,6 +729,27 @@ pub fn lang_slug_for_path(path: &Path) -> Option<&'static str> {
     
     /// Resolve a file extension to a (tree‑sitter Language, slug) pair.
     fn lang_for_path(path: &Path) -> Option<(Language, &'static str)> {
    +    // Distinguish `.tsx` from `.ts` before normalising via `lowercase_ext` —
    +    // the latter merges both into the `"ts"` slug, which would lose the
    +    // information needed to pick the JSX-aware TSX grammar.  The slug returned
    +    // here stays `"typescript"` for both so all downstream KINDS / RULES /
    +    // PARAM_CONFIG entries apply uniformly.
    +    let raw_ext = path
    +        .extension()
    +        .and_then(|s| s.to_str())
    +        .map(|s| s.to_ascii_lowercase());
    +    if matches!(raw_ext.as_deref(), Some("tsx")) {
    +        return Some((
    +            Language::from(tree_sitter_typescript::LANGUAGE_TSX),
    +            "typescript",
    +        ));
    +    }
    +    if matches!(raw_ext.as_deref(), Some("jsx")) {
    +        return Some((
    +            Language::from(tree_sitter_javascript::LANGUAGE),
    +            "javascript",
    +        ));
    +    }
         match lowercase_ext(path) {
             Some("rs") => Some((Language::from(tree_sitter_rust::LANGUAGE), "rust")),
             Some("c") => Some((Language::from(tree_sitter_c::LANGUAGE), "c")),
    @@ -741,24 +770,10 @@ fn lang_for_path(path: &Path) -> Option<(Language, &'static str)> {
                 Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT),
                 "typescript",
             )),
    -        // TSX grammar is a superset of TypeScript plus JSX element/attribute
    -        // nodes, all TypeScript KINDS / RULES / PARAM_CONFIG entries apply,
    -        // and JSX-specific sinks (e.g. `dangerouslySetInnerHTML`) layer on top
    -        // via the same `typescript` slug.
    -        Some("tsx") => Some((
    -            Language::from(tree_sitter_typescript::LANGUAGE_TSX),
    -            "typescript",
    -        )),
             Some("js") => Some((
                 Language::from(tree_sitter_javascript::LANGUAGE),
                 "javascript",
             )),
    -        // JSX uses the same JavaScript grammar (tree-sitter-javascript handles
    -        // JSX natively), slug "javascript" so all JS rules apply.
    -        Some("jsx") => Some((
    -            Language::from(tree_sitter_javascript::LANGUAGE),
    -            "javascript",
    -        )),
             Some("rb") => Some((Language::from(tree_sitter_ruby::LANGUAGE), "ruby")),
             _ => None,
         }
    @@ -770,20 +785,71 @@ fn is_binary(bytes: &[u8]) -> bool {
     }
     
     /// Check if a file path indicates a test file. Matches filename-based
    -/// conventions (`.test.js`, `.spec.ts`) and the `__tests__` directory
    -/// convention.  Directory-only checks (`test/`, `tests/`, `fixtures/`)
    -/// are intentionally excluded because they're too broad when scanning
    -/// absolute paths.
    -fn is_test_file(path: &Path) -> bool {
    +/// conventions across the languages the engine supports, plus the
    +/// `__tests__` directory convention used by JS/TS tooling.
    +///
    +/// Directory-only checks (`test/`, `tests/`, `fixtures/`) are
    +/// intentionally excluded because they are too broad when scanning
    +/// absolute paths.  Severity-downgrade for those directories lives in
    +/// [`is_nonprod_path`].
    +pub(crate) fn is_test_file(path: &Path) -> bool {
    +    // Filename-suffix conventions that are unambiguous markers of a test
    +    // module.  Each entry must end with a `.` suffix so PHP
    +    // `*Test.php` does not match a class file named `MyContestTest.php`
    +    // — the engine's recogniser matches on the filename, not class
    +    // declarations.
         static TEST_SUFFIXES: &[&str] = &[
    +        // JS / TS
             ".test.js",
             ".test.ts",
             ".test.jsx",
             ".test.tsx",
    +        ".test.mjs",
    +        ".test.cjs",
             ".spec.js",
             ".spec.ts",
             ".spec.jsx",
             ".spec.tsx",
    +        ".spec.mjs",
    +        ".spec.cjs",
    +        // Python (`pytest` and `unittest` conventions)
    +        "_test.py",
    +        "_tests.py",
    +        // Java (JUnit / TestNG)
    +        "Test.java",
    +        "Tests.java",
    +        "IT.java",
    +        // PHP (PHPUnit)
    +        "Test.php",
    +        // Ruby (RSpec / Minitest)
    +        "_spec.rb",
    +        "_test.rb",
    +        // Go
    +        "_test.go",
    +        // Rust (uncommon but used by some crates)
    +        "_test.rs",
    +        "_tests.rs",
    +        // C / C++ (varies; cover the common shapes)
    +        "_test.c",
    +        "_test.cc",
    +        "_test.cpp",
    +        "_test.cxx",
    +        "_test.h",
    +        "_test.hpp",
    +    ];
    +
    +    // Filename-prefix conventions for languages whose convention puts
    +    // the `test_` marker at the start instead of the end.
    +    static TEST_PREFIXES: &[&str] = &[
    +        // Python (`pytest`)
    +        "test_",
    +        // C / C++ test runners
    +    ];
    +
    +    // Exact filenames that are always test infrastructure.
    +    static TEST_EXACT: &[&str] = &[
    +        // Pytest fixture entry point (always a test helper, never prod)
    +        "conftest.py",
         ];
     
         if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
    @@ -792,9 +858,28 @@ fn is_test_file(path: &Path) -> bool {
                     return true;
                 }
             }
    +        for prefix in TEST_PREFIXES {
    +            if name.starts_with(prefix)
    +                && (name.ends_with(".py")
    +                    || name.ends_with(".c")
    +                    || name.ends_with(".cc")
    +                    || name.ends_with(".cpp")
    +                    || name.ends_with(".cxx"))
    +            {
    +                return true;
    +            }
    +        }
    +        if TEST_EXACT.contains(&name) {
    +            return true;
    +        }
         }
     
    -    // __tests__ is specific enough (React/Jest convention) to match on directory
    +    // `__tests__` is specific enough (React/Jest convention) to match on
    +    // directory.  Other test directories (`tests/`, `test/`, `spec/`)
    +    // overlap with production paths in some real codebases (e.g.
    +    // django apps that ship a `tests` submodule alongside production
    +    // code under the same package), so the broad directory check stays
    +    // in [`is_nonprod_path`] for severity downgrade only.
         for component in path.components() {
             if let std::path::Component::Normal(c) = component
                 && c == "__tests__"
    @@ -806,12 +891,86 @@ fn is_test_file(path: &Path) -> bool {
         false
     }
     
    +/// Detect bundled or minified third-party assets that the engine should not
    +/// analyse.  These files are produced by build tooling, ship verbatim from
    +/// upstream packages, and can never be remediated by the codebase author, so
    +/// any finding raised against them is signal-less noise.
    +///
    +/// Triggers (any one is sufficient):
    +///   * Filename ends in `.min.js`, `.min.css`, `.bundle.js`, `.umd.js`,
    +///     `.umd.min.js`, `.iife.js`, `.iife.min.js`, or `.bundled.js`.
    +///   * Path component `bower_components` (legacy front-end package dir).
    +///   * Path component `vendor` AND filename has a front-end asset extension
    +///     (`.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.tsx`, `.css`).  Restricted to
    +///     web assets so Go module vendoring (`vendor//*.go`) is not
    +///     suppressed.
    +///
    +/// The check is conservative: it skips files only when the evidence is
    +/// unambiguous.  Hand-authored vendored plugins that lack a `.min` suffix and
    +/// live outside `vendor/` (e.g. `webapp/.../scripts/jquery-ui-plugin.js`) are
    +/// still parsed; their findings flow through `is_nonprod_path` for severity
    +/// downgrade instead.
    +pub(crate) fn is_vendored_asset_path(path: &Path) -> bool {
    +    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
    +        let lower: String = name.to_ascii_lowercase();
    +        const SUFFIXES: &[&str] = &[
    +            ".min.js",
    +            ".min.css",
    +            ".bundle.js",
    +            ".bundled.js",
    +            ".umd.js",
    +            ".umd.min.js",
    +            ".iife.js",
    +            ".iife.min.js",
    +        ];
    +        if SUFFIXES.iter().any(|s| lower.ends_with(s)) {
    +            return true;
    +        }
    +    }
    +
    +    let mut has_vendor_component = false;
    +    for component in path.components() {
    +        if let std::path::Component::Normal(c) = component
    +            && let Some(s) = c.to_str()
    +        {
    +            if s.eq_ignore_ascii_case("bower_components") {
    +                return true;
    +            }
    +            if s.eq_ignore_ascii_case("vendor") || s.eq_ignore_ascii_case("vendors") {
    +                has_vendor_component = true;
    +            }
    +        }
    +    }
    +
    +    if has_vendor_component && let Some(ext) = path.extension().and_then(|e| e.to_str()) {
    +        let ext_lower: String = ext.to_ascii_lowercase();
    +        const FRONT_END_EXTS: &[&str] = &[
    +            "js", "mjs", "cjs", "jsx", "ts", "tsx", "css", "scss", "less",
    +        ];
    +        if FRONT_END_EXTS.iter().any(|e| *e == ext_lower) {
    +            return true;
    +        }
    +    }
    +
    +    false
    +}
    +
     /// Pattern IDs that are noise-prone in test files (fixture credentials,
     /// non-crypto randomness, plain HTTP in test harnesses).
     fn is_test_suppressible_pattern(id: &str) -> bool {
    -    // Suffix-match to handle both js. and ts. prefixes
    +    // Suffix-match so a single rule covers the per-language prefixes
    +    // (`js.`, `ts.`, `go.`, `php.`, `py.`, `rb.`, `java.`).  Each entry
    +    // is a class of finding that is informational at best in a test
    +    // module: hardcoded test API tokens, weak hashes used for fast
    +    // deterministic test data, insecure RNG used for fixture seeding.
         id.ends_with(".secrets.hardcoded_secret")
    +        || id.ends_with(".secrets.hardcoded_key")
             || id.ends_with(".crypto.math_random")
    +        || id.ends_with(".crypto.insecure_random")
    +        || id.ends_with(".crypto.weak_digest")
    +        || id.ends_with(".crypto.md5")
    +        || id.ends_with(".crypto.sha1")
    +        || id.ends_with(".crypto.rand")
             || id.ends_with(".transport.fetch_http")
     }
     
    @@ -908,6 +1067,9 @@ impl<'a> ParsedSource<'a> {
             // this thread that the caller did not consume.  Ensures the slot
             // always reflects "this parse" by the time we return.
             LAST_PARSE_TIMEOUT_MS.with(|c| c.set(None));
    +        if is_vendored_asset_path(path) {
    +            return Ok(None);
    +        }
             if is_binary(bytes) {
                 return Ok(None);
             }
    @@ -980,9 +1142,24 @@ impl<'a> ParsedSource<'a> {
                 let mut matches = cursor.matches(&cq.query, root, self.bytes);
                 while let Some(m) = matches.next() {
                     if let Some(cap) = m.captures.iter().find(|c| c.index == 0) {
    -                    // Layer A: suppress Security findings on calls with all-literal args
    +                    // Layer A: suppress Security findings on calls with all-literal args.
    +                    //
    +                    // Carve-outs for categories where the literal argument IS
    +                    // the bug (algorithm choice, hardcoded secret, insecure
    +                    // protocol scheme, unsafe config flag): suppression would
    +                    // silence the actual signal.  Hash algorithms picked from
    +                    // string literals (`MessageDigest.getInstance("MD5")`,
    +                    // `hashlib.md5(b"…")`) are weak regardless of caller-side
    +                    // data flow.
                         if cq.meta.category.finding_category() == FindingCategory::Security
    -                        && is_call_all_args_literal(cap.node, self.bytes)
    +                        && !matches!(
    +                            cq.meta.category,
    +                            PatternCategory::Crypto
    +                                | PatternCategory::Secrets
    +                                | PatternCategory::InsecureConfig
    +                                | PatternCategory::InsecureTransport
    +                        )
    +                        && is_call_all_args_literal(cap.node, self.bytes, self.lang_slug)
                         {
                             continue;
                         }
    @@ -1037,6 +1214,61 @@ impl<'a> ParsedSource<'a> {
                         {
                             continue;
                         }
    +                    // Layer C3: PHP `unserialize($x)` inside a PHPUnit
    +                    // assertion of the form
    +                    // `$this->assertSame(LITERAL, unserialize($x))`
    +                    // (or `assertEquals` / `assertNull` / static / self
    +                    // / parent dispatch variants).  The literal expected
    +                    // value bounds the unserialize result so the
    +                    // call-site cannot release attacker-controlled
    +                    // object graphs into the test process — failed
    +                    // assertions abort the test rather than leak side
    +                    // effects.  Drupal / Joomla / Nextcloud each carry
    +                    // tens of these `Serializable` round-trip
    +                    // assertions in their test trees and every firing
    +                    // is noise.
    +                    if cq.meta.id == "php.deser.unserialize"
    +                        && self.lang_slug == "php"
    +                        && is_php_unserialize_inside_phpunit_assertion(cap.node, self.bytes)
    +                    {
    +                        continue;
    +                    }
    +                    // Layer C4: Python `pickle.loads` / `yaml.load` /
    +                    // `shelve.open` / kindred deserialization sinks
    +                    // wrapped in a `unittest.TestCase` assertion whose
    +                    // other argument is a literal expected value (or
    +                    // whose verb itself constrains the result, e.g.
    +                    // `assertIsNone(pickle.loads(blob))`).  The
    +                    // assertion bounds the deser result so attacker-
    +                    // controlled blobs would fail loudly rather than
    +                    // leak side effects out of the test boundary.
    +                    // Mirrors the PHP Layer C3 recogniser; deferred
    +                    // note in `project_realrepo_*.md` flagged the same
    +                    // FP shape on Python test trees.
    +                    if matches!(
    +                        cq.meta.id,
    +                        "py.deser.pickle_loads" | "py.deser.yaml_load" | "py.deser.shelve_open"
    +                    ) && self.lang_slug == "python"
    +                        && is_python_deser_inside_unittest_assertion(cap.node, self.bytes)
    +                    {
    +                        continue;
    +                    }
    +                    // Layer C5: Ruby `Marshal.load` / `YAML.load` /
    +                    // `Psych.load` wrapped in a Minitest assertion
    +                    // (`assert_equal LIT, deser`, `assert_nil deser`,
    +                    // `assert deser`, `refute_equal LIT, deser`, ...) or
    +                    // an RSpec matcher chain (`expect(deser).to eq(LIT)`,
    +                    // `expect(deser).to be_nil`, `be_a(TYPE)`, ...).
    +                    // Same bounding semantics as the PHP / Python paths:
    +                    // a poisoned blob fails the assertion loudly rather
    +                    // than leak object-injection side effects out of
    +                    // the test boundary.
    +                    if matches!(cq.meta.id, "rb.deser.marshal_load" | "rb.deser.yaml_load")
    +                        && self.lang_slug == "ruby"
    +                        && is_ruby_deser_inside_test_assertion(cap.node, self.bytes)
    +                    {
    +                        continue;
    +                    }
                         // Layer D: C/C++ buffer-overflow pattern rules
                         // (`{c,cpp}.memory.strcpy`, `strcat`, `sprintf`) fire
                         // syntactically on every call regardless of argument
    @@ -1213,13 +1445,35 @@ impl<'a> ParsedFile<'a> {
             } else {
                 None
             };
    -        let file_cfg = build_cfg(
    +        let mut file_cfg = build_cfg(
                 &source.tree,
                 source.bytes,
                 source.lang_slug,
                 &source.file_path_str,
                 rules_ref,
             );
    +
    +        // Phase 04: when the scan paths produced a project ModuleGraph,
    +        // resolve this file's imports against it and stash both on the
    +        // FileCfg (for local consumers) and on the global per-file
    +        // ImportTable (for cross-file lookups in phases 05/09/10). The
    +        // wiring is no-op for non-JS/TS files and for direct callers of
    +        // `analyse_file_fused` that pass a `Config` without a resolver
    +        // (e.g. unit tests).
    +        if let Some(graph) = cfg.module_graph.as_deref() {
    +            let bindings = crate::resolve::extract_resolved_imports(
    +                &source.tree,
    +                source.bytes,
    +                source.path,
    +                graph,
    +                source.lang_slug,
    +            );
    +            if !bindings.is_empty() {
    +                graph.record_imports_for_file(source.path.to_path_buf(), bindings.clone());
    +                file_cfg.resolved_imports = bindings;
    +            }
    +        }
    +
             Self {
                 source,
                 file_cfg,
    @@ -1300,6 +1554,34 @@ impl<'a> ParsedFile<'a> {
                 }
             }
     
    +        // Phase 10 — annotate entry-point summaries.  Match each
    +        // summary's body span (looked up via `FuncSummaries` keyed on
    +        // `FuncKey`) against the per-file `entry_kinds` table so the
    +        // tag survives SQLite round-trips and cross-file consumption.
    +        if !self.file_cfg.entry_kinds.is_empty() {
    +            // Build a (name, container, disambig) → span lookup from
    +            // the file's bodies so we can associate each exported
    +            // FuncSummary with its body span.
    +            let mut by_identity: std::collections::HashMap<
    +                (String, String, Option),
    +                (usize, usize),
    +            > = std::collections::HashMap::new();
    +            for body in self.file_cfg.function_bodies() {
    +                if let Some(key) = &body.meta.func_key {
    +                    by_identity.insert(
    +                        (key.name.clone(), key.container.clone(), key.disambig),
    +                        body.meta.span,
    +                    );
    +                }
    +            }
    +            for s in &mut out {
    +                let id = (s.name.clone(), s.container.clone(), s.disambig);
    +                if let Some(span) = by_identity.get(&id) {
    +                    s.entry_kind = self.file_cfg.entry_kinds.get(span).cloned();
    +                }
    +            }
    +        }
    +
             // Rust-specific enrichment: derive the crate-relative module path for
             // this file and parse every top-level `use` declaration into an alias
             // map. The information lets the call graph resolve same-name functions
    @@ -1345,6 +1627,7 @@ impl<'a> ParsedFile<'a> {
             &self,
             global_summaries: Option<&GlobalSummaries>,
             scan_root: Option<&Path>,
    +        module_graph: Option<&crate::resolve::ModuleGraph>,
         ) -> (
             Vec<(crate::symbol::FuncKey, SsaFuncSummary)>,
             Vec<(
    @@ -1354,7 +1637,11 @@ impl<'a> ParsedFile<'a> {
         ) {
             let caller_lang = Lang::from_slug(self.source.lang_slug).unwrap_or(Lang::Rust);
             let scan_root_str = scan_root.map(|p| p.to_string_lossy());
    -        let namespace = normalize_namespace(&self.source.file_path_str, scan_root_str.as_deref());
    +        let namespace = crate::symbol::namespace_with_package(
    +            &self.source.file_path_str,
    +            scan_root_str.as_deref(),
    +            module_graph,
    +        );
     
             // Use the FileCfg path (same one `analyse_file` uses at taint time) so
             // the SSA summaries stored cross-file match exactly what pass 2 will
    @@ -1371,6 +1658,8 @@ impl<'a> ParsedFile<'a> {
                 self.local_summaries(),
                 global_summaries,
                 Some(&locator),
    +            scan_root_str.as_deref(),
    +            module_graph,
             );
     
             (summaries.into_iter().collect(), bodies)
    @@ -1386,29 +1675,30 @@ impl<'a> ParsedFile<'a> {
         ///
         /// # Locator policy
         ///
    -    /// Lowering does **not** attach a [`crate::summary::SinkSiteLocator`].
    -    /// Per the same-file rationale documented on [`crate::taint::analyse_file`]:
    -    /// pass-2 intra-file summaries are transient and behavior depends on
    -    /// `SinkSite.cap` only, which is always populated.  Attaching a locator
    -    /// here populates `param_to_sink` with concrete coordinates that the
    -    /// emission path then promotes into `Finding.primary_location`,
    -    /// causing the same-file summary-resolved sink to be reported at the
    -    /// callee-internal sink line instead of the call site, which both
    -    /// duplicates the intraprocedural finding the taint engine already
    -    /// emits at that exact line and re-attributes the flow finding away
    -    /// from the user-visible call site.  Closure-capture, lambda, and
    -    /// helper-with-internal-sink fixtures all expect call-site emission;
    -    /// the standalone [`crate::taint::analyse_file`] entry point already
    -    /// passes `None` here for the same reason.
    +    /// Attaches a [`crate::summary::SinkSiteLocator`] so intra-file
    +    /// summaries record concrete sink coordinates and a `from_chain` flag
    +    /// distinguishing chain-hop markers from this body's own locator span.
    +    /// Pass-2 emission then gates promotion into `Finding.primary_location`
    +    /// on `from_chain || file_rel != caller_namespace`, see
    +    /// [`crate::taint::ssa_transfer::should_promote_sink_site`].
         ///
    -    /// Cross-file primary attribution is unaffected: the artifact-extraction
    -    /// path that persists summaries to SQLite for cross-file consumption
    -    /// runs through [`crate::taint::extract_ssa_artifacts_from_file_cfg`]
    -    /// which threads its own locator-equipped lowering separately.
    +    /// Same-file single-hop helpers continue to surface the flow finding
    +    /// at the call site (their site is `from_chain=false` and lives in the
    +    /// caller's namespace, gate fails).  Multi-hop chains promote because
    +    /// `summary_extract` flips `from_chain=true` on every site that came
    +    /// via `event.primary_sink_site`, the callee already pierced through
    +    /// at least one summary boundary to record the deepest coordinates.
    +    /// Cross-file callees promote because `file_rel` differs.  This
    +    /// preserves the closure-capture / lambda / helper-with-internal-sink
    +    /// fixture shape (two findings: deep + call-site) while gaining
    +    /// deep-line attribution on multi-hop chains that have no per-frame
    +    /// intermediate finding to dedup with.  See "Multi-hop intra-file
    +    /// sink attribution gap" in deferred.md for the design tradeoff.
         fn lower_ssa_for_fused(
             &self,
             global_summaries: Option<&GlobalSummaries>,
             scan_root: Option<&Path>,
    +        module_graph: Option<&crate::resolve::ModuleGraph>,
         ) -> (
             std::collections::HashMap<
                 crate::symbol::FuncKey,
    @@ -1421,14 +1711,25 @@ impl<'a> ParsedFile<'a> {
         ) {
             let caller_lang = Lang::from_slug(self.source.lang_slug).unwrap_or(Lang::Rust);
             let scan_root_str = scan_root.map(|p| p.to_string_lossy());
    -        let namespace = normalize_namespace(&self.source.file_path_str, scan_root_str.as_deref());
    +        let namespace = crate::symbol::namespace_with_package(
    +            &self.source.file_path_str,
    +            scan_root_str.as_deref(),
    +            module_graph,
    +        );
    +        let locator = crate::summary::SinkSiteLocator {
    +            tree: &self.source.tree,
    +            bytes: self.source.bytes,
    +            file_rel: &namespace,
    +        };
             crate::taint::lower_all_functions_from_bodies(
                 &self.file_cfg,
                 caller_lang,
                 &namespace,
                 self.local_summaries(),
                 global_summaries,
    -            None,
    +            Some(&locator),
    +            scan_root_str.as_deref(),
    +            module_graph,
             )
         }
     
    @@ -1452,7 +1753,8 @@ impl<'a> ParsedFile<'a> {
             // in `analyse_file_fused`.
             crate::taint::ssa_transfer::reset_path_safe_suppressed_spans();
             crate::taint::ssa_transfer::reset_all_validated_spans();
    -        let (ssa_summaries, callee_bodies) = self.lower_ssa_for_fused(global_summaries, scan_root);
    +        let (ssa_summaries, callee_bodies) =
    +            self.lower_ssa_for_fused(global_summaries, scan_root, cfg.module_graph.as_deref());
             self.run_cfg_analyses_with_lowered(
                 cfg,
                 global_summaries,
    @@ -1488,12 +1790,30 @@ impl<'a> ParsedFile<'a> {
             tracing::debug!("Running taint analysis on: {}", self.source.path.display());
             tracing::debug!("Func summaries: {:?}", self.local_summaries());
             let scan_root_str = scan_root.map(|p| p.to_string_lossy());
    -        let namespace = normalize_namespace(&self.source.file_path_str, scan_root_str.as_deref());
    +        let namespace = crate::symbol::namespace_with_package(
    +            &self.source.file_path_str,
    +            scan_root_str.as_deref(),
    +            cfg.module_graph.as_deref(),
    +        );
             let extra = if self.lang_rules.extra_labels.is_empty() {
                 None
             } else {
                 Some(self.lang_rules.extra_labels.as_slice())
             };
    +        // Phase-09 cross-package import lookup. Built per-file from the
    +        // resolver's verdict; consumed by `resolve_callee_full` step 0.7
    +        // when a flat-name lookup would otherwise miss.
    +        let cross_package_imports = crate::taint::build_cross_package_func_keys(
    +            &self.file_cfg.resolved_imports,
    +            scan_root_str.as_deref(),
    +            cfg.module_graph.as_deref(),
    +            caller_lang,
    +        );
    +        let cross_package_imports_ref = if cross_package_imports.is_empty() {
    +            None
    +        } else {
    +            Some(&cross_package_imports)
    +        };
             let taint_results = crate::taint::analyse_file_with_lowered(
                 &self.file_cfg,
                 self.local_summaries(),
    @@ -1504,6 +1824,7 @@ impl<'a> ParsedFile<'a> {
                 extra,
                 ssa_summaries,
                 callee_bodies,
    +            cross_package_imports_ref,
             );
             // Drain the path-safe-suppressed sink-span set published by the
             // SSA taint engine.  Used below by the state-analysis pass to
    @@ -1585,8 +1906,44 @@ impl<'a> ParsedFile<'a> {
                             .get(&body.meta.id)
                             .unwrap_or(&empty_set),
                     ),
    +                class_constant_scalars: Some(&self.file_cfg.class_constant_scalars),
                 };
                 for cf in cfg_analysis::run_all(&cfg_ctx) {
    +                // Layer C4 mirror at the CFG-emission point: Python
    +                // `pickle.loads` / `yaml.load` / `shelve.open` calls
    +                // wrapped inside a `unittest.TestCase` literal-bound
    +                // assertion fire `cfg-unguarded-sink` because the
    +                // structural rule has no taint context.  Apply the
    +                // same recogniser used by the AST-pattern layer so
    +                // both sides agree on what counts as test-bound deser.
    +                if cf.rule_id == "cfg-unguarded-sink"
    +                    && self.source.lang_slug == "python"
    +                    && let Some(node) = self
    +                        .source
    +                        .tree
    +                        .root_node()
    +                        .descendant_for_byte_range(cf.span.0, cf.span.1)
    +                    && is_python_deser_inside_unittest_assertion(node, self.source.bytes)
    +                {
    +                    continue;
    +                }
    +                // Layer C5 mirror: Ruby `Marshal.load` / `YAML.load` /
    +                // `Psych.load` inside Minitest / RSpec assertions also
    +                // fire `cfg-unguarded-sink` from the structural rule
    +                // (which has no taint context).  Apply the same
    +                // recogniser used by the AST-pattern layer so both
    +                // sides agree on what counts as test-bound deser.
    +                if cf.rule_id == "cfg-unguarded-sink"
    +                    && self.source.lang_slug == "ruby"
    +                    && let Some(node) = self
    +                        .source
    +                        .tree
    +                        .root_node()
    +                        .descendant_for_byte_range(cf.span.0, cf.span.1)
    +                    && is_ruby_deser_inside_test_assertion(node, self.source.bytes)
    +                {
    +                    continue;
    +                }
                     let point = byte_offset_to_point(&self.source.tree, cf.span.0);
                     let cfg_confidence = Some(match cf.confidence {
                         cfg_analysis::Confidence::High => crate::evidence::Confidence::High,
    @@ -1921,7 +2278,7 @@ pub fn perf_stage_breakdown_fused(
     
         let s_lower = Instant::now();
         let (lowered_summaries, lowered_bodies) =
    -        parsed.lower_ssa_for_fused(global_summaries, scan_root);
    +        parsed.lower_ssa_for_fused(global_summaries, scan_root, cfg.module_graph.as_deref());
         let t_lower = s_lower.elapsed().as_micros();
         let lower_breakdown = crate::taint::perf_lower_timings_take().unwrap_or([0; 7]);
     
    @@ -2012,7 +2369,7 @@ pub fn perf_stage_breakdown(
         let t_auth = s_auth.elapsed().as_micros();
     
         let s_ssa = Instant::now();
    -    let _ = parsed.extract_ssa_artifacts(global_summaries, scan_root);
    +    let _ = parsed.extract_ssa_artifacts(global_summaries, scan_root, cfg.module_graph.as_deref());
         let t_ssa = s_ssa.elapsed().as_micros();
     
         Some([t_parse_cfg, t_taint, t_suppr, t_ast, t_auth, t_ssa])
    @@ -2039,15 +2396,20 @@ pub fn extract_all_summaries_from_bytes(
             crate::symbol::FuncKey,
             auth_analysis::model::AuthCheckSummary,
         )>,
    +    Option<(
    +        String,
    +        std::sync::Arc>,
    +    )>,
     )> {
         let _span = tracing::debug_span!("extract_all_summaries", file = %path.display()).entered();
         let Some(source) = ParsedSource::try_new(bytes, path)? else {
    -        return Ok((vec![], vec![], vec![], vec![]));
    +        return Ok((vec![], vec![], vec![], vec![], None));
         };
         let lang_slug = source.lang_slug;
         let parsed = ParsedFile::from_source(source, cfg);
         let func_summaries = parsed.export_summaries_with_root(scan_root);
    -    let (ssa_summaries, ssa_bodies) = parsed.extract_ssa_artifacts(None, scan_root);
    +    let (ssa_summaries, ssa_bodies) =
    +        parsed.extract_ssa_artifacts(None, scan_root, cfg.module_graph.as_deref());
         let auth_summaries = auth_analysis::extract_auth_summaries_by_key(
             &parsed.source.tree,
             parsed.source.bytes,
    @@ -2056,7 +2418,35 @@ pub fn extract_all_summaries_from_bytes(
             cfg,
             scan_root,
         );
    -    Ok((func_summaries, ssa_summaries, ssa_bodies, auth_summaries))
    +    let cross_package_imports = if parsed.file_cfg.resolved_imports.is_empty() {
    +        None
    +    } else {
    +        let scan_root_str = scan_root.map(|p| p.to_string_lossy());
    +        let ns = crate::symbol::namespace_with_package(
    +            &parsed.source.file_path_str,
    +            scan_root_str.as_deref(),
    +            cfg.module_graph.as_deref(),
    +        );
    +        let caller_lang = Lang::from_slug(parsed.source.lang_slug).unwrap_or(Lang::Rust);
    +        let map = crate::taint::build_cross_package_func_keys(
    +            &parsed.file_cfg.resolved_imports,
    +            scan_root_str.as_deref(),
    +            cfg.module_graph.as_deref(),
    +            caller_lang,
    +        );
    +        if map.is_empty() {
    +            None
    +        } else {
    +            Some((ns, std::sync::Arc::new(map)))
    +        }
    +    };
    +    Ok((
    +        func_summaries,
    +        ssa_summaries,
    +        ssa_bodies,
    +        auth_summaries,
    +        cross_package_imports,
    +    ))
     }
     
     // ─────────────────────────────────────────────────────────────────────────────
    @@ -2064,12 +2454,15 @@ pub fn extract_all_summaries_from_bytes(
     // ─────────────────────────────────────────────────────────────────────────────
     
     /// Returns `true` when the captured call node has only literal arguments
    -/// (string, number, boolean, null/nil/none).  Used to suppress AST pattern
    -/// findings on provably-constant calls like `os.system("echo health-ok")`.
    +/// (string, number, boolean, null/nil/none), or identifier arguments that
    +/// resolve to a file-level scalar constant (`const NAME = "x"` at module
    +/// scope and equivalent in Java / Go / Python / Rust).  Used to suppress
    +/// AST pattern findings on provably-constant calls like
    +/// `os.system(DEFAULT_CMD)` where `DEFAULT_CMD = "ls -la"`.
     ///
     /// Conservative: returns `false` whenever the tree structure is unclear or
     /// any argument is non-literal (including interpolated strings).
    -fn is_call_all_args_literal(node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +fn is_call_all_args_literal(node: tree_sitter::Node, bytes: &[u8], lang_slug: &str) -> bool {
         // Walk upwards from the captured node to find the closest call_expression
         // (or similar) ancestor, then locate its argument list child.
         let call_node = find_enclosing_call(node);
    @@ -2085,6 +2478,11 @@ fn is_call_all_args_literal(node: tree_sitter::Node, bytes: &[u8]) -> bool {
             None => return false,
         };
     
    +    // Build the file-level scalar binding set lazily: only resolve once per
    +    // call, never if every arg is a syntactic literal.  Cheap: walks the
    +    // file root's direct children for const / module-level assignment forms.
    +    let scalars = file_level_scalar_bindings(node, bytes, lang_slug);
    +
         let mut has_any_arg = false;
         for i in 0..arg_list.named_child_count() as u32 {
             let child = match arg_list.named_child(i) {
    @@ -2092,7 +2490,7 @@ fn is_call_all_args_literal(node: tree_sitter::Node, bytes: &[u8]) -> bool {
                 None => continue,
             };
             has_any_arg = true;
    -        if !is_literal_node(child, bytes) {
    +        if !is_literal_or_named_scalar(child, bytes, &scalars) {
                 return false;
             }
         }
    @@ -2102,6 +2500,63 @@ fn is_call_all_args_literal(node: tree_sitter::Node, bytes: &[u8]) -> bool {
         has_any_arg
     }
     
    +/// Walk up from `node` to the file root and collect every file-level scalar
    +/// binding name reachable on this language.  Empty set for languages without
    +/// a recognised binding form (JS/TS, Ruby, PHP, C/C++).
    +fn file_level_scalar_bindings(
    +    node: tree_sitter::Node,
    +    bytes: &[u8],
    +    lang_slug: &str,
    +) -> std::collections::HashSet {
    +    let mut root = node;
    +    while let Some(p) = root.parent() {
    +        root = p;
    +    }
    +    crate::cfg::safe_fields::collect_class_constant_scalars(root, lang_slug, bytes)
    +        .into_keys()
    +        .collect()
    +}
    +
    +/// Like [`is_literal_node`] but also accepts identifiers that resolve to a
    +/// file-level scalar binding (constant string / number / bool).
    +fn is_literal_or_named_scalar(
    +    node: tree_sitter::Node,
    +    bytes: &[u8],
    +    scalars: &std::collections::HashSet,
    +) -> bool {
    +    if is_literal_node(node, bytes) {
    +        return true;
    +    }
    +    let kind = node.kind();
    +    // Identifier forms vary across grammars.  PHP / Ruby use `variable_name`;
    +    // every other supported language uses bare `identifier`.  An `argument`
    +    // wrapper (PHP / Go) lifts a single child — unwrap and recurse.
    +    match kind {
    +        "identifier" | "variable_name" => {
    +            let Ok(text) = std::str::from_utf8(&bytes[node.byte_range()]) else {
    +                return false;
    +            };
    +            scalars.contains(text)
    +        }
    +        "argument" => node
    +            .named_child(0)
    +            .is_some_and(|c| is_literal_or_named_scalar(c, bytes, scalars)),
    +        // Unary / binary forms over a scalar binding remain a literal-valued
    +        // expression at compile time.
    +        "unary_expression" | "unary_op" => node
    +            .named_child(0)
    +            .is_some_and(|c| is_literal_or_named_scalar(c, bytes, scalars)),
    +        "binary_expression" | "concatenated_string" => {
    +            node.named_child_count() >= 2
    +                && (0..node.named_child_count() as u32).all(|i| {
    +                    node.named_child(i)
    +                        .is_some_and(|c| is_literal_or_named_scalar(c, bytes, scalars))
    +                })
    +        }
    +        _ => false,
    +    }
    +}
    +
     /// Walk up to find a call-expression-like ancestor of the captured node.
     /// Stops at statement/block boundaries to avoid matching unrelated outer calls.
     fn find_enclosing_call(mut node: tree_sitter::Node) -> Option {
    @@ -2112,8 +2567,15 @@ fn find_enclosing_call(mut node: tree_sitter::Node) -> Option
             if kind.contains("call") && !kind.contains("callee") {
                 return Some(node);
             }
    -        // PHP: function_call_expression
    -        if kind == "function_call_expression" {
    +        // Java / PHP / C-family kinds that don't have "call" in their name
    +        // but represent the same call shape for arg-list inspection.
    +        if matches!(
    +            kind,
    +            "function_call_expression"
    +                | "method_invocation"
    +                | "object_creation_expression"
    +                | "explicit_constructor_invocation"
    +        ) {
                 return Some(node);
             }
             // Stop at scope/statement boundaries, don't cross into outer calls
    @@ -2654,6 +3116,1131 @@ fn is_php_unserialize_magic_method_passthrough(cap_node: tree_sitter::Node, byte
         arg_name == param_name
     }
     
    +/// PHP-only Layer C3: returns `true` when an `unserialize($x)` call
    +/// site is the second (or later) argument of a PHPUnit assertion call
    +/// whose first (expected) argument is a literal expression
    +/// (scalar, array literal, class constant access, or unary on a
    +/// literal).
    +///
    +/// **Why this is a non-actionable site for `php.deser.unserialize`:**
    +/// PHPUnit's `assertSame($expected, $actual)` /
    +/// `assertEquals(...)` / `assertNull(...)` family bound the
    +/// `unserialize` result to the literal expected value: if the
    +/// `$blob` argument were attacker-controlled and produced a
    +/// different shape, the assertion would fail loudly rather than
    +/// permit any object-injection side effect to escape the test
    +/// boundary.  Drupal, Joomla, and Nextcloud each carry tens of
    +/// these `Serializable` / cache / session round-trip tests and
    +/// every firing is noise; the actionable signal lives at the
    +/// production call sites that thread real input through
    +/// `unserialize` without an assertion sandwich.
    +///
    +/// Conservative recognition:
    +/// - the `unserialize(...)` call must be wrapped in an `argument`
    +///   node whose parent is `arguments`
    +/// - the enclosing call must be a `member_call_expression`,
    +///   `nullsafe_member_call_expression`, `scoped_call_expression`,
    +///   or `function_call_expression` with a method/function name
    +///   starting with `assert` (case-insensitive) — covers the entire
    +///   PHPUnit assertion family
    +/// - the assertion must have at least two argument slots (an
    +///   expected/actual pair)
    +/// - the first argument's inner expression must be a literal: a
    +///   string / number / boolean / null literal, an
    +///   `array_creation_expression` whose elements are recursively
    +///   literal, a `class_constant_access_expression`, or a unary
    +///   sign on one of the above
    +///
    +/// Genuine production sites (`unserialize($_GET[...])`, helpers
    +/// reading from session/cache and handing the value to caller
    +/// code) keep firing because they are not wrapped in a PHPUnit
    +/// assertion.  Single-argument assertions (`assertNotNull($x)`)
    +/// and assertions whose expected value is itself dynamic
    +/// (`assertEquals($computed, unserialize($blob))`) keep firing
    +/// because the bound is not statically verifiable.
    +fn is_php_unserialize_inside_phpunit_assertion(cap_node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    // The pattern captures `@n` (the function name); locate the enclosing
    +    // function_call_expression.  Mirrors the magic-method recogniser.
    +    let call_node = if cap_node.kind() == "function_call_expression" {
    +        cap_node
    +    } else {
    +        let mut cur = cap_node;
    +        let mut found = None;
    +        for _ in 0..4 {
    +            if cur.kind() == "function_call_expression" {
    +                found = Some(cur);
    +                break;
    +            }
    +            match cur.parent() {
    +                Some(p) => cur = p,
    +                None => break,
    +            }
    +        }
    +        match found {
    +            Some(c) => c,
    +            None => return false,
    +        }
    +    };
    +
    +    // The unserialize call must sit directly inside an `argument` wrapper
    +    // that is itself inside an `arguments` list.  Reject any wrapping
    +    // expression (binary, conditional, etc.) — those break the literal
    +    // bounding the assertion provides.
    +    let Some(arg_wrapper) = call_node.parent() else {
    +        return false;
    +    };
    +    if arg_wrapper.kind() != "argument" {
    +        return false;
    +    }
    +    let Some(arguments) = arg_wrapper.parent() else {
    +        return false;
    +    };
    +    if arguments.kind() != "arguments" {
    +        return false;
    +    }
    +    let Some(assertion_call) = arguments.parent() else {
    +        return false;
    +    };
    +    if !matches!(
    +        assertion_call.kind(),
    +        "member_call_expression"
    +            | "nullsafe_member_call_expression"
    +            | "scoped_call_expression"
    +            | "function_call_expression"
    +    ) {
    +        return false;
    +    }
    +
    +    // Method/function name must start with `assert` (case-insensitive).
    +    let name_node = assertion_call
    +        .child_by_field_name("name")
    +        .or_else(|| find_named_child_of_kind(assertion_call, "name"));
    +    let Some(name_node) = name_node else {
    +        return false;
    +    };
    +    let Ok(method_name) = std::str::from_utf8(&bytes[name_node.byte_range()]) else {
    +        return false;
    +    };
    +    if !method_name
    +        .chars()
    +        .take(6)
    +        .collect::()
    +        .eq_ignore_ascii_case("assert")
    +    {
    +        return false;
    +    }
    +
    +    // Collect the assertion's argument wrappers.
    +    let mut args: Vec = Vec::new();
    +    for i in 0..arguments.named_child_count() as u32 {
    +        if let Some(c) = arguments.named_child(i)
    +            && c.kind() == "argument"
    +        {
    +            args.push(c);
    +        }
    +    }
    +    if args.is_empty() {
    +        return false;
    +    }
    +
    +    // Single-arg assertions: the verb itself bounds the result
    +    // (`assertNull`, `assertIsArray`, `assertTrue`, ...).  Restrict to
    +    // a curated set so generic `assertSomething(unserialize($x))`
    +    // helpers without a documented bound don't qualify.
    +    if args.len() == 1 {
    +        return is_phpunit_single_arg_bounding_verb(method_name);
    +    }
    +
    +    // Multi-arg assertions: the first argument is the expected /
    +    // literal-pinned value (PHPUnit's documented `$expected, $actual`
    +    // order).  The expected must be a static literal expression.
    +    let Some(first_inner) = args[0].named_child(0) else {
    +        return false;
    +    };
    +    is_php_assertion_literal_expected(first_inner, bytes)
    +}
    +
    +/// PHPUnit single-arg assertion verbs whose name itself constrains
    +/// the inspected value to a known type or constant.  When
    +/// `unserialize($x)` is the sole argument to one of these, a failed
    +/// assertion aborts the test rather than letting an object-injection
    +/// side effect escape.
    +fn is_phpunit_single_arg_bounding_verb(name: &str) -> bool {
    +    matches!(
    +        name.to_ascii_lowercase().as_str(),
    +        "assertnull"
    +            | "assertnotnull"
    +            | "assertempty"
    +            | "assertnotempty"
    +            | "asserttrue"
    +            | "assertfalse"
    +            | "assertnan"
    +            | "assertfinite"
    +            | "assertinfinite"
    +            | "assertisarray"
    +            | "assertisnotarray"
    +            | "assertisbool"
    +            | "assertisnotbool"
    +            | "assertiscallable"
    +            | "assertisnotcallable"
    +            | "assertisfloat"
    +            | "assertisnotfloat"
    +            | "assertisint"
    +            | "assertisnotint"
    +            | "assertisiterable"
    +            | "assertisnotiterable"
    +            | "assertisnumeric"
    +            | "assertisnotnumeric"
    +            | "assertisobject"
    +            | "assertisnotobject"
    +            | "assertisresource"
    +            | "assertisnotresource"
    +            | "assertisclosedresource"
    +            | "assertisnotclosedresource"
    +            | "assertisstring"
    +            | "assertisnotstring"
    +            | "assertisscalar"
    +            | "assertisnotscalar"
    +    )
    +}
    +
    +/// PHP-only helper: returns `true` if `node` is a statically literal
    +/// expression suitable as the "expected" argument of a PHPUnit
    +/// assertion.  Recursive: array elements must themselves be literal.
    +/// Class constants (`Foo::BAR`) count as literal — they resolve to
    +/// build-time values and PHPUnit treats them as expected pinning.
    +fn is_php_assertion_literal_expected(node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    match node.kind() {
    +        "string"
    +        | "integer"
    +        | "float"
    +        | "boolean"
    +        | "null"
    +        | "true"
    +        | "false"
    +        | "class_constant_access_expression"
    +        | "scoped_property_access_expression" => true,
    +        "encapsed_string" => !has_interpolation(node),
    +        "unary_op_expression" => node
    +            .named_child(0)
    +            .is_some_and(|c| is_php_assertion_literal_expected(c, bytes)),
    +        "array_creation_expression" => {
    +            for i in 0..node.named_child_count() as u32 {
    +                let Some(child) = node.named_child(i) else {
    +                    return false;
    +                };
    +                if child.kind() != "array_element_initializer" {
    +                    return false;
    +                }
    +                // array_element_initializer can have one (value) or
    +                // two (key, value) named children; both must be literal.
    +                for j in 0..child.named_child_count() as u32 {
    +                    let Some(grand) = child.named_child(j) else {
    +                        return false;
    +                    };
    +                    if !is_php_assertion_literal_expected(grand, bytes) {
    +                        return false;
    +                    }
    +                }
    +            }
    +            true
    +        }
    +        _ => false,
    +    }
    +}
    +
    +/// Python-only Layer C4: returns `true` when a deserialization call
    +/// (`pickle.loads`, `yaml.load`, `shelve.open`, etc.) sits inside a
    +/// test assertion that bounds the result to a literal-expected shape.
    +///
    +/// Two assertion idioms are recognised:
    +/// 1. `unittest.TestCase` style — `self.assertEqual(LITERAL, pickle.loads(b))`,
    +///    `self.assertIsNone(pickle.loads(b))`, etc.
    +/// 2. pytest plain `assert` — `assert pickle.loads(b) == LITERAL`,
    +///    `assert pickle.loads(b) is None`, `assert isinstance(pickle.loads(b),
    +///    dict)`, `assert pickle.loads(b)` (truthy), `assert not
    +///    pickle.loads(b)` (falsy).
    +///
    +/// **Why this is a non-actionable site:** the assertion bounds the
    +/// deser result to a literal expected; if the blob argument were
    +/// attacker-controlled and produced a different shape, the assertion
    +/// would fail loudly rather than permit any object-injection side
    +/// effect to escape the test boundary.  Python projects ship
    +/// round-trip tests for every pickled / YAML-loaded data class, and
    +/// every firing on those test bodies is noise.
    +///
    +/// Conservative recognition:
    +/// - the deser call must reach the assertion through allowed wrappers
    +///   only (parenthesized_expression, comparison_operator with literal
    +///   counterpart, unary `not`, `isinstance(_, TYPE)`, `bool` / `len` /
    +///   `type` / `id` single-arg wrap); boolean ops and conditional
    +///   expressions break the bound and reject.
    +/// - unittest verbs must start with `assert` or `fail` (case-sensitive
    +///   per Python conventions) and pass the curated single-arg / multi-
    +///   arg bounding tables.
    +/// - pytest plain `assert` requires the deser to be the asserted
    +///   expression (named_child(0) of `assert_statement`), not the
    +///   optional message at named_child(1).
    +fn is_python_deser_inside_unittest_assertion(cap_node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    // Three entry shapes:
    +    //   (a) unittest AST-pattern: `cap_node` is the `pickle` / `yaml` /
    +    //       `shelve` identifier under the deser call's `function.object`
    +    //       path.  Walk up to the deser call, then up to an outer
    +    //       assertion call via `argument_list`.
    +    //   (b) unittest CFG-emission: `cap_node` is somewhere inside the
    +    //       OUTER assertion call (`self.assertEqual(...)`).  Look for a
    +    //       deser sub-call inside its argument_list.
    +    //   (c) pytest plain-assert: `cap_node` resolves to the deser call,
    +    //       which sits directly under an `assert_statement` (possibly
    +    //       through allowed bounding wrappers).
    +    let enclosing_call = find_enclosing_call(cap_node);
    +    let Some(enclosing_call) = enclosing_call else {
    +        return false;
    +    };
    +
    +    // Path (a)/(c): enclosing call IS the deser.
    +    if is_python_deser_call(enclosing_call, bytes) {
    +        // (a) walk to outer call assertion via argument_list.
    +        if let Some(arg_list) = enclosing_call.parent()
    +            && arg_list.kind() == "argument_list"
    +            && let Some(assertion_call) = arg_list.parent()
    +            && assertion_call.kind() == "call"
    +            && python_assertion_bounds_deser(assertion_call, enclosing_call, bytes)
    +        {
    +            return true;
    +        }
    +        // (c) walk up to assert_statement through allowed wrappers.
    +        if python_pytest_assert_bounds_deser(enclosing_call, bytes) {
    +            return true;
    +        }
    +        return false;
    +    }
    +
    +    // Path (b): enclosing call IS an assertion that wraps a deser arg.
    +    if let Some(deser_call) = python_find_direct_deser_arg(enclosing_call, bytes) {
    +        return python_assertion_bounds_deser(enclosing_call, deser_call, bytes);
    +    }
    +
    +    false
    +}
    +
    +/// Search the assertion call's argument_list for a direct child that
    +/// is a recognised deserialization call.  Direct child only — wrapped
    +/// expressions (binary, conditional, parenthesized) break the literal
    +/// bound and must keep firing.
    +fn python_find_direct_deser_arg<'tree>(
    +    assertion_call: tree_sitter::Node<'tree>,
    +    bytes: &[u8],
    +) -> Option> {
    +    let arg_list = assertion_call.child_by_field_name("arguments")?;
    +    if arg_list.kind() != "argument_list" {
    +        return None;
    +    }
    +    for i in 0..arg_list.named_child_count() as u32 {
    +        let Some(c) = arg_list.named_child(i) else {
    +            continue;
    +        };
    +        if c.kind() == "call" && is_python_deser_call(c, bytes) {
    +            return Some(c);
    +        }
    +    }
    +    None
    +}
    +
    +/// Core bounding check: given an assertion `call` node and the
    +/// deser sub-call inside its arg list, decide whether the assertion
    +/// bounds the deser result so the call is non-actionable.
    +fn python_assertion_bounds_deser(
    +    assertion_call: tree_sitter::Node,
    +    deser_call: tree_sitter::Node,
    +    bytes: &[u8],
    +) -> bool {
    +    let Some(func) = assertion_call.child_by_field_name("function") else {
    +        return false;
    +    };
    +    let name_node = match func.kind() {
    +        "attribute" => func
    +            .child_by_field_name("attribute")
    +            .or_else(|| find_named_child_of_kind(func, "identifier")),
    +        "identifier" => Some(func),
    +        _ => return false,
    +    };
    +    let Some(name_node) = name_node else {
    +        return false;
    +    };
    +    let Ok(verb) = std::str::from_utf8(&bytes[name_node.byte_range()]) else {
    +        return false;
    +    };
    +    let lowered = verb.to_ascii_lowercase();
    +    if !(lowered.starts_with("assert") || lowered.starts_with("fail")) {
    +        return false;
    +    }
    +
    +    let Some(arg_list) = assertion_call.child_by_field_name("arguments") else {
    +        return false;
    +    };
    +    if arg_list.kind() != "argument_list" {
    +        return false;
    +    }
    +    let mut pos_args: Vec = Vec::new();
    +    let mut deser_pos: Option = None;
    +    for i in 0..arg_list.named_child_count() as u32 {
    +        let Some(c) = arg_list.named_child(i) else {
    +            continue;
    +        };
    +        if c.kind() == "keyword_argument" {
    +            continue;
    +        }
    +        if c.id() == deser_call.id() {
    +            deser_pos = Some(pos_args.len());
    +        }
    +        pos_args.push(c);
    +    }
    +    let Some(deser_pos) = deser_pos else {
    +        return false;
    +    };
    +    if pos_args.is_empty() {
    +        return false;
    +    }
    +
    +    if pos_args.len() == 1 {
    +        return is_python_unittest_single_arg_bounding_verb(verb);
    +    }
    +
    +    if matches!(verb, "assertIsInstance" | "assertNotIsInstance") {
    +        let type_pos = if deser_pos == 0 { 1 } else { 0 };
    +        if let Some(type_arg) = pos_args.get(type_pos)
    +            && is_python_type_reference(*type_arg)
    +        {
    +            return true;
    +        }
    +    }
    +
    +    if !is_python_unittest_multi_arg_bounding_verb(verb) {
    +        return false;
    +    }
    +    for (i, arg) in pos_args.iter().enumerate() {
    +        if i == deser_pos {
    +            continue;
    +        }
    +        if is_python_assertion_literal_expected(*arg, bytes) {
    +            return true;
    +        }
    +    }
    +    false
    +}
    +
    +/// pytest plain-`assert` bounding check.  `deser_call` must be the
    +/// recognised deser invocation; we walk upward through allowed
    +/// wrappers until we reach an `assert_statement` whose first named
    +/// child (the asserted expression, NOT the optional message) is the
    +/// chain we walked.  Boolean operators and conditional expressions
    +/// break the bound (they can short-circuit past the assertion).
    +fn python_pytest_assert_bounds_deser(deser_call: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    let mut cur = deser_call;
    +    for _ in 0..8 {
    +        let Some(parent) = cur.parent() else {
    +            return false;
    +        };
    +        match parent.kind() {
    +            "assert_statement" => {
    +                // Asserted expression sits at named_child(0); the
    +                // optional message sits at named_child(1).
    +                let first = parent.named_child(0);
    +                return first.is_some_and(|n| n.id() == cur.id());
    +            }
    +            "comparison_operator" => {
    +                if !python_comparison_other_side_is_literal(parent, cur, bytes) {
    +                    return false;
    +                }
    +                cur = parent;
    +            }
    +            // `not deser` parses as `not_operator`; `+/-/~ deser` as
    +            // `unary_operator`.  Both leave the deser-side as the sole
    +            // operand and bound the assertion result to a scalar.
    +            "unary_operator" | "not_operator" => {
    +                cur = parent;
    +            }
    +            "parenthesized_expression" => {
    +                cur = parent;
    +            }
    +            "argument_list" => {
    +                let Some(parent_call) = parent.parent() else {
    +                    return false;
    +                };
    +                if parent_call.kind() != "call" {
    +                    return false;
    +                }
    +                let Some(func) = parent_call.child_by_field_name("function") else {
    +                    return false;
    +                };
    +                if func.kind() != "identifier" {
    +                    return false;
    +                }
    +                let Ok(name) = std::str::from_utf8(&bytes[func.byte_range()]) else {
    +                    return false;
    +                };
    +                match name {
    +                    "isinstance" => {
    +                        // isinstance(deser, TYPE) — deser must be at
    +                        // positional index 0 and the second positional
    +                        // arg must be a type reference.
    +                        let mut pos = 0usize;
    +                        let mut found_at: Option = None;
    +                        let mut other_args: Vec = Vec::new();
    +                        for i in 0..parent.named_child_count() as u32 {
    +                            let Some(c) = parent.named_child(i) else {
    +                                return false;
    +                            };
    +                            if c.kind() == "keyword_argument" {
    +                                continue;
    +                            }
    +                            if c.id() == cur.id() {
    +                                found_at = Some(pos);
    +                            } else {
    +                                other_args.push(c);
    +                            }
    +                            pos += 1;
    +                        }
    +                        if found_at != Some(0)
    +                            || other_args.len() != 1
    +                            || !is_python_type_reference(other_args[0])
    +                        {
    +                            return false;
    +                        }
    +                    }
    +                    "bool" | "len" | "type" | "id" => {
    +                        // bool(deser) / len(deser) / type(deser) /
    +                        // id(deser) — single-arg scalar wrappers.
    +                        let mut named_count = 0usize;
    +                        for i in 0..parent.named_child_count() as u32 {
    +                            let Some(c) = parent.named_child(i) else {
    +                                return false;
    +                            };
    +                            if c.kind() == "keyword_argument" {
    +                                continue;
    +                            }
    +                            named_count += 1;
    +                        }
    +                        if named_count != 1 {
    +                            return false;
    +                        }
    +                    }
    +                    _ => return false,
    +                }
    +                cur = parent_call;
    +            }
    +            // Boolean ops and conditionals can short-circuit and let
    +            // a poisoned blob's side effect run before the assertion
    +            // fires.  Reject so the original finding stands.
    +            "boolean_operator" | "conditional_expression" => return false,
    +            _ => return false,
    +        }
    +    }
    +    false
    +}
    +
    +/// `comparison_operator` bounding: the other operand(s) must all be
    +/// literal expressions (recursive literal classifier).  Operator-kind
    +/// children (`is` / `is_not` / `in` / `not_in` are named in
    +/// tree-sitter-python) are skipped.  Also requires `deser_side` to
    +/// actually be one of the named children, defending against unrelated
    +/// chained comparisons.
    +fn python_comparison_other_side_is_literal(
    +    cmp: tree_sitter::Node,
    +    deser_side: tree_sitter::Node,
    +    bytes: &[u8],
    +) -> bool {
    +    let mut found_self = false;
    +    for i in 0..cmp.named_child_count() as u32 {
    +        let Some(c) = cmp.named_child(i) else {
    +            return false;
    +        };
    +        match c.kind() {
    +            "is" | "is_not" | "in" | "not_in" => continue,
    +            _ => {}
    +        }
    +        if c.id() == deser_side.id() {
    +            found_self = true;
    +            continue;
    +        }
    +        if !is_python_assertion_literal_expected(c, bytes) {
    +            return false;
    +        }
    +    }
    +    found_self
    +}
    +
    +/// Returns `true` when `call_node` is a Python `call` whose callee
    +/// is a recognised deserialization function (`pickle.loads` /
    +/// `pickle.load` / `yaml.load` / `shelve.open` / `marshal.loads` /
    +/// `marshal.load`).  Plain identifier callees (`loads(blob)` after
    +/// `from pickle import loads`) are also recognised by leaf name to
    +/// match the import-shape ambiguity.
    +fn is_python_deser_call(call_node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    let Some(func) = call_node.child_by_field_name("function") else {
    +        return false;
    +    };
    +    match func.kind() {
    +        "attribute" => {
    +            let Some(obj) = func.child_by_field_name("object") else {
    +                return false;
    +            };
    +            let Some(attr) = func.child_by_field_name("attribute") else {
    +                return false;
    +            };
    +            let Ok(obj_text) = std::str::from_utf8(&bytes[obj.byte_range()]) else {
    +                return false;
    +            };
    +            let Ok(attr_text) = std::str::from_utf8(&bytes[attr.byte_range()]) else {
    +                return false;
    +            };
    +            matches!(
    +                (obj_text, attr_text),
    +                ("pickle", "loads")
    +                    | ("pickle", "load")
    +                    | ("cPickle", "loads")
    +                    | ("cPickle", "load")
    +                    | ("yaml", "load")
    +                    | ("yaml", "unsafe_load")
    +                    | ("shelve", "open")
    +                    | ("marshal", "loads")
    +                    | ("marshal", "load")
    +            )
    +        }
    +        "identifier" => {
    +            let Ok(name) = std::str::from_utf8(&bytes[func.byte_range()]) else {
    +                return false;
    +            };
    +            matches!(name, "loads" | "load" | "unsafe_load")
    +        }
    +        _ => false,
    +    }
    +}
    +
    +/// Single-arg `unittest.TestCase` assertion verbs whose name itself
    +/// constrains the inspected value.  When the deser call is the sole
    +/// positional argument to one of these, a failed assertion aborts
    +/// the test rather than letting an object-injection side effect
    +/// escape.
    +fn is_python_unittest_single_arg_bounding_verb(name: &str) -> bool {
    +    matches!(
    +        name,
    +        "assertIsNone"
    +            | "assertIsNotNone"
    +            | "assertTrue"
    +            | "assertFalse"
    +            | "assertNotNone"
    +            | "assertNone"
    +            | "failIf"
    +            | "failUnless"
    +            | "assert_"
    +    )
    +}
    +
    +/// Multi-arg `unittest.TestCase` assertion verbs that perform a
    +/// literal-comparable bound on every value position (equality,
    +/// ordering, membership, regex match, type-equality).
    +fn is_python_unittest_multi_arg_bounding_verb(name: &str) -> bool {
    +    matches!(
    +        name,
    +        "assertEqual"
    +            | "assertEquals"
    +            | "assertNotEqual"
    +            | "assertNotEquals"
    +            | "assert_equal"
    +            | "assert_not_equal"
    +            | "assertIs"
    +            | "assertIsNot"
    +            | "assertAlmostEqual"
    +            | "assertNotAlmostEqual"
    +            | "assertGreater"
    +            | "assertGreaterEqual"
    +            | "assertLess"
    +            | "assertLessEqual"
    +            | "assertListEqual"
    +            | "assertTupleEqual"
    +            | "assertDictEqual"
    +            | "assertSetEqual"
    +            | "assertSequenceEqual"
    +            | "assertMultiLineEqual"
    +            | "assertCountEqual"
    +            | "assertItemsEqual"
    +            | "assertIn"
    +            | "assertNotIn"
    +            | "assertRegex"
    +            | "assertNotRegex"
    +            | "assertRegexpMatches"
    +            | "assertNotRegexpMatches"
    +            | "failUnlessEqual"
    +            | "failIfEqual"
    +    )
    +}
    +
    +/// Recognise a Python type reference suitable as the second arg to
    +/// `assertIsInstance(value, type)`.  Accepts builtin/user-class
    +/// identifiers, dotted attribute access (`module.Type`), generic
    +/// subscripts (`list[int]`), and tuples-of-types.
    +fn is_python_type_reference(node: tree_sitter::Node) -> bool {
    +    match node.kind() {
    +        "identifier" | "attribute" | "subscript" => true,
    +        "tuple" => {
    +            for i in 0..node.named_child_count() as u32 {
    +                let Some(c) = node.named_child(i) else {
    +                    return false;
    +                };
    +                if !is_python_type_reference(c) {
    +                    return false;
    +                }
    +            }
    +            true
    +        }
    +        _ => false,
    +    }
    +}
    +
    +/// Python literal expression suitable as the "expected" argument of
    +/// a `unittest.TestCase.assertEqual`-family assertion.  Recursive:
    +/// list / tuple / set / dict elements and unary signs on numerics
    +/// must themselves be literal.  Identifier references and attribute
    +/// access do NOT count (those could resolve to dynamic values).
    +fn is_python_assertion_literal_expected(node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    match node.kind() {
    +        "string" => !has_python_string_interpolation(node),
    +        "concatenated_string" => {
    +            for i in 0..node.named_child_count() as u32 {
    +                let Some(c) = node.named_child(i) else {
    +                    return false;
    +                };
    +                if !is_python_assertion_literal_expected(c, bytes) {
    +                    return false;
    +                }
    +            }
    +            true
    +        }
    +        "integer" | "float" | "true" | "false" | "none" | "ellipsis" => true,
    +        "unary_operator" => node
    +            .named_child(0)
    +            .is_some_and(|c| is_python_assertion_literal_expected(c, bytes)),
    +        "list" | "tuple" | "set" => {
    +            for i in 0..node.named_child_count() as u32 {
    +                let Some(c) = node.named_child(i) else {
    +                    return false;
    +                };
    +                if !is_python_assertion_literal_expected(c, bytes) {
    +                    return false;
    +                }
    +            }
    +            true
    +        }
    +        "dictionary" => {
    +            for i in 0..node.named_child_count() as u32 {
    +                let Some(c) = node.named_child(i) else {
    +                    return false;
    +                };
    +                if c.kind() != "pair" {
    +                    return false;
    +                }
    +                let Some(key) = c.child_by_field_name("key") else {
    +                    return false;
    +                };
    +                let Some(value) = c.child_by_field_name("value") else {
    +                    return false;
    +                };
    +                if !is_python_assertion_literal_expected(key, bytes) {
    +                    return false;
    +                }
    +                if !is_python_assertion_literal_expected(value, bytes) {
    +                    return false;
    +                }
    +            }
    +            true
    +        }
    +        _ => false,
    +    }
    +}
    +
    +/// Python f-strings are `string` nodes with `interpolation` children.
    +/// Treat them as non-literal because the interpolated value is
    +/// dynamic.
    +fn has_python_string_interpolation(node: tree_sitter::Node) -> bool {
    +    for i in 0..node.named_child_count() as u32 {
    +        if let Some(c) = node.named_child(i)
    +            && c.kind() == "interpolation"
    +        {
    +            return true;
    +        }
    +    }
    +    false
    +}
    +
    +/// Ruby Layer C5: returns `true` when a `Marshal.load` / `YAML.load` /
    +/// `Psych.load` call sits directly inside a Minitest assertion or RSpec
    +/// matcher chain whose other operand is a literal expected.  Same
    +/// non-actionability rationale as the Python and PHP recognisers
    +/// above: round-trip tests bound the deser result to a literal, a
    +/// poisoned blob would fail the assertion, no object-injection side
    +/// effect escapes the test boundary.
    +///
    +/// Conservative recognition:
    +/// - Minitest: `assert_equal LIT, deser`, `assert_nil deser`,
    +///   `assert deser` (truthy), and the `refute_*` mirrors.
    +/// - RSpec: `expect(deser).to eq(LIT)`, `expect(deser).to be_nil`,
    +///   `expect(deser).to be_a(TYPE)`, `be_truthy`, `not_to`/`to_not`.
    +/// - Old-style `.should ==` chains are NOT recognised (they're
    +///   discouraged in modern RSpec and the AST shape parses as a
    +///   `binary` rather than the receiver-method-arguments shape).
    +fn is_ruby_deser_inside_test_assertion(cap_node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    let enclosing_call = find_enclosing_call(cap_node);
    +    let Some(deser_call) = enclosing_call else {
    +        return false;
    +    };
    +    if !is_ruby_deser_call(deser_call, bytes) {
    +        return false;
    +    }
    +    let Some(arg_list) = deser_call.parent() else {
    +        return false;
    +    };
    +    if arg_list.kind() != "argument_list" {
    +        return false;
    +    }
    +    let Some(outer_call) = arg_list.parent() else {
    +        return false;
    +    };
    +    if outer_call.kind() != "call" {
    +        return false;
    +    }
    +    if outer_call.child_by_field_name("receiver").is_some() {
    +        return false;
    +    }
    +    let Some(method_node) = outer_call.child_by_field_name("method") else {
    +        return false;
    +    };
    +    let Ok(name) = std::str::from_utf8(&bytes[method_node.byte_range()]) else {
    +        return false;
    +    };
    +
    +    if is_ruby_minitest_single_arg_bounding_verb(name)
    +        || is_ruby_minitest_multi_arg_bounding_verb(name)
    +        || matches!(
    +            name,
    +            "assert_kind_of" | "assert_instance_of" | "refute_kind_of" | "refute_instance_of"
    +        )
    +    {
    +        return ruby_minitest_assertion_bounds_deser(outer_call, deser_call, bytes);
    +    }
    +
    +    if name == "expect" {
    +        let Some(rspec_outer) = outer_call.parent() else {
    +            return false;
    +        };
    +        if rspec_outer.kind() != "call" {
    +            return false;
    +        }
    +        let Some(receiver) = rspec_outer.child_by_field_name("receiver") else {
    +            return false;
    +        };
    +        if receiver.id() != outer_call.id() {
    +            return false;
    +        }
    +        let Some(rspec_method) = rspec_outer.child_by_field_name("method") else {
    +            return false;
    +        };
    +        let Ok(verb) = std::str::from_utf8(&bytes[rspec_method.byte_range()]) else {
    +            return false;
    +        };
    +        if !matches!(verb, "to" | "not_to" | "to_not") {
    +            return false;
    +        }
    +        let Some(matcher_args) = rspec_outer.child_by_field_name("arguments") else {
    +            return false;
    +        };
    +        return ruby_rspec_matcher_bounds_deser(matcher_args, bytes);
    +    }
    +
    +    false
    +}
    +
    +/// `Marshal.load` / `YAML.load` / `YAML.unsafe_load` / `Psych.load` /
    +/// `Psych.unsafe_load` shape recogniser.  Only the canonical `Module.method`
    +/// chain — bare-leaf `load(b)` is ambiguous in Ruby and not flagged as a
    +/// pattern hit, so no need to handle it here.
    +fn is_ruby_deser_call(call_node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    let Some(receiver) = call_node.child_by_field_name("receiver") else {
    +        return false;
    +    };
    +    let Some(method) = call_node.child_by_field_name("method") else {
    +        return false;
    +    };
    +    if receiver.kind() != "constant" {
    +        return false;
    +    }
    +    let Ok(recv_text) = std::str::from_utf8(&bytes[receiver.byte_range()]) else {
    +        return false;
    +    };
    +    let Ok(method_text) = std::str::from_utf8(&bytes[method.byte_range()]) else {
    +        return false;
    +    };
    +    matches!(
    +        (recv_text, method_text),
    +        ("Marshal", "load")
    +            | ("Marshal", "restore")
    +            | ("YAML", "load")
    +            | ("YAML", "unsafe_load")
    +            | ("YAML", "load_file")
    +            | ("Psych", "load")
    +            | ("Psych", "unsafe_load")
    +            | ("Psych", "load_file")
    +    )
    +}
    +
    +fn ruby_minitest_assertion_bounds_deser(
    +    call: tree_sitter::Node,
    +    deser_call: tree_sitter::Node,
    +    bytes: &[u8],
    +) -> bool {
    +    let Some(method) = call.child_by_field_name("method") else {
    +        return false;
    +    };
    +    let Ok(name) = std::str::from_utf8(&bytes[method.byte_range()]) else {
    +        return false;
    +    };
    +    let Some(arg_list) = call.child_by_field_name("arguments") else {
    +        return false;
    +    };
    +    let mut pos_args: Vec = Vec::new();
    +    let mut deser_pos: Option = None;
    +    for i in 0..arg_list.named_child_count() as u32 {
    +        let Some(c) = arg_list.named_child(i) else {
    +            continue;
    +        };
    +        // Minitest verbs accept a trailing message argument as last
    +        // positional; both that and the value positions are checked
    +        // through the literal tester so kwargs and hash splats are
    +        // the only kinds that need to be stripped here.
    +        if matches!(c.kind(), "pair" | "hash_splat_argument") {
    +            continue;
    +        }
    +        if c.id() == deser_call.id() {
    +            deser_pos = Some(pos_args.len());
    +        }
    +        pos_args.push(c);
    +    }
    +    let Some(deser_pos) = deser_pos else {
    +        return false;
    +    };
    +    if pos_args.is_empty() {
    +        return false;
    +    }
    +
    +    if pos_args.len() == 1 {
    +        return is_ruby_minitest_single_arg_bounding_verb(name);
    +    }
    +
    +    if matches!(
    +        name,
    +        "assert_kind_of" | "assert_instance_of" | "refute_kind_of" | "refute_instance_of"
    +    ) {
    +        let type_pos = if deser_pos == 0 { 1 } else { 0 };
    +        if let Some(type_arg) = pos_args.get(type_pos)
    +            && is_ruby_type_reference(*type_arg)
    +        {
    +            return true;
    +        }
    +    }
    +
    +    if !is_ruby_minitest_multi_arg_bounding_verb(name) {
    +        return false;
    +    }
    +    for (i, arg) in pos_args.iter().enumerate() {
    +        if i == deser_pos {
    +            continue;
    +        }
    +        if is_ruby_assertion_literal_expected(*arg, bytes) {
    +            return true;
    +        }
    +    }
    +    false
    +}
    +
    +fn ruby_rspec_matcher_bounds_deser(args_node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    let Some(matcher) = args_node.named_child(0) else {
    +        return false;
    +    };
    +    match matcher.kind() {
    +        "identifier" => {
    +            // Bare-name matchers: be_nil, be_truthy, be_falsey, etc.
    +            let Ok(name) = std::str::from_utf8(&bytes[matcher.byte_range()]) else {
    +                return false;
    +            };
    +            is_ruby_rspec_bare_matcher(name)
    +        }
    +        "call" => {
    +            let Some(method) = matcher.child_by_field_name("method") else {
    +                return false;
    +            };
    +            let Ok(name) = std::str::from_utf8(&bytes[method.byte_range()]) else {
    +                return false;
    +            };
    +            let Some(matcher_args) = matcher.child_by_field_name("arguments") else {
    +                return false;
    +            };
    +            match name {
    +                "eq" | "eql" | "equal" | "match_array" | "contain_exactly" => {
    +                    let mut any = false;
    +                    for i in 0..matcher_args.named_child_count() as u32 {
    +                        let Some(c) = matcher_args.named_child(i) else {
    +                            return false;
    +                        };
    +                        if !is_ruby_assertion_literal_expected(c, bytes) {
    +                            return false;
    +                        }
    +                        any = true;
    +                    }
    +                    any
    +                }
    +                "be_a" | "be_an" | "be_kind_of" | "be_instance_of" | "be_a_kind_of" => {
    +                    let Some(c) = matcher_args.named_child(0) else {
    +                        return false;
    +                    };
    +                    is_ruby_type_reference(c)
    +                }
    +                "be" => {
    +                    // `be(LITERAL)` — `be == LIT` shape isn't representable here,
    +                    // accept a single literal arg.
    +                    let Some(c) = matcher_args.named_child(0) else {
    +                        return false;
    +                    };
    +                    is_ruby_assertion_literal_expected(c, bytes)
    +                }
    +                _ => false,
    +            }
    +        }
    +        _ => false,
    +    }
    +}
    +
    +fn is_ruby_minitest_single_arg_bounding_verb(name: &str) -> bool {
    +    matches!(
    +        name,
    +        "assert" | "assert_nil" | "refute" | "refute_nil" | "assert_empty" | "refute_empty"
    +    )
    +}
    +
    +fn is_ruby_minitest_multi_arg_bounding_verb(name: &str) -> bool {
    +    matches!(
    +        name,
    +        "assert_equal"
    +            | "assert_not_equal"
    +            | "refute_equal"
    +            | "assert_in_delta"
    +            | "assert_in_epsilon"
    +            | "assert_includes"
    +            | "refute_includes"
    +            | "assert_match"
    +            | "refute_match"
    +            | "assert_operator"
    +            | "refute_operator"
    +            | "assert_predicate"
    +            | "refute_predicate"
    +            | "assert_same"
    +            | "refute_same"
    +    )
    +}
    +
    +fn is_ruby_rspec_bare_matcher(name: &str) -> bool {
    +    matches!(
    +        name,
    +        "be_nil"
    +            | "be_truthy"
    +            | "be_falsey"
    +            | "be_falsy"
    +            | "be_empty"
    +            | "be_present"
    +            | "be_zero"
    +            | "be_positive"
    +            | "be_negative"
    +    )
    +}
    +
    +fn is_ruby_type_reference(node: tree_sitter::Node) -> bool {
    +    matches!(node.kind(), "constant" | "scope_resolution" | "identifier")
    +}
    +
    +/// Recursive Ruby literal classifier.  Strings count when they have no
    +/// `interpolation` children (`"hello"` literal yes, `"#{x}"` no).
    +/// Symbols, numbers, booleans, `nil`, arrays / hashes (recursive),
    +/// negative numeric unary, and ranges with literal endpoints all
    +/// qualify.
    +fn is_ruby_assertion_literal_expected(node: tree_sitter::Node, bytes: &[u8]) -> bool {
    +    match node.kind() {
    +        "string" => !has_ruby_string_interpolation(node),
    +        "string_array" | "symbol_array" => true,
    +        "integer" | "float" | "true" | "false" | "nil" | "simple_symbol" | "hash_key_symbol"
    +        | "rational" | "complex" | "regex" => true,
    +        "unary" => node
    +            .named_child(0)
    +            .is_some_and(|c| is_ruby_assertion_literal_expected(c, bytes)),
    +        "array" => {
    +            for i in 0..node.named_child_count() as u32 {
    +                let Some(c) = node.named_child(i) else {
    +                    return false;
    +                };
    +                if !is_ruby_assertion_literal_expected(c, bytes) {
    +                    return false;
    +                }
    +            }
    +            true
    +        }
    +        "hash" => {
    +            for i in 0..node.named_child_count() as u32 {
    +                let Some(pair) = node.named_child(i) else {
    +                    return false;
    +                };
    +                if pair.kind() != "pair" {
    +                    return false;
    +                }
    +                let Some(key) = pair.child_by_field_name("key") else {
    +                    return false;
    +                };
    +                let Some(value) = pair.child_by_field_name("value") else {
    +                    return false;
    +                };
    +                if !is_ruby_assertion_literal_expected(key, bytes) {
    +                    return false;
    +                }
    +                if !is_ruby_assertion_literal_expected(value, bytes) {
    +                    return false;
    +                }
    +            }
    +            true
    +        }
    +        "range" => {
    +            for i in 0..node.named_child_count() as u32 {
    +                let Some(c) = node.named_child(i) else {
    +                    return false;
    +                };
    +                if !is_ruby_assertion_literal_expected(c, bytes) {
    +                    return false;
    +                }
    +            }
    +            true
    +        }
    +        _ => false,
    +    }
    +}
    +
    +fn has_ruby_string_interpolation(node: tree_sitter::Node) -> bool {
    +    for i in 0..node.named_child_count() as u32 {
    +        if let Some(c) = node.named_child(i)
    +            && c.kind() == "interpolation"
    +        {
    +            return true;
    +        }
    +    }
    +    false
    +}
    +
     /// C/C++-only Layer D: structural suppression of buffer-overflow pattern
     /// rules when the source / format-string argument is a literal whose
     /// contributed length is statically bounded.
    @@ -3629,15 +5216,18 @@ pub(crate) fn name_is_non_crypto(name: &str) -> bool {
             if prev_pos == 0 {
                 return true;
             }
    -        let prev_char_orig = bytes_orig[prev_pos - 1] as char;
    -        // Word boundary: underscore, digit, etc.
    -        if !prev_char_orig.is_ascii_alphabetic() {
    +        // Word boundary: previous byte is ASCII non-letter (underscore,
    +        // digit, etc.).  Treat non-ASCII (UTF-8 continuation / leading
    +        // bytes) conservatively as part of an identifier letter — no
    +        // boundary — to avoid mis-classifying `ëhash`-style names that
    +        // have no real word break before the suffix.
    +        let prev_byte = bytes_orig[prev_pos - 1];
    +        if prev_byte.is_ascii() && !prev_byte.is_ascii_alphabetic() {
                 return true;
             }
             // CamelCase boundary: suffix starts with an uppercase letter
             // in the original casing (`storageId`, `tableHash`, `sqlMd5`).
    -        let suffix_first_orig = bytes_orig[prev_pos] as char;
    -        if suffix_first_orig.is_ascii_uppercase() {
    +        if bytes_orig[prev_pos].is_ascii_uppercase() {
                 return true;
             }
             // Long stand-alone suffix (≥4 chars) — accept without boundary.
    @@ -4261,6 +5851,22 @@ pub struct FusedResult {
         /// `GlobalSummaries.router_facts_by_module`; pass 2 resolves them
         /// per-file via `GlobalSummaries::resolve_cross_file_router_deps`.
         pub router_facts: Option<(String, auth_analysis::router_facts::PerFileRouterFacts)>,
    +    /// Per-file Phase-09 cross-package import map.  `None` when the
    +    /// file's resolver produced no resolved bindings; otherwise
    +    /// `Some((namespace, map))` where `namespace` is the file's
    +    /// scan-root-relative path (matching `FuncKey::namespace`) and
    +    /// `map` maps each local import binding name (e.g. `escapeHtml`)
    +    /// to the canonical `FuncKey` of the imported function in its
    +    /// own package.  Pass 1 collects these into
    +    /// `GlobalSummaries.cross_package_imports_by_namespace`; pass 2's
    +    /// `inline_analyse_callee` consults the index when an inlined
    +    /// callee body's own `cross_package_imports` Arc is empty (the
    +    /// indexed-mode case where bodies round-trip through SQLite and
    +    /// the Arc field is `#[serde(skip)]`).
    +    pub cross_package_imports: Option<(
    +        String,
    +        std::sync::Arc>,
    +    )>,
     }
     
     /// Parse the file once, build the CFG once, and produce both function
    @@ -4297,6 +5903,7 @@ pub fn analyse_file_fused(
                 ssa_bodies: vec![],
                 auth_summaries: vec![],
                 router_facts: None,
    +            cross_package_imports: None,
             });
         };
     
    @@ -4329,7 +5936,7 @@ pub fn analyse_file_fused(
             crate::taint::ssa_transfer::reset_path_safe_suppressed_spans();
             crate::taint::ssa_transfer::reset_all_validated_spans();
             let (lowered_summaries, lowered_bodies) =
    -            parsed.lower_ssa_for_fused(global_summaries, scan_root);
    +            parsed.lower_ssa_for_fused(global_summaries, scan_root, cfg.module_graph.as_deref());
             out.extend(parsed.run_cfg_analyses_with_lowered(
                 cfg,
                 global_summaries,
    @@ -4444,6 +6051,29 @@ pub fn analyse_file_fused(
         }
         parsed.source.finalize_diags(&mut out, cfg);
     
    +    let cross_package_imports_for_this_file = if parsed.file_cfg.resolved_imports.is_empty() {
    +        None
    +    } else {
    +        let scan_root_str = scan_root.map(|p| p.to_string_lossy());
    +        let ns = crate::symbol::namespace_with_package(
    +            &parsed.source.file_path_str,
    +            scan_root_str.as_deref(),
    +            cfg.module_graph.as_deref(),
    +        );
    +        let caller_lang = Lang::from_slug(parsed.source.lang_slug).unwrap_or(Lang::Rust);
    +        let map = crate::taint::build_cross_package_func_keys(
    +            &parsed.file_cfg.resolved_imports,
    +            scan_root_str.as_deref(),
    +            cfg.module_graph.as_deref(),
    +            caller_lang,
    +        );
    +        if map.is_empty() {
    +            None
    +        } else {
    +            Some((ns, std::sync::Arc::new(map)))
    +        }
    +    };
    +
         Ok(FusedResult {
             summaries,
             diags: out,
    @@ -4452,6 +6082,7 @@ pub fn analyse_file_fused(
             ssa_bodies,
             auth_summaries,
             router_facts: router_facts_for_this_file,
    +        cross_package_imports: cross_package_imports_for_this_file,
         })
     }
     
    @@ -4519,6 +6150,135 @@ fn nonprod_path_detection() {
         assert!(!is_nonprod_path(Path::new("app/views.py")));
     }
     
    +#[test]
    +fn test_file_detection_covers_all_supported_languages() {
    +    // JS / TS — the existing surface, kept as a regression guard.
    +    assert!(is_test_file(Path::new("src/foo.test.js")));
    +    assert!(is_test_file(Path::new("src/foo.test.ts")));
    +    assert!(is_test_file(Path::new("src/foo.spec.tsx")));
    +    assert!(is_test_file(Path::new("src/foo.test.mjs")));
    +    assert!(is_test_file(Path::new("src/__tests__/Component.jsx")));
    +
    +    // Python.
    +    assert!(is_test_file(Path::new("tests/test_login.py")));
    +    assert!(is_test_file(Path::new("project/views_test.py")));
    +    assert!(is_test_file(Path::new("project/tests/conftest.py")));
    +    assert!(is_test_file(Path::new("project/foo_tests.py")));
    +
    +    // Java (JUnit / TestNG).
    +    assert!(is_test_file(Path::new("src/UserTest.java")));
    +    assert!(is_test_file(Path::new("src/UserTests.java")));
    +    assert!(is_test_file(Path::new("src/UserIT.java")));
    +
    +    // PHP (PHPUnit).
    +    assert!(is_test_file(Path::new(
    +        "tests/unit/Gis/GisVisualizationTest.php"
    +    )));
    +
    +    // Ruby (RSpec / Minitest).
    +    assert!(is_test_file(Path::new("spec/widget_spec.rb")));
    +    assert!(is_test_file(Path::new("test/widget_test.rb")));
    +
    +    // Go.
    +    assert!(is_test_file(Path::new("pkg/auth/login_test.go")));
    +
    +    // Rust (uncommon but valid).
    +    assert!(is_test_file(Path::new("src/parser_test.rs")));
    +
    +    // C / C++.
    +    assert!(is_test_file(Path::new("src/auth_test.c")));
    +    assert!(is_test_file(Path::new("src/auth_test.cpp")));
    +    assert!(is_test_file(Path::new("tests/test_main.cc")));
    +
    +    // Production paths must NOT match.
    +    assert!(!is_test_file(Path::new("src/main.rs")));
    +    assert!(!is_test_file(Path::new("src/UserController.java")));
    +    assert!(!is_test_file(Path::new("app/views.py")));
    +    assert!(!is_test_file(Path::new("pkg/auth/login.go")));
    +    assert!(!is_test_file(Path::new("src/handler.go")));
    +    assert!(!is_test_file(Path::new("src/Foo.php")));
    +    assert!(!is_test_file(Path::new("src/Controllers/Operations.php")));
    +}
    +
    +#[test]
    +fn test_suppressible_pattern_covers_cross_language_noise() {
    +    // JS / TS — pre-existing surface, kept as a regression guard.
    +    assert!(is_test_suppressible_pattern("js.crypto.math_random"));
    +    assert!(is_test_suppressible_pattern("ts.crypto.math_random"));
    +    assert!(is_test_suppressible_pattern("js.secrets.hardcoded_secret"));
    +    assert!(is_test_suppressible_pattern("ts.transport.fetch_http"));
    +
    +    // Cross-language extensions added so weak crypto / hardcoded test
    +    // tokens / insecure RNG used as fixture seeds do not surface as
    +    // findings inside test modules.
    +    assert!(is_test_suppressible_pattern("php.crypto.md5"));
    +    assert!(is_test_suppressible_pattern("php.crypto.sha1"));
    +    assert!(is_test_suppressible_pattern("php.crypto.rand"));
    +    assert!(is_test_suppressible_pattern("py.crypto.md5"));
    +    assert!(is_test_suppressible_pattern("py.crypto.sha1"));
    +    assert!(is_test_suppressible_pattern("rb.crypto.md5"));
    +    assert!(is_test_suppressible_pattern("go.crypto.md5"));
    +    assert!(is_test_suppressible_pattern("go.crypto.sha1"));
    +    assert!(is_test_suppressible_pattern("go.secrets.hardcoded_key"));
    +    assert!(is_test_suppressible_pattern("java.crypto.weak_digest"));
    +    assert!(is_test_suppressible_pattern("java.crypto.insecure_random"));
    +
    +    // Other security-relevant patterns must NOT be suppressed in tests:
    +    // they capture real attack surface that test fixtures themselves can
    +    // demonstrate (deserialization, command injection, taint flows).
    +    assert!(!is_test_suppressible_pattern("php.deser.unserialize"));
    +    assert!(!is_test_suppressible_pattern("py.deser.pickle_loads"));
    +    assert!(!is_test_suppressible_pattern("php.cmdi.system"));
    +    assert!(!is_test_suppressible_pattern("taint-unsanitised-flow"));
    +    assert!(!is_test_suppressible_pattern("cfg-unguarded-sink"));
    +}
    +
    +#[test]
    +fn vendored_asset_path_detection() {
    +    // Minified bundle filename markers always trigger.
    +    assert!(is_vendored_asset_path(Path::new(
    +        "src/main/webapp/scripts/jquery-ui.custom.min.js"
    +    )));
    +    assert!(is_vendored_asset_path(Path::new("core/assets/htmx.min.js")));
    +    assert!(is_vendored_asset_path(Path::new("public/app.bundle.js")));
    +    assert!(is_vendored_asset_path(Path::new(
    +        "dist/transliteration.umd.min.js"
    +    )));
    +    assert!(is_vendored_asset_path(Path::new("dist/lib.iife.js")));
    +    assert!(is_vendored_asset_path(Path::new("css/site.min.css")));
    +
    +    // Path-component triggers: bower_components is unambiguous.
    +    assert!(is_vendored_asset_path(Path::new(
    +        "bower_components/lodash/lodash.js"
    +    )));
    +
    +    // `vendor/` triggers only for front-end asset extensions, so Go module
    +    // vendoring under `vendor/` keeps being scanned.
    +    assert!(is_vendored_asset_path(Path::new(
    +        "core/assets/vendor/jquery/jquery.js"
    +    )));
    +    assert!(is_vendored_asset_path(Path::new("src/vendors/foo/lib.css")));
    +    assert!(!is_vendored_asset_path(Path::new(
    +        "vendor/github.com/foo/bar/lib.go"
    +    )));
    +    assert!(!is_vendored_asset_path(Path::new(
    +        "vendor/github.com/foo/bar/lib.rs"
    +    )));
    +
    +    // Hand-authored production paths must NOT match.
    +    assert!(!is_vendored_asset_path(Path::new("src/main.js")));
    +    assert!(!is_vendored_asset_path(Path::new(
    +        "app/components/Button.tsx"
    +    )));
    +    assert!(!is_vendored_asset_path(Path::new("lib/handler.py")));
    +    // Plain `.js` outside vendor/bower with no `.min` suffix stays in scope
    +    // even when the directory hints at third-party origin; the engine's
    +    // existing `is_nonprod_path` downgrade still fires for those.
    +    assert!(!is_vendored_asset_path(Path::new(
    +        "webapp/WEB-INF/view/scripts/jquery-ui/jquery-ui-timepicker-addon.js"
    +    )));
    +}
    +
     #[test]
     fn severity_downgrade_works() {
         assert_eq!(downgrade_severity(Severity::High), Severity::Medium);
    @@ -4583,7 +6343,7 @@ fn constant_arg_suppression_works() {
             let m = matches.next().expect("query should match");
             let cap = m.captures.iter().find(|c| c.index == 0).unwrap();
             assert!(
    -            is_call_all_args_literal(cap.node, code),
    +            is_call_all_args_literal(cap.node, code, "php"),
                 "PHP system(\"echo health-ok\") should have all-literal args"
             );
         }
    @@ -4606,7 +6366,7 @@ fn constant_arg_suppression_works() {
             let m = matches.next().expect("query should match");
             let cap = m.captures.iter().find(|c| c.index == 0).unwrap();
             assert!(
    -            is_call_all_args_literal(cap.node, code),
    +            is_call_all_args_literal(cap.node, code, "python"),
                 "Python os.system(\"echo health-ok\") should have all-literal args"
             );
         }
    @@ -4629,10 +6389,103 @@ fn constant_arg_suppression_works() {
             let m = matches.next().expect("query should match");
             let cap = m.captures.iter().find(|c| c.index == 0).unwrap();
             assert!(
    -            !is_call_all_args_literal(cap.node, code),
    +            !is_call_all_args_literal(cap.node, code, "python"),
                 "Python os.system(cmd) should NOT have all-literal args"
             );
         }
    +
    +    // Python: os.system(DEFAULT_CMD) with module-level `DEFAULT_CMD = "ls -la"`
    +    // should be suppressed via the file-level scalar binding map.
    +    {
    +        let mut parser = tree_sitter::Parser::new();
    +        let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
    +        parser.set_language(&lang).unwrap();
    +        let code = b"import os\nDEFAULT_CMD = \"ls -la\"\nos.system(DEFAULT_CMD)\n";
    +        let tree = parser.parse(code, None).unwrap();
    +        let query_str = r#"(call
    +            function: (attribute
    +                object: (identifier) @pkg (#eq? @pkg "os")
    +                attribute: (identifier) @fn (#eq? @fn "system")))
    +            @vuln"#;
    +        let query = tree_sitter::Query::new(&lang, query_str).unwrap();
    +        let mut cursor = tree_sitter::QueryCursor::new();
    +        let mut matches = cursor.matches(&query, tree.root_node(), code.as_slice());
    +        let m = matches.next().expect("query should match");
    +        let cap = m.captures.iter().find(|c| c.index == 0).unwrap();
    +        assert!(
    +            is_call_all_args_literal(cap.node, code, "python"),
    +            "os.system(DEFAULT_CMD) with module-level scalar should be suppressed"
    +        );
    +    }
    +
    +    // Go: db.Exec(DriverName) with package-level `const DriverName = "postgres"`
    +    // should be suppressed via the file-level scalar binding map.
    +    {
    +        let mut parser = tree_sitter::Parser::new();
    +        let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
    +        parser.set_language(&lang).unwrap();
    +        let code = b"package main\nconst DriverName = \"postgres\"\nfunc f(db Db) { db.Exec(DriverName) }\n";
    +        let tree = parser.parse(code, None).unwrap();
    +        let query_str = r#"(call_expression
    +            function: (selector_expression
    +                field: (field_identifier) @m (#eq? @m "Exec")))
    +            @vuln"#;
    +        let query = tree_sitter::Query::new(&lang, query_str).unwrap();
    +        let mut cursor = tree_sitter::QueryCursor::new();
    +        let mut matches = cursor.matches(&query, tree.root_node(), code.as_slice());
    +        let m = matches.next().expect("query should match");
    +        let cap = m.captures.iter().find(|c| c.index == 0).unwrap();
    +        assert!(
    +            is_call_all_args_literal(cap.node, code, "go"),
    +            "db.Exec(DriverName) with package-level const should be suppressed"
    +        );
    +    }
    +}
    +
    +/// Helper that runs a tree-sitter query against Python source and
    +/// returns the first capture-0 node, panicking if no match is found.
    +/// Used by the Python suppression tests below.
    +#[cfg(test)]
    +fn first_python_capture<'tree>(
    +    tree: &'tree tree_sitter::Tree,
    +    code: &[u8],
    +    query_str: &str,
    +) -> tree_sitter::Node<'tree> {
    +    use tree_sitter::StreamingIterator;
    +    let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
    +    let query = tree_sitter::Query::new(&lang, query_str).expect("query compiles");
    +    let mut cursor = tree_sitter::QueryCursor::new();
    +    let mut matches = cursor.matches(&query, tree.root_node(), code);
    +    let m = matches.next().expect("query should match");
    +    let cap = m
    +        .captures
    +        .iter()
    +        .find(|c| c.index == 0)
    +        .expect("capture index 0");
    +    cap.node
    +}
    +
    +/// Helper that runs a tree-sitter query against Ruby source and returns
    +/// the first capture-0 node, panicking if no match is found.  Used by
    +/// the Ruby suppression tests below.
    +#[cfg(test)]
    +fn first_ruby_capture<'tree>(
    +    tree: &'tree tree_sitter::Tree,
    +    code: &[u8],
    +    query_str: &str,
    +) -> tree_sitter::Node<'tree> {
    +    use tree_sitter::StreamingIterator;
    +    let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
    +    let query = tree_sitter::Query::new(&lang, query_str).expect("query compiles");
    +    let mut cursor = tree_sitter::QueryCursor::new();
    +    let mut matches = cursor.matches(&query, tree.root_node(), code);
    +    let m = matches.next().expect("query should match");
    +    let cap = m
    +        .captures
    +        .iter()
    +        .find(|c| c.index == 0)
    +        .expect("capture index 0");
    +    cap.node
     }
     
     /// Helper that runs a tree-sitter query against PHP source and returns the
    @@ -4868,6 +6721,609 @@ fn php_unserialize_magic_method_passthrough_recognises_serializable_contract() {
         );
     }
     
    +#[test]
    +fn php_unserialize_inside_phpunit_assertion_recognises_roundtrip_shapes() {
    +    let mut parser = tree_sitter::Parser::new();
    +    let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
    +    parser.set_language(&lang).unwrap();
    +    let q = r#"(function_call_expression function: (name) @n (#eq? @n "unserialize")) @vuln"#;
    +
    +    // Canonical assertSame with array literal expected.
    +    let code = b"assertSame(['a' => 1], unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "assertSame(literal array, unserialize($x)) should be suppressed"
    +    );
    +
    +    // assertEquals with scalar string expected.
    +    let code =
    +        b"assertEquals('hello', unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "assertEquals(literal string, unserialize($x)) should be suppressed"
    +    );
    +
    +    // Static dispatch: static::assertSame(...).
    +    let code =
    +        b"assertNull(unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "assertNull(unserialize($x)) should be suppressed (verb bounds the result)"
    +    );
    +
    +    // Single-arg verb: assertIsArray(unserialize($x)).
    +    let code =
    +        b"assertIsArray(unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "assertIsArray(unserialize($x)) should be suppressed"
    +    );
    +
    +    // Case-insensitive method name (PHP semantics).
    +    let code =
    +        b"AssertSame(['z'], unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "method name should match case-insensitively"
    +    );
    +
    +    // Free function `unserialize` outside any assertion: keep firing.
    +    let code = b"assertEquals($e, unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        !is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "assertEquals($computed, unserialize($x)) should NOT be suppressed"
    +    );
    +
    +    // Single-arg unrecognised assertion verb keeps firing.
    +    let code = b"assertSomethingCustom(unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        !is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "1-arg unknown assertion verb should NOT be suppressed"
    +    );
    +
    +    // Wrapping in another expression (binary, ternary) breaks the
    +    // bound — unserialize is no longer the direct argument.  Conservative.
    +    let code = b"assertSame(['x'], unserialize($b) ?: []); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        !is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "wrapped (ternary) unserialize argument should NOT be suppressed"
    +    );
    +
    +    // Method call whose name does NOT start with `assert` keeps firing.
    +    let code = b"log(['x'], unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        !is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "non-assert method should NOT be suppressed"
    +    );
    +
    +    // First arg is a literal but it's a single-arg call (no actual) — defensive.
    +    let code = b"assertSame(unserialize($b)); } }\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_php_capture(&tree, code, q);
    +    assert!(
    +        !is_php_unserialize_inside_phpunit_assertion(cap, code),
    +        "single-arg `assertSame(unserialize($x))` should NOT be suppressed (no expected)"
    +    );
    +}
    +
    +#[test]
    +fn python_deser_inside_unittest_assertion_recognises_roundtrip_shapes() {
    +    let mut parser = tree_sitter::Parser::new();
    +    let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
    +    parser.set_language(&lang).unwrap();
    +    // Pickle pattern equivalent: capture the `pickle` identifier under
    +    // the deser call's `function.object` path.
    +    let q = r#"(call function: (attribute object: (identifier) @pkg (#eq? @pkg "pickle") attribute: (identifier) @fn (#match? @fn "^loads?$"))) @vuln"#;
    +
    +    // Canonical assertEqual with dict literal expected.
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.assertEqual({'a': 1}, pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertEqual(dict literal, pickle.loads(b)) should be suppressed"
    +    );
    +
    +    // assertEquals with list literal expected.
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.assertEquals([1, 2, 3], pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertEquals(list literal, pickle.loads(b)) should be suppressed"
    +    );
    +
    +    // pytest-style ordering: deser first, literal second.
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.assertEqual(pickle.loads(b), {'k': 'v'})\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertEqual(pickle.loads(b), dict literal) should be suppressed"
    +    );
    +
    +    // Unary negative literal.
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.assertEqual(-7, pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertEqual(unary-negative literal, pickle.loads(b)) should be suppressed"
    +    );
    +
    +    // Single-arg verb: assertIsNone.
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.assertIsNone(pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertIsNone(pickle.loads(b)) should be suppressed (verb bounds)"
    +    );
    +
    +    // Single-arg verb: assertTrue.
    +    let code =
    +        b"import pickle\nclass T:\n    def t(self, b):\n        self.assertTrue(pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertTrue(pickle.loads(b)) should be suppressed (verb bounds)"
    +    );
    +
    +    // assertIsInstance(value, type).
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.assertIsInstance(pickle.loads(b), dict)\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertIsInstance(pickle.loads(b), dict) should be suppressed (type bounds)"
    +    );
    +
    +    // msg=... kwarg: keep firing? actually no, msg is just informational; bound is satisfied.
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.assertEqual([1], pickle.loads(b), msg='preserve')\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "msg= kwarg should not break the literal-positional bound"
    +    );
    +
    +    // Free function shape (`from pickle import loads`) covered via leaf-
    +    // name match.  Use a different query that captures the identifier
    +    // call shape.
    +    let code_ff = b"from pickle import loads\nclass T:\n    def t(self, b):\n        self.assertEqual([1], loads(b))\n";
    +    let tree = parser.parse(code_ff, None).unwrap();
    +    // For free-function calls, use a query matching the bare identifier callee.
    +    let q2 = r#"(call function: (identifier) @fn (#match? @fn "^loads?$")) @vuln"#;
    +    let cap = first_python_capture(&tree, code_ff, q2);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code_ff),
    +        "assertEqual(literal, loads(b)) for `from pickle import loads` should be suppressed"
    +    );
    +
    +    // Production call (no assertion wrap) keeps firing.
    +    let code = b"import pickle\ndef handler(blob):\n    return pickle.loads(blob)\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "production pickle.loads should NOT be suppressed"
    +    );
    +
    +    // Non-literal expected ($computed) keeps firing.
    +    let code = b"import pickle\nclass T:\n    def t(self, b, expected):\n        self.assertEqual(expected, pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertEqual(non-literal, pickle.loads(b)) should NOT be suppressed"
    +    );
    +
    +    // Non-assert verb keeps firing.
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.checkEqual([1], pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "checkEqual (non-assert verb) should NOT be suppressed"
    +    );
    +
    +    // Wrapped in ternary: bound is broken.
    +    let code = b"import pickle\nclass T:\n    def t(self, b, c):\n        self.assertEqual([1], pickle.loads(b) if c else [])\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "ternary wrapping pickle.loads should NOT be suppressed"
    +    );
    +
    +    // assertCustom (unrecognised single-arg verb) keeps firing.
    +    let code = b"import pickle\nclass T:\n    def t(self, b):\n        self.assertCustomCheck(pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "assertCustomCheck single-arg should NOT be suppressed (verb not in bounding set)"
    +    );
    +
    +    // assertEqual where both args are non-literal keeps firing.
    +    let code = b"import pickle\nclass T:\n    def t(self, b, e):\n        self.assertEqual(e, pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "two non-literal positional args should NOT be suppressed"
    +    );
    +
    +    // f-string expected (interpolation) keeps firing.
    +    let code = b"import pickle\nclass T:\n    def t(self, b, x):\n        self.assertEqual(f'pre-{x}', pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "f-string expected (interpolation) should NOT be suppressed"
    +    );
    +}
    +
    +/// Pytest plain-`assert` round-trip recogniser invariants.  Same
    +/// entry point as the unittest test above (the function handles both
    +/// idioms) but the asserted shape sits under an `assert_statement`
    +/// instead of a `unittest.TestCase` method call.
    +#[test]
    +fn python_deser_inside_pytest_assert_recognises_roundtrip_shapes() {
    +    let mut parser = tree_sitter::Parser::new();
    +    let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
    +    parser.set_language(&lang).unwrap();
    +    let q = r#"(call function: (attribute object: (identifier) @pkg (#eq? @pkg "pickle") attribute: (identifier) @fn (#match? @fn "^loads?$"))) @vuln"#;
    +
    +    // assert deser == LITERAL
    +    let code = b"import pickle\ndef t(b):\n    assert pickle.loads(b) == [1, 2, 3]\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert deser == [literal] should be suppressed"
    +    );
    +
    +    // assert deser is None
    +    let code = b"import pickle\ndef t(b):\n    assert pickle.loads(b) is None\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert deser is None should be suppressed"
    +    );
    +
    +    // assert deser in [LITERAL, ...]
    +    let code = b"import pickle\ndef t(b):\n    assert pickle.loads(b) in [1, 2, 3]\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert deser in [literal] should be suppressed"
    +    );
    +
    +    // assert deser  (truthy bare)
    +    let code = b"import pickle\ndef t(b):\n    assert pickle.loads(b)\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert deser (truthy bare) should be suppressed"
    +    );
    +
    +    // assert not deser
    +    let code = b"import pickle\ndef t(b):\n    assert not pickle.loads(b)\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert not deser should be suppressed"
    +    );
    +
    +    // assert isinstance(deser, dict)
    +    let code = b"import pickle\ndef t(b):\n    assert isinstance(pickle.loads(b), dict)\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert isinstance(deser, dict) should be suppressed"
    +    );
    +
    +    // assert (deser == LITERAL) — paren wrap.
    +    let code = b"import pickle\ndef t(b):\n    assert (pickle.loads(b) == [1])\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert (deser == literal) with paren wrap should be suppressed"
    +    );
    +
    +    // assert deser == LITERAL, "msg"
    +    let code = b"import pickle\ndef t(b):\n    assert pickle.loads(b) == 1, 'round trip'\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert deser == literal, msg should be suppressed (msg is named_child(1))"
    +    );
    +
    +    // assert bool(deser)
    +    let code = b"import pickle\ndef t(b):\n    assert bool(pickle.loads(b))\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert bool(deser) should be suppressed"
    +    );
    +
    +    // assert len(deser) == 3
    +    let code = b"import pickle\ndef t(b):\n    assert len(pickle.loads(b)) == 3\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert len(deser) == int_literal should be suppressed"
    +    );
    +
    +    // Negatives ----------------------------------------------------------
    +
    +    // assert deser and X — boolean op short-circuits, can run side effect.
    +    let code = b"import pickle\ndef t(b, x):\n    assert pickle.loads(b) and x\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert deser and X (boolean op) should NOT be suppressed"
    +    );
    +
    +    // assert deser if cond else X — conditional short-circuits.
    +    let code = b"import pickle\ndef t(b, c):\n    assert (pickle.loads(b) if c else 0)\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert (deser if c else x) should NOT be suppressed"
    +    );
    +
    +    // assert wrapper(deser) == LITERAL — arbitrary user fn breaks bound.
    +    let code = b"import pickle\ndef t(b):\n    assert wrapper(pickle.loads(b)) == [1]\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert wrapper(deser) == literal should NOT be suppressed"
    +    );
    +
    +    // assert deser == non-literal — bound depends on dynamic var.
    +    let code = b"import pickle\ndef t(b, e):\n    assert pickle.loads(b) == e\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert deser == non_literal should NOT be suppressed"
    +    );
    +
    +    // assert isinstance(deser, type_var) where type is dynamic.
    +    let code = b"import pickle\ndef t(b):\n    t = some_type_factory()\n    assert isinstance(pickle.loads(b), t)\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    // `t` is an `identifier` and `is_python_type_reference` accepts
    +    // identifier (assertIsInstance treats user-class identifiers as
    +    // type references), so this case stays suppressed.  Pinned to
    +    // document the matching behaviour rather than tighten it.
    +    assert!(
    +        is_python_deser_inside_unittest_assertion(cap, code),
    +        "assert isinstance(deser, identifier) treats identifier as type ref"
    +    );
    +
    +    // Production assignment-then-assert: deser sits in `actual = pickle.loads(b)`,
    +    // not under the assert.  Must keep firing.
    +    let code =
    +        b"import pickle\ndef t(b):\n    actual = pickle.loads(b)\n    assert actual == [1]\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_python_capture(&tree, code, q);
    +    assert!(
    +        !is_python_deser_inside_unittest_assertion(cap, code),
    +        "deser bound to a name then asserted should NOT be suppressed (assignment context)"
    +    );
    +}
    +
    +/// Ruby Layer C5 invariants.  The recogniser must accept Minitest
    +/// `assert_*`/`refute_*` shapes, RSpec `expect(_).to MATCHER` shapes,
    +/// and reject production calls / dynamic-expected / unrelated wrappers.
    +#[test]
    +fn ruby_deser_inside_test_assertion_recognises_roundtrip_shapes() {
    +    let mut parser = tree_sitter::Parser::new();
    +    let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
    +    parser.set_language(&lang).unwrap();
    +    // Capture the `Marshal` constant under the deser call's `receiver` field.
    +    let q = r#"(call receiver: (constant) @recv (#eq? @recv "Marshal") method: (identifier) @m (#eq? @m "load")) @vuln"#;
    +
    +    // Minitest assert_equal LITERAL, deser
    +    let code = b"class T\n  def t(b)\n    assert_equal [1, 2, 3], Marshal.load(b)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "assert_equal [literal], Marshal.load(b) should be suppressed"
    +    );
    +
    +    // Minitest assert_nil
    +    let code = b"class T\n  def t(b)\n    assert_nil Marshal.load(b)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "assert_nil Marshal.load(b) should be suppressed"
    +    );
    +
    +    // Minitest single-arg truthy assert
    +    let code = b"class T\n  def t(b)\n    assert Marshal.load(b)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "assert Marshal.load(b) (truthy) should be suppressed"
    +    );
    +
    +    // Minitest assert_kind_of TYPE, deser
    +    let code = b"class T\n  def t(b)\n    assert_kind_of Array, Marshal.load(b)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "assert_kind_of TYPE, deser should be suppressed"
    +    );
    +
    +    // Minitest refute_equal
    +    let code = b"class T\n  def t(b)\n    refute_equal [9, 9], Marshal.load(b)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "refute_equal [literal], deser should be suppressed"
    +    );
    +
    +    // RSpec expect(deser).to eq(LITERAL)
    +    let code =
    +        b"describe X do\n  it 'x' do\n    expect(Marshal.load(b)).to eq([1, 2, 3])\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "expect(deser).to eq([literal]) should be suppressed"
    +    );
    +
    +    // RSpec expect(deser).to be_nil
    +    let code = b"describe X do\n  it 'x' do\n    expect(Marshal.load(b)).to be_nil\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "expect(deser).to be_nil should be suppressed"
    +    );
    +
    +    // RSpec expect(deser).to be_a(TYPE)
    +    let code =
    +        b"describe X do\n  it 'x' do\n    expect(Marshal.load(b)).to be_a(Array)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "expect(deser).to be_a(TYPE) should be suppressed"
    +    );
    +
    +    // RSpec not_to be_nil
    +    let code =
    +        b"describe X do\n  it 'x' do\n    expect(Marshal.load(b)).not_to be_nil\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        is_ruby_deser_inside_test_assertion(cap, code),
    +        "expect(deser).not_to be_nil should be suppressed"
    +    );
    +
    +    // Negatives ----------------------------------------------------------
    +
    +    // Production call (no assertion) keeps firing.
    +    let code = b"def handler(blob)\n  Marshal.load(blob)\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        !is_ruby_deser_inside_test_assertion(cap, code),
    +        "production Marshal.load should NOT be suppressed"
    +    );
    +
    +    // assert_equal with dynamic expected keeps firing.
    +    let code =
    +        b"class T\n  def t(b, expected)\n    assert_equal expected, Marshal.load(b)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        !is_ruby_deser_inside_test_assertion(cap, code),
    +        "assert_equal non_literal, deser should NOT be suppressed"
    +    );
    +
    +    // RSpec expect(deser).to eq(dynamic) keeps firing.
    +    let code =
    +        b"describe X do\n  it 'x' do\n    expect(Marshal.load(b)).to eq(expected)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        !is_ruby_deser_inside_test_assertion(cap, code),
    +        "expect(deser).to eq(non_literal) should NOT be suppressed"
    +    );
    +
    +    // Custom unrecognised verb (not in the bounding sets) keeps firing.
    +    let code = b"class T\n  def t(b)\n    custom_check Marshal.load(b)\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        !is_ruby_deser_inside_test_assertion(cap, code),
    +        "non-assertion-verb wrap should NOT be suppressed"
    +    );
    +
    +    // RSpec .should == LIT (old-style, parses as `binary`, not the
    +    // expected receiver-method-arguments shape) keeps firing.
    +    let code = b"describe X do\n  it 'x' do\n    Marshal.load(b).should == [1]\n  end\nend\n";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_ruby_capture(&tree, code, q);
    +    assert!(
    +        !is_ruby_deser_inside_test_assertion(cap, code),
    +        "old-style .should == LIT should NOT be suppressed"
    +    );
    +}
    +
     #[test]
     fn php_weak_hash_non_crypto_use_recognises_canonical_shapes() {
         let mut parser = tree_sitter::Parser::new();
    @@ -5052,6 +7508,17 @@ fn name_is_non_crypto_recognises_word_boundary_suffixes() {
         assert!(!name_is_non_crypto("result"));
         assert!(!name_is_non_crypto("output"));
         assert!(!name_is_non_crypto(""));
    +
    +    // Non-ASCII before a short suffix should NOT be treated as a word
    +    // boundary (no false-positive classification on identifiers like
    +    // `tëhash` whose previous char is a Unicode letter, not punctuation).
    +    assert!(!name_is_non_crypto("tëid"));
    +    // Non-ASCII before a long (≥4) suffix still classifies via the
    +    // length fallback, matching the `columnnameshashes` shape.
    +    assert!(name_is_non_crypto("tëhash"));
    +    // Non-ASCII before a real underscore-prefixed suffix continues to
    +    // classify via the underscore boundary.
    +    assert!(name_is_non_crypto("tablë_id"));
     }
     
     #[test]
    @@ -5459,3 +7926,83 @@ fn is_literal_node_rejects_python_fstring_with_interpolation() {
             "plain string literal must be classified as literal"
         );
     }
    +
    +#[cfg(test)]
    +fn first_java_capture<'tree>(
    +    tree: &'tree tree_sitter::Tree,
    +    code: &[u8],
    +    query_str: &str,
    +) -> tree_sitter::Node<'tree> {
    +    use tree_sitter::StreamingIterator;
    +    let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
    +    let query = tree_sitter::Query::new(&lang, query_str).expect("query compiles");
    +    let mut cursor = tree_sitter::QueryCursor::new();
    +    let mut matches = cursor.matches(&query, tree.root_node(), code);
    +    let m = matches.next().expect("query should match");
    +    m.captures
    +        .iter()
    +        .find(|c| c.index == 0)
    +        .expect("capture index 0")
    +        .node
    +}
    +
    +#[test]
    +fn is_call_all_args_literal_recognises_java_call_kinds() {
    +    let mut parser = tree_sitter::Parser::new();
    +    let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
    +    parser.set_language(&lang).unwrap();
    +
    +    // method_invocation with literal arg, Layer A must suppress.
    +    let code = b"class T { void f() throws Exception { Class.forName(\"com.foo.Bar\"); } }";
    +    let tree = parser.parse(code, None).unwrap();
    +    let q = r#"(method_invocation
    +                 object: (identifier) @c (#eq? @c "Class")
    +                 name: (identifier) @id (#eq? @id "forName"))
    +               @vuln"#;
    +    let cap = first_java_capture(&tree, code, q);
    +    assert!(
    +        is_call_all_args_literal(cap, code, "java"),
    +        "method_invocation with literal arg must trigger Layer A suppression"
    +    );
    +
    +    // method_invocation with class-constant arg, Layer A must suppress
    +    // via the file-level scalar-binding lookup (session 0014/0015).
    +    let code = b"class T {\n  private static final String D = \"com.foo.Bar\";\n  void f() throws Exception { Class.forName(D); }\n}";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_java_capture(&tree, code, q);
    +    assert!(
    +        is_call_all_args_literal(cap, code, "java"),
    +        "method_invocation with class-const arg must trigger Layer A suppression"
    +    );
    +
    +    // method_invocation with parameter arg, Layer A must NOT suppress.
    +    let code = b"class T { void f(String s) throws Exception { Class.forName(s); } }";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_java_capture(&tree, code, q);
    +    assert!(
    +        !is_call_all_args_literal(cap, code, "java"),
    +        "method_invocation with non-literal arg must NOT trigger Layer A suppression"
    +    );
    +
    +    // object_creation_expression with empty args (`new Yaml()` shape).
    +    // `has_any_arg` stays false so the gate also returns false: empty
    +    // arg lists do not satisfy "all args are literal" (arg-less calls
    +    // can still carry side-effect risk via the constructor itself).
    +    let code = b"class T { Object f() { return new Object(); } }";
    +    let tree = parser.parse(code, None).unwrap();
    +    let q = r#"(object_creation_expression) @vuln"#;
    +    let cap = first_java_capture(&tree, code, q);
    +    assert!(
    +        !is_call_all_args_literal(cap, code, "java"),
    +        "object_creation_expression with empty args must NOT trigger Layer A"
    +    );
    +
    +    // object_creation_expression with literal arg, must suppress.
    +    let code = b"class T { Object f() { return new String(\"literal\"); } }";
    +    let tree = parser.parse(code, None).unwrap();
    +    let cap = first_java_capture(&tree, code, q);
    +    assert!(
    +        is_call_all_args_literal(cap, code, "java"),
    +        "object_creation_expression with literal arg must trigger Layer A"
    +    );
    +}
    diff --git a/src/auth_analysis/checks.rs b/src/auth_analysis/checks.rs
    index 86628ac9..3a122eed 100644
    --- a/src/auth_analysis/checks.rs
    +++ b/src/auth_analysis/checks.rs
    @@ -90,6 +90,13 @@ fn check_ownership_gaps(
                 if op.sink_class.is_some_and(|c| !c.is_auth_relevant()) {
                     continue;
                 }
    +            // NextAuth callbacks are themselves the authentication
    +            // boundary, both reads and mutations inside them operate on
    +            // identity context, so suppress regardless of op kind.
    +            // Other auth helpers stay read-only-suppressed.
    +            if is_nextauth_callback_unit(unit) {
    +                continue;
    +            }
                 if op.kind == OperationKind::Read && unit_is_auth_helper(unit) {
                     continue;
                 }
    @@ -105,6 +112,40 @@ fn check_ownership_gaps(
                     if is_delegated_read_with_actor_context(unit, op, &relevant_subjects) {
                         continue;
                     }
    +                // Owner-equality scoping: when the same call composes a
    +                // foreign-id subject with an actor-context subject (e.g.
    +                // `db.findFirst({where: {id: input.id, userId: ctx.user.id}})`
    +                // in a TRPC handler), the actor pin tenant-scopes the
    +                // query to the authenticated user.  The relevant_subjects
    +                // filter has already excluded actor-context entries; if
    +                // the unfiltered op.subjects still carries an
    +                // actor-context subject, the missing co-binding is the
    +                // owner-eq witness.
    +                //
    +                // `is_actor_context_subject` is constrained: it only
    +                // accepts subjects whose base is in
    +                // `is_self_scoped_session_base` (`req.user`,
    +                // `ctx.session.user`, etc.) OR in the per-unit
    +                // `self_scoped_session_bases` set populated by the
    +                // typed-extractor pre-pass (TRPC alias matches,
    +                // NextAuth callback formals).  Generic `user.id` /
    +                // `me.id` does not qualify, so unrelated co-occurrences
    +                // do not over-suppress.
    +                //
    +                // Trade-off: a privesc-via-`data` shape like
    +                // `db.update({where: {id: input.id}, data: {ownerId: ctx.user.id}})`
    +                // would also be suppressed because both subjects appear
    +                // at the call site without arg-position info.  That
    +                // pattern is rare and would need its own rule.  The
    +                // owner-eq common case removes ~70 cal.com FPs and
    +                // matches the canonical Express / TRPC scoping idiom.
    +                let has_actor_co_subject = op
    +                    .subjects
    +                    .iter()
    +                    .any(|s| is_actor_context_subject(s, unit));
    +                if has_actor_co_subject {
    +                    continue;
    +                }
                     if !has_prior_subject_auth(unit, op, &relevant_subjects) {
                         findings.push(AuthFinding {
                             rule_id: rules.rule_id("missing_ownership_check"),
    @@ -879,7 +920,7 @@ fn unit_is_auth_helper(unit: &AnalysisUnit) -> bool {
             .filter(|c| c.is_ascii_alphanumeric())
             .map(|c| c.to_ascii_lowercase())
             .collect();
    -    (normalized.starts_with("has")
    +    if (normalized.starts_with("has")
             || normalized.starts_with("check")
             || normalized.starts_with("require")
             || normalized.starts_with("verify")
    @@ -891,6 +932,62 @@ fn unit_is_auth_helper(unit: &AnalysisUnit) -> bool {
                 || normalized.contains("access")
                 || normalized.contains("permission")
                 || normalized.contains("authoriz"))
    +    {
    +        return true;
    +    }
    +    is_nextauth_callback_unit(unit)
    +}
    +
    +/// True when this unit IS, or LEXICALLY CONTAINS, a NextAuth
    +/// (next-auth) callback definition.
    +///
    +/// Two shapes are recognised:
    +///   * A unit whose name is `signIn` / `session` / `jwt` / `redirect` /
    +///     `authorize` / `authorized` AND whose destructured params include
    +///     a canonical NextAuth formal (`user` / `token` / `account` /
    +///     `profile` / `credentials` / `session` / `trigger`).  Matches the
    +///     flat `export const authOptions = { callbacks: { ... } }` shape
    +///     where the top-level unit-creation pass walks into the object
    +///     literal and produces one unit per method.
    +///   * A unit whose body contains an object literal with a
    +///     `callbacks: { ... }` property naming at least one NextAuth
    +///     callback (set by `body_returns_nextauth_options` at extract
    +///     time).  Matches the `export const getOptions = (...) =>
    +///     ({ callbacks: { ... } })` shape where the inner callback
    +///     methods do not become their own units — operations from their
    +///     bodies get accumulated under the outer arrow's unit, so the
    +///     outer unit's name (`getOptions`) is the only handle the
    +///     suppressor can latch onto.
    +///
    +/// NextAuth callbacks ARE the authentication boundary; operations on
    +/// `user.id` / `existingUser.id` inside them resolve the authenticated
    +/// identity, they do not look up a tenant-scoped resource based on
    +/// untrusted input.
    +fn is_nextauth_callback_unit(unit: &AnalysisUnit) -> bool {
    +    if unit.is_nextauth_options_factory {
    +        return true;
    +    }
    +    let Some(name) = unit.name.as_deref() else {
    +        return false;
    +    };
    +    if !matches!(
    +        name,
    +        "signIn" | "session" | "jwt" | "redirect" | "authorize" | "authorized"
    +    ) {
    +        return false;
    +    }
    +    const SIGNAL_PARAMS: &[&str] = &[
    +        "user",
    +        "token",
    +        "account",
    +        "profile",
    +        "credentials",
    +        "session",
    +        "trigger",
    +    ];
    +    unit.params
    +        .iter()
    +        .any(|p| SIGNAL_PARAMS.contains(&p.as_str()))
     }
     
     fn is_delegated_read_with_actor_context(
    @@ -1118,6 +1215,7 @@ mod tests {
                 typed_bounded_vars: HashSet::new(),
                 typed_bounded_dto_fields: HashMap::new(),
                 self_scoped_session_bases: HashSet::new(),
    +            is_nextauth_options_factory: false,
             }
         }
     
    diff --git a/src/auth_analysis/extract/common.rs b/src/auth_analysis/extract/common.rs
    index 5630c2aa..a00b1335 100644
    --- a/src/auth_analysis/extract/common.rs
    +++ b/src/auth_analysis/extract/common.rs
    @@ -712,6 +712,8 @@ pub fn build_function_unit_with_meta(
             .cloned()
             .collect();
     
    +    let is_nextauth_options_factory = body_returns_nextauth_options(node, bytes);
    +
         AnalysisUnit {
             kind,
             name,
    @@ -734,9 +736,207 @@ pub fn build_function_unit_with_meta(
             typed_bounded_vars: preseeded_bounded,
             typed_bounded_dto_fields: std::collections::HashMap::new(),
             self_scoped_session_bases: state.self_scoped_session_bases,
    +        is_nextauth_options_factory,
         }
     }
     
    +/// True when the function body at `node` is a NextAuth authority
    +/// surface.  Recognises two shapes:
    +///
    +///   1. An object literal with a `callbacks: { ... }` property whose
    +///      nested entries name at least one canonical NextAuth callback
    +///      (`signIn`, `session`, `jwt`, `redirect`, `authorize`,
    +///      `authorized`).  Matches the cal.com idiom
    +///      `export const getOptions = (...) => ({ callbacks: { ... } })`.
    +///
    +///   2. An object literal whose entries name at least one distinctive
    +///      NextAuth Adapter method (`getUserByAccount`, `linkAccount`,
    +///      `unlinkAccount`, `createVerificationToken`,
    +///      `useVerificationToken`, `getSessionAndUser`) AND at least one
    +///      other canonical Adapter method.  Matches the cal.com idiom
    +///      `function CalComAdapter(prisma): Adapter { return { ... } }`
    +///      where the returned Adapter object holds the implementation.
    +///
    +/// In both shapes the inner method bodies are NOT enumerated as
    +/// separate units (object method shorthands stay anonymous), so every
    +/// identity-resolution operation from the inner methods accumulates
    +/// onto the outer factory's unit.  Without this flag the outer unit's
    +/// name is `getOptions` / `CalComAdapter`, so `is_nextauth_callback_unit`
    +/// cannot match by name and the missing-ownership rule fires on every
    +/// identity lookup inside the surface.
    +///
    +/// JS/TS-only by construction (matches `object` / `pair` /
    +/// `method_definition` / `shorthand_property_identifier` node kinds).
    +/// Returns false on other languages.
    +fn body_returns_nextauth_options(node: Node<'_>, bytes: &[u8]) -> bool {
    +    fn scan(node: Node<'_>, bytes: &[u8]) -> bool {
    +        if matches!(node.kind(), "object" | "object_expression")
    +            && (object_has_nextauth_callbacks_property(node, bytes)
    +                || object_is_nextauth_adapter(node, bytes))
    +        {
    +            return true;
    +        }
    +        for child in named_children(node) {
    +            if scan(child, bytes) {
    +                return true;
    +            }
    +        }
    +        false
    +    }
    +    scan(node, bytes)
    +}
    +
    +fn object_has_nextauth_callbacks_property(node: Node<'_>, bytes: &[u8]) -> bool {
    +    for entry in named_children(node) {
    +        let Some((key_text, value_node)) = object_entry_key_value(entry, bytes) else {
    +            continue;
    +        };
    +        if key_text != "callbacks" {
    +            continue;
    +        }
    +        if matches!(value_node.kind(), "object" | "object_expression")
    +            && object_contains_nextauth_callback_method(value_node, bytes)
    +        {
    +            return true;
    +        }
    +    }
    +    false
    +}
    +
    +fn object_contains_nextauth_callback_method(node: Node<'_>, bytes: &[u8]) -> bool {
    +    for entry in named_children(node) {
    +        if entry.kind() == "method_definition" {
    +            if let Some(name_node) = entry.child_by_field_name("name") {
    +                let name = text(name_node, bytes);
    +                if is_nextauth_callback_name(&name) {
    +                    return true;
    +                }
    +            }
    +            continue;
    +        }
    +        if let Some((key_text, _value_node)) = object_entry_key_value(entry, bytes)
    +            && is_nextauth_callback_name(&key_text)
    +        {
    +            return true;
    +        }
    +    }
    +    false
    +}
    +
    +fn object_entry_key_value<'a>(entry: Node<'a>, bytes: &[u8]) -> Option<(String, Node<'a>)> {
    +    match entry.kind() {
    +        "pair" => {
    +            let key = entry.child_by_field_name("key")?;
    +            let value = entry.child_by_field_name("value")?;
    +            Some((object_key_text(key, bytes), value))
    +        }
    +        "method_definition" => {
    +            let name = entry.child_by_field_name("name")?;
    +            Some((text(name, bytes), entry))
    +        }
    +        _ => None,
    +    }
    +}
    +
    +fn object_key_text(node: Node<'_>, bytes: &[u8]) -> String {
    +    match node.kind() {
    +        "property_identifier" | "identifier" | "shorthand_property_identifier" => text(node, bytes),
    +        "string" | "string_literal" => {
    +            let raw = text(node, bytes);
    +            raw.trim_matches(|c| c == '"' || c == '\'' || c == '`')
    +                .to_string()
    +        }
    +        "computed_property_name" => {
    +            if let Some(inner) = node.named_child(0) {
    +                object_key_text(inner, bytes)
    +            } else {
    +                String::new()
    +            }
    +        }
    +        _ => text(node, bytes),
    +    }
    +}
    +
    +fn is_nextauth_callback_name(name: &str) -> bool {
    +    matches!(
    +        name,
    +        "signIn" | "session" | "jwt" | "redirect" | "authorize" | "authorized"
    +    )
    +}
    +
    +/// True when the object literal at `node` looks like a NextAuth
    +/// Adapter implementation: at least one distinctive Adapter method
    +/// name AND at least two canonical Adapter method names overall.
    +/// The distinctive subset (`getUserByAccount`, `linkAccount`,
    +/// `unlinkAccount`, `createVerificationToken`, `useVerificationToken`,
    +/// `getSessionAndUser`) names operations that are unique to the
    +/// NextAuth Adapter contract; the broader canonical set (createUser /
    +/// getUser / getUserByEmail / updateUser / deleteUser / createSession /
    +/// updateSession / deleteSession) overlaps with generic CRUD repos, so
    +/// the distinctive-name witness gates the recognition.
    +fn object_is_nextauth_adapter(node: Node<'_>, bytes: &[u8]) -> bool {
    +    let mut distinctive_seen = false;
    +    let mut total = 0_usize;
    +    for entry in named_children(node) {
    +        let Some(key_text) = adapter_object_entry_key(entry, bytes) else {
    +            continue;
    +        };
    +        if !is_nextauth_adapter_method_name(&key_text) {
    +            continue;
    +        }
    +        total += 1;
    +        if is_nextauth_adapter_distinctive_method_name(&key_text) {
    +            distinctive_seen = true;
    +        }
    +    }
    +    distinctive_seen && total >= 2
    +}
    +
    +fn adapter_object_entry_key(entry: Node<'_>, bytes: &[u8]) -> Option {
    +    match entry.kind() {
    +        "method_definition" => entry
    +            .child_by_field_name("name")
    +            .map(|n| object_key_text(n, bytes)),
    +        "pair" => entry
    +            .child_by_field_name("key")
    +            .map(|n| object_key_text(n, bytes)),
    +        "shorthand_property_identifier" => Some(text(entry, bytes)),
    +        _ => None,
    +    }
    +}
    +
    +fn is_nextauth_adapter_method_name(name: &str) -> bool {
    +    matches!(
    +        name,
    +        "createUser"
    +            | "getUser"
    +            | "getUserByEmail"
    +            | "getUserByAccount"
    +            | "updateUser"
    +            | "deleteUser"
    +            | "linkAccount"
    +            | "unlinkAccount"
    +            | "createSession"
    +            | "getSessionAndUser"
    +            | "updateSession"
    +            | "deleteSession"
    +            | "createVerificationToken"
    +            | "useVerificationToken"
    +    )
    +}
    +
    +fn is_nextauth_adapter_distinctive_method_name(name: &str) -> bool {
    +    matches!(
    +        name,
    +        "getUserByAccount"
    +            | "linkAccount"
    +            | "unlinkAccount"
    +            | "createVerificationToken"
    +            | "useVerificationToken"
    +            | "getSessionAndUser"
    +    )
    +}
    +
     #[derive(Default)]
     struct UnitState {
         call_sites: Vec,
    @@ -832,14 +1032,13 @@ fn collect_unit_state(
             "call_expression" | "call" | "method_invocation" | "method_call_expression" => {
                 collect_call(node, bytes, rules, state)
             }
    -        "if_statement" | "elif_clause" | "while_statement" | "do_statement" | "if" | "unless"
    -        | "if_modifier" | "unless_modifier" | "while_modifier" | "until_modifier"
    -        | "while_expression" => {
    +        "while_statement" | "do_statement" | "while_modifier" | "until_modifier"
    +        | "while_expression" | "unless" | "unless_modifier" => {
                 if let Some(condition) = node.child_by_field_name("condition") {
                     collect_condition(condition, bytes, rules, state);
                 }
             }
    -        "if_expression" => {
    +        "if_statement" | "elif_clause" | "if_expression" | "if" | "if_modifier" => {
                 if let Some(condition) = node.child_by_field_name("condition") {
                     collect_condition(condition, bytes, rules, state);
                 }
    @@ -868,6 +1067,12 @@ fn collect_unit_state(
                 collect_self_actor_binding(node, bytes, rules, state);
                 collect_self_actor_id_binding(node, bytes, state);
                 collect_const_string_binding(node, bytes, state);
    +            // JS/TS row-fetch declarators (`const webhook = await
    +            // repo.findById(id)`) need row-population recognition so
    +            // the post-fetch ownership-equality detector can attribute
    +            // back to the row's let line. `collect_row_population`
    +            // accepts the `name` field used by `variable_declarator`.
    +            collect_row_population(node, bytes, state);
             }
             // Go `id := "id"` / Python `id = "id"` / Java `String id = "id";` /
             // Ruby `id = "id"`, language-specific binding nodes that the
    @@ -1336,11 +1541,13 @@ fn collect_member_alias_binding(node: Node<'_>, bytes: &[u8], state: &mut UnitSt
     /// flagged despite a textual auth check on the resulting row.
     fn collect_row_population(node: Node<'_>, bytes: &[u8], state: &mut UnitState) {
         // Most languages expose `pattern`/`value` on let / const / var
    -    // declarations.  Ruby `assignment` uses `left`/`right` instead, so
    -    // accept either.  When both fields are missing, the node isn't an
    -    // RHS-bound binding and we skip.
    +    // declarations.  Ruby `assignment` uses `left`/`right` instead.
    +    // JS/TS `variable_declarator` uses `name`/`value`.  Accept any of
    +    // them; when none is present the node isn't an RHS-bound binding
    +    // and we skip.
         let Some(pattern) = node
             .child_by_field_name("pattern")
    +        .or_else(|| node.child_by_field_name("name"))
             .or_else(|| node.child_by_field_name("left"))
         else {
             return;
    @@ -2784,8 +2991,8 @@ fn detect_ownership_equality_check(if_node: Node<'_>, bytes: &[u8], state: &mut
         let Some(operator) = binary_operator_text(condition, bytes) else {
             return;
         };
    -    let is_ne = matches!(operator.as_str(), "!=" | "ne");
    -    let is_eq = matches!(operator.as_str(), "==" | "eq");
    +    let is_ne = matches!(operator.as_str(), "!=" | "!==" | "ne");
    +    let is_eq = matches!(operator.as_str(), "==" | "===" | "eq");
         if !is_ne && !is_eq {
             return;
         }
    @@ -2801,7 +3008,7 @@ fn detect_ownership_equality_check(if_node: Node<'_>, bytes: &[u8], state: &mut
             return;
         };
     
    -    if !branch_has_early_exit(fail_branch) {
    +    if !branch_has_early_exit(fail_branch, bytes) {
             return;
         }
     
    @@ -2925,18 +3132,63 @@ fn resolve_else_block(alt: Node<'_>) -> Node<'_> {
         alt
     }
     
    -fn branch_has_early_exit(branch: Node<'_>) -> bool {
    -    named_children(branch).into_iter().any(node_is_early_exit)
    +fn branch_has_early_exit(branch: Node<'_>, bytes: &[u8]) -> bool {
    +    named_children(branch)
    +        .into_iter()
    +        .any(|n| node_is_early_exit(n, bytes))
     }
     
    -fn node_is_early_exit(node: Node<'_>) -> bool {
    +fn node_is_early_exit(node: Node<'_>, bytes: &[u8]) -> bool {
         match node.kind() {
             "return_expression" | "return_statement" => true,
    -        "expression_statement" => named_children(node).into_iter().any(node_is_early_exit),
    +        // Throwing aborts execution flow.  Common in JS/TS / Java
    +        // (`throw new ForbiddenException()`), Python (`raise ...`),
    +        // Ruby (`raise ...`).
    +        "throw_statement" | "throw_expression" | "raise_statement" => true,
    +        // A call whose callee name is in the framework denial set
    +        // (`notFound()` / `redirect()` / `abort()` / `forbidden()` /
    +        // `unauthorized()` / etc.) terminates the request.  These
    +        // helpers either throw under the hood (Next.js, Flask) or
    +        // exit the process (`process.exit`, `sys.exit`).
    +        "call_expression" | "call" | "method_invocation" => is_denial_call(node, bytes),
    +        "expression_statement" => named_children(node)
    +            .into_iter()
    +            .any(|n| node_is_early_exit(n, bytes)),
             _ => false,
         }
     }
     
    +/// Recognise calls that act as request-terminating denial helpers.
    +///
    +/// The callee name is matched against a curated set of framework
    +/// idioms.  This is read in `node_is_early_exit` from inside the
    +/// row-ownership-equality detector, where the ambient context already
    +/// requires an `owner.field` vs. `self.id` binary comparison; the
    +/// denial-call match is only the early-exit witness, not the auth
    +/// signal itself.
    +fn is_denial_call(call_node: Node<'_>, bytes: &[u8]) -> bool {
    +    let Some(callee_node) = call_node
    +        .child_by_field_name("function")
    +        .or_else(|| call_node.child_by_field_name("name"))
    +    else {
    +        return false;
    +    };
    +    let callee_text = text(callee_node, bytes);
    +    let trimmed = callee_text.trim();
    +    let leaf = trimmed.rsplit('.').next().unwrap_or(trimmed);
    +    let leaf = leaf.rsplit("::").next().unwrap_or(leaf);
    +    matches!(
    +        leaf,
    +        "notFound"
    +            | "redirect"
    +            | "permanentRedirect"
    +            | "unauthorized"
    +            | "forbidden"
    +            | "abort"
    +            | "halt"
    +    )
    +}
    +
     pub(super) fn is_owner_field_subject(subject: &ValueRef) -> bool {
         let raw = match subject.source_kind {
             ValueSourceKind::ArrayIndex => subject.base.as_deref().unwrap_or(&subject.name),
    @@ -5419,4 +5671,220 @@ mod tests {
                 ));
             }
         }
    +
    +    #[test]
    +    fn trpc_options_destructure_param_seeds_self_scoped_session_base() {
    +        // Cal.com-shaped TRPC handler: parameter is a destructured
    +        // options alias whose `ctx` field's nested type literal
    +        // references `TrpcSessionUser`. `FileMeta::scan` adds
    +        // `GetOptions` to `trpc_alias_names` (body-text marker hit);
    +        // `collect_trpc_ctx_param` then fires on the
    +        // `required_parameter` and seeds `ctx.user` into the unit's
    +        // `self_scoped_session_bases`.
    +        let mut parser = tree_sitter::Parser::new();
    +        parser
    +            .set_language(&tree_sitter::Language::from(
    +                tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
    +            ))
    +            .unwrap();
    +        let src = br#"
    +type TrpcSessionUser = { id: number };
    +type GetOptions = {
    +  ctx: { user: NonNullable };
    +  input: { id: number };
    +};
    +export const handleGet = async ({ ctx, input }: GetOptions) => {
    +  return prisma.booking.findFirst({ where: { id: input.id, userId: ctx.user.id } });
    +};
    +"#;
    +        let tree = parser.parse(src.as_slice(), None).unwrap();
    +        let meta = super::FileMeta::scan(tree.root_node(), src);
    +        assert!(
    +            meta.trpc_alias_names.contains("GetOptions"),
    +            "trpc_alias_names missing GetOptions: {:?}",
    +            meta.trpc_alias_names
    +        );
    +
    +        let rules = crate::auth_analysis::config::AuthAnalysisRules::disabled();
    +        let mut model = crate::auth_analysis::model::AuthorizationModel::default();
    +        super::collect_top_level_units(tree.root_node(), src, &rules, &mut model);
    +        let unit = model
    +            .units
    +            .iter()
    +            .find(|u| u.name.as_deref() == Some("handleGet"))
    +            .expect("handleGet unit");
    +        assert!(
    +            unit.self_scoped_session_bases.contains("ctx.user"),
    +            "self_scoped_session_bases missing ctx.user: {:?}",
    +            unit.self_scoped_session_bases
    +        );
    +    }
    +
    +    /// Pin the JS/TS post-fetch ownership-equality recogniser added in
    +    /// session 0011.  The `if_statement` arm of `collect_unit_state`
    +    /// must dispatch to `detect_ownership_equality_check` (previously
    +    /// only `if_expression` did), the strict `!==` operator must be
    +    /// recognised as inequality, the framework denial helper
    +    /// `notFound()` must count as an early-exit witness, and the JS/TS
    +    /// `variable_declarator` arm must populate `row_population_data`
    +    /// so the synthetic `Ownership` AuthCheck attributes back to the
    +    /// row's let line.
    +    #[test]
    +    fn detect_post_fetch_ownership_jsts_with_strict_neq_and_denial_call() {
    +        let mut parser = tree_sitter::Parser::new();
    +        parser
    +            .set_language(&tree_sitter::Language::from(
    +                tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
    +            ))
    +            .unwrap();
    +        let src = br#"
    +declare class Repo { findById(id: string): Promise<{ userId: number }>; }
    +declare function getServerSession(): Promise<{ user?: { id: number } } | null>;
    +declare function notFound(): never;
    +export async function handleGet({ id }: { id: string }) {
    +  const session = await getServerSession();
    +  if (!session?.user?.id) return null;
    +  const repo: Repo = new Repo();
    +  const webhook = await repo.findById(id);
    +  if (webhook.userId !== session.user.id) {
    +    notFound();
    +  }
    +  return webhook;
    +}
    +"#;
    +        let tree = parser.parse(src.as_slice(), None).unwrap();
    +        let rules = crate::auth_analysis::config::AuthAnalysisRules::disabled();
    +        let mut model = crate::auth_analysis::model::AuthorizationModel::default();
    +        super::collect_top_level_units(tree.root_node(), src, &rules, &mut model);
    +        let unit = model
    +            .units
    +            .iter()
    +            .find(|u| u.name.as_deref() == Some("handleGet"))
    +            .expect("handleGet unit");
    +
    +        let webhook_pop = unit
    +            .row_population_data
    +            .get("webhook")
    +            .expect("collect_row_population must populate `webhook` from variable_declarator");
    +        // The `let webhook = await repo.findById(id)` line should
    +        // anchor at the call site, not the let line.  In this fixture
    +        // both are on the same line so the back-dating is invisible
    +        // here, the assertion is that the entry exists.
    +        assert!(webhook_pop.0 > 0);
    +
    +        let owner_check = unit
    +            .auth_checks
    +            .iter()
    +            .find(|c| matches!(c.kind, super::AuthCheckKind::Ownership))
    +            .expect("ownership-equality detector must emit an Ownership AuthCheck");
    +        let owner_subject = owner_check
    +            .subjects
    +            .iter()
    +            .find(|s| s.field.as_deref() == Some("userId"))
    +            .expect("Ownership AuthCheck must carry the owner field subject");
    +        assert_eq!(
    +            owner_subject.base.as_deref(),
    +            Some("webhook"),
    +            "owner subject base must be the row var: {:?}",
    +            owner_subject
    +        );
    +    }
    +
    +    /// Pin the NextAuth Adapter factory recogniser added in session
    +    /// 0030.  `body_returns_nextauth_options` must flip on for the
    +    /// cal.com `function CalComAdapter(client): Adapter { return {
    +    /// createUser, getUser, getUserByAccount, ... } }` shape so that
    +    /// `is_nextauth_callback_unit` suppresses the missing-ownership
    +    /// rule across the inner Adapter methods (their operations
    +    /// accumulate onto the outer factory's unit).
    +    #[test]
    +    fn nextauth_adapter_factory_flags_outer_unit() {
    +        let mut parser = tree_sitter::Parser::new();
    +        parser
    +            .set_language(&tree_sitter::Language::from(
    +                tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
    +            ))
    +            .unwrap();
    +        let src = br#"
    +declare const prismaClient: any;
    +export default function CalComAdapter(client: any) {
    +  return {
    +    createUser: async (data: { email: string }) => {
    +      const user = await prismaClient.user.create({ data });
    +      return user;
    +    },
    +    getUser: async (id: string) => {
    +      const user = await prismaClient.user.findUnique({ where: { id } });
    +      return user;
    +    },
    +    async getUserByAccount(providerAccountId: { provider: string; providerAccountId: string }) {
    +      const account = await prismaClient.account.findUnique({
    +        where: { provider_providerAccountId: providerAccountId },
    +        select: { user: true },
    +      });
    +      return account?.user ?? null;
    +    },
    +    createVerificationToken: async (data: any) => prismaClient.verificationToken.create({ data }),
    +    useVerificationToken: async (identifier: any) => prismaClient.verificationToken.delete({ where: identifier }),
    +    linkAccount: async (account: any) => prismaClient.account.create({ data: account }),
    +    unlinkAccount: async (providerAccountId: any) => prismaClient.account.delete({ where: providerAccountId }),
    +  };
    +}
    +"#;
    +        let tree = parser.parse(src.as_slice(), None).unwrap();
    +        let rules = crate::auth_analysis::config::AuthAnalysisRules::disabled();
    +        let mut model = crate::auth_analysis::model::AuthorizationModel::default();
    +        super::collect_top_level_units(tree.root_node(), src, &rules, &mut model);
    +        let unit = model
    +            .units
    +            .iter()
    +            .find(|u| u.name.as_deref() == Some("CalComAdapter"))
    +            .expect("CalComAdapter unit");
    +        assert!(
    +            unit.is_nextauth_options_factory,
    +            "Adapter factory must set is_nextauth_options_factory: \
    +             {:?}",
    +            unit.name
    +        );
    +    }
    +
    +    /// Negative: a generic CRUD repo with `createUser` / `getUser` /
    +    /// `updateUser` / `deleteUser` (no Adapter-distinctive method
    +    /// names) must NOT be flagged as a NextAuth Adapter.  Without the
    +    /// distinctive-name gate any plain user repo would suppress
    +    /// missing-ownership findings.
    +    #[test]
    +    fn nextauth_adapter_recogniser_rejects_generic_crud_repo() {
    +        let mut parser = tree_sitter::Parser::new();
    +        parser
    +            .set_language(&tree_sitter::Language::from(
    +                tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
    +            ))
    +            .unwrap();
    +        let src = br#"
    +declare const db: any;
    +export function makeUserRepo() {
    +  return {
    +    createUser: async (data: any) => db.user.create({ data }),
    +    getUser: async (id: string) => db.user.findUnique({ where: { id } }),
    +    updateUser: async (id: string, data: any) => db.user.update({ where: { id }, data }),
    +    deleteUser: async (id: string) => db.user.delete({ where: { id } }),
    +  };
    +}
    +"#;
    +        let tree = parser.parse(src.as_slice(), None).unwrap();
    +        let rules = crate::auth_analysis::config::AuthAnalysisRules::disabled();
    +        let mut model = crate::auth_analysis::model::AuthorizationModel::default();
    +        super::collect_top_level_units(tree.root_node(), src, &rules, &mut model);
    +        let unit = model
    +            .units
    +            .iter()
    +            .find(|u| u.name.as_deref() == Some("makeUserRepo"))
    +            .expect("makeUserRepo unit");
    +        assert!(
    +            !unit.is_nextauth_options_factory,
    +            "generic CRUD repo must NOT be flagged as Adapter: {:?}",
    +            unit.name
    +        );
    +    }
     }
    diff --git a/src/auth_analysis/mod.rs b/src/auth_analysis/mod.rs
    index 15fce859..8ddadfdc 100644
    --- a/src/auth_analysis/mod.rs
    +++ b/src/auth_analysis/mod.rs
    @@ -1090,6 +1090,7 @@ mod tests {
                 typed_bounded_vars: HashSet::new(),
                 typed_bounded_dto_fields: HashMap::new(),
                 self_scoped_session_bases: HashSet::new(),
    +            is_nextauth_options_factory: false,
             }
         }
     
    @@ -1205,6 +1206,7 @@ mod tests {
                 typed_bounded_vars: HashSet::new(),
                 typed_bounded_dto_fields: HashMap::new(),
                 self_scoped_session_bases: HashSet::new(),
    +            is_nextauth_options_factory: false,
             }
         }
     
    diff --git a/src/auth_analysis/model.rs b/src/auth_analysis/model.rs
    index d7fccbb6..cc0f450a 100644
    --- a/src/auth_analysis/model.rs
    +++ b/src/auth_analysis/model.rs
    @@ -282,6 +282,23 @@ pub struct AnalysisUnit {
         /// destructures route through a base chain, not a top-level
         /// binding.
         pub self_scoped_session_bases: HashSet,
    +    /// True when this JS/TS unit is the body of a NextAuth options
    +    /// factory: its function body contains an object literal with a
    +    /// `callbacks: { ... }` property whose nested entries name at
    +    /// least one NextAuth canonical callback (`signIn` / `session` /
    +    /// `jwt` / `redirect` / `authorize` / `authorized`).  Set by
    +    /// `build_function_unit_with_meta` when the file structures the
    +    /// options as `export const X = (...) => ({ callbacks: { ... } })`
    +    /// (cal.com's `getOptions` shape) rather than the flat
    +    /// `export const authOptions = { callbacks: { ... } }` shape.
    +    /// Operations inside the inner callback bodies still get
    +    /// accumulated under the outer factory unit (the unit-creation
    +    /// pass does not descend into object-literal method shorthands),
    +    /// so the outer unit is the only place the auth analyser can
    +    /// recognise the identity-resolution context.  Consulted by
    +    /// `is_nextauth_callback_unit` so the missing-ownership check
    +    /// suppresses operations inside the factory.
    +    pub is_nextauth_options_factory: bool,
     }
     
     /// Per-function summary of which positional parameters are
    diff --git a/src/cfg/blocks.rs b/src/cfg/blocks.rs
    index fb6b6ed1..d0ac4eab 100644
    --- a/src/cfg/blocks.rs
    +++ b/src/cfg/blocks.rs
    @@ -521,10 +521,21 @@ pub(super) fn build_switch<'a>(
     ) -> Vec {
         // Locate the case container. Most grammars expose it as field "body"
         // (JS/TS, Java, C, C++); Go puts cases as direct children of the switch.
    +    //
    +    // Per-language gotcha: Go's `expression_case` / `default_case` /
    +    // `type_case` / `communication_case` map to `Kind::Block` (so the case
    +    // body is iterated by the Block handler), so a naive "first Block
    +    // child" fallback latches onto the FIRST case as the container, then
    +    // walks the case's interior looking for case-like children, finds none,
    +    // and falls through to the empty-cases early return (CFG dead-end:
    +    // dispatch If has no False edge, every post-switch statement becomes
    +    // unreachable).  Skip case-kind nodes when picking the container so
    +    // Go's flat "cases-as-direct-children" shape uses `ast` itself.
         let body = ast.child_by_field_name("body").or_else(|| {
             let mut c = ast.walk();
    -        ast.children(&mut c)
    -            .find(|n| matches!(lookup(lang, n.kind()), Kind::Block))
    +        ast.children(&mut c).find(|n| {
    +            matches!(lookup(lang, n.kind()), Kind::Block) && !is_switch_case_kind(n.kind())
    +        })
         });
         let container = body.unwrap_or(ast);
     
    diff --git a/src/cfg/cfg_tests.rs b/src/cfg/cfg_tests.rs
    index d3e0e753..4154a5c6 100644
    --- a/src/cfg/cfg_tests.rs
    +++ b/src/cfg/cfg_tests.rs
    @@ -1202,6 +1202,8 @@ fn clone_preserves_all_sub_structs() {
                 defines: Some("r".into()),
                 uses: vec!["a".into(), "b".into()],
                 extra_defines: vec!["c".into()],
    +            array_pattern_indices: smallvec::SmallVec::new(),
    +            rhs_array_elements: smallvec::SmallVec::new(),
             },
             ast: AstMeta {
                 span: (10, 100),
    @@ -1501,6 +1503,105 @@ fn rust_println_macro_named_arg_lifted() {
         assert!(found, "no println! macro_invocation node found");
     }
     
    +/// `format!(URL_FMT, path)` where `URL_FMT` resolves to a top-level
    +/// `const &str` literal must seed a `string_prefix` on the let-binding
    +/// node so `is_string_safe_for_ssrf` can lock the host the same way
    +/// `format!("https://api/{}", path)` does. The bridge fires only when
    +/// the first non-string token in the macro is an identifier whose
    +/// matching `const_item` has a string-literal value.
    +#[test]
    +fn rust_format_macro_const_first_arg_seeds_string_prefix() {
    +    let src = b"const URL_FMT: &str = \"https://api.example.com/users/{}\";\n\
    +                fn f(path: String) { let u = format!(URL_FMT, path); }";
    +    let ts_lang = Language::from(tree_sitter_rust::LANGUAGE);
    +    let (cfg, _entry) = parse_and_build(src, "rust", ts_lang);
    +    let mut prefix: Option = None;
    +    for n in cfg.node_indices() {
    +        let info = &cfg[n];
    +        if info.taint.defines.as_deref() == Some("u")
    +            && let Some(p) = info.string_prefix.as_deref()
    +        {
    +            prefix = Some(p.to_string());
    +        }
    +    }
    +    assert_eq!(
    +        prefix.as_deref(),
    +        Some("https://api.example.com/users/"),
    +        "expected URL_FMT const to bridge into the format!() string_prefix",
    +    );
    +}
    +
    +/// Counter-test: when the named const has no string-literal initializer
    +/// (e.g. `const X: usize = 4;`), the bridge must not fabricate a
    +/// prefix from a non-string value.
    +#[test]
    +fn rust_format_macro_const_first_arg_non_string_skipped() {
    +    let src = b"const N: usize = 4;\n\
    +                fn f(path: String) { let u = format!(N, path); }";
    +    let ts_lang = Language::from(tree_sitter_rust::LANGUAGE);
    +    let (cfg, _entry) = parse_and_build(src, "rust", ts_lang);
    +    for n in cfg.node_indices() {
    +        let info = &cfg[n];
    +        if info.taint.defines.as_deref() == Some("u") {
    +            assert!(
    +                info.string_prefix.is_none(),
    +                "non-string const must not seed a prefix; got {:?}",
    +                info.string_prefix
    +            );
    +        }
    +    }
    +}
    +
    +/// `static NAME: &str = "...";` declarations participate alongside
    +/// `const_item`: both shapes carry a `name` field and a string-literal
    +/// `value` so the bridge resolves either form identically.
    +#[test]
    +fn rust_format_macro_static_first_arg_seeds_string_prefix() {
    +    let src = b"static API_BASE: &str = \"https://api.example.com/users/{}\";\n\
    +                fn f(path: String) { let u = format!(API_BASE, path); }";
    +    let ts_lang = Language::from(tree_sitter_rust::LANGUAGE);
    +    let (cfg, _entry) = parse_and_build(src, "rust", ts_lang);
    +    let mut prefix: Option = None;
    +    for n in cfg.node_indices() {
    +        let info = &cfg[n];
    +        if info.taint.defines.as_deref() == Some("u")
    +            && let Some(p) = info.string_prefix.as_deref()
    +        {
    +            prefix = Some(p.to_string());
    +        }
    +    }
    +    assert_eq!(
    +        prefix.as_deref(),
    +        Some("https://api.example.com/users/"),
    +        "expected static API_BASE to bridge into the format!() string_prefix",
    +    );
    +}
    +
    +/// A const declared inside a function body must not bridge: only
    +/// file-level `const_item` declarations participate to keep the
    +/// lookup deterministic. (The macro's first arg can shadow a
    +/// file-level const with an inner-fn const, but inner consts are
    +/// off-scope for the AST-time prefix bridge.)
    +#[test]
    +fn rust_format_macro_inner_const_not_bridged() {
    +    let src = b"fn f(path: String) {\n\
    +                  const URL_FMT: &str = \"https://api/{}\";\n\
    +                  let u = format!(URL_FMT, path);\n\
    +                }";
    +    let ts_lang = Language::from(tree_sitter_rust::LANGUAGE);
    +    let (cfg, _entry) = parse_and_build(src, "rust", ts_lang);
    +    for n in cfg.node_indices() {
    +        let info = &cfg[n];
    +        if info.taint.defines.as_deref() == Some("u") {
    +            assert!(
    +                info.string_prefix.is_none(),
    +                "inner-fn const must not bridge; got {:?}",
    +                info.string_prefix
    +            );
    +        }
    +    }
    +}
    +
     #[test]
     fn go_no_import_bindings() {
         let src = b"package main\nimport alias \"fmt\"\n";
    @@ -2354,6 +2455,29 @@ fn py_subscript_write_lowers_to_index_set_call() {
         });
     }
     
    +#[test]
    +fn go_selector_expression_call_sets_receiver() {
    +    // Regression for Phase 15 deferred GORM tuple-return case.
    +    // Go's `userDb.Raw(sql)` parses as `call_expression` whose `function`
    +    // field is a `selector_expression` (operand=userDb, field=Raw).
    +    // The CFG-side `Kind::CallFn` arm must extract `userDb` as the
    +    // receiver so type-qualified resolution can rewrite `userDb.Raw` →
    +    // `GormDb.Raw` once `userDb`'s SSA value is tagged via
    +    // `constructor_type(Lang::Go, "gorm.Open")`.  Pre-fix the arm only
    +    // recognised JS/TS `member_expression`, Python `attribute`, and Rust
    +    // `field_expression`; Go fell through to receiver=None.
    +    let src = br#"package main
    +func f(userDb int) {
    +    userDb.Raw("SELECT 1")
    +}
    +"#;
    +    let ts_lang = Language::from(tree_sitter_go::LANGUAGE);
    +    let (cfg, _entry) = parse_and_build(src, "go", ts_lang);
    +    let node =
    +        find_node_with_callee(&cfg, "userDb.Raw").expect("go: userDb.Raw node should be present");
    +    assert_eq!(node.call.receiver.as_deref(), Some("userDb"));
    +}
    +
     #[test]
     fn go_index_expr_read_lowers_to_index_get_call() {
         with_pointer_on(|| {
    @@ -3217,3 +3341,620 @@ fn js_ternary_branch_subscript_source_classified() {
             "expected ternary subscript branch defining `x` to carry a Source label"
         );
     }
    +
    +/// Regression: Go's `switch` with no `default` arm and an only-case body
    +/// that returns must keep post-switch statements reachable from entry.
    +///
    +/// `expression_case` / `default_case` / `type_case` / `communication_case`
    +/// all map to `Kind::Block` so the case body is iterated by the Block
    +/// handler, but `build_switch`'s container fallback ("first Block child")
    +/// would latch onto the FIRST case as the container.  Walking the case's
    +/// interior for case-like children finds nothing, the empty-cases early
    +/// return fires, and the dispatch If has no False edge: every post-switch
    +/// statement becomes unreachable, lighting up `cfg-unreachable-sanitizer`
    +/// on real code (gin's `binding/form_mapping.go::setTimeField`, line 469
    +/// `if isUTC, _ := strconv.ParseBool(...); isUTC` after a no-default
    +/// `switch tf := strings.ToLower(timeFormat); tf` on the unix epoch
    +/// formats).
    +#[test]
    +fn go_switch_no_default_keeps_post_switch_reachable() {
    +    use petgraph::visit::Bfs;
    +    use std::collections::HashSet;
    +    let src = br#"package p
    +func f(x string) bool {
    +    switch tf := x; tf {
    +    case "unix":
    +        return false
    +    }
    +    after()
    +    return true
    +}
    +"#;
    +    let ts_lang = Language::from(tree_sitter_go::LANGUAGE);
    +    let (cfg, entry) = parse_and_build(src, "go", ts_lang);
    +
    +    let mut reachable: HashSet = HashSet::new();
    +    let mut bfs = Bfs::new(&cfg, entry);
    +    while let Some(n) = bfs.next(&cfg) {
    +        reachable.insert(n);
    +    }
    +
    +    let after = cfg
    +        .node_indices()
    +        .find(|&n| cfg[n].call.callee.as_deref() == Some("after"))
    +        .expect("expected after() Call node");
    +    assert!(
    +        reachable.contains(&after),
    +        "post-switch `after()` must be reachable from entry; got reachable={:?}",
    +        reachable
    +    );
    +}
    +
    +/// `qs = User.objects` at module/function level lowers as a Python
    +/// `expression_statement` wrapping an `assignment`.  The CFG-level
    +/// `member_field` detector must unwrap the wrapper and pick up
    +/// `Some("objects")` from the inner RHS so the type-fact pass can tag
    +/// the bound value as `DjangoQuerySet`.
    +#[test]
    +fn python_member_field_assignment_detected_for_bare_objects() {
    +    let src = b"def view(req):\n    qs = User.objects\n";
    +    let ts_lang = Language::from(tree_sitter_python::LANGUAGE);
    +    let (cfg, _entry) = parse_and_build(src, "python", ts_lang);
    +    let detected: Vec> = cfg
    +        .node_indices()
    +        .filter_map(|n| {
    +            let info = &cfg[n];
    +            if info.taint.defines.as_deref() == Some("qs") {
    +                Some(info.member_field.clone())
    +            } else {
    +                None
    +            }
    +        })
    +        .collect();
    +    assert!(
    +        detected.iter().any(|m| m.as_deref() == Some("objects")),
    +        "expected at least one `qs = ...` CFG node with member_field=Some(\"objects\"); got {:?}",
    +        detected
    +    );
    +}
    +
    +/// Negative shape: `qs = User.something_else` must NOT set
    +/// `member_field == Some("objects")`.  Guards against the unwrap
    +/// accidentally picking up the wrong field name.
    +#[test]
    +fn python_member_field_assignment_non_objects_does_not_match() {
    +    let src = b"def view(req):\n    qs = User.profile\n";
    +    let ts_lang = Language::from(tree_sitter_python::LANGUAGE);
    +    let (cfg, _entry) = parse_and_build(src, "python", ts_lang);
    +    let detected: Vec> = cfg
    +        .node_indices()
    +        .filter_map(|n| {
    +            let info = &cfg[n];
    +            if info.taint.defines.as_deref() == Some("qs") {
    +                Some(info.member_field.clone())
    +            } else {
    +                None
    +            }
    +        })
    +        .collect();
    +    assert!(
    +        detected.iter().any(|m| m.as_deref() == Some("profile")),
    +        "expected `qs = User.profile` to detect member_field=Some(\"profile\"); got {:?}",
    +        detected
    +    );
    +    assert!(
    +        detected.iter().all(|m| m.as_deref() != Some("objects")),
    +        "must not falsely tag non-`objects` field; got {:?}",
    +        detected
    +    );
    +}
    +
    +/// Phase 15 chained-shape closure: a Java local of the form
    +/// `Session sess = sf.openSession();` registers `(fn_start, "sess")`
    +/// → `TypeKind::HibernateSession` in the per-file local-receiver-types
    +/// map, so `find_classifiable_inner_call` can rewrite the chained
    +/// inner `sess.createNativeQuery(...)` to
    +/// `HibernateSession.createNativeQuery` when the legacy literal-
    +/// receiver classify misses.
    +#[test]
    +fn java_hibernate_session_open_registers_local_receiver_type() {
    +    let src = br#"
    +class Foo {
    +    void bar(SessionFactory sf, String sql) {
    +        Session sess = sf.openSession();
    +        sess.createNativeQuery(sql).getResultList();
    +    }
    +}
    +"#;
    +    let ts_lang = Language::from(tree_sitter_java::LANGUAGE);
    +    let _ = parse_to_file_cfg(src, "java", ts_lang);
    +    // The TLS map is cleared at the end of `build_cfg`, but the
    +    // public lookup helper consults it during construction.  Re-run
    +    // population manually for the assertion.
    +    let mut parser = tree_sitter::Parser::new();
    +    parser
    +        .set_language(&Language::from(tree_sitter_java::LANGUAGE))
    +        .unwrap();
    +    let tree = parser.parse(src.as_slice(), None).unwrap();
    +    super::populate_local_receiver_types(&tree, "java", src);
    +    // Walk to find the function body's start_byte.
    +    fn find_method_start(node: tree_sitter::Node<'_>) -> Option {
    +        if node.kind() == "method_declaration" {
    +            return Some(node.start_byte());
    +        }
    +        let mut c = node.walk();
    +        for child in node.children(&mut c) {
    +            if let Some(s) = find_method_start(child) {
    +                return Some(s);
    +            }
    +        }
    +        None
    +    }
    +    let fn_start = find_method_start(tree.root_node()).expect("method_declaration in fixture");
    +    let got = super::lookup_local_receiver_type(fn_start, "sess");
    +    assert_eq!(
    +        got,
    +        Some(crate::ssa::type_facts::TypeKind::HibernateSession),
    +        "local `Session sess = sf.openSession()` should bind to HibernateSession"
    +    );
    +    // Cleanup so the TLS state doesn't leak into other tests.
    +    super::LOCAL_RECEIVER_TYPES.with(|cell| cell.borrow_mut().clear());
    +}
    +
    +/// Same Java per-file map: a local whose RHS is unrelated (no
    +/// `constructor_type` match) must NOT register.  Confirms the
    +/// recogniser is anchored on `constructor_type`'s callee classifier
    +/// rather than the declared receiver type, so a generic
    +/// `Session foo = computeFoo()` doesn't bleed an unrelated method
    +/// into the type-qualified pool.
    +#[test]
    +fn java_unrecognised_rhs_does_not_register_local_receiver_type() {
    +    let src = br#"
    +class Foo {
    +    void bar() {
    +        Session sess = computeSomethingUnrelated();
    +        sess.doSomething();
    +    }
    +}
    +"#;
    +    let mut parser = tree_sitter::Parser::new();
    +    parser
    +        .set_language(&Language::from(tree_sitter_java::LANGUAGE))
    +        .unwrap();
    +    let tree = parser.parse(src.as_slice(), None).unwrap();
    +    super::populate_local_receiver_types(&tree, "java", src);
    +    fn find_method_start(node: tree_sitter::Node<'_>) -> Option {
    +        if node.kind() == "method_declaration" {
    +            return Some(node.start_byte());
    +        }
    +        let mut c = node.walk();
    +        for child in node.children(&mut c) {
    +            if let Some(s) = find_method_start(child) {
    +                return Some(s);
    +            }
    +        }
    +        None
    +    }
    +    let fn_start = find_method_start(tree.root_node()).expect("method_declaration in fixture");
    +    let got = super::lookup_local_receiver_type(fn_start, "sess");
    +    assert_eq!(
    +        got, None,
    +        "unrecognised RHS `computeSomethingUnrelated()` must not register a receiver-type"
    +    );
    +    super::LOCAL_RECEIVER_TYPES.with(|cell| cell.borrow_mut().clear());
    +}
    +
    +/// `collect_array_pattern_bindings_indexed` walks JS/TS `array_pattern`
    +/// children in source order and records `(name, position)` for each
    +/// simple-identifier binding. Skip slots (commas with no binding
    +/// between) advance the position counter without emitting a binding,
    +/// so `const [, b]` produces `[("b", 1)]` and `const [a, ,]` produces
    +/// `[("a", 0)]`. Complex sub-patterns (`assignment_pattern`,
    +/// `rest_pattern`, nested `array_pattern`) cause the helper to return
    +/// an empty vec so the lowering rewrite falls back to scalar union.
    +#[test]
    +fn array_pattern_indexed_bindings_recognise_skip_slots() {
    +    use super::helpers::collect_array_pattern_bindings_indexed;
    +    fn first_array_pattern<'t>(n: tree_sitter::Node<'t>) -> Option> {
    +        if n.kind() == "array_pattern" {
    +            return Some(n);
    +        }
    +        let mut c = n.walk();
    +        for child in n.children(&mut c) {
    +            if let Some(found) = first_array_pattern(child) {
    +                return Some(found);
    +            }
    +        }
    +        None
    +    }
    +    fn parse_first(src: &[u8]) -> (tree_sitter::Tree, Vec) {
    +        let mut parser = tree_sitter::Parser::new();
    +        parser
    +            .set_language(&Language::from(tree_sitter_javascript::LANGUAGE))
    +            .unwrap();
    +        let tree = parser.parse(src, None).unwrap();
    +        (tree, src.to_vec())
    +    }
    +    fn run_case(src: &[u8]) -> Vec<(String, usize)> {
    +        let (tree, bytes) = parse_first(src);
    +        let pat = first_array_pattern(tree.root_node()).expect("array_pattern in fixture");
    +        collect_array_pattern_bindings_indexed(pat, &bytes)
    +            .into_iter()
    +            .collect()
    +    }
    +    assert_eq!(
    +        run_case(b"const [a, b] = x;"),
    +        vec![("a".into(), 0), ("b".into(), 1)],
    +    );
    +    assert_eq!(run_case(b"const [, b] = x;"), vec![("b".into(), 1)]);
    +    assert_eq!(run_case(b"const [a, ,] = x;"), vec![("a".into(), 0)]);
    +    assert_eq!(
    +        run_case(b"const [a, , c] = x;"),
    +        vec![("a".into(), 0), ("c".into(), 2)],
    +    );
    +    // Rest patterns bail to empty so callers fall back to scalar union.
    +    assert!(run_case(b"const [a, ...rest] = x;").is_empty());
    +    // Default value patterns also bail.
    +    assert!(run_case(b"const [a = 1, b] = x;").is_empty());
    +    // Nested array patterns bail.
    +    assert!(run_case(b"const [[a, b], c] = x;").is_empty());
    +}
    +
    +/// Rust `tuple_pattern` shares the helper. The `_` wildcard
    +/// (`_pattern` node) advances the position counter without binding,
    +/// mirroring JS skip-slot semantics. Other complex sub-patterns
    +/// (tuple-struct, parenthesized) bail to empty.
    +#[test]
    +fn tuple_pattern_indexed_bindings_recognise_rust_wildcards() {
    +    use super::helpers::collect_array_pattern_bindings_indexed;
    +    fn first_tuple_pattern<'t>(n: tree_sitter::Node<'t>) -> Option> {
    +        if n.kind() == "tuple_pattern" {
    +            return Some(n);
    +        }
    +        let mut c = n.walk();
    +        for child in n.children(&mut c) {
    +            if let Some(found) = first_tuple_pattern(child) {
    +                return Some(found);
    +            }
    +        }
    +        None
    +    }
    +    fn parse_first_rust(src: &[u8]) -> (tree_sitter::Tree, Vec) {
    +        let mut parser = tree_sitter::Parser::new();
    +        parser
    +            .set_language(&Language::from(tree_sitter_rust::LANGUAGE))
    +            .unwrap();
    +        let tree = parser.parse(src, None).unwrap();
    +        (tree, src.to_vec())
    +    }
    +    fn run_case(src: &[u8]) -> Vec<(String, usize)> {
    +        let (tree, bytes) = parse_first_rust(src);
    +        let pat = first_tuple_pattern(tree.root_node()).expect("tuple_pattern in fixture");
    +        collect_array_pattern_bindings_indexed(pat, &bytes)
    +            .into_iter()
    +            .collect()
    +    }
    +    assert_eq!(
    +        run_case(b"fn f() { let (a, b) = (1, 2); }"),
    +        vec![("a".into(), 0), ("b".into(), 1)],
    +    );
    +    assert_eq!(
    +        run_case(b"fn f() { let (_, b) = (1, 2); }"),
    +        vec![("b".into(), 1)],
    +    );
    +    assert_eq!(
    +        run_case(b"fn f() { let (a, _) = (1, 2); }"),
    +        vec![("a".into(), 0)],
    +    );
    +    assert_eq!(
    +        run_case(b"fn f() { let (a, _, c) = (1, 2, 3); }"),
    +        vec![("a".into(), 0), ("c".into(), 2)],
    +    );
    +}
    +
    +/// Python `pattern_list` (bare `a, b = ...`) and `tuple_pattern`
    +/// (parenthesised `(a, b) = ...`) share the helper.  Python's `_` is
    +/// a normal identifier binding (not a wildcard), so every identifier
    +/// child emits a `(name, position)` entry — `_` lands at its source
    +/// position alongside any other names.  `list_splat_pattern`
    +/// (`a, *rest`) bails to empty so callers fall back to scalar union.
    +#[test]
    +fn pattern_list_indexed_bindings_recognise_python_destructure() {
    +    use super::helpers::collect_array_pattern_bindings_indexed;
    +    fn first_pattern<'t>(
    +        n: tree_sitter::Node<'t>,
    +        kinds: &[&str],
    +    ) -> Option> {
    +        if kinds.contains(&n.kind()) {
    +            return Some(n);
    +        }
    +        let mut c = n.walk();
    +        for child in n.children(&mut c) {
    +            if let Some(found) = first_pattern(child, kinds) {
    +                return Some(found);
    +            }
    +        }
    +        None
    +    }
    +    fn parse_first_python(src: &[u8]) -> (tree_sitter::Tree, Vec) {
    +        let mut parser = tree_sitter::Parser::new();
    +        parser
    +            .set_language(&Language::from(tree_sitter_python::LANGUAGE))
    +            .unwrap();
    +        let tree = parser.parse(src, None).unwrap();
    +        (tree, src.to_vec())
    +    }
    +    fn run_case(src: &[u8], kinds: &[&str]) -> Vec<(String, usize)> {
    +        let (tree, bytes) = parse_first_python(src);
    +        let pat = first_pattern(tree.root_node(), kinds)
    +            .unwrap_or_else(|| panic!("no {kinds:?} in fixture"));
    +        collect_array_pattern_bindings_indexed(pat, &bytes)
    +            .into_iter()
    +            .collect()
    +    }
    +    // Bare comma-list `a, b = ...` is `pattern_list`.
    +    assert_eq!(
    +        run_case(b"a, b = (1, 2)\n", &["pattern_list"]),
    +        vec![("a".into(), 0), ("b".into(), 1)],
    +    );
    +    // Three-binding bare comma list.
    +    assert_eq!(
    +        run_case(b"a, b, c = (1, 2, 3)\n", &["pattern_list"]),
    +        vec![("a".into(), 0), ("b".into(), 1), ("c".into(), 2)],
    +    );
    +    // Underscore is a regular identifier binding in Python.
    +    assert_eq!(
    +        run_case(b"_, b = (1, 2)\n", &["pattern_list"]),
    +        vec![("_".into(), 0), ("b".into(), 1)],
    +    );
    +    assert_eq!(
    +        run_case(b"a, _ = (1, 2)\n", &["pattern_list"]),
    +        vec![("a".into(), 0), ("_".into(), 1)],
    +    );
    +    // Parenthesised destructure surfaces as `tuple_pattern`.
    +    assert_eq!(
    +        run_case(b"(a, b) = (1, 2)\n", &["tuple_pattern"]),
    +        vec![("a".into(), 0), ("b".into(), 1)],
    +    );
    +    // Splat / rest bindings bail because positional mapping breaks.
    +    assert!(run_case(b"a, *rest = (1, 2, 3)\n", &["pattern_list"]).is_empty());
    +    // Nested destructure bails — recogniser doesn't recurse into
    +    // sub-patterns to preserve flat-binding-only semantics.
    +    assert!(run_case(b"(a, b), c = ((1, 2), 3)\n", &["pattern_list"]).is_empty());
    +}
    +
    +/// Ruby `left_assignment_list` is the LHS node tree-sitter-ruby produces
    +/// for `a, b = ...`.  The helper walks comma-separated identifier
    +/// children in source order, emitting `(name, position)` for each.
    +/// Ruby `_` is a normal identifier (matches Python convention).
    +/// `rest_assignment` (`*rest`) and `destructured_left_assignment`
    +/// (parenthesised nested destructure) hit the bail branch so callers
    +/// fall back to scalar union for those advanced shapes.
    +#[test]
    +fn left_assignment_list_indexed_bindings_recognise_ruby_destructure() {
    +    use super::helpers::collect_array_pattern_bindings_indexed;
    +    fn first_left_assignment_list<'t>(n: tree_sitter::Node<'t>) -> Option> {
    +        if n.kind() == "left_assignment_list" {
    +            return Some(n);
    +        }
    +        let mut c = n.walk();
    +        for child in n.children(&mut c) {
    +            if let Some(found) = first_left_assignment_list(child) {
    +                return Some(found);
    +            }
    +        }
    +        None
    +    }
    +    fn parse_first_ruby(src: &[u8]) -> (tree_sitter::Tree, Vec) {
    +        let mut parser = tree_sitter::Parser::new();
    +        parser
    +            .set_language(&Language::from(tree_sitter_ruby::LANGUAGE))
    +            .unwrap();
    +        let tree = parser.parse(src, None).unwrap();
    +        (tree, src.to_vec())
    +    }
    +    fn run_case(src: &[u8]) -> Vec<(String, usize)> {
    +        let (tree, bytes) = parse_first_ruby(src);
    +        let pat =
    +            first_left_assignment_list(tree.root_node()).expect("left_assignment_list in fixture");
    +        collect_array_pattern_bindings_indexed(pat, &bytes)
    +            .into_iter()
    +            .collect()
    +    }
    +    assert_eq!(
    +        run_case(b"a, b = [x, y]\n"),
    +        vec![("a".into(), 0), ("b".into(), 1)],
    +    );
    +    assert_eq!(
    +        run_case(b"a, b, c = [x, y, z]\n"),
    +        vec![("a".into(), 0), ("b".into(), 1), ("c".into(), 2)],
    +    );
    +    // Underscore is a regular identifier binding in Ruby (idiomatic
    +    // "unused" marker, but still resolvable in scope).
    +    assert_eq!(
    +        run_case(b"_, b = [x, y]\n"),
    +        vec![("_".into(), 0), ("b".into(), 1)],
    +    );
    +    assert_eq!(
    +        run_case(b"a, _ = [x, y]\n"),
    +        vec![("a".into(), 0), ("_".into(), 1)],
    +    );
    +    // Call return value, helper walks LHS regardless of RHS shape.
    +    assert_eq!(
    +        run_case(b"a, b = func()\n"),
    +        vec![("a".into(), 0), ("b".into(), 1)],
    +    );
    +    // Splat tail bails because rest_assignment is a complex sub-pattern.
    +    assert!(run_case(b"a, *rest = [x, y, z]\n").is_empty());
    +    // Parenthesised nested destructure bails because
    +    // destructured_left_assignment isn't in the simple-identifier
    +    // whitelist.
    +    assert!(run_case(b"(a, b) = [x, y]\n").is_empty());
    +}
    +
    +/// Helper for `src/ssa/lower.rs` bare-array destructure rewrite.
    +/// Walks the RHS of a destructure assignment and emits one slot per
    +/// source-order element. Each slot is `Ident(name)`, `Literal`, or
    +/// `Complex(inner_uses)`. Bails (empty) on shapes that shift index
    +/// alignment (spread / list splat).
    +#[test]
    +fn rhs_array_literal_elements_recognise_per_language_shapes() {
    +    use super::RhsArraySlot;
    +    use super::helpers::collect_rhs_array_literal_elements;
    +
    +    fn parse(lang_label: &str, src: &[u8]) -> (tree_sitter::Tree, Vec) {
    +        let mut parser = tree_sitter::Parser::new();
    +        let lang = match lang_label {
    +            "javascript" => Language::from(tree_sitter_javascript::LANGUAGE),
    +            "typescript" => Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT),
    +            "python" => Language::from(tree_sitter_python::LANGUAGE),
    +            "ruby" => Language::from(tree_sitter_ruby::LANGUAGE),
    +            "rust" => Language::from(tree_sitter_rust::LANGUAGE),
    +            other => panic!("unsupported lang: {}", other),
    +        };
    +        parser.set_language(&lang).unwrap();
    +        let tree = parser.parse(src, None).unwrap();
    +        (tree, src.to_vec())
    +    }
    +
    +    fn find_first<'t>(n: tree_sitter::Node<'t>, kinds: &[&str]) -> Option> {
    +        if kinds.iter().any(|k| *k == n.kind()) {
    +            return Some(n);
    +        }
    +        let mut c = n.walk();
    +        for child in n.children(&mut c) {
    +            if let Some(found) = find_first(child, kinds) {
    +                return Some(found);
    +            }
    +        }
    +        None
    +    }
    +
    +    fn run(lang: &str, src: &[u8], rhs_kinds: &[&str]) -> Vec {
    +        let (tree, bytes) = parse(lang, src);
    +        let rhs = find_first(tree.root_node(), rhs_kinds).expect("rhs in fixture");
    +        collect_rhs_array_literal_elements(rhs, lang, &bytes, None)
    +            .into_iter()
    +            .collect()
    +    }
    +
    +    fn ident(name: &str) -> RhsArraySlot {
    +        RhsArraySlot::Ident(name.to_string())
    +    }
    +    fn complex(uses: &[&str]) -> RhsArraySlot {
    +        RhsArraySlot::Complex {
    +            uses: uses.iter().map(|s| s.to_string()).collect(),
    +            source_cap: crate::labels::Cap::empty(),
    +        }
    +    }
    +    fn complex_source(uses: &[&str]) -> RhsArraySlot {
    +        RhsArraySlot::Complex {
    +            uses: uses.iter().map(|s| s.to_string()).collect(),
    +            source_cap: crate::labels::Cap::all(),
    +        }
    +    }
    +
    +    // JS/TS `array` literal: two bare idents.
    +    assert_eq!(
    +        run("javascript", b"const _ = [safe, tainted];\n", &["array"]),
    +        vec![ident("safe"), ident("tainted")],
    +    );
    +    // JS/TS `array` mixed ident + string literal.
    +    assert_eq!(
    +        run("javascript", b"const _ = [tainted, \"ok\"];\n", &["array"]),
    +        vec![ident("tainted"), RhsArraySlot::Literal],
    +    );
    +    // JS/TS now classifies a call as `Complex` carrying inner idents
    +    // rather than bailing. `collect_idents_with_paths` lifts both paths
    +    // and bare idents, so a member access surfaces as the dotted path
    +    // (e.g. `req.query.x`) followed by its component idents.
    +    assert_eq!(
    +        run("javascript", b"const _ = [fn(x), 'lit'];\n", &["array"]),
    +        vec![complex(&["fn", "x"]), RhsArraySlot::Literal],
    +    );
    +    // JS/TS member access becomes Complex; dotted path + component idents.
    +    // Per-slot Source classification fires when the slot's subtree carries
    +    // a member-expression that strip-and-retry-classifies as Source
    +    // (`req.query.x` → strip `.x` → `req.query` matches the JS Source rule).
    +    assert_eq!(
    +        run(
    +            "javascript",
    +            b"const _ = [req.query.x, 'lit'];\n",
    +            &["array"],
    +        ),
    +        vec![
    +            complex_source(&["req.query.x", "req", "query", "x"]),
    +            RhsArraySlot::Literal,
    +        ],
    +    );
    +    // Sibling-precision: a Source-classified Complex slot ALONGSIDE a
    +    // Complex slot whose subtree does NOT classify as Source. Pre-session
    +    // 0047 every Complex slot was conservatively re-emitted as Source by
    +    // the outer-node fallback in `src/ssa/lower.rs`; with per-slot
    +    // classification the safe sibling stays empty so the SSA lowering can
    +    // emit `Assign(safe)` instead.
    +    assert_eq!(
    +        run(
    +            "javascript",
    +            b"const _ = [process.env.X, helper(local)];\n",
    +            &["array"],
    +        ),
    +        vec![
    +            complex_source(&["process.env.X", "process", "env", "X"]),
    +            complex(&["helper", "local"]),
    +        ],
    +    );
    +    // JS/TS spread bails entirely (index alignment shifts).
    +    assert!(run("javascript", b"const _ = [...arr, b];\n", &["array"]).is_empty());
    +    // JS/TS binary expression becomes Complex with the inner ident.
    +    assert_eq!(
    +        run(
    +            "javascript",
    +            b"const _ = ['log-' + x, 'lit'];\n",
    +            &["array"],
    +        ),
    +        vec![complex(&["x"]), RhsArraySlot::Literal],
    +    );
    +
    +    // Python `list` shape.
    +    assert_eq!(
    +        run("python", b"a = [safe, tainted]\n", &["list"]),
    +        vec![ident("safe"), ident("tainted")],
    +    );
    +    // Python `expression_list` (bare commas RHS in `a, b = x, y`).
    +    assert_eq!(
    +        run("python", b"a, b = safe, tainted\n", &["expression_list"]),
    +        vec![ident("safe"), ident("tainted")],
    +    );
    +    // Python `tuple` (parenthesised).
    +    assert_eq!(
    +        run("python", b"x = (safe, 42)\n", &["tuple"]),
    +        vec![ident("safe"), RhsArraySlot::Literal],
    +    );
    +    // Python list-splat bails.
    +    assert!(run("python", b"x = [*a, b]\n", &["list"]).is_empty());
    +
    +    // Ruby `array`.
    +    assert_eq!(
    +        run("ruby", b"a, b = [safe, tainted]\n", &["array"]),
    +        vec![ident("safe"), ident("tainted")],
    +    );
    +    // Ruby `array` with literal + ident.
    +    assert_eq!(
    +        run("ruby", b"a, b = [tainted, \"safe\"]\n", &["array"]),
    +        vec![ident("tainted"), RhsArraySlot::Literal],
    +    );
    +
    +    // Rust `tuple_expression`.
    +    assert_eq!(
    +        run(
    +            "rust",
    +            b"fn f(safe: &str, tainted: &str) { let _ = (safe, tainted); }\n",
    +            &["tuple_expression"]
    +        ),
    +        vec![ident("safe"), ident("tainted")],
    +    );
    +
    +    // Non-array-shape node returns empty (defensive guard).
    +    assert!(run("javascript", b"const x = tainted;\n", &["identifier"]).is_empty());
    +}
    diff --git a/src/cfg/conditions.rs b/src/cfg/conditions.rs
    index 9e999f74..85863530 100644
    --- a/src/cfg/conditions.rs
    +++ b/src/cfg/conditions.rs
    @@ -2,7 +2,7 @@ use super::helpers::first_member_label;
     use super::{
         AstMeta, Cfg, EdgeKind, MAX_COND_VARS, MAX_CONDITION_TEXT_LEN, NodeInfo, StmtKind,
         collect_idents, connect_all, detect_eq_with_const, detect_negation, has_call_descendant,
    -    member_expr_text, push_node, text_of,
    +    member_expr_text, push_node, text_of, try_lower_jsx_dangerous_html,
     };
     use crate::labels::{DataLabel, LangAnalysisRules, classify};
     use crate::utils::snippet::truncate_at_char_boundary;
    @@ -378,7 +378,24 @@ pub(super) fn lower_ternary_branch<'a>(
         }
     
         connect_all(g, preds, node, pred_edge);
    -    vec![node]
    +
    +    // React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis when the
    +    // branch expression is itself a JSX element (or contains one as a
    +    // descendant).  Without this, `cond ? 
    : null` and similar ternary-RHS shapes never reach the + // `Kind::Return` / `Kind::Assignment` arms that own the synthesis hook, + // because `build_ternary_diamond` lowers each branch directly. + let post_jsx = try_lower_jsx_dangerous_html( + branch_ast, + &[node], + g, + lang, + code, + enclosing_func, + call_ordinal, + analysis_rules, + ); + post_jsx } /// Extract `(lhs_ast, ternary_ast)` when `outer_ast` is an expression-statement diff --git a/src/cfg/decorators.rs b/src/cfg/decorators.rs index 8864d21a..26f00556 100644 --- a/src/cfg/decorators.rs +++ b/src/cfg/decorators.rs @@ -554,3 +554,469 @@ fn collect_ruby_symbol_list(node: Node<'_>, code: &[u8], out: &mut Vec) _ => {} } } + +/// Extract route-path capture variable names from framework routing decorators +/// on a function AST node. +/// +/// Supported languages: +/// * Python: walks Flask-style `@app.route("/users/")`, +/// blueprint-prefixed `@bp.get("/u/")`, and verb-shaped +/// `@router.post("/")` decorators. Returns inner names from +/// `` / `` brace-segments. +/// * Ruby: walks Sinatra `get "/u/:name" do |name| ... end`. The +/// `func_node` is the `do_block`; its parent `call` carries the verb +/// in the `method` field and the path pattern in the first positional +/// string argument. Returns inner names from `:name` colon-segments. +/// +/// Functions without a recognised routing pattern return an empty `Vec`. +/// Strict additive: downstream consumers gate the result via +/// `param.contains(name)` so empty captures preserve today's behaviour. +pub(super) fn extract_route_path_captures<'a>( + func_node: Node<'a>, + lang: &str, + code: &'a [u8], +) -> Vec { + let mut out: Vec = Vec::new(); + match lang { + "python" => extract_python_route_captures(func_node, code, &mut out), + "ruby" => extract_ruby_route_captures(func_node, code, &mut out), + _ => {} + } + out +} + +fn extract_python_route_captures<'a>(func_node: Node<'a>, code: &'a [u8], out: &mut Vec) { + let Some(parent) = func_node.parent() else { + return; + }; + if parent.kind() != "decorated_definition" { + return; + } + let mut w = parent.walk(); + for ch in parent.children(&mut w) { + if ch.kind() != "decorator" { + continue; + } + let mut dw = ch.walk(); + let Some(expr) = ch.children(&mut dw).find(|c| c.kind() != "@") else { + continue; + }; + if expr.kind() != "call" { + continue; + } + let Some(target) = expr.child_by_field_name("function") else { + continue; + }; + if target.kind() != "attribute" { + continue; + } + let Some(attr) = target.child_by_field_name("attribute") else { + continue; + }; + let Some(attr_text) = text_of(attr, code) else { + continue; + }; + let attr_lower = attr_text.to_ascii_lowercase(); + let is_route_verb = matches!( + attr_lower.as_str(), + "route" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options" + ); + if !is_route_verb { + continue; + } + let Some(args) = expr.child_by_field_name("arguments") else { + continue; + }; + let Some(pattern) = first_positional_string_arg(args, code) else { + continue; + }; + collect_flask_path_captures(&pattern, out); + collect_fastapi_path_captures(&pattern, out); + } +} + +/// Walk up from a Ruby `do_block` / `block` to the enclosing `call`. +/// If the call's method is a Sinatra-style HTTP verb and its first +/// positional argument is a static string literal, parse Sinatra +/// `:name` path captures into `out`. +fn extract_ruby_route_captures<'a>(func_node: Node<'a>, code: &'a [u8], out: &mut Vec) { + let Some(parent) = func_node.parent() else { + return; + }; + if parent.kind() != "call" { + return; + } + let Some(method_node) = parent.child_by_field_name("method") else { + return; + }; + let Some(verb) = text_of(method_node, code) else { + return; + }; + let verb_lc = verb.to_ascii_lowercase(); + let is_sinatra_verb = matches!( + verb_lc.as_str(), + "get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "link" | "unlink" + ); + if !is_sinatra_verb { + return; + } + let Some(args) = parent.child_by_field_name("arguments") else { + return; + }; + let Some(pattern) = first_positional_string_arg_ruby(args, code) else { + return; + }; + collect_sinatra_path_captures(&pattern, out); +} + +/// Return the literal text of the first positional string argument inside a +/// Python `argument_list`. Skips keyword args and non-string positionals. +fn first_positional_string_arg(args: Node<'_>, code: &[u8]) -> Option { + let mut cursor = args.walk(); + for arg in args.children(&mut cursor) { + match arg.kind() { + "(" | ")" | "," => continue, + "keyword_argument" => continue, + "string" => { + return python_string_text(arg, code); + } + _ => return None, + } + } + None +} + +/// Strip Python string-literal quoting from a `string` AST node. Rejects +/// f-strings (interpolation children present) because the captured pattern +/// is not statically known. +fn python_string_text(node: Node<'_>, code: &[u8]) -> Option { + let mut cursor = node.walk(); + for ch in node.children(&mut cursor) { + if ch.kind() == "interpolation" { + return None; + } + } + let raw = text_of(node, code)?; + let trimmed = raw.trim(); + let trimmed = trimmed.trim_start_matches(['r', 'R', 'b', 'B', 'u', 'U', 'f', 'F']); + let stripped = trimmed + .strip_prefix("\"\"\"") + .and_then(|s| s.strip_suffix("\"\"\"")) + .or_else(|| { + trimmed + .strip_prefix("'''") + .and_then(|s| s.strip_suffix("'''")) + }) + .or_else(|| trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"'))) + .or_else(|| { + trimmed + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + })?; + Some(stripped.to_string()) +} + +/// Return the literal text of the first positional string argument inside a +/// Ruby `argument_list`. Hash literals (`pair`), block arguments, +/// hash-splat arguments, and non-string positionals all return `None`. +fn first_positional_string_arg_ruby(args: Node<'_>, code: &[u8]) -> Option { + let mut cursor = args.walk(); + for arg in args.children(&mut cursor) { + match arg.kind() { + "(" | ")" | "," => continue, + "pair" | "hash" | "block_argument" | "hash_splat_argument" => return None, + "string" => return ruby_string_text(arg, code), + _ => return None, + } + } + None +} + +/// Strip Ruby string-literal quoting from a `string` AST node. Rejects +/// strings with `#{...}` interpolation (the captured pattern is not +/// statically known). Returns the concatenation of `string_content` +/// children. +fn ruby_string_text(node: Node<'_>, code: &[u8]) -> Option { + let mut cursor = node.walk(); + let mut content = String::new(); + let mut had_content = false; + for ch in node.children(&mut cursor) { + match ch.kind() { + "interpolation" => return None, + "string_content" => { + if let Some(t) = text_of(ch, code) { + content.push_str(&t); + had_content = true; + } + } + _ => continue, + } + } + if had_content { Some(content) } else { None } +} + +/// Parse Sinatra-style `:name` capture segments out of a route pattern. +/// A capture is a `:` followed by an identifier-ish run of bytes +/// (`[A-Za-z0-9_]+`). Only fires when `:` is at pattern start or +/// immediately follows `/`, so `Foo::Bar` style names embedded in a +/// non-routing string are not mis-parsed as captures. +fn collect_sinatra_path_captures(pattern: &str, out: &mut Vec) { + let bytes = pattern.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let at_segment_boundary = i == 0 || bytes[i - 1] == b'/'; + if bytes[i] == b':' && at_segment_boundary { + let mut j = i + 1; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; + } + if j > i + 1 { + let name = &pattern[i + 1..j]; + let lower = name.to_ascii_lowercase(); + if !out.iter().any(|existing| existing == &lower) { + out.push(lower); + } + } + i = j; + } else { + i += 1; + } + } +} + +/// Parse FastAPI / Starlette-style `{name}` / `{name:converter}` capture +/// segments out of a route pattern. Pushes the inner name (lowercased) +/// into `out`. FastAPI puts the name FIRST (`{item_id:int}`), unlike +/// Flask which puts the converter first (``). Skips +/// malformed segments (no closing `}`, empty name) and rejects names +/// with non-identifier characters. +fn collect_fastapi_path_captures(pattern: &str, out: &mut Vec) { + let bytes = pattern.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' { + let mut j = i + 1; + while j < bytes.len() && bytes[j] != b'}' { + j += 1; + } + if j >= bytes.len() { + break; + } + let inner = &pattern[i + 1..j]; + let name = inner.split(':').next().unwrap_or(inner).trim(); + if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + let lower = name.to_ascii_lowercase(); + if !out.iter().any(|existing| existing == &lower) { + out.push(lower); + } + } + i = j + 1; + } else { + i += 1; + } + } +} + +/// Parse Flask-style `` / `` capture segments out of a +/// route pattern. Pushes the inner name (lowercased) into `out`. Skips +/// malformed segments (no closing `>`, empty name). +fn collect_flask_path_captures(pattern: &str, out: &mut Vec) { + let bytes = pattern.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'<' { + let mut j = i + 1; + while j < bytes.len() && bytes[j] != b'>' { + j += 1; + } + if j >= bytes.len() { + break; + } + let inner = &pattern[i + 1..j]; + let name = match inner.rsplit_once(':') { + Some((_, n)) => n, + None => inner, + }; + let name = name.trim(); + if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + let lower = name.to_ascii_lowercase(); + if !out.iter().any(|existing| existing == &lower) { + out.push(lower); + } + } + i = j + 1; + } else { + i += 1; + } + } +} + +#[cfg(test)] +mod path_capture_tests { + use super::*; + + fn collect_for(pat: &str) -> Vec { + let mut out = Vec::new(); + collect_flask_path_captures(pat, &mut out); + out + } + + #[test] + fn extracts_bare_capture() { + assert_eq!(collect_for("/users/"), vec!["name".to_string()]); + } + + #[test] + fn extracts_converter_capture() { + assert_eq!( + collect_for("/items/"), + vec!["item_id".to_string()] + ); + } + + #[test] + fn extracts_path_converter() { + assert_eq!(collect_for("/x/"), vec!["slug".to_string()]); + } + + #[test] + fn extracts_multiple_captures() { + assert_eq!( + collect_for("/u//post/"), + vec!["uid".to_string(), "pid".to_string()] + ); + } + + #[test] + fn dedupes_repeated_names() { + let mut out = Vec::new(); + collect_flask_path_captures("//", &mut out); + assert_eq!(out, vec!["a".to_string()]); + } + + #[test] + fn rejects_unclosed_brace() { + assert_eq!(collect_for("/::new()); + } + + #[test] + fn rejects_non_ident_chars() { + assert_eq!(collect_for("/"), Vec::::new()); + assert_eq!(collect_for("/"), Vec::::new()); + } + + #[test] + fn empty_when_no_captures() { + assert_eq!(collect_for("/static/path"), Vec::::new()); + } + + fn collect_sinatra_for(pat: &str) -> Vec { + let mut out = Vec::new(); + collect_sinatra_path_captures(pat, &mut out); + out + } + + #[test] + fn sinatra_extracts_bare_capture() { + assert_eq!( + collect_sinatra_for("/users/:name"), + vec!["name".to_string()] + ); + } + + #[test] + fn sinatra_extracts_multiple_captures() { + assert_eq!( + collect_sinatra_for("/u/:uid/post/:pid"), + vec!["uid".to_string(), "pid".to_string()] + ); + } + + #[test] + fn sinatra_extracts_leading_capture() { + assert_eq!(collect_sinatra_for(":root"), vec!["root".to_string()]); + } + + #[test] + fn sinatra_dedupes_repeated_names() { + let mut out = Vec::new(); + collect_sinatra_path_captures("/:a/:a", &mut out); + assert_eq!(out, vec!["a".to_string()]); + } + + #[test] + fn sinatra_ignores_double_colon() { + assert_eq!(collect_sinatra_for("/Foo::Bar"), Vec::::new()); + } + + #[test] + fn sinatra_ignores_lone_colon() { + assert_eq!(collect_sinatra_for("/users/:"), Vec::::new()); + } + + #[test] + fn sinatra_empty_when_no_captures() { + assert_eq!(collect_sinatra_for("/static/path"), Vec::::new()); + } + + fn collect_fastapi_for(pat: &str) -> Vec { + let mut out = Vec::new(); + collect_fastapi_path_captures(pat, &mut out); + out + } + + #[test] + fn fastapi_extracts_bare_capture() { + assert_eq!( + collect_fastapi_for("/items/{item_id}"), + vec!["item_id".to_string()] + ); + } + + #[test] + fn fastapi_extracts_converter_capture() { + assert_eq!( + collect_fastapi_for("/items/{item_id:int}"), + vec!["item_id".to_string()] + ); + } + + #[test] + fn fastapi_extracts_path_converter() { + assert_eq!( + collect_fastapi_for("/files/{file_path:path}"), + vec!["file_path".to_string()] + ); + } + + #[test] + fn fastapi_extracts_multiple_captures() { + assert_eq!( + collect_fastapi_for("/u/{uid}/post/{pid:int}"), + vec!["uid".to_string(), "pid".to_string()] + ); + } + + #[test] + fn fastapi_dedupes_repeated_names() { + let mut out = Vec::new(); + collect_fastapi_path_captures("/{a}/{a}", &mut out); + assert_eq!(out, vec!["a".to_string()]); + } + + #[test] + fn fastapi_rejects_unclosed_brace() { + assert_eq!(collect_fastapi_for("/{oops"), Vec::::new()); + } + + #[test] + fn fastapi_rejects_non_ident_chars() { + assert_eq!(collect_fastapi_for("/{bad name}"), Vec::::new()); + assert_eq!(collect_fastapi_for("/{name!}"), Vec::::new()); + } + + #[test] + fn fastapi_empty_when_no_captures() { + assert_eq!(collect_fastapi_for("/static/path"), Vec::::new()); + } +} diff --git a/src/cfg/helpers.rs b/src/cfg/helpers.rs index c6407794..792ce152 100644 --- a/src/cfg/helpers.rs +++ b/src/cfg/helpers.rs @@ -1,6 +1,7 @@ use super::anon_fn_name; use super::conditions::unwrap_parens; use crate::labels::{DataLabel, Kind, classify, lookup}; +use smallvec::SmallVec; use tree_sitter::Node; // ------------------------------------------------------------------------- @@ -210,7 +211,7 @@ pub(crate) fn first_call_ident_with_span<'a>( .and_then(|f| root_receiver_text(f, lang, code)); match (recv, func) { (Some(r), Some(f)) => Some(format!("{r}.{f}")), - (_, Some(f)) => Some(f.to_string()), + (_, Some(f)) => Some(f), _ => None, } } @@ -269,6 +270,11 @@ pub(crate) fn find_classifiable_inner_call<'a>( } match lookup(lang, c.kind()) { Kind::CallFn | Kind::CallMethod | Kind::CallMacro => { + // For CallMethod we also remember the bare receiver + // identifier so we can try a type-qualified rewrite + // when the literal classify misses. + let mut method_receiver: Option = None; + let mut method_name: Option = None; let ident = match lookup(lang, c.kind()) { Kind::CallFn => c .child_by_field_name("function") @@ -286,6 +292,8 @@ pub(crate) fn find_classifiable_inner_call<'a>( .or_else(|| c.child_by_field_name("receiver")) .or_else(|| c.child_by_field_name("scope")) .and_then(|f| root_receiver_text(f, lang, code)); + method_receiver = recv.clone(); + method_name = func.clone(); match (recv, func) { (Some(r), Some(f)) => Some(format!("{r}.{f}")), (_, Some(f)) => Some(f), @@ -302,6 +310,36 @@ pub(crate) fn find_classifiable_inner_call<'a>( { return Some((id.clone(), lbl, (c.start_byte(), c.end_byte()))); } + // Receiver-type rewrite fallback: when the literal + // `recv.method` text didn't classify, AND we're inside + // a chained call (parent `n` is itself a call), look + // up `recv`'s locally-bound type and retry with the + // type prefix. E.g. for + // `sess.createNativeQuery(sql).getResultList()`, the + // inner `sess.createNativeQuery` rewrites to + // `HibernateSession.createNativeQuery` (rule fires). + // + // Gated on `n` being a Call-kind so the rewrite only + // fires on chain-hop inner calls. When `n` is an + // expression-statement / variable-declarator / etc. + // the candidate `c` IS the outermost call of the + // statement, and the SSA-time + // `resolve_type_qualified_labels` path handles it + // with multi-label semantics that single-label + // `classify` here would erase. + let parent_is_call = matches!( + lookup(lang, n.kind()), + Kind::CallFn | Kind::CallMethod | Kind::CallMacro + ); + if parent_is_call + && let (Some(recv), Some(method)) = (method_receiver, method_name) + && let Some(prefix) = crate::cfg::local_receiver_type_prefix(c, &recv, lang) + { + let alt = format!("{prefix}.{method}"); + if let Some(lbl) = classify(lang, &alt, extra) { + return Some((alt, lbl, (c.start_byte(), c.end_byte()))); + } + } // Recurse into arguments of this call if let Some(found) = find_classifiable_inner_call(c, lang, code, extra) { return Some(found); @@ -412,6 +450,16 @@ pub(crate) fn first_member_label( } // PHP/Python/Ruby subscript access: `$_GET['cmd']`, `os.environ['KEY']`, `params[:cmd]` // Try to classify the object (before the `[`) as a source. + // + // Source-only on the receiver: a subscript reads a value from the + // receiver, so a Sink label found on the receiver text (e.g. + // `response.headers['content-type']`, where `response.headers` + // matches the JS HEADER_INJECTION sink rule) describes the + // *target* of a hypothetical write, not this read. Promoting it + // would fire phantom sinks at every `body = + // response.headers["X"]`-shape line. Sinks/Sanitizers reachable + // via callable positions (function-arg, method-receiver) still + // flow through the outer recursive walk below. "subscript_expression" | "subscript" | "element_reference" => { if let Some(obj) = n .child_by_field_name("object") @@ -419,15 +467,23 @@ pub(crate) fn first_member_label( .or_else(|| n.child(0)) { if let Some(txt) = text_of(obj, code) - && let Some(lbl) = classify(lang, &txt, extra_labels) + && let Some(lbl @ DataLabel::Source(_)) = classify(lang, &txt, extra_labels) { return Some(lbl); } - // Recurse into the object for nested member accesses - if let Some(lbl) = first_member_label(obj, lang, code, extra_labels) { + // Recurse into the object for nested member accesses, but + // keep the same Source-only restriction as above by passing + // through the dedicated source-only walker. + if let Some(lbl @ DataLabel::Source(_)) = + first_member_label(obj, lang, code, extra_labels) + { return Some(lbl); } } + // Suppress further descent into this subscript node, the outer + // child-walk loop would otherwise enter the receiver via the + // member_expression arm and reattach a value-extraction Sink. + return None; } _ => {} } @@ -678,6 +734,7 @@ pub(crate) fn collect_idents_with_paths( "identifier" | "field_identifier" | "property_identifier" + | "shorthand_property_identifier" | "shorthand_property_identifier_pattern" => { if let Some(txt) = text_of(n, code) { idents.push(txt); @@ -697,16 +754,241 @@ pub(crate) fn collect_idents_with_paths( } } +/// Walk an array/tuple destructure pattern in source order and return +/// each simple-identifier binding paired with its position index. +/// +/// Recognises: +/// * JS/TS `array_pattern` — `const [a, b] = ...`, `const [, b] = ...`, +/// `const [a, ,] = ...`. Skip slots (commas with no binding between) +/// advance the position counter without emitting a binding. +/// * Rust `tuple_pattern` — `let (a, _, b) = ...`. `_pattern` (wildcard) +/// advances the position counter without emitting a binding. +/// * Python `pattern_list` / `tuple_pattern` — `a, b = ...` and +/// `(a, b) = ...`. Python `_` is a normal identifier binding (not a +/// wildcard), so every `identifier` child emits a (name, position) +/// entry. +/// * Ruby `left_assignment_list` — `a, b = ...`. Bare comma-list LHS +/// produced by `assignment` whose RHS is an array literal, a call +/// return, or another tuple-yielding expression. Ruby `_` is a normal +/// identifier (matches Python convention; `_` may still be referenced +/// later in scope). Splat (`*rest` parsed as `rest_assignment`) and +/// parenthesised nested destructure (`destructured_left_assignment`) +/// hit the bail branch and fall back to scalar union. +/// +/// Returns an empty `SmallVec` when the pattern is not one of the above +/// kinds OR contains complex sub-patterns (`assignment_pattern` for +/// `[a = 1, b]`, `rest_pattern` for `[a, ...rest]`, Python +/// `list_splat_pattern` for `a, *rest = ...`, Ruby `rest_assignment` for +/// `a, *rest = ...`, nested `array_pattern`, `object_pattern`, +/// `destructured_left_assignment`). Callers treat the empty return as +/// "no position-aware rewrite available; fall back to scalar union". +pub(crate) fn collect_array_pattern_bindings_indexed( + pat: Node, + code: &[u8], +) -> SmallVec<[(String, usize); 4]> { + let mut out: SmallVec<[(String, usize); 4]> = SmallVec::new(); + let kind = pat.kind(); + if !matches!( + kind, + "array_pattern" | "tuple_pattern" | "pattern_list" | "left_assignment_list" + ) { + return out; + } + let mut cursor = pat.walk(); + let mut pos: usize = 0; + for child in pat.children(&mut cursor) { + match child.kind() { + "[" | "]" | "(" | ")" => {} + "," => { + pos += 1; + } + "identifier" | "shorthand_property_identifier_pattern" => { + if let Some(txt) = text_of(child, code) { + out.push((txt, pos)); + } + } + // Rust wildcard `_` in tuple_pattern. Advances position counter + // without binding; no emit. Tree-sitter-rust models the + // wildcard as a leaf node whose `kind()` is literally "_". + "_" => {} + _ => { + // Complex sub-pattern. Bail by clearing — caller treats + // empty as "no position-aware rewrite", preserving the + // pre-existing scalar-union behavior for these shapes. + out.clear(); + return out; + } + } + } + out +} + +/// Walk an array-literal-shape RHS node and return one slot per source-order +/// element. Each slot is one of: +/// * `RhsArraySlot::Ident(name)` — bare identifier element. +/// * `RhsArraySlot::Literal` — syntactic literal (string, number, bool, +/// null/nil). +/// * `RhsArraySlot::Complex(uses)` — call / binary / subscript / member +/// access / nested array literal / etc. `uses` carries the inner +/// identifier names (member-access paths first, bare idents second) +/// harvested from the slot's subtree via `collect_idents_with_paths`. +/// +/// Recognised RHS kinds: +/// * JS/TS / Ruby `array` — `[a, b]` +/// * Python `list` — `[a, b]` +/// * Python `tuple` — `(a, b)` +/// * Python `expression_list` — bare comma form `a, b` +/// * Rust `tuple_expression` — `(a, b)` +/// +/// Bails (returns empty) when the RHS is not one of these kinds OR contains +/// a slot whose shape would shift index alignment (spread, list splat). +/// Callers treat empty as "no per-element rewrite available; fall back to +/// scalar union". +pub(crate) fn collect_rhs_array_literal_elements( + rhs: Node, + lang: &str, + code: &[u8], + extra_labels: Option<&[crate::labels::RuntimeLabelRule]>, +) -> SmallVec<[crate::cfg::RhsArraySlot; 4]> { + use crate::cfg::RhsArraySlot; + use crate::labels::{Cap, DataLabel}; + + // Per-slot source classification: when a slot's own subtree carries a + // Source-labeled member-expression / subscript, capture the Cap so the + // SSA destructure rewrite emits Source for THIS slot specifically and + // lets sibling Complex slots stay slot-scoped Assign. Falls back to + // Cap::empty() when no per-slot source is recognised; the lowering + // path then consults the outer-node Source flag for conservative + // preservation of legacy behavior on shapes whose source pattern + // doesn't text-classify (e.g. a subscript on a tainted local). + let slot_source_cap = |slot: Node| -> Cap { + match first_member_label(slot, lang, code, extra_labels) { + Some(DataLabel::Source(c)) => c, + _ => Cap::empty(), + } + }; + + let mut out: SmallVec<[RhsArraySlot; 4]> = SmallVec::new(); + let kind = rhs.kind(); + if !matches!( + kind, + "array" | "array_literal" | "list" | "tuple" | "tuple_expression" | "expression_list" + ) { + return out; + } + let mut cursor = rhs.walk(); + for child in rhs.named_children(&mut cursor) { + let ck = child.kind(); + match ck { + "identifier" + | "shorthand_property_identifier" + | "shorthand_property_identifier_pattern" + | "field_identifier" + | "property_identifier" => match text_of(child, code) { + Some(txt) => out.push(RhsArraySlot::Ident(txt)), + None => { + out.clear(); + return out; + } + }, + "variable_name" => match text_of(child, code) { + Some(txt) => out.push(RhsArraySlot::Ident(txt.trim_start_matches('$').to_string())), + None => { + out.clear(); + return out; + } + }, + // Syntactic literal slots: no ident, no taint contribution. + // Names follow tree-sitter's per-grammar literal kinds across + // the supported languages. + "string" + | "string_literal" + | "raw_string_literal" + | "interpreted_string_literal" + | "concatenated_string" + | "integer" + | "integer_literal" + | "float" + | "float_literal" + | "number" + | "numeric_literal" + | "true" + | "false" + | "boolean_literal" + | "boolean" + | "null" + | "null_literal" + | "nil" + | "none" + | "None" + | "undefined" => { + out.push(RhsArraySlot::Literal); + } + // Spread / list-splat shift index alignment unpredictably + // (`[...arr, b]` may expand to N elements at index 0). Bail + // so callers fall back to scalar union. + "spread_element" | "list_splat" | "list_splat_pattern" | "splat_argument" + | "unary_splat" | "splat_expression" => { + out.clear(); + return out; + } + // Interpolated strings carry inner identifier uses. Treat as + // Complex so the slot picks up the contributions from + // `${user.id}` etc. + "template_string" | "string_interpolation" | "interpolation" | "encapsed_string" => { + let mut idents = Vec::new(); + let mut paths = Vec::new(); + collect_idents_with_paths(child, code, &mut idents, &mut paths); + let mut uses: SmallVec<[String; 4]> = SmallVec::new(); + for p in paths { + uses.push(p); + } + for ident in idents { + if !uses.iter().any(|u| u == &ident) { + uses.push(ident); + } + } + let source_cap = slot_source_cap(child); + out.push(RhsArraySlot::Complex { uses, source_cap }); + } + // Everything else (call, member access, binary, subscript, + // unary, ternary, nested array literal, etc.) is a "complex" + // slot. Harvest inner ident uses so the SSA lowering can paint + // the binding with this slot's contributions only — not the + // union of every ident on the RHS. + _ => { + let mut idents = Vec::new(); + let mut paths = Vec::new(); + collect_idents_with_paths(child, code, &mut idents, &mut paths); + let mut uses: SmallVec<[String; 4]> = SmallVec::new(); + for p in paths { + uses.push(p); + } + for ident in idents { + if !uses.iter().any(|u| u == &ident) { + uses.push(ident); + } + } + let source_cap = slot_source_cap(child); + out.push(RhsArraySlot::Complex { uses, source_cap }); + } + } + } + out +} + /// Recursively collect every identifier that occurs inside `n`. /// /// Recognises `identifier` (most languages), `variable_name` (PHP), /// `field_identifier` (Go), `property_identifier` (JS/TS), and -/// `shorthand_property_identifier_pattern` (JS/TS destructuring). +/// `shorthand_property_identifier` / `shorthand_property_identifier_pattern` +/// (JS/TS object-literal shorthand uses and destructuring binding patterns). pub(crate) fn collect_idents(n: Node, code: &[u8], out: &mut Vec) { match n.kind() { "identifier" | "field_identifier" | "property_identifier" + | "shorthand_property_identifier" | "shorthand_property_identifier_pattern" // PHP `name`: leaf node carrying the bare identifier text for // function/method names and similar grammar slots. Without this diff --git a/src/cfg/hierarchy.rs b/src/cfg/hierarchy.rs index db7c6fc8..3a6a4789 100644 --- a/src/cfg/hierarchy.rs +++ b/src/cfg/hierarchy.rs @@ -337,7 +337,7 @@ fn collect_ruby(root: Node<'_>, code: &[u8], push: &mu && let Some(t) = text_of(c, code) { let leaf = t.rsplit("::").next().unwrap_or(&t).to_string(); - push(sub.clone(), leaf); + push(sub, leaf); break; } } diff --git a/src/cfg/imports.rs b/src/cfg/imports.rs index 58ba2513..c50aa040 100644 --- a/src/cfg/imports.rs +++ b/src/cfg/imports.rs @@ -1,8 +1,140 @@ use super::{ ImportBinding, ImportBindings, PromisifyAlias, PromisifyAliases, member_expr_text, text_of, }; +use std::collections::HashMap; use tree_sitter::{Node, Tree}; +/// File-local view of every JS/TS import binding: local-name → source-module +/// specifier (verbatim from the `import` / `require` site, without `node:` +/// stripping). Built once per CFG pass; consumed by the gated-label +/// post-pass via [`crate::labels::ClassificationContext::local_imports`]. +/// +/// Records every binding regardless of aliasing (the legacy +/// [`extract_import_bindings`] only preserves *renamed* bindings, which is +/// not enough for Phase 05's `import { readFile } from 'fs/promises'` +/// shape where `local_name == imported_name`). +/// +/// Shares its top-level walk with [`crate::resolve::walk_js_top_level_imports`] +/// so the import-clause / require-declarator parsing logic only lives in one +/// place; this view simply discards the resolver verdict and side-effect-only +/// markers. +pub(super) fn extract_local_import_view(tree: &Tree, code: &[u8]) -> HashMap { + let mut out: HashMap = HashMap::new(); + for raw in crate::resolve::walk_js_top_level_imports(tree, code) { + if raw.local.is_empty() { + continue; + } + out.insert(raw.local, raw.source_spec); + } + extend_with_promises_alias(tree, code, &mut out); + out +} + +/// Recognise top-level `const fsp = fs.promises;` / +/// `const fsp = require('fs').promises;` aliasing and add the new local +/// name to the import view as `fs/promises` (or `node:fs/promises`, +/// whichever the source binding spelt). +/// +/// The Phase 05 `LabelGate::ImportedFromModule(&["fs/promises", ...])` +/// only consults `local_imports[leading_identifier(callee)]`. Without +/// this extension, `fsp.readFile(x)` evades the gate because `fsp` +/// itself is not an import binding — only the underlying `fs` +/// namespace is. +fn extend_with_promises_alias(tree: &Tree, code: &[u8], out: &mut HashMap) { + let root = tree.root_node(); + let mut top_cursor = root.walk(); + for child in root.children(&mut top_cursor) { + if !matches!(child.kind(), "lexical_declaration" | "variable_declaration") { + continue; + } + let mut decl_cursor = child.walk(); + for decl in child.children(&mut decl_cursor) { + if decl.kind() != "variable_declarator" { + continue; + } + let (Some(name_node), Some(value_node)) = ( + decl.child_by_field_name("name"), + decl.child_by_field_name("value"), + ) else { + continue; + }; + if name_node.kind() != "identifier" { + continue; + } + let Some(local_name) = text_of(name_node, code) else { + continue; + }; + if value_node.kind() != "member_expression" { + continue; + } + let property = value_node + .child_by_field_name("property") + .and_then(|p| text_of(p, code)); + if property.as_deref() != Some("promises") { + continue; + } + let Some(obj) = value_node.child_by_field_name("object") else { + continue; + }; + let Some(source) = promises_alias_source(obj, code, out) else { + continue; + }; + // Don't override an existing import entry for the same name — + // an explicit import of `fsp` from `fs/promises` already says + // what we'd be inferring here. + out.entry(local_name).or_insert(source); + } + } +} + +/// Resolve the object side of a ` = .promises` member-expression +/// to a source-module string when `` is a known `fs` binding. +/// +/// Recognised shapes: +/// - identifier `X` where `local_imports[X]` is `fs` or `node:fs` +/// - `require('fs')` / `require("node:fs")` call expression +fn promises_alias_source( + obj: Node, + code: &[u8], + imports_so_far: &HashMap, +) -> Option { + match obj.kind() { + "identifier" => { + let id = text_of(obj, code)?; + let module = imports_so_far.get(&id)?; + map_fs_module_to_promises(module) + } + "call_expression" => { + let func = obj.child_by_field_name("function")?; + if text_of(func, code).as_deref() != Some("require") { + return None; + } + let args = obj.child_by_field_name("arguments")?; + let mut cursor = args.walk(); + for arg in args.children(&mut cursor) { + if !matches!(arg.kind(), "string" | "template_string") { + continue; + } + let raw = text_of(arg, code)?; + let spec = raw.trim_matches(|c: char| c == '\'' || c == '"' || c == '`'); + return map_fs_module_to_promises(spec); + } + None + } + _ => None, + } +} + +fn map_fs_module_to_promises(module: &str) -> Option { + if module.eq_ignore_ascii_case("fs") { + Some("fs/promises".to_string()) + } else if module.eq_ignore_ascii_case("node:fs") { + Some("node:fs/promises".to_string()) + } else { + None + } +} + // ------------------------------------------------------------------------- // Import binding extraction // ------------------------------------------------------------------------- @@ -360,6 +492,129 @@ fn extract_require_module(node: Node, code: &[u8]) -> Option { None } +/// Per-file Rust scan: did the file `use` a join-style macro from `tokio` or +/// `futures`? Returns the crate prefix to use when the file calls a bare +/// `join!` / `try_join!` macro. +/// +/// Rationale: tree-sitter records `tokio::join!(...)` with a fully qualified +/// `macro` field text, but `use tokio::join; ... join!(a, b)` records the +/// bare leaf. Without this lookup, the SSA-level promise-combinator +/// recogniser (`crate::labels::is_promise_combinator`) misses the bare form +/// and the macro's argument taint is dropped. Conservative: returns `None` +/// when both `tokio::` and `futures::` are imported (ambiguous) +/// or when neither is, leaving the bare `join` callee alone. +pub(super) fn rust_bare_join_crate_prefix( + root: Node, + code: &[u8], + leaf: &str, +) -> Option<&'static str> { + if !matches!(leaf, "join" | "try_join") { + return None; + } + let mut cursor = root.walk(); + let mut tokio_seen = false; + let mut futures_seen = false; + for child in root.children(&mut cursor) { + if child.kind() != "use_declaration" { + continue; + } + if rust_use_decl_imports_leaf(child, code, "tokio", leaf) { + tokio_seen = true; + } + if rust_use_decl_imports_leaf(child, code, "futures", leaf) { + futures_seen = true; + } + } + match (tokio_seen, futures_seen) { + (true, false) => Some("tokio"), + (false, true) => Some("futures"), + _ => None, + } +} + +/// True when `use_decl` brings `::` into scope. +/// +/// Recognises the common shapes: +/// * `use tokio::join;` → leaf at the path tail +/// * `use tokio::{join, select};` → leaf inside a use_list +/// * `use tokio::join as my_join;` → aliased; we detect the +/// original path even though the aliased name is unused (the macro is +/// typically invoked under its alias, but if the alias and the bare form +/// collide the rewrite is still safe). +/// * `use tokio::*;` is NOT recognised — wildcard imports are too permissive +/// for the bare-leaf rewrite to stay precise. +fn rust_use_decl_imports_leaf(use_decl: Node, code: &[u8], crate_prefix: &str, leaf: &str) -> bool { + let mut stack = vec![use_decl]; + while let Some(node) = stack.pop() { + match node.kind() { + // `use tokio::join;` — argument is a `scoped_identifier`. + "scoped_identifier" => { + if scoped_identifier_matches(node, code, crate_prefix, leaf) { + return true; + } + } + // `use tokio::{join, select};` — the `path` field is `tokio`, + // and a `use_list` enumerates leaves. + "scoped_use_list" => { + let path_ok = node + .child_by_field_name("path") + .and_then(|p| text_of(p, code)) + .as_deref() + == Some(crate_prefix); + if path_ok && let Some(list) = node.child_by_field_name("list") { + let mut lc = list.walk(); + for entry in list.named_children(&mut lc) { + match entry.kind() { + "identifier" if text_of(entry, code).as_deref() == Some(leaf) => { + return true; + } + "use_as_clause" + if entry + .child_by_field_name("path") + .and_then(|p| text_of(p, code)) + .as_deref() + == Some(leaf) => + { + return true; + } + _ => {} + } + } + } + } + // `use tokio::join as my_join;` — aliased clause sits directly + // under the use_declaration; check the path side. + "use_as_clause" => { + if let Some(p) = node.child_by_field_name("path") + && p.kind() == "scoped_identifier" + && scoped_identifier_matches(p, code, crate_prefix, leaf) + { + return true; + } + } + _ => { + // Walk children for nested groups (`use a::{b::{c, d}}`). + let mut c = node.walk(); + for ch in node.children(&mut c) { + stack.push(ch); + } + } + } + } + false +} + +fn scoped_identifier_matches(node: Node, code: &[u8], crate_prefix: &str, leaf: &str) -> bool { + let path_text = node + .child_by_field_name("path") + .and_then(|p| text_of(p, code)); + let leaf_text = node + .child_by_field_name("name") + .and_then(|n| text_of(n, code)); + matches!((path_text.as_deref(), leaf_text.as_deref()), + (Some(p), Some(l)) if p == crate_prefix && l == leaf) +} + // ------------------------------------------------------------------------- // === PUBLIC ENTRY POINT ================================================= // ------------------------------------------------------------------------- diff --git a/src/cfg/literals.rs b/src/cfg/literals.rs index a7062910..20f11318 100644 --- a/src/cfg/literals.rs +++ b/src/cfg/literals.rs @@ -1,22 +1,45 @@ use super::conditions::unwrap_parens; +use super::helpers::{collect_array_pattern_bindings_indexed, collect_rhs_array_literal_elements}; use super::{ anon_fn_name, collect_idents, collect_idents_with_paths, find_constructor_type_child, first_call_ident, root_receiver_text, text_of, }; use crate::labels::{Cap, Kind, lookup}; +use smallvec::SmallVec; use tree_sitter::Node; /// Find the inner CallFn/CallMethod/CallMacro node within an AST node. /// For direct call nodes, returns the node itself. For wrappers, searches -/// up to two levels of children. +/// up to two levels of children, transparently descending through +/// `await_expression` / `yield_expression` (`Kind::AwaitForward`) wrappers +/// so `const x = await foo(y)` reaches the inner `call_expression` at +/// effective depth 3 (`lexical_declaration > variable_declarator > +/// await_expression > call_expression`). pub(super) fn find_call_node<'a>(n: Node<'a>, lang: &str) -> Option> { match lookup(lang, n.kind()) { Kind::CallFn | Kind::CallMethod | Kind::CallMacro => Some(n), + Kind::AwaitForward => { + // Transparent wrapper: descend into the awaited expression. + let mut cursor = n.walk(); + for c in n.children(&mut cursor) { + if let Some(found) = find_call_node(c, lang) { + return Some(found); + } + } + None + } _ => { let mut cursor = n.walk(); for c in n.children(&mut cursor) { match lookup(lang, c.kind()) { Kind::CallFn | Kind::CallMethod | Kind::CallMacro => return Some(c), + // Skip past await/yield wrappers without consuming a + // recursion level — the wrapper itself is transparent. + Kind::AwaitForward => { + if let Some(found) = find_call_node(c, lang) { + return Some(found); + } + } _ => {} } } @@ -25,11 +48,14 @@ pub(super) fn find_call_node<'a>(n: Node<'a>, lang: &str) -> Option> { for c in n.children(&mut cursor2) { let mut cursor3 = c.walk(); for gc in c.children(&mut cursor3) { - if matches!( - lookup(lang, gc.kind()), - Kind::CallFn | Kind::CallMethod | Kind::CallMacro - ) { - return Some(gc); + match lookup(lang, gc.kind()) { + Kind::CallFn | Kind::CallMethod | Kind::CallMacro => return Some(gc), + Kind::AwaitForward => { + if let Some(found) = find_call_node(gc, lang) { + return Some(found); + } + } + _ => {} } } } @@ -108,9 +134,43 @@ pub(super) fn extract_destination_field_pairs( raw } }), - // Computed keys like `[someVar]` can't be statically - // resolved, skip (conservative: not a destination field). - "computed_property_name" => continue, + // Computed keys: resolve only when the inner expression + // is a pure string literal (`['url']`). Dynamic forms + // (`[someVar]`, `[`url-${i}`]`, ``[`url`]`` with + // interpolation) stay conservative-skip. + "computed_property_name" => { + let mut inner_cursor = key_node.walk(); + let inner = key_node.named_children(&mut inner_cursor).find(|c| { + !matches!(c.kind(), "comment" | "block_comment" | "line_comment") + }); + match inner.map(|n| (n.kind(), n)) { + Some(("string" | "string_literal", n)) => text_of(n, code).map(|raw| { + if raw.len() >= 2 { + raw[1..raw.len() - 1].to_string() + } else { + raw + } + }), + // Template strings only when no interpolation + // (no `template_substitution` children). + Some(("template_string", n)) + if { + let mut tc = n.walk(); + !n.named_children(&mut tc) + .any(|c| c.kind() == "template_substitution") + } => + { + text_of(n, code).map(|raw| { + if raw.len() >= 2 { + raw[1..raw.len() - 1].to_string() + } else { + raw + } + }) + } + _ => continue, + } + } _ => text_of(key_node, code), }; let Some(key) = key_text else { @@ -144,6 +204,13 @@ pub(super) fn extract_destination_field_pairs( /// `requests.post(url, data=tainted, json=safe)` where `data` and `json` are /// `keyword_argument` siblings of the positional URL. /// +/// Also covers Ruby, where tree-sitter-ruby emits `pair` nodes (with +/// `key`/`value` fields) directly under `argument_list` for the +/// `Faraday.new(url: x)` / `Net::HTTP.start(host, port, proxy_addr: prx)` +/// kwarg shape. The `key` is typically a `hash_key_symbol` whose text is the +/// bare identifier (`url`); `simple_symbol` (`:url`) and string keys are +/// normalised by stripping a leading `:` or wrapping quotes. +/// /// Returns the union of matching kwargs, preserving the kwarg name in the /// `field` slot so callers can still attribute findings per-field. Empty /// when no matching kwargs exist or the call has no `arguments` field. @@ -162,22 +229,38 @@ pub(super) fn extract_destination_kwarg_pairs( let mut cursor = args_node.walk(); for child in args_node.named_children(&mut cursor) { let kind = child.kind(); - if kind != "keyword_argument" && kind != "named_argument" { + let (name_node, value_node) = if kind == "keyword_argument" || kind == "named_argument" { + let named_count = child.named_child_count(); + ( + child + .child_by_field_name("name") + .or_else(|| child.named_child(0)), + child + .child_by_field_name("value") + .or_else(|| child.named_child(named_count.saturating_sub(1) as u32)), + ) + } else if kind == "pair" { + // Ruby `pair` node sits directly under `argument_list` for + // kwarg-style call args (`f(url: x)`). `key`/`value` fields + // are populated; key text is `hash_key_symbol` ("url"), + // `simple_symbol` (":url"), or a string literal. + ( + child.child_by_field_name("key"), + child.child_by_field_name("value"), + ) + } else { continue; - } - let named_count = child.named_child_count(); - let name_node = child - .child_by_field_name("name") - .or_else(|| child.named_child(0)); - let value_node = child - .child_by_field_name("value") - .or_else(|| child.named_child(named_count.saturating_sub(1) as u32)); + }; let (Some(nn), Some(vn)) = (name_node, value_node) else { continue; }; - let Some(name) = text_of(nn, code) else { + let Some(name_raw) = text_of(nn, code) else { continue; }; + let name = name_raw + .trim_start_matches(':') + .trim_matches(['"', '\'']) + .to_string(); if !fields.iter().any(|&f| f == name) { continue; } @@ -387,11 +470,9 @@ pub(super) fn extract_const_macro_arg( // C/C++ identifier / PHP `name` node for define-style constants. // Scoped C++ identifiers (`Curl::OPT_POSTFIELDS`) and PHP namespaced // names also surface here so the dangerous_values match catches them. - "identifier" | "name" | "qualified_name" | "scoped_identifier" => { - text_of(arg, code).map(|s| s.to_string()) - } + "identifier" | "name" | "qualified_name" | "scoped_identifier" => text_of(arg, code), // Ruby bare constant (`NOENT`) — leaf form. - "constant" => text_of(arg, code).map(|s| s.to_string()), + "constant" => text_of(arg, code), // Ruby scope-qualified constant (`Nokogiri::XML::ParseOptions::NOENT`). // Return only the rightmost `name` segment so the gate's // `dangerous_values` list can stay identifier-bare instead of @@ -400,8 +481,7 @@ pub(super) fn extract_const_macro_arg( "scope_resolution" => arg .child_by_field_name("name") .and_then(|n| text_of(n, code)) - .map(|s| s.to_string()) - .or_else(|| text_of(arg, code).map(|s| s.to_string())), + .or_else(|| text_of(arg, code)), // Integer literals at the activation arg position. PHP / C / C++ // commonly use plain `0` to opt into the safe-default option set // (e.g. `simplexml_load_string($xml, "SimpleXMLElement", 0)`). The @@ -409,7 +489,7 @@ pub(super) fn extract_const_macro_arg( // the literal text lets the comparison fail against `LIBXML_NOENT` // and suppresses the conservative-fire branch. "integer" | "integer_literal" | "number_literal" | "decimal_integer_literal" => { - text_of(arg, code).map(|s| s.to_string()) + text_of(arg, code) } _ => None, } @@ -443,7 +523,7 @@ pub(super) fn extract_const_keyword_arg( // distinguish literal-safe from dynamic. return match value_node.kind() { "true" | "false" | "none" | "integer" | "float" | "string" | "string_literal" - | "identifier" => text_of(value_node, code).map(|s| s.to_string()), + | "identifier" => text_of(value_node, code), _ => None, } .filter(|_| { @@ -537,7 +617,7 @@ pub(super) fn extract_object_arg_property( let val_node = unwrap_parens(val_node); return match val_node.kind() { "true" | "false" | "null" | "undefined" | "number" | "string" | "string_literal" => { - text_of(val_node, code).map(|s| s.to_string()) + text_of(val_node, code) } // JS booleans true/false are their own node kinds (above), but // some grammar versions wrap them as identifier literals; surface @@ -811,7 +891,7 @@ pub(super) fn js_chain_outer_method_for_inner<'a>( if inner_matched { return function .child_by_field_name("property") - .and_then(|p| text_of(p, code).map(|s| s.to_string())); + .and_then(|p| text_of(p, code)); } } // Recurse: outer chain may have more depth (`a.b().c().d()` , @@ -1518,6 +1598,18 @@ pub(super) fn extract_arg_uses(call_node: Node, code: &[u8]) -> Vec> return result; } + // Rust `tokio::join!` / `futures::join!` (and their `try_*` variants). + // tree-sitter-rust models macro args as a `token_tree` rather than an + // `arguments` field, so a vanilla extraction returns nothing. Walk the + // top-level token_tree splitting on `,` separators, lifting identifiers + // out of each chunk so the existing PromiseCombinator transfer can union + // arg-side taint into the resulting tuple value. + if call_node.kind() == "macro_invocation" + && let Some(arg_uses) = extract_rust_macro_join_arg_uses(call_node, code) + { + return arg_uses; + } + let Some(args_node) = call_node.child_by_field_name("arguments") else { return Vec::new(); }; @@ -1551,6 +1643,82 @@ pub(super) fn extract_arg_uses(call_node: Node, code: &[u8]) -> Vec> result } +/// `tokio::join!` / `futures::join!` (and their `try_*` variants) bundle +/// concurrently-awaited futures into a tuple result. tree-sitter-rust +/// represents the args as a `token_tree` whose children alternate between +/// expressions and `,` separators (`token_tree` itself nests on every +/// parenthesised group, e.g. the `(x)` inside `fetch(x)`). Walk the +/// top-level token_tree, segment by `,` leaves, and lift identifiers out +/// of each chunk so the SSA Call op carries one positional arg per future. +/// +/// Returns `Some(arg_uses)` only when the macro is one of the recognised +/// join macros, so `extract_arg_uses` can fall through to its normal +/// `arguments`-field path for every other macro shape (`format!`, +/// `println!`, custom DSL macros) where arg lifting could disturb existing +/// label / SSA flow. +pub(super) fn extract_rust_macro_join_arg_uses( + call_node: Node, + code: &[u8], +) -> Option>> { + let macro_node = call_node.child_by_field_name("macro")?; + let macro_text = text_of(macro_node, code)?; + if !is_rust_join_macro(¯o_text) { + return None; + } + let tt = match call_node.child_by_field_name("token_tree") { + Some(t) => t, + None => { + let mut cursor = call_node.walk(); + call_node + .children(&mut cursor) + .find(|c| c.kind() == "token_tree")? + } + }; + let mut chunks: Vec> = vec![Vec::new()]; + let mut cursor = tt.walk(); + for child in tt.children(&mut cursor) { + // Skip the surrounding `(`/`)` punctuation. + if !child.is_named() { + let kind = child.kind(); + if kind == "," { + chunks.push(Vec::new()); + continue; + } + if kind == "(" || kind == ")" { + continue; + } + } + chunks.last_mut().unwrap().push(child); + } + let mut result = Vec::new(); + for chunk in chunks { + if chunk.is_empty() { + continue; + } + let mut idents = Vec::new(); + let mut paths = Vec::new(); + for n in chunk { + collect_idents_with_paths(n, code, &mut idents, &mut paths); + } + let mut combined = paths; + combined.extend(idents); + result.push(combined); + } + Some(result) +} + +fn is_rust_join_macro(macro_text: &str) -> bool { + matches!( + macro_text, + "tokio::join" + | "tokio::try_join" + | "futures::join" + | "futures::try_join" + | "join" + | "try_join" + ) +} + /// Extract keyword / named argument bindings for a call node. /// /// Returns `Vec<(name, uses)>` where `uses` are the identifier references @@ -1891,11 +2059,31 @@ pub(super) fn call_ident_of<'a>(n: Node<'a>, lang: &str, code: &'a [u8]) -> Opti .child_by_field_name("method") .or_else(|| n.child_by_field_name("name")) .and_then(|f| text_of(f, code)); - let recv = n + let recv_node = n .child_by_field_name("object") .or_else(|| n.child_by_field_name("receiver")) - .or_else(|| n.child_by_field_name("scope")) - .and_then(|f| root_receiver_text(f, lang, code)); + .or_else(|| n.child_by_field_name("scope")); + let recv = recv_node.and_then(|f| root_receiver_text(f, lang, code)); + // Preserve Java `.getClass()` segment in the chained callee text + // so downstream predicates (e.g. + // [`crate::ssa::type_facts::is_safe_string_producing_callee`]) + // can recognise idiomatic `obj.getClass().()` chains. + // Without this, `root_receiver_text` collapses the chain to + // `obj.`, indistinguishable from a user-defined method. + let recv = if lang == "java" + && let Some(rn) = recv_node + && lookup(lang, rn.kind()) == Kind::CallMethod + && let Some(inner_method) = rn + .child_by_field_name("method") + .or_else(|| rn.child_by_field_name("name")) + .and_then(|f| text_of(f, code)) + && inner_method == "getClass" + && let Some(r) = recv + { + Some(format!("{r}.getClass")) + } else { + recv + }; match (recv, func) { (Some(r), Some(f)) => Some(format!("{r}.{f}")), (_, Some(f)) => Some(f), @@ -1984,7 +2172,7 @@ pub(super) fn extract_arg_string_literals(call_node: Node, code: &[u8]) -> Vec text_of(target, code).map(|s| s.to_string()), + | "decimal_literal" => text_of(target, code), _ => None, }; result.push(literal); @@ -2003,7 +2191,7 @@ pub(super) fn strip_literal_quotes(raw: &str, node: Node, code: &[u8]) -> Option let mut cursor = node.walk(); for child in node.named_children(&mut cursor) { if child.kind() == "string_content" { - return text_of(child, code).map(|s| s.to_string()); + return text_of(child, code); } } if raw.len() >= 2 { @@ -2044,20 +2232,43 @@ pub(super) fn extract_arg_callees(call_node: Node, lang: &str, code: &[u8]) -> V result } -/// Return `(defines, uses)` for the AST fragment `ast`. -/// Returns (defines, uses, extra_defines) where extra_defines captures additional -/// bindings from destructuring patterns beyond the primary define. +/// Return `(defines, uses, extra_defines, array_pattern_indices, +/// rhs_array_elements)` for the AST fragment `ast`. +/// +/// `extra_defines` captures additional bindings from destructuring patterns +/// beyond the primary define. `array_pattern_indices`, when non-empty, gives +/// the source-order position of each binding in `iter::once(defines).chain( +/// extra_defines)` for `array_pattern` / `tuple_pattern` LHS shapes. Empty +/// for non-array destructures and for non-skip array patterns where callers +/// can derive sequential 0..N indices implicitly. +/// +/// `rhs_array_elements`, when non-empty, gives source-order RHS slots for +/// destructure-from-array-literal shapes (`const [a, b] = [safe, tainted]`, +/// `let (a, b) = (safe, tainted)`, Python `a, b = safe, tainted`). Each slot +/// is `Some(ident)` for a bare-ident element or `None` for a syntactic +/// literal. Empty when RHS isn't an array-literal shape or any element is +/// too complex; callers fall back to scalar union in that case. +#[allow(clippy::type_complexity)] pub(super) fn def_use( ast: Node, lang: &str, code: &[u8], -) -> (Option, Vec, Vec) { + extra_labels: Option<&[crate::labels::RuntimeLabelRule]>, +) -> ( + Option, + Vec, + Vec, + SmallVec<[usize; 4]>, + SmallVec<[crate::cfg::RhsArraySlot; 4]>, +) { match lookup(lang, ast.kind()) { // Declaration wrappers (let, var, short_var_declaration, etc.) Kind::CallWrapper => { let mut defs = None; let mut extra_defs = Vec::new(); let mut uses = Vec::new(); + let mut pattern_indices: SmallVec<[usize; 4]> = SmallVec::new(); + let mut rhs_array_elements: SmallVec<[crate::cfg::RhsArraySlot; 4]> = SmallVec::new(); // Try direct field names first (Rust `let_declaration`, Go `short_var_declaration`) let def_node = ast @@ -2076,17 +2287,30 @@ pub(super) fn def_use( if def_node.is_some() || val_node.is_some() { if let Some(pat) = def_node { - let mut idents = Vec::new(); - let mut paths = Vec::new(); - collect_idents_with_paths(pat, code, &mut idents, &mut paths); - let first = paths.pop().or_else(|| idents.first().cloned()); - // Remaining idents are extra defines (for destructuring) - for ident in &idents { - if first.as_ref() != Some(ident) { - extra_defs.push(ident.clone()); + let bindings = collect_array_pattern_bindings_indexed(pat, code); + if !bindings.is_empty() { + let mut iter = bindings.into_iter(); + if let Some((first_name, first_idx)) = iter.next() { + defs = Some(first_name); + pattern_indices.push(first_idx); } + for (name, idx) in iter { + extra_defs.push(name); + pattern_indices.push(idx); + } + } else { + let mut idents = Vec::new(); + let mut paths = Vec::new(); + collect_idents_with_paths(pat, code, &mut idents, &mut paths); + let first = paths.pop().or_else(|| idents.first().cloned()); + // Remaining idents are extra defines (for destructuring) + for ident in &idents { + if first.as_ref() != Some(ident) { + extra_defs.push(ident.clone()); + } + } + defs = first; } - defs = first; } if let Some(val) = val_node { let mut idents = Vec::new(); @@ -2099,6 +2323,14 @@ pub(super) fn def_use( // the format-string bytes, not as a separate AST // argument node, so collect_idents misses it. uses.extend(extract_rust_format_macro_named_idents_in(val, code)); + // When the LHS is a recognised destructure pattern AND + // the RHS is a bare array-literal shape (no call), record + // per-element idents so the SSA destructure rewrite can + // map each binding to its specific RHS slot. + if !pattern_indices.is_empty() { + rhs_array_elements = + collect_rhs_array_literal_elements(val, lang, code, extra_labels); + } } } else { // Try nested declarator pattern (JS/TS `lexical_declaration` → `variable_declarator`, @@ -2135,16 +2367,29 @@ pub(super) fn def_use( if let Some(name_node) = child_name && defs.is_none() { - let mut idents = Vec::new(); - let mut paths = Vec::new(); - collect_idents_with_paths(name_node, code, &mut idents, &mut paths); - let first = paths.pop().or_else(|| idents.first().cloned()); - for ident in &idents { - if first.as_ref() != Some(ident) { - extra_defs.push(ident.clone()); + let bindings = collect_array_pattern_bindings_indexed(name_node, code); + if !bindings.is_empty() { + let mut iter = bindings.into_iter(); + if let Some((first_name, first_idx)) = iter.next() { + defs = Some(first_name); + pattern_indices.push(first_idx); } + for (name, idx) in iter { + extra_defs.push(name); + pattern_indices.push(idx); + } + } else { + let mut idents = Vec::new(); + let mut paths = Vec::new(); + collect_idents_with_paths(name_node, code, &mut idents, &mut paths); + let first = paths.pop().or_else(|| idents.first().cloned()); + for ident in &idents { + if first.as_ref() != Some(ident) { + extra_defs.push(ident.clone()); + } + } + defs = first; } - defs = first; } if let Some(val_node) = child_value { let mut idents = Vec::new(); @@ -2153,6 +2398,14 @@ pub(super) fn def_use( uses.extend(paths); uses.extend(idents); uses.extend(extract_rust_format_macro_named_idents_in(val_node, code)); + if !pattern_indices.is_empty() && rhs_array_elements.is_empty() { + rhs_array_elements = collect_rhs_array_literal_elements( + val_node, + lang, + code, + extra_labels, + ); + } } } } @@ -2168,19 +2421,42 @@ pub(super) fn def_use( uses.extend(extract_rust_format_macro_named_idents_in(ast, code)); } } - (defs, uses, extra_defs) + (defs, uses, extra_defs, pattern_indices, rhs_array_elements) } - // Plain assignment `x = y` + // Plain assignment `x = y` or destructuring assignment such as + // Python `a, b = await asyncio.gather(...)` whose LHS surfaces as + // a `pattern_list` / `tuple_pattern`. When the LHS is a + // destructure pattern that the indexed helper recognises, the + // primary binding lands in `defs`, the rest land in `extra_defs`, + // and `pattern_indices` carries source-order positions so the + // SSA lowering's destructure-promise rewrite can paint each + // binding from the matching combinator argument. Kind::Assignment => { let mut defs = None; + let mut extra_defs = Vec::new(); + let mut pattern_indices: SmallVec<[usize; 4]> = SmallVec::new(); + let mut rhs_array_elements: SmallVec<[crate::cfg::RhsArraySlot; 4]> = SmallVec::new(); let mut uses = Vec::new(); if let Some(lhs) = ast.child_by_field_name("left") { - let mut idents = Vec::new(); - let mut paths = Vec::new(); - collect_idents_with_paths(lhs, code, &mut idents, &mut paths); - // Prefer dotted path (member expression) over last ident - defs = paths.pop().or_else(|| idents.pop()); + let bindings = collect_array_pattern_bindings_indexed(lhs, code); + if !bindings.is_empty() { + let mut iter = bindings.into_iter(); + if let Some((first_name, first_idx)) = iter.next() { + defs = Some(first_name); + pattern_indices.push(first_idx); + } + for (name, idx) in iter { + extra_defs.push(name); + pattern_indices.push(idx); + } + } else { + let mut idents = Vec::new(); + let mut paths = Vec::new(); + collect_idents_with_paths(lhs, code, &mut idents, &mut paths); + // Prefer dotted path (member expression) over last ident + defs = paths.pop().or_else(|| idents.pop()); + } } if let Some(rhs) = ast.child_by_field_name("right") { let mut idents = Vec::new(); @@ -2189,8 +2465,16 @@ pub(super) fn def_use( uses.extend(paths); uses.extend(idents); uses.extend(extract_rust_format_macro_named_idents_in(rhs, code)); + // When the LHS is a recognised destructure pattern AND the + // RHS is a bare array-literal shape, record per-element + // idents so the SSA destructure rewrite can map each + // binding to its specific RHS slot. + if !pattern_indices.is_empty() { + rhs_array_elements = + collect_rhs_array_literal_elements(rhs, lang, code, extra_labels); + } } - (defs, uses, vec![]) + (defs, uses, extra_defs, pattern_indices, rhs_array_elements) } // if‑let / while‑let, the `let_condition` binds a variable from @@ -2215,7 +2499,7 @@ pub(super) fn def_use( if let Some(val) = c.child_by_field_name("value") { collect_idents(val, code, &mut uses); } - return (defs, uses, vec![]); + return (defs, uses, vec![], SmallVec::new(), SmallVec::new()); } let mut idents = Vec::new(); @@ -2223,7 +2507,7 @@ pub(super) fn def_use( collect_idents_with_paths(ast, code, &mut idents, &mut paths); let mut uses = paths; uses.extend(idents); - (None, uses, vec![]) + (None, uses, vec![], SmallVec::new(), SmallVec::new()) } // for-in / for-of / Python `for x in iter:` ───────────────────────── @@ -2267,7 +2551,7 @@ pub(super) fn def_use( collect_idents_with_paths(ast, code, &mut idents, &mut paths); let mut uses = paths; uses.extend(idents); - return (None, uses, vec![]); + return (None, uses, vec![], SmallVec::new(), SmallVec::new()); } let mut defs: Option = None; @@ -2293,7 +2577,7 @@ pub(super) fn def_use( uses.extend(paths); uses.extend(idents); } - (defs, uses, extra_defs) + (defs, uses, extra_defs, SmallVec::new(), SmallVec::new()) } // everything else – no definition, but may read vars @@ -2303,7 +2587,7 @@ pub(super) fn def_use( collect_idents_with_paths(ast, code, &mut idents, &mut paths); let mut uses = paths; uses.extend(idents); - (None, uses, vec![]) + (None, uses, vec![], SmallVec::new(), SmallVec::new()) } } } diff --git a/src/cfg/mod.rs b/src/cfg/mod.rs index b0f0e0d0..c6f69d42 100644 --- a/src/cfg/mod.rs +++ b/src/cfg/mod.rs @@ -42,6 +42,7 @@ mod hierarchy; mod imports; mod literals; mod params; +pub mod safe_fields; use blocks::{build_begin_rescue, build_switch, build_try}; use helpers::{ collect_nested_function_nodes, derive_anon_fn_name_from_context, find_classifiable_inner_call, @@ -55,12 +56,15 @@ use conditions::{ detect_rust_let_match_guard, emit_rust_match_guard_if, find_ternary_rhs_wrapper, is_boolean_operator, unwrap_parens, }; -use decorators::extract_auth_decorators; +use decorators::{extract_auth_decorators, extract_route_path_captures}; pub(crate) use helpers::{ collect_idents, collect_idents_with_paths, find_constructor_type_child, first_call_ident, has_call_descendant, member_expr_text, root_receiver_text, text_of, }; -use imports::{extract_import_bindings, extract_promisify_aliases}; +use imports::{ + extract_import_bindings, extract_local_import_view, extract_promisify_aliases, + rust_bare_join_crate_prefix, +}; #[cfg(test)] use literals::has_sql_placeholders; use literals::{ @@ -70,9 +74,10 @@ use literals::{ extract_destination_field_pairs, extract_destination_kwarg_pairs, extract_kwargs, extract_literal_rhs, extract_object_arg_property, extract_shell_array_payload_idents, find_call_node, find_call_node_deep, find_chained_inner_call, has_keyword_arg, - has_object_arg_property, has_only_literal_args, is_object_create_null_call, - is_parameterized_query_call, java_chain_arg0_kind_for_method, js_chain_arg0_kind_for_method, - js_chain_outer_method_for_inner, ruby_chain_arg0_for_method, walk_chain_inner_call_args, + has_object_arg_property, has_only_literal_args, has_string_interpolation, + is_object_create_null_call, is_parameterized_query_call, java_chain_arg0_kind_for_method, + js_chain_arg0_kind_for_method, js_chain_outer_method_for_inner, ruby_chain_arg0_for_method, + walk_chain_inner_call_args, }; use params::{ compute_container_and_kind, extract_param_meta, inject_framework_param_sources, @@ -150,6 +155,177 @@ thread_local! { /// resolved. pub(crate) static TYPE_ALIAS_LC: RefCell> = RefCell::new(std::collections::HashSet::new()); + /// Per-file map of `(enclosing-function start_byte, local-variable + /// name)` → [`crate::ssa::type_facts::TypeKind`]. Populated at the + /// top of [`build_cfg`] by walking each function body for local + /// variable declarations whose RHS callee is recognised by + /// [`crate::ssa::type_facts::constructor_type`]. Consulted by + /// `find_classifiable_inner_call` (in `helpers.rs`) to rewrite the + /// receiver in a chained inner call (`sess.createNativeQuery(...)`) + /// to its type prefix (`HibernateSession.createNativeQuery`) so a + /// type-qualified label rule fires when the legacy literal-receiver + /// rule misses. Java-only today; extends to any language whose + /// `constructor_type` arm fires on the RHS callee. + pub(crate) static LOCAL_RECEIVER_TYPES: + RefCell> + = RefCell::new(HashMap::new()); +} + +/// Walk every function-kind node in the tree. Within each function +/// body, scan non-nested local variable declarations whose RHS is a +/// call expression and whose callee is recognised by +/// [`crate::ssa::type_facts::constructor_type`]. Record +/// `(fn_start, var_name) → TypeKind` so chained inner calls receive a +/// type-qualified rewrite at classify time. +fn populate_local_receiver_types(tree: &Tree, lang: &str, code: &[u8]) { + use crate::ssa::type_facts::TypeKind; + let Some(lang_enum) = Lang::from_slug(lang) else { + return; + }; + let mut out: HashMap<(usize, String), TypeKind> = HashMap::new(); + walk_functions_for_locals(tree.root_node(), lang, lang_enum, code, &mut out); + LOCAL_RECEIVER_TYPES.with(|cell| *cell.borrow_mut() = out); +} + +fn walk_functions_for_locals( + root: Node<'_>, + lang: &str, + lang_enum: Lang, + code: &[u8], + out: &mut HashMap<(usize, String), crate::ssa::type_facts::TypeKind>, +) { + if lookup(lang, root.kind()) == Kind::Function { + let fn_start = root.start_byte(); + collect_locals_in_fn(root, fn_start, true, lang, lang_enum, code, out); + } + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + walk_functions_for_locals(child, lang, lang_enum, code, out); + } +} + +fn collect_locals_in_fn( + node: Node<'_>, + fn_start: usize, + is_root: bool, + lang: &str, + lang_enum: Lang, + code: &[u8], + out: &mut HashMap<(usize, String), crate::ssa::type_facts::TypeKind>, +) { + use crate::ssa::type_facts::constructor_type; + // Don't descend into nested function bodies — they own their own + // scope and get their own (fn_start, var_name) bindings via the + // outer walk. + if !is_root && lookup(lang, node.kind()) == Kind::Function { + return; + } + if node.kind() == "local_variable_declaration" + || node.kind() == "variable_declarator" + || node.kind() == "let_declaration" + || node.kind() == "short_var_declaration" + || node.kind() == "var_spec" + { + let mut cursor = node.walk(); + for declarator in node.children(&mut cursor) { + if declarator.kind() != "variable_declarator" { + continue; + } + let Some(name_node) = declarator.child_by_field_name("name") else { + continue; + }; + let Some(name) = text_of(name_node, code) else { + continue; + }; + let Some(value_node) = declarator + .child_by_field_name("value") + .or_else(|| declarator.child_by_field_name("right")) + else { + continue; + }; + // The RHS may be a chain like `sf.openSession()`; we want + // the callee text to feed `constructor_type`. For + // method_invocation / call_expression nodes, build the + // dotted callee path. + let Some(callee) = callee_text_for_constructor(value_node, lang, code) else { + continue; + }; + if let Some(kind) = constructor_type(lang_enum, &callee) { + out.insert((fn_start, name), kind); + } + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_locals_in_fn(child, fn_start, false, lang, lang_enum, code, out); + } +} + +fn callee_text_for_constructor(node: Node<'_>, lang: &str, code: &[u8]) -> Option { + match lookup(lang, node.kind()) { + Kind::CallFn => node + .child_by_field_name("function") + .or_else(|| node.child_by_field_name("name")) + .and_then(|f| text_of(f, code)), + Kind::CallMethod => { + let method = node + .child_by_field_name("method") + .or_else(|| node.child_by_field_name("name")) + .and_then(|f| text_of(f, code))?; + let recv = node + .child_by_field_name("object") + .or_else(|| node.child_by_field_name("receiver")) + .or_else(|| node.child_by_field_name("scope")) + .and_then(|f| root_receiver_text(f, lang, code)); + match recv { + Some(r) => Some(format!("{r}.{method}")), + None => Some(method), + } + } + _ => None, + } +} + +/// Walk up from `n` to find the enclosing function-kind node's +/// `start_byte`. Returns `None` for top-level nodes. +fn enclosing_fn_start(n: Node<'_>, lang: &str) -> Option { + let mut cur = n.parent()?; + loop { + if lookup(lang, cur.kind()) == Kind::Function { + return Some(cur.start_byte()); + } + cur = cur.parent()?; + } +} + +/// Look up `(fn_start, var_name)` in the per-file local-receiver-types +/// map populated by [`populate_local_receiver_types`]. Returns `None` +/// when no binding was recorded (no view published, name not bound, or +/// RHS callee not recognised by `constructor_type`). +pub(crate) fn lookup_local_receiver_type( + fn_start: usize, + var_name: &str, +) -> Option { + LOCAL_RECEIVER_TYPES.with(|cell| { + cell.borrow() + .get(&(fn_start, var_name.to_string())) + .cloned() + }) +} + +/// Public entry consulted by `find_classifiable_inner_call`: given the +/// inner call's AST node and its bare receiver text, return the +/// `label_prefix()` for the receiver's locally-bound TypeKind, when +/// available. Returns `None` when no enclosing function is found, no +/// binding was recorded, or the bound `TypeKind` has no label prefix. +pub(crate) fn local_receiver_type_prefix( + inner_call: Node<'_>, + receiver: &str, + lang: &str, +) -> Option<&'static str> { + let fn_start = enclosing_fn_start(inner_call, lang)?; + let kind = lookup_local_receiver_type(fn_start, receiver)?; + kind.label_prefix() } /// Populate the per-file DFS-index map from a preorder walk of the @@ -407,6 +583,63 @@ pub struct TaintMeta { /// Additional variable definitions from destructuring patterns. /// E.g. `const { a, b, c } = source()` → defines="a", extra_defines=["b", "c"]. pub extra_defines: Vec, + /// Pattern-position indices for array-pattern destructure bindings. + /// When non-empty, `array_pattern_indices[0]` is the position index for + /// `defines`, and `array_pattern_indices[1..]` are the indices for each + /// element of `extra_defines` in order. Populated only when the LHS is + /// an `array_pattern` (or tuple_pattern) so consumers can map binding + /// positions back to source-order arguments — e.g. `const [, b] = + /// Promise.all([safe, tainted])` records `array_pattern_indices=[1]` + /// so the SSA destructure-promise rewrite picks index 1 (tainted) + /// instead of index 0 (safe). Empty for object-destructure, plain + /// single-binding assignments, and non-array patterns. + #[serde(default, skip_serializing_if = "SmallVec::is_empty")] + pub array_pattern_indices: SmallVec<[usize; 4]>, + /// Source-order RHS array-literal slots for destructure assignments. + /// Populated only when the LHS is a destructure pattern (`array_pattern`, + /// `tuple_pattern`, `pattern_list`, `left_assignment_list`) AND the RHS + /// is an array-literal shape (JS/TS `array`, Python `list`/`tuple`/ + /// `expression_list`, Ruby `array`, Rust `tuple_expression`). Each slot + /// carries one of: a bare identifier (`Ident`), a syntactic literal + /// (`Literal`), or a complex expression with its inner identifier uses + /// (`Complex`). Empty when the RHS shape doesn't match OR a slot is + /// unrepresentable (spread / list splat) — callers fall back to the + /// existing scalar-union behavior in that case. + /// + /// Used by the SSA destructure rewrite in `lower.rs` so each binding sees + /// only its index's element instead of the scalar union of every ident on + /// the RHS. Closes FPs like `const [a, b] = [safe, tainted]; exec(b);` + /// (Ident shape) as well as `const [c, d] = [fn(req.x), 'lit']; exec(d);` + /// (Complex shape) where the legacy union painted `d` with `req.x`. + #[serde(default, skip_serializing_if = "SmallVec::is_empty")] + pub rhs_array_elements: SmallVec<[RhsArraySlot; 4]>, +} + +/// Source-order slot for an RHS array-literal element in a destructure +/// assignment. See [`TaintMeta::rhs_array_elements`] for context. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum RhsArraySlot { + /// Bare identifier (`safe`, `$user`, `req`). The SSA lowering looks up + /// the reaching def via `var_stacks` and emits an `Assign` of that value. + Ident(String), + /// Syntactic literal (string, number, bool, null/nil/None). The SSA + /// lowering emits a `Const(None)` so the binding carries no taint. + Literal, + /// Complex expression (call, binary, subscript, member access, nested + /// array literal). Carries the inner identifier uses harvested from the + /// slot's subtree plus a per-slot `source_cap` recognised by classifying + /// the slot's own subtree (via `first_member_label`). + /// + /// When `source_cap` is non-empty the SSA lowering knows the source + /// pattern lives in THIS slot and emits `SsaOp::Source` for the binding. + /// Sibling Complex slots whose `source_cap` is empty fall through to the + /// slot-scoped `Assign(inner reaching defs)` path, so a safe Complex + /// sibling stops inheriting the outer node's Source label. + Complex { + uses: SmallVec<[String; 4]>, + #[serde(default, skip_serializing_if = "crate::labels::Cap::is_empty")] + source_cap: crate::labels::Cap, + }, } /// AST origin/location metadata. @@ -521,6 +754,13 @@ pub struct NodeInfo { /// resources captured by the closure body, so the lifecycle of those /// captures must remain unchanged on the assignment node. pub rhs_is_function_literal: bool, + /// True when this CFG node was produced from a tree-sitter + /// `await_expression` (JS/TS `Kind::AwaitForward`). The SSA lowering + /// emits `SsaOp::Assign(operand)` for such nodes so taint, origins, + /// and abstract-domain facts forward 1:1 across the await boundary. + /// Strictly additive: when `false`, legacy lowering applies. + #[serde(default)] + pub is_await_forward: bool, } impl NodeInfo { @@ -643,6 +883,16 @@ pub struct BodyMeta { /// machine consumes this to seed the entry `AuthLevel` for privileged-sink /// checks. Empty for top-level and for functions without auth markers. pub auth_decorators: Vec, + /// Per-formal route-capture flag. Same length as `params`. `true` at + /// position `i` iff the formal name appears as a path capture in a + /// framework routing decorator on this function (Flask + /// `@app.route("/users/")`, blueprint-prefixed `@bp.get("/u/")`, + /// FastAPI / Starlette verb decorators). Today populated only for Python. + /// The entry-kind seeding pass consults this for `FlaskRoute` so only + /// path-bound formals (not implicit globals or DI handles) are painted + /// as adversary input. Empty for top-level and for functions without + /// matching decorators. + pub param_route_capture: Vec, } /// A single executable body's CFG plus metadata. @@ -702,6 +952,43 @@ pub struct FileCfg { /// extractor (Go, C) and for files with no inheritance / impl /// declarations. pub hierarchy_edges: Vec<(String, String)>, + /// Phase-04 resolver output: per-file import bindings resolved + /// against the project [`crate::resolve::ModuleGraph`]. Populated + /// post-`build_cfg` by `crate::ast::ParsedFile::from_source` when + /// a [`crate::resolve::ModuleGraph`] is available on the active + /// `Config`. Empty for non-JS/TS files, scans without a configured + /// resolver, and unit tests that build a CFG directly. + pub resolved_imports: Vec, + /// Phase 10 — Next.js entry-point classification keyed by the + /// function definition's tree-sitter byte span. Populated for + /// JS/TS files, empty otherwise. The summary-extraction pipeline + /// matches against [`BodyMeta::span`] to attach the + /// [`crate::entry_points::EntryKind`] to the resulting summary. + pub entry_kinds: std::collections::HashMap<(usize, usize), crate::entry_points::EntryKind>, + /// Per-file local import view: local-name → source-module specifier. + /// Built once during JS/TS CFG construction (empty for other langs). + /// Consumed by gated label rules and by the ORM TypeKind import gate + /// in `crate::ssa::type_facts::constructor_type` (via the + /// `FILE_IMPORTS_TLS` thread-local set around per-body SSA passes). + pub local_imports: HashMap, + /// Class fields whose `.get(...)` lookups are bounded to a finite + /// set of literal string values. Populated for Java + /// `final ... = Map.of(literal, literal, ...)` declarations; empty + /// for other languages and shapes. Consumed by the SSA taint + /// engine's container-Load fallback (via the + /// `JAVA_SAFE_FIELDS_TLS` thread-local) so a tainted lookup key + /// does not light up downstream sinks when the receiver is a + /// known-safe map field. + pub safe_lookup_fields: HashMap>, + /// Class-level constant scalars: field name → literal text. + /// Populated for Java `static final TYPE NAME = LITERAL;` declarations + /// where the RHS is a primitive scalar literal (string, integer, + /// floating-point, char, boolean, null). Consumed by + /// `cfg_analysis::guards` to recognise sink arguments that resolve to + /// class-level constants (the per-function SSA const-prop sees a free + /// identifier and would otherwise treat the binding as runtime-dynamic). + /// Empty for non-Java files. + pub class_constant_scalars: HashMap, } impl FileCfg { @@ -902,6 +1189,26 @@ pub(super) fn detect_negation<'a>( /// a single binary expression as its immediate RHS. Returns `None` for /// nested binary expressions, compound assignments (`+=`), boolean /// operators (`&&`, `||`), and any ambiguous cases. +/// Phase 12 deferred fix: when the file imports `tokio::join` / `futures::join` +/// (or `_::try_join`) via `use`, rewrite a bare `join` / `try_join` macro +/// callee to its qualified form so the SSA-level promise-combinator +/// recogniser fires. Returns `None` for every non-Rust input and for +/// macro callees that already carry a `::` prefix. +fn rewrite_rust_bare_join_macro(raw: &str, ast: Node, lang: &str, code: &[u8]) -> Option { + if lang != "rust" || raw.contains("::") { + return None; + } + if !matches!(raw, "join" | "try_join") { + return None; + } + let mut root = ast; + while let Some(parent) = root.parent() { + root = parent; + } + let prefix = rust_bare_join_crate_prefix(root, code, raw)?; + Some(format!("{prefix}::{raw}")) +} + fn extract_bin_op(ast: Node, lang: &str) -> Option { // Find the binary expression node: either ast itself or immediate child. let bin_expr = find_single_binary_expr(ast, lang)?; @@ -946,7 +1253,19 @@ fn assignment_rhs<'a>(ast: Node<'a>) -> Option> { "variable_declarator" | "assignment_expression" | "assignment" => ast .child_by_field_name("value") .or_else(|| ast.child_by_field_name("right")), - "variable_declaration" | "lexical_declaration" => { + // Phase 14 — Java `local_variable_declaration`, Go + // `short_var_declaration` / `var_spec`, Rust `let_declaration`, + // Python `assignment` (already covered above), and PHP + // `assignment_expression` (covered above). Added here so the + // `string_prefix` extractor can walk the RHS of a plain + // declaration in any supported language. + "variable_declaration" + | "lexical_declaration" + | "local_variable_declaration" + | "short_var_declaration" + | "var_spec" + | "var_declaration" + | "let_declaration" => { // Walk direct children for the first variable_declarator with a value. let mut w = ast.walk(); ast.named_children(&mut w) @@ -955,6 +1274,17 @@ fn assignment_rhs<'a>(ast: Node<'a>) -> Option> { d.child_by_field_name("value") .or_else(|| d.child_by_field_name("right")) }) + .or_else(|| { + // Go: short_var_declaration's value is on a + // `expression_list` field "right". + ast.child_by_field_name("right") + .or_else(|| ast.child_by_field_name("value")) + }) + .or_else(|| { + // Rust let_declaration: value field directly on the + // node (no wrapping declarator). + ast.child_by_field_name("value") + }) } "expression_statement" => { // expression_statement wraps an assignment_expression @@ -980,8 +1310,17 @@ fn assignment_rhs<'a>(ast: Node<'a>) -> Option> { /// when the prefix contains `scheme://host/`, the sink is suppressed because /// the attacker cannot reach a different host. fn extract_template_prefix(ast: Node, lang: &str, code: &[u8]) -> Option { - // Only JS/TS expose `template_string` nodes; cheap early exit elsewhere. - if !matches!(lang, "javascript" | "typescript") { + // Phase 14 — extended beyond JS/TS so the SSRF prefix-lock fires + // across every supported language whose origin-locked URL shape + // is a literal+tainted string concatenation. The grammar + // dispatch lives in [`prefix_of_expression`]; this function only + // walks the assignment-RHS / first-call-arg slots that consume + // the prefix. + let supported = matches!( + lang, + "javascript" | "typescript" | "java" | "go" | "php" | "ruby" | "python" | "rust" + ); + if !supported { return None; } @@ -995,7 +1334,16 @@ fn extract_template_prefix(ast: Node, lang: &str, code: &[u8]) -> Option // Call expression (including sink call nodes): inspect the first // positional argument. Covers `axios.get(\`https://host/…${x}\`)` shape // where the template literal is inline at the sink. - if matches!(ast.kind(), "call_expression" | "call" | "new_expression") { + if matches!( + ast.kind(), + "call_expression" + | "call" + | "new_expression" + | "object_creation_expression" + | "method_invocation" + | "macro_invocation" + | "function_call_expression" + ) { let args = ast .child_by_field_name("arguments") .or_else(|| ast.child_by_field_name("argument_list")); @@ -1065,28 +1413,134 @@ fn prefix_of_expression(node: Node, code: &[u8]) -> Option { return None; } - // Case 2: `"scheme://host/" + x`, LHS is a string literal. - if cur.kind() == "binary_expression" { + // Case 2: `"scheme://host/" + x` / PHP `"scheme://host/" . $x`, + // LHS is a string literal. Phase 14: also accept `.` as the + // concat operator so PHP's `"prefix" . $tainted` shape locks the + // SSRF prefix the same way `+`-using languages do. + if matches!( + cur.kind(), + "binary_expression" | "binary_operator" | "binary" + ) { let mut w2 = cur.walk(); let mut ops = cur.children(&mut w2).filter(|c| !c.is_named()); - if !ops.any(|c| c.kind() == "+") { + if !ops.any(|c| matches!(c.kind(), "+" | ".")) { return None; } let left = cur.named_child(0)?; - if matches!(left.kind(), "string" | "string_fragment") { - let raw = text_of(left, code)?; - let trimmed = if (raw.starts_with('"') && raw.ends_with('"')) - || (raw.starts_with('\'') && raw.ends_with('\'')) - || (raw.starts_with('`') && raw.ends_with('`')) - { - if raw.len() >= 2 { - raw[1..raw.len() - 1].to_string() - } else { - raw - } + if matches!( + left.kind(), + "string" + | "string_fragment" + | "string_literal" + | "interpreted_string_literal" + | "raw_string_literal" + | "encapsed_string" + ) { + // For strings with embedded fragments (Java string_literal + // wraps a string_fragment child), recurse one level into + // the fragment to get the raw text without quote tokens. + let inner_text = if matches!(left.kind(), "string_literal" | "encapsed_string") { + let mut iw = left.walk(); + left.named_children(&mut iw) + .find(|c| c.kind() == "string_fragment") + .and_then(|n| text_of(n, code)) } else { - raw + None }; + let raw = match inner_text { + Some(t) => t, + None => text_of(left, code)?, + }; + let trimmed = strip_string_quotes_loose(&raw); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + + // Case 3: Rust `format!("scheme://host/{}", x)` macro invocation. + // The first positional arg is the format string literal whose + // leading literal text (up to the first `{`) is the locked prefix. + if cur.kind() == "macro_invocation" { + let macro_name = cur + .child_by_field_name("macro") + .and_then(|n| text_of(n, code)) + .unwrap_or_default(); + if matches!( + macro_name.as_str(), + "format" | "write" | "writeln" | "println" | "eprintln" | "print" | "eprint" + ) { + // tree-sitter-rust models macro args under a named + // `token_tree` child rather than via the `arguments` field. + // Walk every direct child looking for the first string + // literal — that's the format-string positional arg. + let mut iw = cur.walk(); + let mut first_string: Option = None; + for child in cur.named_children(&mut iw) { + if matches!(child.kind(), "string_literal" | "raw_string_literal") { + first_string = Some(child); + break; + } + if child.kind() == "token_tree" { + let mut ttw = child.walk(); + for inner in child.named_children(&mut ttw) { + if matches!(inner.kind(), "string_literal" | "raw_string_literal") { + first_string = Some(inner); + break; + } + } + if first_string.is_some() { + break; + } + } + } + if let Some(first) = first_string { + let mut iw = first.walk(); + let frag_text = first + .named_children(&mut iw) + .find(|c| c.kind() == "string_content" || c.kind() == "string_fragment") + .and_then(|n| text_of(n, code)); + let raw = match frag_text { + Some(t) => t, + None => text_of(first, code)?, + }; + let trimmed = strip_string_quotes_loose(&raw); + if let Some(idx) = trimmed.find('{') { + let head = trimmed[..idx].to_string(); + if !head.is_empty() { + return Some(head); + } + } else if !trimmed.is_empty() { + return Some(trimmed); + } + } else if let Some(prefix) = rust_macro_const_first_arg_prefix(cur, code) { + // No literal first arg, but the first non-literal token is an + // identifier that resolves to a top-level `const NAME: &str = "lit";` + // declaration in the same file. Treat the const value as if it + // had been written inline so `format!(URL_FMT, x)` locks the + // host the same way `format!("https://api/{}", x)` does. + return Some(prefix); + } + } + } + + // Case 4: interpolated-string leading literal fragment. + // Python f-strings parse as `formatted_string`; Ruby interpolated + // strings parse as `string` with an `interpolation` child. The + // `string + has_interpolation child` gate keeps plain JS / TS / + // Java `string` nodes (whose children are only + // `string_content`/`string_fragment`) from accidentally seeding a + // phantom prefix on every literal-URL call site. PHP double- + // quoted strings parse as `encapsed_string`, distinct kind, so + // they don't trip this branch either. + let is_fstring = cur.kind() == "formatted_string"; + let is_interp_string = cur.kind() == "string" && has_string_interpolation(cur); + if is_fstring || is_interp_string { + let mut w = cur.walk(); + let first = cur.named_children(&mut w).next()?; + if matches!(first.kind(), "string_content" | "string_fragment") { + let raw = text_of(first, code)?; + let trimmed = strip_string_quotes_loose(&raw); if !trimmed.is_empty() { return Some(trimmed); } @@ -1096,6 +1550,96 @@ fn prefix_of_expression(node: Node, code: &[u8]) -> Option { None } +/// Resolve the leading prefix of a Rust `format!(IDENT, ...)`-style macro +/// when the first arg is a bare identifier bound to a top-level +/// `const NAME: &str = "literal";` or `static NAME: &str = "literal";` +/// declaration in the same file. Returns the leading literal text up to +/// the first `{` placeholder, or the whole literal when no placeholder is +/// present. +/// +/// Walks the macro's `token_tree` for the first identifier (skipping the +/// `(` `)` `,` punctuation), then ascends to the file root and scans direct +/// `const_item` / `static_item` children for a name match. Bypasses inner +/// functions / impl blocks: only file-level declarations participate, which +/// keeps the lookup deterministic and avoids shadowing surprises. +fn rust_macro_const_first_arg_prefix(macro_node: Node, code: &[u8]) -> Option { + let token_tree = { + let mut w = macro_node.walk(); + macro_node + .named_children(&mut w) + .find(|c| c.kind() == "token_tree")? + }; + let first_ident_name = { + let mut w = token_tree.walk(); + let mut found: Option = None; + for child in token_tree.named_children(&mut w) { + match child.kind() { + "string_literal" | "raw_string_literal" => return None, + "identifier" => { + found = text_of(child, code); + break; + } + _ => continue, + } + } + found? + }; + let mut root = macro_node; + while let Some(parent) = root.parent() { + root = parent; + } + let mut rw = root.walk(); + for child in root.named_children(&mut rw) { + if !matches!(child.kind(), "const_item" | "static_item") { + continue; + } + let name = child + .child_by_field_name("name") + .and_then(|n| text_of(n, code)); + if name.as_deref() != Some(first_ident_name.as_str()) { + continue; + } + let value = child.child_by_field_name("value")?; + let lit = if matches!(value.kind(), "string_literal" | "raw_string_literal") { + value + } else { + continue; + }; + let mut iw = lit.walk(); + let frag_text = lit + .named_children(&mut iw) + .find(|c| c.kind() == "string_content" || c.kind() == "string_fragment") + .and_then(|n| text_of(n, code)); + let raw = match frag_text { + Some(t) => t, + None => text_of(lit, code)?, + }; + let trimmed = strip_string_quotes_loose(&raw); + if let Some(idx) = trimmed.find('{') { + let head = trimmed[..idx].to_string(); + if !head.is_empty() { + return Some(head); + } + } else if !trimmed.is_empty() { + return Some(trimmed); + } + } + None +} + +/// Strip surrounding `"`/`'`/`` ` `` quotes if present. +fn strip_string_quotes_loose(raw: &str) -> String { + if raw.len() >= 2 + && ((raw.starts_with('"') && raw.ends_with('"')) + || (raw.starts_with('\'') && raw.ends_with('\'')) + || (raw.starts_with('`') && raw.ends_with('`'))) + { + raw[1..raw.len() - 1].to_string() + } else { + raw.to_string() + } +} + /// Extract the numeric literal operand from a binary expression. /// /// When a binary expression has one identifier operand (captured in `uses`) @@ -1290,6 +1834,18 @@ fn detect_member_field_assignment(ast: Node, code: &[u8]) -> Option { .or_else(|| d.child_by_field_name("initializer")) }) }) + .or_else(|| { + // Python wraps assignment in `expression_statement`; drill into + // the inner `assignment` node to reach its `right` field. Ruby + // wraps simple `x = rhs` in `assignment` directly so this arm is + // a no-op for Ruby, but the Python case is load-bearing for the + // `qs = User.objects` shape where `member_field` drives the + // Django ORM type-fact tagging. + let mut cursor = ast.walk(); + ast.named_children(&mut cursor) + .find(|c| matches!(c.kind(), "assignment")) + .and_then(|a| a.child_by_field_name("right")) + }) .unwrap_or(ast); extract_member_field_name(target, code) } @@ -1580,6 +2136,7 @@ pub(super) fn push_node<'a>( Kind::CallMacro => ast .child_by_field_name("macro") .and_then(|n| text_of(n, code)) + .map(|raw| rewrite_rust_bare_join_macro(&raw, ast, lang, code).unwrap_or(raw)) .unwrap_or_default(), // Function definitions: use just the function name, not the full @@ -1617,6 +2174,44 @@ pub(super) fn push_node<'a>( text = "subshell".to_string(); } + // JS/TS `for (… of iter)` / `for (… in iter)` / `for await (… of iter)`: + // tree-sitter classifies all three as `for_in_statement` with the + // iterator on the `right` field. Use the iterator expression's text + // (e.g. `"req.body"`) for label classification so the loop binding + // inherits a Source taint when the iterator matches a Source rule. + // Without this, the for_in_statement's text is the full multi-line + // loop, which never matches any short suffix-style Source matcher. + // + // Phase 03 originally proposed narrowing this rewrite to the + // `for await` form alone (where the iterator text classification + // was the immediate motivation). The rewrite is kept broader here + // because the same iterator-text classification benefits plain + // `for (const x of req.body)` and `for (const k in process.env)` + // identically — the loop-binding-inherits-iterator-taint semantics + // are uniform across all three forms, and narrowing would create + // an arbitrary distinction the source rules would have to mirror. + if matches!(lang, "javascript" | "typescript" | "tsx") + && ast.kind() == "for_in_statement" + && let Some(right) = ast.child_by_field_name("right") + && let Some(iter_text) = text_of(right, code) + { + text = iter_text; + } + + // Python `for x in iter:` / `async for x in iter:`: tree-sitter-python + // emits both shapes as `for_statement` (the `async` keyword is an + // unnamed leaf child). Same loop-binding-inherits-iterator-taint + // semantics as the JS rewrite above: classify against the iterator + // text so a `Source` matcher on `request.json` lights up when the + // loop iterates an awaitable request body. + if lang == "python" + && ast.kind() == "for_statement" + && let Some(right) = ast.child_by_field_name("right") + && let Some(iter_text) = text_of(right, code) + { + text = iter_text; + } + // If this is a declaration/expression wrapper or an assignment that // *contains* a call, prefer the first inner call identifier instead of // the whole line. Track the inner call's byte span so we can populate @@ -1760,6 +2355,33 @@ pub(super) fn push_node<'a>( // CVE-2023-38337, the Marshal/JSON/YAML-of-File.read pattern, etc.). let mut outer_callee: Option = None; let mut inner_callee_span: Option<(usize, usize)> = None; + // JS/TS Promise callback methods (`.then`/`.catch`/`.finally`) on chained + // receivers (`Promise.resolve(req.body).then(cb)`). Without this guard, + // `find_classifiable_inner_call` walks into the chain receiver and + // rewrites `text` from `.then` to `Promise.resolve` (which classifies as + // a Source), erasing the outer call's identity. The SSA layer then + // never sees a `then` callee, so `try_apply_promise_callback` never + // fires and taint on the resolved value is dropped. Detect the outer + // promise-callback method here and skip the rewrite — the outer call's + // identity is preserved, and the inner Promise.resolve's argument + // taint flows through `info.taint.uses` (implicit args) as the + // promise-callback handler already expects. + let outer_is_promise_callback = matches!(lang, "javascript" | "typescript" | "tsx") + && find_call_node(ast, lang) + .and_then(|cn| { + cn.child_by_field_name("function") + .or_else(|| cn.child_by_field_name("method")) + }) + .and_then(|fc| { + if matches!(fc.kind(), "member_expression" | "attribute") { + fc.child_by_field_name("property") + .or_else(|| fc.child_by_field_name("name")) + .and_then(|p| text_of(p, code)) + } else { + None + } + }) + .is_some_and(|leaf| crate::labels::is_promise_callback_method(lang, &leaf)); if labels.is_empty() && matches!( lookup(lang, ast.kind()), @@ -1774,9 +2396,11 @@ pub(super) fn push_node<'a>( find_classifiable_inner_call(ast, lang, code, extra) { labels.push(inner_label); - outer_callee = Some(text.clone()); - text = inner_text; - inner_callee_span = Some(inner_span); + if !outer_is_promise_callback { + outer_callee = Some(text.clone()); + text = inner_text; + inner_callee_span = Some(inner_span); + } } // For assignments like `element.innerHTML = value`, the inner-call heuristic @@ -1870,11 +2494,20 @@ pub(super) fn push_node<'a>( // summary resolution can still find the wrapping function // (e.g. `storeInto(req.query.input, items)` → callee="req.query.input" // but outer_callee="storeInto"). - if let Some(member_text) = first_member_text(ast, code) { - if outer_callee.is_none() && text != member_text { - outer_callee = Some(text.clone()); + // + // Skip the text rewrite when the outer call is a JS/TS promise + // callback method (`.then`/`.catch`/`.finally`). The `.then` call + // node must keep its `then` callee text so `try_apply_promise_callback` + // and the synthetic `source_to_callback` emission recognise it. + // The Source label still attaches, so the resolved-value taint + // flows from the inner `Promise.resolve(req.body)`. + if !outer_is_promise_callback { + if let Some(member_text) = first_member_text(ast, code) { + if outer_callee.is_none() && text != member_text { + outer_callee = Some(text.clone()); + } + text = member_text; } - text = member_text; } } @@ -1995,6 +2628,10 @@ pub(super) fn push_node<'a>( let has_source_label = labels .iter() .any(|l| matches!(l, crate::labels::DataLabel::Source(_))); + // Clippy flags one branch's clone as redundant because it cannot + // see that `text` is read after this `let` (further down in this + // function); silence the false positive without restructuring. + #[allow(clippy::redundant_clone)] let gate_callee_text = if let Some(ff) = function_field_text.as_deref() && has_source_label && ff != text.as_str() @@ -2272,6 +2909,28 @@ pub(super) fn push_node<'a>( } } + // React JSX text-content auto-escape sanitizer synthesis. When the + // assignment / wrapper / return AST contains a `{expr}` interpolation as + // a direct child of a `jsx_element` or `jsx_fragment` (NOT inside a + // `jsx_attribute`), React's renderer escapes HTML metacharacters in the + // interpolated value. Tag the wrapping node `Sanitizer(HTML_ESCAPE)` so + // SSA-level Assign / Call processing clears `HTML_ESCAPE` from the + // resulting JSX value's caps. Strictly additive — Source / Sink labels + // already attached are preserved. Already-present `Sanitizer(HTML_ESCAPE)` + // is left untouched to avoid duplicate entries. + if matches!(lang, "javascript" | "typescript" | "tsx") + && matches!( + lookup(lang, ast.kind()), + Kind::CallWrapper | Kind::Assignment | Kind::Return + ) + && !labels + .iter() + .any(|l| matches!(l, DataLabel::Sanitizer(c) if c.contains(Cap::HTML_ESCAPE))) + && jsx_text_content_interp_present(ast, lang) + { + labels.push(DataLabel::Sanitizer(Cap::HTML_ESCAPE)); + } + // Shape-based sanitizer synthesis for Ruby ActiveRecord query methods. // The static label table marks `where` / `order` / `pluck` / `group` / // `having` / `joins` as `Sink(SQL_QUERY)` because their string-interpolation @@ -2463,7 +3122,8 @@ pub(super) fn push_node<'a>( /* ── 3. GRAPH INSERTION + DEBUG ──────────────────────────────────── */ - let (defines, uses, extra_defines) = def_use(ast, lang, code); + let (defines, uses, extra_defines, array_pattern_indices, rhs_array_elements) = + def_use(ast, lang, code, extra); // Capture constant text for SSA constant propagation: when this node // defines a variable from a syntactic literal (no identifier uses), @@ -2622,6 +3282,11 @@ pub(super) fn push_node<'a>( // Rust `obj.method(x)`: call_expression.function = field_expression // (field on `value`, not `object`, value can be another call // for chained forms like `Connection::open(p).unwrap().execute(...)`). + // Go `obj.method(x)`: call_expression.function = selector_expression + // (operand=receiver, field=method name). Without this branch, + // `userDb.Raw(sql)` where `userDb` was bound from `gorm.Open(...)` + // loses its receiver channel, so type-qualified resolution can't + // rewrite `userDb.Raw` → `GormDb.Raw`. // Pull the receiver from the object/attribute-owner field. let func_child = cn.child_by_field_name("function"); let recv_node = match func_child { @@ -2629,6 +3294,9 @@ pub(super) fn push_node<'a>( fc.child_by_field_name("object") } Some(fc) if fc.kind() == "field_expression" => fc.child_by_field_name("value"), + Some(fc) if fc.kind() == "selector_expression" => { + fc.child_by_field_name("operand") + } _ => None, }; if let Some(rn) = recv_node { @@ -2749,6 +3417,8 @@ pub(super) fn push_node<'a>( defines, uses, extra_defines, + array_pattern_indices, + rhs_array_elements, }, ast: AstMeta { span, @@ -2771,6 +3441,7 @@ pub(super) fn push_node<'a>( is_numeric_length_access: detect_numeric_length_access(ast, lang, code), member_field: detect_member_field_assignment(ast, code), rhs_is_function_literal: rhs_is_function_literal(ast, lang), + is_await_forward: lookup(lang, ast.kind()) == Kind::AwaitForward, }); debug!( @@ -2959,8 +3630,8 @@ fn try_lower_subscript_write( kind: StmtKind::Call, call: CallMeta { callee: Some("__index_set__".to_string()), - receiver: Some(arr_text.clone()), - arg_uses: vec![vec![idx_text.clone()], rhs_uses.clone()], + receiver: Some(arr_text), + arg_uses: vec![vec![idx_text], rhs_uses], call_ordinal: ord, sink_payload_args: pp_payload_args, ..Default::default() @@ -3095,8 +3766,301 @@ fn try_lower_spring_redirect_return( Some(n) } -/// Prototype-pollution suppression decisions for the synthetic -/// `__index_set__` node emitted by `try_lower_subscript_write`. +/// React JSX `dangerouslySetInnerHTML={{ __html: x }}` recogniser. Walks +/// `stmt_ast` for every `jsx_attribute` named `dangerouslySetInnerHTML` whose +/// value is a `jsx_expression → object → pair[key="__html"]` shape, and +/// synthesises a CFG call node `dangerouslySetInnerHTML(__html_value)` with +/// `Sink(HTML_ESCAPE)` and `sink_payload_args = [0]`. The synthetic node's +/// span is the `__html` value subtree so finding-line attribution lands on +/// the payload, not the attribute name. +/// +/// Returns the new frontier (synthetic exits) when one or more sinks were +/// emitted; otherwise returns `preds` unchanged. +/// +/// Sanitizer-aware: when the `__html` value is a single call expression +/// whose callee classifies as a `Sanitizer`, the synthetic sink is still +/// emitted but its argument list is empty so no taint flows into it. +/// JS/TS only — JSX has no counterpart in the other supported languages. +#[allow(clippy::too_many_arguments)] +pub(super) fn try_lower_jsx_dangerous_html( + stmt_ast: Node, + preds: &[NodeIndex], + g: &mut Cfg, + lang: &str, + code: &[u8], + enclosing_func: Option<&str>, + call_ordinal: &mut u32, + analysis_rules: Option<&LangAnalysisRules>, +) -> Vec { + if !matches!(lang, "javascript" | "js" | "typescript" | "ts" | "tsx") { + return preds.to_vec(); + } + let mut attrs: Vec = Vec::new(); + collect_jsx_dangerous_html_attrs(stmt_ast, code, &mut attrs); + if attrs.is_empty() { + return preds.to_vec(); + } + let extra = analysis_rules.map(|r| r.extra_labels.as_slice()); + let mut frontier: Vec = preds.to_vec(); + for attr in attrs { + let Some(html_value) = jsx_extract_html_value(attr, code) else { + continue; + }; + let span = (html_value.start_byte(), html_value.end_byte()); + let ord = *call_ordinal; + *call_ordinal += 1; + + // Sanitizer-aware: if the value subtree is a call to a known + // sanitizer, emit the sink with no argument-side taint flow so the + // synthetic site stays silent on already-sanitized payloads. + let arg_uses_idents: Vec = if jsx_value_is_sanitized(html_value, lang, code, extra) + { + Vec::new() + } else { + let mut idents: Vec = Vec::new(); + collect_idents(html_value, code, &mut idents); + idents + }; + + let mut labels: smallvec::SmallVec<[DataLabel; 2]> = smallvec::SmallVec::new(); + labels.push(DataLabel::Sink(Cap::HTML_ESCAPE)); + + let n = g.add_node(NodeInfo { + kind: StmtKind::Call, + call: CallMeta { + callee: Some("dangerouslySetInnerHTML".to_string()), + arg_uses: vec![arg_uses_idents.clone()], + call_ordinal: ord, + sink_payload_args: Some(vec![0]), + ..Default::default() + }, + taint: TaintMeta { + labels, + uses: arg_uses_idents, + ..Default::default() + }, + ast: AstMeta { + span, + enclosing_func: enclosing_func.map(|s| s.to_string()), + }, + ..Default::default() + }); + connect_all(g, &frontier, n, EdgeKind::Seq); + frontier = vec![n]; + } + frontier +} + +/// Walk `root` collecting every `jsx_attribute` descendant whose name (via +/// the source bytes in `code`) equals `dangerouslySetInnerHTML`. +fn collect_jsx_dangerous_html_attrs<'a>(root: Node<'a>, code: &[u8], out: &mut Vec>) { + let mut stack: Vec> = vec![root]; + while let Some(node) = stack.pop() { + if node.kind() == "jsx_attribute" && jsx_attr_name_is(node, "dangerouslySetInnerHTML", code) + { + out.push(node); + // Don't recurse into the attribute's own subtree; nested JSX + // attributes inside the value are vanishingly rare and would + // double-emit if the value contained another React element. + continue; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + stack.push(child); + } + } +} + +/// True when `root`'s subtree contains a `jsx_expression` whose direct +/// parent is a `jsx_element` or `jsx_fragment` (i.e. a `{expr}` text-content +/// interpolation between JSX tags). React renders text content with HTML +/// metachar escaping, so any taint flowing through such an interpolation +/// has its `HTML_ESCAPE` cap cleared by the time the JSX value is rendered. +/// +/// Bails at nested function-literal boundaries so JSX inside a closure body +/// (`const fn = () =>
    {bio}
    `) does not falsely tag the outer +/// assignment — the closure's result only escapes when the closure is called +/// and rendered, which the outer assignment does not perform. +/// +/// Excludes attribute interpolations (`
    `); React does +/// auto-escape attribute values as well, but the deferred-plan scope is +/// text-content only. Widen if a fixture surfaces a pure-attribute FP. +fn jsx_text_content_interp_present(root: Node, lang: &str) -> bool { + let mut stack: Vec = vec![root]; + while let Some(node) = stack.pop() { + // Closure boundary: nested function bodies do not flow their JSX + // result out through this assignment's value. + if matches!(lookup(lang, node.kind()), Kind::Function) && node.id() != root.id() { + continue; + } + if node.kind() == "jsx_expression" + && let Some(parent) = node.parent() + && matches!(parent.kind(), "jsx_element" | "jsx_fragment") + { + return true; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + stack.push(child); + } + } + false +} + +/// Read the attribute name off a `jsx_attribute` node and compare against +/// `expected`. Looks at the `name` field (or first named child) and reads +/// its UTF-8 text from `code`. +fn jsx_attr_name_is(attr: Node, expected: &str, code: &[u8]) -> bool { + let name_node = match attr + .child_by_field_name("name") + .or_else(|| attr.named_child(0)) + { + Some(n) => n, + None => return false, + }; + text_of(name_node, code) + .map(|t| t == expected) + .unwrap_or(false) +} + +/// Resolve the `__html` value subtree of a JSX +/// `dangerouslySetInnerHTML={{ __html: }}` attribute. Returns the +/// AST node for `` or `None` if the shape doesn't match. +fn jsx_extract_html_value<'a>(attr: Node<'a>, code: &[u8]) -> Option> { + let value = attr + .child_by_field_name("value") + .or_else(|| attr.named_child(1))?; + // Strip the `{...}` wrapper. tree-sitter exposes this as + // `jsx_expression`; defensive against grammar variants by also + // accepting the inner expression directly. + let inner = if value.kind() == "jsx_expression" { + let mut cur = value.walk(); + value + .named_children(&mut cur) + .find(|c| c.kind() != "comment")? + } else { + value + }; + let object_kind = inner.kind(); + if !matches!( + object_kind, + "object" | "object_expression" | "object_literal" + ) { + return None; + } + let mut cur = inner.walk(); + for pair in inner.named_children(&mut cur) { + if !matches!( + pair.kind(), + "pair" | "property" | "shorthand_property_identifier" + ) { + continue; + } + let key_node = pair + .child_by_field_name("key") + .or_else(|| pair.named_child(0)); + let val_node = pair + .child_by_field_name("value") + .or_else(|| pair.named_child(1)); + let (Some(k), Some(v)) = (key_node, val_node) else { + continue; + }; + let key_text = text_of(k, code).unwrap_or_default(); + // Strip surrounding quotes for `"__html"` / `'__html'` literal keys. + let key_trim = key_text.trim_matches(|c| c == '"' || c == '\'' || c == '`'); + if key_trim == "__html" { + return Some(v); + } + } + None +} + +/// Returns true when `value_ast` is a call expression whose payload is +/// already routed through a `Sanitizer`. Used to suppress argument-side +/// taint flow on the synthetic `dangerouslySetInnerHTML` sink. +/// +/// Recognised shapes (JS/TS): +/// +/// 1. Direct call: outer callee classifies as `Sanitizer` under the +/// rule set, e.g. `__html: DOMPurify.sanitize(input)`. +/// 2. Function-composition helpers — `pipe(input, sanitizeHtml, ...)`, +/// `compose(DOMPurify.sanitize, escapeHtml)(input)`, etc. When the +/// outer callee leaf is one of `pipe` / `flow` / `compose` / +/// `flowRight` / `pipeWith` (covers fp-ts, Ramda, Lodash/fp, +/// Effect-TS), any argument whose text classifies as `Sanitizer` +/// is treated as the sanitization step. +/// +/// Variable-bound sanitization (`const clean = sanitize(x); __html: clean`) +/// is handled by SSA value tracking on the bound identifier and does not +/// pass through this recogniser. +fn jsx_value_is_sanitized( + value_ast: Node, + lang: &str, + code: &[u8], + extra: Option<&[crate::labels::RuntimeLabelRule]>, +) -> bool { + let mut cur = value_ast; + while cur.kind() == "parenthesized_expression" { + let Some(inner) = cur.named_child(0) else { + return false; + }; + cur = inner; + } + if !matches!(cur.kind(), "call_expression" | "call") { + return false; + } + let callee = match cur + .child_by_field_name("function") + .or_else(|| cur.child_by_field_name("name")) + { + Some(c) => c, + None => return false, + }; + let callee_text = match text_of(callee, code) { + Some(t) => t, + None => return false, + }; + + // 1. Direct sanitizer call. + let labels = classify_all(lang, &callee_text, extra); + if labels.iter().any(|l| matches!(l, DataLabel::Sanitizer(_))) { + return true; + } + + // 2. Function-composition helper. Strip namespace qualifiers from the + // callee so `_.flow` / `R.pipe` / `fp.compose` all reduce to the + // leaf helper name. + let leaf_callee = callee_text + .rsplit(['.', ':']) + .next() + .unwrap_or(callee_text.as_str()); + let is_compose_helper = matches!( + leaf_callee, + "pipe" | "flow" | "compose" | "flowRight" | "pipeWith" + ); + if is_compose_helper { + if let Some(args) = cur.child_by_field_name("arguments") { + let mut walker = args.walk(); + for arg in args.named_children(&mut walker) { + if matches!(arg.kind(), "comment") { + continue; + } + let Some(arg_text) = text_of(arg, code) else { + continue; + }; + let arg_labels = classify_all(lang, &arg_text, extra); + if arg_labels + .iter() + .any(|l| matches!(l, DataLabel::Sanitizer(_))) + { + return true; + } + } + } + } + + false +} /// /// Returns `true` when the assignment is provably safe and the /// `Cap::PROTOTYPE_POLLUTION` sink label should be elided. The three @@ -4195,6 +5159,21 @@ pub(super) fn build_sub<'a>( ); apply_arg_source_bindings(g, call_idx, &src_bindings, &src_uses_only); connect_all(g, &effective_preds, call_idx, EdgeKind::Seq); + // React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis + // (Phase 06): inserted between the wrapping Call (the inner + // sanitizer / source call picked up by find_classifiable_inner_call) + // and the Return so the synthetic sink fires on the + // post-sanitization payload. + let post_jsx = try_lower_jsx_dangerous_html( + ast, + &[call_idx], + g, + lang, + code, + enclosing_func, + call_ordinal, + analysis_rules, + ); let ret = push_node( g, StmtKind::Return, @@ -4205,7 +5184,7 @@ pub(super) fn build_sub<'a>( 0, analysis_rules, ); - connect_all(g, &[call_idx], ret, EdgeKind::Seq); + connect_all(g, &post_jsx, ret, EdgeKind::Seq); // Recurse into any function expressions nested inside the // returned call's arguments (e.g. @@ -4265,6 +5244,19 @@ pub(super) fn build_sub<'a>( ) { effective_preds = vec![synth]; } + // React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis + // (Phase 06) — fires when the JSX has no descendant call so + // the wrapping Return arm reaches this branch. + effective_preds = try_lower_jsx_dangerous_html( + ast, + &effective_preds, + g, + lang, + code, + enclosing_func, + call_ordinal, + analysis_rules, + ); let ret = push_node( g, StmtKind::Return, @@ -4814,6 +5806,18 @@ pub(super) fn build_sub<'a>( // ── 6) Push BodyCfg ─────────────────────────────────────────────── let auth_decorators = extract_auth_decorators(ast, lang, code); + let route_captures = extract_route_path_captures(ast, lang, code); + let param_route_capture: Vec = if route_captures.is_empty() { + vec![false; param_names.len()] + } else { + param_names + .iter() + .map(|n| { + let lc = n.to_ascii_lowercase(); + route_captures.iter().any(|c| c == &lc) + }) + .collect() + }; bodies.push(BodyCfg { meta: BodyMeta { id: fn_body_id, @@ -4831,6 +5835,7 @@ pub(super) fn build_sub<'a>( parent_body_id: Some(current_body_id), func_key: Some(body_func_key), auth_decorators, + param_route_capture, }, graph: fn_graph, entry: fn_entry, @@ -4984,6 +5989,22 @@ pub(super) fn build_sub<'a>( connect_all(g, &effective_preds, node, EdgeKind::Seq); + // React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis + // (Phase 06): chained after the wrapper Call/Seq for cases like + // `
    ;` (expression_statement) where the JSX appears as + // a top-level expression statement. No-op when the wrapper has + // no matching JSX descendant. + let post_jsx_frontier = try_lower_jsx_dangerous_html( + ast, + &[node], + g, + lang, + code, + enclosing_func, + call_ordinal, + analysis_rules, + ); + // If the callee is a configured terminator, treat as a dead end if kind == StmtKind::Call && let Some(callee) = &g[node].call.callee @@ -5061,7 +6082,7 @@ pub(super) fn build_sub<'a>( return vec![true_gate, false_gate]; } - vec![node] + post_jsx_frontier } // Direct call nodes (Ruby `call`, Python `call`, etc. when they appear @@ -5204,16 +6225,59 @@ pub(super) fn build_sub<'a>( analysis_rules, ); connect_all(g, preds, n, EdgeKind::Seq); - vec![n] + // React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis + // (Phase 06): chained after the assignment for shapes like + // `const el =
    `. No-op when no matching JSX descendant + // is found in the assignment subtree. + try_lower_jsx_dangerous_html( + ast, + &[n], + g, + lang, + code, + enclosing_func, + call_ordinal, + analysis_rules, + ) } // Trivia we drop completely --------------------------------------------- Kind::Trivia => preds.to_vec(), + // React JSX attribute (`name={value}`). The CFG builder synthesises + // a sink Call node when the attribute is `dangerouslySetInnerHTML` + // with a `{__html: x}` shape; otherwise no node is added (JSX + // attributes carry no execution semantics on their own). + Kind::JsxAttr => try_lower_jsx_dangerous_html( + ast, + preds, + g, + lang, + code, + enclosing_func, + call_ordinal, + analysis_rules, + ), + // ───────────────────────────────────────────────────────────────── // Every other node = simple sequential statement // ───────────────────────────────────────────────────────────────── _ => { + // React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis + // (Phase 06): handles arrow-bodied components like + // `() =>
    ` that reach this arm without a wrapping + // return / call statement. Strictly additive — when no JSX + // attribute matches the helper returns `preds` unchanged. + let preds_v = try_lower_jsx_dangerous_html( + ast, + preds, + g, + lang, + code, + enclosing_func, + call_ordinal, + analysis_rules, + ); let n = push_node( g, StmtKind::Seq, @@ -5224,7 +6288,7 @@ pub(super) fn build_sub<'a>( 0, analysis_rules, ); - connect_all(g, preds, n, EdgeKind::Seq); + connect_all(g, &preds_v, n, EdgeKind::Seq); vec![n] } } @@ -5264,6 +6328,13 @@ pub(crate) fn build_cfg<'a>( *cell.borrow_mut() = dto::collect_type_alias_local_collections(tree.root_node(), lang, code); }); + // harvest per-function local-receiver type bindings, so a chained + // inner call (`sess.createNativeQuery(sql).getResultList()`) can + // rewrite the receiver `sess` to its type prefix + // (`HibernateSession`) when the legacy literal-receiver classify + // misses. Java-only today; the helper is lang-agnostic, gated on + // `constructor_type` recognising the RHS callee. + populate_local_receiver_types(tree, lang, code); // Create the top-level body graph (BodyId(0)). let (mut g, entry, exit) = create_body_graph(0, code.len(), None); @@ -5353,6 +6424,7 @@ pub(crate) fn build_cfg<'a>( parent_body_id: None, func_key: None, auth_decorators: Vec::new(), + param_route_capture: Vec::new(), }, graph: g, entry, @@ -5388,12 +6460,28 @@ pub(crate) fn build_cfg<'a>( apply_promisify_labels(&mut bodies, &promisify_aliases, lang, extra); } + // Phase 05 — JS/TS gated FILE_IO sinks (`readFile`, `writeFile`, ...) + // for `node:fs/promises` callees. Runs after CFG construction so the + // per-file local-import view is available; classify_all_ctx looks up + // each call's leading identifier in the view to decide whether the + // ImportedFromModule gate fires. + let local_imports = if matches!(lang, "javascript" | "typescript" | "tsx") { + let local_imports = extract_local_import_view(tree, code); + if !local_imports.is_empty() { + apply_gated_label_rules(&mut bodies, lang, extra, &local_imports); + } + local_imports + } else { + HashMap::new() + }; + // Clear the per-file DFS-index map so it does not leak to the next // file built on this thread. clear_fn_dfs_indices(); // same hygiene for the DTO map. DTO_CLASSES.with(|cell| cell.borrow_mut().clear()); TYPE_ALIAS_LC.with(|cell| cell.borrow_mut().clear()); + LOCAL_RECEIVER_TYPES.with(|cell| cell.borrow_mut().clear()); // collect every // declared inheritance / impl / implements relationship in the @@ -5405,12 +6493,39 @@ pub(crate) fn build_cfg<'a>( // `crate::callgraph::TypeHierarchyIndex` at call-graph build time. let hierarchy_edges = hierarchy::collect_hierarchy_edges(tree.root_node(), lang, code); + // Phase 10 — Next.js entry-point detection. Empty for non-JS/TS + // languages; for JS/TS, keys each detected entry function by its + // tree-sitter byte span so the SSA pass can match against + // [`BodyMeta::span`] when seeding params. + let entry_kinds = crate::entry_points::detect_entries_in_file( + tree, + code, + std::path::Path::new(file_path), + lang, + ); + + // Java safe-lookup field map: `final ... = Map.of(literal, literal, ...)` + // declarations whose `.get(...)` results are bounded to the literal + // set. Empty for other languages. + let safe_lookup_fields = safe_fields::collect_safe_lookup_fields(tree.root_node(), lang, code); + + // Java class-level constant scalars: `static final TYPE NAME = LITERAL;` + // declarations whose name surfaces at a sink as a compile-time-bounded + // value. Empty for other languages. + let class_constant_scalars = + safe_fields::collect_class_constant_scalars(tree.root_node(), lang, code); + FileCfg { bodies, summaries, import_bindings, promisify_aliases, hierarchy_edges, + resolved_imports: Vec::new(), + local_imports, + entry_kinds, + safe_lookup_fields, + class_constant_scalars, } } @@ -5463,6 +6578,42 @@ fn apply_promisify_labels( } } +/// Phase 05 — apply [`crate::labels::GatedLabelRule`] entries against +/// every call node in the file. The local-import view supplies the +/// gate evaluation context so a bare-name `readFile(...)` only fires +/// when the file actually imports `readFile` from `fs/promises` / +/// `node:fs/promises` (or is renamed via `import * as fsp` / +/// `import { readFile as rf }`). Strictly additive: only inserts new +/// labels, never removes existing ones. +fn apply_gated_label_rules( + bodies: &mut [BodyCfg], + lang: &str, + _extra: Option<&[crate::labels::RuntimeLabelRule]>, + local_imports: &std::collections::HashMap, +) { + let ctx = crate::labels::ClassificationContext { + local_imports: Some(local_imports), + }; + for body in bodies.iter_mut() { + let indices: Vec = body.graph.node_indices().collect(); + for idx in indices { + let Some(callee) = body.graph[idx].call.callee.clone() else { + continue; + }; + let labels = crate::labels::classify_gated_only(lang, &callee, Some(&ctx)); + if labels.is_empty() { + continue; + } + let info = &mut body.graph[idx]; + for lbl in labels { + if !info.taint.labels.contains(&lbl) { + info.taint.labels.push(lbl); + } + } + } + } +} + /// Build a `CalleeSite` carrying the richer per-call-site metadata for a /// CFG node. /// @@ -5561,6 +6712,11 @@ pub(crate) fn export_summaries( // graph-local `FuncSummaries`. `ParsedFile::export_summaries_with_root` // attaches them after this transform returns. hierarchy_edges: Vec::new(), + // Phase-10 entry-point classification is attached after + // this transform returns by + // `ParsedFile::export_summaries_with_root` (which has + // access to `FileCfg::entry_kinds`). + entry_kind: None, }) .collect() } diff --git a/src/cfg/safe_fields.rs b/src/cfg/safe_fields.rs new file mode 100644 index 00000000..c5bc70eb --- /dev/null +++ b/src/cfg/safe_fields.rs @@ -0,0 +1,882 @@ +//! Per-file extraction of class fields whose `.get(...)` lookups are +//! provably safe. +//! +//! Recognises Java `final` fields whose initializer is `Map.of(K1, V1, +//! K2, V2, ...)` with all string-literal arguments. At a downstream +//! `.get(taintedKey)` call the result is bounded to the literal +//! value set, so the SSA taint engine can suppress propagation from the +//! key to the result. Without this pre-pass the engine sees `` +//! as a free identifier with no SSA value, fails to resolve the +//! container, and falls back to default arg-to-result propagation. +//! +//! Strictly additive: unrecognised initializer shapes (factory chains, +//! `Map.ofEntries`, builders) produce no entry and the engine keeps +//! its prior behaviour. + +use std::cell::RefCell; +use std::collections::HashMap; + +use tree_sitter::Node; + +use super::helpers::text_of; + +thread_local! { + /// Per-file safe-lookup field map published by [`with_safe_lookup_fields`] + /// around taint passes that need it. The SSA taint engine's container + /// Load fallback consults this view via [`safe_lookup_field_values`] when + /// the receiver is a free identifier (no SSA value to resolve against). + static SAFE_LOOKUP_FIELDS_TLS: RefCell>>> = + const { RefCell::new(None) }; +} + +/// Run `f` with `fields` published as the per-thread safe-lookup view. +/// Restores the prior value on drop so nested calls compose; pass `None` +/// to suppress the gate for callers that lack a file context. +pub fn with_safe_lookup_fields( + fields: Option<&HashMap>>, + f: impl FnOnce() -> R, +) -> R { + let prev = SAFE_LOOKUP_FIELDS_TLS.with(|cell| { + cell.borrow_mut() + .replace(fields.cloned().unwrap_or_default()) + }); + let restore_to = if fields.is_some() { prev } else { None }; + struct Guard(Option>>); + impl Drop for Guard { + fn drop(&mut self) { + SAFE_LOOKUP_FIELDS_TLS.with(|cell| *cell.borrow_mut() = self.0.take()); + } + } + let _guard = Guard(restore_to); + f() +} + +/// Look up the literal value set for a safe field. Returns `None` when +/// no view is published, the field is not a known safe lookup, or the +/// value list is empty. +pub fn safe_lookup_field_values(name: &str) -> Option> { + SAFE_LOOKUP_FIELDS_TLS.with(|cell| { + let borrowed = cell.borrow(); + let map = borrowed.as_ref()?; + let values = map.get(name)?; + if values.is_empty() { + None + } else { + Some(values.clone()) + } + }) +} + +/// Per-file safe-lookup field map: field name → finite set of literal +/// values that `.get(...)` may return. Empty for non-Java files. +pub fn collect_safe_lookup_fields( + root: Node<'_>, + lang: &str, + code: &[u8], +) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + if lang == "java" { + collect_java(root, code, &mut out); + } + out +} + +/// Per-file file-level constant scalar map: name → literal value text. +/// +/// Recognises declarations that bind a name to a primitive scalar literal at +/// file or class scope, where the per-function SSA const-prop has no view of +/// the binding (the name is a free identifier from inside any function body): +/// +/// - Java: `static final TYPE NAME = LITERAL;` fields (any class depth). +/// - Python: `NAME = LITERAL` at module scope. +/// - Go: `const NAME = LITERAL` and `const NAME TYPE = LITERAL` at package scope. +/// - Rust: `const NAME: TYPE = LITERAL;` and `static NAME: TYPE = LITERAL;` at +/// crate or module scope. +/// +/// Used by `cfg_analysis::guards` to suppress `cfg-unguarded-sink` when a +/// sink's argument is one of these bindings. `LITERAL` covers strings (no +/// interpolation), integers in any supported base, floats, booleans, null / +/// nil / None, and unary negation / not over those. +/// +/// Empty for unsupported languages. Scalar means single-value, not +/// container; the `Map.of(...)` form is captured by +/// [`collect_safe_lookup_fields`]. +pub fn collect_class_constant_scalars( + root: Node<'_>, + lang: &str, + code: &[u8], +) -> HashMap { + let mut out: HashMap = HashMap::new(); + match lang { + "java" => collect_java_constant_scalars(root, code, &mut out), + "python" => collect_python_constant_scalars(root, code, &mut out), + "go" => collect_go_constant_scalars(root, code, &mut out), + "rust" => collect_rust_constant_scalars(root, code, &mut out), + _ => {} + } + out +} + +fn collect_java_constant_scalars(root: Node<'_>, code: &[u8], out: &mut HashMap) { + walk(root, &mut |node| { + if node.kind() != "field_declaration" { + return; + } + if !has_static_modifier(node) || !has_final_modifier(node) { + return; + } + // A single `field_declaration` may carry multiple + // `variable_declarator` children (`static final int A = 1, B = 2;`). + // Iterate every declarator field; tree-sitter exposes them under + // the `declarator` field name as repeated entries. + let mut cursor = node.walk(); + for child in node.children_by_field_name("declarator", &mut cursor) { + let Some(name_node) = child.child_by_field_name("name") else { + continue; + }; + let Some(field_name) = text_of(name_node, code) else { + continue; + }; + let Some(value_node) = child.child_by_field_name("value") else { + continue; + }; + let Some(literal) = scalar_literal_text(value_node, code) else { + continue; + }; + out.insert(field_name, literal); + } + }); +} + +/// Python: module-level `NAME = LITERAL` assignments. Only top-level +/// expression statements are considered; assignments inside function bodies, +/// class bodies, or other blocks are out of scope (a per-function SSA pass +/// already sees those). +fn collect_python_constant_scalars(root: Node<'_>, code: &[u8], out: &mut HashMap) { + if root.kind() != "module" { + return; + } + let mut cursor = root.walk(); + for child in root.named_children(&mut cursor) { + if child.kind() != "expression_statement" { + continue; + } + let Some(assign) = child.named_child(0) else { + continue; + }; + if assign.kind() != "assignment" { + continue; + } + let Some(target) = assign.child_by_field_name("left") else { + continue; + }; + if target.kind() != "identifier" { + continue; + } + let Some(name) = text_of(target, code) else { + continue; + }; + let Some(value) = assign.child_by_field_name("right") else { + continue; + }; + let Some(literal) = python_scalar_literal_text(value, code) else { + continue; + }; + out.insert(name, literal); + } +} + +/// Go: package-level `const NAME = LITERAL` and `const NAME TYPE = LITERAL`, +/// including the grouped `const (...)` form. Iterates direct +/// `const_declaration` children of the source file, then per-`const_spec` +/// reads the `name` list and `value` expression list, binding by position. +fn collect_go_constant_scalars(root: Node<'_>, code: &[u8], out: &mut HashMap) { + if root.kind() != "source_file" { + return; + } + let mut cursor = root.walk(); + for child in root.named_children(&mut cursor) { + if child.kind() != "const_declaration" { + continue; + } + let mut spec_cursor = child.walk(); + for spec in child.named_children(&mut spec_cursor) { + if spec.kind() != "const_spec" { + continue; + } + collect_go_const_spec(spec, code, out); + } + } +} + +fn collect_go_const_spec(spec: Node<'_>, code: &[u8], out: &mut HashMap) { + // tree-sitter-go `const_spec`: + // name: (repeated) — one or more identifiers + // value: — list of value expressions + // For a multi-target spec `const A, B = 1, 2`, identifiers and values pair + // up positionally. The simpler single-target form parses the same way + // with one entry per side. + let mut name_cursor = spec.walk(); + let names: Vec> = spec + .children_by_field_name("name", &mut name_cursor) + .collect(); + if names.is_empty() { + return; + } + let Some(value_list) = spec.child_by_field_name("value") else { + return; + }; + let mut value_cursor = value_list.walk(); + let values: Vec> = value_list.named_children(&mut value_cursor).collect(); + if values.len() != names.len() { + return; + } + for (name_node, value_node) in names.iter().zip(values.iter()) { + if name_node.kind() != "identifier" { + continue; + } + let Some(name) = text_of(*name_node, code) else { + continue; + }; + let Some(literal) = go_scalar_literal_text(*value_node, code) else { + continue; + }; + out.insert(name, literal); + } +} + +/// Rust: module-level `const NAME: TYPE = LITERAL;` and `static NAME: TYPE = +/// LITERAL;`. Only direct children of `source_file` participate so a `const` +/// defined inside a function body does not bleed across scopes. +fn collect_rust_constant_scalars(root: Node<'_>, code: &[u8], out: &mut HashMap) { + if root.kind() != "source_file" { + return; + } + let mut cursor = root.walk(); + for child in root.named_children(&mut cursor) { + if !matches!(child.kind(), "const_item" | "static_item") { + continue; + } + let Some(name_node) = child.child_by_field_name("name") else { + continue; + }; + let Some(name) = text_of(name_node, code) else { + continue; + }; + let Some(value_node) = child.child_by_field_name("value") else { + continue; + }; + let Some(literal) = rust_scalar_literal_text(value_node, code) else { + continue; + }; + out.insert(name, literal); + } +} + +/// `true` when `field_declaration` carries a `static` modifier. +fn has_static_modifier(field_decl: Node<'_>) -> bool { + let mut cursor = field_decl.walk(); + for child in field_decl.children(&mut cursor) { + if child.kind() != "modifiers" { + continue; + } + let mut sub = child.walk(); + for mod_child in child.children(&mut sub) { + if mod_child.kind() == "static" { + return true; + } + } + } + false +} + +/// Return the source text when `value` is a primitive scalar literal node. +/// Covers the Java grammar's literal kinds. Returns `None` for compound +/// expressions, identifier references, method invocations, and other +/// non-literal initializers. +fn scalar_literal_text(value: Node<'_>, code: &[u8]) -> Option { + match value.kind() { + "string_literal" + | "decimal_integer_literal" + | "hex_integer_literal" + | "octal_integer_literal" + | "binary_integer_literal" + | "decimal_floating_point_literal" + | "hex_floating_point_literal" + | "character_literal" + | "true" + | "false" + | "null_literal" => text_of(value, code), + // Unary `-1`, `+0`, `!true` over a literal child still resolve to a + // compile-time constant; recurse into the operand. + "unary_expression" => { + let operand = value.child_by_field_name("operand")?; + scalar_literal_text(operand, code) + } + _ => None, + } +} + +/// Python scalar literal classifier. Rejects f-strings with interpolation +/// (`f"x{var}"` parses as `string` with an `interpolation` child); returns +/// the source text otherwise. +fn python_scalar_literal_text(value: Node<'_>, code: &[u8]) -> Option { + match value.kind() { + "string" => { + if python_string_has_interpolation(value) { + None + } else { + text_of(value, code) + } + } + "integer" | "float" | "true" | "false" | "none" => text_of(value, code), + "unary_operator" => { + let operand = value.child_by_field_name("argument")?; + python_scalar_literal_text(operand, code) + } + _ => None, + } +} + +fn python_string_has_interpolation(node: Node<'_>) -> bool { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "interpolation" { + return true; + } + } + false +} + +/// Go scalar literal classifier. `interpreted_string_literal` and +/// `raw_string_literal` cover both `"x"` and `` `x` `` forms. +fn go_scalar_literal_text(value: Node<'_>, code: &[u8]) -> Option { + match value.kind() { + "interpreted_string_literal" + | "raw_string_literal" + | "int_literal" + | "float_literal" + | "imaginary_literal" + | "rune_literal" + | "true" + | "false" + | "nil" => text_of(value, code), + "unary_expression" => { + let operand = value.child_by_field_name("operand")?; + go_scalar_literal_text(operand, code) + } + _ => None, + } +} + +/// Rust scalar literal classifier. Accepts `string_literal`, `raw_string_literal` +/// (both unwrappable to a single text run), integer / float / boolean / char. +fn rust_scalar_literal_text(value: Node<'_>, code: &[u8]) -> Option { + match value.kind() { + "string_literal" | "raw_string_literal" | "integer_literal" | "float_literal" + | "char_literal" | "boolean_literal" => text_of(value, code), + // `true` / `false` are leaf identifier-ish nodes in some grammars but + // tree-sitter-rust gives them the `boolean_literal` kind; defensively + // accept the leaf form too in case the grammar is upgraded. + "true" | "false" => text_of(value, code), + "unary_expression" => { + let mut cursor = value.walk(); + value + .named_children(&mut cursor) + .find_map(|c| rust_scalar_literal_text(c, code)) + } + _ => None, + } +} + +fn collect_java(root: Node<'_>, code: &[u8], out: &mut HashMap>) { + walk(root, &mut |node| { + if node.kind() != "field_declaration" { + return; + } + if !has_final_modifier(node) { + return; + } + let Some(decl) = node.child_by_field_name("declarator") else { + return; + }; + let Some(name_node) = decl.child_by_field_name("name") else { + return; + }; + let Some(field_name) = text_of(name_node, code) else { + return; + }; + let Some(value_node) = decl.child_by_field_name("value") else { + return; + }; + let Some(values) = extract_map_of_literal_values(value_node, code) else { + return; + }; + out.insert(field_name, values); + }); +} + +/// `true` when `field_declaration` carries a `final` modifier (static or +/// instance — both block reassignment after construction). +fn has_final_modifier(field_decl: Node<'_>) -> bool { + let mut cursor = field_decl.walk(); + for child in field_decl.children(&mut cursor) { + if child.kind() != "modifiers" { + continue; + } + let mut sub = child.walk(); + for mod_child in child.children(&mut sub) { + if mod_child.kind() == "final" { + return true; + } + } + } + false +} + +/// If `value_node` is `Map.of(LIT, LIT, LIT, LIT, ...)` with at least one +/// key/value pair and every argument a `string_literal`, return the +/// value-position literals (positions 1, 3, 5, ...). +fn extract_map_of_literal_values(value_node: Node<'_>, code: &[u8]) -> Option> { + if value_node.kind() != "method_invocation" { + return None; + } + let object_node = value_node.child_by_field_name("object")?; + let method_node = value_node.child_by_field_name("name")?; + let method_text = text_of(method_node, code)?; + if method_text != "of" { + return None; + } + if !receiver_is_map_class(object_node, code) { + return None; + } + let args_node = value_node.child_by_field_name("arguments")?; + let mut cursor = args_node.walk(); + let args: Vec> = args_node.named_children(&mut cursor).collect(); + if args.is_empty() || !args.len().is_multiple_of(2) { + return None; + } + let mut values = Vec::with_capacity(args.len() / 2); + for (i, arg) in args.iter().enumerate() { + if arg.kind() != "string_literal" { + return None; + } + if i % 2 == 1 { + let literal = string_literal_value(*arg, code)?; + values.push(literal); + } + } + Some(values) +} + +/// `true` when `node` resolves to the `Map` class — either the bare +/// identifier `Map` or a `field_access` whose tail segment is `Map` +/// (covers `java.util.Map.of(...)`). +fn receiver_is_map_class(node: Node<'_>, code: &[u8]) -> bool { + match node.kind() { + "identifier" => text_of(node, code).as_deref() == Some("Map"), + "field_access" => { + // tail segment lives on the `field` field + let Some(field) = node.child_by_field_name("field") else { + return false; + }; + text_of(field, code).as_deref() == Some("Map") + } + _ => false, + } +} + +/// Extract the inner content of a Java `string_literal` node. The +/// grammar wraps the value in `string_fragment` children between quote +/// tokens; concatenate every `string_fragment` so escaped quotes inside +/// the literal are not lost. Returns `None` for literals containing +/// interpolation / escape-sequence children that do not classify as a +/// pure string fragment. +fn string_literal_value(node: Node<'_>, code: &[u8]) -> Option { + let mut cursor = node.walk(); + let mut out = String::new(); + let mut saw_fragment = false; + for child in node.named_children(&mut cursor) { + match child.kind() { + "string_fragment" => { + saw_fragment = true; + out.push_str(&text_of(child, code)?); + } + "escape_sequence" => { + // A real escape sequence keeps the literal pure-string but + // we cannot trivially decode it; return None to be + // conservative on header-injection safety. + return None; + } + _ => return None, + } + } + if saw_fragment { + Some(out) + } else { + // Empty literal `""` — has no `string_fragment` children but is + // a valid empty string. + let raw = text_of(node, code)?; + if raw == "\"\"" { + Some(String::new()) + } else { + None + } + } +} + +fn walk<'a, F: FnMut(Node<'a>)>(node: Node<'a>, f: &mut F) { + f(node); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + walk(child, f); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tree_sitter::Parser; + + fn collect(src: &str) -> HashMap> { + let mut p = Parser::new(); + p.set_language(&tree_sitter_java::LANGUAGE.into()).unwrap(); + let tree = p.parse(src, None).unwrap(); + collect_safe_lookup_fields(tree.root_node(), "java", src.as_bytes()) + } + + #[test] + fn static_final_map_of_two_pairs() { + let src = r#" + class C { + private static final java.util.Map T = Map.of( + "a", "x", "b", "y" + ); + } + "#; + let out = collect(src); + assert_eq!(out.get("T"), Some(&vec!["x".to_string(), "y".to_string()])); + } + + #[test] + fn instance_final_map_of_one_pair() { + let src = r#" + class C { + private final java.util.Map T = Map.of("a", "x"); + } + "#; + let out = collect(src); + assert_eq!(out.get("T"), Some(&vec!["x".to_string()])); + } + + #[test] + fn rejects_non_final_field() { + let src = r#" + class C { + private static java.util.Map T = Map.of("a", "x"); + } + "#; + let out = collect(src); + assert!(out.is_empty()); + } + + #[test] + fn rejects_non_literal_value() { + let src = r#" + class C { + private static final String SAFE = "x"; + private static final java.util.Map T = Map.of("a", SAFE); + } + "#; + let out = collect(src); + // SAFE is an identifier, not a string_literal — even though const- + // foldable, the syntactic check rejects to stay simple. + assert!(!out.contains_key("T")); + } + + #[test] + fn rejects_odd_arg_count() { + // Compiler would reject this too, but the extractor must not panic. + let src = r#" + class C { + private static final java.util.Map T = Map.of("a", "x", "b"); + } + "#; + let out = collect(src); + assert!(out.is_empty()); + } + + #[test] + fn rejects_empty_map_of() { + let src = r#" + class C { + private static final java.util.Map T = Map.of(); + } + "#; + let out = collect(src); + assert!(out.is_empty()); + } + + #[test] + fn fully_qualified_map_of() { + let src = r#" + class C { + private static final java.util.Map T = java.util.Map.of( + "a", "x", "b", "y" + ); + } + "#; + let out = collect(src); + assert_eq!(out.get("T"), Some(&vec!["x".to_string(), "y".to_string()])); + } + + #[test] + fn rejects_escape_sequence_value() { + let src = r#" + class C { + private static final java.util.Map T = Map.of( + "a", "with\nnewline" + ); + } + "#; + let out = collect(src); + // `\n` would smuggle a CRLF-style metachar through the static + // gate; conservative reject keeps header-injection suppression + // honest. + assert!(!out.contains_key("T")); + } + + #[test] + fn ignores_non_java_lang() { + let src = "const x = 1;"; + let mut p = Parser::new(); + p.set_language(&tree_sitter_javascript::LANGUAGE.into()) + .unwrap(); + let tree = p.parse(src, None).unwrap(); + let out = collect_safe_lookup_fields(tree.root_node(), "javascript", src.as_bytes()); + assert!(out.is_empty()); + } + + fn collect_consts(src: &str) -> HashMap { + let mut p = Parser::new(); + p.set_language(&tree_sitter_java::LANGUAGE.into()).unwrap(); + let tree = p.parse(src, None).unwrap(); + collect_class_constant_scalars(tree.root_node(), "java", src.as_bytes()) + } + + #[test] + fn class_constants_capture_string_int_bool() { + let src = r#" + class C { + private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; + public static final int LIMIT = 100; + static final boolean DEBUG = false; + } + "#; + let out = collect_consts(src); + assert_eq!( + out.get("DRIVER"), + Some(&"\"com.mysql.cj.jdbc.Driver\"".to_string()) + ); + assert_eq!(out.get("LIMIT"), Some(&"100".to_string())); + assert_eq!(out.get("DEBUG"), Some(&"false".to_string())); + } + + #[test] + fn class_constants_capture_multi_declarator() { + let src = r#" + class C { + private static final int A = 1, B = 2, C2 = 3; + } + "#; + let out = collect_consts(src); + assert_eq!(out.get("A"), Some(&"1".to_string())); + assert_eq!(out.get("B"), Some(&"2".to_string())); + assert_eq!(out.get("C2"), Some(&"3".to_string())); + } + + #[test] + fn class_constants_capture_unary_negation() { + let src = r#" + class C { + private static final int OFFSET = -1; + } + "#; + let out = collect_consts(src); + // text_of returns the operand text, not the wrapper text. + assert_eq!(out.get("OFFSET"), Some(&"1".to_string())); + } + + #[test] + fn class_constants_reject_non_static() { + let src = r#" + class C { + private final String NAME = "x"; + } + "#; + let out = collect_consts(src); + assert!(!out.contains_key("NAME")); + } + + #[test] + fn class_constants_reject_non_final() { + let src = r#" + class C { + private static String NAME = "x"; + } + "#; + let out = collect_consts(src); + assert!(!out.contains_key("NAME")); + } + + #[test] + fn class_constants_reject_identifier_value() { + let src = r#" + class C { + private static final String OTHER = computed(); + private static final String COPY = OTHER; + } + "#; + let out = collect_consts(src); + assert!(!out.contains_key("OTHER")); + assert!(!out.contains_key("COPY")); + } + + #[test] + fn class_constants_capture_inside_inner_class() { + let src = r#" + class Outer { + static class Inner { + private static final String DRIVER = "x"; + } + } + "#; + let out = collect_consts(src); + assert_eq!(out.get("DRIVER"), Some(&"\"x\"".to_string())); + } + + #[test] + fn class_constants_ignore_non_supported_lang() { + let src = "const x = 1;"; + let mut p = Parser::new(); + p.set_language(&tree_sitter_javascript::LANGUAGE.into()) + .unwrap(); + let tree = p.parse(src, None).unwrap(); + let out = collect_class_constant_scalars(tree.root_node(), "javascript", src.as_bytes()); + assert!(out.is_empty()); + } + + fn collect_consts_lang(src: &str, lang: &str) -> HashMap { + let mut p = Parser::new(); + match lang { + "python" => p + .set_language(&tree_sitter_python::LANGUAGE.into()) + .unwrap(), + "go" => p.set_language(&tree_sitter_go::LANGUAGE.into()).unwrap(), + "rust" => p.set_language(&tree_sitter_rust::LANGUAGE.into()).unwrap(), + _ => unreachable!("unsupported lang in test helper: {lang}"), + }; + let tree = p.parse(src, None).unwrap(); + collect_class_constant_scalars(tree.root_node(), lang, src.as_bytes()) + } + + #[test] + fn python_module_constants_capture_scalars() { + let src = "DRIVER = \"sqlite3\"\nLIMIT = 100\nDEBUG = False\nNAME = None\n"; + let out = collect_consts_lang(src, "python"); + assert_eq!(out.get("DRIVER"), Some(&"\"sqlite3\"".to_string())); + assert_eq!(out.get("LIMIT"), Some(&"100".to_string())); + assert_eq!(out.get("DEBUG"), Some(&"False".to_string())); + assert_eq!(out.get("NAME"), Some(&"None".to_string())); + } + + #[test] + fn python_module_constants_capture_unary_negation() { + // The recogniser recurses into the operand and returns its text, so + // `OFFSET = -1` stores `"1"`. The downstream suppression consumer + // only cares about name binding, not the decoded numeric value. + let src = "OFFSET = -1\n"; + let out = collect_consts_lang(src, "python"); + assert_eq!(out.get("OFFSET"), Some(&"1".to_string())); + } + + #[test] + fn python_module_constants_reject_fstring_with_interpolation() { + let src = "import os\nVAR = f\"hi {os.getcwd()}\"\n"; + let out = collect_consts_lang(src, "python"); + assert!(!out.contains_key("VAR")); + } + + #[test] + fn python_module_constants_reject_call_value() { + let src = "from os import getcwd\nPATH = getcwd()\n"; + let out = collect_consts_lang(src, "python"); + assert!(!out.contains_key("PATH")); + } + + #[test] + fn python_module_constants_skip_inside_function_body() { + // An assignment inside a function body is per-function SSA's job. + // Only top-level module assignments should land in the map. + let src = "def f():\n INNER = \"x\"\n return INNER\n"; + let out = collect_consts_lang(src, "python"); + assert!(!out.contains_key("INNER")); + } + + #[test] + fn go_package_constants_capture_scalars() { + let src = + "package main\nconst DRIVER = \"postgres\"\nconst LIMIT = 100\nconst FLAG = true\n"; + let out = collect_consts_lang(src, "go"); + assert_eq!(out.get("DRIVER"), Some(&"\"postgres\"".to_string())); + assert_eq!(out.get("LIMIT"), Some(&"100".to_string())); + assert_eq!(out.get("FLAG"), Some(&"true".to_string())); + } + + #[test] + fn go_package_constants_capture_grouped_const_block() { + let src = "package main\nconst (\n A = \"x\"\n B int = 42\n C = false\n)\n"; + let out = collect_consts_lang(src, "go"); + assert_eq!(out.get("A"), Some(&"\"x\"".to_string())); + assert_eq!(out.get("B"), Some(&"42".to_string())); + assert_eq!(out.get("C"), Some(&"false".to_string())); + } + + #[test] + fn go_package_constants_reject_non_literal() { + let src = "package main\nconst OTHER = foo()\n"; + let out = collect_consts_lang(src, "go"); + assert!(!out.contains_key("OTHER")); + } + + #[test] + fn go_package_constants_skip_inside_function_body() { + // `const` inside a function body is per-function SSA's territory. + let src = "package main\nfunc f() string { const INNER = \"x\"; return INNER }\n"; + let out = collect_consts_lang(src, "go"); + assert!(!out.contains_key("INNER")); + } + + #[test] + fn rust_module_consts_capture_scalars() { + let src = "const DRIVER: &str = \"sqlite\";\nconst LIMIT: i32 = 100;\nstatic FLAG: bool = false;\n"; + let out = collect_consts_lang(src, "rust"); + assert_eq!(out.get("DRIVER"), Some(&"\"sqlite\"".to_string())); + assert_eq!(out.get("LIMIT"), Some(&"100".to_string())); + assert_eq!(out.get("FLAG"), Some(&"false".to_string())); + } + + #[test] + fn rust_module_consts_reject_non_literal() { + let src = "const VAL: i32 = some_func();\n"; + let out = collect_consts_lang(src, "rust"); + assert!(!out.contains_key("VAL")); + } + + #[test] + fn rust_module_consts_skip_inside_function_body() { + let src = "fn f() -> &'static str { const INNER: &str = \"x\"; INNER }\n"; + let out = collect_consts_lang(src, "rust"); + assert!(!out.contains_key("INNER")); + } +} diff --git a/src/cfg_analysis/guards.rs b/src/cfg_analysis/guards.rs index c177325b..5d5141f0 100644 --- a/src/cfg_analysis/guards.rs +++ b/src/cfg_analysis/guards.rs @@ -12,8 +12,10 @@ use crate::patterns::Severity; use crate::ssa::const_prop::ConstLattice; use crate::ssa::type_facts::TypeFactResult; use crate::ssa::{SsaOp, SsaValue}; +use crate::symbol::Lang; use crate::taint::path_state::{PredicateKind, classify_condition}; use petgraph::graph::NodeIndex; +use smallvec::SmallVec; use std::collections::HashSet; pub struct UnguardedSink; @@ -86,6 +88,17 @@ fn is_all_args_constant(ctx: &AnalysisContext, sink: NodeIndex) -> bool { } } } + // Class-level constant scalar: Java `static final TYPE NAME = LIT;` + // field references are compile-time constants that the per-function + // CFG one-hop trace can't see (fields live outside any function + // body) and that SSA const-prop doesn't surface either (the per- + // function lowering treats the cross-scope reference as a free + // identifier). + if let Some(map) = ctx.class_constant_scalars + && map.contains_key(u.as_str()) + { + return true; + } false }) || ssa_all_sink_operands_constant(ctx, sink, callee_desc, &callee_parts, &outer_parts) } @@ -513,6 +526,1202 @@ fn sink_args_jpa_criteria_query_safe( crate::ssa::type_facts::is_safe_query_object_arg(&values, sink_caps, type_facts) } +/// Suppress a `cfg-unguarded-sink` SQL_QUERY finding when the call site is +/// a zero-positional-argument query-builder execute / create verb. +/// +/// Doctrine DBAL `QueryBuilder` (`$qb->select(...)->from(...)->executeQuery()`), +/// JPA / Hibernate `CriteriaBuilder` (`cb.createQuery()` returning the +/// query-object factory), and any chained-builder pattern share the shape: +/// the SQL string is bound earlier on the receiver chain via parameterized +/// API calls (`->select`, `->from`, `->where(... param ...)`), and the +/// terminal verb that fires on the sink list (`executeQuery`, +/// `executeStatement`, `executeUpdate`, `createQuery`, `createNativeQuery`) +/// takes zero positional args, no SQL string ever flows through the call +/// site itself. +/// +/// vs. the dangerous flat shape: +/// `$conn->executeQuery($sql, $params)` — arg 0 carries the SQL string, +/// the structural finding is correctly preserved. +/// +/// Restricted to verb names where JDBC / Doctrine / JPA expose a +/// receiver-built (zero-arg) overload. PHP `stmt.execute` is excluded +/// because PDOStatement::execute() can be reached via a tainted +/// `prepare($sql)` chain where the SQL was already built unsafely; +/// the receiver-side taint check is the only thing that fires there. +fn sink_is_zero_arg_query_builder(ctx: &AnalysisContext, sink: NodeIndex, sink_caps: Cap) -> bool { + if !sink_caps.intersects(Cap::SQL_QUERY) { + return false; + } + // Only suppress when the sink's caps are SQL_QUERY-only. Multi-cap + // sinks may carry a non-SQL injection vector through the same call. + if sink_caps != Cap::SQL_QUERY { + return false; + } + // Restrict to PHP. Java / Kotlin / JVM langs already cover the + // safe prepared-statement shape via the `prepareStatement` Sanitizer + // rule that dominates `pstmt.executeUpdate()` / `pstmt.executeQuery()` + // at the structural finding site. PHP's Doctrine DBAL `QueryBuilder` + // and Drupal `Connection::prepareStatement` shapes need explicit + // structural support because the receiver isn't always sanitized in + // a way the dominator-Sanitizer scan recognises (chain receiver, + // closure-captured helper, etc.). + if ctx.lang != Lang::Php { + return false; + } + let info = &ctx.cfg[sink]; + let callee = match info.call.callee.as_deref() { + Some(c) => c, + None => return false, + }; + let suffix = callee.rsplit('.').next().unwrap_or(callee); + let is_builder_verb = matches!(suffix, "executeQuery" | "executeStatement" | "createQuery"); + if !is_builder_verb { + return false; + } + // Restrict to receivers that name a known query-builder. The + // root-receiver text is the leftmost segment of the callee chain; + // for `$qb->...->executeQuery()` the root is `qb`, for + // `$deleteQuery->executeStatement()` it is `deleteQuery`, etc. + // Patterns canvassed from Doctrine DBAL / Drupal Database / Nextcloud + // dav / lib idioms: + // * canonical names: qb, query, queryBuilder, builder, q + // * verb-bound builders: deleteQuery, insertQuery, selectTagQuery, + // calendarObjectIdQuery, deleteQb, qbDeleteCalendarObjectProps + // * action-named builders: insert, update, delete, select, upsert, + // forUpdate, restoreUpdate + // Receivers named after the SQL connection (`conn`, `connection`, + // `dbc`, `db`) or entity-manager (`em`, `entityManager`) are + // excluded since their `executeQuery` / `executeStatement` overloads + // accept a SQL string arg. + let root_receiver = match callee.split('.').next() { + Some(r) if !r.is_empty() => r, + _ => return false, + }; + let receiver_lower = root_receiver.to_ascii_lowercase(); + let is_builder_receiver_by_name = receiver_lower == "qb" + || receiver_lower == "q" + || receiver_lower == "query" + || receiver_lower == "querybuilder" + || receiver_lower == "builder" + || receiver_lower == "insert" + || receiver_lower == "update" + || receiver_lower == "delete" + || receiver_lower == "select" + || receiver_lower == "upsert" + || receiver_lower.starts_with("qb") + || receiver_lower.starts_with("querybuilder") + || receiver_lower.ends_with("qb") + || receiver_lower.ends_with("query") + || receiver_lower.ends_with("builder"); + let is_builder_receiver_by_def = receiver_defined_by_builder_factory(ctx, sink, root_receiver); + if !is_builder_receiver_by_name && !is_builder_receiver_by_def { + return false; + } + // Once the receiver is proven to be a builder via def-call lookup, the + // call is the builder-variant of `executeQuery` / `executeStatement` + // regardless of argument count (Doctrine DBAL `QueryBuilder::executeQuery` + // accepts only an optional `?Connection`, never a SQL string). When the + // receiver was identified solely by its NAME, fall back to the byte-level + // zero-arg check that guards the closure-captured shape so an unfamiliar + // verb-named local (`$insert = "DROP TABLE..."`-bound mistake) doesn't + // unconditionally suppress. + if !is_builder_receiver_by_def && !callee_span_has_zero_args(info, ctx.source_bytes) { + return false; + } + true +} + +/// Suppress a `cfg-unguarded-sink` SQL_QUERY finding when the sink call's first +/// positional argument is the result of a Doctrine DBAL safe-SQL accessor — +/// either `.getSQL()` (parameterised SQL from a QueryBuilder chain) +/// or a `Platform::get*SQL(...)` factory (`getTruncateTableSQL`, +/// `getCreateTableSQL`, etc., which return DDL with no user-controlled bytes). +/// +/// Two paths: +/// 1. Direct arg: `arg_callees[0]` names a recognised accessor. Catches +/// `$conn->executeStatement($builder->getSQL(), ...)` and +/// `$conn->executeStatement($platform->getTruncateTableSQL('t', false))`. +/// 2. Indirect via local var: the arg is a bare identifier `$sql` whose +/// most-recent same-function defining Call has a recognised accessor as +/// its callee. Catches the migration shape +/// `$sql = $this->dbc->getDatabasePlatform()->getTruncateTableSQL(...); +/// $this->dbc->executeStatement($sql);` +/// +/// PHP-only: other languages have their own builder conventions (Java JPA's +/// `CriteriaQuery` is already covered by `sink_args_jpa_criteria_query_safe`). +fn sink_first_arg_is_builder_get_sql( + ctx: &AnalysisContext, + sink: NodeIndex, + sink_caps: Cap, +) -> bool { + if !sink_caps.intersects(Cap::SQL_QUERY) { + return false; + } + if sink_caps != Cap::SQL_QUERY { + return false; + } + if ctx.lang != Lang::Php { + return false; + } + let info = &ctx.cfg[sink]; + + // Path 1: direct method-call arg. + if let Some(Some(arg_callee)) = info.arg_callees.first() { + let suffix = arg_callee.rsplit('.').next().unwrap_or(arg_callee); + if is_dbal_safe_sql_accessor(suffix) { + return true; + } + } + + // Path 2: bare-identifier arg defined earlier by a recognised accessor. + // Use `arg_uses[0]` (the first positional argument's identifier set) to + // pick the candidate variable name. When `arg_uses` is empty (e.g. the + // arg is a literal, an arithmetic expression, or a complex chain), no + // back-walk is performed. + let first_arg_use = info + .call + .arg_uses + .first() + .and_then(|grp| grp.first()) + .map(|s| s.as_str()); + let var_name = match first_arg_use { + Some(n) if !n.is_empty() => n, + _ => return false, + }; + let sink_func = info.ast.enclosing_func.as_deref(); + let sink_span_start = info.ast.span.0; + let mut best: Option<(usize, String)> = None; + for nidx in ctx.cfg.node_indices() { + let n = &ctx.cfg[nidx]; + if n.kind != crate::cfg::StmtKind::Call { + continue; + } + if n.taint.defines.as_deref() != Some(var_name) { + continue; + } + if n.ast.enclosing_func.as_deref() != sink_func { + continue; + } + let span_start = n.ast.span.0; + if span_start >= sink_span_start { + continue; + } + let Some(callee) = n.call.callee.as_deref() else { + continue; + }; + match best { + Some((s, _)) if s >= span_start => {} + _ => best = Some((span_start, callee.to_string())), + } + } + if let Some((_, callee)) = best { + let suffix = callee.rsplit('.').next().unwrap_or(&callee); + if is_dbal_safe_sql_accessor(suffix) { + return true; + } + } + false +} + +/// Recognise method names that Doctrine DBAL exposes as safe-SQL accessors. +/// `getSQL` is the QueryBuilder accessor; `get*SQL` (case-sensitive `SQL` +/// suffix) is the Platform-specific DDL builder convention used across the +/// `Doctrine\DBAL\Platforms\*` hierarchy (`getTruncateTableSQL`, +/// `getCreateTableSQL`, `getDropTableSQL`, etc.). All such methods receive +/// schema identifiers and emit DBMS-specific DDL, never weaving user payload. +fn is_dbal_safe_sql_accessor(name: &str) -> bool { + if name == "getSQL" { + return true; + } + name.starts_with("get") && name.len() > 5 && name.ends_with("SQL") +} + +/// Suppress a `cfg-unguarded-sink` SQL_QUERY finding when the sink's first +/// positional argument *composes* a Doctrine DBAL safe-SQL accessor with +/// constant string-shaping ops. Two real-world shapes from nextcloud: +/// (a) `$conn->executeStatement(preg_replace('/^INSERT/i', 'INSERT IGNORE', +/// $builder->getSQL()), ...)` +/// (b) `$conn->executeStatement($builder->getSQL() . ' ON CONFLICT DO +/// NOTHING', ...)` +/// +/// Strategy (byte-level, conservative): +/// 1. Lang-gate to PHP. Cap-gate to SQL_QUERY-only. +/// 2. Extract the sink's first-positional-arg source bytes by balanced-paren +/// walk inside the call's `ast.span`, with single/double-quoted-string +/// awareness. +/// 3. Scan arg-0 bytes for every PHP variable token `$`. Every var +/// must be bound by a query-builder factory (`getQueryBuilder` / +/// `createQueryBuilder` / `*queryBuilder`). Bypasses `arg_uses` because +/// `collect_idents_with_paths` also surfaces method names (`getSQL`, +/// `getParameters`) that are not variable references in PHP. +/// 4. At least one var must appear in arg-0 bytes as the receiver of a DBAL +/// safe-SQL accessor call (`$->getSQL(` or `$->get*SQL(`). +/// +/// The taint engine has already cleared this flow (gate is `!has_taint`), +/// so the suppression's job is to silence the structural cfg-unguarded-sink +/// over-fire on builder-composed SQL. PHP-only. +fn sink_first_arg_composes_safe_dbal_sql( + ctx: &AnalysisContext, + sink: NodeIndex, + sink_caps: Cap, +) -> bool { + if sink_caps != Cap::SQL_QUERY { + return false; + } + if ctx.lang != Lang::Php { + return false; + } + let info = &ctx.cfg[sink]; + let Some(arg0_bytes) = first_positional_arg_bytes(info, ctx.source_bytes) else { + return false; + }; + if arg0_bytes.is_empty() { + return false; + } + let vars = extract_php_variables(arg0_bytes); + if vars.is_empty() { + return false; + } + let mut accessor_seen = false; + for name in &vars { + if !receiver_defined_by_builder_factory(ctx, sink, name) { + return false; + } + if arg_bytes_call_dbal_accessor_on(arg0_bytes, name) { + accessor_seen = true; + } + } + accessor_seen +} + +/// Extract the unique PHP variable identifiers appearing as `$` tokens +/// in `bytes`. Skips the `$` sigil; variables tokens are alphanumeric + +/// underscore. Order-stable (insertion order, with deduplication), so the +/// caller's any-failure-bails loop deterministically rejects the first +/// non-builder-bound var. +fn extract_php_variables(bytes: &[u8]) -> Vec { + let mut result: Vec = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] != b'$' { + i += 1; + continue; + } + let mut e = i + 1; + while e < bytes.len() && (bytes[e].is_ascii_alphanumeric() || bytes[e] == b'_') { + e += 1; + } + if e > i + 1 { + if let Ok(name) = std::str::from_utf8(&bytes[i + 1..e]) { + if !result.iter().any(|n| n == name) { + result.push(name.to_string()); + } + } + } + i = e.max(i + 1); + } + result +} + +/// Extract the source bytes of the sink call's first positional argument. +/// +/// Scans `info.ast.span` for the first `(` (outer args opener), then +/// balance-walks parens with single/double-quoted-string awareness, returning +/// the slice up to the first depth-1 `,` or the matching closing `)`. +/// PHP-shaped: handles `'...'` and `"..."` with backslash escapes; ignores +/// heredoc/nowdoc, which don't appear inside DBAL call-site argument lists +/// in practice. `callee_span` is intentionally ignored because the upstream +/// CFG narrowing path may set it to the *whole* call span (e.g. when a +/// `return $this->conn->executeStatement(...)` is lowered: `inner_text_span` +/// records the call's span via `first_call_ident_with_span`). Searching +/// from `ast.span.0` and matching the first `(` is robust across both +/// direct-call and statement-wrapped shapes. +/// +/// Returns `None` if no `(` is found or the walk runs off the end of +/// `ast.span` without closing. +fn first_positional_arg_bytes<'a>( + info: &crate::cfg::NodeInfo, + bytes: &'a [u8], +) -> Option<&'a [u8]> { + let span = info.ast.span; + if span.1 > bytes.len() || span.0 >= span.1 { + return None; + } + let mut i = span.0; + while i < span.1 && bytes[i] != b'(' { + i += 1; + } + if i >= span.1 { + return None; + } + let arg_start = i + 1; + let mut j = arg_start; + let mut depth: i32 = 1; + let mut quote: Option = None; + while j < span.1 { + let b = bytes[j]; + if let Some(q) = quote { + if b == b'\\' && j + 1 < span.1 { + j += 2; + continue; + } + if b == q { + quote = None; + } + j += 1; + continue; + } + match b { + b'\'' | b'"' => { + quote = Some(b); + j += 1; + } + b'(' => { + depth += 1; + j += 1; + } + b')' => { + depth -= 1; + if depth == 0 { + return Some(&bytes[arg_start..j]); + } + j += 1; + } + b',' if depth == 1 => { + return Some(&bytes[arg_start..j]); + } + _ => j += 1, + } + } + None +} + +/// Return true if `arg0` contains a method-call against `recv_name` whose +/// method matches [`is_dbal_safe_sql_accessor`]. Recognises the PHP +/// member-access shape `$->(`. The backward walk stops at +/// the first non-identifier byte; the immediately preceding byte must be +/// the `$` sigil so `mybuilder->getSQL` does not match `recv = "builder"`. +fn arg_bytes_call_dbal_accessor_on(arg0: &[u8], recv_name: &str) -> bool { + if recv_name.is_empty() { + return false; + } + let recv_bytes = recv_name.as_bytes(); + let mut i = 0usize; + while i + 1 < arg0.len() { + if arg0[i] != b'-' || arg0[i + 1] != b'>' { + i += 1; + continue; + } + // Walk backward to capture the receiver identifier ending at i. + let mut s = i; + while s > 0 { + let c = arg0[s - 1]; + if c.is_ascii_alphanumeric() || c == b'_' { + s -= 1; + } else { + break; + } + } + if s == i || s == 0 || arg0[s - 1] != b'$' || &arg0[s..i] != recv_bytes { + i += 2; + continue; + } + // Walk forward to capture the method identifier following `->`. + let mut e = i + 2; + while e < arg0.len() { + let c = arg0[e]; + if c.is_ascii_alphanumeric() || c == b'_' { + e += 1; + } else { + break; + } + } + // Must be followed by `(`. + if e < arg0.len() && arg0[e] == b'(' { + if let Ok(method) = std::str::from_utf8(&arg0[i + 2..e]) { + if is_dbal_safe_sql_accessor(method) { + return true; + } + } + } + i += 2; + } + false +} + +/// Suppress a `cfg-unguarded-sink` SQL_QUERY finding when the sink's first +/// positional argument interpolates only PHP variables that are bound by a +/// `foreach` over a literal-keyed array within the same function body. +/// Real-world shape from nextcloud `lib/private/DB/MySqlTools.php:27`: +/// ```php +/// $variables = ['innodb_file_per_table' => 'ON']; +/// if (...) { $variables['innodb_file_format'] = 'Barracuda'; } +/// foreach ($variables as $var => $val) { +/// $connection->executeQuery("SHOW VARIABLES LIKE '$var'"); +/// } +/// ``` +/// The foreach-key `$var` ranges over `{innodb_file_per_table, +/// innodb_file_format, innodb_large_prefix}`, all metachar-free, so the +/// interpolated SQL is bounded. +/// +/// Strategy (byte-level, conservative): +/// 1. Lang-gate to PHP. Cap-gate to SQL_QUERY-only. +/// 2. Extract the sink's first-positional-arg source bytes; collect every +/// `$` interpolation token. +/// 3. For every var, walk the enclosing function bytes. Find the +/// innermost `foreach ($X as $name => $...)` or `foreach ($X as $name)` +/// pattern whose body contains the sink span, with `$name` matching +/// the use site. +/// 4. Find every assignment of `$X` in the function body. Each must be +/// either an array literal `['LIT' => 'LIT', ...]` (key-arrow form) or +/// a subscript-set `$X['LIT'] = 'LIT';`. Every key/value involved +/// must be metachar-free (alphanumeric + `_`, `-`, `.`). +/// 5. Whether the use site reads the foreach-key (`$key` slot) or +/// foreach-value (`$val` slot), the corresponding literal set must be +/// proven safe. +/// +/// PHP-only. Limited to the simple foreach + literal-array shape; bare- +/// reference / by-reference foreach variants and dynamic array sources +/// fall through to the structural finding. +fn sink_arg_uses_safe_foreach_key(ctx: &AnalysisContext, sink: NodeIndex, sink_caps: Cap) -> bool { + if sink_caps != Cap::SQL_QUERY { + return false; + } + if ctx.lang != Lang::Php { + return false; + } + let info = &ctx.cfg[sink]; + let Some(arg0_bytes) = first_positional_arg_bytes(info, ctx.source_bytes) else { + return false; + }; + if arg0_bytes.is_empty() { + return false; + } + let vars = extract_php_variables(arg0_bytes); + if vars.is_empty() { + return false; + } + let Some(func_scope) = enclosing_func_byte_scope(ctx, sink) else { + return false; + }; + for name in &vars { + if !php_var_safe_via_foreach_literal_array( + ctx.source_bytes, + func_scope, + info.ast.span.0, + name, + ) { + return false; + } + } + true +} + +/// Extent of the enclosing function body. Returns `None` when the sink +/// has no `enclosing_func` (e.g. file-level top-level statement) or no +/// matching CFG nodes. The byte range is `(min_span.0, max_span.1)` over +/// the function's CFG nodes, conservative against multi-statement bodies. +fn enclosing_func_byte_scope(ctx: &AnalysisContext, sink: NodeIndex) -> Option<(usize, usize)> { + let sink_func = ctx.cfg[sink].ast.enclosing_func.as_deref()?; + let mut lo = usize::MAX; + let mut hi = 0usize; + for n in ctx.cfg.node_indices() { + let info = &ctx.cfg[n]; + if info.ast.enclosing_func.as_deref() != Some(sink_func) { + continue; + } + if info.ast.span.0 < lo { + lo = info.ast.span.0; + } + if info.ast.span.1 > hi { + hi = info.ast.span.1; + } + } + if lo == usize::MAX || hi == 0 || lo >= hi { + return None; + } + Some((lo, hi)) +} + +/// Walk `source[func_scope]` for `foreach (...)` blocks containing +/// `sink_span_start` in their body. Match the iteration pattern shape and +/// (when found) verify every assignment of the iterated identifier in the +/// function body is a literal-keyed array or a subscript-set with literal +/// key, with all keys/values metachar-free. Returns true only when *every* +/// candidate foreach proves safe; bails (returns false) on the first +/// failure to keep the suppression conservative. +fn php_var_safe_via_foreach_literal_array( + source: &[u8], + func_scope: (usize, usize), + sink_span_start: usize, + name: &str, +) -> bool { + if name.is_empty() { + return false; + } + if func_scope.0 >= func_scope.1 || func_scope.1 > source.len() { + return false; + } + let scope = &source[func_scope.0..func_scope.1]; + let sink_offset = if sink_span_start >= func_scope.0 { + sink_span_start - func_scope.0 + } else { + return false; + }; + let needle = b"foreach"; + let mut cursor = 0usize; + let mut matched_any = false; + while cursor + needle.len() <= scope.len() { + let Some(rel) = find_subslice(&scope[cursor..], needle) else { + break; + }; + let pos = cursor + rel; + cursor = pos + needle.len(); + // Require word boundary: prev byte (if any) must not be alnum/`_`. + if pos > 0 { + let prev = scope[pos - 1]; + if prev.is_ascii_alphanumeric() || prev == b'_' { + continue; + } + } + // Skip whitespace; require `(`. + let mut p = pos + needle.len(); + while p < scope.len() && matches!(scope[p], b' ' | b'\t' | b'\n' | b'\r') { + p += 1; + } + if p >= scope.len() || scope[p] != b'(' { + continue; + } + // Balanced walk to closing `)`. + let header_open = p; + let mut depth = 1i32; + let mut q = p + 1; + let mut quote: Option = None; + while q < scope.len() && depth > 0 { + let b = scope[q]; + if let Some(c) = quote { + if b == b'\\' && q + 1 < scope.len() { + q += 2; + continue; + } + if b == c { + quote = None; + } + q += 1; + continue; + } + match b { + b'\'' | b'"' => quote = Some(b), + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + q += 1; + } + if depth != 0 { + continue; + } + let header_close = q - 1; + // Skip whitespace; require `{`. + let mut bp = header_close + 1; + while bp < scope.len() && matches!(scope[bp], b' ' | b'\t' | b'\n' | b'\r') { + bp += 1; + } + if bp >= scope.len() || scope[bp] != b'{' { + continue; + } + // Balanced walk to closing `}`. + let body_open = bp; + let mut bdepth = 1i32; + let mut bq = bp + 1; + let mut bquote: Option = None; + while bq < scope.len() && bdepth > 0 { + let b = scope[bq]; + if let Some(c) = bquote { + if b == b'\\' && bq + 1 < scope.len() { + bq += 2; + continue; + } + if b == c { + bquote = None; + } + bq += 1; + continue; + } + match b { + b'\'' | b'"' => bquote = Some(b), + b'{' => bdepth += 1, + b'}' => bdepth -= 1, + _ => {} + } + bq += 1; + } + if bdepth != 0 { + continue; + } + let body_end = bq - 1; + // Sink position must lie inside the body. + if sink_offset < body_open || sink_offset > body_end { + continue; + } + let header = &scope[header_open + 1..header_close]; + let Some((iter_var, key_var, val_var)) = parse_foreach_header(header) else { + return false; + }; + let used_as_key = key_var.as_deref() == Some(name); + let used_as_val = val_var.as_str() == name; + if !used_as_key && !used_as_val { + // The use site references some other variable; not bound by + // this foreach. Continue scanning (might be a nested foreach). + continue; + } + if !php_iter_var_assigns_safe_literals(scope, &iter_var, used_as_key, used_as_val) { + return false; + } + matched_any = true; + } + matched_any +} + +/// Parse a foreach header text (the bytes between `(` and `)`). Returns +/// `(iter_var, key_var, value_var)`. Recognises `$X as $V` and +/// `$X as $K => $V` shapes; bails (returns `None`) on by-reference +/// (`& $V`), expressions (`call() as $V`), or any unexpected token. +fn parse_foreach_header(header: &[u8]) -> Option<(String, Option, String)> { + let text = std::str::from_utf8(header).ok()?.trim(); + let lower = text; + let as_pos = find_word(lower.as_bytes(), b"as")?; + let iter_part = lower[..as_pos].trim(); + let body_part = lower[as_pos + 2..].trim(); + let iter_var = parse_simple_var(iter_part)?; + if body_part.contains("=>") { + let mut split = body_part.splitn(2, "=>"); + let k = split.next()?.trim(); + let v = split.next()?.trim(); + let key_var = parse_simple_var(k)?; + let val_var = parse_simple_var(v)?; + Some((iter_var, Some(key_var), val_var)) + } else { + let val_var = parse_simple_var(body_part)?; + Some((iter_var, None, val_var)) + } +} + +/// Parse a `$` token, rejecting any extra tokens (whitespace OK). +/// By-reference (`&$x`), splat (`...$x`), or list-destructuring shapes +/// produce `None` so the suppression bails conservatively. +fn parse_simple_var(text: &str) -> Option { + let trimmed = text.trim(); + let bytes = trimmed.as_bytes(); + if bytes.first() != Some(&b'$') { + return None; + } + let rest = &trimmed[1..]; + if rest.is_empty() { + return None; + } + if !rest.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + return None; + } + Some(rest.to_string()) +} + +/// Find a whole-word match of `word` inside `text`. Word boundaries are +/// non-alnum/non-`_` bytes (or the buffer edges). Returns the byte offset +/// of the first match. +fn find_word(text: &[u8], word: &[u8]) -> Option { + let mut cursor = 0usize; + while cursor + word.len() <= text.len() { + let rel = find_subslice(&text[cursor..], word)?; + let pos = cursor + rel; + let prev_ok = pos == 0 || { + let p = text[pos - 1]; + !(p.is_ascii_alphanumeric() || p == b'_') + }; + let next = pos + word.len(); + let next_ok = next == text.len() || { + let p = text[next]; + !(p.is_ascii_alphanumeric() || p == b'_') + }; + if prev_ok && next_ok { + return Some(pos); + } + cursor = pos + 1; + } + None +} + +/// For every assignment of `$` inside `scope` (the enclosing +/// function bytes), require every key/value referenced is a metachar-free +/// string literal (alphanumeric, `_`, `-`, `.`, space). Recognises: +/// * `$ = ['LIT' => 'LIT', ...];` (key-arrow array literal) +/// * `$['LIT'] = 'LIT';` (subscript-set with literal key) +/// +/// Conservative: any other assignment shape, missing literals, or empty +/// array set returns false. When `used_as_key` is true, the literal keys +/// must be safe; when `used_as_val` is true, the literal values must be +/// safe; both flags can be true at once. +fn php_iter_var_assigns_safe_literals( + scope: &[u8], + iter_var: &str, + used_as_key: bool, + used_as_val: bool, +) -> bool { + if iter_var.is_empty() { + return false; + } + let needle: Vec = std::iter::once(b'$').chain(iter_var.bytes()).collect(); + let mut cursor = 0usize; + let mut saw_init = false; + while cursor + needle.len() <= scope.len() { + let Some(rel) = find_subslice(&scope[cursor..], &needle) else { + break; + }; + let pos = cursor + rel; + cursor = pos + 1; + // Word-boundary on the trailing side: the next byte must not be + // alnum/`_` (no `$variables_extra`). + let after = pos + needle.len(); + if after < scope.len() { + let b = scope[after]; + if b.is_ascii_alphanumeric() || b == b'_' { + continue; + } + } + // Skip trailing whitespace. + let mut p = after; + while p < scope.len() && matches!(scope[p], b' ' | b'\t' | b'\n' | b'\r') { + p += 1; + } + if p >= scope.len() { + continue; + } + match scope[p] { + b'=' => { + // Direct assignment: `$X = ['k' => 'v', ...];` + if p + 1 < scope.len() && scope[p + 1] == b'=' { + continue; // comparison + } + if !php_check_array_literal_assignment(scope, p + 1, used_as_key, used_as_val) { + return false; + } + saw_init = true; + } + b'[' + // Subscript-set: `$X['LIT'] = 'LIT';` + if !php_check_subscript_set(scope, p, used_as_key, used_as_val) => + { + return false; + } + _ => { + // Other usage (foreach iter, function arg, member access). + // Doesn't add to the literal set; allowed as long as no + // unrecognised assignment shape appears. + } + } + } + saw_init +} + +/// Validate an array-literal assignment after `$X =` (cursor points at +/// the byte just after `=`). Allowed: optional whitespace, then `[ ... ];` +/// where every element is `'LIT' => 'LIT'` with metachar-free literals. +fn php_check_array_literal_assignment( + scope: &[u8], + after_eq: usize, + used_as_key: bool, + used_as_val: bool, +) -> bool { + let mut p = after_eq; + while p < scope.len() && matches!(scope[p], b' ' | b'\t' | b'\n' | b'\r') { + p += 1; + } + if p >= scope.len() || scope[p] != b'[' { + return false; + } + let body_open = p + 1; + let mut depth = 1i32; + let mut q = body_open; + let mut quote: Option = None; + while q < scope.len() && depth > 0 { + let b = scope[q]; + if let Some(c) = quote { + if b == b'\\' && q + 1 < scope.len() { + q += 2; + continue; + } + if b == c { + quote = None; + } + q += 1; + continue; + } + match b { + b'\'' | b'"' => quote = Some(b), + b'[' => depth += 1, + b']' => depth -= 1, + _ => {} + } + q += 1; + } + if depth != 0 { + return false; + } + let body_close = q - 1; + let elements = &scope[body_open..body_close]; + php_check_kv_array_literal(elements, used_as_key, used_as_val) +} + +/// Walk an array-literal body (between `[` and `]`). Each element must +/// be `'LIT' => 'LIT'`. All keys/values used by the consumer must be +/// metachar-free. +fn php_check_kv_array_literal(elements: &[u8], used_as_key: bool, used_as_val: bool) -> bool { + if elements.iter().all(|b| b.is_ascii_whitespace()) { + return false; + } + // Split by `,` at depth 0. + let mut start = 0usize; + let mut quote: Option = None; + let mut depth = 0i32; + let mut any_pair = false; + let mut i = 0usize; + while i < elements.len() { + let b = elements[i]; + if let Some(c) = quote { + if b == b'\\' && i + 1 < elements.len() { + i += 2; + continue; + } + if b == c { + quote = None; + } + i += 1; + continue; + } + match b { + b'\'' | b'"' => quote = Some(b), + b'[' | b'(' => depth += 1, + b']' | b')' => depth -= 1, + b',' if depth == 0 => { + if !php_check_arrow_pair(&elements[start..i], used_as_key, used_as_val) { + return false; + } + any_pair = true; + start = i + 1; + } + _ => {} + } + i += 1; + } + let tail = &elements[start..]; + if tail.iter().any(|b| !b.is_ascii_whitespace()) { + if !php_check_arrow_pair(tail, used_as_key, used_as_val) { + return false; + } + any_pair = true; + } + any_pair +} + +/// Validate one `'LIT' => 'LIT'` pair. Both literals must be string +/// literals (`'...'` or `"..."`) with metachar-free contents per +/// `is_metachar_free_literal`. +fn php_check_arrow_pair(pair: &[u8], used_as_key: bool, used_as_val: bool) -> bool { + let text = std::str::from_utf8(pair).map(str::trim).unwrap_or(""); + let mut split = text.splitn(2, "=>"); + let k = match split.next() { + Some(s) => s.trim(), + None => return false, + }; + let v = match split.next() { + Some(s) => s.trim(), + None => return false, + }; + if used_as_key && !is_metachar_free_string_literal(k.as_bytes()) { + return false; + } + if used_as_val && !is_metachar_free_string_literal(v.as_bytes()) { + return false; + } + true +} + +/// Validate a subscript-set assignment `$X[...] = ...;` starting at the +/// `[` byte. Both the subscript key (when `used_as_key`) and the +/// assigned value (when `used_as_val`) must be metachar-free string +/// literals. +fn php_check_subscript_set( + scope: &[u8], + open_bracket: usize, + used_as_key: bool, + used_as_val: bool, +) -> bool { + let mut depth = 1i32; + let mut q = open_bracket + 1; + let mut quote: Option = None; + while q < scope.len() && depth > 0 { + let b = scope[q]; + if let Some(c) = quote { + if b == b'\\' && q + 1 < scope.len() { + q += 2; + continue; + } + if b == c { + quote = None; + } + q += 1; + continue; + } + match b { + b'\'' | b'"' => quote = Some(b), + b'[' => depth += 1, + b']' => depth -= 1, + _ => {} + } + q += 1; + } + if depth != 0 { + return false; + } + let close_bracket = q - 1; + let key_bytes = &scope[open_bracket + 1..close_bracket]; + if used_as_key && !is_metachar_free_string_literal(key_bytes.trim_ascii()) { + return false; + } + // Skip whitespace; require `=`, not `==`. + let mut p = close_bracket + 1; + while p < scope.len() && matches!(scope[p], b' ' | b'\t' | b'\n' | b'\r') { + p += 1; + } + if p >= scope.len() || scope[p] != b'=' { + return false; + } + if p + 1 < scope.len() && scope[p + 1] == b'=' { + return false; + } + // Read the RHS up to the next `;` at depth 0 (no string awareness needed + // beyond `;` because PHP statement separator). + let mut q = p + 1; + let mut quote: Option = None; + let mut depth = 0i32; + while q < scope.len() { + let b = scope[q]; + if let Some(c) = quote { + if b == b'\\' && q + 1 < scope.len() { + q += 2; + continue; + } + if b == c { + quote = None; + } + q += 1; + continue; + } + match b { + b'\'' | b'"' => quote = Some(b), + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b';' if depth == 0 => break, + _ => {} + } + q += 1; + } + let rhs = &scope[p + 1..q]; + if used_as_val && !is_metachar_free_string_literal(rhs.trim_ascii()) { + return false; + } + true +} + +/// `true` when `bytes` form a single-quoted or double-quoted string +/// literal whose contents are alphanumeric, `_`, `-`, `.`, or space — +/// safe for SQL pattern literal interpolation. Rejects empty string, +/// any escape sequences, control characters, quotes, semicolons, or +/// shell/SQL metacharacters. +fn is_metachar_free_string_literal(bytes: &[u8]) -> bool { + if bytes.len() < 2 { + return false; + } + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if first != last || (first != b'\'' && first != b'"') { + return false; + } + let inner = &bytes[1..bytes.len() - 1]; + if inner.is_empty() { + return false; + } + inner + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b' ')) +} + +/// Check whether the source bytes inside the sink's `callee_span` end with a +/// zero-argument call form: trailing `)` preceded by `(` with only whitespace +/// in between. Used to identify `qb.executeQuery()` / `qb.executeStatement()` +/// where the SQL was bound earlier on the receiver chain. +fn callee_span_has_zero_args(info: &crate::cfg::NodeInfo, bytes: &[u8]) -> bool { + let span = info.call.callee_span.unwrap_or(info.ast.span); + if span.0 >= span.1 || span.1 > bytes.len() { + return false; + } + let slice = &bytes[span.0..span.1]; + let mut end = slice.len(); + while end > 0 && matches!(slice[end - 1], b' ' | b'\t' | b'\n' | b'\r') { + end -= 1; + } + if end == 0 || slice[end - 1] != b')' { + return false; + } + end -= 1; + while end > 0 && matches!(slice[end - 1], b' ' | b'\t' | b'\n' | b'\r') { + end -= 1; + } + end > 0 && slice[end - 1] == b'(' +} + +/// Detect that `receiver_name` was bound earlier in the same function by a +/// query-builder factory call. Two paths: +/// 1. CFG def-call: a same-function Call node defines `receiver_name` with a +/// callee ending in `getQueryBuilder` / `createQueryBuilder`. +/// 2. Source-text scan: between the enclosing function's first byte and the +/// sink's byte offset, the source contains +/// `$ = ... ->getQueryBuilder(...)` (or `createQueryBuilder`). +/// Picks up assignment nodes whose CFG kind/callee text doesn't surface a +/// leaf factory name (multi-line chains, `for`/`try` block nesting, +/// unusual lowering paths). +fn receiver_defined_by_builder_factory( + ctx: &AnalysisContext, + sink: NodeIndex, + receiver_name: &str, +) -> bool { + if receiver_name.is_empty() { + return false; + } + let sink_info = &ctx.cfg[sink]; + let sink_func = sink_info.ast.enclosing_func.as_deref(); + let sink_span_start = sink_info.ast.span.0; + + // Path 1: CFG-level def lookup. + let mut best: Option<(usize, String)> = None; + for nidx in ctx.cfg.node_indices() { + let n = &ctx.cfg[nidx]; + if n.kind != crate::cfg::StmtKind::Call { + continue; + } + if n.taint.defines.as_deref() != Some(receiver_name) { + continue; + } + if n.ast.enclosing_func.as_deref() != sink_func { + continue; + } + let span_start = n.ast.span.0; + if span_start >= sink_span_start { + continue; + } + let Some(callee) = n.call.callee.as_deref() else { + continue; + }; + match best { + Some((s, _)) if s >= span_start => {} + _ => best = Some((span_start, callee.to_string())), + } + } + if let Some((_, callee)) = best { + let suffix = callee.rsplit('.').next().unwrap_or(&callee); + let suffix_lower = suffix.to_ascii_lowercase(); + if matches!( + suffix_lower.as_str(), + "getquerybuilder" | "createquerybuilder" | "getqb" | "createqb" + ) || suffix_lower.ends_with("querybuilder") + { + return true; + } + } + + // Path 2: source-text scan over the enclosing function's body. Some + // builder assignments (multi-line chains, deeply nested in `try`/`for` + // bodies) bind `defines` to a synthesised name that doesn't match + // `receiver_name` exactly. A direct byte scan for an assignment shape + // catches these without depending on CFG synthesis details. + let func_start = ctx + .cfg + .node_indices() + .filter_map(|i| { + let n = &ctx.cfg[i]; + if n.ast.enclosing_func.as_deref() == sink_func { + Some(n.ast.span.0) + } else { + None + } + }) + .min() + .unwrap_or(0); + let bytes = ctx.source_bytes; + let lo = func_start.min(bytes.len()); + let hi = sink_span_start.min(bytes.len()); + if lo >= hi { + return false; + } + let scope = &bytes[lo..hi]; + text_contains_builder_factory_assignment(scope, receiver_name) +} + +/// Search `scope` for `$ = ... (...)` where `` ends +/// with `getQueryBuilder` / `createQueryBuilder` (case-insensitive). Used as a +/// byte-level fallback for CFG def-lookup that misses multi-line chained +/// assignments inside nested `try` / `for` bodies. +fn text_contains_builder_factory_assignment(scope: &[u8], name: &str) -> bool { + if name.is_empty() { + return false; + } + let needle: Vec = std::iter::once(b'$').chain(name.bytes()).collect(); + let mut start = 0usize; + while start + needle.len() <= scope.len() { + let Some(rel) = find_subslice(&scope[start..], &needle) else { + return false; + }; + let mut cursor = start + rel + needle.len(); + // Require an immediate `=` (allow whitespace before). + while cursor < scope.len() && matches!(scope[cursor], b' ' | b'\t' | b'\n' | b'\r') { + cursor += 1; + } + if cursor < scope.len() + && scope[cursor] == b'=' + && (cursor + 1 == scope.len() || scope[cursor + 1] != b'=') + { + // Find the next `;` (statement terminator) without crossing a + // closing brace boundary, the assignment expression spans up to it. + let mut end = cursor + 1; + while end < scope.len() { + let b = scope[end]; + if b == b';' || b == b'\n' && end + 1 < scope.len() && scope[end + 1] == b'\n' { + break; + } + end += 1; + } + let rhs_lower: Vec = scope[cursor + 1..end] + .iter() + .map(|b| b.to_ascii_lowercase()) + .collect(); + if find_subslice(&rhs_lower, b"getquerybuilder").is_some() + || find_subslice(&rhs_lower, b"createquerybuilder").is_some() + { + return true; + } + } + start = start + rel + 1; + } + false +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + haystack.windows(needle.len()).position(|w| w == needle) +} + /// Walk the sink's Call SSA arguments and check whether every real argument /// resolves through a defining `SsaOp::Call` whose callee carries an SSA /// summary with `validated_params_to_return` covering every propagating @@ -1068,8 +2277,381 @@ fn sink_arg_is_parameter_only(ctx: &AnalysisContext, sink: NodeIndex) -> bool { return false; // can't determine params } - // Check if ALL sink uses are parameters - sink_uses.iter().all(|u| param_names.contains(&u.as_str())) + // The sink's `taint.uses` includes pseudo-uses for callee-chain segments + // when the chain is rooted at a self-pseudo-receiver (`this`, `self`, + // `static`, `parent`). In that case every segment of the chain is part + // of the dotted callee path that tree-sitter records as identifier + // children of the call expression, not a real argument. This shape + // covers thin method wrappers like + // `function wrap($sql) { return $this->inner->execute($sql); }` so the + // sink is recognised as parameter-only despite `this` / `inner` / + // `execute` showing up in `taint.uses`. + // + // For other callee chains (e.g. Python `cursor.execute(name)` where + // `cursor` is a local variable from `connection.cursor()`), only the + // method name itself (`execute`) is filtered. `cursor` is a real + // identifier value — a non-param local — and must not be filtered, + // otherwise wrappers around external receivers get suppressed + // incorrectly. + // + // PHP variable receivers carry a leading `$` (`$this->inner->execute`) + // and use `->` between the receiver and member, so split on the full + // set of separators and strip a leading `$` so identifier-shaped + // fragments line up with bare identifier names in `taint.uses`. + // + // Each segment carries an `is_call` flag so chain pieces that are + // themselves method invocations (`getSession()` in + // `getSession().createQuery(qs)`) can be recognised as pseudo-uses + // alongside the terminal method name. Variable-receiver chains like + // `cursor.execute(name)` keep `cursor` as a real identifier and stay + // out of the param-only filter. + let callee_desc = sink_info.call.callee.as_deref().unwrap_or(""); + let outer_callee = sink_info.call.outer_callee.as_deref().unwrap_or(""); + fn split_chain_with_flags(s: &str) -> SmallVec<[(&str, bool); 8]> { + let mut out: SmallVec<[(&str, bool); 8]> = SmallVec::new(); + for piece in s.split(['.', ':', '>', '-']) { + let stripped = piece.trim_start_matches('$').trim(); + if stripped.is_empty() { + continue; + } + let (name, is_call) = match stripped.find('(') { + Some(idx) => (stripped[..idx].trim(), true), + None => (stripped, false), + }; + if !name.is_empty() { + out.push((name, is_call)); + } + } + out + } + fn is_self_root(seg: &str) -> bool { + matches!(seg, "this" | "self" | "static" | "parent" | "cls") + } + let mut callee_fragments: SmallVec<[&str; 8]> = SmallVec::new(); + for src in [callee_desc, outer_callee] { + let segs = split_chain_with_flags(src); + let Some(&(first_name, _)) = segs.first() else { + continue; + }; + let last_idx = segs.len() - 1; + if is_self_root(first_name) { + // Whole chain is callee path: `$this->inner->execute` → + // every segment is a pseudo-use. + for &(name, _) in &segs { + if !callee_fragments.contains(&name) { + callee_fragments.push(name); + } + } + } else { + // The terminal method name is a pseudo-use. Any non-last + // segment that is itself a method call (`getSession()` in + // `getSession().createQuery(qs)`) is also a pseudo-use, since + // the segment text in the chain refers to a method name, not + // a local variable. Bare-identifier receivers like `cursor` + // in `cursor.execute(name)` carry no `(` and stay as real + // local-variable values. + for (i, &(name, is_call)) in segs.iter().enumerate() { + if (is_call || i == last_idx) && !callee_fragments.contains(&name) { + callee_fragments.push(name); + } + } + } + } + + // Source-text scan: `callee_desc` collapses chains via `root_receiver_text`, + // so `getSession().getCriteriaBuilder().createQuery(qs)` reduces to + // `"getSession().createQuery"` and the intermediate `getCriteriaBuilder` + // is missing. Walk the sink's source bytes up to the outermost args + // opener and lift every `IDENT(` pattern as a method-call pseudo-use. + // Identifiers nested inside earlier `()` groups (which open at depth 0 + // for sibling method calls in a chain) are picked up too, so every + // chain hop contributes its method name. + let span = sink_info.classification_span(); + let (start, end) = span; + if start < ctx.source_bytes.len() && end <= ctx.source_bytes.len() && start < end { + let span_bytes = &ctx.source_bytes[start..end]; + if let Ok(span_text) = std::str::from_utf8(span_bytes) { + let bytes = span_text.as_bytes(); + // Find the outermost args-opener: the last `(` at depth 0. + let mut depth: i32 = 0; + let mut last_open_at_zero: Option = None; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'(' => { + if depth == 0 { + last_open_at_zero = Some(i); + } + depth += 1; + } + b')' => { + depth = depth.saturating_sub(1); + } + _ => {} + } + } + let chain_end = last_open_at_zero.unwrap_or(bytes.len()); + // Walk the chain prefix and lift every identifier directly followed + // by `(` as a method-call pseudo-use. + let mut i = 0; + while i < chain_end { + let b = bytes[i]; + let is_ident_start = b.is_ascii_alphabetic() || b == b'_'; + if !is_ident_start { + i += 1; + continue; + } + let id_start = i; + while i < chain_end { + let c = bytes[i]; + if c.is_ascii_alphanumeric() || c == b'_' { + i += 1; + } else { + break; + } + } + if i < chain_end && bytes[i] == b'(' { + let name = &span_text[id_start..i]; + if !callee_fragments.contains(&name) { + callee_fragments.push(name); + } + } + } + } + } + + // Strict parameter set scoped to the sink's enclosing function only. + // Used for the local-trace fallback below to prevent over-suppression + // when sibling functions in the same file happen to share param names + // with the current scope (e.g. a constructor's `dbConn` param leaking + // into the `param_names` view of an unrelated `logAuditEvent` body). + // The existing broad `param_names` view is preserved for the direct + // in-list check above so legacy suppression behaviour is unchanged. + let strict_param_names: SmallVec<[&str; 8]> = ctx + .func_summaries + .iter() + .filter(|(key, _)| sink_func.is_some_and(|name| key.name.as_str() == name)) + .flat_map(|(_, s)| s.param_names.iter().map(|p| p.as_str())) + .collect(); + sink_uses.iter().all(|u| { + if callee_fragments.contains(&u.as_str()) || u == callee_desc { + return true; + } + if param_names.contains(&u.as_str()) { + return true; + } + // One-hop transitive local trace: when a sink use names a body + // local whose every definition resolves to parameter-derived + // data (e.g. `Statement stmt = connection.createStatement(); + // stmt.executeQuery(sql);` where `connection` is a param), the + // local is wrapper plumbing. Receiver-variable shapes whose + // definitions reach a free (non-param, non-local) identifier or + // a Source label fail the trace and keep the structural finding. + if strict_param_names.is_empty() { + return false; + } + let mut seen: SmallVec<[&str; 4]> = SmallVec::new(); + local_is_param_derived( + ctx, + sink_func, + &strict_param_names, + &callee_fragments, + u.as_str(), + 3, + &mut seen, + ) + }) +} + +/// Recursive trace, return true iff every definition of `name` inside +/// `sink_func` has its right-hand-side fully resolvable to parameter +/// names, callee fragments, or other already-cleared body locals. Bounded +/// by `depth` to prevent runaway on pathological CFGs and uses `seen` to +/// short-circuit cycles (a local whose definition mentions itself does +/// not clear). Called from `sink_arg_is_parameter_only` once the simple +/// param / callee-fragment / source-text check has failed. +fn local_is_param_derived<'a>( + ctx: &'a AnalysisContext, + sink_func: Option<&str>, + param_names: &[&'a str], + callee_fragments: &[&'a str], + name: &'a str, + depth: u8, + seen: &mut SmallVec<[&'a str; 4]>, +) -> bool { + if depth == 0 || seen.contains(&name) { + return false; + } + seen.push(name); + let mut found_def = false; + let mut all_def_clear = true; + for idx in ctx.cfg.node_indices() { + let info = &ctx.cfg[idx]; + if info.ast.enclosing_func.as_deref() != sink_func { + continue; + } + if info.taint.defines.as_deref() != Some(name) { + continue; + } + found_def = true; + if info + .taint + .labels + .iter() + .any(|l| matches!(l, DataLabel::Source(_))) + { + all_def_clear = false; + break; + } + // Compute the defining node's own callee fragments so method-name + // segments (e.g. `createStatement` in `statement = + // connection.createStatement();`) are recognised as pseudo-uses + // alongside the receiver variable. Without this, the trace + // wrongly rejects every chained method initialisation. The + // source-text scan below also lifts intermediate method calls + // (`unwrap` in `connection.unwrap().createStatement`) that the + // collapsed `info.call.callee` drops. + let def_fragments = chain_callee_fragments_with_text( + info.call.callee.as_deref().unwrap_or(""), + info.call.outer_callee.as_deref().unwrap_or(""), + ctx.source_bytes, + info.classification_span(), + ); + let clear = info.taint.uses.iter().all(|u| { + param_names.contains(&u.as_str()) + || callee_fragments.contains(&u.as_str()) + || def_fragments.contains(&u.as_str()) + || local_is_param_derived( + ctx, + sink_func, + param_names, + callee_fragments, + u.as_str(), + depth - 1, + seen, + ) + }); + if !clear { + all_def_clear = false; + break; + } + } + seen.pop(); + found_def && all_def_clear +} + +/// Split a callee chain like `getSession().createQuery` or +/// `connection.createStatement` into method-name segments treated as +/// pseudo-uses. Also walks `source_bytes[span]` up to the outermost +/// args-opener and lifts every `IDENT(` pattern, recovering intermediate +/// method-call segments that the collapsed `info.call.callee` text drops +/// (e.g. `unwrap` in `connection.unwrap().createStatement()`). Mirrors +/// the in-place chain split inside `sink_arg_is_parameter_only` so trace +/// nodes get the same recognition as the sink itself. Self-rooted +/// chains (`this->...`, `self.foo`) surface every segment; other chains +/// surface only the terminal method name plus any inner method-call +/// segments. +fn chain_callee_fragments_with_text<'a>( + callee: &'a str, + outer: &'a str, + source_bytes: &'a [u8], + span: (usize, usize), +) -> SmallVec<[&'a str; 8]> { + fn split_chain<'b>(s: &'b str) -> SmallVec<[(&'b str, bool); 8]> { + let mut out: SmallVec<[(&'b str, bool); 8]> = SmallVec::new(); + for piece in s.split(['.', ':', '>', '-']) { + let stripped = piece.trim_start_matches('$').trim(); + if stripped.is_empty() { + continue; + } + let (name, is_call) = match stripped.find('(') { + Some(idx) => (stripped[..idx].trim(), true), + None => (stripped, false), + }; + if !name.is_empty() { + out.push((name, is_call)); + } + } + out + } + fn is_self_root(seg: &str) -> bool { + matches!(seg, "this" | "self" | "static" | "parent" | "cls") + } + let mut frags: SmallVec<[&str; 8]> = SmallVec::new(); + for src in [callee, outer] { + let segs = split_chain(src); + let Some(&(first_name, _)) = segs.first() else { + continue; + }; + let last_idx = segs.len() - 1; + if is_self_root(first_name) { + for &(name, _) in &segs { + if !frags.contains(&name) { + frags.push(name); + } + } + } else { + for (i, &(name, is_call)) in segs.iter().enumerate() { + if (is_call || i == last_idx) && !frags.contains(&name) { + frags.push(name); + } + } + } + } + let (start, end) = span; + if start < source_bytes.len() && end <= source_bytes.len() && start < end { + let span_bytes = &source_bytes[start..end]; + if let Ok(span_text) = std::str::from_utf8(span_bytes) { + let bytes = span_text.as_bytes(); + let mut depth: i32 = 0; + let mut last_open_at_zero: Option = None; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'(' => { + if depth == 0 { + last_open_at_zero = Some(i); + } + depth += 1; + } + b')' => { + depth = depth.saturating_sub(1); + } + _ => {} + } + } + let chain_end = last_open_at_zero.unwrap_or(bytes.len()); + let mut i = 0; + while i < chain_end { + let b = bytes[i]; + let is_ident_start = b.is_ascii_alphabetic() || b == b'_'; + if !is_ident_start { + i += 1; + continue; + } + let id_start = i; + while i < chain_end { + let c = bytes[i]; + if c.is_ascii_alphanumeric() || c == b'_' { + i += 1; + } else { + break; + } + } + if i < chain_end && bytes[i] == b'(' { + let name = &span_text[id_start..i]; + let abs_start = start + id_start; + let abs_end = start + i; + if abs_start < source_bytes.len() && abs_end <= source_bytes.len() { + let name_slice = + std::str::from_utf8(&source_bytes[abs_start..abs_end]).unwrap_or(name); + if !frags.contains(&name_slice) { + frags.push(name_slice); + } + } + } + } + } + } + frags } /// Check if the source bytes at a given span contain a redirect call whose @@ -1271,6 +2853,46 @@ impl CfgAnalysis for UnguardedSink { continue; } + // Zero-arg query-builder verbs: Doctrine DBAL `QueryBuilder`, + // JPA `CriteriaBuilder`, and similar chain-builder shapes + // execute a query that was bound earlier on the receiver via + // parameterised API calls. No SQL string is concatenated at + // the terminal call site. Closes the nextcloud apps/dav and + // lib/private/DB cluster (`$qb->executeQuery()` / + // `$qb->executeStatement()` after `select`/`from`/`where`/ + // `setParameter` chains). + if !has_taint && sink_is_zero_arg_query_builder(ctx, *sink, sink_caps) { + continue; + } + + // Builder.getSQL() arg suppression: the dangerous flat shape is + // `$conn->executeStatement($sql)` where `$sql` is user-controlled + // SQL. When `$sql` is itself the return of `.getSQL()`, + // the SQL is parameterised by construction (Doctrine DBAL), + // independent of which receiver fires the terminal verb. + if !has_taint && sink_first_arg_is_builder_get_sql(ctx, *sink, sink_caps) { + continue; + } + + // Composition: `.getSQL()` wrapped by string-shaping ops + // (`preg_replace('/^INSERT/i', 'INSERT IGNORE', $b->getSQL())`, + // `$b->getSQL() . ' ON CONFLICT DO NOTHING'`). Closes the + // remaining nextcloud `AdapterMySQL.php` / `AdapterSqlite.php` + // FPs after the direct accessor recognition above. + if !has_taint && sink_first_arg_composes_safe_dbal_sql(ctx, *sink, sink_caps) { + continue; + } + + // PHP foreach-key string interpolation: arg-0 is a SQL string + // whose interpolated `$` is bound by a `foreach ($X as $var)` + // (or `as $key => $var`) over a literal-keyed array assigned + // earlier in the same function. The literal set is finite and + // metachar-free, so the interpolated SQL is bounded. Closes the + // nextcloud `lib/private/DB/MySqlTools.php:27` FP. + if !has_taint && sink_arg_uses_safe_foreach_key(ctx, *sink, sink_caps) { + continue; + } + // Static-map suppression: the SSA value flowing into the sink is // proved by the static-HashMap-lookup idiom detector to be a // finite set of literals free of shell metacharacters. Mirrors @@ -1367,3 +2989,65 @@ impl CfgAnalysis for UnguardedSink { findings } } + +#[cfg(test)] +mod chain_fragments_tests { + use super::chain_callee_fragments_with_text; + + fn frags(callee: &str, outer: &str, source: &str) -> Vec { + chain_callee_fragments_with_text(callee, outer, source.as_bytes(), (0, source.len())) + .iter() + .map(|s| (*s).to_string()) + .collect() + } + + #[test] + fn java_chained_init_lifts_inner_call() { + // `Statement stmt = connection.unwrap().createStatement();` + // The collapsed `info.call.callee` drops the inner method call, + // so the source-text scan has to recover `unwrap` on top of the + // structural split's `createStatement`. + let src = "Statement stmt = connection.unwrap().createStatement()"; + let got = frags("connection.createStatement", "", src); + assert!(got.contains(&"createStatement".to_string())); + assert!(got.contains(&"unwrap".to_string())); + assert!(!got.contains(&"connection".to_string())); + assert!(!got.contains(&"stmt".to_string())); + } + + #[test] + fn flat_method_invocation_terminal_only() { + // `connection.createStatement()` — receiver `connection` stays a + // real local-variable use, only the terminal method counts as a + // pseudo-use. + let src = "connection.createStatement()"; + let got = frags("connection.createStatement", "", src); + assert!(got.contains(&"createStatement".to_string())); + assert!(!got.contains(&"connection".to_string())); + } + + #[test] + fn self_rooted_chain_lifts_every_segment() { + // `$this->inner->execute($sql)` — every chain segment belongs to + // the callee path because the chain is rooted at a self + // pseudo-receiver. + let src = "$this->inner->execute($sql)"; + let got = frags("this->inner->execute", "", src); + assert!(got.contains(&"this".to_string())); + assert!(got.contains(&"inner".to_string())); + assert!(got.contains(&"execute".to_string())); + } + + #[test] + fn source_scan_skips_inside_args() { + // The scan stops at the outermost args opener, so identifiers + // nested inside the arguments are NOT lifted as pseudo-uses. + // `db.exec(transform(raw))` still treats `transform` as a real + // local reference, not a chain segment. + let src = "db.exec(transform(raw))"; + let got = frags("db.exec", "", src); + assert!(got.contains(&"exec".to_string())); + assert!(!got.contains(&"transform".to_string())); + assert!(!got.contains(&"raw".to_string())); + } +} diff --git a/src/cfg_analysis/mod.rs b/src/cfg_analysis/mod.rs index 48d16e5c..34808fe2 100644 --- a/src/cfg_analysis/mod.rs +++ b/src/cfg_analysis/mod.rs @@ -208,6 +208,13 @@ pub struct AnalysisContext<'a> { /// in a callback the per-body CFG can't observe. When `None`, no /// closure-based suppression is applied. pub closure_released_var_names: Option<&'a std::collections::HashSet>, + /// Class-level constant scalars discovered for this file, keyed by + /// the unqualified field name (Java `static final TYPE NAME = LIT;`). + /// Used by `cfg_analysis::guards` to treat identifiers referencing + /// these fields as compile-time constants for the + /// `cfg-unguarded-sink` all-args-constant check. `None` outside Java + /// and on call sites that have not threaded the map through. + pub class_constant_scalars: Option<&'a std::collections::HashMap>, } pub trait CfgAnalysis { diff --git a/src/cfg_analysis/resources.rs b/src/cfg_analysis/resources.rs index e67fc2fb..d815568e 100644 --- a/src/cfg_analysis/resources.rs +++ b/src/cfg_analysis/resources.rs @@ -10,6 +10,43 @@ use std::collections::HashSet; pub struct ResourceMisuse; +/// Distinguishes `obj.connect("event-name", handler)` event-handler +/// registrations from real database-connection acquires. +/// +/// Recognises the canonical handler shape: a string-literal first arg +/// that does not look like a URL (`scheme://`), plus a second positional +/// argument that resolves to a single identifier (the callable being +/// registered). SQLAlchemy `engine.connect()` and `sqlite3.connect( +/// "path.db")` either pass zero args or a single string, so they fall +/// through and the leak check still fires. +/// +/// Kept out of the static `exclude_acquire` list because that list is +/// callee-substring-only; this check needs to read argument shape from +/// the call node. +fn is_event_handler_register_shape(info: &crate::cfg::NodeInfo) -> bool { + let Some(first_literal) = info + .call + .arg_string_literals + .first() + .and_then(|x| x.as_ref()) + else { + return false; + }; + if first_literal.contains("://") { + return false; + } + let Some(second_uses) = info.call.arg_uses.get(1) else { + return false; + }; + // A bare identifier (`callback`) lands as `["callback"]`; a + // member-access ref (`self._on_status`) lands as `["self", + // "_on_status"]`. Both are valid handler shapes. Real DB connects + // either have no second positional or pass a non-ident value + // (string literal for `connect("user", "pass", ...)`), which lands + // as an empty `arg_uses[1]`. + !second_uses.is_empty() +} + /// Find nodes matching acquire patterns for a given resource pair, /// excluding any that match `exclude_patterns`. fn find_acquire_nodes( @@ -517,6 +554,21 @@ impl CfgAnalysis for ResourceMisuse { if ctx.cfg[acquire].managed_resource { continue; } + // Suppress `obj.connect("event-name", callback)` event- + // handler registrations that share the `connect` / + // `cursor` callee suffix with real DB acquires. Sphinx + // app.connect("config-inited", on_init), Flask blueprint + // handlers, and MQTT client.connect("topic", on_msg) all + // pass a string literal event name plus a callable + // identifier; SQLAlchemy `engine.connect()` and + // `sqlite3.connect("path.db")` either have no args or a + // single string arg. Gated on the `db connection` + // resource name so file/socket/mutex pairs are untouched. + if pair.resource_name == "db connection" + && is_event_handler_register_shape(&ctx.cfg[acquire]) + { + continue; + } // SAFE-FOR-FIELD-LHS (Go only): skip member-expression // LHS acquires. `b.cpuprof = os.Create(...)` transfers // ownership to the containing struct; closure @@ -598,3 +650,83 @@ impl CfgAnalysis for ResourceMisuse { findings } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cfg::{CallMeta, NodeInfo, StmtKind}; + + fn call_node(arg_string_literals: Vec>, arg_uses: Vec>) -> NodeInfo { + NodeInfo { + kind: StmtKind::Call, + call: CallMeta { + callee: Some("obj.connect".into()), + arg_string_literals, + arg_uses, + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn event_handler_shape_recognises_sphinx_connect() { + // app.connect("config-inited", _on_init) + let info = call_node( + vec![Some("config-inited".into()), None], + vec![vec![], vec!["_on_init".into()]], + ); + assert!(is_event_handler_register_shape(&info)); + } + + #[test] + fn event_handler_shape_recognises_self_method_callback() { + // client.connect("device/+", self._on_status) + let info = call_node( + vec![Some("device/+".into()), None], + vec![vec![], vec!["self".into(), "_on_status".into()]], + ); + assert!(is_event_handler_register_shape(&info)); + } + + #[test] + fn event_handler_shape_rejects_url_first_arg() { + // engine.connect("postgres://localhost/mydb") + let info = call_node(vec![Some("postgres://localhost/mydb".into())], vec![vec![]]); + assert!(!is_event_handler_register_shape(&info)); + } + + #[test] + fn event_handler_shape_rejects_oracle_string_args() { + // cx_Oracle.connect("user", "pass", "dsn") -- arg1 is a literal, + // no identifier in `arg_uses[1]`. + let info = call_node( + vec![Some("user".into()), Some("pass".into()), Some("dsn".into())], + vec![vec![], vec![], vec![]], + ); + assert!(!is_event_handler_register_shape(&info)); + } + + #[test] + fn event_handler_shape_rejects_no_args() { + // engine.connect() + let info = call_node(vec![], vec![]); + assert!(!is_event_handler_register_shape(&info)); + } + + #[test] + fn event_handler_shape_rejects_single_string_arg() { + // sqlite3.connect("path.db") + let info = call_node(vec![Some("path.db".into())], vec![vec![]]); + assert!(!is_event_handler_register_shape(&info)); + } + + #[test] + fn event_handler_shape_rejects_ident_first_arg() { + // signal.connect(receiver_func, sender=...) -- handled by the + // static exclude list `signal.connect`, but the shape check + // should also gate it out: first arg is not a string literal. + let info = call_node(vec![None], vec![vec!["receiver_func".into()]]); + assert!(!is_event_handler_register_shape(&info)); + } +} diff --git a/src/cfg_analysis/tests.rs b/src/cfg_analysis/tests.rs index b66d54d5..6d6801ea 100644 --- a/src/cfg_analysis/tests.rs +++ b/src/cfg_analysis/tests.rs @@ -35,6 +35,7 @@ fn parse_and_analyse( type_facts: None, auth_decorators: &[], closure_released_var_names: None, + class_constant_scalars: None, }; analysis.run(&ctx) } @@ -65,6 +66,7 @@ fn parse_and_run_all(src: &[u8], lang_str: &str, ts_lang: Language) -> Vec( type_facts: facts.as_ref().map(|f| &f.type_facts), auth_decorators: &[], closure_released_var_names: None, + class_constant_scalars: None, }; analysis.run(&ctx) } @@ -1235,6 +1239,7 @@ fn config_sanitizer_suppresses_unguarded_sink() { type_facts: None, auth_decorators: &[], closure_released_var_names: None, + class_constant_scalars: None, }; let findings = run_all(&ctx); @@ -1715,6 +1720,7 @@ fn cfg_only_no_taint_produces_low_severity() { type_facts: None, auth_decorators: &[], closure_released_var_names: None, + class_constant_scalars: None, }; let findings = guards::UnguardedSink.run(&ctx); diff --git a/src/commands/rules.rs b/src/commands/rules.rs index f9d79a0d..aff086a3 100644 --- a/src/commands/rules.rs +++ b/src/commands/rules.rs @@ -215,7 +215,7 @@ fn print_label_row(r: &RuleInfo) { String::new() } else { let joined = r.matchers.join(", "); - format!(" — {joined}") + format!(" {joined}") }; println!( " {} {:<10} {:<10} {:<14}{}{}", diff --git a/src/commands/scan.rs b/src/commands/scan.rs index fdce5a6f..6c228e18 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -245,6 +245,25 @@ pub(crate) fn ensure_framework_ctx(root: &Path, cfg: &Config) -> Option Some(c) } +/// Build a [`crate::resolve::ModuleGraph`] for `root` and stash it on a +/// clone of `cfg`. Returns `None` when the cfg already carries one or +/// when the build produced an empty graph. +/// +/// Mirrors `ensure_framework_ctx`'s lifecycle: scan-path entry points +/// call this once between the file walk and pass 1, the graph is shared +/// across all per-file analysis via `Config::module_graph`. Building is +/// best-effort, errors during fs walk land as missing entries rather +/// than aborts. +pub(crate) fn ensure_module_graph(root: &Path, cfg: &Config) -> Option { + if cfg.module_graph.is_some() { + return None; + } + let graph = crate::resolve::build_module_graph(&[root.to_path_buf()]); + let mut c = cfg.clone(); + c.module_graph = Some(std::sync::Arc::new(graph)); + Some(c) +} + /// Does `path` belong to a Preview-tier language (C or C++)? /// /// Drives the one-time `preview-tier scan` banner in `handle()`. Tracks @@ -1085,6 +1104,7 @@ fn run_topo_batches( .collect(); let mut ssa_count: usize = 0; + let mg = cfg.module_graph.as_deref(); for (path, diags, summaries, ssa_summaries, _ssa_bodies) in batch_results { // Phase-B: replace (not append) this file's diags // so the cache always reflects the latest @@ -1093,7 +1113,7 @@ fn run_topo_batches( diags_by_file.insert(path, diags); for s in summaries { - let key = s.func_key(root_str_ref); + let key = s.func_key_with_resolver(root_str_ref, mg); global_summaries.insert(key, s); } @@ -1143,7 +1163,7 @@ fn run_topo_batches( .iter() .filter(|p| { let abs = p.to_string_lossy(); - let rel = crate::symbol::normalize_namespace(&abs, root_str_ref); + let rel = crate::symbol::namespace_with_package(&abs, root_str_ref, mg); namespaces_needing_reanalysis.contains(&rel) }) .map(|p| (*p).clone()) @@ -1182,7 +1202,7 @@ fn run_topo_batches( batch = batch_idx, dirty = dirty_files.len(), "SCC converged by snapshot but dirty_files non-empty; \ - call graph disagrees with summary diff — accepting \ + call graph disagrees with summary diff, accepting \ snapshot as authoritative" ); converged = true; @@ -1230,7 +1250,7 @@ fn run_topo_batches( cap = scc_cap, cross_file = cross_file_scc, reason = reason.tag(), - "SCC batch did not converge within safety cap — results \ + "SCC batch did not converge within safety cap, results \ may be imprecise. This usually indicates a very large \ mutually-recursive region or a non-monotone summary \ refinement; please file a bug with a reproducer." @@ -1376,12 +1396,13 @@ fn run_topo_batches( let mut refined_ssa: usize = 0; let mut refined_bodies: usize = 0; let mut refined_auth: usize = 0; + let mg = cfg.module_graph.as_deref(); for (_path, diags, summaries, ssa_summaries, ssa_bodies, auth_summaries) in batch_results { batch_diags.extend(diags); for s in summaries { - let key = s.func_key(root_str_ref); + let key = s.func_key_with_resolver(root_str_ref, mg); global_summaries.insert(key, s); refined_summaries += 1; } @@ -1568,6 +1589,15 @@ pub(crate) fn scan_filesystem_with_observer( }; tracing::info!(file_count = all_paths.len(), "file walk complete"); + // ── Build TS/JS module graph once for the scan root ────────────────── + // Phase 04: resolver foundation. The graph is built between walk and + // pass 1 so every per-file analysis (CFG-time import classification, + // pass-2 cross-file lookup) sees the same view. Build cost is bounded + // (no AST parsing, manifests only) and the result lives behind an + // `Arc` on `Config::module_graph`. + let owned_cfg_with_graph = ensure_module_graph(root, cfg); + let cfg = owned_cfg_with_graph.as_ref().unwrap_or(cfg); + if let Some(flag) = preview_tier_seen { if all_paths.iter().any(|p| is_preview_tier_path(p)) { flag.store(true, Ordering::Relaxed); @@ -1704,6 +1734,7 @@ pub(crate) fn scan_filesystem_with_observer( show_progress, ); let root_str = root.to_string_lossy(); + let mg = cfg.module_graph.as_deref(); let gs = all_paths .par_iter() @@ -1720,7 +1751,7 @@ pub(crate) fn scan_filesystem_with_observer( let first_lang = r.summaries.first().map(|s| s.lang.clone()); for s in r.summaries { - let key = s.func_key(Some(&root_str)); + let key = s.func_key_with_resolver(Some(&root_str), mg); local_gs.insert(key, s); } @@ -1754,6 +1785,16 @@ pub(crate) fn scan_filesystem_with_observer( local_gs.insert_router_facts(module_id, facts); } + // Phase-09 indexed-mode parity: cache the + // file's cross-package import map by namespace + // so an inlined callee body loaded from SQLite + // (where the body's own Arc is stripped by + // `#[serde(skip)]`) can recover its package + // boundary at step 0.7. + if let Some((ns, map)) = r.cross_package_imports { + local_gs.insert_cross_package_imports(ns, map); + } + // Record language for progress if let Some(p) = progress { if let Some(ref lang) = first_lang { @@ -2057,6 +2098,12 @@ pub fn scan_with_index_parallel_observer( ); } + // Phase 04: build the TS/JS module graph between fs walk and pass 1 + // so the indexed scan path sees the same resolver state as the + // non-indexed path (`scan_filesystem_with_observer`). + let owned_cfg_with_graph = ensure_module_graph(scan_root, cfg); + let cfg = owned_cfg_with_graph.as_ref().unwrap_or(cfg); + let current_files: HashSet = files.iter().cloned().collect(); let removed_files: Vec = indexed_files .into_iter() @@ -2139,7 +2186,7 @@ pub fn scan_with_index_parallel_observer( ) }, ) { - Ok((func_sums, ssa_sums, ssa_bodies, auth_sums)) => { + Ok((func_sums, ssa_sums, ssa_bodies, auth_sums, cross_pkg_imports)) => { if let Some(p) = &progress_ref { p.inc_parsed(1); if let Some(lang) = func_sums.first().map(|s| s.lang.as_str()) { @@ -2193,8 +2240,12 @@ pub fn scan_with_index_parallel_observer( .collect(); // Single transaction for all four caches: // one fsync per file instead of four. + let cpi_arg = cross_pkg_imports + .as_ref() + .map(|(ns, map)| (ns.as_str(), map.as_ref())); if let Err(e) = idx.replace_all_for_file( path, &hash, &func_sums, &ssa_rows, &body_rows, &auth_rows, + cpi_arg, ) { record_persist_error( &persist_errors_ref, @@ -2268,7 +2319,11 @@ pub fn scan_with_index_parallel_observer( crate::symbol::Lang::from_slug(&lang_str).unwrap_or(crate::symbol::Lang::Rust); // Use persisted namespace; fall back to normalized file_path let ns = if namespace.is_empty() { - crate::symbol::normalize_namespace(&file_path, Some(&root_str)) + crate::symbol::namespace_with_package( + &file_path, + Some(&root_str), + cfg.module_graph.as_deref(), + ) } else { namespace }; @@ -2289,6 +2344,23 @@ pub fn scan_with_index_parallel_observer( } } + // Load Phase-09 cross-package import maps so an inlined callee + // body loaded from SQLite (where the body's own Arc is stripped + // by `#[serde(skip)]`) can recover its package boundary at + // step 0.7. Indexed-mode parity with `scan_filesystem`. + match idx.load_all_cross_package_imports() { + Ok(rows) => { + for (_file_path, namespace, map) in rows { + if !map.is_empty() { + gs.insert_cross_package_imports(namespace, std::sync::Arc::new(map)); + } + } + } + Err(e) => { + tracing::warn!("failed to load cross_package_imports from DB: {e}"); + } + } + // Load cross-file callee bodies from DB let body_count = if crate::symex::cross_file_symex_enabled() { match idx.load_all_ssa_bodies() { @@ -2309,7 +2381,11 @@ pub fn scan_with_index_parallel_observer( let lang = crate::symbol::Lang::from_slug(&lang_str) .unwrap_or(crate::symbol::Lang::Rust); let ns = if namespace.is_empty() { - crate::symbol::normalize_namespace(&file_path, Some(&root_str)) + crate::symbol::namespace_with_package( + &file_path, + Some(&root_str), + cfg.module_graph.as_deref(), + ) } else { namespace }; @@ -2363,7 +2439,11 @@ pub fn scan_with_index_parallel_observer( let lang = crate::symbol::Lang::from_slug(&lang_str).unwrap_or(crate::symbol::Lang::Rust); let ns = if namespace.is_empty() { - crate::symbol::normalize_namespace(&file_path, Some(&root_str)) + crate::symbol::namespace_with_package( + &file_path, + Some(&root_str), + cfg.module_graph.as_deref(), + ) } else { namespace }; diff --git a/src/constraint/domain.rs b/src/constraint/domain.rs index 06bc82c3..36cd6ddf 100644 --- a/src/constraint/domain.rs +++ b/src/constraint/domain.rs @@ -201,6 +201,36 @@ fn type_kind_index(kind: &TypeKind) -> u32 { // domain has no dedicated slot, share the Object index so // singleton recovery still maps to a meaningful TypeKind. TypeKind::NullPrototypeObject => 3, + // FileSystemPromisesNs is a JS-only namespace receiver type used + // by the Phase 05 fs/promises sink resolver. The bitset domain + // has no dedicated slot; share the Object index so singleton + // recovery still hands back a usable TypeKind. + TypeKind::FileSystemPromisesNs => 3, + // Phase 07 ORM receiver TypeKinds. They participate only in the + // type-qualified callee resolver via their `label_prefix()`; the + // bitset domain's flow-sensitive narrowing has no dedicated slot + // for them, so collapse to Object (3). Singleton recovery from + // the index will hand back `Object`, which is a benign upper + // bound for the ORM receiver shapes. + TypeKind::Sequelize + | TypeKind::TypeOrmRepo + | TypeKind::TypeOrmManager + | TypeKind::MikroOrmEm => 3, + // Phase 10 — `Request` is a Web-platform receiver type used + // by the App Router entry-point seeding path; it shares the + // Object slot for the same reason the ORM TypeKinds do. + TypeKind::Request => 3, + // Phase 15 — cross-language ORM receiver TypeKinds. Same + // rationale as the Phase 07 ORM TypeKinds above; they + // participate only in the type-qualified callee resolver via + // `label_prefix()` and have no dedicated slot in the bitset + // domain. + TypeKind::SqlAlchemySession + | TypeKind::DjangoQuerySet + | TypeKind::ActiveRecordRelation + | TypeKind::GormDb + | TypeKind::SqlxDb + | TypeKind::HibernateSession => 3, } } diff --git a/src/constraint/lower.rs b/src/constraint/lower.rs index d257bbc2..c5492e6f 100644 --- a/src/constraint/lower.rs +++ b/src/constraint/lower.rs @@ -612,6 +612,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } diff --git a/src/database.rs b/src/database.rs index 6d48120b..5052f385 100644 --- a/src/database.rs +++ b/src/database.rs @@ -59,6 +59,7 @@ pub mod index { disambig INTEGER, kind TEXT NOT NULL DEFAULT 'fn', summary TEXT NOT NULL, + entry_kind TEXT, updated_at INTEGER NOT NULL, UNIQUE(project, file_path, name, container, arity, disambig, kind) ); @@ -76,6 +77,7 @@ pub mod index { disambig INTEGER, kind TEXT NOT NULL DEFAULT 'fn', summary TEXT NOT NULL, + entry_kind TEXT, updated_at INTEGER NOT NULL, UNIQUE(project, file_path, name, container, arity, disambig, kind) ); @@ -114,6 +116,17 @@ pub mod index { UNIQUE(project, file_path, name, container, arity, disambig, kind) ); + CREATE TABLE IF NOT EXISTS cross_package_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL, + file_path TEXT NOT NULL, + file_hash BLOB NOT NULL, + namespace TEXT NOT NULL, + imports BLOB NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(project, file_path) + ); + CREATE TABLE IF NOT EXISTS scans ( id TEXT PRIMARY KEY, status TEXT NOT NULL, @@ -204,6 +217,8 @@ pub mod index { ON ssa_function_bodies(project, file_path); CREATE INDEX IF NOT EXISTS idx_auth_check_summaries_project_file ON auth_check_summaries(project, file_path); + CREATE INDEX IF NOT EXISTS idx_cross_package_imports_project_file + ON cross_package_imports(project, file_path); "#; /// Engine version used to detect stale caches across upgrades. @@ -311,7 +326,17 @@ pub mod index { // workers on machines with more cores than that during the // parallel indexing pass. Size the pool to comfortably hold // a connection per rayon thread plus a small slack. - let max_conns = (num_cpus::get() as u32 + 4).max(16); + // + // `NYX_INDEX_POOL_MAX` overrides the auto-sized default. Use it in + // fd-constrained environments (test sandboxes, containers with low + // ulimit) where many parallel indexed scans would otherwise exhaust + // EMFILE: each pooled SQLite WAL connection costs ~3 fds (db + -wal + // + -shm), so 30 parallel scans × 16 conns × 3 fds = 1440 fds. + let max_conns = std::env::var("NYX_INDEX_POOL_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|n| *n >= 1) + .unwrap_or_else(|| (num_cpus::get() as u32 + 4).max(16)); let pool = Arc::new(Pool::builder().max_size(max_conns).build(manager)?); { @@ -400,6 +425,14 @@ pub mod index { conn.execute_batch(SCHEMA)?; } + // Phase 10 — `entry_kind` column on (ssa_)function_summaries. + // Non-destructive `ALTER TABLE ... ADD COLUMN` so existing + // rows survive the upgrade. The column is nullable; the + // INSERT paths write the JSON-encoded `EntryKind` text or + // NULL when the function is not an entry point. + Self::ensure_column(&conn, "function_summaries", "entry_kind", "TEXT")?; + Self::ensure_column(&conn, "ssa_function_summaries", "entry_kind", "TEXT")?; + // Ensure the auth_check_summaries table exists for DBs // created before this column set was introduced. The // `CREATE TABLE IF NOT EXISTS` in SCHEMA handles new DBs; @@ -419,6 +452,26 @@ pub mod index { conn.execute_batch(SCHEMA)?; } + // Phase 09 indexed-mode parity: ensure the + // `cross_package_imports` table exists for DBs created + // before this column set was introduced. `CREATE TABLE + // IF NOT EXISTS` in SCHEMA handles new DBs; this branch + // only fires when the table is missing entirely from a + // pre-existing DB. + let cpi_exists: bool = conn + .query_row( + "SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'cross_package_imports'", + [], + |_| Ok(true), + ) + .optional()? + .unwrap_or(false); + if !cpi_exists { + tracing::info!("creating cross_package_imports table"); + conn.execute_batch(SCHEMA)?; + } + // Schema version check: invalidate cached summary tables // when the on-disk artefact layout has changed in an // incompatible way, independently of the engine version. @@ -433,6 +486,33 @@ pub mod index { Ok(pool) } + /// Add a column to an existing table when it is missing. + /// + /// Non-destructive: leaves all existing rows untouched, populating + /// the new column with NULL. Used to thread additive schema + /// changes (Phase 10's `entry_kind`) into pre-existing databases + /// without forcing a full cache rebuild. + fn ensure_column( + conn: &Connection, + table: &str, + column: &str, + sqlite_type: &str, + ) -> NyxResult<()> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; + let cols: std::collections::HashSet = stmt + .query_map([], |r| r.get::<_, String>(1))? + .filter_map(Result::ok) + .collect(); + if cols.contains(column) { + return Ok(()); + } + tracing::info!("adding column {column} to {table}"); + conn.execute_batch(&format!( + "ALTER TABLE {table} ADD COLUMN {column} {sqlite_type}" + ))?; + Ok(()) + } + /// Check stored schema version against the compiled-in value. /// /// On mismatch (including first-time open), wipe the cached @@ -468,7 +548,8 @@ pub mod index { DELETE FROM function_summaries; DELETE FROM ssa_function_summaries; DELETE FROM auth_check_summaries; - DELETE FROM files;", + DELETE FROM files; + DROP TABLE IF EXISTS cross_package_imports;", )?; conn.execute_batch(SCHEMA)?; conn.execute( @@ -801,14 +882,19 @@ pub mod index { let mut stmt = tx.prepare( "INSERT OR REPLACE INTO function_summaries (project, file_path, file_hash, name, arity, lang, - container, disambig, kind, summary, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + container, disambig, kind, summary, entry_kind, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", )?; for s in summaries { let json = serde_json::to_string(s) .map_err(|e| NyxError::Msg(format!("summary serialise: {e}")))?; let disambig_sql = s.disambig.map(|d| d as i64); + let entry_kind_sql = s + .entry_kind + .as_ref() + .map(|ek| serde_json::to_string(ek).unwrap_or_else(|_| String::new())) + .filter(|s| !s.is_empty()); stmt.execute(params![ self.project, path_str, @@ -820,6 +906,7 @@ pub mod index { disambig_sql, s.kind.as_str(), json, + entry_kind_sql, now ])?; } @@ -863,8 +950,8 @@ pub mod index { let mut stmt = tx.prepare( "INSERT OR REPLACE INTO ssa_function_summaries (project, file_path, file_hash, name, arity, lang, namespace, - container, disambig, kind, summary, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + container, disambig, kind, summary, entry_kind, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", )?; for (name, arity, lang, namespace, container, disambig, kind, summary) in summaries @@ -872,6 +959,11 @@ pub mod index { let json = serde_json::to_string(summary) .map_err(|e| NyxError::Msg(format!("SSA summary serialise: {e}")))?; let disambig_sql = disambig.map(|d| d as i64); + let entry_kind_sql = summary + .entry_kind + .as_ref() + .map(|ek| serde_json::to_string(ek).unwrap_or_else(|_| String::new())) + .filter(|s| !s.is_empty()); stmt.execute(params![ self.project, path_str, @@ -884,6 +976,7 @@ pub mod index { disambig_sql, kind.as_str(), json, + entry_kind_sql, now ])?; } @@ -1392,6 +1485,10 @@ pub mod index { crate::symbol::FuncKind, crate::auth_analysis::model::AuthCheckSummary, )], + cross_package_imports: Option<( + &str, + &std::collections::HashMap, + )>, ) -> NyxResult<()> { let tx = self.conn.transaction()?; let path_str = file_path.to_string_lossy(); @@ -1406,13 +1503,18 @@ pub mod index { let mut stmt = tx.prepare( "INSERT OR REPLACE INTO function_summaries (project, file_path, file_hash, name, arity, lang, - container, disambig, kind, summary, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + container, disambig, kind, summary, entry_kind, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", )?; for s in func_summaries { let json = serde_json::to_string(s) .map_err(|e| NyxError::Msg(format!("summary serialise: {e}")))?; let disambig_sql = s.disambig.map(|d| d as i64); + let entry_kind_sql = s + .entry_kind + .as_ref() + .map(|ek| serde_json::to_string(ek).unwrap_or_else(|_| String::new())) + .filter(|s| !s.is_empty()); stmt.execute(params![ self.project, path_str, @@ -1424,6 +1526,7 @@ pub mod index { disambig_sql, s.kind.as_str(), json, + entry_kind_sql, now ])?; } @@ -1439,8 +1542,8 @@ pub mod index { let mut stmt = tx.prepare( "INSERT OR REPLACE INTO ssa_function_summaries (project, file_path, file_hash, name, arity, lang, namespace, - container, disambig, kind, summary, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + container, disambig, kind, summary, entry_kind, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", )?; for (name, arity, lang, namespace, container, disambig, kind, summary) in ssa_summaries @@ -1448,6 +1551,11 @@ pub mod index { let json = serde_json::to_string(summary) .map_err(|e| NyxError::Msg(format!("SSA summary serialise: {e}")))?; let disambig_sql = disambig.map(|d| d as i64); + let entry_kind_sql = summary + .entry_kind + .as_ref() + .map(|ek| serde_json::to_string(ek).unwrap_or_else(|_| String::new())) + .filter(|s| !s.is_empty()); stmt.execute(params![ self.project, path_str, @@ -1460,6 +1568,7 @@ pub mod index { disambig_sql, kind.as_str(), json, + entry_kind_sql, now ])?; } @@ -1536,6 +1645,26 @@ pub mod index { } } + // cross_package_imports: replace this file's row, even with + // an empty input, so a file that lost its imports does not + // leave stale resolutions in the cache. + tx.execute( + "DELETE FROM cross_package_imports WHERE project = ?1 AND file_path = ?2", + params![self.project, path_str], + )?; + if let Some((namespace, map)) = cross_package_imports + && !map.is_empty() + { + let blob = rmp_serde::to_vec_named(map) + .map_err(|e| NyxError::Msg(format!("cross_package_imports serialise: {e}")))?; + tx.execute( + "INSERT OR REPLACE INTO cross_package_imports + (project, file_path, file_hash, namespace, imports, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![self.project, path_str, file_hash, namespace, blob, now], + )?; + } + tx.commit()?; Ok(()) } @@ -1622,6 +1751,61 @@ pub mod index { Ok(out) } + /// Load every persisted per-file Phase-09 cross-package import map + /// for this project. + /// + /// Returns rows as `(file_path, namespace, imports_map)`. Used by + /// pass 2 of indexed scans to populate + /// `GlobalSummaries::cross_package_imports_by_namespace`, recovering + /// the per-file import view that + /// [`crate::taint::ssa_transfer::CalleeSsaBody::cross_package_imports`] + /// loses across SQLite round-trip (`#[serde(skip)]`). + pub fn load_all_cross_package_imports( + &self, + ) -> NyxResult< + Vec<( + String, + String, + std::collections::HashMap, + )>, + > { + let mut stmt = self.c().prepare( + "SELECT file_path, namespace, imports + FROM cross_package_imports WHERE project = ?1", + )?; + + let rows: Vec<(String, String, Vec)> = stmt + .query_map([&self.project], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Vec>(2)?, + )) + })? + .filter_map(|r| match r { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("failed to read cross_package_imports row: {e}"); + None + } + }) + .collect(); + + let mut out = Vec::with_capacity(rows.len()); + for (fp, ns, blob) in rows { + match rmp_serde::from_slice::< + std::collections::HashMap, + >(&blob) + { + Ok(map) => out.push((fp, ns, map)), + Err(e) => { + tracing::warn!("failed to deserialize cross_package_imports blob: {e}"); + } + } + } + Ok(out) + } + /// Remove a file and all derived persisted state for this project. /// /// This deletes the file row, issues, and all persisted summary rows so @@ -1659,6 +1843,10 @@ pub mod index { "DELETE FROM auth_check_summaries WHERE project = ?1 AND file_path = ?2", params![self.project, path_str.as_ref()], )?; + tx.execute( + "DELETE FROM cross_package_imports WHERE project = ?1 AND file_path = ?2", + params![self.project, path_str.as_ref()], + )?; tx.commit()?; Ok(()) @@ -2539,6 +2727,7 @@ fn ssa_summaries_round_trip() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ), ( @@ -2575,6 +2764,7 @@ fn ssa_summaries_round_trip() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ), ]; @@ -2749,6 +2939,7 @@ fn ssa_summaries_hash_rescan_replaces_stale() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, )]; idx.replace_ssa_summaries_for_file(&f, &hash_v1, &sums_v1) @@ -2787,6 +2978,7 @@ fn ssa_summaries_hash_rescan_replaces_stale() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, )]; idx.replace_ssa_summaries_for_file(&f, &hash_v2, &sums_v2) @@ -2846,6 +3038,7 @@ fn clear_drops_ssa_summaries_table() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, )]; idx.replace_ssa_summaries_for_file(&f, &hash, &sums) @@ -2903,6 +3096,7 @@ fn make_test_callee_body( field_interner: crate::ssa::ir::FieldInterner::new(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }, opt: crate::ssa::OptimizeResult { const_values: std::collections::HashMap::new(), @@ -2921,9 +3115,58 @@ fn make_test_callee_body( param_count, node_meta: std::collections::HashMap::new(), body_graph: None, + cross_package_imports: std::sync::Arc::new(std::collections::HashMap::new()), } } +#[test] +fn cross_package_imports_round_trip_via_replace_all_for_file() { + use crate::symbol::{FuncKey, FuncKind, Lang}; + let td = tempfile::tempdir().unwrap(); + let db = td.path().join("nyx.sqlite"); + let f = td.path().join("caller.ts"); + std::fs::write(&f, "import { escape } from '@scope/util';").unwrap(); + + let pool = index::Indexer::init(&db).unwrap(); + let mut idx = index::Indexer::from_pool("proj", &pool).unwrap(); + let hash = index::Indexer::digest_bytes(b"caller content"); + + let mut imports: std::collections::HashMap = std::collections::HashMap::new(); + imports.insert( + "escape".to_string(), + FuncKey { + lang: Lang::TypeScript, + namespace: "packages/util/src/escape.ts".to_string(), + container: String::new(), + name: "escape".to_string(), + arity: None, + disambig: None, + kind: FuncKind::Function, + }, + ); + + idx.replace_all_for_file(&f, &hash, &[], &[], &[], &[], Some(("caller.ts", &imports))) + .unwrap(); + + let loaded = idx.load_all_cross_package_imports().unwrap(); + assert_eq!(loaded.len(), 1); + let (fp, ns, map) = &loaded[0]; + assert_eq!(fp, &f.to_string_lossy().to_string()); + assert_eq!(ns, "caller.ts"); + assert_eq!(map.len(), 1); + let key = map + .get("escape") + .expect("escape binding survives round-trip"); + assert_eq!(key.namespace, "packages/util/src/escape.ts"); + assert_eq!(key.name, "escape"); + assert_eq!(key.lang, Lang::TypeScript); + + // Empty input on rescan should drop the row. + idx.replace_all_for_file(&f, &hash, &[], &[], &[], &[], None) + .unwrap(); + assert!(idx.load_all_cross_package_imports().unwrap().is_empty()); +} + #[test] fn ssa_bodies_round_trip() { let td = tempfile::tempdir().unwrap(); @@ -3122,6 +3365,7 @@ fn make_test_ssa_summary() -> crate::summary::ssa_summary::SsaFuncSummary { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, } } @@ -3436,6 +3680,153 @@ fn missing_ssa_namespace_column_triggers_recreate() { assert_eq!(idx.load_all_ssa_summaries().unwrap().len(), 1); } +/// Phase 10 migration test. Build a database whose +/// `(ssa_)function_summaries` tables are at the post-Phase 09 shape +/// (namespace + container + disambig + kind columns present, but no +/// `entry_kind` column). Insert a row directly so the migration must +/// preserve it. After `init`, the column should exist on both tables +/// without dropping the pre-existing data. +#[test] +fn entry_kind_column_added_in_place_without_data_loss() { + let td = tempfile::tempdir().unwrap(); + let db = td.path().join("nyx.sqlite"); + + // Hand-build a pre-Phase-10 schema (no `entry_kind` column). + { + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL, path TEXT NOT NULL, + hash BLOB NOT NULL, mtime INTEGER NOT NULL, + scanned_at INTEGER NOT NULL, UNIQUE(project, path) + ); + CREATE TABLE function_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL, file_path TEXT NOT NULL, + file_hash BLOB NOT NULL, name TEXT NOT NULL, + arity INTEGER NOT NULL DEFAULT -1, lang TEXT NOT NULL, + container TEXT NOT NULL DEFAULT '', + disambig INTEGER, + kind TEXT NOT NULL DEFAULT 'fn', + summary TEXT NOT NULL, updated_at INTEGER NOT NULL, + UNIQUE(project, file_path, name, container, arity, disambig, kind) + ); + CREATE TABLE ssa_function_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL, file_path TEXT NOT NULL, + file_hash BLOB NOT NULL, name TEXT NOT NULL, + arity INTEGER NOT NULL DEFAULT -1, lang TEXT NOT NULL, + namespace TEXT NOT NULL DEFAULT '', + container TEXT NOT NULL DEFAULT '', + disambig INTEGER, + kind TEXT NOT NULL DEFAULT 'fn', + summary TEXT NOT NULL, updated_at INTEGER NOT NULL, + UNIQUE(project, file_path, name, container, arity, disambig, kind) + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO function_summaries + (project, file_path, file_hash, name, arity, lang, + container, disambig, kind, summary, updated_at) + VALUES ('proj', 'lib.py', X'00', 'old_func', 1, 'python', + '', NULL, 'fn', '{}', 0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO ssa_function_summaries + (project, file_path, file_hash, name, arity, lang, + namespace, container, disambig, kind, summary, updated_at) + VALUES ('proj', 'lib.py', X'00', 'old_func', 1, 'python', + '', '', NULL, 'fn', '{}', 0)", + [], + ) + .unwrap(); + // Pre-populate the metadata so `check_schema_version` and + // `check_engine_version` consider the database current and do + // not wipe the rows we just inserted. The point of this test + // is the in-place `ALTER TABLE`; the version checks are a + // separate concern. + conn.execute( + "CREATE TABLE IF NOT EXISTS nyx_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)", + [], + ) + .unwrap(); + conn.execute( + "INSERT OR REPLACE INTO nyx_metadata (key, value) VALUES ('schema_version', ?1)", + rusqlite::params![index::SCHEMA_VERSION], + ) + .unwrap(); + conn.execute( + "INSERT OR REPLACE INTO nyx_metadata (key, value) VALUES ('engine_version', ?1)", + rusqlite::params![index::ENGINE_VERSION], + ) + .unwrap(); + } + + // Open via init — should non-destructively ALTER both tables to + // add `entry_kind`, leaving the seeded rows intact. + let pool = index::Indexer::init(&db).unwrap(); + + let conn = pool.get().unwrap(); + let cols_for = |table: &str| { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .unwrap(); + let v: Vec = stmt + .query_map([], |r| r.get::<_, String>(1)) + .unwrap() + .filter_map(Result::ok) + .collect(); + v + }; + assert!( + cols_for("function_summaries") + .iter() + .any(|c| c == "entry_kind"), + "function_summaries.entry_kind missing after migration" + ); + assert!( + cols_for("ssa_function_summaries") + .iter() + .any(|c| c == "entry_kind"), + "ssa_function_summaries.entry_kind missing after migration" + ); + + // Pre-existing rows survive the migration. + let func_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM function_summaries WHERE project = 'proj'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(func_rows, 1, "pre-existing function_summaries row was lost"); + let ssa_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM ssa_function_summaries WHERE project = 'proj'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + ssa_rows, 1, + "pre-existing ssa_function_summaries row was lost" + ); + + // Existing rows have NULL entry_kind by default. + let entry_kind_value: Option = conn + .query_row( + "SELECT entry_kind FROM function_summaries WHERE project = 'proj'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(entry_kind_value.is_none()); +} + #[test] fn valid_schema_no_recreate() { let td = tempfile::tempdir().unwrap(); diff --git a/src/entry_points/mod.rs b/src/entry_points/mod.rs new file mode 100644 index 00000000..afc0e2d7 --- /dev/null +++ b/src/entry_points/mod.rs @@ -0,0 +1,1720 @@ +//! Phase 10 + Phase 16 — framework entry-point detection. +//! +//! Recognises HTTP-handler shapes across the major web frameworks so the +//! SSA taint engine can seed their parameters with `TaintOrigin::Source` +//! at function entry without waiting for a caller-side flow. +//! +//! Phase 10 covered Next.js JS/TS shapes (`'use server'` directive, +//! App Router route handlers). Phase 16 generalises detection to +//! Python (Django views, FastAPI routes, Flask routes, Starlette), +//! Java (Spring `@RequestMapping` / `@GetMapping` / `@PostMapping`, +//! JAX-RS `@Path`), Ruby (Rails `ActionController` actions, Sinatra +//! `get` / `post` blocks), Rust (axum / actix-web / rocket handlers), +//! Go (`net/http` `HandleFunc`, gin / echo / chi route registration), +//! and Express (JS, non-Next.js, `app.get` / `router.post`). +//! +//! Detection runs at pass-1 summary extraction time and writes +//! [`EntryKind`] onto the matching [`crate::summary::FuncSummary`] / +//! [`crate::summary::ssa_summary::SsaFuncSummary`]. Pass 2 reads the +//! tag back from the per-body summary and seeds parameters before the +//! taint worklist starts. + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use tree_sitter::{Node, Tree}; + +/// The HTTP method an HTTP-handler entry-point is responding to. Used +/// by the App Router, FastAPI, Flask, Spring, Sinatra, and Express +/// entry-kind variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + GET, + HEAD, + POST, + PUT, + PATCH, + DELETE, + OPTIONS, +} + +impl HttpMethod { + /// Parse an HTTP method export name (`GET`, `POST`, ...). Used by + /// the Next.js App Router and Python FastAPI dispatchers. + pub fn from_ident(ident: &str) -> Option { + match ident.to_ascii_uppercase().as_str() { + "GET" => Some(Self::GET), + "HEAD" => Some(Self::HEAD), + "POST" => Some(Self::POST), + "PUT" => Some(Self::PUT), + "PATCH" => Some(Self::PATCH), + "DELETE" => Some(Self::DELETE), + "OPTIONS" => Some(Self::OPTIONS), + _ => None, + } + } +} + +/// Entry-point classification recorded on a function summary. Phase 16 +/// adds variants for Python, Java, Ruby, Rust, Go, and non-Next.js +/// Express handlers. Each variant carries the language tag implicit +/// in the variant identity so seeding policy can branch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum EntryKind { + // ── Phase 10 (JS/TS — Next.js) ──────────────────────────────────── + /// `'use server'` directive (file-level *or* function-level). The + /// file-level form marks every exported function in the file; the + /// function-level form marks one specific function whose first + /// statement is the directive. + UseServerDirective, + /// A function exported from `app/**/route.{ts,tsx,js,jsx}` whose + /// name is one of the recognised HTTP methods. + AppRouteHandler { method: HttpMethod }, + /// A `
    ` server-action callee. Detected by + /// walking JSX trees for `` opening / self-closing elements + /// whose `action` attribute carries a `{NAME}` identifier + /// reference (resolved by name to a function in the same file) or + /// an inline `arrow_function` / `function_expression` (resolved + /// by exact span). Seeding policy is identical to + /// [`EntryKind::UseServerDirective`]: every formal is treated as + /// adversary-controlled, which models the framework contract + /// that `` invokes `fn(formData)` with the + /// submitted FormData as the first argument. + FormAction, + + // ── Phase 16 (cross-language) ───────────────────────────────────── + /// Python — Django class-based view method (`get`, `post`, etc.) or + /// function-based view decorated with `@require_http_methods` / + /// `@api_view`. First param is `self` for class-based, second is + /// the `HttpRequest`; for function-based the first param is the + /// `HttpRequest`. All formals are seeded as Source (the request + /// object itself + any path-captured arguments). + /// + /// `method` carries the HTTP verb derived from the function name + /// (CBV) or the first decorator argument (`@api_view(['POST'])` / + /// `@require_http_methods(['PUT'])`). Defaults to `GET` when no + /// verb evidence is available. + DjangoView { method: HttpMethod }, + /// Python — FastAPI route registered via decorator `@app.get(...)` + /// / `@router.post(...)`. Formals are query / path / body + /// extractors; every formal is seeded as Source. + FastApiRoute { method: HttpMethod }, + /// Python — Flask route registered via `@app.route(...)` / + /// `@bp.get(...)`. Method defaults to GET when the decorator + /// omits an explicit `methods=[...]` list. + FlaskRoute { method: HttpMethod }, + + // Java + /// Java — Spring `@RequestMapping` / `@GetMapping` / `@PostMapping` + /// / `@PutMapping` / `@PatchMapping` / `@DeleteMapping` annotated + /// controller method. + SpringMapping { method: HttpMethod }, + /// Java — JAX-RS `@Path`-annotated resource method. Method comes + /// from the verb annotation (`@GET`, `@POST`, etc.) when present. + JaxRsResource, + + // Ruby + /// Ruby — Rails `ActionController` action method (a public + /// instance method on a class extending `ApplicationController` / + /// `ActionController::Base`). No parameters in the formal list; + /// taint flows through the implicit `params` source. + RailsAction, + /// Ruby — Sinatra `get '/path' do |arg| ... end` block. + SinatraRoute { method: HttpMethod }, + + // Rust + /// Rust — axum handler. Conservative recognition: a function whose + /// signature contains an axum extractor type (`Query<_>`, + /// `Json<_>`, `Path<_>`, `Form<_>`, `Extension<_>`, `State<_>`, + /// `Request`, `HeaderMap`, etc.) — strong enough signal that a + /// router maps the path to the function. + AxumHandler, + /// Rust — actix-web handler. Recognised by the routing macros + /// `#[get("...")]` / `#[post("...")]` / etc. attached to the + /// function item. + ActixHandler, + /// Rust — Rocket handler. Recognised by the routing macros + /// `#[get("...")]` / `#[post("...")]` / `#[route(GET, "...")]` + /// attached to the function item. Note the macro name overlaps + /// with actix-web; disambiguation requires import-site evidence + /// which Phase 16 does not consult — the conservative tag is + /// `RocketRoute` when the function is in a file containing a + /// Rocket-specific witness (`#[launch]`, `rocket::build`). + RocketRoute, + + // Go + /// Go — `net/http` `func(w http.ResponseWriter, r *http.Request)` + /// handler. Shape-based recognition: any function whose param + /// list ends with a `*http.Request` is treated as an HTTP handler. + GoNetHttp, + /// Go — gin handler (`func(c *gin.Context)`) or echo handler + /// (`func(c echo.Context) error`) or chi handler. All carry a + /// single context-receiver parameter whose type contains "Context". + GinRoute, + + // Express (non-Next.js JS/TS) + /// Express / Koa / Fastify handler. Recognised at the + /// registration site (`app.get('/path', handler)` etc.) by + /// resolving the callback identifier to a function definition in + /// the same file. Anonymous arrow callbacks at the call site are + /// tagged on the arrow definition itself. + ExpressRoute { method: HttpMethod }, +} + +/// Detect every entry-point function in a single parsed file. +/// +/// The result keys each detected function by its tree-sitter byte +/// span `(start, end)`. The summary-extraction pipeline matches +/// against [`crate::cfg::BodyMeta::span`] to attach the [`EntryKind`] +/// to the corresponding summary. +/// +/// Returns an empty map for unsupported languages and for files +/// without any recognised entry shape. No caller has to special-case +/// the empty result. +pub fn detect_entries_in_file( + tree: &Tree, + bytes: &[u8], + path: &Path, + lang_slug: &str, +) -> HashMap<(usize, usize), EntryKind> { + let root = tree.root_node(); + match lang_slug { + "javascript" | "typescript" | "tsx" => detect_js_ts(root, bytes, path), + "python" => detect_python(root, bytes), + "java" => detect_java(root, bytes), + "ruby" => detect_ruby(root, bytes), + "rust" => detect_rust(root, bytes), + "go" => detect_go(root, bytes), + _ => HashMap::new(), + } +} + +// ───────────────────────────────────────────────────────────────────── +// JS / TS — Next.js (Phase 10) + Express (Phase 16) +// ───────────────────────────────────────────────────────────────────── + +fn detect_js_ts(root: Node<'_>, bytes: &[u8], path: &Path) -> HashMap<(usize, usize), EntryKind> { + let mut entries: HashMap<(usize, usize), EntryKind> = HashMap::new(); + + let file_use_server = file_level_use_server(root, bytes); + let route_methods = if is_app_route_path(path) { + Some(collect_route_handler_exports(root, bytes)) + } else { + None + }; + + // Express: collect `app.METHOD("...", handler)` / `router.METHOD(...)` + // call sites and resolve handler identifiers to function definitions. + let express_handlers = collect_express_handlers(root, bytes); + + // Server actions: collect `` callees so a + // function bound as a form action gets the same seeding policy as + // a `'use server'`-marked export. + let form_actions = collect_form_action_handlers(root, bytes); + + walk_functions_js(root, bytes, &mut |node, name| { + let span = (node.start_byte(), node.end_byte()); + + if function_level_use_server(node, bytes) { + entries.entry(span).or_insert(EntryKind::UseServerDirective); + return; + } + + if file_use_server && exports_function(node, root, bytes, name) { + entries.entry(span).or_insert(EntryKind::UseServerDirective); + return; + } + + if let (Some(map), Some(name)) = (&route_methods, name) + && let Some(method) = map.get(name).copied() + { + entries + .entry(span) + .or_insert(EntryKind::AppRouteHandler { method }); + return; + } + + // FormAction: tag named callees (by string lookup) and inline + // arrows / function expressions (by span lookup). Runs before + // Express dispatch so a function bound as both a form action + // and an `app.get(..., handler)` argument is tagged as the + // form action — the form binding is the more specific + // entry-point claim and carries the same Pure-Param-only + // seeding policy. + if form_actions.by_span.contains(&span) + || name.is_some_and(|n| form_actions.by_name.contains(n)) + { + entries.entry(span).or_insert(EntryKind::FormAction); + return; + } + + // Express handler resolution: matches by name (named function + // declaration) OR by exact span (anonymous arrow registered at + // the call site). + if let Some(method) = express_handlers + .by_span + .get(&span) + .copied() + .or_else(|| name.and_then(|n| express_handlers.by_name.get(n).copied())) + { + entries + .entry(span) + .or_insert(EntryKind::ExpressRoute { method }); + } + }); + + entries +} + +/// Form-action handler resolution. Built by walking the JSX subtree +/// for `` elements with an `action={...}` attribute, then +/// classifying the right-hand-side as either a named identifier +/// (matched against function declarations / arrow bindings by name) +/// or an inline arrow / function expression (matched by exact span). +struct FormActionHandlers { + by_name: HashSet, + by_span: HashSet<(usize, usize)>, +} + +fn collect_form_action_handlers(root: Node, bytes: &[u8]) -> FormActionHandlers { + let mut out = FormActionHandlers { + by_name: HashSet::new(), + by_span: HashSet::new(), + }; + walk_form_action_recursive(root, bytes, &mut out); + out +} + +fn walk_form_action_recursive(node: Node, bytes: &[u8], out: &mut FormActionHandlers) { + if matches!( + node.kind(), + "jsx_opening_element" | "jsx_self_closing_element" + ) && jsx_element_tag_is_form(node, bytes) + && let Some(action_value) = jsx_element_action_attr_value(node, bytes) + { + record_form_action_handler(action_value, bytes, out); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_form_action_recursive(child, bytes, out); + } +} + +/// True when the JSX element's tag name is the lowercase host element +/// `form`. React renders `` (lowercase) as an HTML form; an +/// uppercase `` is a user component and the action prop's +/// semantics are component-defined, not the framework form-action +/// contract. Members like `` are also rejected. +fn jsx_element_tag_is_form(elem: Node, bytes: &[u8]) -> bool { + let Some(name_node) = elem.child_by_field_name("name") else { + return false; + }; + if name_node.kind() != "identifier" { + return false; + } + name_node.utf8_text(bytes).ok() == Some("form") +} + +/// Return the `value` field node of an `action="..."` / `action={...}` +/// attribute on the given JSX opening / self-closing element. +fn jsx_element_action_attr_value<'a>(elem: Node<'a>, bytes: &[u8]) -> Option> { + let mut cursor = elem.walk(); + for child in elem.children(&mut cursor) { + if child.kind() != "jsx_attribute" { + continue; + } + let name = child + .child_by_field_name("name") + .or_else(|| child.named_child(0))?; + let Ok(text) = name.utf8_text(bytes) else { + continue; + }; + if text != "action" { + continue; + } + return child + .child_by_field_name("value") + .or_else(|| child.named_child(1)); + } + None +} + +/// Classify the right-hand-side of `action=...`. Identifier +/// references add to `by_name`; inline arrow / function-expression +/// definitions add to `by_span`. String literals (URL form, +/// `action="/api/submit"`) and other shapes are ignored. +fn record_form_action_handler(value: Node, bytes: &[u8], out: &mut FormActionHandlers) { + let mut cur = value; + // Unwrap JSX expression / parenthesized expression wrappers + // around the value. Bounded depth keeps the walk cheap. + for _ in 0..4 { + match cur.kind() { + "jsx_expression" | "parenthesized_expression" => { + let mut walker = cur.walk(); + let Some(next) = cur + .named_children(&mut walker) + .find(|c| c.kind() != "comment") + else { + return; + }; + cur = next; + } + _ => break, + } + } + match cur.kind() { + "identifier" => { + if let Ok(name) = cur.utf8_text(bytes) { + out.by_name.insert(name.to_string()); + } + } + "arrow_function" | "function_expression" | "function_declaration" => { + out.by_span.insert((cur.start_byte(), cur.end_byte())); + } + _ => {} + } +} + +/// Path-based recogniser for `app/**/route.{ts,tsx,js,jsx}`. +fn is_app_route_path(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + let recognised_basename = matches!(name, "route.ts" | "route.tsx" | "route.js" | "route.jsx"); + if !recognised_basename { + return false; + } + path.components() + .any(|c| c.as_os_str().to_string_lossy() == "app") +} + +/// Read the first non-comment top-level statement and return `true` +/// when it is a string-literal directive `'use server'` / +/// `"use server"`. +fn file_level_use_server(root: Node, bytes: &[u8]) -> bool { + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + match child.kind() { + "comment" | "hash_bang_line" => continue, + "expression_statement" => { + if let Some(stmt) = first_string_child(child) + && string_literal_equals(stmt, bytes, "use server") + { + return true; + } + return false; + } + _ => return false, + } + } + false +} + +/// Per-function recogniser: `function() { 'use server'; ... }`. +fn function_level_use_server(func_node: Node, bytes: &[u8]) -> bool { + let Some(body) = function_body_js(func_node) else { + return false; + }; + let mut cursor = body.walk(); + for stmt in body.children(&mut cursor) { + match stmt.kind() { + "comment" => continue, + "expression_statement" => { + if let Some(s) = first_string_child(stmt) { + return string_literal_equals(s, bytes, "use server"); + } + return false; + } + "{" | "}" => continue, + _ => return false, + } + } + false +} + +/// Walk every JS/TS function-like definition and invoke +/// `visit(node, name)` for each. +fn walk_functions_js)>(root: Node, bytes: &[u8], visit: &mut F) { + let mut cursor = root.walk(); + visit_recursive_js(root, bytes, &mut cursor, visit); +} + +fn visit_recursive_js)>( + node: Node, + bytes: &[u8], + _cursor: &mut tree_sitter::TreeCursor, + visit: &mut F, +) { + match node.kind() { + "function_declaration" + | "function_expression" + | "generator_function_declaration" + | "generator_function" => { + let name = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()); + visit(node, name); + } + "arrow_function" => { + let name = function_name_for_arrow(node, bytes); + visit(node, name.as_deref()); + } + "method_definition" => { + let name = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()); + visit(node, name); + } + _ => {} + } + let mut walker = node.walk(); + for child in node.children(&mut walker) { + visit_recursive_js(child, bytes, _cursor, visit); + } +} + +/// Resolve the textual name attached to an arrow function via the +/// enclosing `const NAME = (…) => …` shape. Returns `None` when the +/// arrow is not the initialiser of a `variable_declarator`. +fn function_name_for_arrow(node: Node, bytes: &[u8]) -> Option { + let parent = node.parent()?; + if parent.kind() != "variable_declarator" { + return None; + } + let name_node = parent.child_by_field_name("name")?; + let text = name_node.utf8_text(bytes).ok()?; + Some(text.to_string()) +} + +/// Get the body of a function-like node. Returns the +/// `statement_block` for declarations / expressions; `None` for arrow +/// functions whose body is an expression rather than a block (those +/// cannot host a directive prologue). +fn function_body_js<'a>(func_node: Node<'a>) -> Option> { + let body = func_node.child_by_field_name("body")?; + if body.kind() == "statement_block" { + Some(body) + } else { + None + } +} + +/// Extract the first `string` child of an `expression_statement`. +fn first_string_child<'a>(node: Node<'a>) -> Option> { + let mut cursor = node.walk(); + node.children(&mut cursor) + .find(|child| child.kind() == "string") +} + +/// Compare the textual content of a `string` node (quotes stripped) +/// to `expected`. +fn string_literal_equals(string_node: Node, bytes: &[u8], expected: &str) -> bool { + let Ok(raw) = string_node.utf8_text(bytes) else { + return false; + }; + let trimmed = raw + .trim() + .trim_start_matches(['\'', '"', '`']) + .trim_end_matches(['\'', '"', '`']); + trimmed == expected +} + +/// Decide whether a function declaration / arrow definition with the +/// given name is exported at the top level of the program. +fn exports_function(func_node: Node, root: Node, bytes: &[u8], name: Option<&str>) -> bool { + if let Some(parent) = func_node.parent() + && parent.kind() == "export_statement" + { + return true; + } + let mut cur = func_node; + for _ in 0..4 { + let Some(parent) = cur.parent() else { + break; + }; + if parent.kind() == "export_statement" { + return true; + } + cur = parent; + } + if let Some(target) = name { + let mut walker = root.walk(); + for child in root.children(&mut walker) { + if child.kind() != "export_statement" { + continue; + } + let mut cur = child.walk(); + for export_child in child.children(&mut cur) { + if export_child.kind() == "export_clause" { + let mut spec = export_child.walk(); + for s in export_child.children(&mut spec) { + if s.kind() == "export_specifier" + && s.child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + .is_some_and(|t| t == target) + { + return true; + } + } + } + } + } + } + false +} + +/// Collect the names of exported HTTP-method functions in a +/// route-handler file. The map binds each name to the matching +/// [`HttpMethod`]. +fn collect_route_handler_exports(root: Node, bytes: &[u8]) -> HashMap { + let mut out = HashMap::new(); + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + if child.kind() != "export_statement" { + continue; + } + let mut walker = child.walk(); + for export_child in child.children(&mut walker) { + collect_named_exports(export_child, bytes, &mut out); + } + } + out +} + +fn collect_named_exports(node: Node, bytes: &[u8], out: &mut HashMap) { + match node.kind() { + "function_declaration" | "generator_function_declaration" => { + if let Some(name) = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + && let Some(m) = HttpMethod::from_ident(name) + { + out.insert(name.to_string(), m); + } + } + "lexical_declaration" | "variable_declaration" => { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "variable_declarator" + && let Some(name) = child + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + && let Some(m) = HttpMethod::from_ident(name) + { + out.insert(name.to_string(), m); + } + } + } + _ => {} + } +} + +/// Express handler resolution: collected from `app.METHOD(path, handler)` / +/// `router.METHOD(...)` call sites. Anonymous arrow callbacks are +/// tagged by exact span; named identifier callbacks are tagged by name. +struct ExpressHandlers { + by_name: HashMap, + by_span: HashMap<(usize, usize), HttpMethod>, +} + +fn collect_express_handlers(root: Node, bytes: &[u8]) -> ExpressHandlers { + let mut out = ExpressHandlers { + by_name: HashMap::new(), + by_span: HashMap::new(), + }; + walk_express_recursive(root, bytes, &mut out); + out +} + +fn walk_express_recursive(node: Node, bytes: &[u8], out: &mut ExpressHandlers) { + if node.kind() == "call_expression" + && let Some(method) = express_call_method(node, bytes) + && let Some(args) = node.child_by_field_name("arguments") + { + let mut last_handler: Option = None; + let mut cursor = args.walk(); + for arg in args.named_children(&mut cursor) { + last_handler = Some(arg); + } + if let Some(handler) = last_handler { + match handler.kind() { + "identifier" => { + if let Ok(name) = handler.utf8_text(bytes) { + out.by_name.insert(name.to_string(), method); + } + } + "arrow_function" | "function_expression" | "function_declaration" => { + out.by_span + .insert((handler.start_byte(), handler.end_byte()), method); + } + _ => {} + } + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_express_recursive(child, bytes, out); + } +} + +/// Recognise an Express-style `app.METHOD(...)` / `router.METHOD(...)` +/// call. Returns the matching [`HttpMethod`] when the call shape is +/// `.(...)` with `` an HTTP method AND the +/// receiver text looks like an Express app / router / route binding. +/// +/// The receiver allowlist (suffixes `app` / `router` / `route` and the +/// constructor calls `express()` / `Router()`) keeps non-Express +/// `.post(...)` shapes out of the entry-point set — e.g. an HTTP +/// client `client.post(url, body, cb)` whose last positional argument +/// happens to be a callback would otherwise be tagged as an +/// `EntryKind::ExpressRoute` and propagate the seeding policy onto +/// unrelated functions. +fn express_call_method(call_node: Node, bytes: &[u8]) -> Option { + let func = call_node.child_by_field_name("function")?; + if func.kind() != "member_expression" { + return None; + } + let prop = func.child_by_field_name("property")?; + let name = prop.utf8_text(bytes).ok()?; + let method = HttpMethod::from_ident(name)?; + let object = func.child_by_field_name("object")?; + if !express_receiver_text_matches(object, bytes) { + return None; + } + Some(method) +} + +/// Returns `true` when `object` looks like an Express app / router / +/// route binding. Accepted shapes: +/// * Identifier whose text is exactly `app` / `router` / `route` or +/// ends with one of those (e.g. `apiRouter`, `userApp`). +/// * Member expression whose property is `app` / `router` / `route` +/// (e.g. `this.router`, `module.exports.app`). +/// * Call expression whose callee is `express()` or `Router()` +/// (e.g. `express().get(...)`, `Router().post(...)`). +fn express_receiver_text_matches(object: Node, bytes: &[u8]) -> bool { + fn matches_suffix(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower == "app" + || lower == "router" + || lower == "route" + || lower.ends_with("app") + || lower.ends_with("router") + || lower.ends_with("route") + } + match object.kind() { + "identifier" | "property_identifier" => { + object.utf8_text(bytes).ok().is_some_and(matches_suffix) + } + "member_expression" => object + .child_by_field_name("property") + .and_then(|p| p.utf8_text(bytes).ok()) + .is_some_and(matches_suffix), + "call_expression" => { + // `express()` / `Router()` constructor inline. + let Some(callee) = object.child_by_field_name("function") else { + return false; + }; + let Ok(text) = callee.utf8_text(bytes) else { + return false; + }; + let leaf = text.rsplit('.').next().unwrap_or(text).trim(); + leaf == "express" || leaf == "Router" || leaf == "express.Router" + } + _ => false, + } +} + +// ───────────────────────────────────────────────────────────────────── +// Python — Django / FastAPI / Flask +// ───────────────────────────────────────────────────────────────────── + +fn detect_python(root: Node, bytes: &[u8]) -> HashMap<(usize, usize), EntryKind> { + let mut entries: HashMap<(usize, usize), EntryKind> = HashMap::new(); + walk_python(root, bytes, &mut |func_node, decorated_node| { + let span = (func_node.start_byte(), func_node.end_byte()); + + // FastAPI / Flask take the decorator as a `@app.(...)` + // call expression on the `decorated_definition` node. + if let Some(dec) = decorated_node + && let Some(kind) = python_decorator_entry_kind(dec, bytes) + { + entries.entry(span).or_insert(kind); + return; + } + + // Django class-based views: a method named `get`/`post`/... on + // a class derived from `View` / `APIView` / `ViewSet`. + if let Some(kind) = python_django_method_kind(func_node, bytes) { + entries.entry(span).or_insert(kind); + } + }); + entries +} + +fn walk_python<'a, F>(node: Node<'a>, _bytes: &'a [u8], visit: &mut F) +where + F: FnMut(Node<'a>, Option>), +{ + if node.kind() == "function_definition" { + let dec = node.parent().filter(|p| p.kind() == "decorated_definition"); + visit(node, dec); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_python(child, _bytes, visit); + } +} + +fn python_decorator_entry_kind(decorated: Node, bytes: &[u8]) -> Option { + let mut cursor = decorated.walk(); + for ch in decorated.children(&mut cursor) { + if ch.kind() != "decorator" { + continue; + } + let mut dw = ch.walk(); + let expr = ch.children(&mut dw).find(|c| c.kind() != "@")?; + // FastAPI / Flask shape: `@app.get(...)` / `@router.post("/")` + // / `@app.route("/", methods=["GET","POST"])`. + let (call_target, call_args) = match expr.kind() { + "call" => ( + expr.child_by_field_name("function"), + expr.child_by_field_name("arguments"), + ), + _ => (Some(expr), None), + }; + let Some(target) = call_target else { continue }; + if target.kind() != "attribute" { + continue; + } + let attr = target.child_by_field_name("attribute")?; + let attr_text = attr.utf8_text(bytes).ok()?; + let attr_lower = attr_text.to_ascii_lowercase(); + if let Some(method) = HttpMethod::from_ident(attr_text) { + return Some(EntryKind::FastApiRoute { method }); + } + if attr_lower == "route" { + // Flask `@app.route("/", methods=["POST"])` — extract + // first method from the methods kwarg, default GET. + let method = call_args + .and_then(|args| extract_flask_methods_arg(args, bytes)) + .unwrap_or(HttpMethod::GET); + return Some(EntryKind::FlaskRoute { method }); + } + if matches!( + attr_lower.as_str(), + "websocket" | "websocket_route" | "include_router" + ) { + // FastAPI websocket / Starlette WebSocket — treat as a + // FastApiRoute with GET so the same seeding policy + // applies. + return Some(EntryKind::FastApiRoute { + method: HttpMethod::GET, + }); + } + // Django REST framework `@api_view(['GET'])`: extract first + // method from the args list. + if attr_lower == "api_view" || attr_lower == "require_http_methods" { + let method = call_args + .and_then(|args| extract_first_method_in_list(args, bytes)) + .unwrap_or(HttpMethod::GET); + return Some(EntryKind::DjangoView { method }); + } + } + None +} + +fn extract_flask_methods_arg(args: Node, bytes: &[u8]) -> Option { + let mut cursor = args.walk(); + for arg in args.children(&mut cursor) { + if arg.kind() != "keyword_argument" { + continue; + } + let name_node = arg.child_by_field_name("name")?; + let Ok(name) = name_node.utf8_text(bytes) else { + continue; + }; + if name == "methods" { + let value = arg.child_by_field_name("value")?; + return extract_first_method_in_list(value, bytes); + } + } + None +} + +fn extract_first_method_in_list(node: Node, bytes: &[u8]) -> Option { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "string" { + let raw = child.utf8_text(bytes).ok()?; + let trimmed = raw + .trim() + .trim_start_matches(['\'', '"']) + .trim_end_matches(['\'', '"']); + if let Some(m) = HttpMethod::from_ident(trimmed) { + return Some(m); + } + } + } + None +} + +fn python_django_method_kind(func_node: Node, bytes: &[u8]) -> Option { + // Django CBV: function named one of the HTTP methods inside a + // `class_definition` whose superclass list mentions `View` / + // `APIView` / `ViewSet`. + let name_node = func_node.child_by_field_name("name")?; + let name = name_node.utf8_text(bytes).ok()?; + let method = HttpMethod::from_ident(name)?; + let class = enclosing_python_class(func_node)?; + let supers = class.child_by_field_name("superclasses")?; + let mut cursor = supers.walk(); + for sup in supers.named_children(&mut cursor) { + let text = sup.utf8_text(bytes).ok()?; + if text.contains("View") + || text.contains("APIView") + || text.contains("ViewSet") + || text.contains("TemplateView") + { + return Some(EntryKind::DjangoView { method }); + } + } + None +} + +fn enclosing_python_class<'a>(node: Node<'a>) -> Option> { + let mut cur = node.parent(); + while let Some(p) = cur { + if p.kind() == "class_definition" { + return Some(p); + } + cur = p.parent(); + } + None +} + +// ───────────────────────────────────────────────────────────────────── +// Java — Spring + JAX-RS +// ───────────────────────────────────────────────────────────────────── + +fn detect_java(root: Node, bytes: &[u8]) -> HashMap<(usize, usize), EntryKind> { + let mut entries: HashMap<(usize, usize), EntryKind> = HashMap::new(); + walk_java(root, bytes, &mut |method| { + let span = (method.start_byte(), method.end_byte()); + if let Some(kind) = java_method_entry_kind(method, bytes) { + entries.entry(span).or_insert(kind); + } + }); + entries +} + +fn walk_java<'a, F>(node: Node<'a>, _bytes: &'a [u8], visit: &mut F) +where + F: FnMut(Node<'a>), +{ + if node.kind() == "method_declaration" { + visit(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_java(child, _bytes, visit); + } +} + +fn java_method_entry_kind(method: Node, bytes: &[u8]) -> Option { + let modifiers = method.child_by_field_name("modifiers").or_else(|| { + let mut w = method.walk(); + method.children(&mut w).find(|c| c.kind() == "modifiers") + })?; + let mut cursor = modifiers.walk(); + for ch in modifiers.children(&mut cursor) { + match ch.kind() { + "marker_annotation" | "annotation" => { + let name_node = ch.child_by_field_name("name")?; + let name = name_node.utf8_text(bytes).ok()?; + if let Some(kind) = java_annotation_to_entry_kind(name, ch, bytes) { + return Some(kind); + } + } + _ => {} + } + } + None +} + +fn java_annotation_to_entry_kind(name: &str, annotation: Node, bytes: &[u8]) -> Option { + match name { + "RequestMapping" => { + // `@RequestMapping(method = RequestMethod.POST)` carries the + // verb on the `method` element-value-pair; default to GET when + // absent (Spring itself defaults to "all verbs", but GET is + // the safest single-method approximation for seeding policy). + let method = + extract_spring_request_mapping_method(annotation, bytes).unwrap_or(HttpMethod::GET); + Some(EntryKind::SpringMapping { method }) + } + "GetMapping" => Some(EntryKind::SpringMapping { + method: HttpMethod::GET, + }), + "PostMapping" => Some(EntryKind::SpringMapping { + method: HttpMethod::POST, + }), + "PutMapping" => Some(EntryKind::SpringMapping { + method: HttpMethod::PUT, + }), + "DeleteMapping" => Some(EntryKind::SpringMapping { + method: HttpMethod::DELETE, + }), + "PatchMapping" => Some(EntryKind::SpringMapping { + method: HttpMethod::PATCH, + }), + "Path" | "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" => { + Some(EntryKind::JaxRsResource) + } + _ => None, + } +} + +/// Extract `method = RequestMethod.` (or array form +/// `method = {RequestMethod.POST, RequestMethod.PUT}`, taking the first +/// entry) from a Java `@RequestMapping(...)` annotation node. +fn extract_spring_request_mapping_method(annotation: Node, bytes: &[u8]) -> Option { + let args = annotation.child_by_field_name("arguments")?; + let mut cursor = args.walk(); + for child in args.children(&mut cursor) { + if child.kind() != "element_value_pair" { + continue; + } + let key_node = child.child_by_field_name("key")?; + let key = key_node.utf8_text(bytes).ok()?; + if key != "method" { + continue; + } + let value = child.child_by_field_name("value")?; + if let Some(m) = http_method_from_request_method_text(value, bytes) { + return Some(m); + } + } + None +} + +/// Parse `RequestMethod.POST` (or its bare leaf `POST`) from an +/// `element_value` node. Falls through to scan an array initialiser +/// (`{RequestMethod.GET, RequestMethod.POST}`) and returns the first +/// recognised verb. +fn http_method_from_request_method_text(node: Node, bytes: &[u8]) -> Option { + let raw = node.utf8_text(bytes).ok()?; + let trimmed = raw.trim().trim_matches('{').trim_matches('}'); + for token in trimmed.split(',') { + let leaf = token.trim().rsplit('.').next().unwrap_or("").trim(); + if let Some(m) = HttpMethod::from_ident(leaf) { + return Some(m); + } + } + None +} + +// ───────────────────────────────────────────────────────────────────── +// Ruby — Rails + Sinatra +// ───────────────────────────────────────────────────────────────────── + +fn detect_ruby(root: Node, bytes: &[u8]) -> HashMap<(usize, usize), EntryKind> { + let mut entries: HashMap<(usize, usize), EntryKind> = HashMap::new(); + walk_ruby_methods(root, bytes, &mut |method| { + let span = (method.start_byte(), method.end_byte()); + if let Some(class) = enclosing_ruby_controller(method, bytes) { + let _ = class; + entries.entry(span).or_insert(EntryKind::RailsAction); + } + }); + walk_ruby_sinatra(root, bytes, &mut |block, method| { + let span = (block.start_byte(), block.end_byte()); + entries + .entry(span) + .or_insert(EntryKind::SinatraRoute { method }); + }); + entries +} + +fn walk_ruby_methods<'a, F>(node: Node<'a>, _bytes: &'a [u8], visit: &mut F) +where + F: FnMut(Node<'a>), +{ + if node.kind() == "method" { + visit(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_ruby_methods(child, _bytes, visit); + } +} + +fn enclosing_ruby_controller<'a>(node: Node<'a>, bytes: &'a [u8]) -> Option> { + let mut cur = node.parent(); + while let Some(p) = cur { + if p.kind() == "class" { + // Recognise any class extending an `*Controller` superclass + // (`ApplicationController`, `ActionController::Base`, etc.) + // OR a class whose own name ends in `Controller`. + if let Some(sup) = p.child_by_field_name("superclass") + && let Ok(text) = sup.utf8_text(bytes) + && text.contains("Controller") + { + return Some(p); + } + if let Some(name_node) = p.child_by_field_name("name") + && let Ok(name) = name_node.utf8_text(bytes) + && name.ends_with("Controller") + { + return Some(p); + } + } + cur = p.parent(); + } + None +} + +fn walk_ruby_sinatra<'a, F>(node: Node<'a>, bytes: &'a [u8], visit: &mut F) +where + F: FnMut(Node<'a>, HttpMethod), +{ + if node.kind() == "call" { + // Sinatra DSL: `get '/path' do |arg| ... end`. In tree-sitter-ruby + // `get '/x' do ... end` parses as a `call` whose `method` field is + // `get` and whose argument is the path. The `do` block is a + // sibling `do_block` child. + if let Some(method_node) = node.child_by_field_name("method") + && let Ok(method_text) = method_node.utf8_text(bytes) + && let Some(method) = HttpMethod::from_ident(method_text) + { + if let Some(block) = node.child_by_field_name("block") { + visit(block, method); + } else { + // Look for a sibling do_block child + let mut w = node.walk(); + for ch in node.children(&mut w) { + if ch.kind() == "do_block" || ch.kind() == "block" { + visit(ch, method); + } + } + } + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_ruby_sinatra(child, bytes, visit); + } +} + +// ───────────────────────────────────────────────────────────────────── +// Rust — axum / actix-web / rocket +// ───────────────────────────────────────────────────────────────────── + +fn detect_rust(root: Node, bytes: &[u8]) -> HashMap<(usize, usize), EntryKind> { + let mut entries: HashMap<(usize, usize), EntryKind> = HashMap::new(); + let file_text = std::str::from_utf8(bytes).unwrap_or(""); + let has_rocket_witness = file_text.contains("rocket::") + || file_text.contains("#[launch]") + || file_text.contains("rocket::build") + || file_text.contains("use rocket"); + let has_axum_witness = file_text.contains("axum::") + || file_text.contains("use axum") + || file_text.contains("axum::Router"); + walk_rust(root, bytes, &mut |func| { + let span = (func.start_byte(), func.end_byte()); + if let Some(kind) = + rust_function_entry_kind(func, bytes, has_rocket_witness, has_axum_witness) + { + entries.entry(span).or_insert(kind); + } + }); + entries +} + +fn walk_rust<'a, F>(node: Node<'a>, _bytes: &'a [u8], visit: &mut F) +where + F: FnMut(Node<'a>), +{ + if node.kind() == "function_item" { + visit(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_rust(child, _bytes, visit); + } +} + +fn rust_function_entry_kind( + func: Node, + bytes: &[u8], + has_rocket_witness: bool, + has_axum_witness: bool, +) -> Option { + // 1. macro-attribute recognition: `#[get("/x")]` / `#[post(...)]` / + // `#[route(GET, "/x")]` etc. + let attrs_text = collect_rust_attribute_text(func, bytes); + let has_routing_attr = attrs_text.iter().any(|s| { + let t = s.trim_start_matches(['#', '!', '[']); + t.starts_with("get(") + || t.starts_with("post(") + || t.starts_with("put(") + || t.starts_with("delete(") + || t.starts_with("patch(") + || t.starts_with("head(") + || t.starts_with("options(") + || t.starts_with("route(") + || t.starts_with("connect(") + || t.starts_with("trace(") + }); + if has_routing_attr { + if has_rocket_witness { + return Some(EntryKind::RocketRoute); + } + return Some(EntryKind::ActixHandler); + } + + // 2. axum handler: signature contains an axum extractor type. + if has_axum_witness && rust_signature_has_axum_extractor(func, bytes) { + return Some(EntryKind::AxumHandler); + } + + None +} + +fn collect_rust_attribute_text(func: Node, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut harvest = |node: Node<'_>| { + if let Ok(text) = node.utf8_text(bytes) { + out.push(text.to_string()); + } + }; + let mut w = func.walk(); + for ch in func.children(&mut w) { + if ch.kind() == "attribute_item" || ch.kind() == "inner_attribute_item" { + // get the inside `attribute` so we have just the call shape. + let mut aw = ch.walk(); + for inner in ch.children(&mut aw) { + if inner.kind() == "attribute" { + harvest(inner); + } + } + } + } + if let Some(parent) = func.parent() { + let mut pw = parent.walk(); + let mut pending: Vec> = Vec::new(); + for sib in parent.children(&mut pw) { + if sib.id() == func.id() { + for p in &pending { + let mut aw = p.walk(); + for inner in p.children(&mut aw) { + if inner.kind() == "attribute" { + harvest(inner); + } + } + } + break; + } + if sib.kind() == "attribute_item" || sib.kind() == "inner_attribute_item" { + pending.push(sib); + } else { + pending.clear(); + } + } + } + out +} + +fn rust_signature_has_axum_extractor(func: Node, bytes: &[u8]) -> bool { + let Some(params) = func.child_by_field_name("parameters") else { + return false; + }; + let Ok(text) = params.utf8_text(bytes) else { + return false; + }; + // Conservative substring scan against known axum extractor types. + let needles = [ + "Query<", + "Json<", + "Path<", + "Form<", + "Extension<", + "State<", + "TypedHeader<", + "Multipart", + "HeaderMap", + "Request<", + "Body", + "WebSocketUpgrade", + ]; + needles.iter().any(|n| text.contains(n)) +} + +// ───────────────────────────────────────────────────────────────────── +// Go — net/http + gin / echo / chi +// ───────────────────────────────────────────────────────────────────── + +fn detect_go(root: Node, bytes: &[u8]) -> HashMap<(usize, usize), EntryKind> { + let mut entries: HashMap<(usize, usize), EntryKind> = HashMap::new(); + walk_go(root, bytes, &mut |func| { + let span = (func.start_byte(), func.end_byte()); + if let Some(kind) = go_function_entry_kind(func, bytes) { + entries.entry(span).or_insert(kind); + } + }); + entries +} + +fn walk_go<'a, F>(node: Node<'a>, _bytes: &'a [u8], visit: &mut F) +where + F: FnMut(Node<'a>), +{ + if matches!( + node.kind(), + "function_declaration" | "method_declaration" | "func_literal" + ) { + visit(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_go(child, _bytes, visit); + } +} + +fn go_function_entry_kind(func: Node, bytes: &[u8]) -> Option { + let params = func.child_by_field_name("parameters")?; + let Ok(text) = params.utf8_text(bytes) else { + return None; + }; + // net/http: signature ends with `*http.Request` (with or without the + // leading `http.ResponseWriter` writer arg). + if text.contains("http.Request") || text.contains("*http.Request") { + return Some(EntryKind::GoNetHttp); + } + // gin: `*gin.Context`; echo: `echo.Context`; chi (passes std http + // handler); fiber: `*fiber.Ctx`. + if text.contains("gin.Context") + || text.contains("echo.Context") + || text.contains("fiber.Ctx") + || text.contains("iris.Context") + { + return Some(EntryKind::GinRoute); + } + None +} + +// ───────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn detect_lang(source: &str, lang: &str, path: &str) -> HashMap<(usize, usize), EntryKind> { + let mut parser = tree_sitter::Parser::new(); + let language = match lang { + "javascript" => tree_sitter_javascript::LANGUAGE.into(), + "typescript" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + "tsx" => tree_sitter_typescript::LANGUAGE_TSX.into(), + "python" => tree_sitter_python::LANGUAGE.into(), + "java" => tree_sitter_java::LANGUAGE.into(), + "ruby" => tree_sitter_ruby::LANGUAGE.into(), + "rust" => tree_sitter_rust::LANGUAGE.into(), + "go" => tree_sitter_go::LANGUAGE.into(), + _ => panic!("unknown lang"), + }; + // The production [`detect_entries_in_file`] dispatch tags `.tsx` + // files with `lang_slug = "typescript"`, so the test helper does + // the same once the parser is configured with the TSX grammar. + let detect_slug = match lang { + "tsx" => "typescript", + other => other, + }; + parser.set_language(&language).unwrap(); + let tree = parser.parse(source, None).unwrap(); + detect_entries_in_file(&tree, source.as_bytes(), Path::new(path), detect_slug) + } + + #[test] + fn detects_python_fastapi_route() { + let src = r#" +from fastapi import FastAPI + +app = FastAPI() + +@app.get("/items/{id}") +async def read_item(id: str): + return {"id": id} +"#; + let entries = detect_lang(src, "python", "main.py"); + assert!( + entries.values().any(|e| matches!( + e, + EntryKind::FastApiRoute { + method: HttpMethod::GET + } + )), + "expected FastApiRoute(GET); got {entries:?}" + ); + } + + #[test] + fn detects_python_flask_route() { + let src = r#" +from flask import Flask + +app = Flask(__name__) + +@app.route("/submit", methods=["POST"]) +def submit(name): + return name +"#; + let entries = detect_lang(src, "python", "app.py"); + assert!( + entries.values().any(|e| matches!( + e, + EntryKind::FlaskRoute { + method: HttpMethod::POST + } + )), + "expected FlaskRoute(POST); got {entries:?}" + ); + } + + #[test] + fn detects_python_django_class_view() { + let src = r#" +from django.views import View + +class MyView(View): + def get(self, request): + return None +"#; + let entries = detect_lang(src, "python", "views.py"); + assert!( + entries + .values() + .any(|e| matches!(e, EntryKind::DjangoView { .. })), + "expected DjangoView; got {entries:?}" + ); + } + + #[test] + fn detects_java_spring_get() { + let src = r#" +package x; + +import org.springframework.web.bind.annotation.GetMapping; + +public class X { + @GetMapping("/u") + public String u(String n) { return n; } +} +"#; + let entries = detect_lang(src, "java", "X.java"); + assert!( + entries.values().any(|e| matches!( + e, + EntryKind::SpringMapping { + method: HttpMethod::GET + } + )), + "expected SpringMapping(GET); got {entries:?}" + ); + } + + #[test] + fn detects_java_jaxrs_path() { + let src = r#" +package x; + +import javax.ws.rs.Path; + +public class X { + @Path("/u") + public String u(String n) { return n; } +} +"#; + let entries = detect_lang(src, "java", "X.java"); + assert!( + entries + .values() + .any(|e| matches!(e, EntryKind::JaxRsResource)), + "expected JaxRsResource; got {entries:?}" + ); + } + + #[test] + fn detects_ruby_rails_action() { + let src = r#" +class UsersController < ApplicationController + def show + @user = User.find(params[:id]) + end +end +"#; + let entries = detect_lang(src, "ruby", "users_controller.rb"); + assert!( + entries + .values() + .any(|e| matches!(e, EntryKind::RailsAction)), + "expected RailsAction; got {entries:?}" + ); + } + + #[test] + fn detects_ruby_sinatra_route() { + let src = r#" +require 'sinatra' + +get '/hello' do |name| + "Hello #{name}" +end +"#; + let entries = detect_lang(src, "ruby", "app.rb"); + assert!( + entries.values().any(|e| matches!( + e, + EntryKind::SinatraRoute { + method: HttpMethod::GET + } + )), + "expected SinatraRoute(GET); got {entries:?}" + ); + } + + #[test] + fn detects_rust_actix_handler() { + let src = r#" +use actix_web::{get, web, HttpResponse}; + +#[get("/u/{name}")] +async fn u(name: web::Path) -> HttpResponse { + HttpResponse::Ok().body(name.into_inner()) +} +"#; + let entries = detect_lang(src, "rust", "u.rs"); + assert!( + entries + .values() + .any(|e| matches!(e, EntryKind::ActixHandler)), + "expected ActixHandler; got {entries:?}" + ); + } + + #[test] + fn detects_rust_axum_handler_via_extractor() { + let src = r#" +use axum::{extract::Query, Router}; + +async fn list(Query(q): Query) -> String { + q +} +"#; + let entries = detect_lang(src, "rust", "list.rs"); + assert!( + entries + .values() + .any(|e| matches!(e, EntryKind::AxumHandler)), + "expected AxumHandler; got {entries:?}" + ); + } + + #[test] + fn detects_go_net_http_handler() { + let src = r#" +package main + +import "net/http" + +func handler(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hi")) +} +"#; + let entries = detect_lang(src, "go", "main.go"); + assert!( + entries.values().any(|e| matches!(e, EntryKind::GoNetHttp)), + "expected GoNetHttp; got {entries:?}" + ); + } + + #[test] + fn detects_go_gin_handler() { + let src = r#" +package main + +import "github.com/gin-gonic/gin" + +func handler(c *gin.Context) { + c.String(200, "hi") +} +"#; + let entries = detect_lang(src, "go", "main.go"); + assert!( + entries.values().any(|e| matches!(e, EntryKind::GinRoute)), + "expected GinRoute; got {entries:?}" + ); + } + + #[test] + fn detects_express_route_named_handler() { + let src = r#" +const express = require('express'); +const app = express(); + +function getUser(req, res) { + res.send('hi'); +} + +app.get('/u', getUser); +"#; + let entries = detect_lang(src, "javascript", "server.js"); + assert!( + entries.values().any(|e| matches!( + e, + EntryKind::ExpressRoute { + method: HttpMethod::GET + } + )), + "expected ExpressRoute(GET); got {entries:?}" + ); + } + + #[test] + fn detects_express_route_arrow_handler() { + let src = r#" +const express = require('express'); +const app = express(); +app.post('/submit', (req, res) => { + res.send(req.body.name); +}); +"#; + let entries = detect_lang(src, "javascript", "server.js"); + assert!( + entries.values().any(|e| matches!( + e, + EntryKind::ExpressRoute { + method: HttpMethod::POST + } + )), + "expected ExpressRoute(POST); got {entries:?}" + ); + } + + #[test] + fn detects_form_action_named_handler() { + let src = r#" +async function submit(formData) { + return formData; +} + +export default function Page() { + return ; +} +"#; + let entries = detect_lang(src, "tsx", "app/page.tsx"); + assert!( + entries.values().any(|e| matches!(e, EntryKind::FormAction)), + "expected FormAction; got {entries:?}" + ); + } + + #[test] + fn detects_form_action_inline_arrow() { + let src = r#" +export default function Page() { + return
    { return formData; }} />; +} +"#; + let entries = detect_lang(src, "tsx", "app/page.tsx"); + assert!( + entries.values().any(|e| matches!(e, EntryKind::FormAction)), + "expected FormAction for inline arrow; got {entries:?}" + ); + } + + #[test] + fn ignores_form_action_string_url() { + // `` is a URL form-post target, not + // a server-action callee; no function should be tagged. + let src = r#" +async function submit(formData) { + return formData; +} + +export default function Page() { + return ; +} +"#; + let entries = detect_lang(src, "tsx", "app/page.tsx"); + assert!( + !entries.values().any(|e| matches!(e, EntryKind::FormAction)), + "string action URL must not tag any function; got {entries:?}" + ); + } + + #[test] + fn ignores_component_form_action() { + // `` is a user component, not the HTML host + // form element; framework semantics for `action` are + // component-defined, so the recogniser must not tag `fn`. + let src = r#" +import { Form } from "./form"; + +async function submit(formData) { + return formData; +} + +export default function Page() { + return ; +} +"#; + let entries = detect_lang(src, "tsx", "app/page.tsx"); + assert!( + !entries.values().any(|e| matches!(e, EntryKind::FormAction)), + "component-form action must not tag any function; got {entries:?}" + ); + } + + #[test] + fn ignores_form_without_action_attr() { + let src = r#" +async function submit(formData) { + return formData; +} + +export default function Page() { + return ; +} +"#; + let entries = detect_lang(src, "tsx", "app/page.tsx"); + assert!( + !entries.values().any(|e| matches!(e, EntryKind::FormAction)), + "form without action attr must not tag any function; got {entries:?}" + ); + } + + /// Regression: phase 10 fixture must still detect the `'use server'` + /// directive at the file level so existing nextjs_entrypoints test + /// stays green. + #[test] + fn regression_use_server_file_level() { + let src = r#" +"use server"; + +export async function submit(userId) { + return userId; +} +"#; + let entries = detect_lang(src, "javascript", "actions.ts"); + assert!( + entries + .values() + .any(|e| matches!(e, EntryKind::UseServerDirective)), + "expected UseServerDirective; got {entries:?}" + ); + } +} diff --git a/src/labels/go.rs b/src/labels/go.rs index 043eed40..ceb3a19a 100644 --- a/src/labels/go.rs +++ b/src/labels/go.rs @@ -73,6 +73,27 @@ pub static RULES: &[LabelRule] = &[ "db.Exec", "db.QueryRow", "db.Prepare", + // Phase 15 — GORM `db.Raw(sql)` raw-SQL passthrough. GORM's + // `*gorm.DB` is conventionally bound to a `db`-named receiver, + // so the suffix `db.Raw` carries the GORM semantic without + // colliding with stdlib `*sql.DB` (which has no `Raw` method). + // The `GormDb.Raw` type-qualified variant in the receiver-typed + // rule list below covers receivers tagged from `gorm.Open(...)` + // with non-`db` names. + "db.Raw", + // Phase 15 — `database/sql`-context variants. `db.QueryContext`, + // `db.ExecContext`, `db.QueryRowContext`, `db.PrepareContext` + // accept the SQL string at arg 1 (after `ctx`). Receivers + // typed as `*sql.DB` / `*sql.Tx` / `*sql.Stmt` resolve via + // suffix-matching on `db.`; calls on differently-named + // bound receivers (`tx.QueryContext(...)`) only suffix-match + // when the receiver text ends with `db` (covers `userDb`, + // `pgDb`, etc.). More-precise receiver typing is in scope + // for `DatabaseConnection.` rules below. + "db.QueryContext", + "db.ExecContext", + "db.QueryRowContext", + "db.PrepareContext", // goqu raw SQL literal builders: `goqu.L(s)` and the alias // `goqu.Lit(s)` insert `s` verbatim into the generated SQL with no // parameterisation. CVE-2026-41422 (daptin) loops a user-controlled @@ -88,6 +109,36 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sink(Cap::SQL_QUERY), case_sensitive: false, }, + // Phase 15 — receiver-typed Go ORM/raw-SQL sinks. `*gorm.DB` (set by + // `constructor_type` for `gorm.Open(...)`) exposes `Raw(sql)` and + // `Exec(sql)` as raw-SQL passthrough; the type-qualified resolver + // rewrites `db.Raw(...)` → `GormDb.Raw`. `*sqlx.DB` likewise gets + // `NamedExec` / `NamedQuery` / `Select` / `Get` rewriting via + // `SqlxDb.`. `DatabaseConnection.` covers the stdlib + // `*sql.DB` / `*sql.Tx` receivers tagged by the existing + // `sql.Open` / `sql.OpenDB` constructor mapping — currently the + // chained QueryContext shape suffix-matches `db.QueryContext` above, + // so `DatabaseConnection.QueryContext` is here for receivers whose + // identifier text doesn't end in `db`. + LabelRule { + matchers: &[ + "GormDb.Raw", + "GormDb.Exec", + "SqlxDb.NamedExec", + "SqlxDb.NamedQuery", + "SqlxDb.Select", + "SqlxDb.Get", + "SqlxDb.MustExec", + "DatabaseConnection.QueryContext", + "DatabaseConnection.ExecContext", + "DatabaseConnection.QueryRowContext", + "DatabaseConnection.Query", + "DatabaseConnection.Exec", + "DatabaseConnection.QueryRow", + ], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + }, // fmt.Printf/Sprintf write to stdout or build strings in memory, not // security sinks. fmt.Fprintf writes to an io.Writer (often http.ResponseWriter) // so it IS a security sink for XSS. @@ -576,6 +627,363 @@ pub static GATED_SINKS: &[SinkGate] = &[ object_destination_fields: &[], }, }, + // ── SQL execute payload-arg gating (Phase 15 deferred fix, Go) ──────── + // + // Mirrors the Python resolution recorded in `python::GATED_SINKS`. The + // flat rules above already classify these callees as `Sink(SQL_QUERY)` + // on every argument. `database/sql` and the Go ORM/raw-SQL ecosystem + // (GORM, sqlx, goqu) follow the convention that the SQL string is at + // arg 0 (or arg 1 for the `*Context` variants whose first arg is a + // `context.Context`); subsequent positional arguments are bind values + // sent through the driver's parameterised path. Tainted bind values + // are SAFE; tainted SQL is the SQLi vector. + // + // Destination-activation gates carry the same `Sink(SQL_QUERY)` label + // as the flat rule (cap dedupes against the flat label) and propagate + // `payload_args: &[0]` (or `&[1]` for `*Context` shapes) into + // `sink_payload_args`, narrowing the SSA sink scan to the SQL position. + SinkGate { + callee_matcher: "db.Query", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "db.Exec", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "db.QueryRow", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "db.Prepare", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "db.Raw", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + // `*Context` variants take `ctx` at arg 0 and the SQL string at arg 1. + SinkGate { + callee_matcher: "db.QueryContext", + arg_index: 1, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[1], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "db.ExecContext", + arg_index: 1, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[1], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "db.QueryRowContext", + arg_index: 1, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[1], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "db.PrepareContext", + arg_index: 1, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[1], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + // goqu raw SQL literal builders. Single arg, payload at 0. + SinkGate { + callee_matcher: "goqu.L", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "goqu.Lit", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + // Receiver-typed (case-sensitive, matching the flat rule): GORM / sqlx + // / `*sql.DB` typed via `constructor_type`. All take SQL at arg 0 + // EXCEPT the `*Context` variants on `DatabaseConnection`, which take + // SQL at arg 1. + SinkGate { + callee_matcher: "GormDb.Raw", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "GormDb.Exec", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "SqlxDb.NamedExec", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "SqlxDb.NamedQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "SqlxDb.Select", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "SqlxDb.Get", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "SqlxDb.MustExec", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.Query", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.Exec", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.QueryRow", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.QueryContext", + arg_index: 1, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[1], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.ExecContext", + arg_index: 1, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[1], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.QueryRowContext", + arg_index: 1, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[1], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, ]; pub static KINDS: Map<&'static str, Kind> = phf_map! { diff --git a/src/labels/java.rs b/src/labels/java.rs index 72176e96..9064915d 100644 --- a/src/labels/java.rs +++ b/src/labels/java.rs @@ -94,6 +94,21 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sanitizer(Cap::SQL_QUERY), case_sensitive: false, }, + // Phase 15 — JPA / Hibernate `Query.setParameter(name, value)` / + // `Query.setParameterList(...)` bind a positional / named parameter + // and return the same query object. The bind step does NOT inject + // the value into the SQL string; the value is sent as a separate + // parameter through the JDBC layer at execution. Treating + // `setParameter` / `setParameterList` as a SQL_QUERY sanitizer + // clears any taint inadvertently smeared onto the chain return so + // downstream `.getResultList()` / `.executeUpdate()` calls see a + // clean value. Case-sensitive: these are JPA-specific verb names + // and the chain shape is canonical. + LabelRule { + matchers: &["setParameter", "setParameterList"], + label: DataLabel::Sanitizer(Cap::SQL_QUERY), + case_sensitive: true, + }, // ─────────── Sinks ───────────── LabelRule { matchers: &["Runtime.exec", "ProcessBuilder"], @@ -125,6 +140,72 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sink(Cap::CODE_EXEC), case_sensitive: false, }, + // Phase 13 — java.nio.file path-traversal sinks. `Files.` is + // the modern stdlib API for read/write/copy/move/delete operations; + // each takes a `Path` (or `Path` + payload) as arg 0. Default + // arg→return propagation smears taint through `Paths.get(...)` + // (forwarder) so the path arg of these calls inherits any taint + // present on the components. `FileInputStream` / `FileOutputStream` / + // `RandomAccessFile` are constructor-style sinks: `new + // FileInputStream(path)` reaches the FILE_IO sink at the + // `object_creation_expression` level (mapped to `Kind::CallFn` in + // Java's KINDS). Receiver-typing already maps these classes to + // `TypeKind::FileHandle` (see `class_name_to_type_kind`) so chained + // method calls on the resulting handle resolve via type-qualified + // labels, but the construction call itself is the canonical + // path-traversal vector. + LabelRule { + matchers: &[ + "Files.readString", + "Files.readAllBytes", + "Files.readAllLines", + "Files.write", + "Files.writeString", + "Files.lines", + "Files.copy", + "Files.move", + "Files.delete", + "Files.deleteIfExists", + "Files.newInputStream", + "Files.newOutputStream", + "Files.newBufferedReader", + "Files.newBufferedWriter", + "FileInputStream", + "FileOutputStream", + "RandomAccessFile", + ], + label: DataLabel::Sink(Cap::FILE_IO), + case_sensitive: true, + }, + // Phase 13 — `Path.normalize()` collapses `.` / `..` segments and + // is the canonical Java path-traversal sanitiser when paired with + // a `startsWith(base)` containment check (not modelled here; the + // sanitiser rule clears the FILE_IO cap on the call's return, + // which is sufficient for the cap-based gate to suppress the + // sink finding). Case-sensitive: `Path.normalize` is unique to + // `java.nio.file.Path`; bare `normalize` would over-fire on + // `Locale.normalize`, `BigDecimal.normalize`, etc. + LabelRule { + matchers: &[ + "Path.normalize", + // Canonical Java path-traversal sanitiser idiom: + // `base.resolve(name).normalize()`. CFG paren-strip yields + // callee text `.resolve.normalize`; the bare 2-call + // `resolve.normalize` suffix is unique to `java.nio.file.Path` + // (no overload across the supported corpus produces the same + // chain text). Case-sensitive on the leaf chain to avoid + // colliding with non-path `.resolve()`-then-`.normalize()` + // shapes in unrelated grammars. + "resolve.normalize", + // Receiver-bound shape `Paths.get(p).normalize()` — the + // `Paths.get` constructor mapping in `ssa/type_facts.rs` types + // the receiver as `FileHandle`, so the type-qualified resolver + // rewrites `.normalize` → `FileHandle.normalize` here. + "FileHandle.normalize", + ], + label: DataLabel::Sanitizer(Cap::FILE_IO), + case_sensitive: true, + }, // HTTP response sinks, println/print are broad (also match System.out) // but necessary to catch response.getWriter().println() via suffix matching. LabelRule { @@ -134,12 +215,34 @@ pub static RULES: &[LabelRule] = &[ }, // openConnection() is the standard java.net.URL API for initiating a connection. // It is the correct interception point, the URL is already set on the object. + // + // Phase 14 — additional SSRF entry points covered: + // * `URL.openStream` — equivalent of `URL.openConnection().getInputStream()`, + // fetches the resource at the URL directly. Bare `openStream` + // suffix is unique to `java.net.URL` in the supported corpus. + // * `OkHttpClient.newCall(Request)` — Square OkHttp's request + // dispatch entry point. The `Request` is built via a + // `Request.Builder().url(u).build()` chain whose default + // arg→return propagation smears URL taint through the chain. + // * `RestTemplate.getForEntity` / `RestTemplate.headForHeaders` — + // read-shaped Spring verbs that take the URL at arg 0. LabelRule { matchers: &[ "openConnection", + "openStream", "HttpClient.send", "HttpClient.sendAsync", + // Phase 14 — `OkHttpClient.newCall(Request)` and the + // generic `HttpClient.newCall` form OkHttp resolves to via + // the JAVA_HIERARCHY (OkHttpClient → HttpClient). Both + // forms are covered so a constructor-typed receiver + // (HttpClient) and a class-named receiver (OkHttpClient) + // both fire. + "HttpClient.newCall", + "OkHttpClient.newCall", "getForObject", + "getForEntity", + "headForHeaders", "RestTemplate.exchange", "postForObject", "postForEntity", @@ -246,8 +349,34 @@ pub static RULES: &[LabelRule] = &[ matchers: &[ "entityManager.createNativeQuery", "entityManager.createQuery", + "em.createNativeQuery", + "em.createQuery", "session.createQuery", "session.createSQLQuery", + "session.createNativeQuery", + // Phase 15 — Spring Data JPA / Hibernate factory chains: + // `getEntityManager().createNativeQuery(...)` / + // `getSession().createQuery(...)` reduce to + // `getEntityManager.createNativeQuery` / + // `getSession.createQuery` after the chain-normalisation + // strips parens. + "getEntityManager.createNativeQuery", + "getEntityManager.createQuery", + "getSession.createQuery", + "getSession.createSQLQuery", + "getSession.createNativeQuery", + // Type-qualified Hibernate Session matchers fire when the + // receiver carries a `TypeKind::HibernateSession` fact (set + // by `constructor_type` for `sessionFactory.openSession()` / + // `sessionFactory.getCurrentSession()` / + // `sessionFactory.openStatelessSession()` returns). Closes + // the arbitrary-receiver-name shape (`sess`, + // `hibernateSession`, etc.) the flat `session.*` matchers + // above only catch when receiver is literally named + // `session`. + "HibernateSession.createQuery", + "HibernateSession.createSQLQuery", + "HibernateSession.createNativeQuery", ], label: DataLabel::Sink(Cap::SQL_QUERY), case_sensitive: true, @@ -484,6 +613,385 @@ pub static GATED_SINKS: &[SinkGate] = &[ object_destination_fields: &[], }, }, + // ── SQL execute payload-arg gating (Phase 15 deferred fix, Java) ────── + // + // Mirrors the Python resolution recorded in `python::GATED_SINKS`: the + // flat rules above already classify these callees as `Sink(SQL_QUERY)` + // on every argument. The JDBC / JPA / Hibernate / Spring conventions + // are that arg 0 is the SQL template (or HQL/JPQL string) and any + // remaining arguments are bind values, RowMappers, result-set classes, + // or other non-SQL payloads. Tainted bind values are SAFE because the + // driver / JPA layer escapes them; tainted SQL is the SQLi vector. + // + // These Destination-activation gates carry the same `Sink(SQL_QUERY)` + // label as the flat rule (so cap dedupes against the flat label) but + // propagate `payload_args: &[0]` into `sink_payload_args`, narrowing the + // SSA sink scan to arg 0 only. Receiver-typed `DatabaseConnection.*` + // forms are case-sensitive, matching the flat rule. + SinkGate { + callee_matcher: "executeQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "executeUpdate", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.executeBatch", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DatabaseConnection.executeLargeUpdate", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + // Spring JdbcTemplate verbs. All take SQL at arg 0; remaining args are + // bind values (`Object[]` / varargs) or `RowMapper` / `ResultSetExtractor` + // / class hints — all non-SQL payloads. + SinkGate { + callee_matcher: "jdbcTemplate.query", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "jdbcTemplate.update", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "jdbcTemplate.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "jdbcTemplate.queryForObject", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "jdbcTemplate.queryForList", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + // JPA / Hibernate factories. `createQuery(sql)` / `createQuery(sql, ResultClass)` + // both take the SQL/JPQL/HQL string at arg 0; the optional `ResultClass` + // at arg 1 is metadata, not SQL. + SinkGate { + callee_matcher: "entityManager.createQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "entityManager.createNativeQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "em.createQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "em.createNativeQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "session.createQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "session.createSQLQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "session.createNativeQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "getEntityManager.createQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "getEntityManager.createNativeQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "getSession.createQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "getSession.createSQLQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "getSession.createNativeQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + // Type-qualified Hibernate Session gates. Mirror the + // `session.create*` family above so type-qualified resolution at + // sink-firing time consults `payload_args = &[0]` and suppresses + // tainted bind-arg shapes that route through `setParameter` / + // `setString` rather than the raw query string. Receivers carry + // `TypeKind::HibernateSession` via `constructor_type`'s + // `openSession` / `getCurrentSession` / `openStatelessSession` + // arms. + SinkGate { + callee_matcher: "HibernateSession.createQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "HibernateSession.createSQLQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "HibernateSession.createNativeQuery", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + 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! { diff --git a/src/labels/javascript.rs b/src/labels/javascript.rs index 1ebe5b3d..fae5a878 100644 --- a/src/labels/javascript.rs +++ b/src/labels/javascript.rs @@ -1,5 +1,6 @@ use crate::labels::{ - Cap, DataLabel, GateActivation, Kind, LabelRule, ParamConfig, RuntimeLabelRule, SinkGate, + Cap, DataLabel, GateActivation, GatedLabelRule, Kind, LabelGate, LabelRule, ParamConfig, + RuntimeLabelRule, SinkGate, }; use crate::utils::project::{DetectedFramework, FrameworkContext}; use phf::{Map, phf_map}; @@ -29,6 +30,21 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Source(Cap::all()), case_sensitive: false, }, + // Phase 10 — Web `Request` receiver-method reads. Triggered when + // the SSA receiver carries `TypeKind::Request` and the + // type-qualified resolver rewrites `req.json()` → `Request.json` + // etc. Mirrors the matching list in `labels/typescript.rs`. + LabelRule { + matchers: &[ + "Request.json", + "Request.formData", + "Request.text", + "Request.url", + "Request.headers.get", + ], + label: DataLabel::Source(Cap::all()), + case_sensitive: true, + }, // ───────── Sanitizers ────────── LabelRule { matchers: &["JSON.parse"], @@ -253,6 +269,40 @@ pub static RULES: &[LabelRule] = &[ "fs.unlinkSync", "fs.readdir", "fs.readdirSync", + // Phase 05 — `node:fs/promises` member-access forms covered + // here. Bare-name forms (`readFile`, `open`, ...) and + // `fsp.readFile` namespace-import forms ride the gated + // matcher in `GATED_LABEL_RULES`. Receiver-type fallback + // synthesises `FileSystemPromisesNs.` (handled + // below). + "fs.promises.readFile", + "fs.promises.writeFile", + "fs.promises.unlink", + "fs.promises.open", + "fs.promises.stat", + "fs.promises.readdir", + "fs.promises.mkdir", + "fs.promises.rmdir", + "fs.promises.rm", + "fs.promises.appendFile", + "fs.promises.copyFile", + "fs.promises.rename", + "fs.promises.truncate", + "fs.promises.chmod", + "FileSystemPromisesNs.readFile", + "FileSystemPromisesNs.writeFile", + "FileSystemPromisesNs.unlink", + "FileSystemPromisesNs.open", + "FileSystemPromisesNs.stat", + "FileSystemPromisesNs.readdir", + "FileSystemPromisesNs.mkdir", + "FileSystemPromisesNs.rmdir", + "FileSystemPromisesNs.rm", + "FileSystemPromisesNs.appendFile", + "FileSystemPromisesNs.copyFile", + "FileSystemPromisesNs.rename", + "FileSystemPromisesNs.truncate", + "FileSystemPromisesNs.chmod", ], label: DataLabel::Sink(Cap::FILE_IO), case_sensitive: false, @@ -310,6 +360,31 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sink(Cap::SQL_QUERY), case_sensitive: true, }, + // ── Phase 07 — ORM query-builder receiver-typed sinks ── + // + // Each rule here matches a callee text constructed by + // `resolve_type_qualified_labels` when a value's inferred TypeKind has a + // `label_prefix()`. The matcher form `.` is the + // wire shape produced by that helper. The receiver TypeKinds + // themselves are populated by [`crate::ssa::type_facts::constructor_type`] + // (TS/JS branch): `new Sequelize(...)` → `Sequelize`, + // `getRepository(Entity)` → `TypeOrmRepo`, + // `getManager()` → `TypeOrmManager`, + // `createEntityManager()` → `MikroOrmEm`. Without a typed receiver the + // qualified callee text is never built, so these rules cannot misfire on + // unrelated `.literal()` / `.query()` / `.execute()` methods. + LabelRule { + matchers: &[ + "Sequelize.literal", + "TypeOrmRepo.query", + "TypeOrmRepo.createQueryBuilder", + "TypeOrmManager.query", + "TypeOrmManager.createQueryBuilder", + "MikroOrmEm.execute", + ], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + }, // ─── LDAP injection sinks ─── // // `ldapjs`: both the bound-variable idiom @@ -527,6 +602,75 @@ pub static EXCLUDES: &[&str] = &[ "exec.start", ]; +/// Phase 05 — `node:fs/promises` path-traversal sinks. The matcher list +/// holds the bare-name and `.` member-access shapes; the +/// [`LabelGate::ImportedFromModule`] gate suppresses bare-name matches +/// unless the file actually imports the method from `node:fs/promises` +/// or `fs/promises`. Bare-name only — `fs.promises.readFile`-style +/// member-access forms continue to fire via the flat FILE_IO matcher +/// list (no gate needed because the `fs.promises.` prefix is itself +/// witness to the resolution). +pub static GATED_LABEL_RULES: &[GatedLabelRule] = &[ + GatedLabelRule { + matchers: &[ + "readFile", + "writeFile", + "unlink", + "open", + "stat", + "readdir", + "mkdir", + "rmdir", + "rm", + "appendFile", + "copyFile", + "rename", + "truncate", + "chmod", + ], + label: DataLabel::Sink(Cap::FILE_IO), + case_sensitive: false, + gate: LabelGate::ImportedFromModule(&["node:fs/promises", "fs/promises"]), + }, + // Phase 07 — Knex bare-name raw-SQL escape hatches. The receiver in + // `db.whereRaw(sql)` shape is an arbitrary local binding (`db`, `qb`, + // `users`, ...) so leading-identifier gating cannot witness the + // import. Phase 07 deferred-item 10 tightening: require the file to + // bind the conventional value-import name `knex` (lowercase) so that + // type-only shapes like `import { Knex } from 'knex'` (for + // `Knex.QueryBuilder` type annotations) do not over-fire the gate. + GatedLabelRule { + matchers: &["whereRaw", "orderByRaw", "havingRaw"], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + gate: LabelGate::FileImportsModuleAsLocalName { + modules: &["knex"], + local_names: &["knex"], + }, + }, + // Phase 07 — Drizzle `sql` template-tag builder. Two shapes: + // - `sql.raw(x)` → callee text "sql.raw" (member call) + // - `sql\`SELECT ${x}\`` → callee text "sql" (tag call) + // Both leading-identifier-gate against the imported `sql` symbol from + // `drizzle-orm`. `=sql` is exact-only so unrelated `.sql()` methods do + // not collide; `sql.raw` carries its own member-access matcher. + GatedLabelRule { + matchers: &["=sql", "sql.raw"], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + gate: LabelGate::ImportedFromModule(&["drizzle-orm"]), + }, + // Phase 10 — Next.js `cookies()` / `headers()` from `next/headers` + // return adversary-controlled request-bound state. Mirrors the + // entry in `labels/typescript.rs::GATED_LABEL_RULES`. + GatedLabelRule { + matchers: &["cookies", "headers"], + label: DataLabel::Source(Cap::all()), + case_sensitive: true, + gate: LabelGate::ImportedFromModule(&["next/headers"]), + }, +]; + pub static GATED_SINKS: &[SinkGate] = &[ SinkGate { callee_matcher: "setAttribute", @@ -1316,6 +1460,8 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! { "variable_declaration" => Kind::CallWrapper, "lexical_declaration" => Kind::CallWrapper, "expression_statement" => Kind::CallWrapper, + "await_expression" => Kind::AwaitForward, + "jsx_attribute" => Kind::JsxAttr, // trivia "comment" => Kind::Trivia, diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 90066fd5..97ef01f8 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -38,6 +38,61 @@ pub struct LabelRule { pub case_sensitive: bool, } +/// Activation gate carried by a [`GatedLabelRule`]. Phase 05 introduces the +/// import-derived gate so JS/TS bare-name `fs/promises` sinks (`readFile`, +/// `writeFile`, ...) only fire when the call resolves to that module — a +/// flat bare-name match would over-fire on user-defined `readFile` helpers. +#[derive(Debug, Clone, Copy)] +pub enum LabelGate { + /// Fires only when the call's leading identifier is locally bound by an + /// import / `require` whose `source_module` equals one of the listed + /// specifiers. The synthetic prefix `FileSystemPromisesNs.` produced by + /// receiver-type qualification also satisfies the gate (see Phase 05's + /// `TypeKind::FileSystemPromisesNs`). + ImportedFromModule(&'static [&'static str]), + /// Fires when *any* local-name in the file's import view resolves to one + /// of the listed specifiers, regardless of which identifier leads the + /// call. Used for Phase 07 ORM bare-name method sinks (Knex's `whereRaw` + /// / `orderByRaw` / `havingRaw`) where the receiver is a query-builder + /// instance whose binding name is arbitrary (`db`, `qb`, `users`, ...) + /// and the import witness is the package itself. + FileImportsModule(&'static [&'static str]), + /// Fires when the file's import view binds at least one of `local_names` + /// to one of `modules`. Tighter than [`Self::FileImportsModule`]: type-only + /// or peripheral named-import shapes (e.g. `import { Knex } from 'knex'` + /// for type-only use of `Knex.QueryBuilder`) do not satisfy the gate + /// unless the conventional value-binding name (`knex`, lowercase) is also + /// present. Used for Phase 07 deferred-item 10's tightening of the Knex + /// `whereRaw` / `orderByRaw` / `havingRaw` gate. + FileImportsModuleAsLocalName { + modules: &'static [&'static str], + local_names: &'static [&'static str], + }, +} + +/// A label rule that only fires when its [`LabelGate`] is satisfied at the +/// call site. The matcher / label / case-sensitivity semantics mirror +/// [`LabelRule`]; the gate is checked by [`classify_all_ctx`] using the +/// caller-supplied [`ClassificationContext`]. +#[derive(Debug, Clone, Copy)] +pub struct GatedLabelRule { + pub matchers: &'static [&'static str], + pub label: DataLabel, + pub case_sensitive: bool, + pub gate: LabelGate, +} + +/// Per-file context consulted by [`classify_all_ctx`] when evaluating +/// gated rules. Threaded from the CFG layer's gated post-pass; `None` +/// elsewhere keeps existing classification paths intact. +#[derive(Debug, Default, Clone, Copy)] +pub struct ClassificationContext<'a> { + /// Local-name → source-module view of the file's imports. The map is + /// computed at CFG build time (see `cfg::imports::extract_local_import_view`) + /// so the gate fires before the project-wide resolver runs. + pub local_imports: Option<&'a std::collections::HashMap>, +} + /// Sentinel returned by [`classify_gated_sink`] for the dynamic/unknown-activation /// branch: the gate fires conservatively and every positional argument must be /// considered a potential tainted payload, not just the explicit `payload_args`. @@ -300,6 +355,17 @@ pub enum Kind { /// any other sequential statement in the CFG but explicitly classified so /// code that inspects `Kind` can recognise it. Seq, + /// Async-await unary forward. An `await x` expression evaluates `x` and + /// resolves to the same value/taint, modelled as a 1:1 copy. Lowered to + /// SSA as `SsaOp::Assign(operand)` so taint, origins, and abstract value + /// pass through unchanged. + AwaitForward, + /// JSX attribute (``). Dispatched in the CFG so the + /// builder can recognise React-specific shapes such as + /// `dangerouslySetInnerHTML={{ __html: x }}` and synthesise a sink call. + /// The attribute name is read from the AST at CFG-build time, not carried + /// in this enum (which must remain `Copy` for `phf_map` storage). + JsxAttr, Other, } @@ -445,6 +511,19 @@ static GATED_REGISTRY: Lazy> = Lazy:: m }); +/// Per-language registry of [`GatedLabelRule`] entries. Phase 05 wires +/// JS/TS only (the `fs/promises` FILE_IO matcher set); other languages +/// fall back to an empty slice. +static GATED_LABEL_REGISTRY: Lazy> = + Lazy::new(|| { + let mut m = HashMap::new(); + m.insert("javascript", javascript::GATED_LABEL_RULES); + m.insert("js", javascript::GATED_LABEL_RULES); + m.insert("typescript", typescript::GATED_LABEL_RULES); + m.insert("ts", typescript::GATED_LABEL_RULES); + 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. @@ -599,6 +678,89 @@ pub fn lookup(lang: &str, raw: &str) -> Kind { .unwrap_or(Kind::Other) } +/// Promise-callback methods (`p.then(cb)`, `p.catch(cb)`, `p.finally(cb)`). +/// +/// These are not sinks. The taint engine consumes this predicate to recognise +/// the receiver as a Promise whose resolved value will be fed to the callback's +/// first parameter. See phase 03 of `plan.md` for the recall-gap rationale. +/// +/// JS/TS only. `callee_leaf` is expected to be the post-`callee_leaf_name` +/// short form (e.g. `"then"`, not `"p.then"`). +pub fn is_promise_callback_method(lang: &str, callee_leaf: &str) -> bool { + if !matches!(lang, "javascript" | "js" | "typescript" | "ts" | "tsx") { + return false; + } + matches!(callee_leaf, "then" | "catch" | "finally") +} + +/// Static `Promise.*` combinator a call resolves to, or `None`. +/// +/// Combinators wrap arguments into a single Promise: +/// * `Promise.resolve(x)` — identity for `x`. +/// * `Promise.all([a, b])` — array whose elements have per-arg taint. +/// * `Promise.allSettled([...])` — same shape as `all`, conservative union. +/// * `Promise.race([...])` — first-to-settle, conservative union. +/// +/// `callee` is the full callee text (e.g. `"Promise.all"`) since the leaf +/// segment alone (`"all"`) is too generic to match safely. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromiseCombinatorKind { + Resolve, + All, + AllSettled, + Race, +} + +/// Lang-agnostic recognition of any promise combinator callee text. Used by +/// SSA lowering, which doesn't carry a `lang` argument. +pub fn is_any_promise_combinator(callee: &str) -> Option { + match callee { + "Promise.resolve" => Some(PromiseCombinatorKind::Resolve), + "Promise.all" => Some(PromiseCombinatorKind::All), + "Promise.allSettled" => Some(PromiseCombinatorKind::AllSettled), + "Promise.race" => Some(PromiseCombinatorKind::Race), + "asyncio.gather" | "asyncio.wait" => Some(PromiseCombinatorKind::All), + "tokio::join" | "tokio::try_join" | "futures::join" | "futures::try_join" => { + Some(PromiseCombinatorKind::All) + } + _ => None, + } +} + +pub fn is_promise_combinator(lang: &str, callee: &str) -> Option { + match lang { + "javascript" | "js" | "typescript" | "ts" | "tsx" => match callee { + "Promise.resolve" => Some(PromiseCombinatorKind::Resolve), + "Promise.all" => Some(PromiseCombinatorKind::All), + "Promise.allSettled" => Some(PromiseCombinatorKind::AllSettled), + "Promise.race" => Some(PromiseCombinatorKind::Race), + _ => None, + }, + // Python: `asyncio.gather(...)` / `asyncio.wait(...)` resolve to a + // tuple/list whose elements carry the union of argument taints. + // `asyncio.wait` returns `(done, pending)` sets but the same + // conservative scalar-union approximation applies, downstream + // destructuring already taints all bindings. + "python" | "py" => match callee { + "asyncio.gather" | "asyncio.wait" => Some(PromiseCombinatorKind::All), + _ => None, + }, + // Rust: `tokio::join!` / `futures::join!` (and their `try_*` + // variants) evaluate every future concurrently and bind the + // tuple of resolved values. `cfg::push_node` rewrites the + // macro_invocation's `arg_uses` so each future's tainted inputs + // surface as a positional arg; this combinator entry then unions + // them onto the tuple value. + "rust" | "rs" => match callee { + "tokio::join" | "tokio::try_join" | "futures::join" | "futures::try_join" => { + Some(PromiseCombinatorKind::All) + } + _ => None, + }, + _ => None, + } +} + /// The kind of taint source, used to refine finding severity. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -953,6 +1115,17 @@ fn ends_with_cs(haystack: &[u8], needle: &[u8], case_sensitive: bool) -> bool { } } +/// Allocation-free ASCII-case-insensitive prefix check on `&str` inputs. +/// Used by the gated-sink dispatch hot path where the previous +/// `value.to_ascii_lowercase().starts_with(&p.to_ascii_lowercase())` pair +/// allocated two `String` values per check. +#[inline] +fn starts_with_ignore_ascii_case(haystack: &str, needle: &str) -> bool { + let h = haystack.as_bytes(); + let n = needle.as_bytes(); + h.len() >= n.len() && h[..n.len()].eq_ignore_ascii_case(n) +} + /// Prefix check with configurable case sensitivity. The `=` exact-match /// sigil is meaningless for prefix matchers (which by definition match many /// suffixes); it is stripped if present so a malformed matcher like @@ -1028,6 +1201,9 @@ pub fn classify(lang: &str, text: &str, extra: Option<&[RuntimeLabelRule]>) -> O // For chained calls like `r.URL.Query().Get`, also strip internal // `().` segments to produce a normalized form like `r.URL.Query.Get`. + // `normalize_chained_call` returns `Cow::Borrowed` when no rewrite is + // needed, so the alloc is paid only on inputs that actually require + // it. let full_normalized = normalize_chained_call(text); let full_norm_bytes = full_normalized.as_bytes(); @@ -1116,6 +1292,9 @@ pub fn classify_all( return SmallVec::new(); } + // `normalize_chained_call` returns `Cow::Borrowed` when no rewrite + // is needed, so the alloc is paid only on inputs that actually + // require it. The hot classify path runs on every CFG node. let full_normalized = normalize_chained_call(text); let full_norm_bytes = full_normalized.as_bytes(); @@ -1198,6 +1377,228 @@ pub fn classify_all( out } +/// Classify a call with an optional [`ClassificationContext`] enabling +/// gated rule evaluation. +/// +/// This is a strict superset of [`classify_all`]: the same flat-rule +/// matching runs first, then any per-language [`GatedLabelRule`] is +/// evaluated against `ctx`. A `None` context (or a context with no +/// `local_imports`) leaves only the synthetic receiver-type prefix +/// (e.g. `FileSystemPromisesNs.`) able to satisfy the gate. +pub fn classify_all_ctx( + lang: &str, + text: &str, + extra: Option<&[RuntimeLabelRule]>, + ctx: Option<&ClassificationContext<'_>>, +) -> SmallVec<[DataLabel; 2]> { + let mut out = classify_all(lang, text, extra); + classify_gated_into(lang, text, ctx, &mut out); + out +} + +/// Run only the gated-rule pass — skip the flat [`classify_all`] scan. +/// +/// Use when the caller has already classified `text` with the flat rules +/// during initial CFG construction and only needs the gate-conditioned +/// labels (which require a per-file [`ClassificationContext`] not +/// available at the original classification site). +pub fn classify_gated_only( + lang: &str, + text: &str, + ctx: Option<&ClassificationContext<'_>>, +) -> SmallVec<[DataLabel; 2]> { + let mut out = SmallVec::new(); + classify_gated_into(lang, text, ctx, &mut out); + out +} + +fn classify_gated_into( + lang: &str, + text: &str, + ctx: Option<&ClassificationContext<'_>>, + out: &mut SmallVec<[DataLabel; 2]>, +) { + let gated = match GATED_LABEL_REGISTRY.get(lang).or_else(|| { + let key = lang.to_ascii_lowercase(); + GATED_LABEL_REGISTRY.get(key.as_str()) + }) { + Some(g) => *g, + None => return, + }; + if gated.is_empty() { + return; + } + + let head = text.split(['(', '<']).next().unwrap_or(""); + let trimmed = head.trim().as_bytes(); + if is_excluded(lang, trimmed) { + return; + } + let full_normalized = normalize_chained_call(text); + let full_norm_bytes = full_normalized.as_bytes(); + + #[inline] + fn push_dedup(out: &mut SmallVec<[DataLabel; 2]>, label: DataLabel) { + if !out.contains(&label) { + out.push(label); + } + } + + // Pass 1: exact / suffix. + for rule in gated { + for raw in rule.matchers { + let m = raw.as_bytes(); + if m.last() == Some(&b'_') { + continue; + } + let matches = match_suffix_cs(trimmed, m, rule.case_sensitive) + || match_suffix_cs(full_norm_bytes, m, rule.case_sensitive); + if matches && gate_satisfied(&rule.gate, head, ctx) { + push_dedup(out, rule.label); + } + } + } + // Pass 2: prefix. + for rule in gated { + for raw in rule.matchers { + let m = raw.as_bytes(); + if m.last() == Some(&b'_') + && (starts_with_cs(trimmed, m, rule.case_sensitive) + || starts_with_cs(full_norm_bytes, m, rule.case_sensitive)) + && gate_satisfied(&rule.gate, head, ctx) + { + push_dedup(out, rule.label); + } + } + } +} + +/// Restricted payload-arg positions for known type-qualified sink callees. +/// +/// Phase 07's ORM raw-SQL receiver methods (`TypeOrmRepo.query`, +/// `TypeOrmManager.query`, `MikroOrmEm.execute`, etc.) take the SQL +/// template at arg 0 and bind / parameter arrays at arg 1+. The flat +/// label rule alone cannot encode this and would FP on +/// `repo.query("SELECT $1", [tainted])`. When the type-qualified +/// resolver synthesises one of these callees, this lookup returns the +/// payload positions to which sink-taint checks must be restricted. +/// +/// Sequelize.literal(sql) is single-arg, so `&[0]` is also correct +/// (no precision loss vs the unconditional flat rule). +pub fn type_qualified_sink_payload_args(qualified_callee: &str) -> Option<&'static [usize]> { + match qualified_callee { + "Sequelize.literal" + | "TypeOrmRepo.query" + | "TypeOrmRepo.createQueryBuilder" + | "TypeOrmManager.query" + | "TypeOrmManager.createQueryBuilder" + | "MikroOrmEm.execute" => Some(&[0]), + _ => None, + } +} + +/// Receiver-type prefixes that count as a witness for a given module +/// specifier on a [`LabelGate::ImportedFromModule`] gate. +/// +/// When SSA receiver-type qualification synthesises a callee like +/// `FileSystemPromisesNs.readFile(...)`, the leading identifier becomes +/// the type prefix rather than an imported binding. Each gate module +/// can declare which type prefixes legitimise the gate firing without +/// a textual import witness. Returning an empty slice means the gate +/// must fall back to the `local_imports` map alone. +fn receiver_type_prefixes_for_module(module: &str) -> &'static [&'static str] { + if module.eq_ignore_ascii_case("node:fs/promises") || module.eq_ignore_ascii_case("fs/promises") + { + &["FileSystemPromisesNs"] + } else { + &[] + } +} + +/// Evaluate a [`LabelGate`] against the call's leading identifier and the +/// caller-supplied context. Receiver-type qualification can satisfy +/// [`LabelGate::ImportedFromModule`] via +/// [`receiver_type_prefixes_for_module`]. +fn gate_satisfied( + gate: &LabelGate, + callee_head: &str, + ctx: Option<&ClassificationContext<'_>>, +) -> bool { + match gate { + LabelGate::ImportedFromModule(modules) => { + let leading = leading_identifier(callee_head); + for m in modules.iter() { + for prefix in receiver_type_prefixes_for_module(m) { + if leading == *prefix { + return true; + } + } + } + let Some(ctx) = ctx else { + return false; + }; + let Some(map) = ctx.local_imports else { + return false; + }; + let Some(source_module) = map.get(leading) else { + return false; + }; + modules + .iter() + .any(|m| source_module.eq_ignore_ascii_case(m)) + } + LabelGate::FileImportsModule(modules) => { + let Some(ctx) = ctx else { + return false; + }; + let Some(map) = ctx.local_imports else { + return false; + }; + map.values().any(|source_module| { + modules + .iter() + .any(|m| source_module.eq_ignore_ascii_case(m)) + }) + } + LabelGate::FileImportsModuleAsLocalName { + modules, + local_names, + } => { + let Some(ctx) = ctx else { + return false; + }; + let Some(map) = ctx.local_imports else { + return false; + }; + local_names.iter().any(|name| { + map.get(*name).is_some_and(|source_module| { + modules + .iter() + .any(|m| source_module.eq_ignore_ascii_case(m)) + }) + }) + } + } +} + +/// Leading identifier of a call expression's text — the segment up to the +/// first `.`, `:`, `(`, or `<`. Used to drive ImportTable lookups. +fn leading_identifier(callee_head: &str) -> &str { + let bytes = callee_head.as_bytes(); + let mut end = 0; + for (i, b) in bytes.iter().enumerate() { + match b { + b'.' | b':' | b'(' | b'<' | b' ' | b'[' => { + end = i; + return &callee_head[..end]; + } + _ => {} + } + end = i + 1; + } + &callee_head[..end] +} + /// Result of a gated-sink classification. /// /// `label` is the sink capability the callee contributes at this site. @@ -1289,8 +1690,7 @@ pub fn classify_gated_sink( } match const_keyword_arg(name) { Some(v) => { - let lower = v.to_ascii_lowercase(); - if values.iter().any(|dv| lower == dv.to_ascii_lowercase()) { + if values.iter().any(|dv| v.eq_ignore_ascii_case(dv)) { any_dangerous = true; break; } @@ -1332,15 +1732,14 @@ pub fn classify_gated_sink( match activation_value { Some(value) => { - let lower = value.to_ascii_lowercase(); let is_dangerous = gate .dangerous_values .iter() - .any(|v| lower == v.to_ascii_lowercase()) + .any(|v| value.eq_ignore_ascii_case(v)) || gate .dangerous_prefixes .iter() - .any(|p| lower.starts_with(&p.to_ascii_lowercase())); + .any(|p| starts_with_ignore_ascii_case(&value, p)); if is_dangerous { out.push(GateMatch { label: gate.label, @@ -1379,7 +1778,7 @@ pub fn classify_gated_sink( /// Public wrapper for `normalize_chained_call` so callers outside the module /// can share the same normalization used by the label classifier. pub fn normalize_chained_call_for_classify(text: &str) -> String { - normalize_chained_call(text) + normalize_chained_call(text).into_owned() } /// Return the bare method-name segment of a callee text. Returns the @@ -1394,38 +1793,79 @@ pub fn bare_method_name(callee: &str) -> &str { /// Normalize a chained method call: strip `()` between `.` segments. /// e.g. `r.URL.Query().Get` → `r.URL.Query.Get` /// e.g. `r.URL.Query().Get("host")` → `r.URL.Query.Get` -fn normalize_chained_call(text: &str) -> String { - let mut result = String::with_capacity(text.len()); +/// +/// Returns a borrow when no transformation is required (no `()` between +/// `.` segments and no leading `<`), avoiding the heap allocation. Only +/// pays for a `String` when the input actually needs rewriting; the hot +/// classify path runs on every CFG node so the borrow case dominates. +fn normalize_chained_call(text: &str) -> std::borrow::Cow<'_, str> { let bytes = text.as_bytes(); let mut i = 0; while i < bytes.len() { match bytes[i] { b'(' => { - // Skip from `(` to matching `)`, but only if followed by `.` - // This handles `Query().Get` → `Query.Get` let mut depth = 1u32; let mut j = i + 1; while j < bytes.len() && depth > 0 { - if bytes[j] == b'(' { - depth += 1; - } else if bytes[j] == b')' { - depth -= 1; + match bytes[j] { + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + j += 1; + } + if j >= bytes.len() || bytes[j] == b'.' { + return std::borrow::Cow::Owned(normalize_chained_call_owned(text, i)); + } + i += 1; + } + b'<' => return std::borrow::Cow::Borrowed(&text[..i]), + _ => i += 1, + } + } + std::borrow::Cow::Borrowed(text) +} + +/// Slow path for `normalize_chained_call`: runs only when the input +/// actually contains a `(...)` group followed by `.` (the case that +/// requires removing characters). `prefix_end` is the byte offset of the +/// first transformation point so the prefix can be copied wholesale. +/// +/// `(`, `)`, `<`, and `.` are all ASCII, so byte-level scanning is safe +/// for control characters. Non-ASCII identifier bytes are copied as +/// contiguous slices to keep multi-byte UTF-8 sequences intact. +fn normalize_chained_call_owned(text: &str, prefix_end: usize) -> String { + let bytes = text.as_bytes(); + let mut result = String::with_capacity(text.len()); + result.push_str(&text[..prefix_end]); + let mut i = prefix_end; + while i < bytes.len() { + match bytes[i] { + b'(' => { + let mut depth = 1u32; + let mut j = i + 1; + while j < bytes.len() && depth > 0 { + match bytes[j] { + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} } j += 1; } - // If we're at end or next char is `.`, skip the parens if j >= bytes.len() || bytes[j] == b'.' { i = j; } else { - // Keep the paren content (unusual case) result.push('('); i += 1; } } - b'<' => break, // Stop at generic args + b'<' => break, _ => { - result.push(bytes[i] as char); - i += 1; + let start = i; + while i < bytes.len() && !matches!(bytes[i], b'(' | b'<') { + i += 1; + } + result.push_str(&text[start..i]); } } } @@ -1979,6 +2419,58 @@ mod tests { assert_eq!(lookup_receiver_validator("python", "joinpath"), None); } + #[test] + fn normalize_chained_call_borrows_when_no_change() { + // No parens, no `<` → no rewrite, borrow returned. + let r = normalize_chained_call("plain"); + assert!(matches!(r, std::borrow::Cow::Borrowed(_))); + assert_eq!(r.as_ref(), "plain"); + + // `(` mid-token but not at end of any `.` chain → still owned + // because the function's policy collapses any `(` followed by + // EOL or `.`. Use a callee with a non-collapsing shape: bare + // dotted text. + let r = normalize_chained_call("a.b.c"); + assert!(matches!(r, std::borrow::Cow::Borrowed(_))); + assert_eq!(r.as_ref(), "a.b.c"); + + // Truncate at `<` (generics) is a borrow with shorter slice. + let r = normalize_chained_call("Vec"); + assert!(matches!(r, std::borrow::Cow::Borrowed(_))); + assert_eq!(r.as_ref(), "Vec"); + } + + #[test] + fn normalize_chained_call_collapses_paren_dot_chain() { + let r = normalize_chained_call("r.URL.Query().Get"); + assert_eq!(r.as_ref(), "r.URL.Query.Get"); + + let r = normalize_chained_call("a.b().c().d"); + assert_eq!(r.as_ref(), "a.b.c.d"); + + // Last paren-call before EOL is also collapsed (j >= bytes.len()). + let r = normalize_chained_call("a.b()"); + assert_eq!(r.as_ref(), "a.b"); + } + + #[test] + fn normalize_chained_call_preserves_utf8_after_collapse() { + // Greek lowercase letters are 2-byte UTF-8 sequences. The slow + // path must not split them when copying tail bytes after a + // collapsed `(...)` group. + let r = normalize_chained_call("obj.func().αβγ"); + assert_eq!(r.as_ref(), "obj.func.αβγ"); + + // CJK ideographs are 3-byte sequences. Same invariant. + let r = normalize_chained_call("a.b().名前"); + assert_eq!(r.as_ref(), "a.b.名前"); + + // Emoji (4-byte sequence) inside an identifier. Engines never + // see this in practice but the byte loop must not corrupt it. + let r = normalize_chained_call("x.y().🦀_id"); + assert_eq!(r.as_ref(), "x.y.🦀_id"); + } + #[test] fn bare_method_name_strips_chain() { // No-dot input → returned as-is. @@ -2739,6 +3231,26 @@ mod tests { assert_eq!(result[0], DataLabel::Sink(Cap::HTML_ESCAPE)); } + #[test] + fn starts_with_ignore_ascii_case_matches_canonical_shapes() { + assert!(starts_with_ignore_ascii_case( + "FILE://etc/passwd", + "file://" + )); + assert!(starts_with_ignore_ascii_case( + "file://etc/passwd", + "FILE://" + )); + assert!(starts_with_ignore_ascii_case("http://", "http://")); + assert!(starts_with_ignore_ascii_case("http://", "")); + assert!(!starts_with_ignore_ascii_case("http", "https")); + assert!(!starts_with_ignore_ascii_case("", "x")); + // Multibyte UTF-8: the helper is intentionally ASCII-only; non-ASCII + // bytes compare byte-for-byte (no Unicode case folding). + assert!(starts_with_ignore_ascii_case("café", "café")); + assert!(!starts_with_ignore_ascii_case("café", "CAFÉ")); + } + #[test] fn classify_all_dual_label_php() { let result = classify_all("php", "file_get_contents", None); diff --git a/src/labels/php.rs b/src/labels/php.rs index a1f46814..23ca51ef 100644 --- a/src/labels/php.rs +++ b/src/labels/php.rs @@ -48,9 +48,29 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sanitizer(Cap::FILE_IO), case_sensitive: false, }, - // PDO parameterized queries + // PDO parameterized queries. `prepareStatement` covers Drupal's + // Database\\Connection convention (and any PSR-style wrapper that + // uses the longer name); semantically identical to `prepare` — + // both return a statement object, the bind step ships values as + // out-of-band parameters, no concatenation occurs. LabelRule { - matchers: &["prepare", "bindParam", "bindValue"], + matchers: &["prepare", "prepareStatement", "bindParam", "bindValue"], + label: DataLabel::Sanitizer(Cap::SQL_QUERY), + case_sensitive: false, + }, + // Phase 15 — `mysqli_real_escape_string($conn, $s)` and + // `pg_escape_string($s)` apply driver-side escaping for legacy + // string-concat shapes. Treat as SQL_QUERY sanitizers so the + // value-replacement clears the cap on the call return. + // `addslashes` is intentionally excluded — it does NOT cover + // multibyte / charset-aware injection vectors. + LabelRule { + matchers: &[ + "mysqli_real_escape_string", + "pg_escape_string", + "pg_escape_literal", + "pg_escape_identifier", + ], label: DataLabel::Sanitizer(Cap::SQL_QUERY), case_sensitive: false, }, @@ -121,10 +141,39 @@ pub static RULES: &[LabelRule] = &[ "pdo.query", "mysqli.real_query", "mysqli_real_query", + // Phase 15 — `PDOStatement::execute` (with no args) executes a + // prepared statement; when prepared from a tainted string the + // bind step does NOT prevent injection (the SQL was already + // built unsafely). The receiver-text suffix is `stmt.execute`. + // Distinct from the bare `execute` matcher (already on the + // generic SQL_QUERY rule via `query` matcher) because the + // OOP `$stmt->execute()` shape skips the SQL-string arg. + "stmt.execute", ], label: DataLabel::Sink(Cap::SQL_QUERY), case_sensitive: false, }, + // Phase 15 — Doctrine ORM raw-SQL passthrough APIs. Doctrine's + // `EntityManager::createQuery($dql)` accepts a DQL string; + // `createNativeQuery($sql, $rsm)` accepts a native SQL string; + // `getConnection()->executeQuery($sql)` / + // `getConnection()->executeStatement($sql)` are the low-level + // Connection passthroughs that route to the underlying driver + // verbatim. Suffix-matching covers both bound-receiver shapes + // (`$em->createQuery($dql)`) and the documentation-style + // class-qualified call form (`EntityManager.createQuery`). + LabelRule { + matchers: &[ + "EntityManager.createQuery", + "EntityManager.createNativeQuery", + "createQuery", + "createNativeQuery", + "executeQuery", + "executeStatement", + ], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + }, // Laravel Eloquent: raw SQL methods. // DB::raw() → scoped_call_expression, callee text "DB.raw". // whereRaw/selectRaw/orderByRaw/havingRaw → member_call_expression on query builder. @@ -133,6 +182,22 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sink(Cap::SQL_QUERY), case_sensitive: false, }, + // Phase 15 — Laravel raw-SQL execution facade methods. `DB::select`, + // `DB::statement`, `DB::insert`, `DB::update`, `DB::delete`, + // `DB::unprepared` all accept a literal SQL string; the + // `unprepared` form is the explicit no-bind escape hatch. + LabelRule { + matchers: &[ + "DB.select", + "DB.statement", + "DB.insert", + "DB.update", + "DB.delete", + "DB.unprepared", + ], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + }, // NOTE: `file_get_contents` and `fopen` can fetch URLs (SSRF vector) and // local files (LFI vector — `file://` scheme). As a Sink(SSRF) they only // fire when the argument is tainted. `fopen` is the canonical low-level @@ -145,6 +210,32 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sink(Cap::SSRF), case_sensitive: false, }, + // Phase 14 — `\GuzzleHttp\Client::request($method, $url, ...)` and the + // verb-shorthand methods `$client->get($url)` / `->head($url)` / + // `->options($url)`. The read-shaped verbs carry the URL at arg 0 + // and have no body argument, so a flat SSRF sink is FP-safe. The + // body-bearing verbs (`post` / `put` / `patch`) live on the + // DATA_EXFIL list above; their URL-position SSRF is covered via + // `Client.request` (arg 1 is URL) below as a flat sink — Guzzle + // does not expose argument-role-aware metadata that would let the + // gate distinguish URL from body, but the source-sensitivity gate + // already silences plain `$_GET` / `$_POST` flows so the + // remaining FP surface is small. + LabelRule { + matchers: &[ + "Client.get", + "Client.head", + "Client.options", + "Client.request", + "HttpClient.get", + "HttpClient.head", + "HttpClient.request", + "Http.get", + "Http.head", + ], + label: DataLabel::Sink(Cap::SSRF), + case_sensitive: true, + }, // ── Cross-boundary data exfiltration ────────────────────────────────── // // Body-bearing outbound HTTP verb methods on the major PHP HTTP clients. @@ -343,6 +434,26 @@ pub static GATED_SINKS: &[SinkGate] = &[ dangerous_kwargs: &[], activation: GateActivation::ValueMatch, }, + // Phase 14 — `curl_setopt($ch, CURLOPT_URL, $url)` is the canonical + // pre-`curl_exec` URL bind. Tainted `$url` reaching this option is + // SSRF; the `curl_exec($ch)` flat sink above also fires on the + // tainted handle but only when the handle's taint propagates + // through opaque resource state, which the engine cannot follow + // across `curl_setopt` calls. Activating the SSRF cap directly at + // the option-bind site catches the flow at the construction step + // independent of the handle-flow analysis. + SinkGate { + callee_matcher: "curl_setopt", + arg_index: 1, + dangerous_values: &["CURLOPT_URL"], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SSRF), + 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 diff --git a/src/labels/python.rs b/src/labels/python.rs index 67eeee89..778ae147 100644 --- a/src/labels/python.rs +++ b/src/labels/python.rs @@ -97,6 +97,39 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sink(Cap::FILE_IO), case_sensitive: false, }, + // Phase 13 — pathlib / aiofiles / shutil path-traversal sinks. + // Chained constructor + method shapes (`Path(p).read_text()`) reduce + // via paren-strip to the matcher text below; the path argument is + // the sink payload. Receiver-bound shapes (`p = Path(...); + // p.read_text()`) are not covered here without a `pathlib.Path` + // TypeKind override and are left for a future phase. + LabelRule { + matchers: &[ + "Path.open", + "Path.read_text", + "Path.write_text", + "Path.read_bytes", + "Path.write_bytes", + // Receiver-bound shapes (`p = Path(name); p.read_text()`) + // resolve via the `TypeKind::FileHandle` constructor mapping + // for `Path(...)` in `ssa/type_facts.rs`, which lets the + // type-qualified resolver rewrite `p.read_text` → + // `FileHandle.read_text` against the matchers below. + "FileHandle.open", + "FileHandle.read_text", + "FileHandle.write_text", + "FileHandle.read_bytes", + "FileHandle.write_bytes", + "aiofiles.open", + "shutil.copy", + "shutil.copy2", + "shutil.copyfile", + "shutil.move", + "shutil.rmtree", + ], + label: DataLabel::Sink(Cap::FILE_IO), + case_sensitive: true, + }, LabelRule { matchers: &[ "argparse.parse_args", @@ -157,6 +190,22 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sanitizer(Cap::FILE_IO), case_sensitive: false, }, + // Phase 13 — `pathlib.Path.resolve(strict=True)` raises if the + // resolved path doesn't exist; the canonical / strict form is the + // documented path-traversal sanitiser. Strict-mode argument + // inspection is not modeled (the rule fires for any `.resolve()` + // chained on a `Path(...)`); the false-clear risk on + // `Path(...).resolve()` (non-strict) is an accepted trade-off + // because the non-strict form still resolves symlinks and + // collapses `..` segments, which dominates the path-traversal + // attack surface. Case-sensitive: `Path.resolve` is the literal + // pathlib method name; bare `resolve` is too broad (Django URL + // resolvers, Promise.resolve in JS-style libs). + LabelRule { + matchers: &["Path.resolve", "FileHandle.resolve"], + label: DataLabel::Sanitizer(Cap::FILE_IO), + case_sensitive: true, + }, // ─────────── Sinks ───────────── // Flask sinks LabelRule { @@ -218,6 +267,26 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sink(Cap::SQL_QUERY), case_sensitive: false, }, + // Phase 15 — receiver-typed ORM sinks. `SqlAlchemySession.execute` + // / `SqlAlchemySession.scalar` / `SqlAlchemySession.scalars` etc. + // are produced when the receiver carries `TypeKind::SqlAlchemySession` + // (set by `constructor_type` for `sessionmaker()` / `Session(engine)` / + // `engine.connect()`). `DjangoQuerySet.raw` / `DjangoQuerySet.extra` + // fire on `Model.objects.raw(sql)` / `Model.objects.extra(...)` shapes + // when the receiver was tagged via the `Model.objects` access path. + // `ActiveRecordRelation` is registered in `labels/ruby.rs`. + LabelRule { + matchers: &[ + "SqlAlchemySession.execute", + "SqlAlchemySession.scalar", + "SqlAlchemySession.scalars", + "SqlAlchemySession.exec_driver_sql", + "DjangoQuerySet.raw", + "DjangoQuerySet.extra", + ], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + }, // SQL injection: sqlite3 / SQLAlchemy / generic DB connection execute. LabelRule { matchers: &[ @@ -1245,6 +1314,214 @@ pub static GATED_SINKS: &[SinkGate] = &[ object_destination_fields: &["data"], }, }, + // ── SQL execute payload-arg gating (Phase 15 deferred fix) ──────────── + // + // The flat label rules above already classify these callees as + // `Sink(SQL_QUERY)` on every argument. The DB-API convention is that + // arg 0 is the SQL string and arg 1+ are parameterised bind values + // (`cursor.execute("SELECT * FROM t WHERE id = %s", (user_id,))`). Tainted + // bind values are SAFE because the driver escapes them; tainted SQL is + // the SQLi vector. These Destination-activation gates carry the same + // `Sink(SQL_QUERY)` label so they dedupe against the flat rule, but + // their `payload_args: &[0]` propagates into `sink_payload_args`, + // narrowing the SSA sink scan to arg 0 only. + SinkGate { + callee_matcher: "cursor.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "cursor.executemany", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "conn.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "connection.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "session.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "engine.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "db.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "objects.raw", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: false, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + // Receiver-typed forms; same payload shape (sql at arg 0). + SinkGate { + callee_matcher: "SqlAlchemySession.execute", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "SqlAlchemySession.scalar", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "SqlAlchemySession.scalars", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "SqlAlchemySession.exec_driver_sql", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DjangoQuerySet.raw", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, + SinkGate { + callee_matcher: "DjangoQuerySet.extra", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, ]; /// Prototype-pollution-style gates for Python. Opt-in via the @@ -1329,6 +1606,13 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! { "call" => Kind::CallFn, "assignment" => Kind::Assignment, "expression_statement" => Kind::CallWrapper, + // tree-sitter-python emits `await x` as a named `await` node (no + // `_expression` suffix, unlike JS/TS). Map it to `AwaitForward` so + // the SSA lowering forwards the awaited value 1:1, mirroring the + // JS/TS contract. Async-for in Python is plain `for_statement` with + // an unnamed `async` token child; the iterator-text rewrite in + // `cfg::push_node` covers both sync and async forms uniformly. + "await" => Kind::AwaitForward, // trivia "comment" => Kind::Trivia, diff --git a/src/labels/ruby.rs b/src/labels/ruby.rs index 1878b008..abb4e733 100644 --- a/src/labels/ruby.rs +++ b/src/labels/ruby.rs @@ -113,7 +113,25 @@ pub static RULES: &[LabelRule] = &[ // in the resource-lifecycle acquire/release pair (cfg_analysis::RUBY_RESOURCES), // so this entry is additive, it does not disturb resource-leak detection. LabelRule { - matchers: &["File.open", "File.new", "File.read", "IO.read"], + matchers: &[ + "File.open", + "File.new", + "File.read", + "IO.read", + // Phase 13 — write-side and directory-listing path-traversal + // sinks. `Pathname.new(p)` is conservative: a Pathname + // construction with attacker-controlled `p` is the documented + // entry point for downstream Path / File operations and + // surfaces the path-traversal vector at the construction + // site. `Dir.entries` / `Dir.glob` enumerate filesystem + // contents, so a tainted path argument is a directory + // disclosure / glob-injection vector. + "File.write", + "IO.write", + "Pathname.new", + "Dir.entries", + "Dir.glob", + ], label: DataLabel::Sink(Cap::FILE_IO), case_sensitive: false, }, @@ -136,10 +154,28 @@ pub static RULES: &[LabelRule] = &[ matchers: &[ "Net::HTTP.get", "Net::HTTP.post", + // Phase 14 — `Net::HTTP.start(host, port, ...)` is a session + // factory whose host argument is the SSRF vector when + // tainted. `Net::HTTP.get_response(uri)` is a stdlib + // convenience wrapper around `start` + `request_get`. + "Net::HTTP.start", + "Net::HTTP.get_response", "URI.open", "OpenURI.open_uri", "HTTParty.get", "HTTParty.post", + // Phase 14 — Faraday::Connection verb methods on a typed + // receiver. `Faraday.new(url: base)` produces an + // `HttpClient`-typed value (see `constructor_type`); the + // `client.get(path)` chain resolves through the + // type-qualified `HttpClient.get` rule below. Bare + // `Faraday.get` / `.post` / etc. are the module-level + // shorthand the existing `Faraday.post` matcher already + // covers for DATA_EXFIL; SSRF needs the read-shaped + // verbs registered explicitly. + "Faraday.get", + "Faraday.head", + "Faraday.delete", ], label: DataLabel::Sink(Cap::SSRF), case_sensitive: false, @@ -214,11 +250,41 @@ pub static RULES: &[LabelRule] = &[ case_sensitive: false, }, // SQL injection: ActiveRecord unsafe raw-query execution APIs. + // Phase 15 expands coverage with `exec_query` (the raw-SQL execution + // verb on the ActiveRecord connection adapter) and `select_value` / + // `select_values` / `select_rows` (driver-level select helpers that + // accept a literal SQL string). LabelRule { - matchers: &["find_by_sql", "connection.execute", "select_all"], + matchers: &[ + "find_by_sql", + "connection.execute", + "select_all", + "exec_query", + "select_value", + "select_values", + "select_rows", + "select_one", + ], label: DataLabel::Sink(Cap::SQL_QUERY), case_sensitive: false, }, + // Phase 15 — receiver-typed ActiveRecord raw-SQL sinks. The + // `ActiveRecordRelation` TypeKind is set by `constructor_type` on + // class-method scope chains (`User.where(...)` etc.); type-qualified + // resolution rewrites `relation.find_by_sql(sql)` → + // `ActiveRecordRelation.find_by_sql` so the chained shape is caught + // even when the receiver text has lost its model-class prefix. + LabelRule { + matchers: &[ + "ActiveRecordRelation.find_by_sql", + "ActiveRecordRelation.exec_query", + "ActiveRecordRelation.select_all", + "ActiveRecordRelation.select_one", + "ActiveRecordRelation.select_value", + ], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + }, // SQL injection: ActiveRecord query methods that accept raw SQL strings. // `where` and `order` are the most common Rails SQLi vectors when called // with string interpolation (e.g., User.where("name = '#{params[:name]}'")). @@ -383,6 +449,32 @@ pub static RULES: &[LabelRule] = &[ /// `Nokogiri::XML::ParseOptions::DEFAULT_XML`); any non-dangerous /// scope-qualified constant disables the gate. pub static GATED_SINKS: &[SinkGate] = &[ + // `Faraday.new(url: tainted)` — base-URL kwarg controls the destination + // origin for every subsequent verb call on the returned client + // (`client.get(path)` / `.post` / etc.). When the kwarg value is + // attacker-controlled, the constructor itself is the SSRF entry point; + // the existing type-qualified rules on `HttpClient.get` / `.post` only + // cover taint flowing into the per-call `path` arg. + // + // Activation is `Destination` on positional position 0 with a single + // `url` field; tree-sitter-ruby emits the kwarg as a `pair` node sibling + // of the positional args, and `extract_destination_kwarg_pairs` walks + // those pairs (Ruby support added alongside this gate in + // `cfg::literals::extract_destination_kwarg_pairs`). + SinkGate { + callee_matcher: "Faraday.new", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::SSRF), + case_sensitive: true, + payload_args: &[0], + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &["url"], + }, + }, // `Nokogiri::XML(xml, url=nil, encoding=nil, options=NIL)` — top-level // module method. arg 3 carries the parse-option flag literal. // diff --git a/src/labels/rust.rs b/src/labels/rust.rs index c384efc4..76d27d63 100644 --- a/src/labels/rust.rs +++ b/src/labels/rust.rs @@ -60,6 +60,26 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sanitizer(Cap::SHELL_ESCAPE), case_sensitive: false, }, + // Phase 13 — `Path::canonicalize` (and `tokio::fs::canonicalize`) is + // the canonical Rust path-traversal sanitiser when paired with a + // `starts_with(&base)` containment check. Same convention as the + // Java / Python `.normalize()` / `.resolve()` sanitiser rules: the + // call clears the FILE_IO cap on its return so the cap-based gate + // suppresses the downstream `tokio::fs::*` / `std::fs::*` sink. + // Bare `canonicalize` would over-fire on unrelated APIs (e.g. + // `Url::canonicalize`); the qualified forms below are unique to + // path-handling. + LabelRule { + matchers: &[ + "Path.canonicalize", + "PathBuf.canonicalize", + "fs::canonicalize", + "std::fs::canonicalize", + "tokio::fs::canonicalize", + ], + label: DataLabel::Sanitizer(Cap::FILE_IO), + case_sensitive: false, + }, // ─────────── Sinks ───────────── LabelRule { matchers: &[ @@ -90,6 +110,21 @@ pub static RULES: &[LabelRule] = &[ "fs::copy", "File::open", "File::create", + // Phase 13 — `tokio::fs` async path-traversal sinks. The + // suffix matchers also catch the bare `tokio::fs::File::open` + // chain after paren-strip. `tokio::fs::*` is the + // async-runtime-bound mirror of `std::fs::*`; same path + // arg-0 semantics. + "tokio::fs::read", + "tokio::fs::read_to_string", + "tokio::fs::write", + "tokio::fs::remove_file", + "tokio::fs::remove_dir", + "tokio::fs::remove_dir_all", + "tokio::fs::rename", + "tokio::fs::copy", + "tokio::fs::File::open", + "tokio::fs::File::create", ], label: DataLabel::Sink(Cap::FILE_IO), case_sensitive: false, @@ -105,6 +140,12 @@ pub static RULES: &[LabelRule] = &[ "reqwest::Client.head", "reqwest::Client.patch", "reqwest::Client.request", + // Phase 14 — hyper Client `request(req)` dispatch entry. The + // `req` builder chain (covered by the type-qualified + // RequestBuilder.* / Request::builder.* rules below) smears + // URL taint into the request value via default propagation. + "hyper::Client.request", + "hyper::client::Client.request", // Chained constructor + verb form: `reqwest::Client::new() // .post(url)` reduces (via root-receiver collapse) to chain // text `Client::new.post`, so existing `Client.post` matchers @@ -370,6 +411,10 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! { "let_declaration" => Kind::CallWrapper, "expression_statement" => Kind::CallWrapper, "assignment_expression" => Kind::Assignment, + // `x.await` postfix. Documented per-language so the contract does + // not depend on the raw-string fallback in `cfg::push_node`; SSA + // lowering emits `Assign(operand)` for these nodes. + "await_expression" => Kind::AwaitForward, // struct expressions, recurse so env::var() calls inside field // initialisers produce Source-labelled CFG nodes (needed for summaries). diff --git a/src/labels/typescript.rs b/src/labels/typescript.rs index b933bdca..79d763f7 100644 --- a/src/labels/typescript.rs +++ b/src/labels/typescript.rs @@ -1,5 +1,6 @@ use crate::labels::{ - Cap, DataLabel, GateActivation, Kind, LabelRule, ParamConfig, RuntimeLabelRule, SinkGate, + Cap, DataLabel, GateActivation, GatedLabelRule, Kind, LabelGate, LabelRule, ParamConfig, + RuntimeLabelRule, SinkGate, }; use crate::utils::project::{DetectedFramework, FrameworkContext}; use phf::{Map, phf_map}; @@ -29,6 +30,24 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Source(Cap::all()), case_sensitive: false, }, + // Phase 10 — Web `Request` receiver-method reads. Triggered when + // the SSA receiver carries `TypeKind::Request` (Next.js App + // Router handler's first formal) and the type-qualified resolver + // rewrites `req.json()` → `Request.json` etc. The reads return + // user-controlled bytes / strings; the matchers also cover + // `Request.url` and `Request.headers.get(...)` which both expose + // header / URL state to the handler. + LabelRule { + matchers: &[ + "Request.json", + "Request.formData", + "Request.text", + "Request.url", + "Request.headers.get", + ], + label: DataLabel::Source(Cap::all()), + case_sensitive: true, + }, // ───────── Sanitizers ────────── LabelRule { matchers: &["JSON.parse"], @@ -215,6 +234,40 @@ pub static RULES: &[LabelRule] = &[ "fs.unlinkSync", "fs.readdir", "fs.readdirSync", + // Phase 05 — `node:fs/promises` member-access forms covered + // here. Bare-name forms (`readFile`, `open`, ...) and + // `fsp.readFile` namespace-import forms ride the gated + // matcher in `GATED_LABEL_RULES`. Receiver-type fallback + // synthesises `FileSystemPromisesNs.` (handled + // below). + "fs.promises.readFile", + "fs.promises.writeFile", + "fs.promises.unlink", + "fs.promises.open", + "fs.promises.stat", + "fs.promises.readdir", + "fs.promises.mkdir", + "fs.promises.rmdir", + "fs.promises.rm", + "fs.promises.appendFile", + "fs.promises.copyFile", + "fs.promises.rename", + "fs.promises.truncate", + "fs.promises.chmod", + "FileSystemPromisesNs.readFile", + "FileSystemPromisesNs.writeFile", + "FileSystemPromisesNs.unlink", + "FileSystemPromisesNs.open", + "FileSystemPromisesNs.stat", + "FileSystemPromisesNs.readdir", + "FileSystemPromisesNs.mkdir", + "FileSystemPromisesNs.rmdir", + "FileSystemPromisesNs.rm", + "FileSystemPromisesNs.appendFile", + "FileSystemPromisesNs.copyFile", + "FileSystemPromisesNs.rename", + "FileSystemPromisesNs.truncate", + "FileSystemPromisesNs.chmod", ], label: DataLabel::Sink(Cap::FILE_IO), case_sensitive: false, @@ -255,6 +308,25 @@ pub static RULES: &[LabelRule] = &[ label: DataLabel::Sink(Cap::SQL_QUERY), case_sensitive: true, }, + // ── Phase 07 — ORM query-builder receiver-typed sinks ── + // See `labels/javascript.rs` for the design rationale; mirrored here so + // TypeScript fixtures pick up the same coverage. Receiver TypeKinds + // are populated by [`crate::ssa::type_facts::constructor_type`] for + // `new Sequelize(...)` / `getRepository(...)` / `getManager()` / + // `createEntityManager()`; the type-qualified resolver rewrites + // `.` → `.` against these matchers. + LabelRule { + matchers: &[ + "Sequelize.literal", + "TypeOrmRepo.query", + "TypeOrmRepo.createQueryBuilder", + "TypeOrmManager.query", + "TypeOrmManager.createQueryBuilder", + "MikroOrmEm.execute", + ], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + }, // ─── LDAP injection sinks ─── // // Mirror of `labels/javascript.rs`; ldapjs / ts-ldapjs has the same @@ -391,6 +463,67 @@ pub static EXCLUDES: &[&str] = &[ "exec.start", ]; +/// Phase 05 — `node:fs/promises` path-traversal sinks. See +/// `javascript.rs::GATED_LABEL_RULES` for the design rationale; both +/// language registries carry the same matcher list to keep .ts and .js +/// fixtures in lockstep. +pub static GATED_LABEL_RULES: &[GatedLabelRule] = &[ + GatedLabelRule { + matchers: &[ + "readFile", + "writeFile", + "unlink", + "open", + "stat", + "readdir", + "mkdir", + "rmdir", + "rm", + "appendFile", + "copyFile", + "rename", + "truncate", + "chmod", + ], + label: DataLabel::Sink(Cap::FILE_IO), + case_sensitive: false, + gate: LabelGate::ImportedFromModule(&["node:fs/promises", "fs/promises"]), + }, + // Phase 07 — Knex bare-name raw-SQL escape hatches. See + // `labels/javascript.rs::GATED_LABEL_RULES` for the rationale; this + // mirror keeps `.ts` and `.js` fixtures in lockstep. + GatedLabelRule { + matchers: &["whereRaw", "orderByRaw", "havingRaw"], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + gate: LabelGate::FileImportsModuleAsLocalName { + modules: &["knex"], + local_names: &["knex"], + }, + }, + // Phase 07 — Drizzle `sql` template-tag builder. See + // `labels/javascript.rs::GATED_LABEL_RULES` for the two callee + // shapes covered (`sql\`...\`` and `sql.raw(...)`). + GatedLabelRule { + matchers: &["=sql", "sql.raw"], + label: DataLabel::Sink(Cap::SQL_QUERY), + case_sensitive: true, + gate: LabelGate::ImportedFromModule(&["drizzle-orm"]), + }, + // Phase 10 — Next.js `cookies()` / `headers()` helpers from the + // `next/headers` module return adversary-controlled + // request-bound state (cookies carry session tokens, headers + // carry auth material). Gated on the import so app-internal + // helpers named `cookies` or `headers` keep their default + // classification. + GatedLabelRule { + matchers: &["cookies", "headers"], + label: DataLabel::Source(Cap::all()), + case_sensitive: true, + gate: LabelGate::ImportedFromModule(&["next/headers"]), + }, +]; + pub static GATED_SINKS: &[SinkGate] = &[ SinkGate { callee_matcher: "setAttribute", @@ -958,6 +1091,8 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! { "expression_statement" => Kind::CallWrapper, "as_expression" => Kind::Seq, "type_assertion" => Kind::Seq, + "await_expression" => Kind::AwaitForward, + "jsx_attribute" => Kind::JsxAttr, // trivia "comment" => Kind::Trivia, diff --git a/src/lib.rs b/src/lib.rs index 93815af7..bd6b9858 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -100,6 +100,7 @@ pub mod constraint; pub mod convergence_telemetry; pub mod database; pub mod engine_notes; +pub mod entry_points; pub mod errors; pub mod evidence; pub mod fmt; @@ -109,6 +110,7 @@ pub mod output; pub mod patterns; pub mod pointer; pub mod rank; +pub mod resolve; pub mod rust_resolve; #[cfg(feature = "serve")] pub mod server; diff --git a/src/pointer/analysis.rs b/src/pointer/analysis.rs index 3ce98c3c..3e18a1a6 100644 --- a/src/pointer/analysis.rs +++ b/src/pointer/analysis.rs @@ -668,6 +668,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } } @@ -884,6 +885,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let facts = analyse_body(&body, body_id()); assert!(facts.is_trivial()); diff --git a/src/resolve/mod.rs b/src/resolve/mod.rs new file mode 100644 index 00000000..72956809 --- /dev/null +++ b/src/resolve/mod.rs @@ -0,0 +1,1042 @@ +//! TS/JS module resolver foundation. +//! +//! Walks every `package.json` and `tsconfig.json` under a scan root once, +//! builds a project-wide [`ModuleGraph`], and exposes a single entry point +//! for resolving an import specifier (relative path, package import, scoped +//! package, tsconfig `paths` alias, or `node:*` builtin) to a concrete file +//! path on disk plus the exported symbol the import binds to. +//! +//! This module ships the resolver foundation only. Phases 05/09/10 consume +//! the resolved key when threading import information through SSA lowering, +//! callee resolution, and cross-file taint, no behaviour change to findings +//! is gated by phase 04 alone. +//! +//! # Public surface +//! +//! * [`ModuleGraph`], project-scoped resolver state. +//! * [`PackageEntry`], one row per resolved `package.json`. +//! * [`TsConfigPaths`], `compilerOptions.baseUrl` + `paths` for one tsconfig. +//! * [`GlobPattern`], the matched-prefix form of a tsconfig `paths` key. +//! * [`ImportTable`], per-file resolved-import view. +//! * [`ImportBinding`], one resolved import binding (local name → file + +//! exported name). +//! * [`ResolvedModule`], the resolver's reply for a single specifier. +//! * [`build_module_graph`], walk-and-build entry point. +//! +//! Resolution is deliberately conservative: when a specifier cannot be +//! mapped to a file under the scan root the resolver returns +//! `ResolvedModule { file: None, .. }` rather than fabricating a path. The +//! consumer side decides whether to treat unresolved imports as opaque +//! (current behaviour) or as taint stops (phase 09+). + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +#[cfg(test)] +mod tests; + +/// One discovered `package.json`. +/// +/// `name` is the value of the JSON `"name"` field (`"@scope/util"` or +/// `"my-pkg"`). `root` is the directory containing the manifest, used as +/// the package root for both bare and relative resolution. +/// `manifest_main` carries the legacy `main` / `module` / `types` field +/// (preserved verbatim, in spec-priority order). `exports` carries the +/// raw `"exports"` JSON value when present, parsed lazily by +/// `resolve_exports_to_relpath` each time a specifier asks for it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PackageEntry { + pub name: String, + pub root: PathBuf, + #[serde(default)] + pub manifest_main: Option, + #[serde(default)] + pub exports: Option, +} + +/// One match key from a `tsconfig.json` `paths` mapping. +/// +/// Holds the prefix that precedes the `*` (or the full key for non-glob +/// mappings) plus a flag telling [`ModuleGraph::resolve_specifier`] whether +/// the wildcard portion needs to be substituted into each candidate target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GlobPattern { + pub prefix: String, + pub has_wildcard: bool, +} + +/// `tsconfig.json` `compilerOptions.baseUrl` and `paths` for one file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TsConfigPaths { + pub base_url: PathBuf, + pub paths: Vec<(GlobPattern, Vec)>, +} + +/// One per-file resolved import binding. +/// +/// Mirrors the shape of an ES module specifier on the import side +/// (`local_name`, `source_module`) plus the resolver's verdict +/// (`resolved_file`, `exported_name`). Either of the verdict fields can +/// be `None` when the specifier cannot be mapped, the binding is still +/// stored so downstream consumers see the unresolved-but-known set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportBinding { + pub local_name: String, + pub source_module: String, + pub resolved_file: Option, + pub exported_name: Option, +} + +/// Project-wide per-file import view. +/// +/// Logical container for [`ImportBinding`] vectors keyed by the importing +/// file. Phase 04 populates entries lazily as files are CFG-built; phases +/// 05/09/10 read them. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct ImportTable { + pub per_file: HashMap>, +} + +/// Result of [`ModuleGraph::resolve_specifier`]. +/// +/// `file` is `None` for builtins (`node:*`) and unresolvable specifiers. +/// `package` is `Some` when the specifier landed inside a discovered +/// [`PackageEntry`]. The combination distinguishes "resolved into a known +/// package" from "resolved into a free file" from "no resolution at all". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedModule { + pub file: Option, + pub package: Option, + pub is_builtin: bool, +} + +const NODE_BUILTINS: &[&str] = &[ + "assert", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "domain", + "events", + "fs", + "fs/promises", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "os", + "path", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "repl", + "stream", + "stream/promises", + "stream/web", + "string_decoder", + "sys", + "timers", + "timers/promises", + "tls", + "trace_events", + "tty", + "url", + "util", + "util/types", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", +]; + +const RESOLVE_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"]; + +/// Project-wide resolver state. +/// +/// Built once per scan via [`build_module_graph`]. All public methods are +/// `&self`, the per-file [`ImportTable`] is filled in afterwards by the +/// CFG layer (concurrently across rayon workers, see +/// [`ModuleGraph::record_imports_for_file`]). +#[derive(Debug, Default)] +pub struct ModuleGraph { + packages: Vec, + tsconfigs: Vec<(PathBuf, TsConfigPaths)>, + builtins: HashSet, + imports: std::sync::RwLock, +} + +impl ModuleGraph { + /// Empty graph with the standard `node:*` builtin set seeded. + pub fn empty() -> Self { + let builtins = NODE_BUILTINS.iter().map(|s| (*s).to_string()).collect(); + Self { + packages: Vec::new(), + tsconfigs: Vec::new(), + builtins, + imports: std::sync::RwLock::new(ImportTable::default()), + } + } + + /// All discovered [`PackageEntry`] rows, deepest-root last. + pub fn packages(&self) -> &[PackageEntry] { + &self.packages + } + + /// All discovered tsconfig `paths` mappings. + pub fn tsconfigs(&self) -> &[(PathBuf, TsConfigPaths)] { + &self.tsconfigs + } + + /// `true` when `spec` is a known node builtin (`node:fs`, `fs`, + /// `fs/promises`, etc.). + pub fn is_builtin(&self, spec: &str) -> bool { + let bare = spec.strip_prefix("node:").unwrap_or(spec); + self.builtins.contains(bare) + } + + /// Innermost [`PackageEntry`] containing `file`, if any. + /// + /// "Innermost" = the entry whose `root` is the deepest ancestor of + /// `file`. Returns `None` for paths outside every package root (e.g. + /// scratch files in a parent directory of the scan root). + pub fn package_for(&self, file: &Path) -> Option<&PackageEntry> { + let canonical = canonicalize_or_owned(file); + self.packages + .iter() + .filter(|p| canonical.starts_with(&p.root)) + .max_by_key(|p| p.root.as_os_str().len()) + } + + /// Project-relative or package-qualified namespace string for `file`. + /// + /// Returns `"@scope/name::src/file.ts"` when `file` lies inside a + /// resolved package and `"src/file.ts"` (the bare scan-root-relative + /// path) otherwise. Phase 10 will route [`crate::symbol::FuncKey`] + /// construction through this helper, phase 04 only exposes it. + pub fn project_namespace_for(&self, file: &Path, scan_root: &Path) -> String { + let canonical_file = canonicalize_or_owned(file); + let canonical_root = canonicalize_or_owned(scan_root); + let rel = canonical_file + .strip_prefix(&canonical_root) + .unwrap_or(&canonical_file) + .to_string_lossy() + .into_owned(); + match self.package_for(file) { + Some(pkg) => format!("{}::{}", pkg.name, rel), + None => rel, + } + } + + /// Resolve `spec` as imported by `importer`. + /// + /// Walks the resolution tower in spec order: + /// 1. `node:*` and bare-name builtins → `is_builtin: true`. + /// 2. Relative (`./`, `../`) → file relative to `importer`'s parent. + /// 3. Tsconfig `paths` alias → first existing target under `baseUrl`. + /// 4. Bare package name (`@scope/util`, `lodash`) → matching + /// [`PackageEntry`] root, optionally appending the sub-path + /// (`@scope/util/sub/file`). + /// + /// Returns `None` only when the specifier is structurally invalid + /// (empty string). All other failures land as + /// `Some(ResolvedModule { file: None, .. })` so the caller sees the + /// attempted classification. + pub fn resolve_specifier(&self, importer: &Path, spec: &str) -> Option { + if spec.is_empty() { + return None; + } + + if self.is_builtin(spec) { + return Some(ResolvedModule { + file: None, + package: None, + is_builtin: true, + }); + } + + if spec.starts_with("./") || spec.starts_with("../") || spec == "." || spec == ".." { + let base = importer.parent().unwrap_or_else(|| Path::new("")); + let joined = base.join(spec); + let file = resolve_file_or_index(&joined); + let package = file + .as_ref() + .and_then(|f| self.package_for(f).map(|p| p.name.clone())); + return Some(ResolvedModule { + file, + package, + is_builtin: false, + }); + } + + if let Some(file) = self.resolve_tsconfig_alias(importer, spec) { + let package = self.package_for(&file).map(|p| p.name.clone()); + return Some(ResolvedModule { + file: Some(file), + package, + is_builtin: false, + }); + } + + if let Some(resolved) = self.resolve_bare_package(spec) { + return Some(resolved); + } + + Some(ResolvedModule { + file: None, + package: None, + is_builtin: false, + }) + } + + /// All [`ImportBinding`]s recorded for `file`, or `&[]` when none. + /// + /// Returns an owned `Vec` snapshot rather than a borrow because the + /// underlying [`ImportTable`] is held behind an `RwLock` for parallel + /// CFG-time population. Most call sites only iterate once, so the + /// clone is cheap relative to the lock contention an exposed + /// `RwLockReadGuard` would create. + pub fn imports_for(&self, file: &Path) -> Vec { + self.imports + .read() + .ok() + .and_then(|t| t.per_file.get(file).cloned()) + .unwrap_or_default() + } + + /// Replace the binding list for `file` with `bindings`. + /// + /// Called by the CFG layer after classifying a file's import + /// statements. Idempotent, the last writer for a given file wins. + pub fn record_imports_for_file(&self, file: PathBuf, bindings: Vec) { + if let Ok(mut table) = self.imports.write() { + table.per_file.insert(file, bindings); + } + } + + /// Snapshot the per-file import table. + pub fn snapshot_import_table(&self) -> ImportTable { + self.imports.read().map(|t| t.clone()).unwrap_or_default() + } + + fn resolve_tsconfig_alias(&self, importer: &Path, spec: &str) -> Option { + let canonical_importer = canonicalize_or_owned(importer); + // Prefer the deepest tsconfig that's an ancestor of the importer; + // fall back to any tsconfig if none matches (covers test fixtures + // where the tsconfig sits at the scan root and `importer` is a + // synthetic absolute path). + let mut candidates: Vec<&(PathBuf, TsConfigPaths)> = self + .tsconfigs + .iter() + .filter(|(p, _)| { + p.parent() + .map(|d| canonical_importer.starts_with(d)) + .unwrap_or(false) + }) + .collect(); + if candidates.is_empty() { + candidates = self.tsconfigs.iter().collect(); + } + candidates.sort_by_key(|(p, _)| std::cmp::Reverse(p.as_os_str().len())); + + for (_, ts) in candidates { + for (pat, targets) in &ts.paths { + let suffix = match (pat.has_wildcard, spec.strip_prefix(&pat.prefix)) { + (true, Some(rest)) => Some(rest), + (false, _) if spec == pat.prefix => Some(""), + _ => None, + }; + let Some(suffix) = suffix else { continue }; + for target in targets { + let candidate_str = if pat.has_wildcard { + target.to_string_lossy().replace('*', suffix) + } else { + target.to_string_lossy().into_owned() + }; + let mut candidate = ts.base_url.clone(); + candidate.push(candidate_str); + if let Some(file) = resolve_file_or_index(&candidate) { + return Some(file); + } + } + } + } + None + } + + fn resolve_bare_package(&self, spec: &str) -> Option { + let (pkg_name, sub) = split_package_specifier(spec)?; + let entry = self.packages.iter().find(|p| p.name == pkg_name)?; + let resolved_file = package_entry_resolve(entry, &sub); + Some(ResolvedModule { + file: resolved_file, + package: Some(entry.name.clone()), + is_builtin: false, + }) + } +} + +/// Extract resolved [`ImportBinding`]s from a parsed JS/TS file. +/// +/// Walks top-level `import_statement` nodes, captures every named, default, +/// and namespace specifier, and resolves each against `graph` from the +/// importer's perspective. CommonJS `require(...)` patterns are handled +/// alongside the ES variants. Specifiers that don't classify (empty +/// strings, malformed) are dropped silently, the conservative path mirrors +/// how the legacy CFG-side extractor treats unparseable imports. +/// +/// The returned vector is in source order. The bindings carry both the +/// `source_module` text and the resolver verdict (`resolved_file`, +/// `exported_name`), so consumers that want raw text can keep working +/// without round-tripping through [`ResolvedModule`]. +pub fn extract_resolved_imports( + tree: &tree_sitter::Tree, + code: &[u8], + importer: &Path, + graph: &ModuleGraph, + lang: &str, +) -> Vec { + if !matches!(lang, "javascript" | "typescript" | "tsx") { + return Vec::new(); + } + let raws = walk_js_top_level_imports(tree, code); + let mut cache: HashMap = HashMap::new(); + let mut out = Vec::with_capacity(raws.len()); + for raw in raws { + let resolved = cache.entry(raw.source_spec.clone()).or_insert_with(|| { + graph + .resolve_specifier(importer, &raw.source_spec) + .unwrap_or(ResolvedModule { + file: None, + package: None, + is_builtin: false, + }) + }); + out.push(make_binding( + &raw.local, + &raw.exported, + &raw.source_spec, + resolved, + )); + } + out +} + +/// One raw JS/TS import binding lifted from a top-level +/// `import_statement` / `lexical_declaration` / `variable_declaration`, +/// pre-resolution. Both [`extract_resolved_imports`] (which adds the +/// resolver verdict) and `crate::cfg::imports::extract_local_import_view` +/// (which only needs `local` → `source_spec`) consume this. +/// +/// `local` is empty for side-effect-only `import 'mod'` shapes; consumers +/// that need a local binding skip those entries. +#[derive(Debug, Clone)] +pub struct RawJsImport { + pub local: String, + /// `"default"` for default-import / `const x = require(...)` / shorthand + /// destructure; `"*"` for namespace-import; the pre-alias imported name + /// for named-import / `const { orig: alias } = require(...)`; `""` for + /// side-effect-only `import 'mod'`. + pub exported: String, + /// Source module specifier with surrounding quotes stripped. + pub source_spec: String, +} + +/// Top-level walker for JS/TS `import_statement` and `require(...)` +/// declarations. Returns raw bindings without consulting any +/// [`ModuleGraph`], so it can run at CFG-build time before the resolver +/// has populated its tables. +pub fn walk_js_top_level_imports(tree: &tree_sitter::Tree, code: &[u8]) -> Vec { + let mut out = Vec::new(); + let root = tree.root_node(); + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + match child.kind() { + "import_statement" => walk_import_statement(child, code, &mut out), + "lexical_declaration" | "variable_declaration" => { + walk_require_decl(child, code, &mut out) + } + _ => {} + } + } + out +} + +fn walk_import_statement(node: tree_sitter::Node, code: &[u8], out: &mut Vec) { + let Some(source) = node.child_by_field_name("source") else { + return; + }; + let Ok(raw) = source.utf8_text(code) else { + return; + }; + let spec = raw.trim_matches(|c| c == '\'' || c == '"' || c == '`'); + if spec.is_empty() { + return; + } + + let mut cursor = node.walk(); + let mut emitted_any = false; + for clause_child in node.children(&mut cursor) { + if clause_child.kind() != "import_clause" { + continue; + } + let mut c2 = clause_child.walk(); + for part in clause_child.children(&mut c2) { + match part.kind() { + "identifier" => { + if let Ok(name) = part.utf8_text(code) { + out.push(RawJsImport { + local: name.to_string(), + exported: "default".to_string(), + source_spec: spec.to_string(), + }); + emitted_any = true; + } + } + "namespace_import" => { + let mut c3 = part.walk(); + for ns_child in part.children(&mut c3) { + if ns_child.kind() == "identifier" + && let Ok(name) = ns_child.utf8_text(code) + { + out.push(RawJsImport { + local: name.to_string(), + exported: "*".to_string(), + source_spec: spec.to_string(), + }); + emitted_any = true; + } + } + } + "named_imports" => { + let mut c3 = part.walk(); + for spec_node in part.children(&mut c3) { + if spec_node.kind() != "import_specifier" { + continue; + } + let original = spec_node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(code).ok()); + let alias = spec_node + .child_by_field_name("alias") + .and_then(|n| n.utf8_text(code).ok()); + let (Some(orig), local) = + (original, alias.unwrap_or(original.unwrap_or(""))) + else { + continue; + }; + if local.is_empty() { + continue; + } + out.push(RawJsImport { + local: local.to_string(), + exported: orig.to_string(), + source_spec: spec.to_string(), + }); + emitted_any = true; + } + } + _ => {} + } + } + } + + // Side-effect-only `import "mod"`: emit a marker so phase 09 still + // sees the dependency edge. + if !emitted_any { + out.push(RawJsImport { + local: String::new(), + exported: String::new(), + source_spec: spec.to_string(), + }); + } +} + +fn walk_require_decl(node: tree_sitter::Node, code: &[u8], out: &mut Vec) { + let mut cursor = node.walk(); + for decl in node.children(&mut cursor) { + if decl.kind() != "variable_declarator" { + continue; + } + let (Some(pattern), Some(value)) = ( + decl.child_by_field_name("name"), + decl.child_by_field_name("value"), + ) else { + continue; + }; + let Some(spec) = require_spec_from_value(value, code) else { + continue; + }; + match pattern.kind() { + "identifier" => { + if let Ok(name) = pattern.utf8_text(code) { + out.push(RawJsImport { + local: name.to_string(), + exported: "default".to_string(), + source_spec: spec.clone(), + }); + } + } + "object_pattern" => { + let mut pc = pattern.walk(); + for pair in pattern.children(&mut pc) { + match pair.kind() { + "shorthand_property_identifier_pattern" | "identifier" => { + if let Ok(name) = pair.utf8_text(code) { + out.push(RawJsImport { + local: name.to_string(), + exported: name.to_string(), + source_spec: spec.clone(), + }); + } + } + "pair_pattern" => { + let key = pair + .child_by_field_name("key") + .and_then(|n| n.utf8_text(code).ok()); + let val = pair + .child_by_field_name("value") + .and_then(|n| n.utf8_text(code).ok()); + if let (Some(orig), Some(local)) = (key, val) { + out.push(RawJsImport { + local: local.to_string(), + exported: orig.to_string(), + source_spec: spec.clone(), + }); + } + } + _ => {} + } + } + } + _ => {} + } + } +} + +fn require_spec_from_value(value: tree_sitter::Node, code: &[u8]) -> Option { + if value.kind() != "call_expression" { + return None; + } + let func = value.child_by_field_name("function")?; + let name = func.utf8_text(code).ok()?; + if name != "require" { + return None; + } + let args = value.child_by_field_name("arguments")?; + let mut cursor = args.walk(); + for arg in args.children(&mut cursor) { + if matches!(arg.kind(), "string" | "template_string") { + let raw = arg.utf8_text(code).ok()?; + return Some( + raw.trim_matches(|c| c == '\'' || c == '"' || c == '`') + .to_string(), + ); + } + } + None +} + +fn make_binding( + local: &str, + exported: &str, + spec: &str, + resolved: &ResolvedModule, +) -> ImportBinding { + ImportBinding { + local_name: local.to_string(), + source_module: spec.to_string(), + resolved_file: resolved.file.clone(), + exported_name: if exported.is_empty() { + None + } else { + Some(exported.to_string()) + }, + } +} + +/// Walk every `roots` entry, collect all `package.json` and `tsconfig.json`, +/// and return a populated [`ModuleGraph`]. +/// +/// Hidden directories (`.git`, `.cache`, `.pitboss`) and `node_modules` are +/// skipped, the resolver targets first-party source. Manifest parse errors +/// are logged via `tracing::debug!` and the offending file is dropped from +/// the graph; the rest of the scan continues. Results from multiple roots +/// are merged in walk order. +pub fn build_module_graph(roots: &[PathBuf]) -> ModuleGraph { + let mut graph = ModuleGraph::empty(); + + for root in roots { + let canonical = canonicalize_or_owned(root); + walk_manifests(&canonical, &mut graph); + } + graph +} + +fn walk_manifests(root: &Path, graph: &mut ModuleGraph) { + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + let file_name = entry.file_name(); + let name_lossy = file_name.to_string_lossy(); + if file_type.is_dir() { + if name_lossy.starts_with('.') || name_lossy == "node_modules" { + continue; + } + stack.push(path); + continue; + } + if !file_type.is_file() { + continue; + } + match name_lossy.as_ref() { + "package.json" => { + if let Some(pkg) = parse_package_json(&path) { + graph.packages.push(pkg); + } + } + "tsconfig.json" | "jsconfig.json" => { + if let Some(ts) = parse_tsconfig(&path) { + graph.tsconfigs.push((path, ts)); + } + } + _ => {} + } + } + } +} + +fn parse_package_json(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + let json: serde_json::Value = parse_json_lenient(&bytes).ok()?; + let name = json.get("name")?.as_str()?.to_string(); + let root = path.parent()?.to_path_buf(); + let manifest_main = json + .get("main") + .and_then(|v| v.as_str()) + .or_else(|| json.get("module").and_then(|v| v.as_str())) + .or_else(|| json.get("types").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + let exports = json.get("exports").cloned(); + Some(PackageEntry { + name, + root, + manifest_main, + exports, + }) +} + +/// Resolve a specifier to a concrete file inside `entry`'s package root. +/// +/// `sub` is the post-package portion of the import specifier: +/// `""` for `@scope/util`, `"sub"` for `@scope/util/sub`, etc. +/// +/// Resolution order: +/// 1. `entry.exports` if present — handles string/object/conditional/wildcard +/// shapes via [`resolve_exports_to_relpath`]. +/// 2. `entry.manifest_main` (`main`/`module`/`types`) for the root entry. +/// 3. Direct path join under `entry.root` for sub-paths. +fn package_entry_resolve(entry: &PackageEntry, sub: &str) -> Option { + if let Some(exports) = entry.exports.as_ref() { + // When `exports` is defined, Node treats it as the closure for the + // package: legacy `main` and direct path joins under the package + // root no longer apply. A `null` value or a missing key both + // produce `None` here so downstream consumers see "blocked" rather + // than silently picking up a free file. + let rel = resolve_exports_to_relpath(exports, sub)?; + let stripped = rel.trim_start_matches("./"); + let candidate = entry.root.join(stripped); + return resolve_file_or_index(&candidate); + } + if sub.is_empty() { + let candidate = match entry.manifest_main.as_deref() { + Some(rel) => entry.root.join(rel), + None => entry.root.join("index"), + }; + return resolve_file_or_index(&candidate); + } + let candidate = entry.root.join(sub); + resolve_file_or_index(&candidate) +} + +/// Map `sub` against an `"exports"` JSON value to a relative path. +/// +/// `sub` is the spec tail after the package name (`""`, `"sub"`, +/// `"feat/x"`, …). The returned path is relative to the package root, +/// kept verbatim from the manifest (typically `"./src/main.ts"` style). +/// +/// Handles four spec-defined shapes: +/// - String value (`"exports": "./index.js"`) — root-only. +/// - Subpath map (`{ ".": "./index.js", "./sub": "./sub.js" }`). +/// - Conditional values (`{ ".": { "import": "./esm.mjs", "default": "./fallback.js" } }`). +/// - Subpath patterns (`{ "./feat/*": "./src/feat/*.js" }`). +/// +/// Conditional preference order: `import` → `node` → `default` → `require`. +/// `null` values block the resolution and return `None`. Returns `None` +/// when no key matches; the caller falls back to the legacy `main` path. +fn resolve_exports_to_relpath(exports: &serde_json::Value, sub: &str) -> Option { + let key = if sub.is_empty() { + ".".to_string() + } else if sub.starts_with("./") { + sub.to_string() + } else { + format!("./{sub}") + }; + + match exports { + serde_json::Value::String(s) if key == "." => Some(s.clone()), + serde_json::Value::Object(map) => { + if let Some(val) = map.get(&key) + && let Some(target) = pick_conditional(val) + { + return Some(target); + } + for (pat, val) in map.iter() { + if let Some(inner) = exports_pattern_match(pat, &key) + && let Some(target) = pick_conditional(val) + { + return Some(target.replace('*', &inner)); + } + } + None + } + _ => None, + } +} + +fn pick_conditional(val: &serde_json::Value) -> Option { + match val { + serde_json::Value::Null => None, + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Object(map) => { + for cond in ["import", "node", "default", "require"] { + if let Some(v) = map.get(cond) + && let Some(s) = pick_conditional(v) + { + return Some(s); + } + } + None + } + serde_json::Value::Array(arr) => arr.iter().find_map(pick_conditional), + _ => None, + } +} + +fn exports_pattern_match(pat: &str, key: &str) -> Option { + let idx = pat.find('*')?; + let prefix = &pat[..idx]; + let suffix = &pat[idx + 1..]; + if !key.starts_with(prefix) || !key.ends_with(suffix) { + return None; + } + if key.len() < prefix.len() + suffix.len() { + return None; + } + let inner = &key[prefix.len()..key.len() - suffix.len()]; + Some(inner.to_string()) +} + +fn parse_tsconfig(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + let json: serde_json::Value = parse_json_lenient(&bytes).ok()?; + let opts = json.get("compilerOptions")?; + let dir = path.parent()?.to_path_buf(); + let base_url = match opts.get("baseUrl").and_then(|v| v.as_str()) { + Some(rel) => dir.join(rel), + None => dir, + }; + let mut paths_out: Vec<(GlobPattern, Vec)> = Vec::new(); + if let Some(paths_obj) = opts.get("paths").and_then(|v| v.as_object()) { + for (key, val) in paths_obj { + let targets: Vec = val + .as_array() + .into_iter() + .flatten() + .filter_map(|t| t.as_str()) + .map(PathBuf::from) + .collect(); + if targets.is_empty() { + continue; + } + let (prefix, has_wildcard) = if let Some(stripped) = key.strip_suffix("/*") { + (format!("{stripped}/"), true) + } else if key.ends_with('*') { + (key.trim_end_matches('*').to_string(), true) + } else { + (key.clone(), false) + }; + paths_out.push(( + GlobPattern { + prefix, + has_wildcard, + }, + targets, + )); + } + } + Some(TsConfigPaths { + base_url, + paths: paths_out, + }) +} + +fn parse_json_lenient(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).unwrap_or(""); + let stripped = strip_jsonc(text); + serde_json::from_str(&stripped) +} + +/// Strip line/block comments and trailing commas. tsconfig files are JSONC. +/// +/// Operates on raw bytes and writes raw bytes through to the output so +/// non-ASCII UTF-8 sequences (multi-byte string contents, paths) survive +/// verbatim. The earlier `out.push(b as char)` form re-encoded each +/// continuation byte as its own char and corrupted multi-byte sequences. +/// Comment / string / trailing-comma scanning only checks ASCII bytes +/// (`/`, `*`, `\\`, `"`, `,`, `]`, `}`, `\n`), and UTF-8 continuation +/// bytes are 0x80..=0xBF which never collide with ASCII, so byte-level +/// scanning stays correct on UTF-8 input. +fn strip_jsonc(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out: Vec = Vec::with_capacity(input.len()); + let mut i = 0; + let mut in_string = false; + let mut escape = false; + while i < bytes.len() { + let b = bytes[i]; + if in_string { + out.push(b); + if escape { + escape = false; + } else if b == b'\\' { + escape = true; + } else if b == b'"' { + in_string = false; + } + i += 1; + continue; + } + if b == b'"' { + in_string = true; + out.push(b'"'); + i += 1; + continue; + } + if b == b'/' && i + 1 < bytes.len() { + let next = bytes[i + 1]; + if next == b'/' { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if next == b'*' { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + } + if b == b',' { + // trailing-comma elide: peek ahead past whitespace for ] or } + let mut j = i + 1; + while j < bytes.len() && bytes[j].is_ascii_whitespace() { + j += 1; + } + if j < bytes.len() && (bytes[j] == b']' || bytes[j] == b'}') { + i += 1; + continue; + } + } + out.push(b); + i += 1; + } + String::from_utf8(out).unwrap_or_default() +} + +fn split_package_specifier(spec: &str) -> Option<(String, String)> { + if spec.starts_with('@') { + let mut parts = spec.splitn(3, '/'); + let scope = parts.next()?; + let pkg = parts.next()?; + let rest = parts.next().unwrap_or(""); + Some((format!("{scope}/{pkg}"), rest.to_string())) + } else { + let mut parts = spec.splitn(2, '/'); + let pkg = parts.next()?; + let rest = parts.next().unwrap_or(""); + Some((pkg.to_string(), rest.to_string())) + } +} + +fn resolve_file_or_index(candidate: &Path) -> Option { + if candidate.is_file() { + return Some(normalize_path(candidate)); + } + for ext in RESOLVE_EXTENSIONS { + let mut with_ext = candidate.to_path_buf(); + match with_ext.extension() { + Some(_) => {} + None => { + with_ext.set_extension(ext); + if with_ext.is_file() { + return Some(normalize_path(&with_ext)); + } + } + } + } + if candidate.is_dir() { + for ext in RESOLVE_EXTENSIONS { + let idx = candidate.join(format!("index.{ext}")); + if idx.is_file() { + return Some(normalize_path(&idx)); + } + } + } + None +} + +/// Lexically normalize `.` / `..` segments without touching the +/// filesystem. Used so `../bar/baz` resolves to a canonical path that +/// downstream `ends_with` / `starts_with` checks can match against the +/// scan root. +fn normalize_path(p: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in p.components() { + match comp { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +fn canonicalize_or_owned(p: &Path) -> PathBuf { + p.canonicalize().unwrap_or_else(|_| p.to_path_buf()) +} diff --git a/src/resolve/tests.rs b/src/resolve/tests.rs new file mode 100644 index 00000000..3cd94ef8 --- /dev/null +++ b/src/resolve/tests.rs @@ -0,0 +1,380 @@ +//! Phase-04 resolver tests. +//! +//! Six specifier shapes (relative, parent-relative, scoped package, +//! tsconfig path alias, node builtin, missing) plus a memory-ceiling +//! guard. Each test sets up a synthetic tree under +//! `tests/fixtures/resolver/` (or a `tempfile::TempDir` for the cheap +//! ceiling test), constructs a [`ModuleGraph`] via [`build_module_graph`], +//! and asserts the resolver verdict. + +use super::*; +use std::path::PathBuf; + +fn fixture_root() -> PathBuf { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("tests/fixtures/resolver"); + p +} + +fn root() -> PathBuf { + let r = fixture_root(); + if r.exists() { + r.canonicalize().unwrap_or(r) + } else { + r + } +} + +#[test] +fn resolves_relative_specifier() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "./foo") + .expect("relative spec must classify"); + let file = resolved.file.expect("./foo must resolve"); + assert!( + file.ends_with("apps/web/src/foo.ts"), + "unexpected resolution: {}", + file.display() + ); + assert!(!resolved.is_builtin); +} + +#[test] +fn resolves_parent_relative_specifier() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "../bar/baz") + .expect("../bar/baz must classify"); + let file = resolved.file.expect("../bar/baz must resolve"); + assert!( + file.ends_with("apps/web/bar/baz.ts"), + "unexpected resolution: {}", + file.display() + ); +} + +#[test] +fn resolves_scoped_package_import() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "@scope/util") + .expect("@scope/util must classify"); + assert_eq!(resolved.package.as_deref(), Some("@scope/util")); + let file = resolved.file.expect("@scope/util must resolve to a file"); + assert!( + file.ends_with("packages/util/src/index.ts") || file.ends_with("packages/util/index.ts"), + "unexpected resolution: {}", + file.display() + ); +} + +#[test] +fn resolves_tsconfig_path_alias() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "@/lib/x") + .expect("@/lib/x must classify"); + let file = resolved.file.expect("@/lib/x must resolve"); + assert!( + file.ends_with("apps/web/src/lib/x.ts"), + "unexpected resolution: {}", + file.display() + ); +} + +#[test] +fn classifies_node_builtin_specifier() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "node:fs/promises") + .expect("node:fs/promises must classify"); + assert!(resolved.is_builtin); + assert!(resolved.file.is_none()); + assert!(resolved.package.is_none()); + + let bare = graph + .resolve_specifier(&importer, "fs") + .expect("bare 'fs' must classify"); + assert!(bare.is_builtin); +} + +#[test] +fn missing_module_returns_none_resolved_file() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "no-such-package") + .expect("non-empty spec must classify"); + assert!(!resolved.is_builtin); + assert!(resolved.file.is_none(), "missing module must not resolve"); + assert!(resolved.package.is_none()); +} + +#[test] +fn package_for_returns_innermost_match() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let inner = r.join("packages/util/src/index.ts"); + let outer_pkg = graph + .package_for(&inner) + .expect("file under packages/util belongs to a package"); + assert_eq!(outer_pkg.name, "@scope/util"); + + let app_file = r.join("apps/web/src/index.ts"); + let web_pkg = graph + .package_for(&app_file) + .expect("file under apps/web belongs to a package"); + assert_eq!(web_pkg.name, "web-app"); +} + +#[test] +fn project_namespace_prefixes_when_in_package() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let in_pkg = r.join("packages/util/src/index.ts"); + let ns = graph.project_namespace_for(&in_pkg, &r); + assert!( + ns.starts_with("@scope/util::"), + "expected package-prefixed namespace, got {ns}" + ); + + let outside = std::env::temp_dir().join("nyx-resolver-outside.ts"); + let plain = graph.project_namespace_for(&outside, &r); + assert!( + !plain.contains("::"), + "outside-package namespace must be plain: {plain}" + ); +} + +/// `"exports"."."` conditional map: `import` branch wins over `default`, +/// and the legacy `main` field is shadowed when exports resolve. +#[test] +fn resolves_exports_root_conditional() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "@scope/exports-pkg") + .expect("@scope/exports-pkg must classify"); + assert_eq!(resolved.package.as_deref(), Some("@scope/exports-pkg")); + let file = resolved.file.expect("@scope/exports-pkg must resolve"); + assert!( + file.ends_with("exports-pkg/src/main.ts"), + "expected import-branch main.ts, got {}", + file.display() + ); +} + +/// Exact subpath key (`"./sub": "./src/sub.ts"`) resolves before any +/// pattern fallback would fire. +#[test] +fn resolves_exports_exact_subpath() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "@scope/exports-pkg/sub") + .expect("subpath spec must classify"); + let file = resolved.file.expect("./sub must resolve"); + assert!( + file.ends_with("exports-pkg/src/sub.ts"), + "unexpected resolution: {}", + file.display() + ); +} + +/// Wildcard pattern (`"./feat/*": "./src/feat/*.ts"`) substitutes the +/// matched tail into the target. +#[test] +fn resolves_exports_wildcard_subpath() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "@scope/exports-pkg/feat/widget") + .expect("wildcard subpath must classify"); + let file = resolved.file.expect("./feat/widget must resolve"); + assert!( + file.ends_with("exports-pkg/src/feat/widget.ts"), + "unexpected resolution: {}", + file.display() + ); +} + +/// `null` value blocks the subpath: resolver returns no file rather than +/// falling back to a direct path join. +#[test] +fn exports_null_blocks_subpath() { + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let resolved = graph + .resolve_specifier(&importer, "@scope/exports-pkg/blocked") + .expect("blocked spec must classify"); + assert!( + resolved.file.is_none(), + "null exports value must not resolve, got {:?}", + resolved.file + ); +} + +#[test] +fn module_graph_is_cheap() { + use std::time::Instant; + + let r = root(); + let bytes_before = approximate_rss_kib(); + let start = Instant::now(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let elapsed = start.elapsed(); + let bytes_after = approximate_rss_kib(); + + assert!( + elapsed.as_millis() < 50, + "build_module_graph took {}ms (>50ms ceiling)", + elapsed.as_millis() + ); + + let delta_kib = bytes_after.saturating_sub(bytes_before); + assert!( + delta_kib < 10 * 1024, + "build_module_graph added {delta_kib} KiB RSS (>10 MiB ceiling)" + ); + + assert!( + !graph.packages().is_empty(), + "fixture tree must have packages" + ); +} + +/// Parse a TypeScript file with tree-sitter and run +/// [`extract_resolved_imports`] against it. Tests pull this through to +/// keep the parsing setup in one place. +fn extract_imports_for(file: &std::path::Path, graph: &ModuleGraph) -> Vec { + let bytes = std::fs::read(file).expect("read fixture file"); + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter::Language::from( + tree_sitter_typescript::LANGUAGE_TYPESCRIPT, + )) + .expect("load TS grammar"); + let tree = parser.parse(&bytes, None).expect("parse fixture"); + extract_resolved_imports(&tree, &bytes, file, graph, "typescript") +} + +#[test] +fn parses_imports_from_fixture_file() { + // Verify `extract_resolved_imports` lifts the same four binding shapes + // that `tests/fixtures/resolver/apps/web/src/index.ts` exercises: + // relative, parent-relative, scoped package, tsconfig path alias, plus + // the `node:fs/promises` builtin. Phases 09/10 thread these bindings + // through cross-file taint, so the parsed-file integration path must + // produce the rows the resolver tests already cover via + // `resolve_specifier`. + let r = root(); + let graph = build_module_graph(std::slice::from_ref(&r)); + let importer = r.join("apps/web/src/index.ts"); + let bindings = extract_imports_for(&importer, &graph); + + let by_local: std::collections::HashMap<&str, &ImportBinding> = bindings + .iter() + .map(|b| (b.local_name.as_str(), b)) + .collect(); + + // `import { foo } from "./foo"` — relative. + let foo = by_local.get("foo").expect("foo binding present"); + assert_eq!(foo.source_module, "./foo"); + assert_eq!(foo.exported_name.as_deref(), Some("foo")); + let foo_file = foo.resolved_file.as_ref().expect("./foo resolves"); + assert!( + foo_file.ends_with("apps/web/src/foo.ts"), + "foo unexpected: {}", + foo_file.display() + ); + + // `import { baz } from "../bar/baz"` — parent-relative. + let baz = by_local.get("baz").expect("baz binding present"); + assert_eq!(baz.source_module, "../bar/baz"); + let baz_file = baz.resolved_file.as_ref().expect("../bar/baz resolves"); + assert!( + baz_file.ends_with("apps/web/bar/baz.ts"), + "baz unexpected: {}", + baz_file.display() + ); + + // `import { util } from "@scope/util"` — scoped package. + let util = by_local.get("util").expect("util binding present"); + assert_eq!(util.source_module, "@scope/util"); + assert!( + util.resolved_file.is_some(), + "@scope/util must resolve to a file" + ); + + // `import { x } from "@/lib/x"` — tsconfig path alias. + let x = by_local.get("x").expect("x binding present"); + assert_eq!(x.source_module, "@/lib/x"); + let x_file = x.resolved_file.as_ref().expect("@/lib/x resolves"); + assert!( + x_file.ends_with("apps/web/src/lib/x.ts"), + "x unexpected: {}", + x_file.display() + ); + + // `import { promises as fs } from "node:fs/promises"` — node builtin. + // Local-name binding must use the alias `fs`, not the original `promises`. + let fs = by_local.get("fs").expect("fs alias binding present"); + assert_eq!(fs.source_module, "node:fs/promises"); + assert_eq!(fs.exported_name.as_deref(), Some("promises")); + assert!( + fs.resolved_file.is_none(), + "node:* builtin must not carry a resolved file" + ); +} + +/// Best-effort RSS reader. Returns 0 on any failure, the test only uses +/// the delta and treats "0 → 0" as "below ceiling". +fn approximate_rss_kib() -> u64 { + #[cfg(target_os = "linux")] + { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines().find(|l| l.starts_with("VmRSS:")).and_then(|l| { + l.split_whitespace() + .nth(1) + .and_then(|n| n.parse::().ok()) + }) + }) + .unwrap_or(0) + } + #[cfg(target_os = "macos")] + { + let output = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .ok(); + output + .and_then(|o| { + String::from_utf8(o.stdout) + .ok() + .and_then(|s| s.trim().parse::().ok()) + }) + .unwrap_or(0) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + 0 + } +} diff --git a/src/server/app.rs b/src/server/app.rs index ea290d31..83144753 100644 --- a/src/server/app.rs +++ b/src/server/app.rs @@ -137,7 +137,7 @@ mod tests { AppState { scan_root: scan_root.clone(), config_dir: scan_root.clone(), - database_dir: scan_root.clone(), + database_dir: scan_root, security: LocalServerSecurity::new(port), config: Arc::new(RwLock::new(Config::default())), job_manager: Arc::new(JobManager::new(4, 8 * 1024 * 1024)), diff --git a/src/server/debug.rs b/src/server/debug.rs index d46fd5ee..c118fcb5 100644 --- a/src/server/debug.rs +++ b/src/server/debug.rs @@ -1187,6 +1187,18 @@ fn type_kind_tag(k: &TypeKind) -> String { TypeKind::Template => "Template".into(), TypeKind::Dto(_) => "Dto".into(), TypeKind::NullPrototypeObject => "NullPrototypeObject".into(), + TypeKind::FileSystemPromisesNs => "FileSystemPromisesNs".into(), + TypeKind::Sequelize => "Sequelize".into(), + TypeKind::TypeOrmRepo => "TypeOrmRepo".into(), + TypeKind::TypeOrmManager => "TypeOrmManager".into(), + TypeKind::MikroOrmEm => "MikroOrmEm".into(), + TypeKind::Request => "Request".into(), + TypeKind::SqlAlchemySession => "SqlAlchemySession".into(), + TypeKind::DjangoQuerySet => "DjangoQuerySet".into(), + TypeKind::ActiveRecordRelation => "ActiveRecordRelation".into(), + TypeKind::GormDb => "GormDb".into(), + TypeKind::SqlxDb => "SqlxDb".into(), + TypeKind::HibernateSession => "HibernateSession".into(), } } @@ -1565,6 +1577,10 @@ pub fn analyse_function_taint( auto_seed_handler_params: matches!(lang, Lang::JavaScript | Lang::TypeScript), cross_file_bodies: global_summaries.and_then(|gs| gs.bodies_by_key()), pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; crate::taint::ssa_transfer::run_ssa_taint_full_with_exits(ssa, cfg, &transfer) @@ -1628,7 +1644,7 @@ pub fn analyse_file_summaries( config: &Config, ) -> Result { let bytes = std::fs::read(file_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let (func_summaries, ssa_rows, _ssa_bodies, auth_rows) = + let (func_summaries, ssa_rows, _ssa_bodies, auth_rows, cross_pkg_imports) = crate::ast::extract_all_summaries_from_bytes(&bytes, file_path, config, None) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -1640,6 +1656,9 @@ pub fn analyse_file_summaries( for (key, auth_summary) in auth_rows { global.insert_auth(key, auth_summary); } + if let Some((ns, map)) = cross_pkg_imports { + global.insert_cross_package_imports(ns, map); + } Ok(global) } @@ -1883,6 +1902,7 @@ function consume() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); @@ -2039,6 +2059,7 @@ async function recentAuditLogs() { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let facts = analyse_body(&body, BodyId(0)); diff --git a/src/server/jobs.rs b/src/server/jobs.rs index 9efd107a..0b0d37bb 100644 --- a/src/server/jobs.rs +++ b/src/server/jobs.rs @@ -169,7 +169,7 @@ impl JobManager { started_at: Some(chrono::Utc::now().to_rfc3339()), finished_at: None, duration_secs: None, - engine_version: Some(engine_version.clone()), + engine_version: Some(engine_version), languages: None, files_scanned: None, files_skipped: None, @@ -261,7 +261,7 @@ impl JobManager { let languages: Vec = progress_snap.languages.keys().cloned().collect(); let files_scanned = progress_snap.files_discovered; let files_skipped = progress_snap.files_skipped; - let timing = progress_snap.timing.clone(); + let timing = progress_snap.timing; let finished_at = chrono::Utc::now(); // Prepare the final state outside the lock. @@ -292,9 +292,9 @@ impl JobManager { if let Some(job) = jobs.get_mut(&jid) { job.finished_at = Some(finished_at); job.duration_secs = Some(elapsed); - job.languages = Some(languages.clone()); + job.languages = Some(languages); job.files_scanned = Some(files_scanned); - job.timing = Some(timing.clone()); + job.timing = Some(timing); job.status = status.clone(); job.findings = diags; job.error = error_str.clone(); @@ -590,7 +590,7 @@ handleRequest({ query: { name: 'x' } }, { send() {} }); let id = manager .start_scan( - project_dir.clone(), + project_dir, test_config(), tx, Some(Arc::clone(&pool)), diff --git a/src/server/routes/config.rs b/src/server/routes/config.rs index 73ef35ed..c3148ba4 100644 --- a/src/server/routes/config.rs +++ b/src/server/routes/config.rs @@ -161,7 +161,7 @@ async fn add_rule( .or_default(); let new_rule = crate::utils::config::ConfigLabelRule { - matchers: rule.matchers.clone(), + matchers: rule.matchers, kind: rule_kind, cap: cap_name, case_sensitive: false, @@ -242,7 +242,7 @@ async fn add_terminator( .entry(term.lang.clone()) .or_default(); if !lang_cfg.terminators.contains(&term.name) { - lang_cfg.terminators.push(term.name.clone()); + lang_cfg.terminators.push(term.name); } } diff --git a/src/server/routes/debug.rs b/src/server/routes/debug.rs index a9c5540e..b0604305 100644 --- a/src/server/routes/debug.rs +++ b/src/server/routes/debug.rs @@ -447,6 +447,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, )], ) @@ -520,6 +521,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }, false, false, @@ -544,6 +546,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }, true, true, @@ -568,6 +571,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }, true, false, @@ -666,6 +670,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, )], ) diff --git a/src/server/routes/overview.rs b/src/server/routes/overview.rs index 622670ea..00d15c5a 100644 --- a/src/server/routes/overview.rs +++ b/src/server/routes/overview.rs @@ -149,7 +149,7 @@ async fn overview(State(state): State) -> Json { latest_scan_id, latest_scan_at, by_severity: summary.by_severity.clone(), - by_category: summary.by_category.clone(), + by_category: summary.by_category, by_language, top_files, top_directories, diff --git a/src/server/routes/scans.rs b/src/server/routes/scans.rs index 17ffb50c..18fdb39b 100644 --- a/src/server/routes/scans.rs +++ b/src/server/routes/scans.rs @@ -309,13 +309,12 @@ async fn get_scan_findings( let per_page = query.per_page.unwrap_or(50).min(200); let start = (page - 1) * per_page; - let scan_root = state.scan_root.clone(); let page_findings: Vec = filtered .into_iter() .enumerate() .skip(start) .take(per_page) - .map(|(i, d)| models::finding_from_diag_with_context(i, d, &scan_root)) + .map(|(i, d)| models::finding_from_diag_with_context(i, d, &state.scan_root)) .collect(); Ok(Json(serde_json::json!({ @@ -361,8 +360,6 @@ async fn compare_scans( .push((i, d)); } - let scan_root = state.scan_root.clone(); - let mut new_findings = Vec::new(); let mut fixed_findings = Vec::new(); let mut changed_findings = Vec::new(); @@ -378,7 +375,7 @@ async fn compare_scans( for i in 0..matched { let (idx, diag) = right_group[i]; let (_, left_diag) = left_group[i]; - let view = models::finding_from_diag_with_context(idx, diag, &scan_root); + let view = models::finding_from_diag_with_context(idx, diag, &state.scan_root); let changes = compute_field_changes(left_diag, diag); if changes.is_empty() { unchanged_findings.push(ComparedFinding { @@ -397,7 +394,7 @@ async fn compare_scans( for &(idx, diag) in &right_group[matched..] { new_findings.push(ComparedFinding { fingerprint: fp.clone(), - finding: models::finding_from_diag_with_context(idx, diag, &scan_root), + finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root), }); } } else { @@ -405,7 +402,7 @@ async fn compare_scans( for &(idx, diag) in right_group { new_findings.push(ComparedFinding { fingerprint: fp.clone(), - finding: models::finding_from_diag_with_context(idx, diag, &scan_root), + finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root), }); } } @@ -419,7 +416,7 @@ async fn compare_scans( for &(idx, diag) in &left_group[start..] { fixed_findings.push(ComparedFinding { fingerprint: fp.clone(), - finding: models::finding_from_diag_with_context(idx, diag, &scan_root), + finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root), }); } } diff --git a/src/ssa/alias.rs b/src/ssa/alias.rs index ac7a6973..70e19f49 100644 --- a/src/ssa/alias.rs +++ b/src/ssa/alias.rs @@ -219,6 +219,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } diff --git a/src/ssa/const_prop.rs b/src/ssa/const_prop.rs index f3d70b68..39542d11 100644 --- a/src/ssa/const_prop.rs +++ b/src/ssa/const_prop.rs @@ -741,6 +741,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } diff --git a/src/ssa/copy_prop.rs b/src/ssa/copy_prop.rs index 5b4f355e..795c703c 100644 --- a/src/ssa/copy_prop.rs +++ b/src/ssa/copy_prop.rs @@ -217,6 +217,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let (eliminated, copy_map) = copy_propagate(&mut body, &cfg); @@ -300,6 +301,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let (eliminated, copy_map) = copy_propagate(&mut body, &cfg); @@ -372,6 +374,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; (cfg, body) } @@ -496,6 +499,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let (eliminated, _map) = copy_propagate(&mut body, &cfg); assert_eq!(eliminated, 0, "two-operand Assign is not a copy"); @@ -577,6 +581,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let (eliminated, _) = copy_propagate(&mut body, &cfg); assert_eq!(eliminated, 1, "v1 should be eliminated"); @@ -676,6 +681,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let (eliminated, _map) = copy_propagate(&mut body, &cfg); assert_eq!(eliminated, 1); @@ -726,6 +732,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let (eliminated, map) = copy_propagate(&mut body, &cfg); assert_eq!(eliminated, 0); diff --git a/src/ssa/dce.rs b/src/ssa/dce.rs index 1bbb328d..5001e46c 100644 --- a/src/ssa/dce.rs +++ b/src/ssa/dce.rs @@ -219,6 +219,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -269,6 +270,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -320,6 +322,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -367,6 +370,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -406,6 +410,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -472,6 +477,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -541,6 +547,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -603,6 +610,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -655,6 +663,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -744,6 +753,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); @@ -823,6 +833,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let removed = eliminate_dead_defs(&mut body, &cfg); diff --git a/src/ssa/invariants.rs b/src/ssa/invariants.rs index 774a9b14..44e279ef 100644 --- a/src/ssa/invariants.rs +++ b/src/ssa/invariants.rs @@ -790,6 +790,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let errs = check_structural_invariants(&body); assert!( @@ -839,6 +840,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let errs = check_structural_invariants(&body); assert!( @@ -891,6 +893,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let errs = check_structural_invariants(&body); assert!( @@ -921,6 +924,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let errs = check_structural_invariants(&body); assert!( diff --git a/src/ssa/ir.rs b/src/ssa/ir.rs index 112adae0..2b52ab74 100644 --- a/src/ssa/ir.rs +++ b/src/ssa/ir.rs @@ -373,6 +373,22 @@ pub struct SsaBody { /// produced before this field existed. #[serde(default)] pub synthetic_externals: HashSet, + /// SSA values whose [`SsaOp::Assign`] is a slot-scoped binding from a + /// bare-array destructure rewrite (see `bare_array_ops` in + /// [`crate::ssa::lower`]). The Assign transfer arm in + /// [`crate::taint::ssa_transfer`] consults this set to skip the + /// `info.taint.labels` Source pickup that would otherwise bleed the + /// outer destructure node's Source label into the slot-scoped binding. + /// + /// Operand union still runs normally, so transitive taint via an + /// inner ident (e.g. `helper(tainted_local)` in slot 1 of + /// `[req.body.other, helper(tainted_local)]`) propagates through the + /// Assign's operands without inheriting the outer-node Source. + /// + /// Empty by default; only the per-slot kill arm in the bare-array + /// destructure lowering populates this set. + #[serde(default)] + pub slot_scoped_assigns: HashSet, } impl SsaBody { @@ -581,6 +597,7 @@ mod tests { field_interner: FieldInterner::new(), field_writes: HashMap::new(), synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; let fid = body.intern_field("mu"); body.blocks[0].body.push(SsaInst { diff --git a/src/ssa/lower.rs b/src/ssa/lower.rs index 1939d4d5..a72d1a45 100644 --- a/src/ssa/lower.rs +++ b/src/ssa/lower.rs @@ -257,6 +257,7 @@ fn lower_to_ssa_inner( field_interner, field_writes, synthetic_externals, + slot_scoped_assigns, ) = rename_variables( cfg, &blocks_nodes, @@ -326,6 +327,7 @@ fn lower_to_ssa_inner( field_interner, field_writes, synthetic_externals, + slot_scoped_assigns, }; // 9. Catch-block reachability invariant. @@ -957,6 +959,7 @@ fn rename_variables( crate::ssa::ir::FieldInterner, HashMap, HashSet, + HashSet, ) { let num_blocks = blocks_nodes.len(); let mut next_value: u32 = 0; @@ -973,6 +976,10 @@ fn rename_variables( // Populated below at the synthetic-Assign emission site. Read by // the taint engine to lift the assign into a structural field WRITE. let mut field_writes: HashMap = HashMap::new(); + // SSA values whose `Assign` comes from a bare-array destructure + // slot-scoped kill arm; the taint engine consults this set to skip + // outer-node Source label pickup while still unioning operand taint. + let mut slot_scoped_assigns: HashSet = HashSet::new(); // Per-variable rename stacks let mut var_stacks: HashMap> = HashMap::new(); @@ -1041,6 +1048,7 @@ fn rename_variables( nop_nodes: &HashSet, field_interner: &mut crate::ssa::ir::FieldInterner, field_writes: &mut HashMap, + slot_scoped_assigns: &mut HashSet, ) { let block_id = BlockId(block_idx as u32); @@ -1258,6 +1266,27 @@ fn rename_variables( } else { SsaOp::Assign(uses) } + } else if info.is_await_forward + && info.call.callee.is_none() + && !info.taint.uses.is_empty() + { + // `await x` resolves to the same value as `x` — model as a 1:1 + // copy so taint, origins, and abstract-domain facts forward + // unchanged. Gated on `callee.is_none()` so an await-wrapped + // call still lowers as a Call op rather than being collapsed + // to Assign (today CFG splits `await foo(x)` into two nodes, + // but the guard keeps the invariant explicit). + let uses: SmallVec<[SsaValue; 4]> = info + .taint + .uses + .iter() + .filter_map(|u| var_stacks.get(u).and_then(|s| s.last().copied())) + .collect(); + if uses.is_empty() { + SsaOp::Nop + } else { + SsaOp::Assign(uses) + } } else if matches!( info.kind, StmtKind::Entry @@ -1344,15 +1373,311 @@ fn rename_variables( cfg_node_map.insert(node, v); - // Clone op for potential extra_defines before moving into SsaInst - let primary_op_for_extras = if info.taint.extra_defines.is_empty() { + // Promise.all-style array-destructure precision: when a CallWrapper + // node binds an array_pattern (`const [a, b] = await Promise.all( + // [x, y])` or `let (a, b) = tokio::join!(x, y)`) and the value is a + // promise combinator that produces an array/tuple of per-element + // results (`Promise.all`, `Promise.allSettled`, `asyncio.gather`, + // `tokio::join!` and friends), rewrite the per-binding SSA so each + // binding sees only its own index's taint instead of the scalar + // union that `try_apply_promise_combinator` would produce. + // + // Two argument shapes are supported: + // (a) literal-array (JS/Python): one positional arg whose + // collected idents represent the array elements in order, + // e.g. `Promise.all([x, y])` → args = [[x, y]]. + // (b) positional (Rust macros): N positional args, each one + // ident, e.g. `tokio::join!(x, y)` → args = [[x], [y]]. + // + // `Promise.race` and `Promise.resolve` are excluded: the awaited + // value of a race is whichever promise wins (a single value, not + // an array), and destructuring that value index-by-index does not + // correspond to the args. + // The rewrite fires when: + // - the call is a promise combinator that produces an array of + // per-element results (`All` / `AllSettled`), AND + // - the LHS destructures into >= 2 bindings (sequential case + // where `extra_defines` is non-empty), OR + // - the LHS is an array_pattern with at least one skip slot + // (`array_pattern_indices` is non-empty, even if `extra_defines` + // itself is empty — `const [, b]` is a single-binding pattern + // whose index is 1, not 0). + let is_combinator_rewrite_target = matches!( + info.call + .callee + .as_deref() + .and_then(crate::labels::is_any_promise_combinator), + Some( + crate::labels::PromiseCombinatorKind::All + | crate::labels::PromiseCombinatorKind::AllSettled + ) + ); + // Indices for each binding in source order: primary at index 0, + // then extras. Falls back to sequential 0..N when the AST didn't + // record explicit indices (non-array_pattern destructures and + // tuple_pattern shapes that contain no wildcards). + let binding_indices: SmallVec<[usize; 4]> = + if !info.taint.array_pattern_indices.is_empty() { + info.taint.array_pattern_indices.clone() + } else if !info.taint.extra_defines.is_empty() { + (0..=info.taint.extra_defines.len()).collect() + } else { + SmallVec::new() + }; + let promise_destruct_args: Option> = + if is_combinator_rewrite_target && !binding_indices.is_empty() { + let max_index = binding_indices.iter().copied().max().unwrap_or(0); + let needed = max_index + 1; + // Use `info.call.arg_uses` directly rather than the + // build_call_args-derived `args`, which may include an + // implicit "uses not in arg_uses" group appended for chain + // bookkeeping that would inflate the apparent arity. + let arg_uses = &info.call.arg_uses; + let map_idents = |idents: &[String]| -> Option> { + let mapped: SmallVec<[SsaValue; 4]> = idents + .iter() + .take(needed) + .filter_map(|ident| { + var_stacks.get(ident).and_then(|s| s.last().copied()) + }) + .collect(); + if mapped.len() == needed { + Some(mapped) + } else { + None + } + }; + if arg_uses.len() == 1 && arg_uses[0].len() >= needed { + // Shape (a): single positional arg whose idents are the + // array elements in source order (`Promise.all([x, y])`, + // `asyncio.gather([x, y])`). + map_idents(&arg_uses[0]) + } else if arg_uses.len() >= needed + && arg_uses.iter().take(needed).all(|g| g.len() == 1) + { + // Shape (b): N positional args, each with one ident + // (`tokio::join!(x, y)`). + let names: Vec<&String> = + arg_uses.iter().take(needed).map(|g| &g[0]).collect(); + let mapped: SmallVec<[SsaValue; 4]> = names + .iter() + .filter_map(|ident| { + var_stacks + .get(ident.as_str()) + .and_then(|s| s.last().copied()) + }) + .collect(); + if mapped.len() == needed { + Some(mapped) + } else { + None + } + } else { + None + } + } else { + None + }; + + // Bare-array RHS destructure precision: when the LHS is an + // array_pattern / tuple_pattern / pattern_list / left_assignment_list + // AND the RHS is a bare array-literal, build per-source-position + // ops so each binding sees only its index's element instead of + // the scalar union of every RHS ident. + // + // Three slot shapes are recognised by `collect_rhs_array_literal_elements`: + // + // * `Ident(name)` — bare identifier. Emit `Assign(reaching_def)`. + // * `Literal` — syntactic literal (string/number/etc.). Emit + // `Const(None)` so the binding carries no taint. + // * `Complex(uses)` — call / binary / subscript / member access / + // interpolated string / nested array literal / etc. Emit + // `Assign(union of inner ident reaching defs)` — slot-scoped + // union, not the whole-RHS union the legacy path produced. + // Falls back to `Const(None)` when no inner idents resolve + // (pure literal subexpression like `1 + 2`). + // + // Closes FPs like `const [a, b] = [safe, tainted]; exec(b);` + // (Ident shape) and `const [c, d] = [fn(req.x), 'lit']; exec(d);` + // (Complex shape) where the legacy union painted the safe binding. + // + // The promise-combinator path above has already populated + // `promise_destruct_args` when its preconditions held, so the + // mutual exclusion is gated through `promise_destruct_args.is_none()` + // rather than `info.call.callee.is_none()`. The earlier + // callee-none gate was wrong because the outer + // variable_declarator node picks up `info.call.callee` whenever + // the RHS text matches a Source label — which is exactly the + // case where we need the per-slot rewrite most. + // The outer node may carry a `DataLabel::Source(_)` whose + // classification matched somewhere in the RHS expression text + // (`req.body.cmd`, `process.env.X`, etc.). For multi-slot + // RHS we can't statically partition WHICH slot caused that + // match, but it must originate from a Complex slot (Literal + // and bare-Ident slots whose names resolve through + // `var_stacks` carry their own SsaValue identity). Treat + // Complex slots as Source-emitting when the outer label set + // included Source — strict precision improvement over the + // legacy union path which painted EVERY slot, including + // Literal, with the outer Source. + let outer_is_source = info + .taint + .labels + .iter() + .any(|l| matches!(l, crate::labels::DataLabel::Source(_))); + + // Per-slot Source classification (see `RhsArraySlot::Complex.source_cap`): + // when at least one Complex slot's own subtree classified as + // Source, we know which slot(s) carried the source pattern, so + // sibling Complex slots without their own source_cap stay + // slot-scoped (Assign / Const). Otherwise (the outer node + // matched but no per-slot classifier fired — typical of subscript + // chains and other shapes whose source flows via reaching-def + // rather than static text), fall back to the conservative + // "all-Complex-are-Source" emission for legacy preservation. + use crate::cfg::RhsArraySlot; + let any_slot_has_source_cap = info.taint.rhs_array_elements.iter().any(|s| { + matches!( + s, + RhsArraySlot::Complex { source_cap, .. } + if !source_cap.is_empty() + ) + }); + let effective_outer_fallback = outer_is_source && !any_slot_has_source_cap; + + let bare_array_ops: Option<(SmallVec<[SsaOp; 4]>, SmallVec<[bool; 4]>)> = + if !info.taint.rhs_array_elements.is_empty() + && !binding_indices.is_empty() + && promise_destruct_args.is_none() + { + let max_index = binding_indices.iter().copied().max().unwrap_or(0); + let needed = max_index + 1; + if info.taint.rhs_array_elements.len() < needed { + None + } else { + let mut per_pos: SmallVec<[SsaOp; 4]> = SmallVec::new(); + let mut slot_scoped_mask: SmallVec<[bool; 4]> = SmallVec::new(); + let mut bail = false; + for slot in info.taint.rhs_array_elements.iter().take(needed) { + let mut is_slot_scoped = false; + let slot_op = match slot { + RhsArraySlot::Ident(ident) => { + match var_stacks + .get(ident.as_str()) + .and_then(|s| s.last().copied()) + { + Some(sv) => SsaOp::Assign(SmallVec::from_elem(sv, 1)), + None => { + bail = true; + break; + } + } + } + RhsArraySlot::Literal => SsaOp::Const(None), + RhsArraySlot::Complex { + uses: inner_uses, + source_cap, + } => { + let mut mapped: SmallVec<[SsaValue; 4]> = SmallVec::new(); + for ident in inner_uses.iter() { + if let Some(sv) = var_stacks + .get(ident.as_str()) + .and_then(|s| s.last().copied()) + { + if !mapped.contains(&sv) { + mapped.push(sv); + } + } + } + if !source_cap.is_empty() { + // Per-slot classification found a Source + // pattern (e.g. `req.body.cmd`) inside + // THIS slot's subtree. Emit Source so the + // binding inherits the outer-node Source + // caps for this slot's index. + SsaOp::Source + } else if outer_is_source && any_slot_has_source_cap { + // Some OTHER slot's subtree classified as + // Source; this slot did NOT. Emit + // Assign(mapped) and mark the slot as + // slot-scoped so the taint transfer's + // Assign arm skips outer-node Source + // label pickup for this binding (without + // losing transitive taint through inner + // uses). When `mapped` is empty, fall + // back to Const(None) — the binding + // carries no taint anyway. + if mapped.is_empty() { + SsaOp::Const(None) + } else { + is_slot_scoped = true; + SsaOp::Assign(mapped.clone()) + } + } else if effective_outer_fallback { + // Outer-node Source label but no + // per-slot classifier fired on any slot + // (typical of subscript-on-tainted-local + // shapes). Preserve legacy conservative + // emission for unrecognised shapes. + SsaOp::Source + } else if mapped.is_empty() { + SsaOp::Const(None) + } else { + SsaOp::Assign(mapped) + } + } + }; + per_pos.push(slot_op); + slot_scoped_mask.push(is_slot_scoped); + } + if bail { + None + } else { + Some((per_pos, slot_scoped_mask)) + } + } + } else { + None + }; + + // Clone op for potential extra_defines before moving into SsaInst. + // For the destructure-promise / bare-array rewrites, the + // per-extra ops are built explicitly below, so the shared clone + // path is bypassed. + let primary_op_for_extras = if info.taint.extra_defines.is_empty() + || promise_destruct_args.is_some() + || bare_array_ops.is_some() + { None } else { Some(op.clone()) }; + + // Override primary op to single-operand Assign when the + // destructure-promise rewrite fires. The primary's source-order + // index is `binding_indices[0]` — non-zero for skip-leading + // patterns like `const [, b]` where `b` is the FIRST (and only) + // binding but lives at pattern position 1. + let primary_op = if let Some(ref args) = promise_destruct_args { + let primary_idx = binding_indices.first().copied().unwrap_or(0); + let pick = args.get(primary_idx).copied().unwrap_or(args[0]); + SsaOp::Assign(SmallVec::from_elem(pick, 1)) + } else if let Some((ref per_pos, ref slot_scoped_mask)) = bare_array_ops { + let primary_idx = binding_indices.first().copied().unwrap_or(0); + if slot_scoped_mask.get(primary_idx).copied().unwrap_or(false) { + slot_scoped_assigns.insert(v); + } + per_pos + .get(primary_idx) + .cloned() + .unwrap_or(SsaOp::Const(None)) + } else { + op + }; + ssa_blocks[block_idx].body.push(SsaInst { value: v, - op, + op: primary_op, cfg_node: node, var_name: var_name_for_ssa.clone(), span: info.ast.span, @@ -1423,7 +1748,66 @@ fn rename_variables( // Emit extra SSA instructions for destructuring bindings. // Each extra define inherits the same op (Source/Call/Assign) as the primary. - if let Some(ref primary_op) = primary_op_for_extras { + // + // For the destructure-promise rewrite, each extra emits an Assign + // on its corresponding indexed argument so per-element taint is + // preserved instead of the scalar union. The source-order index + // for `extra_defines[i]` is `binding_indices[i + 1]` — accounts + // for skip slots like `const [a, , b]` where `b` sits at index 2, + // not at index 1. + if let Some(ref pd_args) = promise_destruct_args { + for (i, extra_def) in info.taint.extra_defines.iter().enumerate() { + let ev = SsaValue(*next_value); + *next_value += 1; + value_defs.push(ValueDef { + var_name: Some(extra_def.clone()), + cfg_node: node, + block: block_id, + }); + var_stacks.entry(extra_def.clone()).or_default().push(ev); + let extra_idx = binding_indices.get(i + 1).copied().unwrap_or(i + 1); + let arg = pd_args.get(extra_idx).copied().unwrap_or(pd_args[0]); + ssa_blocks[block_idx].body.push(SsaInst { + value: ev, + op: SsaOp::Assign(SmallVec::from_elem(arg, 1)), + cfg_node: node, + var_name: Some(extra_def.clone()), + span: info.ast.span, + }); + } + } else if let Some((ref per_pos, ref slot_scoped_mask)) = bare_array_ops { + // Bare-array RHS destructure: each extra emits the op for its + // source-order RHS position. Ident slots emit Assign of the + // ident's reaching SSA value; literal slots emit Const(None). + // Slot-scoped Assigns are registered in + // `slot_scoped_assigns` so the taint transfer skips + // outer-node Source pickup for those bindings. + for (i, extra_def) in info.taint.extra_defines.iter().enumerate() { + let ev = SsaValue(*next_value); + *next_value += 1; + value_defs.push(ValueDef { + var_name: Some(extra_def.clone()), + cfg_node: node, + block: block_id, + }); + var_stacks.entry(extra_def.clone()).or_default().push(ev); + let extra_idx = binding_indices.get(i + 1).copied().unwrap_or(i + 1); + let op_for_extra = per_pos + .get(extra_idx) + .cloned() + .unwrap_or(SsaOp::Const(None)); + if slot_scoped_mask.get(extra_idx).copied().unwrap_or(false) { + slot_scoped_assigns.insert(ev); + } + ssa_blocks[block_idx].body.push(SsaInst { + value: ev, + op: op_for_extra, + cfg_node: node, + var_name: Some(extra_def.clone()), + span: info.ast.span, + }); + } + } else if let Some(ref primary_op) = primary_op_for_extras { for extra_def in &info.taint.extra_defines { let ev = SsaValue(*next_value); *next_value += 1; @@ -1685,6 +2069,7 @@ fn rename_variables( nop_nodes, field_interner, field_writes, + slot_scoped_assigns, ); } @@ -1802,6 +2187,7 @@ fn rename_variables( nop_nodes, &mut field_interner, &mut field_writes, + &mut slot_scoped_assigns, ); // Process orphan blocks (e.g. catch blocks disconnected after exception edge removal). @@ -1843,6 +2229,7 @@ fn rename_variables( nop_nodes, &mut field_interner, &mut field_writes, + &mut slot_scoped_assigns, ); } } @@ -1855,6 +2242,7 @@ fn rename_variables( field_interner, field_writes, synthetic_externals, + slot_scoped_assigns, ) } diff --git a/src/ssa/param_points_to.rs b/src/ssa/param_points_to.rs index 180d96d8..02e324aa 100644 --- a/src/ssa/param_points_to.rs +++ b/src/ssa/param_points_to.rs @@ -419,6 +419,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } diff --git a/src/ssa/static_map.rs b/src/ssa/static_map.rs index 3f6351a2..4748d0d2 100644 --- a/src/ssa/static_map.rs +++ b/src/ssa/static_map.rs @@ -442,6 +442,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let cfg: Cfg = Graph::new(); let const_values = HashMap::new(); diff --git a/src/ssa/type_facts.rs b/src/ssa/type_facts.rs index 32f24fa2..17d80539 100644 --- a/src/ssa/type_facts.rs +++ b/src/ssa/type_facts.rs @@ -1,5 +1,6 @@ #![allow(clippy::if_same_then_else)] +use std::cell::RefCell; use std::collections::{BTreeMap, HashMap}; use super::const_prop::ConstLattice; @@ -9,6 +10,88 @@ use crate::symbol::Lang; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; +thread_local! { + /// Per-file local import view (local-name → source-module specifier), + /// set by [`with_file_imports`] around every per-body SSA pass that + /// needs to gate ORM TypeKind assignment in [`constructor_type`]. + /// `None` (default) preserves prior un-gated behaviour for legacy / + /// test paths that build SSA without a surrounding file context. + static FILE_IMPORTS_TLS: RefCell>> = const { RefCell::new(None) }; +} + +/// Run `f` with `imports` published to the per-thread file-imports view. +/// Restores the prior value on drop so nested calls compose; pass `None` +/// to suppress gating for callers that lack a file context. +pub fn with_file_imports(imports: Option<&HashMap>, f: impl FnOnce() -> R) -> R { + let prev = FILE_IMPORTS_TLS.with(|cell| { + cell.borrow_mut() + .replace(imports.cloned().unwrap_or_default()) + }); + let restore_to = if imports.is_some() { prev } else { None }; + struct Guard(Option>); + impl Drop for Guard { + fn drop(&mut self) { + FILE_IMPORTS_TLS.with(|cell| *cell.borrow_mut() = self.0.take()); + } + } + let _guard = Guard(restore_to); + f() +} + +/// Returns true iff any local-import in the active file-imports view maps +/// to a module specifier whose canonical form satisfies `pred`. When the +/// view has not been published (legacy / test paths) the predicate is +/// considered satisfied so prior behaviour is preserved. +fn file_imports_match(pred: impl Fn(&str) -> bool) -> bool { + FILE_IMPORTS_TLS.with(|cell| { + let borrowed = cell.borrow(); + let Some(map) = borrowed.as_ref() else { + return true; + }; + map.values().any(|spec| pred(spec.as_str())) + }) +} + +/// Strip a leading `node:` prefix from a module specifier so gates can +/// match `import x from "fs"` and `import x from "node:fs"` uniformly. +fn strip_node_prefix(spec: &str) -> &str { + spec.strip_prefix("node:").unwrap_or(spec) +} + +/// Returns true iff the active file-imports view satisfies the +/// import-gate for an ORM TypeKind. When the TLS view is unset (legacy +/// callers without file context) the gate is treated as satisfied so +/// prior behaviour is preserved. +fn orm_typekind_import_satisfied(tk: &TypeKind) -> bool { + let predicate: fn(&str) -> bool = match tk { + TypeKind::Sequelize => |spec| { + let s = strip_node_prefix(spec); + s == "sequelize" || s.starts_with("sequelize/") + }, + TypeKind::TypeOrmRepo | TypeKind::TypeOrmManager => |spec| { + let s = strip_node_prefix(spec); + s == "typeorm" || s.starts_with("typeorm/") + }, + TypeKind::MikroOrmEm => |spec| { + let s = strip_node_prefix(spec); + s.starts_with("@mikro-orm/") + }, + _ => return true, + }; + file_imports_match(predicate) +} + +/// Small helper used inside [`constructor_type`] to fold the ORM import +/// gate into the JS/TS arm without restructuring the surrounding +/// `match`. Returns `Some(tk)` only when the gate is satisfied. +fn orm_gate(tk: TypeKind) -> Option { + if orm_typekind_import_satisfied(&tk) { + Some(tk) + } else { + None + } +} + /// Inferred type kind for an SSA value. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[allow(dead_code)] // All variants are part of the public API @@ -92,6 +175,18 @@ pub enum TypeKind { /// Strictly additive, without a DTO definition, callers fall back /// to name-only resolution. Dto(DtoFields), + /// The `node:fs/promises` namespace. Receivers typed as + /// `FileSystemPromisesNs` resolve method calls (`recv.readFile(...)`, + /// `recv.open(...)`, ...) through the type-qualified rewrite to + /// `FileSystemPromisesNs.`, which the Phase 05 FILE_IO + /// matcher list covers without an [`crate::labels::LabelGate`] + /// (the receiver type is itself the import witness). The TypeKind + /// is reached today via the gated-import path in + /// `crate::cfg::apply_gated_label_rules`; SSA-time tagging from + /// `constructor_type` is intentionally not wired (member-of-call + /// shapes like `fs.promises` decompose into Call + FieldProj ops, + /// so the full expression text never reaches the constructor table). + FileSystemPromisesNs, /// An object created with `Object.create(null)` — has no prototype /// chain, so subscript-write keys cannot pollute `Object.prototype`. /// Populated for JS/TS values whose constructor call is @@ -101,6 +196,71 @@ pub enum TypeKind { /// the receiver only sometimes null-prototyped, the fact widens to /// `Unknown` and the sink fires on the unsafe path. NullPrototypeObject, + /// A Sequelize ORM instance produced by `new Sequelize(...)`. The + /// type-qualified resolver rewrites `sequelize.literal(x)` → + /// `Sequelize.literal` against a flat SQL_QUERY rule, so user-supplied + /// strings flowing into Sequelize raw-SQL helpers are caught. + Sequelize, + /// A TypeORM `Repository` instance, produced by + /// `getRepository(Entity)` / `manager.getRepository(Entity)`. + /// `repo.query(sql)` and `repo.createQueryBuilder().query` etc. are + /// SQL_QUERY sinks — type-qualified callees match flat + /// `TypeOrmRepo.` rules. + TypeOrmRepo, + /// A TypeORM `EntityManager` produced by `getManager()` / + /// `connection.manager`. Same sink shape as `Repository`. + TypeOrmManager, + /// A MikroORM `EntityManager` produced by `orm.em.fork()` / + /// `createEntityManager()`. `em.execute(sql)` is the raw-SQL sink. + MikroOrmEm, + /// A Web-platform `Request` object passed as the first argument to a + /// Next.js App Router HTTP-method handler (`GET`, `POST`, ...). + /// Phase 10 seeds the formal at function entry so receiver-method + /// reads (`req.json()`, `req.formData()`, `req.text()`, + /// `req.headers.get(...)`, `req.url`) carry their parameter's + /// taint through `Request.` label rewrites without + /// requiring a caller-side flow. + Request, + /// A SQLAlchemy `Session` / `Connection` produced by + /// `sessionmaker()()`, `Session(engine)`, `engine.connect()`, + /// `scoped_session()()`. Type-qualified resolution rewrites + /// `session.execute(sql)` → `SqlAlchemySession.execute` against + /// the flat SQL_QUERY rule so Python ORM raw-SQL passthrough is + /// caught even when the receiver name shadows another `execute` + /// method. + SqlAlchemySession, + /// A Django ORM `QuerySet` / `Manager` produced by + /// `Model.objects` access or `Model.objects.filter(...)`-shaped + /// chains. Receiver type for `qs.raw(sql)` and `qs.extra(...)` + /// raw-SQL passthrough sinks. + DjangoQuerySet, + /// An ActiveRecord `Relation` produced by `Model.where(...)` / + /// `Model.all` / `Model.find_by_sql(...)`-shaped chains, or by + /// the model class itself when used as a class-method receiver. + /// Used so `relation.find_by_sql(sql)` and chained raw-SQL + /// methods resolve to receiver-typed sinks instead of bare verbs. + ActiveRecordRelation, + /// A GORM `*gorm.DB` produced by `gorm.Open(dialector, &gorm.Config{})`. + /// Receiver for `db.Raw(sql)` / `db.Exec(sql)` raw-SQL passthrough + /// sinks. Distinct from `DatabaseConnection` so the Go + /// type-qualified rules fire only on GORM receivers and don't + /// collide with stdlib `*sql.DB` or `*sqlx.DB`. + GormDb, + /// A `*sqlx.DB` / `*sqlx.Tx` produced by `sqlx.Connect(driver, dsn)` + /// / `sqlx.Open(...)` / `sqlx.MustConnect(...)`. Receiver for + /// `sqlxDb.NamedExec(sql, ...)` / `sqlxDb.NamedQuery(sql, ...)` / + /// `sqlxDb.Select(dest, sql, ...)` etc. raw-SQL passthrough sinks. + SqlxDb, + /// A Hibernate `org.hibernate.Session` produced by + /// `sessionFactory.openSession()` / `sessionFactory.getCurrentSession()` + /// or via a declared field/local of type `Session`. Receiver for + /// `sess.createQuery(sql)` / `sess.createSQLQuery(sql)` / + /// `sess.createNativeQuery(sql)` raw-SQL passthrough sinks. The + /// flat `session.create*` matchers in `labels/java.rs` only fire on + /// receivers literally named `session`; this TypeKind closes the + /// arbitrary-receiver-name shape (`sess`, `hibernateSession`, etc.) + /// via type-qualified resolution. + HibernateSession, } /// structural carrier for a recognised DTO type. Maps @@ -146,6 +306,18 @@ impl TypeKind { Self::XPathClient => Some("XPathClient"), Self::XmlParser => Some("XmlParser"), Self::Template => Some("Template"), + Self::FileSystemPromisesNs => Some("FileSystemPromisesNs"), + Self::Sequelize => Some("Sequelize"), + Self::TypeOrmRepo => Some("TypeOrmRepo"), + Self::TypeOrmManager => Some("TypeOrmManager"), + Self::MikroOrmEm => Some("MikroOrmEm"), + Self::Request => Some("Request"), + Self::SqlAlchemySession => Some("SqlAlchemySession"), + Self::DjangoQuerySet => Some("DjangoQuerySet"), + Self::ActiveRecordRelation => Some("ActiveRecordRelation"), + Self::GormDb => Some("GormDb"), + Self::SqlxDb => Some("SqlxDb"), + Self::HibernateSession => Some("HibernateSession"), _ => None, } } @@ -251,9 +423,14 @@ impl TypeFactResult { /// Suppression policy: /// * [`TypeKind::Int`] (and float, treated as numeric): suppresses /// `SQL_QUERY`, `FILE_IO`, `SHELL_ESCAPE`, `HTML_ESCAPE`, `SSRF`, -/// `DATA_EXFIL`, numeric values cannot carry the metacharacters -/// required to drive any of these injection classes, nor can they -/// encode credentials/tokens that meaningfully constitute leakage. +/// `DATA_EXFIL`, `HEADER_INJECTION`, `OPEN_REDIRECT`. Numeric values +/// cannot carry the metacharacters required to drive any of these +/// injection classes, nor can they encode credentials/tokens that +/// meaningfully constitute leakage. HEADER_INJECTION needs CRLF; +/// OPEN_REDIRECT needs a `://` scheme followed by an attacker host +/// , numeric scalars and the safe-string upgrades that share this +/// tag (see [`is_safe_string_producing_callee`]) cannot encode +/// either. /// * [`TypeKind::Bool`]: suppresses every type-suppressible bit , /// `true`/`false` cannot carry a payload of any kind. pub fn is_type_safe_for_sink( @@ -267,7 +444,9 @@ pub fn is_type_safe_for_sink( | Cap::SHELL_ESCAPE | Cap::HTML_ESCAPE | Cap::SSRF - | Cap::DATA_EXFIL; + | Cap::DATA_EXFIL + | Cap::HEADER_INJECTION + | Cap::OPEN_REDIRECT; if !sink_caps.intersects(type_suppressible) { return false; } @@ -389,6 +568,38 @@ fn receiver_is_criteria_builder(receiver_text: &str) -> bool { || receiver_text.contains(".cb.") } +/// True when `callee` is a single-argument URL/URI factory whose first +/// argument carries the resulting URL's full spec (so a leading literal +/// prefix on that arg locks the constructed URL's host). Used by the +/// abstract-string transfer in +/// `taint::ssa_transfer::transfer_abstract` to gate the single-arg URL +/// constructor StringFact passthrough alongside the +/// `constructor_type(...) == TypeKind::Url` check. Currently covers +/// Java's static `URI.create(spec)` and `URL.of(spec)` factories +/// (`URL.of` introduced in Java 23, returns a `URL` from a single +/// string spec). Bare-leaf forms (`URI.create`, `URL.of`) and +/// fully-qualified prefixes (`java.net.URI.create`) are both accepted. +pub(crate) fn is_url_single_arg_factory(lang: Lang, callee: &str) -> bool { + matches!(lang, Lang::Java) + && (callee == "URI.create" + || callee.ends_with(".URI.create") + || callee == "URL.of" + || callee.ends_with(".URL.of")) +} + +/// True when `field_name` reads off a WHATWG `URL` instance as a logical +/// alias of the same URL value: `searchParams` is the mutable view (any +/// `.set` / `.append` on it mutates the underlying URL), the others are +/// pure-string projections of the same URL. Used by the FieldProj +/// type-aliasing rule so a `.set(k, v)` on the searchParams view dispatches +/// to the URL receiver-type rule rather than as an opaque Object. +pub(crate) fn is_url_identity_field(field_name: &str) -> bool { + matches!( + field_name, + "searchParams" | "host" | "hostname" | "pathname" | "href" | "origin" + ) +} + /// Infer a type from a constructor, factory, or allocator call. /// /// Maps known constructor/factory/allocator patterns to security-relevant @@ -426,6 +637,18 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { "createStatement" | "prepareCall" => Some(TypeKind::DatabaseConnection), "FileInputStream" | "FileOutputStream" | "FileReader" | "FileWriter" | "BufferedReader" | "BufferedWriter" => Some(TypeKind::FileHandle), + // Phase 13 — `java.nio.file.Paths.get(...)` returns a `Path`, + // and `java.io.File(...)` is the legacy stdlib path handle. + // Tagging the receiver as `FileHandle` lets the type-qualified + // resolver rewrite chained ops like `.normalize()` / + // `.toAbsolutePath()` on the returned value via the new + // `FileHandle.*` matchers. `get` matched on its own would + // over-fire (Map.get / List.get / etc.); the qualified + // `Paths.get` form is unambiguous. + "get" if callee == "Paths.get" || callee.ends_with(".Paths.get") => { + Some(TypeKind::FileHandle) + } + "File" => Some(TypeKind::FileHandle), "getWriter" | "getOutputStream" => Some(TypeKind::HttpResponse), // JPA / Hibernate Criteria API factory methods. These are // unambiguous: `createCriteriaUpdate` / `createCriteriaDelete` @@ -475,31 +698,66 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { // `tpl.process(...)` → `Template.process` against the // existing flat rule in `labels/java.rs`. "Template" | "getTemplate" => Some(TypeKind::Template), + // Hibernate `SessionFactory.openSession()` / + // `SessionFactory.getCurrentSession()` produce an + // `org.hibernate.Session` instance whose `createQuery(sql)` / + // `createSQLQuery(sql)` / `createNativeQuery(sql)` are SQL + // sinks. Method names are unique to Hibernate so suffix + // matching is unambiguous. `openStatelessSession` returns a + // `StatelessSession` with the same query-builder API. + "openSession" | "getCurrentSession" | "openStatelessSession" => { + Some(TypeKind::HibernateSession) + } _ => None, }, - Lang::JavaScript | Lang::TypeScript => match suffix { - "URL" => Some(TypeKind::Url), - "Request" | "XMLHttpRequest" => Some(TypeKind::HttpClient), - // JS built-in collection constructors. `new Map()` / `new Set()` - // / `new WeakMap()` / `new WeakSet()` / `new Array()` produce - // in-memory collections; downstream `m.get(k)` / `m.set(k, v)` - // / `s.add(x)` / `s.has(x)` / `arr.find(p)` are container ops, - // not data-layer reads. Without this mapping the bare verb - // dispatch in `auth_analysis::config::classify_sink_class` - // matches the `get` / `find` / `add` read/mutation indicators - // and over-fires `js.auth.missing_ownership_check` on every - // Map lookup in pure data-manipulation code (excalidraw's - // `elementsMap.get(id)`, `origIdToDuplicateId.get(...)`, - // `groupIdMapForOperation.set(...)` shapes). - "Map" | "Set" | "WeakMap" | "WeakSet" | "Array" => Some(TypeKind::LocalCollection), - // ldapjs client factory: `ldap.createClient({ url: '…' })` returns - // a Client whose `search(base, opts, cb)` is an LDAP injection - // sink. Match the qualified callee text rather than the bare - // `createClient` suffix to avoid widening to unrelated factories - // with the same verb name. - "createClient" if callee.contains("ldap") => Some(TypeKind::LdapClient), - _ => None, - }, + Lang::JavaScript | Lang::TypeScript => { + // NB: `fs.promises` and `require('fs').promises` member-access + // shapes are NOT mapped here — SSA decomposes member-of-call + // into separate Call + FieldProj ops, so the full expression + // text never reaches `constructor_type` as a callee string. + // The `FileSystemPromisesNs` TypeKind is reached via the + // gated-import path in `cfg::apply_gated_label_rules` instead. + match suffix { + "URL" => Some(TypeKind::Url), + "Request" | "XMLHttpRequest" => Some(TypeKind::HttpClient), + // Phase 07 — ORM constructors / factory functions. Coverage: + // `new Sequelize(...)` → Sequelize + // `getRepository(Entity)` → TypeOrmRepo (typeorm) + // `getManager()` → TypeOrmManager (typeorm) + // `createEntityManager()` → MikroOrmEm (@mikro-orm/core) + // Gated on the per-file local-import view published via + // [`with_file_imports`]: the suffix names are distinctive but + // not unique (an app-internal class named `Sequelize` with a + // `.literal()` helper, a custom `getRepository` method on a + // user-defined repository pattern, etc. would collide). + // When the TLS view is unset (test paths / non-file callers) + // the gate is treated as satisfied so prior behaviour is + // preserved. + "Sequelize" => orm_gate(TypeKind::Sequelize), + "getRepository" => orm_gate(TypeKind::TypeOrmRepo), + "getManager" => orm_gate(TypeKind::TypeOrmManager), + "createEntityManager" => orm_gate(TypeKind::MikroOrmEm), + // JS built-in collection constructors. `new Map()` / `new Set()` + // / `new WeakMap()` / `new WeakSet()` / `new Array()` produce + // in-memory collections; downstream `m.get(k)` / `m.set(k, v)` + // / `s.add(x)` / `s.has(x)` / `arr.find(p)` are container ops, + // not data-layer reads. Without this mapping the bare verb + // dispatch in `auth_analysis::config::classify_sink_class` + // matches the `get` / `find` / `add` read/mutation indicators + // and over-fires `js.auth.missing_ownership_check` on every + // Map lookup in pure data-manipulation code (excalidraw's + // `elementsMap.get(id)`, `origIdToDuplicateId.get(...)`, + // `groupIdMapForOperation.set(...)` shapes). + "Map" | "Set" | "WeakMap" | "WeakSet" | "Array" => Some(TypeKind::LocalCollection), + // ldapjs client factory: `ldap.createClient({ url: '…' })` returns + // a Client whose `search(base, opts, cb)` is an LDAP injection + // sink. Match the qualified callee text rather than the bare + // `createClient` suffix to avoid widening to unrelated factories + // with the same verb name. + "createClient" if callee.contains("ldap") => Some(TypeKind::LdapClient), + _ => None, + } + } Lang::Python => { // Python uses qualified names: requests.get, sqlite3.connect, etc. if callee.starts_with("requests.") @@ -518,6 +776,25 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { } else if suffix == "open" && !callee.contains('.') { // Bare `open()` is file I/O in Python Some(TypeKind::FileHandle) + } else if callee == "Path" + || callee == "pathlib.Path" + || callee == "PurePath" + || callee == "pathlib.PurePath" + || callee == "PurePosixPath" + || callee == "pathlib.PurePosixPath" + || callee == "PureWindowsPath" + || callee == "pathlib.PureWindowsPath" + || callee == "PosixPath" + || callee == "WindowsPath" + { + // Phase 13 — `pathlib.Path(p)` and friends. Tagging the + // receiver as `FileHandle` lets the type-qualified resolver + // rewrite `p.read_text()` / `p.write_text()` etc. against + // the new `FileHandle.*` matchers in `labels/python.rs`, + // covering the receiver-bound shape `p = Path(name); + // p.read_text()` that the chained `Path(name).read_text()` + // matcher already handles via paren-strip. + Some(TypeKind::FileHandle) } else if callee == "ldap.initialize" || callee == "ldap3.Connection" || callee.ends_with(".initialize") && callee.contains("ldap") @@ -527,6 +804,45 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { // LDAP-injection sinks. ldap3: `Connection(server, ...)` // returns a Connection with a `search()` method. Some(TypeKind::LdapClient) + } else if callee == "sessionmaker" + || callee == "scoped_session" + || callee == "sqlalchemy.orm.sessionmaker" + || callee == "sqlalchemy.orm.scoped_session" + || callee == "Session" + || callee == "sqlalchemy.orm.Session" + || (suffix == "connect" && callee.contains("sqlalchemy")) + || (suffix == "begin" && callee.contains("engine")) + { + // Phase 15 — SQLAlchemy session / connection factories. + // `sessionmaker()` returns a callable, `sessionmaker()()` + // returns a Session; the inner-call collapse step in + // `cfg::push_node` flattens that to a single CallFn whose + // callee text suffix matches `sessionmaker`. `Session(engine)`, + // `Session()`, and `engine.connect()` likewise produce a + // session-like object. Tagging the resulting receiver as + // `SqlAlchemySession` lets the type-qualified resolver rewrite + // `session.execute(sql)` → `SqlAlchemySession.execute`. + Some(TypeKind::SqlAlchemySession) + } else if suffix == "objects" { + // Phase 15 — Django ORM `Model.objects` access surfaces as a + // FieldProj whose call form is `Model.objects` (read as a + // call by the chain-normalisation pass). Tagging the + // resulting receiver as `DjangoQuerySet` lets `qs.raw(sql)` / + // `qs.extra(...)` rewrite to `DjangoQuerySet.`. + Some(TypeKind::DjangoQuerySet) + } else if callee.contains(".objects.") && is_orm_queryset_chain_method(suffix) { + // Django ORM chained-queryset producers. + // `Model.objects.all() / .filter(...) / .exclude(...)` etc. + // return another `QuerySet`. The FieldProj-chain + // decomposition for `Model.` bails when the base + // identifier (the class name `Model`) isn't in the local + // SSA var stack, leaving the Call op carrying the full + // chain text as its callee. Tagging the result as + // `DjangoQuerySet` lets a bound `qs = Model.objects.all(); + // qs.raw(sql)` resolve `qs.raw` via the type-qualified + // sink rule, closing the intermediate-binding shape that + // the flat `objects.raw` matcher misses. + Some(TypeKind::DjangoQuerySet) } else { None } @@ -544,6 +860,18 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { // go-ldap (`github.com/go-ldap/ldap/v3`): `conn, _ := ldap.DialURL(url)` // returns `*ldap.Conn` whose `Search(req)` is an LDAP-injection sink. Some(TypeKind::LdapClient) + } else if callee.starts_with("gorm.") && matches!(suffix, "Open" | "Must") { + // Phase 15 — GORM: `gorm.Open(driver, &gorm.Config{})` returns + // `*gorm.DB`. Tagging it as `GormDb` lets the type-qualified + // resolver rewrite `db.Raw(...)` → `GormDb.Raw` etc. + Some(TypeKind::GormDb) + } else if callee.starts_with("sqlx.") + && matches!(suffix, "Connect" | "MustConnect" | "Open" | "MustOpen") + { + // Phase 15 — sqlx: `sqlx.Connect("postgres", dsn)` returns + // `*sqlx.DB`; tagging it as `SqlxDb` lets `db.NamedExec(...)` + // / `db.NamedQuery(...)` rewrite to `SqlxDb.`. + Some(TypeKind::SqlxDb) } else { None } @@ -551,6 +879,16 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { Lang::Php => match suffix { "PDO" | "mysqli" => Some(TypeKind::DatabaseConnection), "curl_init" => Some(TypeKind::HttpClient), + // Phase 14 — Guzzle / Symfony HTTP client constructors. + // `new \GuzzleHttp\Client(...)` and `new Client(...)` both + // tail-match `Client` here; the resulting `TypeKind::HttpClient` + // routes `$c->request($method, $url)` through the type-qualified + // `HttpClient.request` SSRF rule in `labels/php.rs`. The + // `Client` leaf can collide with framework-internal classes + // also named `Client`, but the source-sensitivity gate + // already silences plain user-input flows so the FP surface + // is bounded. + "Client" => Some(TypeKind::HttpClient), "fopen" => Some(TypeKind::FileHandle), "SplFileObject" => Some(TypeKind::FileHandle), // DOMXPath: `$xp = new DOMXPath($doc)`. `$xp->query($expr)` / @@ -621,6 +959,15 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { // Suffix alone is too generic (new, get, open); match on full callee. if callee.contains("Net::HTTP") || after_colons.starts_with("HTTParty") { Some(TypeKind::HttpClient) + } else if callee == "Faraday.new" + || callee == "RestClient::Resource.new" + || (after_colons.starts_with("Typhoeus") && suffix == "new") + { + // Phase 14 — Faraday / Typhoeus / rest-client client + // instances. `client = Faraday.new(url: base)` returns + // an HTTP client whose `client.get(path)` resolves via + // the type-qualified `HttpClient.get` SSRF rule. + Some(TypeKind::HttpClient) } else if after_colons.starts_with("URI") && matches!(suffix, "parse" | "URI") { Some(TypeKind::Url) } else if after_colons == "PG.connect" @@ -635,6 +982,25 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { // returns a connection whose `search(base:, filter:)` accepts // an attacker-influenceable filter expression. Some(TypeKind::LdapClient) + } else if matches!( + suffix, + "where" | "all" | "find_by_sql" | "find_by" | "joins" | "order" + ) && callee + .chars() + .next() + .map(|c| c.is_ascii_uppercase()) + .unwrap_or(false) + { + // Phase 15 — ActiveRecord class-method scopes return a + // `Relation` (chainable query object). Tagging the receiver + // as `ActiveRecordRelation` lets the type-qualified resolver + // rewrite chained calls (`User.where(...).find_by_sql(...)`) + // to `ActiveRecordRelation.` when the original class + // name is preserved in the receiver text. Conservative: + // only fires on receivers that start with an uppercase + // segment (Ruby class-name convention) so plain helpers are + // not collected. + Some(TypeKind::ActiveRecordRelation) } else { None } @@ -642,6 +1008,104 @@ pub(crate) fn constructor_type(lang: Lang, callee: &str) -> Option { } } +/// Phase 14 — recognise per-language URL builders that take a `(base, +/// path)`-shaped argument pair. Returns `Some((path_arg_idx, base_arg_idx))` +/// when the callee is known to construct / join a URL out of a literal +/// base origin and a (possibly tainted) path component. The caller then: +/// +/// 1. Forwards taint from the path arg into the call's result SSA value +/// (so downstream HTTP sinks see the propagated taint). +/// 2. When the base arg is a syntactic string literal, seeds the abstract +/// [`crate::abstract_interp::StringFact::from_url_with_base`] on the +/// result so [`is_string_safe_for_ssrf`] can suppress the SSRF sink at +/// a fully-formed `scheme://host/...` prefix. +/// +/// Coverage matches the phase-14 origin-lock table: JS/TS `new URL(path, +/// base)` (constructor), Python `urllib.parse.urljoin(base, path)`, Java +/// `new URL(URL context, String spec)`, Go `url.JoinPath(base, paths...)`, +/// Ruby `URI.join(base, path)`. Rust is intentionally omitted: the +/// idiomatic shape is `Url::parse(base).unwrap().join(path)` (a chain), +/// not a single (base, path) call, so no per-call site fits the helper's +/// shape. The `Url::parse(literal_url)` single-arg case is covered by +/// generic abstract-string seeding via [`SsaOp::Const`]. +pub(crate) fn url_builder_arg_indices( + lang: Lang, + callee: &str, + outer_callee: Option<&str>, + is_constructor: bool, +) -> Option<(usize, usize)> { + // Normalise to leaf segment (last `::`/`.` token) for languages that + // attach module / receiver prefixes in front of the callee text. + let leaf = callee.rsplit("::").next().unwrap_or(callee); + let leaf = leaf.rsplit('.').next().unwrap_or(leaf); + match lang { + Lang::JavaScript | Lang::TypeScript => { + if !is_constructor { + return None; + } + // CFG-level rewrite of source-bearing assignments may replace + // the visible callee with the source path; the original + // constructor identifier is preserved on `outer_callee`. + let direct = constructor_type(lang, callee) == Some(TypeKind::Url); + let via_outer = + outer_callee.is_some_and(|oc| constructor_type(lang, oc) == Some(TypeKind::Url)); + if direct || via_outer { + Some((0, 1)) + } else { + None + } + } + Lang::Python => { + // `urllib.parse.urljoin(base, path)` and the bare-import + // `urljoin(base, path)` (`from urllib.parse import urljoin`). + if callee == "urllib.parse.urljoin" || leaf == "urljoin" { + Some((1, 0)) + } else { + None + } + } + Lang::Go => { + // `url.JoinPath(base, paths...)` and the receiver form + // `(*URL).JoinPath(base, paths...)` — both expose `JoinPath` + // as the leaf segment. `(*URL).Parse(ref)` (single-arg + // resolve against a base URL receiver) is not modelled here + // because the base lives on the receiver rather than at a + // positional arg. + if leaf == "JoinPath" { + Some((1, 0)) + } else { + None + } + } + Lang::Java => { + // `new URL(URL context, String spec)` — context (base) at + // arg 0, spec (path) at arg 1. Only the explicit + // (context, spec) two-arg constructor form is recognised; + // `new URL(String spec)` and `new URI(String spec)` carry a + // single string literal that the generic abstract-string + // path already handles via `SsaOp::Const` seeding. + if is_constructor && (leaf == "URL" || leaf == "URI") { + Some((1, 0)) + } else { + None + } + } + Lang::Ruby => { + // `URI.join(base, *paths)` — base at arg 0, first path at arg 1. + if callee == "URI.join" || (leaf == "join" && callee.contains("URI")) { + Some((1, 0)) + } else { + None + } + } + // PHP / Rust / C / C++: no first-class (base, path) URL builder + // function the engine recognises. Single-arg shapes (e.g. + // `Url::parse("https://api/" . $tainted)`) flow through the + // generic abstract-string concat prefix path. + _ => None, + } +} + /// Check if a callee is a known integer/numeric-producing function. /// /// Conservative list: only includes functions whose return type is unambiguously @@ -782,6 +1246,48 @@ pub fn is_identity_method(callee: &str) -> bool { ) } +/// True when `verb` is an ORM queryset chain method that returns another +/// queryset of the same logical type as the receiver. Used to propagate +/// `DjangoQuerySet` / `ActiveRecordRelation` type facts through chained +/// calls (`qs.filter(...).exclude(...)`) so a terminal verb like `.raw(sql)` +/// / `.find_by_sql(sql)` resolves via the type-qualified sink rule. +pub fn is_orm_queryset_chain_method(verb: &str) -> bool { + matches!( + verb, + // Django queryset chain methods (subset that returns QuerySet) + "all" + | "filter" + | "exclude" + | "order_by" + | "annotate" + | "distinct" + | "select_related" + | "prefetch_related" + | "only" + | "defer" + | "reverse" + | "none" + | "using" + | "values" + | "values_list" + // ActiveRecord relation chain methods (subset that returns Relation) + | "where" + | "joins" + | "includes" + | "preload" + | "eager_load" + | "references" + | "group" + | "having" + | "limit" + | "offset" + | "lock" + | "readonly" + | "rewhere" + | "unscope" + ) +} + pub fn is_int_producing_callee(callee: &str) -> bool { // Peel trailing identity methods (e.g. `.unwrap()`/`.expect("...")` after // `.parse()`) so the underlying numeric-producing verb is exposed. @@ -800,6 +1306,77 @@ pub fn is_int_producing_callee(callee: &str) -> bool { ) } +/// True when `callee` produces a string value that is provably free of +/// CRLF / quote / shell-metacharacter / SQL-quote payloads , the +/// canonical "safe-by-construction string" idiom. Used as a stealth +/// type-fact upgrade so the resulting SSA value is tagged as +/// [`TypeKind::Int`] and the type-suppressible sink mask +/// (HEADER_INJECTION / OPEN_REDIRECT / SQL_QUERY / ...) fires on +/// idiomatic Java patterns: +/// +/// ```java +/// res.setHeader("X-Count", Integer.toString(payload.size())); +/// res.setHeader("X-Class", loaded.getClass().getName()); +/// ``` +/// +/// Coverage: +/// * Numeric-to-string converters: `Integer.toString` / `Long.toString` +/// / `Float.toString` / `Double.toString` / `Short.toString` / +/// `Byte.toString` / `Boolean.toString` / `Character.toString` , +/// output is `[+-]?\d+(\.\d+)?` / `"true"` / `"false"` / `"NaN"` / +/// `"Infinity"`, none of which can carry CRLF or injection metachars. +/// * `String.valueOf` static factories , most overloads (`int`, +/// `long`, `boolean`, `char`, ...) emit the same digit / boolean / +/// single-character text as their per-class `toString`. The +/// `Object` overload falls back to `Object.toString()` whose output +/// shape depends on the runtime type, but the dominant safe usage +/// shape (`String.valueOf(payload.size())`, +/// `String.valueOf(rendered.length())`) covers the common +/// header-injection mitigation pattern. +/// * `Class.getName` / `Class.getSimpleName` / `Class.getCanonicalName` +/// , the JVM class-name grammar disallows CRLF, quotes, slashes, +/// spaces, and shell metacharacters; the dot-separated FQCN is safe +/// for header / shell / SQL / file / HTML / SSRF sinks. +/// +/// Receiver shape match: also accepts the chained form +/// `.getClass().getName()` whose collapsed callee text contains +/// `.getClass()` followed by the class-name accessor. +pub fn is_safe_string_producing_callee(callee: &str) -> bool { + let base = peel_identity_suffix(callee); + // Last segment after `::` (Rust/Ruby) , Java callees normalise + // through `.` only, but the same peeling is harmless for cross-lang + // input. + let after_colons = base.rsplit("::").next().unwrap_or(&base); + if let Some((prefix, method)) = after_colons.rsplit_once('.') { + let class_name = prefix.rsplit(['.', ' ']).next().unwrap_or(prefix); + match (class_name, method) { + ( + "Integer" | "Long" | "Float" | "Double" | "Short" | "Byte" | "Boolean" + | "Character", + "toString", + ) => return true, + ("String", "valueOf") => return true, + ("Class", "getName" | "getSimpleName" | "getCanonicalName") => return true, + _ => {} + } + } + // Chained `.getClass().()` form. The Java arm of + // `call_ident_of` preserves the inner `.getClass` segment in the + // collapsed chain text (e.g. `loaded.getClass.getName`), so a + // contains-check on `.getClass.` suffices to disambiguate from + // user-defined `getName` methods on unrelated classes. + let suffix = after_colons + .rsplit(['.', ':']) + .next() + .unwrap_or(after_colons); + if matches!(suffix, "getName" | "getSimpleName" | "getCanonicalName") + && (after_colons.contains(".getClass.") || after_colons.contains(".getClass()")) + { + return true; + } + false +} + /// Polarity hint for a generic input-validator callee. /// /// Most validation idioms route attacker-controlled input through a @@ -939,9 +1516,26 @@ pub fn analyze_types_with_param_types( .node_weight(inst.cfg_node) .map(|ni| ni.call.produces_null_proto) .unwrap_or(false); + // The CFG-level text-rewrite for source-bearing + // assignments (`const u = new URL(req.body.path, …)` + // → `callee` becomes `req.body.path`) strips the + // visible constructor identifier, so when the direct + // `callee` mapping fails fall back to + // `info.call.outer_callee` which preserves the + // original (e.g. `URL`) for type inference. + let outer_callee = cfg + .node_weight(inst.cfg_node) + .and_then(|ni| ni.call.outer_callee.clone()); + let constructor_ty = lang.and_then(|l| { + constructor_type(l, callee).or_else(|| { + outer_callee + .as_deref() + .and_then(|oc| constructor_type(l, oc)) + }) + }); if null_proto { TypeFact::from_kind(TypeKind::NullPrototypeObject) - } else if let Some(ty) = lang.and_then(|l| constructor_type(l, callee)) { + } else if let Some(ty) = constructor_ty { TypeFact::from_kind(ty) } else if let Some(ty) = lang.and_then(|l| arg_aware_call_type(l, callee, args, consts)) @@ -949,6 +1543,15 @@ pub fn analyze_types_with_param_types( TypeFact::from_kind(ty) } else if is_int_producing_callee(callee) { TypeFact::from_kind(TypeKind::Int) + } else if is_safe_string_producing_callee(callee) { + // Numeric/boolean to-string converters and class-name + // accessors emit a string provably free of CRLF and + // injection metacharacters. Tag as `Int` so the + // shared type-suppressible sink mask treats the + // value as non-payload-bearing for HEADER_INJECTION + // / OPEN_REDIRECT / SQL_QUERY / FILE_IO / SHELL / + // HTML / SSRF / DATA_EXFIL. + TypeFact::from_kind(TypeKind::Int) } else { // Identity-preserving methods propagated in second pass. TypeFact::unknown() @@ -1055,6 +1658,47 @@ pub fn analyze_types_with_param_types( } } + // ORM queryset chain propagation. `Model.objects.filter(...)` + // / `qs.exclude(...)` / `qs.all()` etc. return a `QuerySet` of + // the same logical type as the receiver. When the receiver + // carries a `DjangoQuerySet` fact and the callee verb is one of + // the QuerySet-returning chain methods, propagate the fact to + // the result so a later `qs2.raw(sql)` / `qs2.extra(sql)` resolves + // via the type-qualified rule. Gated on the receiver type to + // keep the FP surface bounded. + for inst in &block.body { + if let SsaOp::Call { + callee, + receiver: Some(recv), + .. + } = &inst.op + { + let suffix = callee.rsplit(['.', ':']).next().unwrap_or(callee); + if !is_orm_queryset_chain_method(suffix) { + continue; + } + let recv_fact = facts.get(recv).cloned().unwrap_or_else(TypeFact::unknown); + let propagate = matches!( + recv_fact.kind, + TypeKind::DjangoQuerySet | TypeKind::ActiveRecordRelation + ); + if !propagate { + continue; + } + let current_kind = facts + .get(&inst.value) + .map(|f| f.kind.clone()) + .unwrap_or(TypeKind::Unknown); + if !matches!(current_kind, TypeKind::Unknown) { + continue; + } + if facts.get(&inst.value) != Some(&recv_fact) { + facts.insert(inst.value, recv_fact); + changed = true; + } + } + } + // FieldProj receiver-driven type narrowing. When // SSA lowering decomposed `a.b.c()` into a FieldProj chain, // intermediate FieldProj insts default to `projected_type = @@ -1078,6 +1722,37 @@ pub fn analyze_types_with_param_types( continue; }; let field_name = body.field_name(*field).to_string(); + // WHATWG URL alias: a `URL` instance's `searchParams` + // and identity-projection accessors (`host`, `hostname`, + // `pathname`, `href`, `origin`) read as the same logical + // URL for sink/sanitiser dispatch. Mark the projection + // as `TypeKind::Url` so a downstream `.set(k, v)` / + // `.append(k, v)` on the searchParams view dispatches via + // the URL receiver-type rule rather than as an opaque + // Object. + if matches!(recv_fact.kind, TypeKind::Url) && is_url_identity_field(&field_name) { + let new_fact = TypeFact::from_kind(TypeKind::Url); + if facts.get(&inst.value) != Some(&new_fact) { + facts.insert(inst.value, new_fact); + changed = true; + } + continue; + } + // Django ORM manager projection. `Model.objects` decomposes + // into a FieldProj whose `field` is `objects`. Tag it as + // `DjangoQuerySet` so a downstream `qs.raw(sql)` / + // `qs.extra(sql)` (where `qs = Model.objects`) resolves via + // the type-qualified `DjangoQuerySet.` sink rule. + // Strictly additive — fires only when the projection has not + // already been pinned to another type. + if matches!(lang, Some(Lang::Python)) && field_name == "objects" { + let new_fact = TypeFact::from_kind(TypeKind::DjangoQuerySet); + if facts.get(&inst.value) != Some(&new_fact) { + facts.insert(inst.value, new_fact); + changed = true; + } + continue; + } let Some(new_fact) = TypeFact::from_dto_field(&recv_fact.kind, &field_name) else { continue; }; @@ -1122,6 +1797,37 @@ pub fn analyze_types_with_param_types( continue; } if let SsaOp::Assign(uses) = &inst.op { + // Django ORM manager projection in Assign form. + // `qs = Model.objects` lowers to an Assign whose CFG + // node carries `member_field = Some("objects")`. The + // FieldProj-chain decomposition only fires when there + // is a trailing method call (e.g. `Model.objects.all()`); + // the bare-projection shape leaves the Assign with + // multiple operands (the path "Model.objects" plus the + // unresolved root identifiers), so neither the len==1 + // copy-prop arm nor the len==2 BinOp arm picks up the + // type. Tag the result as `DjangoQuerySet` directly + // when the CFG node's `member_field` is "objects" and + // the language is Python; mirrors the FieldProj + // second-pass arm above. Strictly additive: only + // fires when the result fact is still Unknown. + if matches!(lang, Some(Lang::Python)) + && cfg + .node_weight(inst.cfg_node) + .and_then(|ni| ni.member_field.as_deref()) + == Some("objects") + { + let current_kind = facts + .get(&inst.value) + .map(|f| f.kind.clone()) + .unwrap_or(TypeKind::Unknown); + if matches!(current_kind, TypeKind::Unknown) { + let new_fact = TypeFact::from_kind(TypeKind::DjangoQuerySet); + facts.insert(inst.value, new_fact); + changed = true; + continue; + } + } if uses.len() == 1 { // when the RHS is a single member-access // expression and the receiver value carries a @@ -1417,6 +2123,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let consts = HashMap::from([ @@ -1532,6 +2239,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let consts = HashMap::new(); @@ -1581,6 +2289,8 @@ mod tests { Cap::HTML_ESCAPE, Cap::SSRF, Cap::DATA_EXFIL, + Cap::HEADER_INJECTION, + Cap::OPEN_REDIRECT, ] { assert!( is_type_safe_for_sink(&[SsaValue(0)], cap, &result), @@ -1617,6 +2327,8 @@ mod tests { Cap::HTML_ESCAPE, Cap::SSRF, Cap::DATA_EXFIL, + Cap::HEADER_INJECTION, + Cap::OPEN_REDIRECT, ] { assert!( is_type_safe_for_sink(&[SsaValue(0)], cap, &result), @@ -1653,14 +2365,14 @@ mod tests { /// `is_type_safe_for_sink` requires an intentional matrix edit + a /// test update. Truth values: /// - /// | TypeKind | SQL | FILE | SHELL | HTML | SSRF | DATA_EXFIL | CODE_EXEC | DESERIALIZE | - /// |-----------|-----|------|-------|------|------|------------|-----------|-------------| - /// | Int | Y | Y | Y | Y | Y | Y | N | N | - /// | Bool | Y | Y | Y | Y | Y | Y | N | N | - /// | String | N | N | N | N | N | N | N | N | - /// | Url | N | N | N | N | N | N | N | N | - /// | Object | N | N | N | N | N | N | N | N | - /// | Unknown | N | N | N | N | N | N | N | N | + /// | TypeKind | SQL | FILE | SHELL | HTML | SSRF | DATA_EXFIL | HEADER_INJ | OPEN_REDIR | CODE_EXEC | DESERIALIZE | + /// |----------|-----|------|-------|------|------|------------|------------|------------|-----------|-------------| + /// | Int | Y | Y | Y | Y | Y | Y | Y | Y | N | N | + /// | Bool | Y | Y | Y | Y | Y | Y | Y | Y | N | N | + /// | String | N | N | N | N | N | N | N | N | N | N | + /// | Url | N | N | N | N | N | N | N | N | N | N | + /// | Object | N | N | N | N | N | N | N | N | N | N | + /// | Unknown | N | N | N | N | N | N | N | N | N | N | #[test] fn type_kind_cap_suppression_matrix() { use crate::labels::Cap; @@ -1671,40 +2383,50 @@ mod tests { ("HTML_ESCAPE", Cap::HTML_ESCAPE), ("SSRF", Cap::SSRF), ("DATA_EXFIL", Cap::DATA_EXFIL), + ("HEADER_INJECTION", Cap::HEADER_INJECTION), + ("OPEN_REDIRECT", Cap::OPEN_REDIRECT), ("CODE_EXEC", Cap::CODE_EXEC), ("DESERIALIZE", Cap::DESERIALIZE), ]; // (kind_name, kind, [suppress for each cap in `caps` order]) - let rows: &[(&str, TypeKind, [bool; 8])] = &[ + let rows: &[(&str, TypeKind, [bool; 10])] = &[ ( "Int", TypeKind::Int, - [true, true, true, true, true, true, false, false], + [true, true, true, true, true, true, true, true, false, false], ), ( "Bool", TypeKind::Bool, - [true, true, true, true, true, true, false, false], + [true, true, true, true, true, true, true, true, false, false], ), ( "String", TypeKind::String, - [false, false, false, false, false, false, false, false], + [ + false, false, false, false, false, false, false, false, false, false, + ], ), ( "Url", TypeKind::Url, - [false, false, false, false, false, false, false, false], + [ + false, false, false, false, false, false, false, false, false, false, + ], ), ( "Object", TypeKind::Object, - [false, false, false, false, false, false, false, false], + [ + false, false, false, false, false, false, false, false, false, false, + ], ), ( "Unknown", TypeKind::Unknown, - [false, false, false, false, false, false, false, false], + [ + false, false, false, false, false, false, false, false, false, false, + ], ), ]; for (kind_name, kind, expected) in rows { @@ -1837,6 +2559,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let consts = HashMap::new(); @@ -2178,6 +2901,24 @@ mod tests { constructor_type(Lang::Java, "MongoClient"), Some(TypeKind::DatabaseConnection) ); + // Hibernate Session factory methods. Suffix-only match — receiver + // text is irrelevant. + assert_eq!( + constructor_type(Lang::Java, "sessionFactory.openSession"), + Some(TypeKind::HibernateSession) + ); + assert_eq!( + constructor_type(Lang::Java, "sessionFactory.getCurrentSession"), + Some(TypeKind::HibernateSession) + ); + assert_eq!( + constructor_type(Lang::Java, "openStatelessSession"), + Some(TypeKind::HibernateSession) + ); + assert_eq!( + TypeKind::HibernateSession.label_prefix(), + Some("HibernateSession") + ); } #[test] diff --git a/src/state/mod.rs b/src/state/mod.rs index 2bd93177..36a8b344 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -298,6 +298,16 @@ pub fn build_resource_method_summaries( ) { continue; } + // Skip acquires whose lifetime is bounded by a managed cleanup + // scope (Python `with`, Java try-with-resources, Ruby + // File.open-with-block, Rust RAII). The acquired handle is + // released before the method returns, so propagating an + // Acquire effect onto the caller's receiver creates an FP + // class where callers of `def foo(self): with open(...): ...` + // are flagged as leaking the receiver. + if info.managed_resource { + continue; + } let callee = match &info.call.callee { Some(c) => c.to_ascii_lowercase(), None => continue, @@ -308,6 +318,20 @@ pub fn build_resource_method_summaries( .iter() .any(|a| transfer::callee_matches_pub(&callee, a)) { + // The receiver-proxy mechanism (state/transfer.rs) + // matches a method-name summary against `recv.method()` + // call sites and marks the receiver as OPEN. This is + // only meaningful when the acquire actually binds a + // resource into receiver state (`self.fd = open(...)`, + // `this.fd = fs.openSync(...)`). Acquires with no + // binding (`return open(...)`) or with a local-only + // binding (`f = open(...); f.close()`) do not transfer + // ownership onto the caller's receiver. Gate the + // summary on a defines field so anonymous and local- + // only acquires no longer leak through this path. + if info.taint.defines.is_none() { + continue; + } summaries.push(transfer::ResourceMethodSummary { method_name: method_name.clone(), effect: transfer::ResourceEffect::Acquire, diff --git a/src/summary/mod.rs b/src/summary/mod.rs index 46283569..0c89c46d 100644 --- a/src/summary/mod.rs +++ b/src/summary/mod.rs @@ -33,6 +33,20 @@ use std::hash::{Hash, Hasher}; /// Pairs a [`Cap`] with the source location of the consuming /// instruction so cross-file findings can attribute to the callee /// rather than the caller call-site. +/// +/// `from_chain` distinguishes two flavours of recorded site: +/// * `false`, the site was resolved via the body-local locator span, +/// i.e. it points at a sink instruction in the function's own body. +/// * `true`, the site was promoted from a deeper callee through +/// `event.primary_sink_site`, i.e. this function's summary carries +/// a chain-hop marker for a sink several frames down. +/// +/// Pass-2 emission gates promotion of a site into `Finding.primary_location` +/// on `from_chain || file_rel != caller_file_rel`: same-file single-hop +/// helpers keep call-site emission (matching benchmark and real-world +/// fixture calibration), multi-hop chains and cross-file callees surface +/// the deep sink line. See "Multi-hop intra-file sink attribution gap" +/// in deferred.md for the design tradeoff. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct SinkSite { #[serde(default, skip_serializing_if = "String::is_empty")] @@ -44,11 +58,18 @@ pub struct SinkSite { #[serde(default, skip_serializing_if = "String::is_empty")] pub snippet: String, pub cap: Cap, + /// True when this site was promoted from a deeper callee's summary + /// (`event.primary_sink_site` chain-hop), false when recorded from + /// the function's own locator span. See struct docs. + #[serde(default, skip_serializing_if = "is_false")] + pub from_chain: bool, } impl SinkSite { /// Dedup key: two sites with the same `(file_rel, line, col, cap)` - /// describe the same consumption and collapse on merge. + /// describe the same consumption and collapse on merge. `from_chain` + /// is intentionally excluded, the upgrade rule in [`union_sink_sites`] + /// takes over when two sites with different `from_chain` collide. pub(crate) fn dedup_key(&self) -> (&str, u32, u32, u32) { (self.file_rel.as_str(), self.line, self.col, self.cap.bits()) } @@ -62,10 +83,15 @@ impl SinkSite { col: 0, snippet: String::new(), cap, + from_chain: false, } } } +fn is_false(b: &bool) -> bool { + !*b +} + /// Tree/bytes context for resolving a CFG span to a [`SinkSite`]. /// Threaded as `Option<&Locator>` so extraction paths without tree /// access can pass `None` cheaply. @@ -93,6 +119,7 @@ impl<'a> SinkSiteLocator<'a> { col: (point.column + 1) as u32, snippet, cap, + from_chain: false, } } } @@ -101,11 +128,17 @@ pub(crate) use crate::utils::snippet::line_snippet; /// Union two `SmallVec<[SinkSite; 1]>` lists with `(file_rel, line, col, /// cap)` dedup. Preserves insertion order of `existing` then appends any -/// new sites from `incoming` not already present. +/// new sites from `incoming` not already present. When two sites with the +/// same dedup key collide, `from_chain=true` wins, so a chain-hop marker is +/// never lost when a same-file locator span happens to share coordinates. pub(crate) fn union_sink_sites(existing: &mut SmallVec<[SinkSite; 1]>, incoming: &[SinkSite]) { for site in incoming { let key = site.dedup_key(); - if !existing.iter().any(|s| s.dedup_key() == key) { + if let Some(ex) = existing.iter_mut().find(|s| s.dedup_key() == key) { + if site.from_chain && !ex.from_chain { + ex.from_chain = true; + } + } else { existing.push(site.clone()); } } @@ -388,6 +421,16 @@ pub struct FuncSummary { /// [`crate::callgraph::TypeHierarchyIndex`]. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub hierarchy_edges: Vec<(String, String)>, + + /// Phase-10 Next.js entry-point classification. When `Some(_)`, + /// the function is treated as an externally-driven entry point + /// whose parameters are seeded as `TaintOrigin::Source` at SSA + /// entry, mirroring the way an HTTP request handler's formals are + /// adversary-controlled by default. `None` for ordinary + /// helpers — pass-2 keeps its existing baseline-subtraction + /// semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_kind: Option, } // ── Cap conversion helpers ────────────────────────────────────────────── @@ -428,6 +471,35 @@ impl FuncSummary { kind: self.kind, } } + + /// Phase-04 [`FuncKey`] builder that consults a project-wide + /// [`crate::resolve::ModuleGraph`]. + /// + /// When the file producing this summary lies inside a discovered + /// package, `namespace` becomes `"@scope/name::src/file.ts"`; + /// otherwise the result matches [`Self::func_key`] exactly. + /// Phase 04 only adds the helper, no resolution call site uses + /// it. Phase 10 switches the JS/TS pass-1 path to call this + /// instead of [`Self::func_key`]. + pub fn func_key_with_resolver( + &self, + scan_root: Option<&str>, + module_graph: Option<&crate::resolve::ModuleGraph>, + ) -> FuncKey { + FuncKey { + lang: Lang::from_slug(&self.lang).unwrap_or(Lang::Rust), + namespace: crate::symbol::namespace_with_package( + &self.file_path, + scan_root, + module_graph, + ), + container: self.container.clone(), + name: self.name.clone(), + arity: Some(self.param_count), + disambig: self.disambig, + kind: self.kind, + } + } } // ── Callee resolution ──────────────────────────────────────────────────── @@ -543,6 +615,26 @@ pub struct GlobalSummaries { /// Precise SSA-derived per-parameter summaries, keyed by `FuncKey`. /// These take precedence over `FuncSummary` during callee resolution. ssa_by_key: HashMap, + /// Sibling index over [`Self::ssa_by_key`] keyed by + /// `(lang, namespace, name)`. Populated in lockstep with `ssa_by_key` + /// (every `insert_ssa` / `merge` adds the key). Used by the + /// cross-package SSA resolution path (step 0.7 in + /// `taint::ssa_transfer::resolve_callee`) to avoid an + /// `O(|ssa_by_key|)` linear scan per cross-package call site: + /// the resolver looks up the candidate `Vec` and narrows + /// to a single hit by container / arity / disambig. Strictly + /// additive: when the index is empty (e.g. tests that never insert + /// SSA summaries) the resolver falls back to its existing flat + /// paths. + /// + /// Note: SSA summaries are append-only on `GlobalSummaries` (no + /// remove/clear methods), so the index never needs invalidation. + /// Synthetic-disambig probing in + /// [`Self::reconcile_ssa_summary_key`] only mutates the inserted + /// key's `disambig` field, never the `(lang, namespace, name)` + /// triple, so the index value still points at every relevant + /// `FuncKey` after reconciliation. + ssa_by_lang_ns_name: HashMap<(Lang, String, String), Vec>, /// Cross-file callee bodies for interprocedural symbolic execution. /// Keyed by `FuncKey` (same identity model as SSA summaries). bodies_by_key: HashMap, @@ -564,6 +656,16 @@ pub struct GlobalSummaries { /// execution-API auth-recognition gap on routes attached to bare /// child routers. router_facts_by_module: HashMap, + /// Per-file Phase-09 cross-package import maps, keyed by file + /// namespace (scan-root-relative path, the same form + /// [`FuncKey::namespace`] uses). Populated in pass 1 from each + /// file's [`crate::cfg::FileCfg::resolved_imports`] and consumed by + /// `inline_analyse_callee` when the inlined callee body's own + /// `cross_package_imports` Arc is empty (i.e. the body was loaded + /// from SQLite, where the field is `#[serde(skip)]`). Closes the + /// indexed-mode parity gap on transitive cross-package IPA inside + /// inlined frames. + cross_package_imports_by_namespace: HashMap>>, /// Type hierarchy index for runtime virtual-dispatch fan-out. /// /// Installed by [`Self::install_hierarchy`] after pass 1 from the @@ -864,6 +966,7 @@ impl GlobalSummaries { } // SSA summaries: last-writer-wins (exact-key replacement, no unioning) for (key, ssa_sum) in other.ssa_by_key { + self.index_ssa_key(&key); self.ssa_by_key.insert(key, ssa_sum); } // Cross-file bodies: last-writer-wins @@ -879,6 +982,10 @@ impl GlobalSummaries { for (module_id, facts) in other.router_facts_by_module { self.router_facts_by_module.insert(module_id, facts); } + // Cross-package imports: last-writer-wins per namespace. + for (ns, map) in other.cross_package_imports_by_namespace { + self.cross_package_imports_by_namespace.insert(ns, map); + } // Hierarchy index: invalidate after a merge so the next consumer // sees a freshly-built view that includes `other`'s edges. The // alternative, point-merging two indexes, is racy when the @@ -966,9 +1073,41 @@ impl GlobalSummaries { } else { self.reconcile_ssa_summary_key(key, &summary) }; + self.index_ssa_key(&key); self.ssa_by_key.insert(key, summary); } + /// Push `key` onto the secondary `(lang, namespace, name)` index. + /// Idempotent: a re-insert at the same triple does not duplicate + /// the key in the candidate vector. + fn index_ssa_key(&mut self, key: &FuncKey) { + let triple = (key.lang, key.namespace.clone(), key.name.clone()); + let bucket = self.ssa_by_lang_ns_name.entry(triple).or_default(); + if !bucket.contains(key) { + bucket.push(key.clone()); + } + } + + /// Look up SSA summary `FuncKey`s by `(lang, namespace, name)`. + /// Returns `&[]` when no SSA summary at that triple has been + /// stored. Used by the cross-package resolution path so the + /// step-0.7 narrowing can iterate only the candidate set rather + /// than every persisted SSA key. + pub fn ssa_keys_by_qualified(&self, lang: Lang, namespace: &str, name: &str) -> &[FuncKey] { + // Borrow against (Lang, &str, &str) avoiding allocation by + // looking up with a tuple of owned Strings only when present. + // HashMap requires equivalent hash; (Lang, String, String) + // hashes the same as the equivalent tuple of equivalent + // values, so we construct a small owned key for the probe. + // Profile-light: this runs once per cross-package callee and + // both string clones are short (namespace path + leaf name). + let probe = (lang, namespace.to_string(), name.to_string()); + self.ssa_by_lang_ns_name + .get(&probe) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + /// Exact lookup of an SSA summary by fully-qualified key. pub fn get_ssa(&self, key: &FuncKey) -> Option<&SsaFuncSummary> { self.ssa_by_key.get(key) @@ -1088,6 +1227,38 @@ impl GlobalSummaries { self.router_facts_by_module.len() } + /// Insert a per-file Phase-09 cross-package import map. Last-writer-wins + /// per namespace key — re-analysing a file produces a fresh snapshot + /// of its `(local_name → FuncKey)` resolutions. + pub fn insert_cross_package_imports( + &mut self, + namespace: String, + map: std::sync::Arc>, + ) { + if map.is_empty() { + return; + } + self.cross_package_imports_by_namespace + .insert(namespace, map); + } + + /// Look up a per-file cross-package import map by file namespace. + /// Used by [`crate::taint::ssa_transfer`]'s inline-analysis frame to + /// recover the callee body's own import view when the body was loaded + /// from SQLite (where the Arc on `CalleeSsaBody` is stripped by + /// `#[serde(skip)]`). + pub fn get_cross_package_imports( + &self, + namespace: &str, + ) -> Option<&std::sync::Arc>> { + self.cross_package_imports_by_namespace.get(namespace) + } + + /// Count of files that contributed cross-package import maps. + pub fn cross_package_imports_len(&self) -> usize { + self.cross_package_imports_by_namespace.len() + } + /// Insert a cross-file callee body. /// /// See [`insert_ssa`](Self::insert_ssa) for the identity-safety rule. @@ -1149,8 +1320,10 @@ impl GlobalSummaries { pub fn is_empty(&self) -> bool { self.by_key.is_empty() && self.ssa_by_key.is_empty() + && self.ssa_by_lang_ns_name.is_empty() && self.auth_by_key.is_empty() && self.router_facts_by_module.is_empty() + && self.cross_package_imports_by_namespace.is_empty() } /// Iterate over all (key, summary) pairs. @@ -1683,6 +1856,10 @@ impl std::fmt::Debug for GlobalSummaries { .field("bodies_len", &self.bodies_by_key.len()) .field("auth_len", &self.auth_by_key.len()) .field("router_facts_len", &self.router_facts_by_module.len()) + .field( + "cross_package_imports_len", + &self.cross_package_imports_by_namespace.len(), + ) .finish() } } diff --git a/src/summary/ssa_summary.rs b/src/summary/ssa_summary.rs index a3b65714..c1159457 100644 --- a/src/summary/ssa_summary.rs +++ b/src/summary/ssa_summary.rs @@ -347,6 +347,14 @@ pub struct SsaFuncSummary { /// on both vulnerable and patched code. #[serde(default, skip_serializing_if = "SmallVec::is_empty")] pub validated_params_to_return: SmallVec<[usize; 2]>, + + /// Phase-10 Next.js entry-point classification. Mirrors + /// [`crate::summary::FuncSummary::entry_kind`] — recorded on the + /// SSA summary so cross-file consumers don't have to consult the + /// coarse `FuncSummary` to know whether the callee is an entry + /// point. `None` for ordinary helpers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_kind: Option, } /// A per-return-path [`PathFact`] entry. diff --git a/src/summary/tests.rs b/src/summary/tests.rs index 1220ac6d..d33d7e84 100644 --- a/src/summary/tests.rs +++ b/src/summary/tests.rs @@ -530,6 +530,7 @@ fn ssa_summary_serde_round_trip_identity() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let json = serde_json::to_string(&summary).unwrap(); let back: SsaFuncSummary = serde_json::from_str(&json).unwrap(); @@ -564,6 +565,7 @@ fn ssa_summary_serde_round_trip_strip_bits() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let json = serde_json::to_string(&summary).unwrap(); let back: SsaFuncSummary = serde_json::from_str(&json).unwrap(); @@ -595,6 +597,7 @@ fn ssa_summary_serde_round_trip_add_bits() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let json = serde_json::to_string(&summary).unwrap(); let back: SsaFuncSummary = serde_json::from_str(&json).unwrap(); @@ -633,6 +636,7 @@ fn ssa_summary_serde_round_trip_all_variants() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let json = serde_json::to_string(&summary).unwrap(); let back: SsaFuncSummary = serde_json::from_str(&json).unwrap(); @@ -673,6 +677,7 @@ fn global_summaries_insert_ssa_exact_key_replacement() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; gs.insert_ssa(key.clone(), v1.clone()); assert_eq!(gs.get_ssa(&key), Some(&v1)); @@ -701,6 +706,7 @@ fn global_summaries_insert_ssa_exact_key_replacement() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; gs.insert_ssa(key.clone(), v2.clone()); assert_eq!(gs.get_ssa(&key), Some(&v2)); @@ -749,6 +755,7 @@ fn global_summaries_merge_with_ssa_entries() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let sum_b = SsaFuncSummary { param_to_return: vec![], @@ -773,6 +780,7 @@ fn global_summaries_merge_with_ssa_entries() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; gs1.insert_ssa(key_a.clone(), sum_a.clone()); @@ -821,6 +829,7 @@ fn global_summaries_is_empty_considers_ssa() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); @@ -852,6 +861,7 @@ fn ssa_summary_serde_round_trip_param_to_sink_param() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let json = serde_json::to_string(&summary).unwrap(); let back: SsaFuncSummary = serde_json::from_str(&json).unwrap(); @@ -898,6 +908,7 @@ fn ssa_summary_serde_round_trip_container_fields() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let json = serde_json::to_string(&summary).unwrap(); let back: SsaFuncSummary = serde_json::from_str(&json).unwrap(); @@ -954,6 +965,7 @@ fn ssa_summary_serde_round_trip_return_abstract() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let json = serde_json::to_string(&summary).unwrap(); let back: SsaFuncSummary = serde_json::from_str(&json).unwrap(); @@ -1029,6 +1041,7 @@ fn make_callee_body( field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }, opt: crate::ssa::OptimizeResult { const_values: std::collections::HashMap::new(), @@ -1047,6 +1060,7 @@ fn make_callee_body( param_count, node_meta: std::collections::HashMap::new(), body_graph: None, + cross_package_imports: std::sync::Arc::new(std::collections::HashMap::new()), } } @@ -1478,6 +1492,7 @@ fn global_summaries_resolve_body_requires_body_present() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); // Don't insert body @@ -3415,6 +3430,7 @@ fn sink_site_serde_round_trip_solo() { col: 9, snippet: "Command::new(\"sh\").arg(cmd).status()".into(), cap: Cap::CODE_EXEC | Cap::SHELL_ESCAPE, + from_chain: false, }; let json = serde_json::to_string(&site).unwrap(); let back: SinkSite = serde_json::from_str(&json).unwrap(); @@ -3446,6 +3462,7 @@ fn ssa_summary_serde_round_trip_with_sink_sites() { col: 4, snippet: "cursor.execute(sql)".into(), cap: Cap::SQL_QUERY, + from_chain: false, }; let site_b = SinkSite { file_rel: "exec.py".into(), @@ -3453,6 +3470,7 @@ fn ssa_summary_serde_round_trip_with_sink_sites() { col: 12, snippet: "subprocess.call(cmd, shell=True)".into(), cap: Cap::CODE_EXEC | Cap::SHELL_ESCAPE, + from_chain: false, }; let summary = SsaFuncSummary { param_to_return: vec![(0, TaintTransform::Identity)], @@ -3526,6 +3544,7 @@ fn merge_unions_sink_sites_with_dedup() { col: 1, snippet: "execute(sql)".into(), cap: Cap::SQL_QUERY, + from_chain: false, }; let site_b = SinkSite { file_rel: "svc.py".into(), @@ -3533,6 +3552,7 @@ fn merge_unions_sink_sites_with_dedup() { col: 4, snippet: "os.system(cmd)".into(), cap: Cap::CODE_EXEC, + from_chain: false, }; let mut left = FuncSummary { @@ -3623,6 +3643,7 @@ fn cf4_return_path_transform_serde_round_trip() { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }; let json = serde_json::to_string(&summary).unwrap(); let back: SsaFuncSummary = serde_json::from_str(&json).unwrap(); @@ -4459,3 +4480,95 @@ mod hierarchy_widened_tests { assert!(post_merge_reinstalled.contains(&k_sub)); } } + +#[test] +fn cross_package_imports_round_trip_via_global_summaries() { + use crate::symbol::{FuncKey, FuncKind, Lang}; + let mut gs = GlobalSummaries::new(); + let mut map: std::collections::HashMap = std::collections::HashMap::new(); + map.insert( + "escape".to_string(), + FuncKey { + lang: Lang::TypeScript, + namespace: "packages/util/src/escape.ts".to_string(), + container: String::new(), + name: "escape".to_string(), + arity: None, + disambig: None, + kind: FuncKind::Function, + }, + ); + let arc = std::sync::Arc::new(map); + gs.insert_cross_package_imports("apps/api/handler.ts".to_string(), arc.clone()); + + assert_eq!(gs.cross_package_imports_len(), 1); + let looked_up = gs + .get_cross_package_imports("apps/api/handler.ts") + .expect("namespace lookup must hit"); + assert_eq!(looked_up.len(), 1); + assert!(looked_up.contains_key("escape")); + assert!(gs.get_cross_package_imports("missing").is_none()); + + // Inserting an empty map is a no-op so the index does not get + // polluted with bookkeeping rows when a file's resolver produces + // no resolved bindings. + gs.insert_cross_package_imports( + "apps/api/no_imports.ts".to_string(), + std::sync::Arc::new(std::collections::HashMap::new()), + ); + assert_eq!(gs.cross_package_imports_len(), 1); +} + +#[test] +fn cross_package_imports_merged_across_thread_local_summaries() { + use crate::symbol::{FuncKey, FuncKind, Lang}; + + let mut gs_a = GlobalSummaries::new(); + let mut map_a: std::collections::HashMap = std::collections::HashMap::new(); + map_a.insert( + "escape".to_string(), + FuncKey { + lang: Lang::TypeScript, + namespace: "packages/util/src/escape.ts".to_string(), + container: String::new(), + name: "escape".to_string(), + arity: None, + disambig: None, + kind: FuncKind::Function, + }, + ); + gs_a.insert_cross_package_imports( + "apps/api/handler_a.ts".to_string(), + std::sync::Arc::new(map_a), + ); + + let mut gs_b = GlobalSummaries::new(); + let mut map_b: std::collections::HashMap = std::collections::HashMap::new(); + map_b.insert( + "format".to_string(), + FuncKey { + lang: Lang::TypeScript, + namespace: "packages/util/src/format.ts".to_string(), + container: String::new(), + name: "format".to_string(), + arity: None, + disambig: None, + kind: FuncKind::Function, + }, + ); + gs_b.insert_cross_package_imports( + "apps/api/handler_b.ts".to_string(), + std::sync::Arc::new(map_b), + ); + + gs_a.merge(gs_b); + assert_eq!(gs_a.cross_package_imports_len(), 2); + assert!( + gs_a.get_cross_package_imports("apps/api/handler_a.ts") + .is_some() + ); + assert!( + gs_a.get_cross_package_imports("apps/api/handler_b.ts") + .is_some() + ); +} diff --git a/src/symbol/mod.rs b/src/symbol/mod.rs index 2c447527..94cb8054 100644 --- a/src/symbol/mod.rs +++ b/src/symbol/mod.rs @@ -262,5 +262,31 @@ pub fn normalize_namespace(abs_path: &str, root: Option<&str>) -> String { abs_path.to_string() } +/// Phase-04 namespace builder that prefixes a project-relative path with +/// the canonical package name when the importer file lies inside a +/// resolved [`crate::resolve::PackageEntry`]. +/// +/// Returns `"@scope/name::src/file.ts"` when the file is in a package +/// and `"src/file.ts"` (the same value `normalize_namespace` produces) +/// otherwise. Phase 04 ships this helper unused at the resolution +/// site, phase 10 will route [`FuncKey`] construction through it for +/// JS/TS files so cross-file callee lookup honours the package +/// boundary. +pub fn namespace_with_package( + abs_path: &str, + root: Option<&str>, + module_graph: Option<&crate::resolve::ModuleGraph>, +) -> String { + let plain = normalize_namespace(abs_path, root); + let Some(graph) = module_graph else { + return plain; + }; + let path = std::path::Path::new(abs_path); + match graph.package_for(path) { + Some(pkg) => format!("{}::{}", pkg.name, plain), + None => plain, + } +} + #[cfg(test)] mod tests; diff --git a/src/symex/executor.rs b/src/symex/executor.rs index 96e8be85..7f34a0aa 100644 --- a/src/symex/executor.rs +++ b/src/symex/executor.rs @@ -1384,6 +1384,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let empty_succs = HashMap::new(); @@ -1445,6 +1446,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let empty_succs = HashMap::new(); @@ -1579,6 +1581,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let finding = make_finding(n0, n1); @@ -1688,6 +1691,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; // Finding path goes through B0 → B1 → B3 @@ -1836,6 +1840,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let finding = Finding { @@ -1950,6 +1955,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let mut exc_succs: HashMap> = HashMap::new(); @@ -2018,6 +2024,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let mut exc_succs: HashMap> = HashMap::new(); @@ -2127,6 +2134,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let finding = Finding { diff --git a/src/symex/loops.rs b/src/symex/loops.rs index 652476b4..c2eef998 100644 --- a/src/symex/loops.rs +++ b/src/symex/loops.rs @@ -391,6 +391,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -438,6 +439,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -521,6 +523,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -585,6 +588,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -667,6 +671,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -740,6 +745,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -776,6 +782,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -834,6 +841,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -916,6 +924,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -996,6 +1005,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); @@ -1033,6 +1043,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let info = analyse_loops(&ssa); diff --git a/src/symex/mod.rs b/src/symex/mod.rs index 1e3b0041..1f185083 100644 --- a/src/symex/mod.rs +++ b/src/symex/mod.rs @@ -381,6 +381,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let finding = Finding { @@ -456,6 +457,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let finding = Finding { @@ -560,6 +562,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let ctx = SymexContext { @@ -622,6 +625,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let ctx = SymexContext { diff --git a/src/symex/state.rs b/src/symex/state.rs index 4bb3a7a4..ceb6fe07 100644 --- a/src/symex/state.rs +++ b/src/symex/state.rs @@ -355,6 +355,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let witness = state.get_sink_witness(&finding, &ssa); @@ -397,6 +398,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; assert_eq!(state.get_sink_witness(&finding, &ssa), None); @@ -436,6 +438,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; assert_eq!(state.get_sink_witness(&finding, &ssa), None); @@ -478,6 +481,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; state.widen_at_loop_head(BlockId(0), &ssa); @@ -523,6 +527,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; state.widen_at_loop_head(BlockId(0), &ssa); @@ -568,6 +573,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; state.widen_at_loop_head(BlockId(0), &ssa); diff --git a/src/symex/transfer.rs b/src/symex/transfer.rs index 2acc19d5..6e64727a 100644 --- a/src/symex/transfer.rs +++ b/src/symex/transfer.rs @@ -1014,6 +1014,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } @@ -1595,6 +1596,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); let ctx = make_summary_ctx(&gs); @@ -1665,6 +1667,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); let ctx = make_summary_ctx(&gs); @@ -1735,6 +1738,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); let ctx = make_summary_ctx(&gs); @@ -1800,6 +1804,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); let ctx = make_summary_ctx(&gs); @@ -1865,6 +1870,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); let ctx = make_summary_ctx(&gs); @@ -2064,6 +2070,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); @@ -2144,6 +2151,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); @@ -2225,6 +2233,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); // Second "send", in ns B, also with same arity → ambiguous bare-name @@ -2256,6 +2265,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); // Also register the type-qualified name so Attempt 1 can find it @@ -2287,6 +2297,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); @@ -2367,6 +2378,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); @@ -2449,6 +2461,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); insert_java_summary( @@ -2479,6 +2492,7 @@ mod tests { typed_call_receivers: vec![], validated_params_to_return: smallvec::SmallVec::new(), param_to_gate_filters: vec![], + entry_kind: None, }, ); // No "HttpClient.send" summary registered, disambiguation has 0 exact matches diff --git a/src/symex/witness.rs b/src/symex/witness.rs index d8574929..0dbaff81 100644 --- a/src/symex/witness.rs +++ b/src/symex/witness.rs @@ -797,6 +797,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let finding = Finding { @@ -854,6 +855,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let cfg = Cfg::new(); let finding = Finding { @@ -917,6 +919,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let finding = Finding { @@ -981,6 +984,7 @@ mod tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let finding = Finding { diff --git a/src/taint/backwards.rs b/src/taint/backwards.rs index 5e2400de..d505dc6a 100644 --- a/src/taint/backwards.rs +++ b/src/taint/backwards.rs @@ -753,6 +753,7 @@ mod tests { field_interner: crate::ssa::ir::FieldInterner::default(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; (ssa, cfg) @@ -843,6 +844,7 @@ mod tests { field_interner: crate::ssa::ir::FieldInterner::default(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let demand = DemandState::new(Cap::all()); let (step, next) = backward_transfer(&ssa, SsaValue(0), &demand); @@ -876,6 +878,7 @@ mod tests { field_interner: crate::ssa::ir::FieldInterner::default(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let demand = DemandState::new(Cap::all()); let (step, _next) = backward_transfer(&ssa, SsaValue(0), &demand); @@ -964,6 +967,7 @@ mod tests { field_interner: crate::ssa::ir::FieldInterner::default(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let demand = DemandState::new(Cap::all()); @@ -1053,6 +1057,7 @@ mod tests { field_interner: crate::ssa::ir::FieldInterner::default(), field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let ctx = BackwardsCtx::new(&ssa, &cfg, Lang::JavaScript); diff --git a/src/taint/mod.rs b/src/taint/mod.rs index 8d90be58..ed47877e 100644 --- a/src/taint/mod.rs +++ b/src/taint/mod.rs @@ -403,6 +403,86 @@ fn compute_module_aliases_for_summary( crate::ssa::const_prop::collect_module_aliases(ssa, &cp.values) } +/// Build a per-file cross-package import lookup for Phase 09 cross-file IPA. +/// +/// For each [`crate::resolve::ImportBinding`] whose resolver verdict +/// produced a concrete `(resolved_file, exported_name)` pair, builds the +/// canonical [`FuncKey`] of the imported function in its own file's +/// scan-root-relative namespace and stores it under the caller-file's +/// local binding name. +/// +/// Returns an empty map when the file has no resolved imports (non-JS/TS +/// files, scans without a `ModuleGraph`, side-effect-only imports, or +/// builtin/unresolved specifiers). The caller passes `None` to +/// `SsaTaintTransfer::cross_package_imports` in that case. +/// +/// `module_graph` aligns the target [`FuncKey::namespace`] with the +/// package-prefixed form that `FuncSummary::func_key_with_resolver` +/// produces on the cross-file storage side: when the resolved file lies +/// inside a discovered package the namespace becomes +/// `"@scope/name::src/file.ts"`, otherwise it falls back to plain +/// `normalize_namespace`. Step 0.7 of `resolve_callee_full` looks up +/// `(lang, namespace, name)` against `GlobalSummaries::ssa_by_key` +/// where the SSA-side keys are now produced via the same +/// `namespace_with_package` shape (callers in `crate::ast::ParsedFile` +/// pre-compute the package-prefixed namespace before invoking +/// `lower_all_functions_from_bodies`), so the two sides agree even +/// when two packages share a project-relative file path. +/// +/// `module_graph = None` (single-package scans, non-JS/TS files, unit +/// tests, indexed-mode SQLite fallback) collapses to the historical +/// `normalize_namespace` behaviour, keeping the migration strictly +/// additive for any consumer that does not opt in. +/// +/// The constructed key intentionally leaves `container`, `arity`, +/// `disambig`, and `kind` at their defaults — the resolver verdict only +/// fixes the `(lang, namespace, name)` triple, and step 0.7 of +/// `resolve_callee_full` matches against `GlobalSummaries::ssa_by_key` +/// using only those three fields plus an arity hint when available. +pub fn build_cross_package_func_keys( + resolved_imports: &[crate::resolve::ImportBinding], + scan_root: Option<&str>, + module_graph: Option<&crate::resolve::ModuleGraph>, + caller_lang: Lang, +) -> HashMap { + let mut out: HashMap = HashMap::new(); + for binding in resolved_imports { + let Some(ref resolved_file) = binding.resolved_file else { + continue; + }; + let Some(ref exported_name) = binding.exported_name else { + continue; + }; + if exported_name.is_empty() + || exported_name == "*" + || exported_name == "default" + || binding.local_name.is_empty() + { + // Side-effect / namespace / default imports do not map to a + // single named export; step 0.7 needs a concrete leaf name. + continue; + } + let target_lang = resolved_file + .extension() + .and_then(|e| e.to_str()) + .and_then(Lang::from_extension) + .unwrap_or(caller_lang); + let abs = resolved_file.to_string_lossy(); + let namespace = crate::symbol::namespace_with_package(&abs, scan_root, module_graph); + let key = FuncKey { + lang: target_lang, + namespace, + container: String::new(), + name: exported_name.clone(), + arity: None, + disambig: None, + kind: FuncKind::Function, + }; + out.insert(binding.local_name.clone(), key); + } + out +} + /// Run taint analysis on all bodies in a file. /// /// Uses a unified multi-body analysis for all languages: @@ -432,25 +512,32 @@ pub fn analyse_file( ssa_transfer::reset_all_validated_spans(); // No locator: pass-2 intra-file summaries are transient (not persisted) // and behavior depends on SinkSite.cap only, which is always populated. - let (ssa_summaries, callee_bodies) = lower_all_functions_from_bodies( - file_cfg, - caller_lang, - caller_namespace, - local_summaries, - global_summaries, - None, - ); - analyse_file_with_lowered( - file_cfg, - local_summaries, - global_summaries, - caller_lang, - caller_namespace, - interop_edges, - extra_labels, - &ssa_summaries, - &callee_bodies, - ) + crate::ssa::type_facts::with_file_imports(Some(&file_cfg.local_imports), || { + crate::cfg::safe_fields::with_safe_lookup_fields(Some(&file_cfg.safe_lookup_fields), || { + let (ssa_summaries, callee_bodies) = lower_all_functions_from_bodies( + file_cfg, + caller_lang, + caller_namespace, + local_summaries, + global_summaries, + None, + None, + None, + ); + analyse_file_with_lowered( + file_cfg, + local_summaries, + global_summaries, + caller_lang, + caller_namespace, + interop_edges, + extra_labels, + &ssa_summaries, + &callee_bodies, + None, + ) + }) + }) } /// Same as [`analyse_file`] but takes pre-lowered SSA summaries + callee @@ -459,6 +546,10 @@ pub fn analyse_file( /// the SSA-artifact extractor; the bare [`analyse_file`] entry-point keeps /// its prior signature for any caller that does not have a pre-lowered /// result handy. +/// +/// `cross_package_imports` is the optional Phase-09 lookup map built via +/// [`build_cross_package_func_keys`]. `None` (the public-API default) +/// disables cross-package step 0.7 in `resolve_callee_full`. #[allow(clippy::too_many_arguments)] pub(crate) fn analyse_file_with_lowered( file_cfg: &FileCfg, @@ -470,9 +561,49 @@ pub(crate) fn analyse_file_with_lowered( extra_labels: Option<&[crate::labels::RuntimeLabelRule]>, ssa_summaries: &std::collections::HashMap, callee_bodies: &std::collections::HashMap, + cross_package_imports: Option<&std::collections::HashMap>, ) -> Vec { let _span = tracing::debug_span!("taint_analyse_file").entered(); + // Publish the per-file local-import view so the ORM TypeKind gate + // inside [`crate::ssa::type_facts::constructor_type`] can read it + // during downstream `optimize_ssa_with_param_types` passes. The + // outer `analyse_file` already wraps this for its own + // `lower_all_functions_from_bodies` pre-pass; wrapping here too + // keeps direct callers (e.g. [`crate::ast::analyse_file_fused`]) + // covered. Idempotent under nesting — the inner guard restores + // the outer value on drop. + crate::ssa::type_facts::with_file_imports(Some(&file_cfg.local_imports), || { + crate::cfg::safe_fields::with_safe_lookup_fields(Some(&file_cfg.safe_lookup_fields), || { + analyse_file_with_lowered_inner( + file_cfg, + local_summaries, + global_summaries, + caller_lang, + caller_namespace, + interop_edges, + extra_labels, + ssa_summaries, + callee_bodies, + cross_package_imports, + ) + }) + }) +} + +#[allow(clippy::too_many_arguments)] +fn analyse_file_with_lowered_inner( + file_cfg: &FileCfg, + local_summaries: &FuncSummaries, + global_summaries: Option<&GlobalSummaries>, + caller_lang: Lang, + caller_namespace: &str, + interop_edges: &[InteropEdge], + extra_labels: Option<&[crate::labels::RuntimeLabelRule]>, + ssa_summaries: &std::collections::HashMap, + callee_bodies: &std::collections::HashMap, + cross_package_imports: Option<&std::collections::HashMap>, +) -> Vec { // NOTE: the path-safe-suppressed span set is reset by the caller, not // here. Per-parameter probes inside the lowering phase // (`lower_all_functions_from_bodies`) can already publish spans via @@ -551,6 +682,7 @@ pub(crate) fn analyse_file_with_lowered( max_iterations, import_bindings_ref, cross_file_bodies_ref, + cross_package_imports, ); // 4. Deduplicate findings using a richer key that preserves distinct @@ -797,6 +929,34 @@ fn inject_external_type_facts( } } +/// Apply entry-kind-derived overrides to a body's `param_types` vector. +/// +/// Today only `EntryKind::AppRouteHandler` triggers an override: the first +/// formal of a Next.js App Router handler always carries a Web `Request`, +/// regardless of the user's TypeScript annotation. Returns `Some(vec)` when +/// the override changes the vector, `None` otherwise. Folding the rule into +/// one helper keeps the two consumers (`analyse_body_with_seed` and +/// `lower_all_functions_from_bodies_inner`) in lockstep. +fn entry_kind_param_type_override( + entry_kind: Option<&crate::entry_points::EntryKind>, + param_types: &[Option], +) -> Option>> { + if matches!( + entry_kind, + Some(crate::entry_points::EntryKind::AppRouteHandler { .. }) + ) { + let mut pt = param_types.to_vec(); + if pt.is_empty() { + pt.push(Some(crate::ssa::type_facts::TypeKind::Request)); + } else { + pt[0] = Some(crate::ssa::type_facts::TypeKind::Request); + } + Some(pt) + } else { + None + } +} + /// Analyse a single body with an optional parent seed. /// /// Shared logic extracted from `analyse_multi_body` to avoid deep nesting. @@ -818,6 +978,7 @@ fn analyse_body_with_seed( import_bindings: Option<&crate::cfg::ImportBindings>, cross_file_bodies: Option<&std::collections::HashMap>, parent_var_types: Option<&HashMap>, + cross_package_imports: Option<&std::collections::HashMap>, ) -> ( Vec, Option>, @@ -853,10 +1014,156 @@ fn analyse_body_with_seed( // so that `cmd -> Runtime.exec(cmd)` picks up `cmd` as a handler param. let is_java_lambda = lang == Lang::Java && body.meta.kind == crate::cfg::BodyKind::AnonymousFunction; + // Java methods tagged with a Spring/JaxRs entry-point annotation need + // scoped lowering so the formal parameters (`@RequestParam String name`, + // `@PathParam Long id`, ...) materialise as `SsaOp::Param` ops that + // the entry-point seeding pass paints as `Source(UserInput)`. Restricted + // to Java because (a) JS/TS already use scoped lowering above, (b) Go + // and Ruby handlers introduce request-OBJECT formals (`r *http.Request`, + // implicit `params`) whose Cap::all() seeding triggers FPs at sinks + // that take the bare object (e.g. `http.Redirect(w, r, safe, code)` + // where `r` is the request, not the URL), and (c) Python free-name + // captures (`request`, `b64decode`) bubble up as synthetic externals + // and shift source attribution. Java methods don't have those + // free-capture shapes (every reference is via explicit qualification), + // so the precision-vs-recall trade lands on the precision side. + let is_java_entry_method = lang == Lang::Java + && body.meta.kind == crate::cfg::BodyKind::NamedFunction + && body.meta.func_key.as_ref().is_some_and(|k| { + let mut k = k.clone(); + k.namespace = namespace.to_string(); + ssa_summaries + .and_then(|m| m.get(&k)) + .is_some_and(|s| s.entry_kind.is_some()) + }); + // Rust framework handlers (axum, actix-web, Rocket) need scoped + // lowering so the typed-extractor formals (`Query`, `Json`, + // `Form`, `Path`) materialise as `SsaOp::Param` ops that the + // entry-point seeding pass paints as `Source(UserInput)`. The + // per-formal seed decision is gated on a recovered `TypeKind` from + // `BodyMeta.param_types`: extractor-wrapped formals get + // `Some(TypeKind::Int|String|Bool|...)` (or a DTO type) via + // `rust_type_to_kind`, while denylist wrappers (`State`, + // `Extension`, `Pool`, ...) and bare primitives stay `None` + // and are skipped at seed time. This keeps DI handles + // server-side without painting the database pool as adversary input. + let is_rust_entry_method = lang == Lang::Rust + && body.meta.kind == crate::cfg::BodyKind::NamedFunction + && body.meta.func_key.as_ref().is_some_and(|k| { + let mut k = k.clone(); + k.namespace = namespace.to_string(); + ssa_summaries.and_then(|m| m.get(&k)).is_some_and(|s| { + matches!( + s.entry_kind, + Some(crate::entry_points::EntryKind::AxumHandler) + | Some(crate::entry_points::EntryKind::ActixHandler) + | Some(crate::entry_points::EntryKind::RocketRoute) + ) + }) + }); + // Python Flask handlers need scoped lowering so the route-bound formal + // parameters (`@app.route("/users/")` + `def view(name):`) + // materialise as `SsaOp::Param` ops the entry-point seeding pass paints + // as `Source(UserInput)`. The per-formal seed decision is gated against + // `BodyMeta.param_route_capture`, so only formals whose names appear as + // path captures in the routing decorator are painted; implicit globals + // (`request`, `g`, `session`) and DI-injected formals stay un-seeded. + // Restricted to Flask (`FlaskRoute`) here because FastAPI / Django + // free-name capture shapes (`request`, `b64decode`) bubble up as + // synthetic externals under scoped lowering and shift source + // attribution, while Flask handlers have all formals = path captures + // (precision lands cleanly). + let is_python_flask_route = lang == Lang::Python + && body.meta.kind == crate::cfg::BodyKind::NamedFunction + && body + .meta + .param_route_capture + .iter() + .any(|captured| *captured) + && body.meta.func_key.as_ref().is_some_and(|k| { + let mut k = k.clone(); + k.namespace = namespace.to_string(); + ssa_summaries.and_then(|m| m.get(&k)).is_some_and(|s| { + matches!( + s.entry_kind, + Some(crate::entry_points::EntryKind::FlaskRoute { .. }) + ) + }) + }); + // Ruby Sinatra route handlers need scoped lowering so the block + // parameters (`get "/u/:name" do |name| ... end`) materialise as + // `SsaOp::Param` ops the entry-point seeding pass paints as + // `Source(UserInput)`. Sinatra body bodies are anonymous (the + // `do_block` AST node has no name field), so `BodyKind` is + // `AnonymousFunction`; the gate accepts both anonymous and named. + // Per-formal seed decision is gated against + // `BodyMeta.param_route_capture`, so only block formals whose + // names appear as `:name` segments in the routing path are + // painted. Block formals not in the capture set fall back to + // existing label rules. + let is_ruby_sinatra_route = lang == Lang::Ruby + && matches!( + body.meta.kind, + crate::cfg::BodyKind::NamedFunction | crate::cfg::BodyKind::AnonymousFunction + ) + && body + .meta + .param_route_capture + .iter() + .any(|captured| *captured) + && body.meta.func_key.as_ref().is_some_and(|k| { + let mut k = k.clone(); + k.namespace = namespace.to_string(); + ssa_summaries.and_then(|m| m.get(&k)).is_some_and(|s| { + matches!( + s.entry_kind, + Some(crate::entry_points::EntryKind::SinatraRoute { .. }) + ) + }) + }); + // Python FastAPI / Starlette handlers need scoped lowering so the + // route-bound and typed-extractor formals materialise as `SsaOp::Param` + // ops that the entry-point seeding pass paints as `Source(UserInput)`. + // The per-formal decision in `ssa_transfer` consults BOTH + // `BodyMeta.param_route_capture` (for `{name}` brace-segment captures) + // and `type_facts.get_type(value)` (for `Annotated[T, Path()/Query()/Body() + // /Header()/Cookie()/Form()/File()]` typed extractors). Formals without + // either signal — `db: Session = Depends(get_db)`, `request: Request`, + // bare `session` — stay un-seeded, matching the Hard Rule 3 policy that + // unannotated formals are not adversary input. + // + // Gated on "at least one formal qualifies" to mirror the Flask gate: + // a handler with zero path captures and zero typed extractors gets the + // existing label-rule treatment (free-name captures of `request`, + // `b64decode`, etc. bubble up as synthetic externals without scoped + // lowering shifting attribution). + let is_python_fastapi_route = lang == Lang::Python + && body.meta.kind == crate::cfg::BodyKind::NamedFunction + && (body + .meta + .param_route_capture + .iter() + .any(|captured| *captured) + || body.meta.param_types.iter().any(|t| t.is_some())) + && body.meta.func_key.as_ref().is_some_and(|k| { + let mut k = k.clone(); + k.namespace = namespace.to_string(); + ssa_summaries.and_then(|m| m.get(&k)).is_some_and(|s| { + matches!( + s.entry_kind, + Some(crate::entry_points::EntryKind::FastApiRoute { .. }) + ) + }) + }); let use_scoped_lowering = !is_toplevel && (matches!(lang, Lang::JavaScript | Lang::TypeScript) || has_nonempty_seed - || is_java_lambda); + || is_java_lambda + || is_java_entry_method + || is_rust_entry_method + || is_python_flask_route + || is_python_fastapi_route + || is_ruby_sinatra_route); let ssa_result = if use_scoped_lowering { let func_name = body.meta.name.clone().unwrap_or_else(|| { body.meta @@ -878,11 +1185,28 @@ fn analyse_body_with_seed( match ssa_result { Ok(mut ssa_body) => { + // App Router handlers carry a Web `Request` as their first + // formal. Override `param_types[0]` so the type-fact pass tags + // the formal as `TypeKind::Request` and receiver-method reads + // (`req.json()`, ...) rewrite to `Request.` for + // type-qualified label resolution. + let body_entry_kind = body.meta.func_key.as_ref().and_then(|k| { + let mut k = k.clone(); + k.namespace = namespace.to_string(); + ssa_summaries + .and_then(|m| m.get(&k)) + .and_then(|s| s.entry_kind.clone()) + }); + let overridden_param_types = + entry_kind_param_type_override(body_entry_kind.as_ref(), &body.meta.param_types); + let param_types_ref = overridden_param_types + .as_deref() + .unwrap_or(body.meta.param_types.as_slice()); let mut opt = crate::ssa::optimize_ssa_with_param_types( &mut ssa_body, cfg, Some(lang), - &body.meta.param_types, + param_types_ref, ); // Forward parent-body type facts onto closure-captured Param ops // before any consumer reads `opt.type_facts`. This is the lever @@ -965,6 +1289,16 @@ fn analyse_body_with_seed( && body.meta.kind == crate::cfg::BodyKind::AnonymousFunction), cross_file_bodies, pointer_facts: pointer_facts.as_ref(), + cross_package_imports, + // Phase 10 — Next.js entry-point seeding (looked up + // above when overriding `param_types`). + entry_kind: body_entry_kind, + param_route_capture: if body.meta.param_route_capture.is_empty() { + None + } else { + Some(body.meta.param_route_capture.as_slice()) + }, + recording_summary: false, }; let (events, block_states) = ssa_transfer::run_ssa_taint_full(&ssa_body, cfg, &transfer); @@ -1098,6 +1432,7 @@ fn analyse_multi_body( max_iterations: usize, import_bindings: Option<&crate::cfg::ImportBindings>, cross_file_bodies: Option<&std::collections::HashMap>, + cross_package_imports: Option<&std::collections::HashMap>, ) -> Vec { let order = containment_order(&file_cfg.bodies); let mut all_findings: Vec = Vec::new(); @@ -1144,6 +1479,7 @@ fn analyse_multi_body( import_bindings, cross_file_bodies, parent_var_types, + cross_package_imports, ); tracing::debug!( body_id = body.meta.id.0, @@ -1340,6 +1676,7 @@ fn analyse_multi_body( import_bindings, cross_file_bodies, parent_var_types, + cross_package_imports, ); // Phase-B: replace (not append) this body's findings // in the cache. Previous rounds' findings for this @@ -1688,6 +2025,7 @@ pub(crate) fn extract_intra_file_ssa_summaries( /// resistant identity we have: same-name methods on different classes, same- /// name overloads with different arity, and anonymous bodies at distinct /// source spans all get distinct keys. +#[allow(clippy::too_many_arguments)] pub(crate) fn lower_all_functions_from_bodies( file_cfg: &FileCfg, lang: Lang, @@ -1695,6 +2033,38 @@ pub(crate) fn lower_all_functions_from_bodies( local_summaries: &FuncSummaries, global_summaries: Option<&GlobalSummaries>, locator: Option<&crate::summary::SinkSiteLocator<'_>>, + scan_root: Option<&str>, + module_graph: Option<&crate::resolve::ModuleGraph>, +) -> ( + std::collections::HashMap, + std::collections::HashMap, +) { + crate::ssa::type_facts::with_file_imports(Some(&file_cfg.local_imports), || { + crate::cfg::safe_fields::with_safe_lookup_fields(Some(&file_cfg.safe_lookup_fields), || { + lower_all_functions_from_bodies_inner( + file_cfg, + lang, + namespace, + local_summaries, + global_summaries, + locator, + scan_root, + module_graph, + ) + }) + }) +} + +#[allow(clippy::too_many_arguments)] +fn lower_all_functions_from_bodies_inner( + file_cfg: &FileCfg, + lang: Lang, + namespace: &str, + local_summaries: &FuncSummaries, + global_summaries: Option<&GlobalSummaries>, + locator: Option<&crate::summary::SinkSiteLocator<'_>>, + scan_root: Option<&str>, + module_graph: Option<&crate::resolve::ModuleGraph>, ) -> ( std::collections::HashMap, std::collections::HashMap, @@ -1702,6 +2072,23 @@ pub(crate) fn lower_all_functions_from_bodies( let mut summaries = std::collections::HashMap::new(); let mut bodies = std::collections::HashMap::new(); + // Build the file's cross-package import map once and share it + // across every body produced from this file. The map mirrors what + // `analyse_file_with_lowered` builds at pass-2 entry, but storing + // it on each `CalleeSsaBody` lets the inline-analysis frame inside + // another file resolve the callee's local import names against + // the callee's own package boundary (Phase 09 step 0.7) instead of + // skipping the lookup entirely. + let cross_package_imports_arc = { + let map = build_cross_package_func_keys( + &file_cfg.resolved_imports, + scan_root, + module_graph, + lang, + ); + std::sync::Arc::new(map) + }; + for body in file_cfg.function_bodies() { let _t_misc = std::time::Instant::now(); let func_name = body.meta.name.clone().unwrap_or_else(|| { @@ -1797,6 +2184,15 @@ pub(crate) fn lower_all_functions_from_bodies( param_types_ref, ); + // Phase 10 — annotate entry-point summaries. The pass-2 + // taint engine reads `entry_kind` to seed the function's + // formals as `TaintOrigin::Source` at SSA entry, mirroring + // an HTTP handler's adversary-controlled inputs. Always + // recorded even on empty summaries so caller-side resolution + // sees the entry classification through cross-file lookups. + let mut summary = summary; + summary.entry_kind = file_cfg.entry_kinds.get(&body.meta.span).cloned(); + // Always insert the summary, even when all fields are empty/default. // An empty summary tells resolve_callee "this function exists and has // no taint effects", preventing fallthrough to the less precise old @@ -1804,18 +2200,34 @@ pub(crate) fn lower_all_functions_from_bodies( // For zero-param functions we only insert when the summary carries // the fresh-container signal (the only observable effect worth // persisting for a parameter-less body). - if param_count > 0 || summary.points_to.returns_fresh_alloc { + // + // An entry-kind tag also keeps the summary in the map even + // for zero-param entry points so cross-file resolvers see it. + if param_count > 0 + || summary.points_to.returns_fresh_alloc + || summary.entry_kind.is_some() + { summaries.insert(key.clone(), summary); } perf_lower_record(1, _t_extract.elapsed().as_micros()); } let _t_opt = std::time::Instant::now(); + // Override `param_types[0]` for entry-kind-tagged formals (e.g. App + // Router handlers receive a Web `Request`). Other entry kinds keep + // the ambient param-type vector unchanged. See + // `entry_kind_param_type_override` for the full rule set. + let entry_kind_for_body = file_cfg.entry_kinds.get(&body.meta.span); + let overridden_param_types = + entry_kind_param_type_override(entry_kind_for_body, &body.meta.param_types); + let param_types_ref = overridden_param_types + .as_deref() + .unwrap_or(body.meta.param_types.as_slice()); let opt = crate::ssa::optimize_ssa_with_param_types( &mut func_ssa, &body.graph, Some(lang), - &body.meta.param_types, + param_types_ref, ); perf_lower_record(2, _t_opt.elapsed().as_micros()); @@ -1857,6 +2269,7 @@ pub(crate) fn lower_all_functions_from_bodies( param_count, node_meta: std::collections::HashMap::new(), body_graph: Some(body.graph.clone()), + cross_package_imports: std::sync::Arc::clone(&cross_package_imports_arc), }, ); perf_lower_record(6, _t_misc2.elapsed().as_micros()); @@ -2256,6 +2669,10 @@ fn augment_summaries_with_child_sinks( auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let (_parent_events, parent_block_states) = @@ -2320,6 +2737,10 @@ fn augment_summaries_with_child_sinks( auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let (child_events, _child_block_states) = @@ -2448,6 +2869,7 @@ type EligibleCalleeBodies = Vec<(FuncKey, ssa_transfer::CalleeSsaBody)>; /// entry) and lowers each body's graph with its recorded entry/params. This /// path is equivalent to what `analyse_file` uses at taint time, so the SSA /// summaries produced here line up exactly with what pass 2 will consult. +#[allow(clippy::too_many_arguments)] pub(crate) fn extract_ssa_artifacts_from_file_cfg( file_cfg: &FileCfg, lang: Lang, @@ -2455,6 +2877,8 @@ pub(crate) fn extract_ssa_artifacts_from_file_cfg( local_summaries: &FuncSummaries, global_summaries: Option<&GlobalSummaries>, locator: Option<&crate::summary::SinkSiteLocator<'_>>, + scan_root: Option<&str>, + module_graph: Option<&crate::resolve::ModuleGraph>, ) -> (SsaArtifactSummaries, EligibleCalleeBodies) { let (summaries, bodies) = lower_all_functions_from_bodies( file_cfg, @@ -2463,6 +2887,8 @@ pub(crate) fn extract_ssa_artifacts_from_file_cfg( local_summaries, global_summaries, locator, + scan_root, + module_graph, ); let eligible_bodies = build_eligible_bodies(file_cfg, bodies); (summaries, eligible_bodies) diff --git a/src/taint/ssa_transfer/inline.rs b/src/taint/ssa_transfer/inline.rs index 07dcbf02..05d496d4 100644 --- a/src/taint/ssa_transfer/inline.rs +++ b/src/taint/ssa_transfer/inline.rs @@ -142,6 +142,27 @@ pub struct CalleeSsaBody { /// bodies. #[serde(skip)] pub body_graph: Option, + /// The callee body's own file-level cross-package import map (Phase 09 + /// step 0.7 keyset). + /// + /// Populated when the body is freshly lowered with the file's + /// [`crate::cfg::FileCfg::resolved_imports`] in scope. Forwarded into + /// the inline-analysis child transfer so transitive cross-package + /// resolution inside an inlined frame can land in + /// `crate::summary::GlobalSummaries::ssa_by_key` using the callee's + /// own import view rather than the caller's (which would mis-resolve + /// names against the caller's package boundary). + /// + /// Wrapped in `Arc` so every body in a file shares one heap + /// allocation; per-file bodies typically count in the tens to + /// hundreds, and import maps are append-only after construction. + /// `#[serde(skip)]` because the map is reproducible from the file's + /// `resolved_imports` and bears no identity on its own; an indexed + /// scan that loads a body from SQLite simply skips step 0.7 inside + /// the inlined frame (same conservative behaviour as before this + /// field existed). + #[serde(skip)] + pub cross_package_imports: std::sync::Arc>, } /// Populate `node_meta` from the original CFG for cross-file persistence. diff --git a/src/taint/ssa_transfer/mod.rs b/src/taint/ssa_transfer/mod.rs index 9aa952e8..99f812f1 100644 --- a/src/taint/ssa_transfer/mod.rs +++ b/src/taint/ssa_transfer/mod.rs @@ -204,6 +204,51 @@ pub struct SsaTaintTransfer<'a> { /// the matching cells. Strict-additive: `None` reproduces today's /// pointer-unaware behaviour. pub pointer_facts: Option<&'a crate::pointer::PointsToFacts>, + /// Phase-09 cross-package import lookup: maps the caller-file's local + /// binding name (e.g. `escapeHtml`) to the canonical [`FuncKey`] of + /// the imported function in its own package's namespace. + /// + /// Populated by [`crate::taint::build_cross_package_func_keys`] from + /// each file's [`crate::cfg::FileCfg::resolved_imports`] before pass-2 + /// taint analysis. Consumed by `resolve_callee_full` at step 0.7 to + /// look up the cross-package callee's SSA summary directly via + /// [`crate::summary::GlobalSummaries::get_ssa`]. + /// + /// `None` (or empty map) when the file has no resolver-resolved + /// imports (non-JS/TS, no `ModuleGraph`, no resolved package boundary). + /// In that case step 0.7 is a no-op and resolution falls through to + /// the existing flat-name paths. + pub cross_package_imports: Option<&'a HashMap>, + /// Phase-10 Next.js entry-point classification for the body + /// currently under analysis. When `Some(_)`, every formal + /// [`SsaOp::Param`] in the entry block is seeded with + /// `Cap::all()` taint and a `TaintOrigin` whose + /// [`SourceKind`] is derived from the entry kind, mirroring an + /// HTTP request handler's adversary-controlled inputs. `None` + /// preserves today's per-callsite seeding (`global_seed`, + /// `param_seed`, `auto_seed_handler_params`). + pub entry_kind: Option, + /// Per-formal route-capture flag, indexed by `SsaOp::Param.index`. + /// Used by the entry-kind seeding pass for Python `FlaskRoute` + /// (and any future per-formal-name framework gate) to restrict + /// `Source(UserInput)` painting to formals whose names appear as + /// path captures in the routing decorator. Out-of-range indices + /// default to `false` and skip seeding. `None` preserves today's + /// "seed every Param" behaviour for entry kinds without per-formal + /// gating (Spring, JaxRs, Sinatra, Express, Gin, AppRoute, ...). + pub param_route_capture: Option<&'a [bool]>, + /// True when the transfer is driving a per-parameter probe inside + /// [`crate::taint::ssa_transfer::summary_extract::extract_ssa_func_summary`] + /// rather than the pass-2 emission path. The probes record chain-hop + /// sites onto the function's [`crate::summary::ssa_summary::SsaFuncSummary`]; + /// pass-2 emission gates the same sites against the + /// `from_chain || file_rel != caller_namespace` predicate so single-hop + /// intra-file helpers keep call-site emission. Probes set this flag + /// so `pick_primary_sink_sites` always promotes a deeper-callee site + /// into `event.primary_sink_site`, regardless of file boundary, so + /// `crate::taint::ssa_transfer::summary_extract` can flip + /// `from_chain=true` and persist the chain hop. Default: false. + pub recording_summary: bool, } /// Per-predecessor state tracking for path-sensitive phi evaluation. @@ -257,6 +302,235 @@ fn run_ssa_taint_internal( let mut block_exit_states: Vec> = vec![None; num_blocks]; block_states[ssa.entry.0 as usize] = Some(SsaTaintState::initial()); + // Phase 10 + Phase 16 — entry-point parameter seeding. When the + // body under analysis is a recognised framework entry (Next.js, + // Express, Django, FastAPI, Flask, Spring, JAX-RS, Rails, Sinatra, + // axum, actix-web, rocket, net/http, gin, ...), seed the relevant + // formal `Param` operations in the entry block with `Cap::all()` and + // a `TaintOrigin::UserInput` so the engine treats request-bound + // inputs as adversary-controlled without waiting for a caller-side + // flow. Per-variant policy below selects which formals to seed: + // most variants seed every named formal; Express seeds only the + // first (`req`); `net/http` seeds only the second (`r` after `w`); + // class-method shapes (Django CBV) skip implicit `self`. + // Entry-kind seeding paints framework handler formals as + // adversary-controlled. Suppress entirely when the body lives in a + // test file: closures with the right shape (`func(c *gin.Context)`, + // `func(req, res)`, etc.) appear in unit tests as scaffolding, not + // as request-reachable production routes. Painting their formals + // as Source surfaces every internal sink the test exercises as a + // finding whose "attacker" is the test author. + let body_in_test_file = !transfer.namespace.is_empty() + && crate::ast::is_test_file(std::path::Path::new(transfer.namespace)); + if !body_in_test_file + && let (Some(entry_kind), Some(state)) = ( + transfer.entry_kind.as_ref(), + block_states[ssa.entry.0 as usize].as_mut(), + ) + { + use crate::entry_points::EntryKind; + let source_kind = SourceKind::UserInput; + // (skip_self_param, only_param_index, seed_at_all) — + // `only_param_index = Some(i)` restricts seeding to the `i`-th + // non-self formal Param op (counted in SSA insertion order). + // `None` seeds every Param. `seed_at_all = false` skips seeding + // entirely; the engine relies on existing label rules instead. + let (skip_self, only_index, seed_at_all): (bool, Option, bool) = match entry_kind { + // Pure Param-only handlers, all named formals are request-bound. + EntryKind::AppRouteHandler { .. } + | EntryKind::UseServerDirective + | EntryKind::FormAction + | EntryKind::FastApiRoute { .. } + | EntryKind::FlaskRoute { .. } + | EntryKind::SpringMapping { .. } + | EntryKind::JaxRsResource + | EntryKind::SinatraRoute { .. } + | EntryKind::AxumHandler + | EntryKind::ActixHandler + | EntryKind::RocketRoute => (true, None, true), + // Class-method shapes — `self` is the controller instance, + // not adversary input. + EntryKind::DjangoView { .. } | EntryKind::RailsAction => (true, None, true), + // Express handler `(req, res, next)` — `req.body` / + // `req.query` / `req.params` / `req.headers` already classify + // as Source via the JS label rules shipped before phase 16, + // so the SSA engine sees user input via member-access paths + // without needing a flat `req` seed. Seeding `req` itself + // as `Source(Cap::all())` adds nothing for those flows but + // re-fires every excluded `req.session.*` / `req.app.*` + // lifecycle method as a structural sink (FP regression in + // `session_destroy_safe.js` / + // `session_destroy_with_query.js`). Skip seeding for + // Express; the existing label rules carry the request. + EntryKind::ExpressRoute { .. } => (true, Some(0), false), + // Gin (`*gin.Context`), echo (`echo.Context`), fiber + // (`*fiber.Ctx`), iris (`iris.Context`) — `c.Query` / + // `c.Param` / `c.PostForm` / `c.QueryArray` / + // `c.PostFormArray` / `c.QueryParam` are classified as + // `Source(Cap::all())` by the framework-aware label rules + // in `src/labels/go.rs` (gated on `DetectedFramework::Gin`). + // The receiver-method calls flow that taint through to + // local variables without painting the bare `c` object, + // which avoids the FP shape where excluded lifecycle + // methods (`c.AbortWithStatus`, `c.Set`, `c.Next`) get + // re-classified as sinks consuming an adversary-painted + // receiver. Same precedent as Express above. + EntryKind::GinRoute => (true, None, false), + // net/http `(w http.ResponseWriter, r *http.Request)` — + // `r.FormValue` / `r.URL.Query` / `r.URL.Query.Get` / + // `r.Header.Get` / `r.Header.Values` / `r.Body` / + // `r.Cookie` / `r.Cookies` are classified as + // `Source(Cap::all())` by the global Go label rules. + // The access-path label rules carry every adversary byte + // through to local variables. Painting the bare `r` + // object as `Source(Cap::all())` would re-fire excluded + // methods like `r.Context()` and `r.WithContext(...)` as + // structural sinks, mirroring the Express FP risk. + EntryKind::GoNetHttp => (true, Some(1), false), + }; + let entry_block = &ssa.blocks[ssa.entry.0 as usize]; + for inst in entry_block.phis.iter().chain(entry_block.body.iter()) { + if !seed_at_all { + continue; + } + let (is_self, param_index) = match &inst.op { + SsaOp::SelfParam => (true, None), + SsaOp::Param { index } => (false, Some(*index)), + _ => continue, + }; + if skip_self && is_self { + continue; + } + if ssa.synthetic_externals.contains(&inst.value) { + continue; + } + let seed_this = match (only_index, param_index) { + (Some(want), Some(idx)) => idx == want, + (Some(_), None) => false, + (None, _) => true, + }; + if !seed_this { + continue; + } + // Rust framework handlers (axum / actix-web / Rocket): + // skip seeding when the formal isn't a recognised typed + // extractor. `param_types` is recovered at CFG + // construction time via `rust_type_to_kind`, which only + // matches `Query` / `Json` / `Form` / `Path` + // / `web::*` wrappers (Hard Rule 3 — bare primitives and + // denylist wrappers like `State`, `Extension`, + // `Pool`, `Db` return `None`). The downstream + // type-fact pass propagates the recovered `TypeKind` onto + // the SSA `Param` value, so `type_facts.get_type(value)` + // returning `None` means "no extractor wrapper" → don't + // paint as adversary input. Without this gate, lifting + // scoped lowering for Rust handlers would FP-fire every + // database / shared-state sink that accepts a DI handle. + if !is_self + && matches!( + entry_kind, + EntryKind::AxumHandler | EntryKind::ActixHandler | EntryKind::RocketRoute, + ) + { + // Treat both missing facts and the explicit `Unknown` + // bottom element as "no extractor wrapper". + // `analyze_types_with_param_types` materialises + // `Some(&TypeKind::Unknown)` for every Param whose + // `BodyMeta.param_types` entry was None (denylist + // wrappers like `State` / `Extension`, bare + // primitives, unrecognised user types), so excluding + // `is_some` alone would still let them through. + let seed_extractor = transfer + .type_facts + .and_then(|tf| tf.get_type(inst.value)) + .is_some_and(|t| !matches!(t, crate::ssa::type_facts::TypeKind::Unknown)); + if !seed_extractor { + continue; + } + } + if !is_self + && matches!( + entry_kind, + EntryKind::FlaskRoute { .. } | EntryKind::SinatraRoute { .. } + ) + { + // Python Flask handlers carry path-bound captures + // (`@app.route("/u/")` + `def view(name):`) alongside + // implicit globals (`request`, `g`) and DI-injected + // formals (webargs, dependency injection). Only the + // path-bound captures qualify as adversary input. + // + // Ruby Sinatra handlers (`get "/u/:name" do |name| ... end`) + // share the same per-formal model: the block formal `name` + // is path-bound; other formals are unusual but possible + // (`do |name, captures| ...`) and must come from a + // recognised capture in the route pattern to seed. + // + // `BodyMeta.param_route_capture` is populated at CFG + // construction time from `extract_route_path_captures`; + // formals not in the capture set fall back to existing + // label rules (`request.json()`, `params['x']`, ...) + // for source attribution. + let seed_capture = param_index + .and_then(|idx| { + transfer + .param_route_capture + .and_then(|m| m.get(idx).copied()) + }) + .unwrap_or(false); + if !seed_capture { + continue; + } + } + if !is_self && matches!(entry_kind, EntryKind::FastApiRoute { .. }) { + // FastAPI / Starlette handlers mix four formal shapes: + // - path captures (`@app.get("/u/{name}")` + `name: str`) + // - typed extractors (`q: Annotated[int, Query()]`, + // `body: Annotated[User, Body()]`) + // - DI handles (`db: Session = Depends(get_db)`) + // - implicit globals (`request: Request`) + // + // Seed only when the formal is either a path capture + // (`param_route_capture[idx]`) OR carries a recognised + // FastAPI typed-extractor wrapper. `classify_param_type_python` + // populates `BodyMeta.param_types` with `Some(TypeKind)` for + // `Annotated[T, Path()/Query()/Body()/Header()/Cookie()/Form() + // /File()]` shapes; everything else (`Session`, `Request`, + // bare `dict`, unannotated) returns `None`. + // `analyze_types_with_param_types` then materialises + // `Some(&TypeKind::Unknown)` for `None` entries, so the gate + // must reject both missing facts and explicit `Unknown`. + let seed_capture = param_index + .and_then(|idx| { + transfer + .param_route_capture + .and_then(|m| m.get(idx).copied()) + }) + .unwrap_or(false); + let seed_extractor = transfer + .type_facts + .and_then(|tf| tf.get_type(inst.value)) + .is_some_and(|t| !matches!(t, crate::ssa::type_facts::TypeKind::Unknown)); + if !seed_capture && !seed_extractor { + continue; + } + } + let origin = TaintOrigin { + node: inst.cfg_node, + source_kind, + source_span: None, + }; + state.set( + inst.value, + VarTaint { + caps: Cap::all(), + origins: SmallVec::from_elem(origin, 1), + uses_summary: false, + }, + ); + } + } + // Seed entry block's PathEnv from optimization results if let Some(ref mut entry_state) = block_states[ssa.entry.0 as usize] { if let Some(ref mut env) = entry_state.path_env { @@ -1956,12 +2230,21 @@ fn apply_path_fact_branch_narrowing_with_interner( // ── Context-Sensitive Inline Analysis Functions ─────────────────────── /// Build a compact taint signature from the actual argument taint at a call site. -fn build_arg_taint_sig( +/// Cache-key builder. Folds optional Phase 03 promise-callback seeds in. +/// +/// Cap bits from `promise_callback_seeds[i] = (idx, taint)` are unioned +/// onto position `idx` of the signature so two cache lookups for the +/// same callback function but different receiver-promise taints map to +/// distinct entries. Without this, an unseeded `cb()` call earlier in +/// the same file would poison the cache for a later seeded +/// `p.then(cb)`. +fn build_arg_taint_sig_with_seeds( args: &[SmallVec<[SsaValue; 2]>], receiver: &Option, state: &SsaTaintState, + promise_callback_seeds: PromiseCallbackSeeds<'_>, ) -> ArgTaintSig { - let mut sig = SmallVec::new(); + let mut sig: SmallVec<[(usize, u32); 4]> = SmallVec::new(); // Receiver taint at position usize::MAX (sentinel) if let Some(rv) = receiver { @@ -1983,6 +2266,20 @@ fn build_arg_taint_sig( } } + // Phase 03: fold extra param seeds into the signature so two + // callers seeding the same callback with different caps cache + // separately. + for (idx, seed) in promise_callback_seeds { + if seed.caps.is_empty() { + continue; + } + if let Some(slot) = sig.iter_mut().find(|(j, _)| *j == *idx) { + slot.1 |= seed.caps.bits(); + } else { + sig.push((*idx, seed.caps.bits())); + } + } + sig.sort_by_key(|(idx, _)| *idx); ArgTaintSig(sig) } @@ -2018,6 +2315,43 @@ fn inline_analyse_callee( cfg: &Cfg, caller_ssa: &SsaBody, call_inst: &SsaInst, +) -> Option { + inline_analyse_callee_with_seeds( + callee, + args, + receiver, + state, + transfer, + cfg, + caller_ssa, + call_inst, + &[], + ) +} + +/// Promise-callback seed entries plumbed into [`inline_analyse_callee_with_seeds`]. +/// +/// Each entry is `(param_idx, seed)`: when the inline-analyzed callee binds +/// `Param { index: param_idx }`, the corresponding parameter's entry-state +/// taint is unioned with `seed` *before* `run_ssa_taint_full` executes. +/// +/// Phase 03 uses this to seed the first parameter of a `.then(cb)` / +/// `.catch(cb)` callback with the receiver Promise's resolved-value taint +/// when the callback itself is the inlined callee (the outer `.then` call's +/// receiver does not appear in `args`, so the existing `arg → param` seed +/// mechanism would otherwise lose the flow). +pub(crate) type PromiseCallbackSeeds<'a> = &'a [(usize, VarTaint)]; + +fn inline_analyse_callee_with_seeds( + callee: &str, + args: &[SmallVec<[SsaValue; 2]>], + receiver: &Option, + state: &SsaTaintState, + transfer: &SsaTaintTransfer, + cfg: &Cfg, + caller_ssa: &SsaBody, + call_inst: &SsaInst, + promise_callback_seeds: PromiseCallbackSeeds<'_>, ) -> Option { // Enforce k=1 depth limit if transfer.context_depth >= 1 { @@ -2126,8 +2460,9 @@ fn inline_analyse_callee( return None; } - // Build cache key from actual argument taint - let sig = build_arg_taint_sig(args, receiver, state); + // Build cache key from actual argument taint, folding any extra + // promise-callback seeds into the signature. + let sig = build_arg_taint_sig_with_seeds(args, receiver, state, promise_callback_seeds); // Check cache (keyed by FuncKey + arg signature). The cached value // is a structural shape, re-attribute origins to the current call @@ -2195,7 +2530,32 @@ fn inline_analyse_callee( } }; - let param_seed: Vec> = args.iter().map(combine_taint).collect(); + let mut param_seed: Vec> = args.iter().map(combine_taint).collect(); + // Phase 03 promise-callback hook: union extra per-param seeds (from + // `.then(cb)` / `.catch(cb)` resolved-value flows) into `param_seed`. + // Cap union + origin merge keeps the cache key (`ArgTaintSig`) the + // same shape as a normal call: the seeded caps end up reflected in + // the `(idx, caps_bits)` signature, so two callbacks with different + // receiver caps cache under different keys. + if !promise_callback_seeds.is_empty() { + for (idx, seed) in promise_callback_seeds { + while param_seed.len() <= *idx { + param_seed.push(None); + } + let merged = match param_seed[*idx].take() { + None => seed.clone(), + Some(mut existing) => { + existing.caps |= seed.caps; + for o in &seed.origins { + push_origin_bounded(&mut existing.origins, *o); + } + existing.uses_summary |= seed.uses_summary; + existing + } + }; + param_seed[*idx] = Some(merged); + } + } let receiver_seed: Option = receiver.and_then(|rv| { state.get(rv).map(|taint| VarTaint { caps: taint.caps, @@ -2316,6 +2676,32 @@ fn inline_analyse_callee( // forward the caller's facts. `PointsToSummary` is the // cross-call substitute. pointer_facts: None, + // The inlined callee body lives in another file with its own + // import view; the caller's `cross_package_imports` would + // resolve the callee's local names against the wrong package + // boundary. Each `CalleeSsaBody` carries its own map populated + // at lowering time from the source file's + // [`crate::cfg::FileCfg::resolved_imports`], so we can forward + // the *callee's* view here for transitive Phase 09 step 0.7 + // resolution. SQLite-cached bodies (loaded with `node_meta` + // populated and `body_graph: None`) carry an empty map; we then + // recover the callee's import view from + // [`crate::summary::GlobalSummaries::get_cross_package_imports`] + // (populated in pass 1 from each file's resolved imports), so + // indexed-mode scans see the same step 0.7 hits as in-memory + // scans for transitive cross-package IPA inside the inlined + // frame. + cross_package_imports: if !callee_body.cross_package_imports.is_empty() { + Some(callee_body.cross_package_imports.as_ref()) + } else { + transfer + .global_summaries + .and_then(|gs| gs.get_cross_package_imports(&callee_key.namespace)) + .map(|arc| arc.as_ref()) + }, + entry_kind: None, + param_route_capture: None, + recording_summary: transfer.recording_summary, }; // Use the callee's own body graph for inline analysis (per-body CFGs @@ -3258,6 +3644,290 @@ fn ssa_value_validated_bits( } } +/// Phase 03: handle JS/TS Promise-callback method calls (`.then(cb)`, +/// `.catch(cb)`, `.finally(cb)`). +/// +/// Returns `true` when the call was recognised as a Promise callback and +/// fully handled here (caller returns from the Call arm without further +/// processing). Returns `false` for any other call. +/// +/// Semantics: +/// * `p.then(cb)` — `cb`'s first parameter receives `p`'s resolved-value +/// taint; result of `then(cb)` carries `cb`'s return taint plus a +/// conservative copy of `p`'s taint (subsequent `.then` calls in a +/// chain re-feed it). +/// * `p.catch(cb)` — same shape as `.then`. The receiver may have +/// resolved or rejected, but at the taint level we treat both +/// identically (caps are coarse enough that a rejection-only flow +/// does not need a separate channel). +/// * `p.finally(cb)` — `cb` takes no value parameter; result is `p`'s +/// taint unchanged. +fn try_apply_promise_callback( + inst: &SsaInst, + info: &crate::cfg::NodeInfo, + callee: &str, + args: &[SmallVec<[SsaValue; 2]>], + receiver: &Option, + state: &mut SsaTaintState, + transfer: &SsaTaintTransfer, + cfg: &Cfg, + caller_ssa: &SsaBody, +) -> bool { + let leaf = crate::callgraph::callee_leaf_name(callee); + if !crate::labels::is_promise_callback_method(transfer.lang.as_str(), leaf) { + return false; + } + + // Upstream Promise taint = receiver taint + every non-callback arg's + // taint. When the upstream is a chained expression + // (`Promise.resolve(req.body).then(cb)`), tree-sitter's receiver field + // resolves to the chain-root identifier (`Promise` here), which has no + // useful taint; the chained subexpression's taint instead surfaces in + // the implicit-uses arg group emitted by SSA `build_call_args`. + // Unioning both channels covers the named-promise (`p.then(cb)`), + // chained (`Promise.resolve(x).then(cb)`), and `await`-wrapped + // (`await p.then(cb)` lowered to `Assign` over the call result) shapes. + let mut receiver_taint = VarTaint { + caps: Cap::empty(), + origins: SmallVec::new(), + uses_summary: false, + }; + if let Some(rv) = receiver { + if let Some(t) = state.get(*rv) { + receiver_taint.caps |= t.caps; + for o in &t.origins { + push_origin_bounded(&mut receiver_taint.origins, *o); + } + receiver_taint.uses_summary |= t.uses_summary; + } + } + for (idx, arg_group) in args.iter().enumerate() { + if idx == 0 { + // Skip the callback argument itself; its taint is the function + // reference, not the value flowed into the callback. + continue; + } + for &v in arg_group { + if let Some(t) = state.get(v) { + receiver_taint.caps |= t.caps; + for o in &t.origins { + push_origin_bounded(&mut receiver_taint.origins, *o); + } + receiver_taint.uses_summary |= t.uses_summary; + } + } + } + // Chained-receiver shape (`Promise.resolve(req.body).then(cb)`): the inner + // `Promise.resolve` collapses into the outer `.then` CFG node, so the + // resolved-value Source label rides on the `.then` node's labels rather + // than on a separate SSA op the receiver/args reach. Union those Source + // caps so the chained shape seeds the callback's param[0] the same way + // the named-promise shape does. Synthesise a minimal origin pointing + // at the `.then` node so the seed carries provenance. + let label_source_caps = info + .taint + .labels + .iter() + .filter_map(|l| match l { + DataLabel::Source(bits) => Some(*bits), + _ => None, + }) + .fold(Cap::empty(), |acc, b| acc | b); + if !label_source_caps.is_empty() { + receiver_taint.caps |= label_source_caps; + let synthetic_origin = TaintOrigin { + node: inst.cfg_node, + source_kind: crate::labels::infer_source_kind(label_source_caps, callee), + source_span: None, + }; + if !receiver_taint + .origins + .iter() + .any(|o| o.node == inst.cfg_node) + { + push_origin_bounded(&mut receiver_taint.origins, synthetic_origin); + } + } + let receiver_taint: Option = if receiver_taint.caps.is_empty() { + None + } else { + Some(receiver_taint) + }; + + // Combine receiver taint into the result so chain-style `.then().then()` + // continues to flow even when the callback's body is opaque or absent + // (e.g. trailing `.then(console.log)`). For `finally`, callback has no + // value param and the chain just forwards `p`. + let mut combined_caps = Cap::empty(); + let mut combined_origins: SmallVec<[TaintOrigin; 2]> = SmallVec::new(); + let mut combined_summary = false; + if let Some(ref rt) = receiver_taint { + combined_caps |= rt.caps; + for o in &rt.origins { + push_origin_bounded(&mut combined_origins, *o); + } + combined_summary |= rt.uses_summary; + } + + if !matches!(leaf, "finally") { + // Pull the callback function out of arg[0]; the .finally callback + // has no resolved-value parameter so its inline analysis does not + // need a seed and we leave the callback opaque (chain just + // forwards `p`). + if let Some(cb_arg) = args.first() { + for &cb_v in cb_arg { + let cb_name = caller_ssa + .value_defs + .get(cb_v.0 as usize) + .and_then(|vd| vd.var_name.as_deref()); + let Some(name) = cb_name else { continue }; + // Promise callbacks accept only the resolved value as + // arg[0]; build synthetic args so the existing + // `arg_uses → param_seed` path still runs (constants, + // origin chain truncation, abstract-state seeding). + // The dedicated `promise_callback_seeds` channel then + // unions the receiver's taint into param[0]'s entry + // state for callbacks whose declared arity is zero + // (e.g. `() => doStuff()` reading from a closed-over + // promise field). + let synthetic_args: Vec> = Vec::new(); + let seeds: smallvec::SmallVec<[(usize, VarTaint); 1]> = + if let Some(ref rt) = receiver_taint { + smallvec::smallvec![(0, rt.clone())] + } else { + smallvec::SmallVec::new() + }; + if let Some(result) = inline_analyse_callee_with_seeds( + name, + &synthetic_args, + &None, + state, + transfer, + cfg, + caller_ssa, + inst, + seeds.as_slice(), + ) { + if let Some(rt) = result.return_taint { + combined_caps |= rt.caps; + for o in &rt.origins { + push_origin_bounded(&mut combined_origins, *o); + } + combined_summary |= rt.uses_summary; + } + } + } + } + } + + // Source/sanitizer labels on the .then/.catch node itself stay + // honoured: a custom rule that taints `then` (rare but possible) or + // sanitises it should still apply. + for lbl in &info.taint.labels { + match lbl { + DataLabel::Source(bits) => { + combined_caps |= *bits; + let source_kind = crate::labels::infer_source_kind(*bits, callee); + let origin = TaintOrigin { + node: inst.cfg_node, + source_kind, + source_span: None, + }; + if !combined_origins.iter().any(|o| o.node == inst.cfg_node) { + combined_origins.push(origin); + } + } + DataLabel::Sanitizer(bits) => { + combined_caps &= !*bits; + } + _ => {} + } + } + + if combined_caps.is_empty() { + state.remove(inst.value); + } else { + state.set( + inst.value, + VarTaint { + caps: combined_caps, + origins: combined_origins, + uses_summary: combined_summary, + }, + ); + } + true +} + +/// Phase 03: handle JS/TS `Promise.resolve|all|allSettled|race(...)`. +/// +/// For all four shapes the conservative approximation is: result = union +/// of every argument's taint. `Promise.all` would in principle produce +/// a per-element-tainted array, but downstream destructuring already +/// taints all bindings via the existing destructuring handling, so the +/// scalar union is precise enough at the recall-gap level. +fn try_apply_promise_combinator( + inst: &SsaInst, + info: &crate::cfg::NodeInfo, + callee: &str, + args: &[SmallVec<[SsaValue; 2]>], + state: &mut SsaTaintState, + transfer: &SsaTaintTransfer, +) -> bool { + if crate::labels::is_promise_combinator(transfer.lang.as_str(), callee).is_none() { + return false; + } + + let mut caps = Cap::empty(); + let mut origins: SmallVec<[TaintOrigin; 2]> = SmallVec::new(); + let mut uses_summary = false; + for arg_group in args { + for &v in arg_group { + if let Some(taint) = state.get(v) { + caps |= taint.caps; + uses_summary |= taint.uses_summary; + for o in &taint.origins { + push_origin_bounded(&mut origins, *o); + } + } + } + } + + // Honour custom Source/Sanitizer labels on the Promise.* call node. + for lbl in &info.taint.labels { + match lbl { + DataLabel::Source(bits) => { + caps |= *bits; + let source_kind = crate::labels::infer_source_kind(*bits, callee); + let origin = TaintOrigin { + node: inst.cfg_node, + source_kind, + source_span: None, + }; + if !origins.iter().any(|o| o.node == inst.cfg_node) { + origins.push(origin); + } + } + DataLabel::Sanitizer(bits) => caps &= !*bits, + _ => {} + } + } + + if caps.is_empty() { + state.remove(inst.value); + } else { + state.set( + inst.value, + VarTaint { + caps, + origins, + uses_summary, + }, + ); + } + true +} + /// Transfer a single SSA instruction. pub(super) fn transfer_inst( inst: &SsaInst, @@ -3329,6 +3999,90 @@ pub(super) fn transfer_inst( return; } + // Phase 03 Promise plumbing: handle `.then(cb)`/`.catch(cb)`/ + // `.finally(cb)` and `Promise.resolve|all|allSettled|race(...)` + // before the rest of the Call arm. Returning early avoids + // re-classifying these as ordinary calls (no summary, no sink), + // which would otherwise drop the receiver/element taint flow. + if try_apply_promise_callback( + inst, info, callee, args, receiver, state, transfer, cfg, ssa, + ) { + return; + } + if try_apply_promise_combinator(inst, info, callee, args, state, transfer) { + return; + } + + // Phase 08 — `URL.searchParams.set/append`: writing a key/value + // pair on the searchParams view mutates the underlying URL. + // The receiver of the Call is the searchParams projection + // (TypeKind::Url alias via `is_url_identity_field`); walking + // back through the FieldProj chain reaches the original URL + // SSA value and any intermediate projections. Union the + // arg-side taint into each of those values so a downstream + // `fetch(u)` / `axios.get(u)` sees the URL as tainted. + if let Some(rv) = *receiver { + let leaf = crate::callgraph::callee_leaf_name(callee); + if matches!(leaf, "set" | "append") { + let receiver_kind = transfer + .type_facts + .and_then(|tf| tf.get_type(rv)) + .cloned() + .or_else(|| { + state + .path_env + .as_ref() + .and_then(|env| env.get(rv).types.as_singleton()) + }); + if matches!(receiver_kind, Some(crate::ssa::type_facts::TypeKind::Url)) { + let mut arg_caps = Cap::empty(); + let mut arg_origins: SmallVec<[TaintOrigin; 2]> = SmallVec::new(); + let mut arg_uses_summary = false; + for arg_group in args.iter() { + for &v in arg_group { + if let Some(t) = state.get(v) { + arg_caps |= t.caps; + arg_uses_summary |= t.uses_summary; + for o in &t.origins { + push_origin_bounded(&mut arg_origins, *o); + } + } + } + } + if !arg_caps.is_empty() { + // Walk the FieldProj receiver chain (and any + // Rust-style nested call receivers) so every + // SSA value that aliases the URL — `u`, + // `u.searchParams`, etc. — picks up the new + // taint, not just the immediate set receiver. + let chain = + receiver_candidates_for_type_lookup(rv, Some(ssa), transfer.lang); + for v in chain { + let combined = match state.get(v) { + Some(prev) => { + let mut origins = prev.origins.clone(); + for o in &arg_origins { + push_origin_bounded(&mut origins, *o); + } + VarTaint { + caps: prev.caps | arg_caps, + origins, + uses_summary: prev.uses_summary | arg_uses_summary, + } + } + None => VarTaint { + caps: arg_caps, + origins: arg_origins.clone(), + uses_summary: arg_uses_summary, + }, + }; + state.set(v, combined); + } + } + } + } + } + // Chain-wrapper sanitiser detection. Computed up-front so // both the container-element-write hook and the outer- // callee taint suppression block below can consult it. @@ -3534,6 +4288,41 @@ pub(super) fn transfer_inst( let mut return_bits = Cap::empty(); let mut return_origins: SmallVec<[TaintOrigin; 2]> = SmallVec::new(); + // Phase 08 / Phase 14 — URL-builder path-arg taint propagation. + // Per-language `(base, path)` URL builders that don't carry a + // label rule and have no summary: without an explicit + // propagation pass the constructed URL value would arrive + // untainted at the downstream HTTP sink and the SSRF would be + // missed. The arg-position table lives in + // [`crate::ssa::type_facts::url_builder_arg_indices`] — + // generalised in Phase 14 from the JS/TS-only Phase-08 + // constructor recognition to cover Python `urljoin`, Go + // `url.JoinPath`, Java `new URL(URL, spec)`, Ruby `URI.join`. + // + // Origin-locked suppression (when the base arg is a literal) + // lives in the abstract domain + // (`StringFact::from_url_with_base`) and runs in + // `is_string_safe_for_ssrf`, so propagating the taint here is + // safe: the prefix-lock fact still suppresses the sink for + // the two-arg form. + if let Some((path_idx, _base_idx)) = crate::ssa::type_facts::url_builder_arg_indices( + transfer.lang, + callee, + info.call.outer_callee.as_deref(), + info.call.is_constructor, + ) { + if let Some(path_group) = args.get(path_idx) { + for &v in path_group { + if let Some(t) = state.get(v) { + return_bits |= t.caps; + for o in &t.origins { + push_origin_bounded(&mut return_origins, *o); + } + } + } + } + } + // Network-fetch source suppression: a Call that carries BOTH // a Source label and a Sink(SSRF) label is a network-fetch // primitive (e.g. PHP `file_get_contents`, `curl_exec`, @@ -4813,18 +5602,25 @@ pub(super) fn transfer_inst( } } - // Check for source labels - for lbl in &info.taint.labels { - if let DataLabel::Source(bits) = lbl { - combined_caps |= *bits; - let callee_str = info.call.callee.as_deref().unwrap_or(""); - let source_kind = crate::labels::infer_source_kind(*bits, callee_str); - let origin = TaintOrigin { - node: inst.cfg_node, - source_kind, - source_span: None, - }; - push_origin_bounded(&mut combined_origins, origin); + // Check for source labels. Skip outer-node Source pickup when + // this Assign was emitted by the bare-array slot-scoped kill arm + // in `src/ssa/lower.rs` (per-slot Source classification puts the + // SsaValue into `ssa.slot_scoped_assigns`). Operand union still + // ran above, so transitive taint via inner uses propagates. + let suppress_node_source = ssa.slot_scoped_assigns.contains(&inst.value); + if !suppress_node_source { + for lbl in &info.taint.labels { + if let DataLabel::Source(bits) = lbl { + combined_caps |= *bits; + let callee_str = info.call.callee.as_deref().unwrap_or(""); + let source_kind = crate::labels::infer_source_kind(*bits, callee_str); + let origin = TaintOrigin { + node: inst.cfg_node, + source_kind, + source_span: None, + }; + push_origin_bounded(&mut combined_origins, origin); + } } } @@ -5320,6 +6116,37 @@ pub(super) fn transfer_inst( } } +/// Resolve a URL builder's `(base)` arg to a concrete origin string when +/// either (a) the call site recorded a syntactic string literal at +/// `base_idx`, or (b) the SSA value at that arg position carries an +/// abstract-string singleton domain (typical for +/// `const BASE = "https://..."; new URL(path, BASE)`). +/// +/// Returning `Some(s)` means the prefix-lock arm can seed the result's +/// [`StringFact`] via [`StringFact::from_url_with_base`]. +fn url_builder_concrete_base( + info: &NodeInfo, + args: &[SmallVec<[SsaValue; 2]>], + abs: &AbstractState, + base_idx: usize, +) -> Option { + if let Some(s) = info + .call + .arg_string_literals + .get(base_idx) + .and_then(|s| s.as_deref()) + { + return Some(s.to_string()); + } + let bv = args.get(base_idx).and_then(|g| g.first().copied())?; + let dom = abs.get(bv).string.domain?; + if dom.len() == 1 { + Some(dom.into_iter().next().expect("len==1 guards index")) + } else { + None + } +} + /// Compute abstract values for an SSA instruction. /// /// Propagates interval and string domain facts forward through constants, @@ -5551,6 +6378,117 @@ fn transfer_abstract(inst: &SsaInst, cfg: &Cfg, abs: &mut AbstractState, lang: O } } + // Phase 08 / Phase 14 — `(base, path)` URL builder origin-lock. + // When the base arg is a literal (read off + // `info.call.arg_string_literals[base_idx]`) or a const-bound + // identifier whose abstract `StringFact.domain` is a singleton + // (e.g. `const BASE = "https://api.cal.com"; new URL(path, BASE)`), + // seed the result's [`StringFact`] with + // `from_url_with_base(base, path_string)` so the locked-host + // prefix survives even when the path component carries arbitrary + // taint. `is_string_safe_for_ssrf` honours the prefix and + // suppresses the SSRF sink at the downstream HTTP call. The + // arg-position table lives in + // [`crate::ssa::type_facts::url_builder_arg_indices`] — covers + // JS/TS `new URL(path, base)`, Python `urljoin(base, path)`, + // Go `url.JoinPath(base, ...)`, Java `new URL(URL, spec)`, + // Ruby `URI.join(base, path)`. + SsaOp::Call { callee, args, .. } + if lang + .and_then(|l| { + crate::ssa::type_facts::url_builder_arg_indices( + l, + callee, + info.call.outer_callee.as_deref(), + info.call.is_constructor, + ) + }) + .is_some_and(|(_p, base_idx)| { + url_builder_concrete_base(info, args, abs, base_idx).is_some() + }) => + { + let lang_u = lang.expect("guard ensures lang.is_some()"); + let (path_idx, base_idx) = crate::ssa::type_facts::url_builder_arg_indices( + lang_u, + callee, + info.call.outer_callee.as_deref(), + info.call.is_constructor, + ) + .expect("guard ensures Some"); + let base = + url_builder_concrete_base(info, args, abs, base_idx).expect("guard ensures Some"); + let path_string = args + .get(path_idx) + .and_then(|g| g.first().copied()) + .map(|pv| abs.get(pv).string) + .unwrap_or_else(StringFact::top); + abs.set( + inst.value, + AbstractValue { + interval: IntervalFact::top(), + string: StringFact::from_url_with_base(&base, &path_string), + bits: BitFact::top(), + path: PathFact::top(), + }, + ); + } + + // Phase 14 — single-arg URL/URI constructor StringFact passthrough. + // `new URL(spec)` (Java/JS), plus the static factory list in + // [`crate::ssa::type_facts::is_url_single_arg_factory`] — when the + // single argument's StringFact carries a locked-host prefix + // (typically from a literal+tainted concat), propagate it onto + // the constructed URL value so a downstream receiver sink like + // `u.openStream()` / `u.openConnection()` can consult the prefix + // through `is_abstract_safe_for_sink`. Strictly additive: the + // 2-arg `(base, path)` shape is handled by the + // `url_builder_arg_indices` arm above; this single-arg arm only + // fires when that arm doesn't. + SsaOp::Call { callee, args, .. } + if lang.is_some_and(|l| { + let l_u = l; + let is_url_ctor = info.call.is_constructor + && crate::ssa::type_facts::constructor_type(l_u, callee) + == Some(crate::ssa::type_facts::TypeKind::Url); + let via_outer = info.call.outer_callee.as_deref().is_some_and(|oc| { + crate::ssa::type_facts::constructor_type(l_u, oc) + == Some(crate::ssa::type_facts::TypeKind::Url) + }); + let is_static_factory = + crate::ssa::type_facts::is_url_single_arg_factory(l_u, callee); + (is_url_ctor || via_outer || is_static_factory) + && crate::ssa::type_facts::url_builder_arg_indices( + l_u, + callee, + info.call.outer_callee.as_deref(), + info.call.is_constructor, + ) + .is_none_or(|(_p, base_idx)| { + // Skip when the 2-arg arm above would already + // have fired (it consumed a literal or + // const-bound singleton base). + url_builder_concrete_base(info, args, abs, base_idx).is_none() + }) + }) => + { + let arg_string = args + .first() + .and_then(|g| g.first().copied()) + .map(|pv| abs.get(pv).string) + .unwrap_or_else(StringFact::top); + if !arg_string.is_top() { + abs.set( + inst.value, + AbstractValue { + interval: IntervalFact::top(), + string: arg_string, + bits: BitFact::top(), + path: PathFact::top(), + }, + ); + } + } + // Known integer-producing calls get a bounded interval so downstream // arithmetic transfer produces useful facts (e.g. parseInt(x) * 10). // Unknown calls: implicit Top (don't store). @@ -5630,7 +6568,7 @@ fn transfer_abstract(inst: &SsaInst, cfg: &Cfg, abs: &mut AbstractState, lang: O .as_ref() .and_then(|d| (d.len() == 1).then(|| d[0].clone())); if let Some(needle) = needle { - let mut new_fact = input_fact.clone(); + let mut new_fact = input_fact; let mut narrowed = false; if needle == ".." { new_fact = new_fact.with_dotdot_cleared(); @@ -6014,6 +6952,10 @@ fn collect_block_events( // Type-qualified sink resolution: when normal sink resolution found nothing, // try using the receiver's inferred type to construct a qualified callee name. + // For known type-qualified ORM raw-SQL methods (`TypeOrmRepo.query` et al.), + // also capture the restricted payload-arg list so bind-array taint at arg 1+ + // does not fire. + let mut tq_payload_args: Option<&'static [usize]> = None; if sink_caps.is_empty() { if let SsaOp::Call { callee, @@ -6022,7 +6964,7 @@ fn collect_block_events( } = &inst.op { if transfer.type_facts.is_some() || state.path_env.is_some() { - let tq_labels = resolve_type_qualified_labels( + let (tq_labels, tq_args) = resolve_type_qualified_labels_with_args( callee, *rv, transfer.type_facts, @@ -6036,6 +6978,7 @@ fn collect_block_events( sink_caps |= *bits; } } + tq_payload_args = tq_args; } } } @@ -6108,22 +7051,129 @@ fn collect_block_events( } } + // Phase 03: Promise-callback synthetic source_to_callback. When + // the call is `p.then(cb)` / `p.catch(cb)` with a tainted + // receiver, the callback's first parameter receives the + // resolved-value taint. Synthesise a single-entry + // `source_to_callback = [(0, receiver_caps)]` so the + // existing callback-pattern detector below pairs `cb`'s + // `param_to_sink` with the receiver's caps and emits the + // sink finding. + let synthetic_promise_callback: Option<(usize, Cap)> = match &inst.op { + SsaOp::Call { + callee, + receiver, + args, + .. + } => { + let leaf = crate::callgraph::callee_leaf_name(callee); + if crate::labels::is_promise_callback_method(transfer.lang.as_str(), leaf) + && !matches!(leaf, "finally") + { + let mut recv_caps = Cap::empty(); + if let Some(rv) = receiver { + if let Some(t) = state.get(*rv) { + recv_caps |= t.caps; + } + } + // Chained-receiver shape (`Promise.resolve(req.body).then(cb)`): + // the inner Promise.resolve call collapses into the outer + // .then node so there is no separate Call op for it. The + // resolved-value taint instead surfaces in the implicit-uses + // arg group emitted by `build_call_args`. Union those caps + // (skipping arg[0], which is the callback function itself, + // not the resolved value) so the named-promise and chained + // shapes share one source_to_callback synthesis path. + for (idx, arg_group) in args.iter().enumerate() { + if idx == 0 { + continue; + } + for &v in arg_group { + if let Some(t) = state.get(v) { + recv_caps |= t.caps; + } + } + } + // Same chained shape: when the inner `Promise.resolve` + // collapses into the `.then` node, its Source label is + // attached directly to the `.then` node's labels rather + // than to a separate SSA op whose value the receiver/args + // would expose. Union those Source caps so the callback + // pattern fires uniformly across named and chained shapes. + for lbl in &info.taint.labels { + if let DataLabel::Source(bits) = lbl { + recv_caps |= *bits; + } + } + if recv_caps.is_empty() { + None + } else { + Some((0usize, recv_caps)) + } + } else { + None + } + } + _ => None, + }; if sink_caps.is_empty() { // Callback pattern: check if callee has source_to_callback and the // actual callback argument has a matching param_to_sink. if let SsaOp::Call { callee, .. } = &inst.op { let caller_func = info.ast.enclosing_func.as_deref().unwrap_or(""); // Use arg_uses.len() for arity (see transfer_inst's Call arm). - if let Some(resolved) = resolve_callee_hinted( + let resolved = resolve_callee_hinted( transfer, callee, caller_func, info.call.call_ordinal, Some(info.call.arg_uses.len()), - ) { - for &(cb_idx, src_caps) in &resolved.source_to_callback { - let cb_name = info.arg_callees.get(cb_idx).and_then(|ac| ac.as_ref()); - if let Some(cb_callee) = cb_name { + ); + // Collect source_to_callback entries: real summary (if any) + // plus the Phase 03 synthetic entry for promise callbacks. + let mut s2c: SmallVec<[(usize, Cap); 2]> = SmallVec::new(); + if let Some(ref r) = resolved { + for &e in &r.source_to_callback { + s2c.push(e); + } + } + if let Some(entry) = synthetic_promise_callback { + if !s2c.iter().any(|&(i, _)| i == entry.0) { + s2c.push(entry); + } + } + if !s2c.is_empty() { + for &(cb_idx, src_caps) in &s2c { + // Two channels for resolving the callback's name: + // 1. `info.arg_callees` — populated when the + // argument is a tree-sitter call/function node; + // the typical path for inline arrow callbacks + // and for native CallFn/CallMethod arguments. + // 2. SSA `value_defs.var_name` of the argument + // itself — a plain identifier reference such as + // `p.then(cb)` doesn't classify as a Call AST + // node so `arg_callees[0]` is `None`, but the + // lowered SSA value carries the identifier text + // via `var_name`. Falling back to it lets + // Phase 03 promise-callback synthesis resolve + // the named-callback shape. + let arg_callees_name: Option = + info.arg_callees.get(cb_idx).and_then(|ac| ac.clone()); + let ssa_var_name: Option = + if let SsaOp::Call { args, .. } = &inst.op { + args.get(cb_idx).and_then(|grp| { + grp.iter().find_map(|v| { + ssa.value_defs + .get(v.0 as usize) + .and_then(|vd| vd.var_name.clone()) + .filter(|n| !n.contains('.') && !n.is_empty()) + }) + }) + } else { + None + }; + let cb_callee_owned = arg_callees_name.or(ssa_var_name); + if let Some(cb_callee) = cb_callee_owned.as_deref() { // First try the standard summary-based resolution // path (covers user-defined functions and built-ins // that landed in label-derived summaries upstream). @@ -6137,6 +7187,15 @@ fn collect_block_events( // param_to_sink. let cb_resolved = resolve_callee(transfer, cb_callee, caller_func, 0); let mut matching_sink_caps = Cap::empty(); + // Mark every callback-resolved sink site as + // `from_chain=true`: the callback callee is a + // logically-deeper frame than the outer + // `.then(cb)` / `setImmediate(cb)` / etc. call + // site. Without this, `should_promote_sink_site` + // filters same-file callback sinks out (the file + // matches `caller_namespace`), and the trace + // finding falls back to the outer dispatch line + // instead of attributing to the actual sink. let cb_param_to_sink_sites: Vec<(usize, SmallVec<[SinkSite; 1]>)> = if let Some(ref r) = cb_resolved { matching_sink_caps = r @@ -6144,7 +7203,20 @@ fn collect_block_events( .iter() .filter(|(_, caps)| !(src_caps & *caps).is_empty()) .fold(Cap::empty(), |acc, (_, c)| acc | *c); - r.param_to_sink_sites.clone() + r.param_to_sink_sites + .iter() + .map(|(idx, sites)| { + let chain_sites: SmallVec<[SinkSite; 1]> = sites + .iter() + .map(|s| { + let mut sc = s.clone(); + sc.from_chain = true; + sc + }) + .collect(); + (*idx, chain_sites) + }) + .collect() } else { vec![] }; @@ -6190,6 +7262,8 @@ fn collect_block_events( let cb_sites = pick_primary_sink_sites_from_resolved( matching_sink_caps, &cb_param_to_sink_sites, + transfer.namespace, + transfer.recording_summary, ); emit_ssa_taint_events( events, @@ -6325,6 +7399,29 @@ fn collect_block_events( continue; } + // Go same-request self-redirect suppression. + // + // `http.Redirect(w, r, url, code)` whose URL string arg is derived + // from the same request's `*url.URL` is a same-origin redirect by + // construction: scheme/host echo the inbound request, only the path + // can be edited. gin's `redirectTrailingSlash` / + // `redirectFixedPath` / `redirectRequest` helpers all bottom out in + // this shape (`req := c.Request; rURL := req.URL.String(); + // http.Redirect(w, req, rURL, code)`). Without this suppression, + // the inner `http.Redirect` records `param_to_sink` for OPEN_REDIRECT + // and the IPA path then surfaces `taint-open-redirect` at every + // call site that reaches `redirectTrailingSlash(c)` with a + // tainted `c.Request.URL`. + if transfer.lang == Lang::Go + && sink_caps.intersects(Cap::OPEN_REDIRECT) + && is_go_request_self_redirect(inst, info, ssa) + { + sink_caps &= !Cap::OPEN_REDIRECT; + } + if sink_caps.is_empty() { + continue; + } + // Same-node Sanitizer subtraction. When the CFG node carries both // Sink and Sanitizer labels for overlapping caps, the shape-based // synthesis pattern used by Ruby AR safe-arg-0 detection @@ -6351,7 +7448,7 @@ fn collect_block_events( // Suppress known non-sink callees (e.g., System.out.println in Java) if let SsaOp::Call { callee, .. } = &inst.op { - sink_caps = suppress_known_safe_callees(sink_caps, callee, transfer.lang); + sink_caps = suppress_known_safe_callees(sink_caps, callee, transfer.lang, info); if sink_caps.is_empty() { continue; } @@ -6542,7 +7639,7 @@ fn collect_block_events( .map(|e| (sink_caps & e.caps, Some(e.idx.as_slice()), None)) .collect() } else { - smallvec::smallvec![(sink_caps, None, None)] + smallvec::smallvec![(sink_caps, tq_payload_args, None)] }; for (filter_caps, positions_override, destination_override) in filter_iter { @@ -6618,6 +7715,8 @@ fn collect_block_events( &tainted, filter_caps, &sink_info.param_to_sink_sites, + transfer.namespace, + transfer.recording_summary, ); emit_ssa_taint_events( events, @@ -6635,6 +7734,26 @@ fn collect_block_events( // ── Primary sink-site attribution ─────────────────────────────────────── +/// Decide whether a [`SinkSite`] should be promoted into a caller-side +/// `Finding.primary_location`. +/// +/// Same-file single-hop helpers keep call-site emission, the existing +/// intra-procedural finding already lights up the deep sink line and the +/// flow finding then fires at the call site to give reviewers two +/// coordinates (deep + call-site). Promoting the deep coordinate onto +/// the flow finding would collapse the pair via `deduplicate_taint_flows` +/// and matches benchmark fixtures that expect the call-site shape. +/// +/// Multi-hop chains and cross-file callees promote: a chain hop has no +/// per-frame intermediate finding to dedup with, and a cross-file callee's +/// body is not visible to the caller's intra-file taint pass. +/// +/// Returns `true` when the site is `from_chain` (chain-hop marker) or its +/// `file_rel` differs from the caller's namespace (cross-file). +fn should_promote_sink_site(site: &SinkSite, caller_namespace: &str) -> bool { + site.from_chain || (!site.file_rel.is_empty() && site.file_rel != caller_namespace) +} + /// Pick primary [`SinkSite`]s for a summary-based sink event in the main /// sink-detection path. /// @@ -6644,7 +7763,11 @@ fn collect_block_events( /// carried the tainted flow), AND /// 2. [`SinkSite`] carries resolved coordinates (`line != 0`, cap-only /// sites are ignored), AND -/// 3. [`SinkSite::cap`] intersects `sink_caps` (the propagated cap mask). +/// 3. [`SinkSite::cap`] intersects `sink_caps` (the propagated cap mask), +/// AND +/// 4. [`should_promote_sink_site`] returns `true` (chain-hop marker or +/// cross-file callee), so same-file single-hop helpers keep their +/// call-site emission. /// /// Returns the deduped list of matching sites (`dedup_key` identity). /// Empty ⇒ no primary attribution, caller emits a single event with @@ -6654,6 +7777,8 @@ fn pick_primary_sink_sites( tainted: &[(SsaValue, Cap, SmallVec<[TaintOrigin; 2]>)], sink_caps: Cap, param_to_sink_sites: &[(usize, SmallVec<[SinkSite; 1]>)], + caller_namespace: &str, + recording_summary: bool, ) -> Vec { if param_to_sink_sites.is_empty() || tainted.is_empty() { return Vec::new(); @@ -6680,6 +7805,9 @@ fn pick_primary_sink_sites( if (site.cap & sink_caps).is_empty() { continue; } + if !recording_summary && !should_promote_sink_site(site, caller_namespace) { + continue; + } let key = (site.file_rel.clone(), site.line, site.col, site.cap.bits()); if seen.insert(key) { out.push(site.clone()); @@ -6692,10 +7820,13 @@ fn pick_primary_sink_sites( /// Pick primary [`SinkSite`]s for the callback-pattern path, where the /// tainted-arg positional mapping is not directly available (the callback /// callee is resolved separately from the outer call's `args`). Matches -/// solely on cap intersection and coordinate resolution. +/// on cap intersection, coordinate resolution, and the same chain-hop / +/// cross-file gate used by [`pick_primary_sink_sites`]. fn pick_primary_sink_sites_from_resolved( sink_caps: Cap, param_to_sink_sites: &[(usize, SmallVec<[SinkSite; 1]>)], + caller_namespace: &str, + recording_summary: bool, ) -> Vec { if param_to_sink_sites.is_empty() { return Vec::new(); @@ -6710,6 +7841,9 @@ fn pick_primary_sink_sites_from_resolved( if (site.cap & sink_caps).is_empty() { continue; } + if !recording_summary && !should_promote_sink_site(site, caller_namespace) { + continue; + } let key = (site.file_rel.clone(), site.line, site.col, site.cap.bits()); if seen.insert(key) { out.push(site.clone()); @@ -7207,7 +8341,21 @@ fn try_container_propagation( ContainerOp::Load { index_arg } => { let container_val = match resolve_container(receiver) { Some(v) => v, - None => return false, + None => { + // Java safe-lookup field fallback: when the receiver is a + // free identifier (no SSA value to look up) and the + // callee text is `.get`, check whether `` + // is a class field whose initializer is a recognised + // safe map (`final ... = Map.of(literal, literal, + // ...)`). In that case the lookup result is bounded + // to the literal value set, so a tainted key cannot + // taint the result; leave `inst.value` untainted and + // claim the call as handled. + if lang == Lang::Java && try_java_safe_field_lookup_load(callee) { + return true; + } + return false; + } }; // Resolve index argument to HeapSlot. @@ -7666,6 +8814,13 @@ fn collect_tainted_sink_values( } } apply_field_aware_suppression(&mut result, inst, info, state, sink_caps, ssa); + apply_arg_type_safe_suppression( + &mut result, + sink_caps, + transfer.type_facts, + inst, + info, + ); return result; } } @@ -7695,6 +8850,13 @@ fn collect_tainted_sink_values( } } apply_field_aware_suppression(&mut result, inst, info, state, sink_caps, ssa); + apply_arg_type_safe_suppression( + &mut result, + sink_caps, + transfer.type_facts, + inst, + info, + ); return result; } } @@ -7710,9 +8872,95 @@ fn collect_tainted_sink_values( } apply_field_aware_suppression(&mut result, inst, info, state, sink_caps, ssa); + apply_arg_type_safe_suppression(&mut result, sink_caps, transfer.type_facts, inst, info); result } +/// Drop tainted argument SSA values from the per-call sink-emission set +/// when their inferred [`crate::ssa::type_facts::TypeKind`] proves the +/// value is payload-incompatible with `sink_caps` (e.g. an `Int`-tagged +/// value reaching a `HEADER_INJECTION` sink: numeric scalars, the +/// safe-string conversions in +/// [`crate::ssa::type_facts::is_safe_string_producing_callee`], and +/// `length()` / `size()` numeric-property reads cannot encode CRLF or +/// any sink-class metacharacter). +/// +/// Mirrors the non-call sink path's +/// [`crate::ssa::type_facts::is_type_safe_for_sink`] gate at line 7317 +/// of the main analyser, applied here on Call instructions so the +/// shared suppression rule covers idiomatic Java mitigation patterns +/// (`res.setHeader("X-Count", Integer.toString(payload.size()))`, +/// `res.setHeader("X-Class", loaded.getClass().getName())`) without +/// special-casing the sink callee. +fn apply_arg_type_safe_suppression( + result: &mut Vec<(SsaValue, Cap, SmallVec<[TaintOrigin; 2]>)>, + sink_caps: Cap, + _type_facts: Option<&crate::ssa::type_facts::TypeFactResult>, + inst: &SsaInst, + info: &NodeInfo, +) { + use crate::ssa::type_facts::is_safe_string_producing_callee; + if result.is_empty() { + return; + } + // Type-suppression mask. An arg whose enclosing call is a "safe + // string" producer (numeric/boolean to-string conversion or a + // class-name accessor) emits a string provably free of the + // metacharacters that drive these injection classes. The same + // mask the shared + // [`crate::ssa::type_facts::is_type_safe_for_sink`] gate uses for + // `Int` / `Bool` values, applied here at Call sinks against the + // arg-level callee text instead of the value-level type kind. + let type_suppressible = Cap::SQL_QUERY + | Cap::FILE_IO + | Cap::SHELL_ESCAPE + | Cap::HTML_ESCAPE + | Cap::SSRF + | Cap::DATA_EXFIL + | Cap::HEADER_INJECTION + | Cap::OPEN_REDIRECT; + let sink_fully_type_suppressible = + !sink_caps.is_empty() && (sink_caps & !type_suppressible).is_empty(); + if !sink_fully_type_suppressible { + return; + } + // Identify SSA values whose enclosing arg position has an inner + // call to a safe-string producer + // ([`is_safe_string_producing_callee`]). The CFG/SSA pipeline does + // not lower nested method invocations into separate Call SSA ops + // (the outer call's arg list captures the inner receiver's SSA + // value directly), so the only place to recover "this arg came + // from `Integer.toString` / `Class.getName` / ..." is the + // `info.arg_callees` text recorded by `extract_arg_callees`. + // + // Strict-additive: we only suppress when the entire arg expression + // IS a safe-string-producing call, not when a tainted value flows + // through a string concat , the latter is a real SQLi shape + // (`"SELECT ... LIMIT " + intExpr`) and must keep firing. + let SsaOp::Call { args, .. } = &inst.op else { + return; + }; + let mut safe_string_values: std::collections::HashSet = + std::collections::HashSet::new(); + for (pos, arg_vals) in args.iter().enumerate() { + let safe = info + .arg_callees + .get(pos) + .and_then(|c| c.as_deref()) + .map(is_safe_string_producing_callee) + .unwrap_or(false); + if safe { + for &v in arg_vals { + safe_string_values.insert(v); + } + } + } + if safe_string_values.is_empty() { + return; + } + result.retain(|(v, _, _)| !safe_string_values.contains(v)); +} + /// Suppress plain-ident taint when a dotted-path field value used by the same /// instruction is untainted. Prevents false positives from base-ident bleed /// (e.g. `obj.safe = "const"; sink(obj.safe)` where `obj` is tainted). @@ -7917,6 +9165,201 @@ fn inst_use_values(inst: &SsaInst) -> Vec { } } +// ── Go same-request self-redirect detection ──────────────────────────── + +/// Detect Go `http.Redirect(w, r, urlExpr, code)` whose URL string arg is +/// derived from the same `*http.Request`'s `URL` (e.g. `r.URL.String()`, +/// `r.URL.RequestURI()`, `r.URL.EscapedPath()`). Such a redirect echoes +/// the inbound request's URL with at most path-only edits, so scheme/host +/// are same-origin by construction and `Cap::OPEN_REDIRECT` cannot fire. +/// +/// Recognition is purely syntactic over the SSA call's args: +/// * arg 1 (the `*Request`) and arg 2 (the URL string) are correlated +/// through the FieldProj `URL` accessor on a shared receiver chain. +/// * arg 2's defining op is a Call to a `*url.URL` accessor whose +/// return is a string derived from the URL. +/// +/// gin's `redirectTrailingSlash` / `redirectFixedPath` / `redirectRequest` +/// helpers are the canonical shape; the same gate applies to any +/// hand-written `http.Redirect(w, r, r.URL.String(), code)` form. +fn is_go_request_self_redirect(inst: &SsaInst, info: &NodeInfo, ssa: &SsaBody) -> bool { + let callee = match info.call.callee.as_deref() { + Some(c) => c, + None => return false, + }; + if !callee.eq_ignore_ascii_case("http.Redirect") { + return false; + } + let SsaOp::Call { ref args, .. } = inst.op else { + return false; + }; + // `http.Redirect(w, r, url, code)` is the canonical 4-arg shape, but + // SSA construction sometimes folds the package-qualifier into an extra + // arg-0 phantom group (5-arg shape); both keep `r`/`url` at indices + // 1/2. Accept either. + let req_arg_idx = 1usize; + let url_arg_idx = 2usize; + if args.len() <= url_arg_idx { + return false; + } + let url_v = match args[url_arg_idx].first() { + Some(&v) => v, + None => return false, + }; + // Resolve the request's canonical name. Prefer the SSA-level value + // when args[1] is populated; fall back to the CFG's `arg_uses` row, + // which records the syntactic identifier list for arg position 1 + // even when SSA didn't lift the reference into a tracked value. + let (req_v_opt, req_name) = match args[req_arg_idx].first() { + Some(&v) => (Some(v), ssa_canonical_var_name(v, ssa)), + None => ( + None, + info.call + .arg_uses + .get(req_arg_idx) + .and_then(|row| row.first()) + .cloned(), + ), + }; + is_request_url_method_value(url_v, req_v_opt, req_name.as_deref(), ssa) +} + +/// Walk through `Assign` hops to find the canonical "name" of an SSA +/// value: prefer the leading use's `var_name` when the def is an Assign +/// chain; otherwise fall back to the value's own `var_name`. +fn ssa_canonical_var_name(v: SsaValue, ssa: &SsaBody) -> Option { + let mut cur = v; + for _ in 0..8 { + let def = ssa.def_of(cur); + if let Some(name) = def.var_name.as_deref() { + if !name.is_empty() { + return Some(name.to_string()); + } + } + let def_inst = find_inst_for_value(cur, ssa)?; + if let SsaOp::Assign(uses) = &def_inst.op { + if let Some(&first) = uses.first() { + if first == cur { + return None; + } + cur = first; + continue; + } + } + return None; + } + None +} + +/// Return true when `url_v` traces (through up to a few Assign hops) to +/// either of two equivalent SSA shapes that read a `*url.URL` accessor on +/// the same request: +/// 1. Decomposed chain — `Call("String"|"RequestURI"|...)` whose +/// receiver is `FieldProj(req, "URL")` (chained-method shape, kicks +/// in for `r.URL.String()`-style method calls). +/// 2. Flat chain — `Call(".URL.", rcv=None)` (no +/// decomposition), used by SSA lowering for plain field reads +/// (`r.URL.Path`) and short method chains alike. +/// +/// Match success means: arg 1 (the `*Request`) and arg 2 (the URL string) +/// are correlated through the same request's `URL` field, so the redirect +/// destination is same-origin by construction. +fn is_request_url_method_value( + url_v: SsaValue, + req_v: Option, + req_name: Option<&str>, + ssa: &SsaBody, +) -> bool { + let mut cur = url_v; + for _ in 0..8 { + let Some(def_inst) = find_inst_for_value(cur, ssa) else { + return false; + }; + match &def_inst.op { + SsaOp::Assign(uses) if uses.len() == 1 => { + cur = uses[0]; + } + SsaOp::Call { + callee, + receiver: Some(rcv), + .. + } => { + if !is_url_accessor_method(callee) { + return false; + } + let Some(rcv_def) = find_inst_for_value(*rcv, ssa) else { + return false; + }; + let SsaOp::FieldProj { + receiver: inner_recv, + field, + .. + } = rcv_def.op + else { + return false; + }; + if ssa.field_interner.resolve(field) != "URL" { + return false; + } + if Some(inner_recv) == req_v { + return true; + } + let inner_name = ssa_canonical_var_name(inner_recv, ssa); + return match (inner_name.as_deref(), req_name) { + (Some(a), Some(b)) => a == b, + _ => false, + }; + } + SsaOp::Call { + callee, + receiver: None, + .. + } => { + // Flat chain shape: callee text is `.URL.`. + let req_name = match req_name { + Some(n) => n, + None => return false, + }; + let prefix = format!("{req_name}.URL."); + if !callee.starts_with(&prefix) { + return false; + } + let suffix = &callee[prefix.len()..]; + // Reject deeper chains (e.g. `.URL.X.Y`) so the + // gate stays scoped to direct URL accessors. + if suffix.contains('.') { + return false; + } + return is_url_accessor_method(suffix); + } + _ => return false, + } + } + false +} + +/// Bare-method names on `*url.URL` whose return is a string derived from +/// the URL value. Recognised for the same-request self-redirect gate. +fn is_url_accessor_method(callee: &str) -> bool { + matches!( + callee, + "String" | "RequestURI" | "EscapedPath" | "Path" | "RawPath" | "RawQuery" + ) +} + +/// Locate the [`SsaInst`] that defines `v` within its declared block. +/// Returns `None` only when the SSA body is malformed (the instruction +/// table and `value_defs` table disagree on which block defines `v`). +fn find_inst_for_value(v: SsaValue, ssa: &SsaBody) -> Option<&SsaInst> { + let def = ssa.def_of(v); + let block = ssa.block(def.block); + block + .phis + .iter() + .chain(block.body.iter()) + .find(|inst| inst.value == v) +} + // ── Alias-Aware Sanitization ──────────────────────────────────────────── /// After sanitizing `inst`, propagate the sanitization to must-aliased field paths. @@ -8199,6 +9642,69 @@ fn resolve_type_qualified_labels( SmallVec::new() } +/// Sibling of [`resolve_type_qualified_labels`] used at sink-firing time. +/// +/// Returns the resolved sink labels plus, when the matched qualified +/// callee has a known restricted payload arg list (Phase 07 ORM raw-SQL +/// receiver methods such as `TypeOrmRepo.query`), the static slice +/// describing which positional args carry the SQL payload. The caller +/// uses this slice to override `positions_override` so taint flowing +/// only into the bind-array argument (arg 1+) does not fire. +#[allow(clippy::too_many_arguments)] +fn resolve_type_qualified_labels_with_args( + callee: &str, + receiver: SsaValue, + type_facts: Option<&crate::ssa::type_facts::TypeFactResult>, + path_env: Option<&constraint::PathEnv>, + lang: Lang, + extra_labels: Option<&[crate::labels::RuntimeLabelRule]>, + ssa: Option<&SsaBody>, +) -> (SmallVec<[DataLabel; 2]>, Option<&'static [usize]>) { + let method_candidates = method_candidates_from_chain(callee, lang); + let receiver_candidates = receiver_candidates_for_type_lookup(receiver, ssa, lang); + + if let Some(tf) = type_facts { + for rv in &receiver_candidates { + if let Some(receiver_type) = tf.get_type(*rv) { + if let Some(prefix) = receiver_type.label_prefix() { + for method in &method_candidates { + let qualified = format!("{}.{}", prefix, method); + let labels = + crate::labels::classify_all(lang.as_str(), &qualified, extra_labels); + if !labels.is_empty() { + let payload = + crate::labels::type_qualified_sink_payload_args(&qualified); + return (labels, payload); + } + } + } + } + } + } + + if let Some(env) = path_env { + for rv in &receiver_candidates { + let types = env.get(*rv).types; + if let Some(kind) = types.as_singleton() { + if let Some(prefix) = kind.label_prefix() { + for method in &method_candidates { + let qualified = format!("{}.{}", prefix, method); + let labels = + crate::labels::classify_all(lang.as_str(), &qualified, extra_labels); + if !labels.is_empty() { + let payload = + crate::labels::type_qualified_sink_payload_args(&qualified); + return (labels, payload); + } + } + } + } + } + } + + (SmallVec::new(), None) +} + /// Walk back through `SsaOp::Call.receiver` and `SsaOp::FieldProj.receiver` /// chains to collect candidate SSA values for type-fact lookup. /// @@ -8234,11 +9740,17 @@ fn receiver_candidates_for_type_lookup( SsaOp::FieldProj { receiver, .. } => { next_receiver = Some(*receiver); } - // Rust-only: chain through nested Call receivers - // (`conn.execute(x).unwrap()` parsed as one outer call). + // Chain through nested Call receivers. Rust: + // `conn.execute(x).unwrap()` parsed as one outer + // call. JS/TS: `getUrl().searchParams.set(k, v)`, + // where the FieldProj walks `searchParams → + // ` and we want to keep walking + // through the `getUrl()` call to surface the + // original URL receiver value (Phase 09 deferred + // fix). SsaOp::Call { receiver: Some(rv), .. - } if matches!(lang, Lang::Rust) => { + } if matches!(lang, Lang::Rust | Lang::JavaScript | Lang::TypeScript) => { next_receiver = Some(*rv); } _ => {} @@ -8297,7 +9809,7 @@ fn method_candidates_from_chain(callee: &str, lang: Lang) -> SmallVec<[String; 4 /// /// These are callees whose suffix matches a broad sink rule but whose /// receiver is known to be safe (console output, not HTTP response). -fn suppress_known_safe_callees(sink_caps: Cap, callee: &str, lang: Lang) -> Cap { +fn suppress_known_safe_callees(sink_caps: Cap, callee: &str, lang: Lang, info: &NodeInfo) -> Cap { match lang { Lang::Java => { if callee.starts_with("System.out.") || callee.starts_with("System.err.") { @@ -8306,10 +9818,60 @@ fn suppress_known_safe_callees(sink_caps: Cap, callee: &str, lang: Lang) -> Cap sink_caps } } + // Go `fmt.Fprintf` / `fmt.Fprint` / `fmt.Fprintln` carry an HTML_ESCAPE + // sink label because they CAN write to an `http.ResponseWriter`. When + // the writer (positional arg 0) is a known non-response stream + // (stderr/stdout/discard/gin's package-level debug writers), the call + // is a logging side effect, not a response-rendering sink, and the + // HTML_ESCAPE bit should be stripped. Without this strip, gin's own + // `defer func() { debugPrintError(err) }()` shape lights up as + // `taint-unsanitised-flow` because `debugPrintError` summarises as + // param 0 → `fmt.Fprintf` HTML_ESCAPE through the IPA path. + Lang::Go => { + if !sink_caps.intersects(Cap::HTML_ESCAPE) { + return sink_caps; + } + let is_fprintf = matches!(callee, "fmt.Fprintf" | "fmt.Fprint" | "fmt.Fprintln"); + if !is_fprintf { + return sink_caps; + } + let Some(first_arg) = info.call.arg_uses.first() else { + return sink_caps; + }; + if first_arg + .iter() + .any(|s| is_go_non_response_writer(s.as_str())) + { + sink_caps & !Cap::HTML_ESCAPE + } else { + sink_caps + } + } _ => sink_caps, } } +/// Recognise Go writer identifiers that are categorically not +/// `http.ResponseWriter` and therefore should not host an XSS sink for +/// `fmt.Fprintf` / `fmt.Fprint` / `fmt.Fprintln`. The set covers the +/// stdlib stdout/stderr/discard streams plus gin's package-level +/// `DefaultWriter` / `DefaultErrorWriter` (both are `io.Writer` aliases for +/// `os.Stdout` / `os.Stderr`). Both qualified (`gin.DefaultErrorWriter`) +/// and bare (`DefaultErrorWriter`, intra-package) shapes match. +fn is_go_non_response_writer(text: &str) -> bool { + matches!( + text, + "os.Stderr" + | "os.Stdout" + | "io.Discard" + | "ioutil.Discard" + | "DefaultErrorWriter" + | "DefaultWriter" + | "gin.DefaultErrorWriter" + | "gin.DefaultWriter" + ) +} + /// Check if a sink is type-safe (e.g., SQL injection or path traversal with int-typed argument). /// /// Suppresses findings when all argument values are known to be integer-typed, @@ -8883,6 +10445,31 @@ fn is_stringify_callee(callee: &str) -> bool { /// Returns `false` when `static_map` is `None`, when any value is missing, /// or when any value's bounded set contains a shell metacharacter, the /// predicate is conservative, so a missing entry never suppresses. +/// Java-only suppression for the free-identifier `.get(key)` shape. +/// +/// When a class field is initialized with `Map.of(literal, literal, ...)` +/// and the consumer references it via the bare field name (no `this.` / +/// no SSA-resolved receiver) the receiver lookup in `try_container_ +/// propagation` fails, leaving the engine to fall back to default +/// arg-to-result propagation. This walks the callee text — required to +/// be a single-segment `.get` — and consults the per-file +/// safe-lookup map populated by the build_cfg pre-pass. Returns `true` +/// when the lookup is safe to suppress. +fn try_java_safe_field_lookup_load(callee: &str) -> bool { + let Some(dot_pos) = callee.rfind('.') else { + return false; + }; + let receiver_name = &callee[..dot_pos]; + let method = &callee[dot_pos + 1..]; + if method != "get" { + return false; + } + if receiver_name.is_empty() || receiver_name.contains('.') || receiver_name.contains('(') { + return false; + } + crate::cfg::safe_fields::safe_lookup_field_values(receiver_name).is_some() +} + fn is_static_map_shell_safe( values: &[SsaValue], static_map: Option<&crate::ssa::static_map::StaticMapResult>, @@ -9668,6 +11255,64 @@ fn resolve_callee_full( } } + // 0.7) Cross-package import resolution (Phase 09). + // + // When the callee leaf name matches an import binding the resolver + // resolved to a concrete `(file, exported_name)` pair, look up the + // canonical [`FuncKey`] in [`GlobalSummaries::ssa_by_key`]. This + // closes the recall gap on `import { foo } from '@scope/pkg'` shapes + // where `foo` lives in another package's namespace and the same-name + // narrowing in step 0.5 can't reach it (the caller's namespace ≠ the + // callee's namespace). + // + // The pre-built map carries the target's `(lang, namespace, name)` + // triple but leaves arity / container / disambig / kind unset because + // the resolver doesn't inspect the export's signature. We narrow + // candidates by those three fields plus the call-site arity hint when + // available; if exactly one survives, claim resolution. On miss or + // ambiguity we fall through to the existing flat paths. + if let (Some(map), Some(gs)) = (transfer.cross_package_imports, transfer.global_summaries) { + if let Some(target) = map.get(normalized) { + // Indexed candidate lookup: the + // `(lang, namespace, name)` triple narrows to the small + // set of SSA keys that share the import target's leaf + // name. Replaces the prior `O(|ssa_by_key|)` scan over + // every persisted SSA key with a single hash probe plus + // an iteration over only the matching bucket. + let candidates = gs.ssa_keys_by_qualified(target.lang, &target.namespace, &target.name); + let mut hit: Option<&FuncKey> = None; + let mut ambiguous = false; + for k in candidates { + if !k.container.is_empty() { + continue; + } + if let Some(want) = arity_hint + && k.arity != Some(want) + { + continue; + } + if hit.replace(k).is_some() { + ambiguous = true; + break; + } + } + if !ambiguous && let Some(k) = hit { + if let Some(ssa_sum) = gs.get_ssa(k) { + tracing::debug!( + callee = %callee, + target_namespace = %target.namespace, + target_name = %target.name, + "cross-package SSA summary hit (step 0.7)" + ); + return Some(convert_ssa_to_resolved_for_caller( + ssa_sum, + Some(transfer.namespace), + )); + } + } + } + } + // 1) Local (same-file), lookup via canonical FuncKey using the // same qualified-first policy as the global resolver. if let Some(key) = resolve_local_func_key_query(transfer.local_summaries, &build_query()) { @@ -9959,14 +11604,16 @@ fn convert_ssa_to_resolved_for_caller( // extraction time) remain in the list but contribute no primary // location, the emission site filters by `SinkSite::line != 0`. // - // Strip same-file sites when `caller_namespace` is supplied: the - // caller's own taint analysis already produces a finding at the - // callee's internal sink (e.g. closure body's `eval(q)` finding at - // pass-1 lexical containment), so promoting `primary_location` at - // the call site to the same line collides with that finding under - // [`crate::commands::scan::deduplicate_taint_flows`] and silently - // drops the call-site finding. Cross-file sites are preserved - // (the other file's analysis can't be deduped against this one). + // Strip same-file, non-chain sites when `caller_namespace` is + // supplied: a single-hop intra-file helper's own sink coordinates + // collide with the caller's pass-2 intraprocedural finding under + // [`crate::commands::scan::deduplicate_taint_flows`], silently + // dropping the call-site flow finding. Sites with `from_chain=true` + // are chain-hop markers from a deeper callee and have no matching + // intermediate finding to dedup with — keep them so multi-hop + // chains surface the deepest sink line. Cross-file sites are + // preserved (the other file's analysis can't be deduped against + // this one). Mirrors the gate in [`should_promote_sink_site`]. let param_to_sink_sites = if let Some(caller_ns) = caller_namespace { ssa_sum .param_to_sink @@ -9974,7 +11621,7 @@ fn convert_ssa_to_resolved_for_caller( .map(|(idx, sites)| { let filtered: SmallVec<[crate::summary::SinkSite; 1]> = sites .iter() - .filter(|s| s.file_rel.is_empty() || s.file_rel != caller_ns) + .filter(|s| s.from_chain || s.file_rel.is_empty() || s.file_rel != caller_ns) .cloned() .collect(); (*idx, filtered) diff --git a/src/taint/ssa_transfer/summary_extract.rs b/src/taint/ssa_transfer/summary_extract.rs index 9d21a1af..c131f777 100644 --- a/src/taint/ssa_transfer/summary_extract.rs +++ b/src/taint/ssa_transfer/summary_extract.rs @@ -264,6 +264,10 @@ pub fn extract_ssa_func_summary_full( auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: true, }; let (events, block_states) = run_ssa_taint_full(ssa, cfg, &transfer); @@ -745,14 +749,36 @@ pub fn extract_ssa_func_summary_full( if event.sink_caps.is_empty() { continue; } - let site = match locator { - Some(loc) => { - loc.site_for_span(cfg[event.sink_node].classification_span(), event.sink_caps) + // Preserve the deepest sink attribution across multi-hop summaries. + // When `event.primary_sink_site` is populated, the upstream + // resolver already pierced through a callee summary to the + // dangerous instruction's coordinates; promoting it here means a + // grandparent caller of this function sees `line N` of the + // innermost helper rather than `line M` of *this* function's + // call site to its child. Mark `from_chain = true` so pass-2 + // emission can distinguish multi-hop chain markers (always + // promote into `Finding.primary_location`) from this body's own + // locator-resolved sink (only promote across file boundaries). + // Falls back to locator-based call-site attribution when the + // event is intra-procedural. + let site = match event.primary_sink_site.as_ref() { + Some(s) => { + let mut s = s.clone(); + s.from_chain = true; + s } - None => SinkSite::cap_only(event.sink_caps), + None => match locator { + Some(loc) => loc + .site_for_span(cfg[event.sink_node].classification_span(), event.sink_caps), + None => SinkSite::cap_only(event.sink_caps), + }, }; let key = site.dedup_key(); - if !param_sites.iter().any(|s| s.dedup_key() == key) { + if let Some(existing) = param_sites.iter_mut().find(|s| s.dedup_key() == key) { + if site.from_chain && !existing.from_chain { + existing.from_chain = true; + } + } else { param_sites.push(site); } } @@ -812,6 +838,10 @@ pub fn extract_ssa_func_summary_full( auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: true, }; detect_source_to_callback_from_states( ssa, @@ -867,6 +897,11 @@ pub fn extract_ssa_func_summary_full( // caller patches it in. typed_call_receivers: Vec::new(), validated_params_to_return, + // Phase-10 entry-point classification is attached post-extraction + // by `taint::lower_all_functions_from_bodies` (which has access + // to `FileCfg::entry_kinds`). Empty here means the extractor + // itself does not carry the tag. + entry_kind: None, } } @@ -1112,11 +1147,25 @@ fn infer_summary_return_type( continue; } // Only inspect the very last instruction in the returning block. + // Mirror the CFG-level `outer_callee` fallback (Phase 08 audit) so a + // CFG-rewritten callee (e.g. `req.body.path` displacing `URL` on + // `new URL(req.body.path, base)`) still resolves to the original + // constructor identifier preserved in `callee_text`. if let Some(inst) = block.body.last() - && let SsaOp::Call { callee, .. } = &inst.op - && let Some(ty) = crate::ssa::type_facts::constructor_type(lang, callee) + && let SsaOp::Call { + callee, + callee_text, + .. + } = &inst.op { - return Some(ty); + if let Some(ty) = crate::ssa::type_facts::constructor_type(lang, callee) { + return Some(ty); + } + if let Some(orig) = callee_text.as_deref() + && let Some(ty) = crate::ssa::type_facts::constructor_type(lang, orig) + { + return Some(ty); + } } } None diff --git a/src/taint/ssa_transfer/tests.rs b/src/taint/ssa_transfer/tests.rs index f789eaa7..288d7f60 100644 --- a/src/taint/ssa_transfer/tests.rs +++ b/src/taint/ssa_transfer/tests.rs @@ -87,6 +87,7 @@ mod cross_file_tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }, opt: crate::ssa::OptimizeResult { const_values: std::collections::HashMap::new(), @@ -105,6 +106,7 @@ mod cross_file_tests { param_count: 0, node_meta: std::collections::HashMap::new(), body_graph: None, + cross_package_imports: std::sync::Arc::new(std::collections::HashMap::new()), } } @@ -838,6 +840,7 @@ mod primary_sink_location_tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } @@ -862,6 +865,7 @@ mod primary_sink_location_tests { col: 10, snippet: "Command::new(cmd).status()".into(), cap: Cap::SHELL_ESCAPE, + from_chain: false, }; let summary = SsaFuncSummary { param_to_sink: vec![(0usize, smallvec![site.clone()])], @@ -886,6 +890,8 @@ mod primary_sink_location_tests { &tainted, Cap::SHELL_ESCAPE, &summary.param_to_sink, + "caller.rs", + false, ); assert_eq!( primary_sites.len(), @@ -971,6 +977,7 @@ mod goto_succ_propagation_tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let cfg: Cfg = Graph::new(); @@ -1009,6 +1016,10 @@ mod goto_succ_propagation_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; // A non-bottom exit state, the test only cares that *every* succ @@ -1065,6 +1076,7 @@ mod goto_succ_propagation_tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let cfg: Cfg = Graph::new(); let interner = SymbolInterner::new(); @@ -1101,6 +1113,10 @@ mod goto_succ_propagation_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let exit_state = SsaTaintState::initial(); @@ -1128,6 +1144,7 @@ mod goto_succ_propagation_tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } @@ -1390,6 +1407,7 @@ mod goto_succ_propagation_tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } @@ -1517,6 +1535,7 @@ mod receiver_candidates_field_proj_tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), } } @@ -1604,6 +1623,7 @@ mod receiver_candidates_field_proj_tests { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let cands = super::super::receiver_candidates_for_type_lookup(SsaValue(0), Some(&body), Lang::Go); @@ -1739,6 +1759,7 @@ mod fanout_merge_tests { col: 5, snippet: "exec(q)".into(), cap: Cap::from_bits(0b0001).unwrap(), + from_chain: false, }; let unique_a = SinkSite { file_rel: "src/a.rs".into(), @@ -1746,6 +1767,7 @@ mod fanout_merge_tests { col: 3, snippet: "do_a(q)".into(), cap: Cap::from_bits(0b0001).unwrap(), + from_chain: false, }; let unique_b = SinkSite { file_rel: "src/b.rs".into(), @@ -1753,6 +1775,7 @@ mod fanout_merge_tests { col: 7, snippet: "do_b(q)".into(), cap: Cap::from_bits(0b0001).unwrap(), + from_chain: false, }; let mut a = empty(); a.param_to_sink_sites = vec![(0, smallvec![shared.clone(), unique_a.clone()])]; @@ -2008,6 +2031,7 @@ mod field_write_tests { field_interner, field_writes, synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; (body, cache_id) } @@ -2056,6 +2080,10 @@ mod field_write_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: Some(pf), + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let mut state = SsaTaintState::initial(); @@ -2140,6 +2168,10 @@ mod field_write_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let mut state = SsaTaintState::initial(); for inst in &body.blocks[0].body { @@ -2208,6 +2240,10 @@ mod field_write_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: Some(&pf), + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; // Pre-seed `validated_must` on `src` so the synth Assign @@ -2312,6 +2348,7 @@ mod field_write_tests { m }, synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; let pf = crate::pointer::analyse_body(&body, crate::cfg::BodyId(0)); // v0 is Const → empty pt, the hook should not insert anything. @@ -2354,6 +2391,10 @@ mod field_write_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: Some(&pf), + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let mut state = SsaTaintState::initial(); @@ -2452,6 +2493,10 @@ mod container_elem_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: Some(pf), + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let mut state = SsaTaintState::initial(); @@ -2549,6 +2594,7 @@ mod container_elem_tests { field_writes: HashMap::new(), synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; // Run pointer analysis first to confirm the result of `shift()` @@ -2689,6 +2735,7 @@ mod container_elem_tests { field_writes: HashMap::new(), synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; let pf = crate::pointer::analyse_body(&body, crate::cfg::BodyId(7)); @@ -2731,6 +2778,10 @@ mod container_elem_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: Some(&pf), + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; // Seed `src` as validated_must before the push fires. @@ -2833,6 +2884,7 @@ mod container_elem_tests { field_writes: HashMap::new(), synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; let interner = SymbolInterner::new(); @@ -2869,6 +2921,10 @@ mod container_elem_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let mut state = SsaTaintState::initial(); for inst in &body.blocks[0].body { @@ -2960,6 +3016,7 @@ mod cross_call_field_tests { field_writes: HashMap::new(), synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; let pf = crate::pointer::analyse_body(&body, crate::cfg::BodyId(7)); (body, cache_id, pf) @@ -3334,6 +3391,7 @@ mod field_taint_origin_cap_tests { field_writes: HashMap::new(), synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; (body, cache_id, cfg, n_proj) } @@ -3425,6 +3483,10 @@ mod field_taint_origin_cap_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: Some(&pf), + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; for inst in &body.blocks[0].body { transfer_inst(inst, &cfg, &body, &transfer, &mut state); @@ -3660,6 +3722,7 @@ mod pointer_lattice_worklist_tests { field_interner, field_writes, synthetic_externals: HashSet::new(), + slot_scoped_assigns: HashSet::new(), }; let mut interner = SymbolInterner::new(); @@ -3713,6 +3776,10 @@ mod pointer_lattice_worklist_tests { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: Some(pf), + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, } } diff --git a/src/taint/tests.rs b/src/taint/tests.rs index da952f32..79722ad1 100644 --- a/src/taint/tests.rs +++ b/src/taint/tests.rs @@ -63,6 +63,10 @@ fn ssa_analyse_rust(src: &[u8]) -> Vec { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let events = ssa_transfer::run_ssa_taint(&ssa, cfg, &transfer); let mut findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, cfg); @@ -663,6 +667,7 @@ fn cross_file_sink_finding_carries_primary_location() { col: 5, snippet: "Command::new(\"sh\").arg(cmd).status().unwrap();".into(), cap: Cap::SHELL_ESCAPE, + from_chain: false, }; global.insert( key, @@ -3788,6 +3793,10 @@ fn assert_ssa_integration(src: &[u8]) { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let events = ssa_transfer::run_ssa_taint(&ssa, the_cfg, &ssa_xfer); let mut ssa_findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, the_cfg); @@ -3926,6 +3935,10 @@ fn integ_php_echo_simple_var() { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let events = ssa_transfer::run_ssa_taint(&ssa, the_cfg, &ssa_xfer); let mut ssa_findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, the_cfg); @@ -3996,6 +4009,10 @@ fn integ_c_curl_handle_ssrf() { auto_seed_handler_params: false, cross_file_bodies: None, pointer_facts: None, + cross_package_imports: None, + entry_kind: None, + param_route_capture: None, + recording_summary: false, }; let events = ssa_transfer::run_ssa_taint(&ssa, the_cfg, &ssa_xfer); let mut ssa_findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, the_cfg); @@ -5481,6 +5498,8 @@ class Worker { &file_cfg.summaries, None, None, + None, + None, ); // Collect containers of every key named "process". @@ -5553,6 +5572,8 @@ function helper(x) { &file_cfg.summaries, None, None, + None, + None, ); let helper_keys: Vec<_> = summaries.keys().filter(|k| k.name == "helper").collect(); @@ -5776,6 +5797,8 @@ class Reader { &file_cfg.summaries, None, None, + None, + None, ); let read_sum = summaries @@ -5821,6 +5844,8 @@ class Maker { &file_cfg.summaries, None, None, + None, + None, ); // make() has zero parameters and no fresh-allocation return, so the @@ -6837,6 +6862,55 @@ function handler(req, res) { /// traversal flow alive end-to-end. Pins the precision claim — the /// strip is element-of-array-after-filter scoped, not a wholesale /// kill on any `.filter` call regardless of callback identity. +#[test] +fn callee_body_carries_file_cross_package_imports() { + // Phase 09: every `CalleeSsaBody` produced from a file's lowering + // pipeline should carry the file-level cross-package import map + // so the inline-analysis frame can resolve the callee's local + // names against the callee's own package boundary (step 0.7 + // inside an inlined frame). + let src = b"export function passthrough(s) { return s; }\n"; + let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE); + let mut file_cfg = parse_lang(src, "javascript", lang); + + // Inject a synthetic resolved import binding the way the Phase 04 + // resolver would for `import { helper } from "@scope/util/helper";`. + file_cfg + .resolved_imports + .push(crate::resolve::ImportBinding { + local_name: "helper".to_string(), + source_module: "@scope/util/helper".to_string(), + resolved_file: Some(std::path::PathBuf::from("/scope/util/src/helper.ts")), + exported_name: Some("helper".to_string()), + }); + + let (_summaries, bodies) = super::extract_ssa_artifacts_from_file_cfg( + &file_cfg, + Lang::JavaScript, + "test.js", + &file_cfg.summaries, + None, + None, + None, + None, + ); + + assert!( + !bodies.is_empty(), + "expected at least one eligible body for `passthrough`", + ); + for (_key, body) in &bodies { + assert!( + !body.cross_package_imports.is_empty(), + "every body in a file with resolved imports should carry the file's cross-package import map; got an empty map", + ); + assert!( + body.cross_package_imports.contains_key("helper"), + "expected the synthetic `helper` binding to surface in the body's cross-package import map", + ); + } +} + #[test] fn cve_2026_42353_filter_without_validator_callback_preserves_taint() { let src = br#" @@ -6867,3 +6941,74 @@ function handler(req, res) { "expected taint flow via filter(pickFirst) — pickFirst is not a recognised validator and must not strip taint; got 0 findings", ); } + +// ── Phase 09 cross-package namespace migration ───────────────────────────── + +/// `build_cross_package_func_keys` produces a package-prefixed +/// [`FuncKey::namespace`] for files inside a discovered monorepo +/// package and a plain namespace otherwise. +/// +/// Locks in the migration done as part of the deferred Phase 09 audit: +/// SSA summary keys produced by +/// [`crate::taint::lower_all_functions_from_bodies`] use +/// `namespace_with_package` for their namespace, so the cross-package +/// import map's `FuncKey::namespace` must agree for step 0.7 of +/// `resolve_callee_full` to land hits in +/// [`crate::summary::GlobalSummaries::ssa_by_key`]. +#[test] +fn cross_package_func_keys_namespace_uses_resolver_when_available() { + use crate::resolve::{ImportBinding, build_module_graph}; + use std::path::PathBuf; + + let mut fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + fixture_root.push("tests/fixtures/resolver"); + let root = fixture_root + .canonicalize() + .unwrap_or_else(|_| fixture_root.clone()); + let graph = build_module_graph(std::slice::from_ref(&root)); + + let resolved_file = root.join("packages/util/src/index.ts"); + let binding = ImportBinding { + local_name: "doStuff".to_string(), + source_module: "@scope/util".to_string(), + resolved_file: Some(resolved_file.clone()), + exported_name: Some("doStuff".to_string()), + }; + let scan_root = root.to_string_lossy().to_string(); + + let with_resolver = crate::taint::build_cross_package_func_keys( + std::slice::from_ref(&binding), + Some(&scan_root), + Some(&graph), + Lang::TypeScript, + ); + let key = with_resolver + .get("doStuff") + .expect("resolved binding maps to a FuncKey"); + assert!( + key.namespace.starts_with("@scope/util::"), + "expected package-prefixed namespace, got {ns}", + ns = key.namespace, + ); + assert!( + key.namespace.ends_with("packages/util/src/index.ts"), + "expected the suffix to remain the scan-root-relative path, got {ns}", + ns = key.namespace, + ); + + let without_resolver = crate::taint::build_cross_package_func_keys( + std::slice::from_ref(&binding), + Some(&scan_root), + None, + Lang::TypeScript, + ); + let plain = without_resolver + .get("doStuff") + .expect("plain binding maps to a FuncKey"); + assert!( + !plain.namespace.contains("::"), + "without a resolver the namespace must stay plain, got {ns}", + ns = plain.namespace, + ); + assert_eq!(plain.namespace, "packages/util/src/index.ts"); +} diff --git a/src/utils/config.rs b/src/utils/config.rs index 30d9eefa..6f704cc9 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -794,6 +794,13 @@ pub struct Config { /// not persisted to config files. #[serde(skip)] pub framework_ctx: Option, + /// TS/JS module resolver state, set by the scan pipeline once per scan + /// after the file walk and before pass 1. `None` outside the scan paths + /// (e.g. unit-test direct callers of `analyse_file_fused`); consumers + /// must treat absence as "no resolver hints available, fall back to + /// pre-resolver behaviour" rather than as a hard error. + #[serde(skip)] + pub module_graph: Option>, } impl Config { diff --git a/tests/auth_analysis_tests.rs b/tests/auth_analysis_tests.rs index b353474e..a2a45dea 100644 --- a/tests/auth_analysis_tests.rs +++ b/tests/auth_analysis_tests.rs @@ -971,64 +971,56 @@ fn auth_analysis_does_not_run_in_cfg_mode() { diags.iter().all(|diag| !diag.id.starts_with("rs.auth.")), "CFG mode should not emit rs.auth findings" ); - assert!( + // Per-file checks: CFG mode must not produce any *.auth.* finding on + // each fixture file. We filter by id prefix (not path-only) so that + // genuine taint flows the engine catches in CFG mode (e.g. + // `ctx.body = { project }` data exfil after a query) don't trip the + // assertion. The earlier global asserts above already cover the auth + // rule prefixes; these per-file checks pin the intent that auth + // analysis is fully gated on AST mode. + let auth_in_file = |needle: &str| { diags .iter() - .all(|diag| !diag.path.contains("koa_scoped_read_missing.js")), + .any(|d| d.path.contains(needle) && d.id.contains(".auth.")) + }; + assert!( + !auth_in_file("koa_scoped_read_missing.js"), "CFG mode should not emit Koa auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("fastify_scoped_write_missing.js")), + !auth_in_file("fastify_scoped_write_missing.js"), "CFG mode should not emit Fastify auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("flask_scoped_write_missing.py")), + !auth_in_file("flask_scoped_write_missing.py"), "CFG mode should not emit Flask auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("django_cbv_scoped_write_missing.py")), + !auth_in_file("django_cbv_scoped_write_missing.py"), "CFG mode should not emit Django auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("rails_scoped_write_missing.rb")), + !auth_in_file("rails_scoped_write_missing.rb"), "CFG mode should not emit Rails auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("sinatra_scoped_read_missing.rb")), + !auth_in_file("sinatra_scoped_read_missing.rb"), "CFG mode should not emit Sinatra auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("gin_admin_route_missing.go")), + !auth_in_file("gin_admin_route_missing.go"), "CFG mode should not emit Gin auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("echo_partial_batch.go")), + !auth_in_file("echo_partial_batch.go"), "CFG mode should not emit Echo auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("spring_scoped_read_missing.java")), + !auth_in_file("spring_scoped_read_missing.java"), "CFG mode should not emit Spring auth-analysis findings" ); assert!( - diags - .iter() - .all(|diag| !diag.path.contains("actix_scoped_write_missing.rs")), + !auth_in_file("actix_scoped_write_missing.rs"), "CFG mode should not emit Rust auth-analysis findings" ); } diff --git a/tests/benchmark/ground_truth.json b/tests/benchmark/ground_truth.json index f6ba57c7..2f4c2246 100644 --- a/tests/benchmark/ground_truth.json +++ b/tests/benchmark/ground_truth.json @@ -814,7 +814,8 @@ "py.xss.jinja_from_string" ], "allowed_alternative_rule_ids": [ - "taint-unsanitised-flow" + "taint-unsanitised-flow", + "taint-template-injection" ], "forbidden_rule_ids": [], "expected_severity": "HIGH", @@ -11087,6 +11088,12 @@ "expected_severity": "MEDIUM", "expected_category": "Security", "expected_sink_lines": [ + [ + 76, + 80 + ] + ], + "expected_call_site_lines": [ [ 58, 58 @@ -11104,7 +11111,7 @@ "path_traversal", "rack-middleware" ], - "notes": "CVE-2023-38337: rswag-api Rack middleware concatenated env['PATH_INFO'] into the swagger root path with no validation; GET /../config/secrets.yml served arbitrary YAML/JSON files. Fixed in 2.10.1 by File.expand_path + start_with? rooted-path check. MIT" + "notes": "CVE-2023-38337: rswag-api Rack middleware concatenated env['PATH_INFO'] into the swagger root path with no validation; GET /../config/secrets.yml served arbitrary YAML/JSON files. Fixed in 2.10.1 by File.expand_path + start_with? rooted-path check. After multi-hop attribution lands (2026-05-10 session 0008 from_chain flag), engine reports the deeper File.read sink at line 76 (load_yaml arm) or line 80 (load_json arm); the call site for parse_file remains at line 58 and is asserted via expected_call_site_lines. MIT" }, { "case_id": "cve-rb-2023-38337-patched", diff --git a/tests/benchmark/results/latest.json b/tests/benchmark/results/latest.json index acb77cee..3270d7db 100644 --- a/tests/benchmark/results/latest.json +++ b/tests/benchmark/results/latest.json @@ -1,7 +1,7 @@ { "benchmark_version": "1.0", - "timestamp": "2026-05-04T17:11:50Z", - "scanner_version": "0.6.1", + "timestamp": "2026-05-11T15:19:43Z", + "scanner_version": "0.7.0", "scanner_config": { "analysis_mode": "Full", "taint_enabled": true, @@ -9,7 +9,7 @@ "state_analysis_enabled": true, "worker_threads": 1 }, - "ground_truth_hash": "sha256:414494ab1b6881a9b78eca38e26561231f78767480399fda73a477e23a9fcbaa", + "ground_truth_hash": "sha256:00a4629e50841ab26c7ba947adfdab43b909d72d7a0885d604e702cc56552eb4", "corpus_size": 565, "cases_run": 562, "cases_skipped": 3, @@ -739,14 +739,11 @@ "matched_rule_ids": [ "taint-unsanitised-flow (source 25:19)" ], - "unexpected_rule_ids": [ - "cfg-unguarded-sink" - ], + "unexpected_rule_ids": [], "all_finding_ids": [ - "cfg-unguarded-sink", "taint-unsanitised-flow (source 25:19)" ], - "security_finding_count": 2, + "security_finding_count": 1, "non_security_finding_count": 0 }, { @@ -1541,7 +1538,7 @@ "is_vulnerable": true, "outcome_file_level": "TP", "outcome_rule_level": "TP", - "outcome_location_level": "FN", + "outcome_location_level": "TP", "matched_rule_ids": [ "taint-unsanitised-flow (source 43:28)" ], @@ -1578,14 +1575,16 @@ "outcome_location_level": "TP", "matched_rule_ids": [ "js.code_exec.eval", + "taint-unsanitised-flow (source 24:5)", "taint-unsanitised-flow (source 24:5)" ], "unexpected_rule_ids": [], "all_finding_ids": [ "js.code_exec.eval", + "taint-unsanitised-flow (source 24:5)", "taint-unsanitised-flow (source 24:5)" ], - "security_finding_count": 2, + "security_finding_count": 3, "non_security_finding_count": 0 }, { @@ -1934,14 +1933,11 @@ "matched_rule_ids": [ "py.code_exec.eval" ], - "unexpected_rule_ids": [ - "cfg-unguarded-sink" - ], + "unexpected_rule_ids": [], "all_finding_ids": [ - "cfg-unguarded-sink", "py.code_exec.eval" ], - "security_finding_count": 2, + "security_finding_count": 1, "non_security_finding_count": 0 }, { @@ -2477,12 +2473,12 @@ "outcome_location_level": "TP", "matched_rule_ids": [ "taint-unsanitised-flow (source 73:5)", - "taint-unsanitised-flow (source 72:20)" + "taint-unsanitised-flow (source 73:5)" ], "unexpected_rule_ids": [], "all_finding_ids": [ "taint-unsanitised-flow (source 73:5)", - "taint-unsanitised-flow (source 72:20)" + "taint-unsanitised-flow (source 73:5)" ], "security_finding_count": 2, "non_security_finding_count": 0 @@ -2512,13 +2508,15 @@ "outcome_rule_level": "TP", "outcome_location_level": "TP", "matched_rule_ids": [ + "taint-unsanitised-flow (source 50:5)", "taint-unsanitised-flow (source 50:5)" ], "unexpected_rule_ids": [], "all_finding_ids": [ + "taint-unsanitised-flow (source 50:5)", "taint-unsanitised-flow (source 50:5)" ], - "security_finding_count": 1, + "security_finding_count": 2, "non_security_finding_count": 0 }, { @@ -2687,16 +2685,14 @@ "outcome_location_level": "TP", "matched_rule_ids": [ "cfg-error-fallthrough", - "cfg-unguarded-sink", "go.sqli.query_concat" ], "unexpected_rule_ids": [], "all_finding_ids": [ "cfg-error-fallthrough", - "cfg-unguarded-sink", "go.sqli.query_concat" ], - "security_finding_count": 3, + "security_finding_count": 2, "non_security_finding_count": 0 }, { @@ -3748,13 +3744,15 @@ "outcome_rule_level": "TP", "outcome_location_level": "TP", "matched_rule_ids": [ - "state-resource-leak" + "state-resource-leak", + "taint-unsanitised-flow (source 6:23)" ], "unexpected_rule_ids": [], "all_finding_ids": [ - "state-resource-leak" + "state-resource-leak", + "taint-unsanitised-flow (source 6:23)" ], - "security_finding_count": 1, + "security_finding_count": 2, "non_security_finding_count": 0 }, { @@ -4090,17 +4088,13 @@ "language": "java", "vuln_class": "sqli", "is_vulnerable": true, - "outcome_file_level": "TP", - "outcome_rule_level": "TP", - "outcome_location_level": "TP", - "matched_rule_ids": [ - "cfg-unguarded-sink" - ], + "outcome_file_level": "FN", + "outcome_rule_level": "FN", + "outcome_location_level": "FN", + "matched_rule_ids": [], "unexpected_rule_ids": [], - "all_finding_ids": [ - "cfg-unguarded-sink" - ], - "security_finding_count": 1, + "all_finding_ids": [], + "security_finding_count": 0, "non_security_finding_count": 0 }, { @@ -4141,7 +4135,7 @@ "is_vulnerable": true, "outcome_file_level": "TP", "outcome_rule_level": "TP", - "outcome_location_level": "FN", + "outcome_location_level": "TP", "matched_rule_ids": [ "taint-unsanitised-flow (source 25:28)" ], @@ -6247,16 +6241,16 @@ "outcome_rule_level": "TP", "outcome_location_level": "TP", "matched_rule_ids": [ - "taint-unsanitised-flow (source 6:5)", - "py.cmdi.os_system" + "py.cmdi.os_system", + "taint-unsanitised-flow (source 6:5)" ], "unexpected_rule_ids": [ "cfg-unguarded-sink" ], "all_finding_ids": [ - "taint-unsanitised-flow (source 6:5)", "cfg-unguarded-sink", - "py.cmdi.os_system" + "py.cmdi.os_system", + "taint-unsanitised-flow (source 6:5)" ], "security_finding_count": 3, "non_security_finding_count": 0 @@ -6846,6 +6840,7 @@ "outcome_rule_level": "TP", "outcome_location_level": "TP", "matched_rule_ids": [ + "taint-unsanitised-flow (source 17:11)", "taint-unsanitised-flow (source 17:11)" ], "unexpected_rule_ids": [ @@ -6853,11 +6848,12 @@ "py.sqli.execute_format" ], "all_finding_ids": [ + "taint-unsanitised-flow (source 17:11)", "state-resource-leak", "py.sqli.execute_format", "taint-unsanitised-flow (source 17:11)" ], - "security_finding_count": 3, + "security_finding_count": 4, "non_security_finding_count": 0 }, { @@ -6892,11 +6888,11 @@ "outcome_rule_level": "TP", "outcome_location_level": "TP", "matched_rule_ids": [ - "taint-unsanitised-flow (source 5:12)" + "taint-template-injection (source 5:12)" ], "unexpected_rule_ids": [], "all_finding_ids": [ - "taint-unsanitised-flow (source 5:12)" + "taint-template-injection (source 5:12)" ], "security_finding_count": 1, "non_security_finding_count": 0 @@ -9187,14 +9183,16 @@ "outcome_location_level": "TP", "matched_rule_ids": [ "taint-unsanitised-flow (source 5:5)", - "ts.code_exec.eval" + "ts.code_exec.eval", + "taint-unsanitised-flow (source 5:5)" ], "unexpected_rule_ids": [], "all_finding_ids": [ "taint-unsanitised-flow (source 5:5)", - "ts.code_exec.eval" + "ts.code_exec.eval", + "taint-unsanitised-flow (source 5:5)" ], - "security_finding_count": 2, + "security_finding_count": 3, "non_security_finding_count": 0 }, { @@ -9915,14 +9913,11 @@ "matched_rule_ids": [ "taint-unsanitised-flow (source 18:5)" ], - "unexpected_rule_ids": [ - "cfg-unguarded-sink" - ], + "unexpected_rule_ids": [], "all_finding_ids": [ - "cfg-unguarded-sink", "taint-unsanitised-flow (source 18:5)" ], - "security_finding_count": 2, + "security_finding_count": 1, "non_security_finding_count": 0 }, { @@ -10033,33 +10028,35 @@ "outcome_rule_level": "TP", "outcome_location_level": "TP", "matched_rule_ids": [ - "taint-unsanitised-flow (source 7:5)" + "taint-unsanitised-flow (source 7:5)", + "taint-unsanitised-flow (source 6:17)" ], "unexpected_rule_ids": [], "all_finding_ids": [ - "taint-unsanitised-flow (source 7:5)" + "taint-unsanitised-flow (source 7:5)", + "taint-unsanitised-flow (source 6:17)" ], - "security_finding_count": 1, + "security_finding_count": 2, "non_security_finding_count": 0 } ], "aggregate_file_level": { - "tp": 275, + "tp": 274, "fp": 0, - "fn_": 0, + "fn_": 1, "tn": 287, "precision": 1.0, - "recall": 1.0, - "f1": 1.0 + "recall": 0.9963636363636363, + "f1": 0.9981785063752276 }, "aggregate_rule_level": { - "tp": 275, + "tp": 274, "fp": 0, - "fn_": 0, + "fn_": 1, "tn": 287, "precision": 1.0, - "recall": 1.0, - "f1": 1.0 + "recall": 0.9963636363636363, + "f1": 0.9981785063752276 }, "by_language": { "c": { @@ -10090,13 +10087,13 @@ "f1": 1.0 }, "java": { - "tp": 23, + "tp": 22, "fp": 0, - "fn_": 0, + "fn_": 1, "tn": 23, "precision": 1.0, - "recall": 1.0, - "f1": 1.0 + "recall": 0.9565217391304348, + "f1": 0.9777777777777777 }, "javascript": { "tp": 25, @@ -10317,13 +10314,13 @@ "f1": 1.0 }, "sqli": { - "tp": 37, + "tp": 36, "fp": 0, - "fn_": 0, + "fn_": 1, "tn": 0, "precision": 1.0, - "recall": 1.0, - "f1": 1.0 + "recall": 0.972972972972973, + "f1": 0.9863013698630138 }, "ssrf": { "tp": 32, @@ -10355,22 +10352,22 @@ "f1": 0.3586497890295359 }, ">=Low": { - "tp": 86, + "tp": 85, "fp": 142, - "fn_": 189, + "fn_": 190, "tn": 145, - "precision": 0.37719298245614036, - "recall": 0.31272727272727274, - "f1": 0.341948310139165 + "precision": 0.3744493392070485, + "recall": 0.3090909090909091, + "f1": 0.33864541832669326 }, ">=Medium": { - "tp": 86, + "tp": 85, "fp": 133, - "fn_": 189, + "fn_": 190, "tn": 154, - "precision": 0.3926940639269406, - "recall": 0.31272727272727274, - "f1": 0.3481781376518218 + "precision": 0.38990825688073394, + "recall": 0.3090909090909091, + "f1": 0.3448275862068966 } } } \ No newline at end of file diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 74b0322e..48b9bd52 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,5 +1,7 @@ // Shared test helpers for integration and perf tests. +pub mod recall; + use nyx_scanner::commands::scan::Diag; use nyx_scanner::utils::config::{AnalysisMode, Config}; use serde::Deserialize; diff --git a/tests/common/recall.rs b/tests/common/recall.rs new file mode 100644 index 00000000..daba6364 --- /dev/null +++ b/tests/common/recall.rs @@ -0,0 +1,161 @@ +//! Recall-gap fixture harness. +//! +//! Exposes `scan_fixture`, `assert_finding`, and `ExpectedFinding` for the +//! integration test binary `tests/recall_gaps.rs`. Phases 02–11 each own one +//! fixture under `tests/fixtures/realistic/` and one matching test. + +#![allow(dead_code)] + +use std::fs; +use std::path::{Path, PathBuf}; + +pub use nyx_scanner::commands::scan::Diag as Finding; +use nyx_scanner::utils::config::Config; + +/// Copy `tests/fixtures/realistic/` into a fresh temp directory and +/// run a two-pass filesystem scan against the copy. Isolating in tempdir +/// prevents SQLite or `nyx.conf` artefacts from leaking between tests. +/// +/// Accepts either a directory or a single file. When `rel_path` resolves +/// to a regular file the harness copies just that file (preserving its +/// basename) — useful for fixture areas where each test owns its own file +/// and the directory-wide rescan would multiply wall time on cold caches. +pub fn scan_fixture(rel_path: &str) -> Vec { + let src: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/realistic") + .join(rel_path); + assert!(src.exists(), "recall fixture not found: {}", src.display()); + let tmp = tempfile::tempdir().expect("tempdir for recall fixture"); + if src.is_file() { + let name = src + .file_name() + .unwrap_or_else(|| panic!("fixture has no filename: {}", src.display())); + fs::copy(&src, tmp.path().join(name)).expect("copy single fixture file into tempdir"); + } else { + copy_dir_recursive(&src, tmp.path()).expect("copy fixture into tempdir"); + } + + let cfg = Config::default(); + nyx_scanner::scan_no_index(tmp.path(), &cfg).expect("scan_no_index on recall fixture") +} + +/// Shape used by `recall_gaps.rs` tests to assert a specific finding exists. +/// +/// - `rule_id` matches the rule prefix of `Diag.id`. Taint findings carry a +/// trailing ` (source N:M)` suffix; this struct compares only the prefix. +/// - `file_suffix` matches `Diag.path.ends_with(file_suffix)` so callers do +/// not have to reproduce the tempdir prefix. +/// - `sink_line` matches `Diag.line` exactly (1-based). +/// - `source_line`, when `Some`, matches the `N` parsed from the trailing +/// ` (source N:M)` suffix on `Diag.id`. +#[derive(Debug, Clone)] +pub struct ExpectedFinding { + pub rule_id: &'static str, + pub file_suffix: &'static str, + pub sink_line: usize, + pub source_line: Option, +} + +/// Assert that at least one finding in `findings` matches `expected`. +pub fn assert_finding(findings: &[Finding], expected: ExpectedFinding) { + let hit = findings.iter().any(|f| { + rule_id_prefix(&f.id) == expected.rule_id + && f.path.ends_with(expected.file_suffix) + && f.line == expected.sink_line + && match expected.source_line { + None => true, + Some(want) => parse_source_line(&f.id) == Some(want), + } + }); + assert!( + hit, + "expected recall finding not produced: {expected:?}\nactual findings:\n{}", + findings + .iter() + .map(|f| format!( + " {} :: {}:{} [{}]", + f.id, + f.path, + f.line, + f.severity.as_db_str() + )) + .collect::>() + .join("\n"), + ); +} + +/// Like [`assert_finding`] but also requires that the matched finding's +/// resolved sink capability bits include all of `cap_bits`. Use to defend +/// against a coincidentally co-located finding at the same `sink_line` +/// (e.g. an XSS sink on `res.json(rows)` happening to sit on the same +/// line as the SQL_QUERY sink the test actually wants to assert) silently +/// satisfying the assertion. Pass `Cap::FOO.bits().into()` from the +/// caller. +pub fn assert_finding_with_cap(findings: &[Finding], expected: ExpectedFinding, cap_bits: u32) { + let hit = findings.iter().any(|f| { + rule_id_prefix(&f.id) == expected.rule_id + && f.path.ends_with(expected.file_suffix) + && f.line == expected.sink_line + && match expected.source_line { + None => true, + Some(want) => parse_source_line(&f.id) == Some(want), + } + && f.evidence + .as_ref() + .map(|e| e.sink_caps & cap_bits == cap_bits) + .unwrap_or(false) + }); + assert!( + hit, + "expected recall finding not produced: {expected:?} (cap_bits=0x{cap_bits:x})\nactual findings:\n{}", + findings + .iter() + .map(|f| { + let caps = f.evidence.as_ref().map(|e| e.sink_caps).unwrap_or(0); + format!( + " {} :: {}:{} [{}] caps=0x{:x}", + f.id, + f.path, + f.line, + f.severity.as_db_str(), + caps, + ) + }) + .collect::>() + .join("\n"), + ); +} + +fn rule_id_prefix(id: &str) -> &str { + match id.find(" (source ") { + Some(idx) => &id[..idx], + None => id, + } +} + +fn parse_source_line(id: &str) -> Option { + let needle = " (source "; + let start = id.find(needle)? + needle.len(); + let rest = &id[start..]; + let end = rest.find(':').or_else(|| rest.find(')'))?; + rest[..end].parse().ok() +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let from = entry.path(); + let name = entry.file_name(); + if name == ".gitkeep" { + continue; + } + let to = dst.join(&name); + if from.is_dir() { + copy_dir_recursive(&from, &to)?; + } else { + fs::copy(&from, &to)?; + } + } + Ok(()) +} diff --git a/tests/fixtures/async_promise_chain_js/README.md b/tests/fixtures/async_promise_chain_js/README.md index c802135d..a650750c 100644 --- a/tests/fixtures/async_promise_chain_js/README.md +++ b/tests/fixtures/async_promise_chain_js/README.md @@ -1,4 +1,4 @@ -# async_promise_chain_js — known gap +# async_promise_chain_js — chained-receiver promise taint ## Intended flow A promise chain reads `process.env.PREFIX` inside the second `.then` @@ -6,14 +6,15 @@ callback, concatenates it with fetched text, and sinks the result via `child_process.exec` from the third callback. The intended finding is `taint-unsanitised-flow` from the env source to the exec sink. -## Current engine behaviour -The scanner produces **no** taint finding for this fixture. Tracking -taint across chained promise callbacks requires reasoning about the -promise resolution value returned from each arrow, which the engine -does not model today. +## Engine behaviour +The engine now closes this gap. The chained-receiver promise shape +(`fetch(...).then(..).then(..).then(..)`) keeps each `.then` call's +identity at the CFG level so `try_apply_promise_callback` and the +synthetic `source_to_callback` emission see the chain head's Source +label and seed the callback's first parameter, propagating taint +through the chain to the `exec` sink. -## Why this expectation is codified as a `forbidden_findings` entry -The fixture asserts current behaviour so a future improvement that -closes the gap — e.g. promise resolution modelling or coarser -callback return propagation — must update `expectations.json` and -delete this README. +## Expectation +`required_findings` pins the taint flow finding so a future +regression that re-collapses the chain (e.g. an inner-call rewrite +that erases the outer `.then` identity) will fail this test. diff --git a/tests/fixtures/async_promise_chain_js/expectations.json b/tests/fixtures/async_promise_chain_js/expectations.json index eac76b7c..f925bf98 100644 --- a/tests/fixtures/async_promise_chain_js/expectations.json +++ b/tests/fixtures/async_promise_chain_js/expectations.json @@ -1,10 +1,11 @@ { - "required_findings": [], - "forbidden_findings": [ + "required_findings": [ { - "id_prefix": "taint-" + "id_prefix": "taint-unsanitised-flow", + "min_count": 1 } ], + "forbidden_findings": [], "performance_expectations": { "max_ms_no_index": 1500, "max_ms_index_cold": 2000, diff --git a/tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/crypto_demo.py b/tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/crypto_demo.py new file mode 100644 index 00000000..18d3567e --- /dev/null +++ b/tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/crypto_demo.py @@ -0,0 +1,33 @@ +"""Pin the Crypto carve-out for the Layer A literal-args suppression. + +Pre-fix, ``hashlib.md5(b"hello")`` was treated as "all-literal args" +and silently suppressed. The literal IS the weakness signal here: MD5 +is the algorithm choice. Suppressing the call erases the actual +finding even though no taint flows through it. + +The same shape applies to ``hashlib.sha1``. Both must keep firing. + +CommandExec / SqlInjection patterns stay covered by the literal-args +suppression: a literal command string or literal SQL string carries +no attacker-controlled data, so silencing those is correct. The +``os.system("ls -la")`` call demonstrates the contrast. +""" + +import hashlib +import os + + +def hash_with_literal_bytes() -> bytes: + return hashlib.md5(b"static-string").hexdigest().encode() + + +def hash_with_literal_sha1() -> bytes: + return hashlib.sha1(b"another-static").hexdigest().encode() + + +def hash_with_user_data(data: bytes) -> bytes: + return hashlib.md5(data).hexdigest().encode() + + +def safe_command_literal() -> int: + return os.system("ls -la /tmp") diff --git a/tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/expectations.json b/tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/expectations.json new file mode 100644 index 00000000..1bb583ef --- /dev/null +++ b/tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/expectations.json @@ -0,0 +1,19 @@ +{ + "required_findings": [ + { "id_prefix": "py.crypto.md5", "min_count": 1 }, + { "id_prefix": "py.crypto.sha1", "min_count": 1 } + ], + "forbidden_findings": [ + { "id_prefix": "py.cmdi.os_system" } + ], + "noise_budget": { + "max_total_findings": 6, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/ast_layer_a_java_call_args/DriverLoader.java b/tests/fixtures/fp_guards/ast_layer_a_java_call_args/DriverLoader.java new file mode 100644 index 00000000..c5897ebe --- /dev/null +++ b/tests/fixtures/fp_guards/ast_layer_a_java_call_args/DriverLoader.java @@ -0,0 +1,27 @@ +package guards; + +import java.security.MessageDigest; + +public class DriverLoader { + + private static final String MYSQL_DRIVER = "com.mysql.cj.jdbc.Driver"; + private static final String POSTGRES_DRIVER = "org.postgresql.Driver"; + + public void loadKnownDriverByLiteral() throws Exception { + Class.forName("com.mysql.cj.jdbc.Driver"); + } + + public void loadKnownDriverByConst() throws Exception { + Class.forName(MYSQL_DRIVER); + Class.forName(POSTGRES_DRIVER); + } + + public void loadCallerSuppliedDriver(String driver) throws Exception { + Class.forName(driver); + } + + public byte[] hashWithLiteralAlgo() throws Exception { + MessageDigest md = MessageDigest.getInstance("MD5"); + return md.digest("payload".getBytes()); + } +} diff --git a/tests/fixtures/fp_guards/ast_layer_a_java_call_args/expectations.json b/tests/fixtures/fp_guards/ast_layer_a_java_call_args/expectations.json new file mode 100644 index 00000000..bf3a3ba8 --- /dev/null +++ b/tests/fixtures/fp_guards/ast_layer_a_java_call_args/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [ + { "id_prefix": "java.reflection.class_forname", "min_count": 1 }, + { "id_prefix": "java.crypto.weak_digest", "min_count": 1 } + ], + "forbidden_findings": [], + "noise_budget": { + "max_total_findings": 6, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/auth_nextauth_callback/expectations.json b/tests/fixtures/fp_guards/auth_nextauth_callback/expectations.json new file mode 100644 index 00000000..7229003c --- /dev/null +++ b/tests/fixtures/fp_guards/auth_nextauth_callback/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "js.auth.missing_ownership_check" }, + { "id_prefix": "ts.auth.missing_ownership_check" } + ], + "noise_budget": { + "max_total_findings": 2, + "max_high_findings": 1 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-custom-adapter.ts b/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-custom-adapter.ts new file mode 100644 index 00000000..167f2247 --- /dev/null +++ b/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-custom-adapter.ts @@ -0,0 +1,95 @@ +// cal.com NextAuth Adapter factory shape. The function returns the +// Adapter implementation directly, with no `callbacks: { ... }` +// wrapper. Inner method bodies are object method shorthands that +// don't become their own units, so every identity-resolution +// operation inside them accumulates onto the OUTER `CalComAdapter` +// unit. Without the Adapter-shape arm of `body_returns_nextauth_options`, +// `is_nextauth_callback_unit` cannot match by name and the +// missing-ownership rule fires on every `prismaClient.user.findUnique` +// / `prismaClient.account.findUnique` call. + +import { prisma } from "./prisma"; + +type AdapterUser = { id: string; email: string; emailVerified?: Date }; +type AdapterAccount = { + userId: string; + provider: string; + providerAccountId: string; +}; + +const toAdapterUser = (user: { id: number; email: string }): AdapterUser => ({ + id: user.id.toString(), + email: user.email, +}); + +const getAccountWhere = (provider: string, providerAccountId: string) => ({ + provider_providerAccountId: { provider, providerAccountId }, +}); + +export default function CalComAdapter(prismaClient: typeof prisma) { + return { + createUser: async (data: Omit) => { + const user = await prismaClient.user.create({ data }); + return toAdapterUser(user); + }, + + getUser: async (id: string) => { + const user = await prismaClient.user.findUnique({ where: { id: parseInt(id, 10) } }); + return user ? toAdapterUser(user) : null; + }, + + getUserByEmail: async (email: string) => { + const user = await prismaClient.user.findUnique({ where: { email } }); + return user ? toAdapterUser(user) : null; + }, + + async getUserByAccount(providerAccountId: { provider: string; providerAccountId: string }) { + const account = await prismaClient.account.findUnique({ + where: getAccountWhere(providerAccountId.provider, providerAccountId.providerAccountId), + select: { user: true }, + }); + return account?.user ? toAdapterUser(account.user) : null; + }, + + updateUser: async (userData: AdapterUser) => { + const { id, ...data } = userData; + const user = await prismaClient.user.update({ + where: { id: parseInt(id, 10) }, + data, + }); + return toAdapterUser(user); + }, + + deleteUser: async (userId: string) => { + const user = await prismaClient.user.delete({ where: { id: parseInt(userId, 10) } }); + return toAdapterUser(user); + }, + + createVerificationToken: async (data: { identifier: string; token: string; expires: Date }) => { + const token = await prismaClient.verificationToken.create({ data }); + return token; + }, + + useVerificationToken: async (identifier_token: { identifier: string; token: string }) => { + const token = await prismaClient.verificationToken.delete({ where: { identifier_token } }); + return token; + }, + + linkAccount: async (account: AdapterAccount) => { + const created = await prismaClient.account.create({ data: account }); + return created; + }, + + unlinkAccount: async (providerAccountId: Pick) => { + const deleted = await prismaClient.account.delete({ + where: getAccountWhere(providerAccountId.provider, providerAccountId.providerAccountId), + }); + return deleted; + }, + + createSession: async (session: { sessionToken: string; userId: string; expires: Date }) => session, + getSessionAndUser: async () => null, + updateSession: async (session: { sessionToken: string }) => ({ sessionToken: session.sessionToken }), + deleteSession: async () => undefined, + }; +} diff --git a/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options-factory.ts b/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options-factory.ts new file mode 100644 index 00000000..c559cd6d --- /dev/null +++ b/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options-factory.ts @@ -0,0 +1,57 @@ +// Same NextAuth options content, but exposed via a factory arrow that +// returns the options object. Matches cal.com's `getOptions` shape: +// +// export const getOptions = (deps): AuthOptions => ({ +// callbacks: { async signIn(...) { ... }, async jwt(...) { ... } }, +// }); +// +// The top-level unit-creation pass attributes every operation inside +// the inner callback methods to the OUTER arrow's unit, because object +// method shorthands are not enumerated as their own units. Without the +// factory-aware suppressor the outer unit name is `getOptions`, not +// `jwt`, so `is_nextauth_callback_unit`'s name match fails and the +// missing-ownership-check rule fires on every identity-resolution +// operation inside the callbacks. + +import { prisma } from "./prisma"; + +type Token = { sub?: string }; +type Account = { provider: string }; +type Profile = { email?: string }; +type User = { id: number; email: string }; + +export const getOptions = ({ + getDubId, + getTrackingData, +}: { + getDubId: () => string | undefined; + getTrackingData: () => any; +}) => ({ + callbacks: { + async signIn({ user, account, profile }: { user: User; account: Account; profile: Profile }) { + await prisma.user.update({ + where: { id: user.id }, + data: { lastSignInProvider: account.provider }, + }); + return true; + }, + + async session({ session, user, token }: { session: any; user: User; token: Token }) { + const existingUser = await prisma.user.findUnique({ where: { id: user.id } }); + if (!existingUser) return session; + const profile = await prisma.profile.findUnique({ where: { userId: existingUser.id } }); + session.user = { ...session.user, profileId: profile?.id }; + return session; + }, + + async jwt({ token, user, account }: { token: Token; user?: User; account?: Account }) { + if (user) { + const dbUser = await prisma.user.findUnique({ where: { id: user.id } }); + if (dbUser) { + token.sub = String(dbUser.id); + } + } + return token; + }, + }, +}); diff --git a/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options.ts b/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options.ts new file mode 100644 index 00000000..467505a6 --- /dev/null +++ b/tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options.ts @@ -0,0 +1,54 @@ +// Minimal NextAuth-style configuration. The named callbacks below are +// the authentication boundary itself: operations on `user.id` / +// `existingUser.id` inside them resolve the authenticated identity, +// they are not request-driven foreign-id lookups. The auth analyser +// must NOT flag js.auth.missing_ownership_check on these operations. + +import { prisma } from "./prisma"; + +type Token = { sub?: string }; +type Account = { provider: string; providerAccountId: string }; +type Profile = { email?: string }; +type User = { id: number; email: string }; + +export const authOptions = { + callbacks: { + async signIn({ user, account, profile }: { user: User; account: Account; profile: Profile }) { + // Authentication-time mutation: record provider linkage on the + // authenticated user. Not a tenant-scoped resource lookup. + await prisma.user.update({ + where: { id: user.id }, + data: { lastSignInProvider: account.provider }, + }); + return true; + }, + + async session({ session, user, token }: { session: any; user: User; token: Token }) { + // Identity-resolution read against `user.id` / `token.sub`. + const existingUser = await prisma.user.findUnique({ where: { id: user.id } }); + if (!existingUser) return session; + const profile = await prisma.profile.findUnique({ where: { userId: existingUser.id } }); + session.user = { ...session.user, profileId: profile?.id }; + return session; + }, + + async jwt({ token, user, account }: { token: Token; user?: User; account?: Account }) { + if (user) { + const dbUser = await prisma.user.findUnique({ where: { id: user.id } }); + if (dbUser) { + token.sub = String(dbUser.id); + } + } + return token; + }, + + async authorize(credentials: { email: string; password: string }) { + // Credentials-provider authorize: looks up the user by email and + // verifies the password. Authentication boundary, not foreign-id + // targeting. + const user = await prisma.user.findUnique({ where: { email: credentials.email } }); + if (!user) return null; + return { id: user.id, email: user.email }; + }, + }, +}; diff --git a/tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/expectations.json b/tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/expectations.json new file mode 100644 index 00000000..99a651e3 --- /dev/null +++ b/tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "js.auth.missing_ownership_check" }, + { "id_prefix": "ts.auth.missing_ownership_check" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/page.tsx b/tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/page.tsx new file mode 100644 index 00000000..5be0bea9 --- /dev/null +++ b/tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/page.tsx @@ -0,0 +1,103 @@ +// FP guard for cal.com-shape post-fetch ownership equality checks +// in JS/TS Next.js page handlers. +// +// The handler fetches a row by id, then verifies the row's owner field +// matches the session user via strict-inequality. Failure calls the +// framework denial helper (notFound, redirect, forbidden, unauthorized) +// to terminate the request. This shape is canonical post-fetch +// authorization across cal.com and other Next.js codebases. +// +// Pre-fix the engine missed this for three reasons: +// 1. detect_ownership_equality_check only ran for if_expression (Rust), +// not if_statement (JS/TS/Java/Python/Go/PHP). +// 2. is_ne / is_eq matched "!=" / "==" but not the JS/TS strict variants +// "!==" / "===". +// 3. branch_has_early_exit only matched return / throw. notFound() and +// similar Next.js denial helpers are call_expression and were +// invisible. +// 4. collect_row_population only read pattern/left, missing the +// JS/TS variable_declarator name field. +// +// Each shape below exercises one column of the matrix. + +import { notFound, redirect, unauthorized, forbidden } from "next/navigation"; + +declare class WebhookRepository { + static getInstance(): WebhookRepository; + findByWebhookId(id: string | undefined): Promise<{ userId: number }>; +} + +declare function getServerSession(): Promise<{ user?: { id: number } } | null>; + +// 1. notFound() denial in if_statement with !== strict inequality. +export async function pageNotFound({ params }: { params: { id: string } }) { + const session = await getServerSession(); + if (!session?.user?.id) return null; + const repo = WebhookRepository.getInstance(); + const webhook = await repo.findByWebhookId(params.id); + if (webhook.userId !== session.user.id) { + notFound(); + } + return webhook; +} + +// 2. redirect() denial. +export async function pageRedirect({ params }: { params: { id: string } }) { + const session = await getServerSession(); + if (!session?.user?.id) return null; + const repo = WebhookRepository.getInstance(); + const webhook = await repo.findByWebhookId(params.id); + if (webhook.userId !== session.user.id) { + redirect("/login"); + } + return webhook; +} + +// 3. unauthorized() denial. +export async function pageUnauthorized({ params }: { params: { id: string } }) { + const session = await getServerSession(); + if (!session?.user?.id) return null; + const repo = WebhookRepository.getInstance(); + const webhook = await repo.findByWebhookId(params.id); + if (webhook.userId !== session.user.id) { + unauthorized(); + } + return webhook; +} + +// 4. forbidden() denial. +export async function pageForbidden({ params }: { params: { id: string } }) { + const session = await getServerSession(); + if (!session?.user?.id) return null; + const repo = WebhookRepository.getInstance(); + const webhook = await repo.findByWebhookId(params.id); + if (webhook.userId !== session.user.id) { + forbidden(); + } + return webhook; +} + +// 5. throw on the failure branch. +export async function pageThrow({ params }: { params: { id: string } }) { + const session = await getServerSession(); + if (!session?.user?.id) return null; + const repo = WebhookRepository.getInstance(); + const webhook = await repo.findByWebhookId(params.id); + if (webhook.userId !== session.user.id) { + throw new Error("not authorized"); + } + return webhook; +} + +// 6. === inverted equality with else { notFound() }. +export async function pageEqElse({ params }: { params: { id: string } }) { + const session = await getServerSession(); + if (!session?.user?.id) return null; + const repo = WebhookRepository.getInstance(); + const webhook = await repo.findByWebhookId(params.id); + if (webhook.userId === session.user.id) { + return webhook; + } else { + notFound(); + } +} diff --git a/tests/fixtures/fp_guards/auth_trpc_handler_options/expectations.json b/tests/fixtures/fp_guards/auth_trpc_handler_options/expectations.json new file mode 100644 index 00000000..7229003c --- /dev/null +++ b/tests/fixtures/fp_guards/auth_trpc_handler_options/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "js.auth.missing_ownership_check" }, + { "id_prefix": "ts.auth.missing_ownership_check" } + ], + "noise_budget": { + "max_total_findings": 2, + "max_high_findings": 1 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/auth_trpc_handler_options/handler.ts b/tests/fixtures/fp_guards/auth_trpc_handler_options/handler.ts new file mode 100644 index 00000000..321d2152 --- /dev/null +++ b/tests/fixtures/fp_guards/auth_trpc_handler_options/handler.ts @@ -0,0 +1,62 @@ +// Cal.com-shaped TRPC handler: parameter is a destructured options +// alias (`{ ctx, input }: GetOptions`) where `GetOptions` is a local +// type alias whose `ctx.user` is typed `NonNullable`. +// The session-resolved `ctx.user.id` is the authenticated actor; +// composing it with `input.id` in a where-clause is the standard +// owner-eq pattern, NOT a foreign-id targeting flow. +// +// `collect_trpc_ctx_param` (in src/auth_analysis/extract/common.rs) +// must recognise the destructured `ctx` and add `ctx.user` to the +// per-unit `self_scoped_session_bases`, so the auth analyser +// suppresses `missing_ownership_check` on operations rooted at +// `ctx.user.id`. +// +// Marker text in the body of `GetOptions` is what +// `body_text_references_trpc_marker` keys on +// (`TrpcSessionUser`/`TRPCContext`/`ProtectedTRPCContext`/`TrpcContext`). + +import { prisma } from "./prisma"; + +type TrpcSessionUser = { id: number; email: string }; + +type GetOptions = { + ctx: { user: NonNullable }; + input: { id: number }; +}; + +type ListOptions = { + ctx: { user: NonNullable }; + input: { teamId: number }; +}; + +export const handleGet = async ({ ctx, input }: GetOptions) => { + return prisma.booking.findFirst({ + where: { id: input.id, userId: ctx.user.id }, + }); +}; + +export const handleList = async ({ ctx, input }: ListOptions) => { + return prisma.team.findMany({ + where: { id: input.teamId, ownerId: ctx.user.id }, + }); +}; + +// Renamed destructure form: `ctx: c` aliases the trpc context. +type DeleteOptions = { + ctx: { user: NonNullable }; + input: { id: number }; +}; + +export const handleDelete = async ({ ctx: c, input }: DeleteOptions) => { + return prisma.booking.delete({ + where: { id: input.id, userId: c.user.id }, + }); +}; + +// Plain identifier form: `(opts: GetOptions)` -> `opts.ctx.user`. +export const handleUpdate = async (opts: GetOptions) => { + return prisma.booking.update({ + where: { id: opts.input.id, userId: opts.ctx.user.id }, + data: { lastSeenAt: new Date() }, + }); +}; diff --git a/tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/DatabaseDriverLoader.java b/tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/DatabaseDriverLoader.java new file mode 100644 index 00000000..e2e0fed5 --- /dev/null +++ b/tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/DatabaseDriverLoader.java @@ -0,0 +1,23 @@ +package org.example.util; + +public class DatabaseDriverLoader { + private static final String MYSQL_DRIVER = "com.mysql.cj.jdbc.Driver"; + private static final String POSTGRESQL_DRIVER = "org.postgresql.Driver"; + private static final String H2_DRIVER = "org.h2.Driver"; + private static final int CONNECT_TIMEOUT_MS = 5000; + + public static void loadDriver(String connectionUrl) throws ClassNotFoundException { + if (connectionUrl.contains("jdbc:mysql")) { + Class.forName(MYSQL_DRIVER); + } else if (connectionUrl.contains("jdbc:postgresql")) { + Class.forName(POSTGRESQL_DRIVER); + } else if (connectionUrl.contains("jdbc:h2")) { + Class.forName(H2_DRIVER); + } + } + + public static int timeoutMs() { + Thread.sleep(CONNECT_TIMEOUT_MS); + return CONNECT_TIMEOUT_MS; + } +} diff --git a/tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/expectations.json b/tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/expectations.json new file mode 100644 index 00000000..0a1237fb --- /dev/null +++ b/tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/expectations.json @@ -0,0 +1,16 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" } + ], + "noise_budget": { + "max_total_findings": 6, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/DbSession.java b/tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/DbSession.java new file mode 100644 index 00000000..5477ac69 --- /dev/null +++ b/tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/DbSession.java @@ -0,0 +1,15 @@ +package org.openmrs.api.db.hibernate; + +public class DbSession { + public Object createQuery(String queryString) { + return getSession().createQuery(queryString); + } + + public Object createSQLQuery(String queryString, Class type) { + return getSession().createNativeQuery(queryString, type); + } + + public Object createCriteria(Class persistentClass) { + return getSession().getCriteriaBuilder().createQuery(persistentClass); + } +} diff --git a/tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/expectations.json b/tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/expectations.json new file mode 100644 index 00000000..011db0b9 --- /dev/null +++ b/tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/expectations.json @@ -0,0 +1,16 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/ChangeSet.java b/tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/ChangeSet.java new file mode 100644 index 00000000..8a93c00e --- /dev/null +++ b/tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/ChangeSet.java @@ -0,0 +1,45 @@ +package org.openmrs.util.databasechange; + +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.ResultSet; + +public class ChangeSet { + private int getInt(JdbcConnection connection, String sql) { + Statement stmt = null; + int result = 0; + try { + stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery(sql); + if (rs.next()) { + result = rs.getInt(1); + } + } catch (SQLException e) { + // ignored for fixture + } finally { + if (stmt != null) { + try { + stmt.close(); + } catch (SQLException e) { + } + } + } + return result; + } + + private void runUpdate(JdbcConnection connection, String sql) throws SQLException { + Statement statement = connection.createStatement(); + statement.executeUpdate(sql); + statement.close(); + } + + private void runChained(JdbcConnection connection, String sql) throws SQLException { + Statement stmt = connection.unwrap().createStatement(); + stmt.executeQuery(sql); + } + + interface JdbcConnection { + Statement createStatement() throws SQLException; + JdbcConnection unwrap() throws SQLException; + } +} diff --git a/tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/expectations.json b/tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/expectations.json new file mode 100644 index 00000000..4eeecf95 --- /dev/null +++ b/tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/expectations.json @@ -0,0 +1,16 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" } + ], + "noise_budget": { + "max_total_findings": 5, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/file_level_const_scalars_xlang/expectations.json b/tests/fixtures/fp_guards/file_level_const_scalars_xlang/expectations.json new file mode 100644 index 00000000..d468a81b --- /dev/null +++ b/tests/fixtures/fp_guards/file_level_const_scalars_xlang/expectations.json @@ -0,0 +1,18 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" }, + { "id_prefix": "py.cmdi" }, + { "id_prefix": "py.sqli" } + ], + "noise_budget": { + "max_total_findings": 4, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 2000, + "max_ms_index_cold": 2500, + "max_ms_index_warm": 1000, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_go_package_const.go b/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_go_package_const.go new file mode 100644 index 00000000..91230b37 --- /dev/null +++ b/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_go_package_const.go @@ -0,0 +1,16 @@ +// Package-level scalar constant flows into a db.Exec sink. The argument +// resolves to a `const DriverName = "postgres"` declaration at file scope, +// so the SQL string is compile-time bounded and cfg-unguarded-sink must +// not fire. +package main + +import ( + "database/sql" +) + +const DriverName = "postgres" +const QueryLimit = 100 + +func setup(db *sql.DB) { + db.Exec(DriverName) +} diff --git a/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_python_module_const.py b/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_python_module_const.py new file mode 100644 index 00000000..9c4d0f90 --- /dev/null +++ b/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_python_module_const.py @@ -0,0 +1,19 @@ +"""Module-level scalar constant flows into a cmdi-shaped sink. + +`os.system(DEFAULT_CMD)` looks like a shell-injection sink but the argument +binds to a top-level string literal at file load time, so no attacker can +influence the value. The `py.cmdi.os_system` AST pattern and the structural +`cfg-unguarded-sink` rule both fire without file-level const recognition; +the file-scalars suppression closes both. +""" + +import os + +DEFAULT_CMD = "ls -la /tmp" +RETRIES = 3 +ENABLED = True + + +def run(): + os.system(DEFAULT_CMD) + os.popen(DEFAULT_CMD) diff --git a/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_rust_module_const.rs b/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_rust_module_const.rs new file mode 100644 index 00000000..7917bfa1 --- /dev/null +++ b/tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_rust_module_const.rs @@ -0,0 +1,12 @@ +// Module-level `const` scalar binds the first arg of a Command::new call. +// Without file-level scalar recognition the SSA path treats COMMAND as a +// free identifier and the structural rule over-fires. + +use std::process::Command; + +const COMMAND: &str = "ls"; +const ARG_COUNT: i32 = 2; + +pub fn run() { + let _ = Command::new(COMMAND).output(); +} diff --git a/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/expectations.json b/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/expectations.json new file mode 100644 index 00000000..743c3146 --- /dev/null +++ b/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/expectations.json @@ -0,0 +1,24 @@ +{ + "required_findings": [ + { + "id_prefix": "taint-unsanitised-flow", + "min_count": 1 + } + ], + "forbidden_findings": [ + { + "id_prefix": "taint-unsanitised-flow", + "file_glob": "**/server.go" + } + ], + "noise_budget": { + "max_total_findings": 5, + "max_high_findings": 2 + }, + "performance_expectations": { + "max_ms_no_index": 1500, + "max_ms_index_cold": 2000, + "max_ms_index_warm": 1000, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/response_writer.go b/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/response_writer.go new file mode 100644 index 00000000..3f21650b --- /dev/null +++ b/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/response_writer.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "net/http" +) + +// renderResponse keeps the canonical XSS sink shape: tainted user input +// flows into `http.ResponseWriter` via `fmt.Fprintf`. This MUST still fire +// `taint-unsanitised-flow` with HTML_ESCAPE caps, the writer-aware +// suppression must not over-clear when the writer IS a response stream. +func renderResponse(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + fmt.Fprintf(w, "

    hello %s

    ", name) +} diff --git a/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/server.go b/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/server.go new file mode 100644 index 00000000..38ffa90c --- /dev/null +++ b/tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/server.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" +) + +// Package-level non-response writers, mirroring gin's `mode.go` declarations. +// These are `io.Writer` aliases for `os.Stdout` / `os.Stderr` and are NOT +// HTTP response sinks. +var ( + DefaultWriter io.Writer = os.Stdout + DefaultErrorWriter io.Writer = os.Stderr +) + +// debugPrintError is the gin-style logging helper. It writes to a +// package-level non-response writer. When `err` returns from +// `http.Server.ListenAndServe`, the value is stdlib state, not user input, +// but the engine should still suppress HTML_ESCAPE on the writer-aware +// branch even if a tainted value reached the writer. +func debugPrintError(err error) { + if err != nil { + fmt.Fprintf(DefaultErrorWriter, "[debug] [ERROR] %v\n", err) + } +} + +// debugPrint writes formatted debug output to stdout-aliased DefaultWriter. +func debugPrint(format string, values ...any) { + fmt.Fprintf(DefaultWriter, "[debug] "+format, values...) +} + +// runServer mirrors gin's `Engine.Run` shape: a deferred call that pipes +// the named-return error into the gin-style debug logger. +func runServer(addr string) (err error) { + defer func() { debugPrintError(err) }() + server := &http.Server{Addr: addr} + err = server.ListenAndServe() + return +} + +// stdlibLog is the equivalent shape using stdlib stderr directly. +func stdlibLog(err error) { + fmt.Fprintf(os.Stderr, "boot error: %v\n", err) +} + +// discardLog drops formatted output entirely. Always benign. +func discardLog(payload string) { + fmt.Fprintf(io.Discard, "ignored: %s\n", payload) +} diff --git a/tests/fixtures/fp_guards/go_http_redirect_self_request/expectations.json b/tests/fixtures/fp_guards/go_http_redirect_self_request/expectations.json new file mode 100644 index 00000000..f70b6989 --- /dev/null +++ b/tests/fixtures/fp_guards/go_http_redirect_self_request/expectations.json @@ -0,0 +1,24 @@ +{ + "required_findings": [ + { + "id_prefix": "taint-open-redirect", + "min_count": 1 + } + ], + "forbidden_findings": [ + { + "id_prefix": "taint-open-redirect", + "file_glob": "**/server.go" + } + ], + "noise_budget": { + "max_total_findings": 8, + "max_high_findings": 2 + }, + "performance_expectations": { + "max_ms_no_index": 1500, + "max_ms_index_cold": 2000, + "max_ms_index_warm": 1000, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/go_http_redirect_self_request/handler.go b/tests/fixtures/fp_guards/go_http_redirect_self_request/handler.go new file mode 100644 index 00000000..cce903b1 --- /dev/null +++ b/tests/fixtures/fp_guards/go_http_redirect_self_request/handler.go @@ -0,0 +1,25 @@ +package main + +import ( + "net/http" +) + +// Canonical OPEN_REDIRECT vulnerability: redirect destination is fully +// attacker-controlled via `r.FormValue`. This MUST still fire +// `taint-open-redirect` after the same-request self-redirect suppression, +// otherwise the gate over-clears. +func openRedirectVuln(w http.ResponseWriter, r *http.Request) { + target := r.FormValue("redirect") + http.Redirect(w, r, target, http.StatusFound) +} + +// Cross-request shape: a `*http.Request` from `http.NewRequest` (proxy +// request) is NOT the inbound request; redirecting to its URL would land +// off-origin if the URL was attacker-influenced. The same-request gate +// only fires when arg 1 (the redirect's *Request) and the URL chain root +// match by name, so this stays in scope of the OPEN_REDIRECT detector. +func proxyRedirect(w http.ResponseWriter, r *http.Request) { + target := r.FormValue("upstream") + other, _ := http.NewRequest("GET", target, nil) + http.Redirect(w, r, other.URL.String(), http.StatusFound) +} diff --git a/tests/fixtures/fp_guards/go_http_redirect_self_request/server.go b/tests/fixtures/fp_guards/go_http_redirect_self_request/server.go new file mode 100644 index 00000000..57c7c547 --- /dev/null +++ b/tests/fixtures/fp_guards/go_http_redirect_self_request/server.go @@ -0,0 +1,37 @@ +package main + +import ( + "net/http" +) + +// Same-request self-redirect via the canonical `*url.URL.String()` shape. +// gin's `redirectTrailingSlash` / `redirectFixedPath` / `redirectRequest` +// helpers all bottom out here: scheme/host echo the inbound request, only +// the path can be edited. MUST suppress `taint-open-redirect`. +func redirectTrailingSlash(r *http.Request, w http.ResponseWriter) { + r.URL.Path = r.URL.Path + "/" + rURL := r.URL.String() + http.Redirect(w, r, rURL, http.StatusMovedPermanently) +} + +// Same-request self-redirect via the `*url.URL.Path` field accessor. No +// method-call parens; SSA encodes this as a flat callee text. MUST +// suppress. +func redirectPath(r *http.Request, w http.ResponseWriter) { + target := r.URL.Path + http.Redirect(w, r, target, http.StatusFound) +} + +// Same-request self-redirect via the `*url.URL.RequestURI()` accessor. +// MUST suppress. +func redirectRequestURI(w http.ResponseWriter, r *http.Request) { + target := r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusFound) +} + +// Same-request self-redirect via the `*url.URL.EscapedPath()` accessor. +// MUST suppress. +func redirectEscapedPath(w http.ResponseWriter, r *http.Request) { + target := r.URL.EscapedPath() + http.Redirect(w, r, target, http.StatusFound) +} diff --git a/tests/fixtures/fp_guards/java_safe_map_field_lookup/SafeAllowlist.java b/tests/fixtures/fp_guards/java_safe_map_field_lookup/SafeAllowlist.java new file mode 100644 index 00000000..29e2ad27 --- /dev/null +++ b/tests/fixtures/fp_guards/java_safe_map_field_lookup/SafeAllowlist.java @@ -0,0 +1,35 @@ +// FP guard fixture: a final class field initialised with `Map.of(literal, +// literal, ...)` is an immutable allowlist whose `.get(taintedKey)` result +// is bounded to the literal value set. Engine must NOT surface +// `taint-header-injection` on the safe handler. +// +// Mirrors CVE-2017-12629 (Apache Solr) patched counterpart: a pre-defined +// transformer name table prevents arbitrary downstream sinks from being +// reached by user-controlled data. +package com.example.fixtures; + +import java.util.Map; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class SafeAllowlist extends HttpServlet { + private static final Map TRANSFORMERS = Map.of( + "identity", "classpath:xslt/identity.xsl", + "summary", "classpath:xslt/summary.xsl" + ); + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws Exception { + String requested = req.getParameter("tr"); + String resolved = TRANSFORMERS.get(requested); + if (resolved == null) { + res.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + // Safe: `resolved` is one of the two literal values above; neither + // contains CR/LF, so no header-injection sink can be reached. + res.setHeader("X-Solr-Transform", resolved); + res.setStatus(HttpServletResponse.SC_NO_CONTENT); + } +} diff --git a/tests/fixtures/fp_guards/java_safe_map_field_lookup/UnsafeBypass.java b/tests/fixtures/fp_guards/java_safe_map_field_lookup/UnsafeBypass.java new file mode 100644 index 00000000..bd103f92 --- /dev/null +++ b/tests/fixtures/fp_guards/java_safe_map_field_lookup/UnsafeBypass.java @@ -0,0 +1,19 @@ +// Recall guard counterpart: when the field initializer is NOT a recognised +// safe `Map.of(literal, literal, ...)` shape (here, the value position is +// constructed dynamically from a separate request parameter), the engine +// must still surface the header-injection flow. +package com.example.fixtures; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class UnsafeBypass extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws Exception { + // Pure passthrough: tainted parameter flows directly to the header + // value with no allowlist gate. + String value = req.getParameter("v"); + res.setHeader("X-Echo", value); + } +} diff --git a/tests/fixtures/fp_guards/java_safe_map_field_lookup/expectations.json b/tests/fixtures/fp_guards/java_safe_map_field_lookup/expectations.json new file mode 100644 index 00000000..e78ccb15 --- /dev/null +++ b/tests/fixtures/fp_guards/java_safe_map_field_lookup/expectations.json @@ -0,0 +1,29 @@ +{ + "required_findings": [ + { + "id_prefix": "taint-header-injection", + "file_glob": "**/UnsafeBypass.java", + "min_count": 1 + } + ], + "forbidden_findings": [ + { + "id_prefix": "taint-header-injection", + "file_glob": "**/SafeAllowlist.java" + }, + { + "id_prefix": "taint-unsanitised-flow", + "file_glob": "**/SafeAllowlist.java" + } + ], + "noise_budget": { + "max_total_findings": 4, + "max_high_findings": 2 + }, + "performance_expectations": { + "max_ms_no_index": 1500, + "max_ms_index_cold": 2000, + "max_ms_index_warm": 1000, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/expectations.json b/tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/expectations.json new file mode 100644 index 00000000..37a73d89 --- /dev/null +++ b/tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "taint-unsanitised-flow" }, + { "id_prefix": "cfg-unguarded-sink" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1500, + "max_ms_index_cold": 2000, + "max_ms_index_warm": 800, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/page.tsx b/tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/page.tsx new file mode 100644 index 00000000..72e45eff --- /dev/null +++ b/tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/page.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import express, { Request, Response } from 'express'; + +const app = express(); + +// Safe: JSX text-content `{bio}` is auto-escaped by React's renderer, +// so HTML metacharacters in the user's bio cannot inject HTML. +app.get('/bio', (req: Request, res: Response) => { + const bio = req.query.bio as string; + const page =
    {bio}
    ; + res.send(page); +}); + +// Safe: nested text-content interpolation across multiple elements. +app.get('/profile', (req: Request, res: Response) => { + const name = req.query.name as string; + const page = ( +
    +

    {name}

    +

    Profile for {name}

    +
    + ); + res.send(page); +}); diff --git a/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterMySQL.php b/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterMySQL.php new file mode 100644 index 00000000..bf607010 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterMySQL.php @@ -0,0 +1,38 @@ +conn->getQueryBuilder()`, and the executeStatement +// first-arg wraps `$builder->getSQL()` with a `preg_replace` rewrite that +// patches the leading verb (`INSERT` -> `INSERT IGNORE`) without weaving +// any user payload. The structural cfg-unguarded-sink rule had previously +// fired because `arg_callees[0]` is `preg_replace`, not a DBAL accessor. + +namespace OC\DB; + +class Connection +{ + public function getQueryBuilder() { return new \OC\DB\QueryBuilder\QueryBuilder(); } + public function executeStatement(string $sql, array $params = [], array $types = []): int { return 0; } +} + +class AdapterMySQL +{ + /** @var Connection */ + protected $conn; + + public function insertIgnoreConflict(string $table, array $values): int + { + $builder = $this->conn->getQueryBuilder(); + $builder->insert($table); + foreach ($values as $key => $value) { + $builder->setValue($key, $builder->createNamedParameter($value)); + } + + $res = $this->conn->executeStatement( + preg_replace('/^INSERT/i', 'INSERT IGNORE', $builder->getSQL()), + $builder->getParameters(), + $builder->getParameterTypes() + ); + + return $res; + } +} diff --git a/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterSqlite.php b/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterSqlite.php new file mode 100644 index 00000000..74c75ee4 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterSqlite.php @@ -0,0 +1,28 @@ +conn->getQueryBuilder()`, and the executeStatement +// first-arg appends a constant `' ON CONFLICT DO NOTHING'` to the +// `$builder->getSQL()` accessor. No user payload, no taint. + +namespace OC\DB; + +class AdapterSqlite +{ + /** @var Connection */ + protected $conn; + + public function insertIgnoreConflict(string $table, array $values): int + { + $builder = $this->conn->getQueryBuilder(); + $builder->insert($table); + foreach ($values as $key => $value) { + $builder->setValue($key, $builder->createNamedParameter($value)); + } + + return $this->conn->executeStatement( + $builder->getSQL() . ' ON CONFLICT DO NOTHING', + $builder->getParameters(), + $builder->getParameterTypes() + ); + } +} diff --git a/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/expectations.json b/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/expectations.json new file mode 100644 index 00000000..b4bacb25 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_builder_compose_sql/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" }, + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/php_dbal_builder_get_sql/QueryBuilder.php b/tests/fixtures/fp_guards/php_dbal_builder_get_sql/QueryBuilder.php new file mode 100644 index 00000000..ceb819b0 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_builder_get_sql/QueryBuilder.php @@ -0,0 +1,46 @@ +getSQL()`. Real +// nextcloud `lib/private/DB/QueryBuilder/QueryBuilder.php` implements +// the terminal `executeQuery` / `executeStatement` overloads by passing +// `$this->getSQL()` plus `$this->getParameters()` to a connection. The +// connection's `executeQuery` has a flat overload that takes a SQL +// string, so the structural cfg-unguarded-sink rule fires on the +// receiver-typed call. A `getSQL` first arg proves the SQL was built +// via the parameterised builder API and the structural finding is noise. + +namespace OC\DB\QueryBuilder; + +class QueryBuilder +{ + private $connection; + + public function executeQuery(?\IDBConnection $connection = null) + { + if (!$connection) { + $connection = $this->connection; + } + return $connection->executeQuery( + $this->getSQL(), + $this->getParameters(), + $this->getParameterTypes(), + ); + } + + public function executeStatement(?\IDBConnection $connection = null): int + { + if (!$connection) { + $connection = $this->connection; + } + return $connection->executeStatement( + $this->getSQL(), + $this->getParameters(), + $this->getParameterTypes(), + ); + } + + public function getSQL(): string { return ''; } + public function getParameters(): array { return []; } + public function getParameterTypes(): array { return []; } +} diff --git a/tests/fixtures/fp_guards/php_dbal_builder_get_sql/expectations.json b/tests/fixtures/fp_guards/php_dbal_builder_get_sql/expectations.json new file mode 100644 index 00000000..b4bacb25 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_builder_get_sql/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" }, + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/Propagator.php b/tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/Propagator.php new file mode 100644 index 00000000..ecbc5556 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/Propagator.php @@ -0,0 +1,58 @@ +connection->getQueryBuilder()` for the SELECT +// FOR UPDATE row lock then chains `->select(...)->from(...)->where(...) +// ->orderBy(...)->forUpdate()->executeQuery()`. + +namespace OC\Files\Cache; + +class Propagator +{ + private $connection; + private $storage; + + const MAX_RETRIES = 5; + + public function propagateChange(array $parents, int $time, int $sizeDifference = 0): void + { + $parentHashes = array_map('md5', $parents); + sort($parentHashes); + + $builder = $this->connection->getQueryBuilder(); + $hashParams = array_map(static fn (string $hash) => $builder->expr()->literal($hash), $parentHashes); + + $builder->update('filecache') + ->set('mtime', $builder->func()->greatest('mtime', $builder->createNamedParameter($time))) + ->where($builder->expr()->eq('storage', $builder->createNamedParameter('x'))) + ->andWhere($builder->expr()->in('path_hash', $hashParams)); + + for ($i = 0; $i < self::MAX_RETRIES; $i++) { + try { + if ($this->connection->getDatabaseProvider() !== 'sqlite') { + $this->connection->beginTransaction(); + $forUpdate = $this->connection->getQueryBuilder(); + $forUpdate->select('fileid') + ->from('filecache') + ->where($forUpdate->expr()->eq('storage', $forUpdate->createNamedParameter('x'))) + ->andWhere($forUpdate->expr()->in('path_hash', $hashParams)) + ->orderBy('path_hash') + ->forUpdate() + ->executeQuery(); + $builder->executeStatement(); + $this->connection->commit(); + } else { + $builder->executeStatement(); + } + break; + } catch (\Exception $e) { + } + } + } +} diff --git a/tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/expectations.json b/tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/expectations.json new file mode 100644 index 00000000..b4bacb25 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" }, + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/Migration.php b/tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/Migration.php new file mode 100644 index 00000000..2f4d6a31 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/Migration.php @@ -0,0 +1,36 @@ +dbc->executeStatement($sql)`. The flat +// `executeStatement` SQL_QUERY sink rule fires structurally; the +// suppression must walk back to the local's defining call to recognise +// the safe accessor. + +namespace OC\Migrations; + +class TruncateBackupTableMigration +{ + private $dbc; + + public function postSchemaChange(\IOutput $output, \Closure $schemaClosure, array $options): void + { + $schema = $schemaClosure(); + if ($schema->hasTable('ldap_group_mapping_backup')) { + $sql = $this->dbc->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*ldap_group_mapping_backup`', false); + $this->dbc->executeStatement($sql); + } + } + + public function preInline(\IOutput $output, \Closure $schemaClosure, array $options): void + { + // Direct method-call arg variant. + $this->dbc->executeStatement( + $this->dbc->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*tmp`', false) + ); + } +} diff --git a/tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/expectations.json b/tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/expectations.json new file mode 100644 index 00000000..b4bacb25 --- /dev/null +++ b/tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" }, + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/php_doctrine_querybuilder/App.php b/tests/fixtures/fp_guards/php_doctrine_querybuilder/App.php new file mode 100644 index 00000000..784cedb1 --- /dev/null +++ b/tests/fixtures/fp_guards/php_doctrine_querybuilder/App.php @@ -0,0 +1,61 @@ +db->getQueryBuilder(); + $qb->select(['id', 'deleted_at']) + ->from('calendars') + ->where($qb->expr()->isNotNull('deleted_at')) + ->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($deletedBefore))); + $result = $qb->executeQuery(); + $calendars = []; + while (($row = $result->fetchAssociative()) !== false) { + $calendars[] = [ + 'id' => (int) $row['id'], + 'deleted_at' => (int) $row['deleted_at'], + ]; + } + $result->closeCursor(); + + return $calendars; + } + + public function deleteExpired(int $expiry): int + { + $qb = $this->db->getQueryBuilder(); + $qb->delete('calendars') + ->where($qb->expr()->lt('deleted_at', $qb->createNamedParameter($expiry))); + + return $qb->executeStatement(); + } + + public function restoreCalendar(int $id): void + { + // Closure-wrapped variant: the inner $update->executeStatement() + // is reached via find_classifiable_inner_call descent so the + // CFG node represents the outer atomic() call but the callee + // text resolves to update.executeStatement. Receiver "update" + // matches the verb-named builder allowlist; the structural + // suppression must still fire. + $this->atomic(function () use ($id): void { + $qb = $this->db->getQueryBuilder(); + $update = $qb->update('calendars') + ->set('deleted_at', $qb->createNamedParameter(null)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + $update->executeStatement(); + }); + } +} diff --git a/tests/fixtures/fp_guards/php_doctrine_querybuilder/expectations.json b/tests/fixtures/fp_guards/php_doctrine_querybuilder/expectations.json new file mode 100644 index 00000000..b4bacb25 --- /dev/null +++ b/tests/fixtures/fp_guards/php_doctrine_querybuilder/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" }, + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/php_drupal_prepare_statement/App.php b/tests/fixtures/fp_guards/php_drupal_prepare_statement/App.php new file mode 100644 index 00000000..524bf8c6 --- /dev/null +++ b/tests/fixtures/fp_guards/php_drupal_prepare_statement/App.php @@ -0,0 +1,40 @@ +execute($values, $opts)` with values shipped +// out of band. The structural cfg-unguarded-sink rule must treat +// `prepareStatement` as a SQL_QUERY sanitizer the same way it treats +// `prepare`, otherwise every Drupal Query subclass surfaces an FP at +// the execute call. + +class DrupalQueryWrapper +{ + private $connection; + private $queryOptions; + + public function execute() + { + $stmt = $this->connection->prepareStatement((string) $this, $this->queryOptions, true); + try { + $stmt->execute([], $this->queryOptions); + return $stmt->rowCount(); + } catch (\Exception $e) { + $this->connection->exceptionHandler()->handleExecutionException($e, $stmt, [], $this->queryOptions); + } + + return null; + } + + public function executeUpdate($values) + { + $stmt = $this->connection->prepareStatement((string) $this, $this->queryOptions, true); + try { + $stmt->execute($values, $this->queryOptions); + return $stmt->rowCount(); + } catch (\Exception $e) { + $this->connection->exceptionHandler()->handleExecutionException($e, $stmt, $values, $this->queryOptions); + } + + return null; + } +} diff --git a/tests/fixtures/fp_guards/php_drupal_prepare_statement/expectations.json b/tests/fixtures/fp_guards/php_drupal_prepare_statement/expectations.json new file mode 100644 index 00000000..b4bacb25 --- /dev/null +++ b/tests/fixtures/fp_guards/php_drupal_prepare_statement/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" }, + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/MySqlTools.php b/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/MySqlTools.php new file mode 100644 index 00000000..d0aad47e --- /dev/null +++ b/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/MySqlTools.php @@ -0,0 +1,39 @@ + 'ON']; + if (!$this->isMariaDBWithLargePrefix($connection)) { + $variables['innodb_file_format'] = 'Barracuda'; + $variables['innodb_large_prefix'] = 'ON'; + } + + foreach ($variables as $var => $val) { + $result = $connection->executeQuery("SHOW VARIABLES LIKE '$var'"); + $row = $result->fetch(); + $result->closeCursor(); + if ($row === false) { + return false; + } + if (strcasecmp($row['Value'], $val) !== 0) { + return false; + } + } + return true; + } + + protected function isMariaDBWithLargePrefix($connection): bool + { + return false; + } +} diff --git a/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/UnsafeBypass.php b/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/UnsafeBypass.php new file mode 100644 index 00000000..9f960890 --- /dev/null +++ b/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/UnsafeBypass.php @@ -0,0 +1,17 @@ + $val) { + $connection->executeQuery("SHOW VARIABLES LIKE '$var'"); + } + return true; + } +} diff --git a/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/expectations.json b/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/expectations.json new file mode 100644 index 00000000..fa2e334d --- /dev/null +++ b/tests/fixtures/fp_guards/php_foreach_safe_literal_keys/expectations.json @@ -0,0 +1,28 @@ +{ + "required_findings": [ + { + "id_prefix": "cfg-unguarded-sink", + "min_count": 1 + } + ], + "forbidden_findings": [ + { + "id_prefix": "cfg-unguarded-sink", + "file_glob": "**/MySqlTools.php" + }, + { + "id_prefix": "taint-unsanitised-flow", + "file_glob": "**/MySqlTools.php" + } + ], + "noise_budget": { + "max_total_findings": 2, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/php_thin_method_wrapper/Connection.php b/tests/fixtures/fp_guards/php_thin_method_wrapper/Connection.php new file mode 100644 index 00000000..08eeecd1 --- /dev/null +++ b/tests/fixtures/fp_guards/php_thin_method_wrapper/Connection.php @@ -0,0 +1,47 @@ +inner->executeQuery`, +// Drupal `Connection::query` thin overrides per driver. Because every +// argument to the inner call is the wrapper's own parameter, the +// `cfg-unguarded-sink` structural rule has zero signal at the wrapper +// site; the real signal is at callers, which the taint engine handles. + +namespace OC\DB; + +class Connection +{ + private $inner; + + public function executeUpdate(string $sql, array $params = [], array $types = []): int + { + return $this->executeStatement($sql, $params, $types); + } + + public function executeStatement($sql, array $params = [], array $types = []): int + { + return 0; + } +} + +class ConnectionAdapter +{ + private $inner; + + public function executeQuery(string $sql, array $params = [], $types = []) + { + return new ResultAdapter($this->inner->executeQuery($sql, $params, $types)); + } + + public function executeStatement($sql, array $params = [], array $types = []): int + { + return $this->inner->executeStatement($sql, $params, $types); + } +} + +class ResultAdapter +{ + public function __construct($inner) {} +} diff --git a/tests/fixtures/fp_guards/php_thin_method_wrapper/expectations.json b/tests/fixtures/fp_guards/php_thin_method_wrapper/expectations.json new file mode 100644 index 00000000..b4bacb25 --- /dev/null +++ b/tests/fixtures/fp_guards/php_thin_method_wrapper/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "cfg-unguarded-sink" }, + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/RoundtripTest.php b/tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/RoundtripTest.php new file mode 100644 index 00000000..4beafde5 --- /dev/null +++ b/tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/RoundtripTest.php @@ -0,0 +1,61 @@ + 1, 'b' => 2]); + $this->assertSame(['a' => 1, 'b' => 2], unserialize($blob)); + } + + public function testNestedArrayLiteralExpected(): void + { + $blob = serialize([['k' => 'v'], 'tail']); + $this->assertEquals([['k' => 'v'], 'tail'], unserialize($blob)); + } + + public function testScalarStringExpected(): void + { + $blob = 's:5:"hello";'; + $this->assertSame('hello', unserialize($blob)); + } + + public function testScalarIntegerExpected(): void + { + $blob = 'i:42;'; + $this->assertEquals(42, unserialize($blob)); + } + + public function testNullExpected(): void + { + $blob = 'N;'; + $this->assertNull(unserialize($blob)); + } + + public function testStaticCallScopeExpected(): void + { + $blob = serialize(['x']); + static::assertSame(['x'], unserialize($blob)); + } + + public function testSelfCallScopeExpected(): void + { + $blob = serialize(['y']); + self::assertEquals(['y'], unserialize($blob)); + } + + public function testCaseInsensitiveAssertionVerb(): void + { + $blob = serialize([true, false]); + $this->AssertSame([true, false], unserialize($blob)); + } +} diff --git a/tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/expectations.json b/tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/expectations.json new file mode 100644 index 00000000..03b5d727 --- /dev/null +++ b/tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/expectations.json @@ -0,0 +1,16 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "php.deser.unserialize" } + ], + "noise_budget": { + "max_total_findings": 2, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/python_deser_in_pytest_assert/expectations.json b/tests/fixtures/fp_guards/python_deser_in_pytest_assert/expectations.json new file mode 100644 index 00000000..1fcb9088 --- /dev/null +++ b/tests/fixtures/fp_guards/python_deser_in_pytest_assert/expectations.json @@ -0,0 +1,18 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "py.deser.pickle_loads" }, + { "id_prefix": "py.deser.yaml_load" }, + { "id_prefix": "py.deser.shelve_open" } + ], + "noise_budget": { + "max_total_findings": 2, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/python_deser_in_pytest_assert/test_roundtrip.py b/tests/fixtures/fp_guards/python_deser_in_pytest_assert/test_roundtrip.py new file mode 100644 index 00000000..c9a798af --- /dev/null +++ b/tests/fixtures/fp_guards/python_deser_in_pytest_assert/test_roundtrip.py @@ -0,0 +1,87 @@ +"""pytest plain-`assert` round-trip patterns. Each shape bounds the +deserialized result to a literal expected; a poisoned blob produces a +different shape, the assertion fails loudly, no object-injection side +effect escapes the test boundary. Layer C4's pytest path suppresses +every finding here.""" + +import pickle +import yaml + + +def test_eq_literal(): + blob = pickle.dumps([1, 2, 3]) + assert pickle.loads(blob) == [1, 2, 3] + + +def test_neq_literal(): + blob = pickle.dumps([1]) + assert pickle.loads(blob) != [9, 9, 9] + + +def test_is_none(): + blob = pickle.dumps(None) + assert pickle.loads(blob) is None + + +def test_is_not_none(): + blob = pickle.dumps([1]) + assert pickle.loads(blob) is not None + + +def test_in_literal_tuple(): + blob = pickle.dumps(2) + assert pickle.loads(blob) in [1, 2, 3] + + +def test_not_in_literal(): + blob = pickle.dumps(99) + assert pickle.loads(blob) not in [1, 2, 3] + + +def test_truthy_assertion(): + blob = pickle.dumps([1]) + assert pickle.loads(blob) + + +def test_not_truthy_assertion(): + blob = pickle.dumps(None) + assert not pickle.loads(blob) + + +def test_isinstance_dict(): + blob = pickle.dumps({"a": 1}) + assert isinstance(pickle.loads(blob), dict) + + +def test_isinstance_tuple_of_types(): + blob = pickle.dumps([1]) + assert isinstance(pickle.loads(blob), (list, tuple)) + + +def test_paren_wrap(): + blob = pickle.dumps([1]) + assert (pickle.loads(blob) == [1]) + + +def test_assert_with_message(): + blob = pickle.dumps(1) + assert pickle.loads(blob) == 1, "round trip failed" + + +def test_yaml_load_truthy(): + assert yaml.load(b"key: 1") + + +def test_bool_wrap(): + blob = pickle.dumps([1]) + assert bool(pickle.loads(blob)) + + +def test_len_wrap(): + blob = pickle.dumps([1, 2, 3]) + assert len(pickle.loads(blob)) == 3 + + +def test_unary_minus_eq_literal(): + blob = pickle.dumps(-7) + assert -pickle.loads(blob) == 7 diff --git a/tests/fixtures/fp_guards/python_deser_in_unittest_assertion/expectations.json b/tests/fixtures/fp_guards/python_deser_in_unittest_assertion/expectations.json new file mode 100644 index 00000000..1fcb9088 --- /dev/null +++ b/tests/fixtures/fp_guards/python_deser_in_unittest_assertion/expectations.json @@ -0,0 +1,18 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "py.deser.pickle_loads" }, + { "id_prefix": "py.deser.yaml_load" }, + { "id_prefix": "py.deser.shelve_open" } + ], + "noise_budget": { + "max_total_findings": 2, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/python_deser_in_unittest_assertion/roundtrip_test.py b/tests/fixtures/fp_guards/python_deser_in_unittest_assertion/roundtrip_test.py new file mode 100644 index 00000000..3ef3508a --- /dev/null +++ b/tests/fixtures/fp_guards/python_deser_in_unittest_assertion/roundtrip_test.py @@ -0,0 +1,78 @@ +""" +unittest.TestCase methods that round-trip a value through pickle.dumps / +yaml.dump and assert the loads result equals a literal expected value. +Production Python projects ship hundreds of these in their test trees; +every firing is noise because the assertion bounds the deser result to +the literal expected. Suppress `py.deser.pickle_loads`, +`py.deser.yaml_load`, `py.deser.shelve_open`, and the +`cfg-unguarded-sink` mirror on every shape below. +""" + +import pickle +import yaml +import unittest + + +class RoundtripTest(unittest.TestCase): + def test_dict_literal_expected(self): + blob = pickle.dumps({"a": 1, "b": 2}) + self.assertEqual({"a": 1, "b": 2}, pickle.loads(blob)) + + def test_list_literal_expected(self): + blob = pickle.dumps([1, 2, 3]) + self.assertEqual([1, 2, 3], pickle.loads(blob)) + + def test_nested_literal_expected(self): + blob = pickle.dumps([{"k": "v"}, "tail"]) + self.assertEquals([{"k": "v"}, "tail"], pickle.loads(blob)) + + def test_string_literal_expected(self): + blob = pickle.dumps("hello") + self.assertEqual("hello", pickle.loads(blob)) + + def test_integer_literal_expected(self): + blob = pickle.dumps(42) + self.assertEqual(42, pickle.loads(blob)) + + def test_unary_negative_expected(self): + blob = pickle.dumps(-7) + self.assertEqual(-7, pickle.loads(blob)) + + def test_none_expected(self): + blob = pickle.dumps(None) + self.assertIsNone(pickle.loads(blob)) + + def test_assert_true_bounds(self): + blob = pickle.dumps(True) + self.assertTrue(pickle.loads(blob)) + + def test_assert_is_instance_dict(self): + blob = pickle.dumps({"a": 1}) + self.assertIsInstance(pickle.loads(blob), dict) + + def test_assert_in_list(self): + blob = pickle.dumps("apple") + self.assertIn(pickle.loads(blob), ["apple", "banana"]) + + def test_yaml_round_trip(self): + blob = yaml.dump({"port": 5432}) + self.assertEqual({"port": 5432}, yaml.load(blob)) + + def test_msg_kwarg_keeps_bound(self): + blob = pickle.dumps([1]) + self.assertEqual([1], pickle.loads(blob), msg="round trip should preserve list") + + def test_actual_first_position(self): + """pytest-style ordering: deser result first, literal second.""" + blob = pickle.dumps({"k": "v"}) + self.assertEqual(pickle.loads(blob), {"k": "v"}) + + +# Free function imports also cover the suppression: `from pickle import loads`. +from pickle import loads as pickle_loads + + +class FreeImportTest(unittest.TestCase): + def test_free_function_loads(self): + blob = pickle.dumps([1, 2]) + self.assertEqual([1, 2], pickle_loads(blob)) diff --git a/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/expectations.json b/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/expectations.json new file mode 100644 index 00000000..71d2a95e --- /dev/null +++ b/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "rb.deser.marshal_load" }, + { "id_prefix": "rb.deser.yaml_load" } + ], + "noise_budget": { + "max_total_findings": 2, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_spec.rb b/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_spec.rb new file mode 100644 index 00000000..f62d629d --- /dev/null +++ b/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_spec.rb @@ -0,0 +1,48 @@ +# RSpec round-trip patterns: `expect(deser).to MATCHER`. Same bounding +# semantics as the Minitest sibling fixture. + +RSpec.describe MarshalRoundTrip do + it "eq literal" do + blob = Marshal.dump([1, 2, 3]) + expect(Marshal.load(blob)).to eq([1, 2, 3]) + end + + it "is nil" do + blob = Marshal.dump(nil) + expect(Marshal.load(blob)).to be_nil + end + + it "is_a Array" do + blob = Marshal.dump([1]) + expect(Marshal.load(blob)).to be_a(Array) + end + + it "be_kind_of Array" do + blob = Marshal.dump([1]) + expect(Marshal.load(blob)).to be_kind_of(Array) + end + + it "be_truthy" do + blob = Marshal.dump([1]) + expect(Marshal.load(blob)).to be_truthy + end + + it "not_to be_nil" do + blob = Marshal.dump([1]) + expect(Marshal.load(blob)).not_to be_nil + end + + it "to_not be_nil" do + blob = Marshal.dump([1]) + expect(Marshal.load(blob)).to_not be_nil + end + + it "yaml load eq literal" do + expect(YAML.load("- 1\n")).to eq([1]) + end + + it "match_array" do + blob = Marshal.dump([1, 2, 3]) + expect(Marshal.load(blob)).to match_array([3, 2, 1]) + end +end diff --git a/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_test.rb b/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_test.rb new file mode 100644 index 00000000..963108f0 --- /dev/null +++ b/tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_test.rb @@ -0,0 +1,57 @@ +# Minitest round-trip patterns. Each shape bounds the deserialized +# result to a literal expected; a poisoned blob produces a different +# shape, the assertion fails loudly, no object-injection side effect +# escapes the test boundary. Layer C5 suppresses every finding here. + +require "minitest/autorun" + +class MarshalRoundTripTest < Minitest::Test + def test_eq_array + blob = Marshal.dump([1, 2, 3]) + assert_equal [1, 2, 3], Marshal.load(blob) + end + + def test_eq_hash + blob = Marshal.dump({a: 1}) + assert_equal({a: 1}, Marshal.load(blob)) + end + + def test_assert_nil + blob = Marshal.dump(nil) + assert_nil Marshal.load(blob) + end + + def test_assert_truthy + blob = Marshal.dump([1]) + assert Marshal.load(blob) + end + + def test_kind_of + blob = Marshal.dump([1]) + assert_kind_of Array, Marshal.load(blob) + end + + def test_instance_of + blob = Marshal.dump([1]) + assert_instance_of Array, Marshal.load(blob) + end + + def test_refute_nil + blob = Marshal.dump([1]) + refute_nil Marshal.load(blob) + end + + def test_refute_equal_literal + blob = Marshal.dump([1]) + refute_equal [9, 9], Marshal.load(blob) + end + + def test_yaml_eq_literal + assert_equal [1], YAML.load("- 1\n") + end + + def test_assert_includes + blob = Marshal.dump(2) + assert_includes [1, 2, 3], Marshal.load(blob) + end +end diff --git a/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/expectations.json b/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/expectations.json new file mode 100644 index 00000000..f2f45234 --- /dev/null +++ b/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/expectations.json @@ -0,0 +1,17 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "state-resource-leak" }, + { "id_prefix": "cfg-resource-leak" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 2000, + "max_ms_index_cold": 2500, + "max_ms_index_warm": 1000, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_java_try_with_resources.java b/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_java_try_with_resources.java new file mode 100644 index 00000000..52c8b936 --- /dev/null +++ b/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_java_try_with_resources.java @@ -0,0 +1,22 @@ +// Resources acquired inside Java try-with-resources must not propagate +// an Acquire effect onto callers' receivers. The acquire node is +// `managed_resource = true` after CFG lowering, so the method-summary +// builder skips it. +// +// Pre-fix: methods like `void load(File f) { try (var in = new +// FileInputStream(f)) { ... } }` were summarised as Acquire, so callers +// `obj.load(f)` got `obj` marked OPEN. +import java.io.FileInputStream; + +public class SafeLoader { + public void load(java.io.File f) throws Exception { + try (java.io.FileInputStream in = new java.io.FileInputStream(f)) { + in.read(); + } + } + + public static void useLoader(java.io.File f) throws Exception { + SafeLoader loader = new SafeLoader(); + loader.load(f); + } +} diff --git a/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_python_sphinx_connect.py b/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_python_sphinx_connect.py new file mode 100644 index 00000000..1e9825aa --- /dev/null +++ b/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_python_sphinx_connect.py @@ -0,0 +1,41 @@ +"""Sphinx / Flask / MQTT-style event handler `app.connect(event, cb)` +must not be flagged as a DB connection acquire by the generic `connect` +matcher. + +Pre-fix: `app.connect("config-inited", _create_init_py)` matched the +ends-with-`.connect` acquire pattern; the static `exclude_acquire` +list only carved out `signal.connect`, `event.connect`, and `.register`, +missing Sphinx's `app.connect` and similar event-handler dispatchers. + +Post-fix: `is_event_handler_register_shape` (string-literal first arg +without `scheme://` plus a single-identifier second positional arg) +recognises the canonical handler shape and suppresses the acquire on +`db connection` pairs only. Real `engine.connect("postgres://...")` +shapes still fire because their first arg carries a `://`. +""" + + +def setup(app): + app.connect("config-inited", _on_config_inited) + app.connect("html-page-context", _on_page_context) + app.connect("build-finished", _on_build_finished) + + +def _on_config_inited(app, config): + pass + + +def _on_page_context(app, pagename, templatename, context, doctree): + pass + + +def _on_build_finished(app, exception): + pass + + +class MqttListener: + def setup(self, client): + client.connect("device/status/+", self._on_status) + + def _on_status(self, topic, payload): + pass diff --git a/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_python_with_block.py b/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_python_with_block.py new file mode 100644 index 00000000..15ea9b29 --- /dev/null +++ b/tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_python_with_block.py @@ -0,0 +1,51 @@ +"""Resource opens inside Python `with` blocks must not propagate an +Acquire effect onto callers' receivers. + +Pre-fix: `build_resource_method_summaries` summarised every method body +containing an `open(...)` callee as Acquire, regardless of whether the +handle was managed by a `with` block (released before return) or bound +to a class field (genuine receiver state). Callers of `obj.method()` +were then marked OPEN forever, producing the airflow `subject=self` FP +cluster (58 findings). + +Post-fix: nodes flagged `managed_resource = true` (Python `with`, Java +try-with-resources, Ruby File.open block) are excluded from the summary +table. +""" + + +class JWKS: + """Mimics airflow's tokens.JWKS shape.""" + + def __init__(self, url): + self.url = url + + def _fetch_local_jwks(self): + try: + with open(self.url) as jwks_file: + content = jwks_file.read() + return content + except Exception: + return None + + +class BundleVersionLockReader: + """Mimics airflow's BundleUsageTrackingManager._remove_stale_bundle.""" + + @staticmethod + def remove_stale(info): + try: + with open(info, "a") as f: + f.write("x") + except OSError: + pass + + +def use_jwks(): + j = JWKS("/tmp/x") + j._fetch_local_jwks() + + +def use_bundle_reader(info): + r = BundleVersionLockReader() + r.remove_stale(info) diff --git a/tests/fixtures/fp_guards/url_builder_const_base/expectations.json b/tests/fixtures/fp_guards/url_builder_const_base/expectations.json new file mode 100644 index 00000000..359df2fc --- /dev/null +++ b/tests/fixtures/fp_guards/url_builder_const_base/expectations.json @@ -0,0 +1,24 @@ +{ + "required_findings": [ + { + "id_prefix": "taint-unsanitised-flow", + "min_count": 1 + } + ], + "forbidden_findings": [ + { + "id_prefix": "taint-unsanitised-flow", + "file_glob": "**/server.ts" + } + ], + "noise_budget": { + "max_total_findings": 8, + "max_high_findings": 2 + }, + "performance_expectations": { + "max_ms_no_index": 1500, + "max_ms_index_cold": 2000, + "max_ms_index_warm": 1000, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/url_builder_const_base/handler.ts b/tests/fixtures/fp_guards/url_builder_const_base/handler.ts new file mode 100644 index 00000000..fd524ea4 --- /dev/null +++ b/tests/fixtures/fp_guards/url_builder_const_base/handler.ts @@ -0,0 +1,10 @@ +// Negative control: an unbound base must still surface SSRF. +// Without const-bound origin lock the abstract domain has no prefix +// info, the SSRF arm fires. + +export async function fetchByBase(req: { + body: { path: string; base: string }; +}) { + const u = new URL(req.body.path, req.body.base); + return fetch(u); +} diff --git a/tests/fixtures/fp_guards/url_builder_const_base/server.ts b/tests/fixtures/fp_guards/url_builder_const_base/server.ts new file mode 100644 index 00000000..87461565 --- /dev/null +++ b/tests/fixtures/fp_guards/url_builder_const_base/server.ts @@ -0,0 +1,17 @@ +// Phase 08 const-bound base: the URL constructor's second arg is a +// `const` identifier whose value is a literal. Must surface no SSRF +// finding because the abstract-string singleton domain proves the +// origin is locked even though the base arg is not a syntactic +// literal at the call site. + +export async function fetchUserPath(req: { body: { path: string } }) { + const apiBase = "https://api.example.com"; + const u = new URL(req.body.path, apiBase); + return fetch(u); +} + +export async function fetchAltPath(req: { body: { path: string } }) { + const altBase = "https://alt.example.com/"; + const u = new URL(req.body.path, altBase); + return fetch(u); +} diff --git a/tests/fixtures/fp_guards/vendored_assets_skip/bower_components/lib/lib.js b/tests/fixtures/fp_guards/vendored_assets_skip/bower_components/lib/lib.js new file mode 100644 index 00000000..40597801 --- /dev/null +++ b/tests/fixtures/fp_guards/vendored_assets_skip/bower_components/lib/lib.js @@ -0,0 +1,5 @@ +// Synthetic bower component. The engine must not parse this file because +// `bower_components/` is unambiguously vendored regardless of file name. +var entropy = Math.random(); +var compiled = eval("1+1"); +document.write(location.hash); diff --git a/tests/fixtures/fp_guards/vendored_assets_skip/expectations.json b/tests/fixtures/fp_guards/vendored_assets_skip/expectations.json new file mode 100644 index 00000000..c7d086d5 --- /dev/null +++ b/tests/fixtures/fp_guards/vendored_assets_skip/expectations.json @@ -0,0 +1,20 @@ +{ + "required_findings": [ + { "id_prefix": "js.crypto.math_random", "min_count": 1 } + ], + "forbidden_findings": [ + { "id_prefix": "", "file_glob": "**/*.min.js" }, + { "id_prefix": "", "file_glob": "**/vendor/**" }, + { "id_prefix": "", "file_glob": "**/bower_components/**" } + ], + "noise_budget": { + "max_total_findings": 5, + "max_high_findings": 2 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/vendored_assets_skip/jquery-ui.custom.min.js b/tests/fixtures/fp_guards/vendored_assets_skip/jquery-ui.custom.min.js new file mode 100644 index 00000000..5c95326a --- /dev/null +++ b/tests/fixtures/fp_guards/vendored_assets_skip/jquery-ui.custom.min.js @@ -0,0 +1,4 @@ +// Synthetic minified bundle. The engine must not parse this file because +// it carries the `.min.js` suffix. If parsed, every line below would surface +// findings (Math.random, eval, prototype merge, document.write). +var x=Math.random();var y=eval("1+1");function deepMerge(a,b){for(var k in b){a[k]=b[k];}return a;}deepMerge({},JSON.parse(location.hash));document.write(location.search); diff --git a/tests/fixtures/fp_guards/vendored_assets_skip/src/handler.js b/tests/fixtures/fp_guards/vendored_assets_skip/src/handler.js new file mode 100644 index 00000000..546cb25c --- /dev/null +++ b/tests/fixtures/fp_guards/vendored_assets_skip/src/handler.js @@ -0,0 +1,8 @@ +// Negative control: hand-authored production source must still be scanned. +// `Math.random` here MUST surface as `js.crypto.math_random` so the +// vendored-asset skip is proven not to over-suppress. +function makeToken() { + return Math.random().toString(16).slice(2); +} + +module.exports = { makeToken }; diff --git a/tests/fixtures/fp_guards/vendored_assets_skip/vendor/jquery/jquery.js b/tests/fixtures/fp_guards/vendored_assets_skip/vendor/jquery/jquery.js new file mode 100644 index 00000000..26e69762 --- /dev/null +++ b/tests/fixtures/fp_guards/vendored_assets_skip/vendor/jquery/jquery.js @@ -0,0 +1,8 @@ +// Synthetic vendored library. The engine must not parse this file because +// it lives under a `vendor/` directory with a front-end `.js` extension. +// Without the vendored-asset skip, every line below would surface findings. +var token = Math.random(); +var result = eval("1+1"); +function merge(target, src) { for (var k in src) target[k] = src[k]; } +merge({}, JSON.parse(location.hash)); +document.write(location.search); diff --git a/tests/fixtures/realistic/.gitkeep b/tests/fixtures/realistic/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/realistic/async_await/await_count.rs b/tests/fixtures/realistic/async_await/await_count.rs new file mode 100644 index 00000000..bfbff52c --- /dev/null +++ b/tests/fixtures/realistic/async_await/await_count.rs @@ -0,0 +1,16 @@ +// Phase 12 ssa-equivalence fixture (Rust): three `await_expression` nodes +// in distinct positions (let-binding, statement-expression, implicit +// return) used by `await_emits_at_most_one_assign_per_node` to assert +// the SSA lowering does not double-fire Assign ops on a single +// AwaitForward CFG node. +async fn pass(s: String) -> String { + s +} + +pub async fn run() -> String { + let env = std::env::var("X").unwrap_or_default(); + let fut1 = pass(env); + let r1 = fut1.await; + let fut2 = pass(r1); + fut2.await +} diff --git a/tests/fixtures/realistic/async_await/gather.py b/tests/fixtures/realistic/async_await/gather.py new file mode 100644 index 00000000..98642fa9 --- /dev/null +++ b/tests/fixtures/realistic/async_await/gather.py @@ -0,0 +1,14 @@ +# Phase 12 recall-gap fixture (Python combinator). `asyncio.gather` +# concurrently awaits its argument futures and resolves to a list whose +# elements carry the union of argument taints. The SQL sink on +# `results[0]` proves the engine's `PromiseCombinator` rule fires for +# Python via the `is_promise_combinator("python", "asyncio.gather")` +# entry added in this phase. +import asyncio + + +async def main(request): + a = request.args.get("x") + b = request.form.get("y") + results = await asyncio.gather(a, b) + cursor.execute(results[0]) diff --git a/tests/fixtures/realistic/async_await/handler.js b/tests/fixtures/realistic/async_await/handler.js new file mode 100644 index 00000000..835dea01 --- /dev/null +++ b/tests/fixtures/realistic/async_await/handler.js @@ -0,0 +1,9 @@ +// Phase 02 recall-gap fixture: source flows through `await` into a SQL sink. +// Modern handler shape — `await` is the front door of every framework that +// exposes the request as a Promise (Next.js, Web Streams, fetch handlers). +async function handler(req, res) { + const data = await req.body; + db.query(data); +} + +module.exports = handler; diff --git a/tests/fixtures/realistic/async_await/handler.py b/tests/fixtures/realistic/async_await/handler.py new file mode 100644 index 00000000..6643c949 --- /dev/null +++ b/tests/fixtures/realistic/async_await/handler.py @@ -0,0 +1,8 @@ +# Phase 12 recall-gap fixture (Python). FastAPI / starlette-shape async +# handler reads the request body via `await request.json()` and feeds it +# to a SQL sink. Exercises the new Python `"await"` -> `Kind::AwaitForward` +# mapping in `src/labels/python.rs`: without it, the engine never models +# the await boundary as a 1:1 forward and `data` carries no Source taint. +async def handler(request): + data = await request.json() + cursor.execute("SELECT * FROM t WHERE id = " + data) diff --git a/tests/fixtures/realistic/async_await/handler.rs b/tests/fixtures/realistic/async_await/handler.rs new file mode 100644 index 00000000..d41ca928 --- /dev/null +++ b/tests/fixtures/realistic/async_await/handler.rs @@ -0,0 +1,27 @@ +// Phase 12 recall-gap fixture (Rust). axum-style async handler reads a +// header value via `req.headers.get(...)` (a Source-tagged accessor in +// `src/labels/rust.rs`) and awaits the result before passing it to a +// command-injection sink. Exercises the new explicit +// `"await_expression" => Kind::AwaitForward` mapping in +// `src/labels/rust.rs`: the engine must see the await boundary as a +// 1:1 forward so taint from the headers chain reaches `cmd`. +use std::process::Command; + +#[allow(unused)] +struct Request; +impl Request { + fn headers(&self) -> Headers { + Headers + } +} +struct Headers; +impl Headers { + async fn get(&self, _key: &str) -> String { + String::new() + } +} + +pub async fn handler(req: Request) { + let cmd = req.headers().get("X-Cmd").await; + Command::new("sh").arg("-c").arg(&cmd).status().ok(); +} diff --git a/tests/fixtures/realistic/async_await/handler.ts b/tests/fixtures/realistic/async_await/handler.ts new file mode 100644 index 00000000..010e0d9f --- /dev/null +++ b/tests/fixtures/realistic/async_await/handler.ts @@ -0,0 +1,9 @@ +// Phase 02 deferred-sweep fixture: the `.ts` counterpart to handler.js. +// Exercises the TypeScript KINDS-map entry for `await_expression`. +async function handler(req: { body: string }): Promise { + const data = await req.body; + db.query(data); +} + +declare const db: { query(sql: string): void }; +export default handler; diff --git a/tests/fixtures/realistic/async_await/tokio_join.rs b/tests/fixtures/realistic/async_await/tokio_join.rs new file mode 100644 index 00000000..75d47e83 --- /dev/null +++ b/tests/fixtures/realistic/async_await/tokio_join.rs @@ -0,0 +1,12 @@ +// Phase 12 recall-gap fixture (Rust combinator). `tokio::join!` evaluates +// every passed future concurrently and binds the tuple of resolved values. +// `cfg::push_node` lifts the macro_invocation's `arg_uses` from its +// `token_tree`, and `is_promise_combinator("rust", "tokio::join")` (added +// in this phase) routes the SsaOp::Call through the existing combinator +// transfer so each future's tainted inputs surface on the result tuple. +pub async fn run() { + let url_a = std::env::var("URL_A").unwrap_or_default(); + let url_b = std::env::var("URL_B").unwrap_or_default(); + let results = tokio::join!(url_a, url_b); + reqwest::get(results.0).await.ok(); +} diff --git a/tests/fixtures/realistic/async_await/tokio_join_bare.rs b/tests/fixtures/realistic/async_await/tokio_join_bare.rs new file mode 100644 index 00000000..c3345bfd --- /dev/null +++ b/tests/fixtures/realistic/async_await/tokio_join_bare.rs @@ -0,0 +1,14 @@ +// Phase 12 deferred-fix fixture (Rust combinator, bare macro form). +// `use tokio::join;` brings the macro into scope; the call site then uses +// the bare `join!(...)` shape. `cfg::push_node` rewrites the bare macro +// callee text to `tokio::join` when the file imports the matching macro, +// so `is_promise_combinator("rust", "tokio::join")` recognises the +// resulting SSA Call op and unions argument taint into the tuple value. +use tokio::join; + +pub async fn run() { + let url_a = std::env::var("URL_A").unwrap_or_default(); + let url_b = std::env::var("URL_B").unwrap_or_default(); + let results = join!(url_a, url_b); + reqwest::get(results.0).await.ok(); +} diff --git a/tests/fixtures/realistic/cross_package_ipa/packages/util/package.json b/tests/fixtures/realistic/cross_package_ipa/packages/util/package.json new file mode 100644 index 00000000..f9dab188 --- /dev/null +++ b/tests/fixtures/realistic/cross_package_ipa/packages/util/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/util", + "version": "0.0.0", + "main": "src/index.ts" +} diff --git a/tests/fixtures/realistic/cross_package_ipa/packages/util/src/sanitize.ts b/tests/fixtures/realistic/cross_package_ipa/packages/util/src/sanitize.ts new file mode 100644 index 00000000..3ed7131c --- /dev/null +++ b/tests/fixtures/realistic/cross_package_ipa/packages/util/src/sanitize.ts @@ -0,0 +1,7 @@ +// Cross-package fixture for Phase 09: passthrough function whose name is +// NOT in the JS/TS intrinsic sanitizer matcher list, so the only way for +// the engine to know it preserves taint is via the cross-package SSA +// summary lookup that step 0.7 of `resolve_callee_full` performs. +export function escapeHtmlNoop(s: string): string { + return s; +} diff --git a/tests/fixtures/realistic/cross_package_ipa/packages/util/src/strip.ts b/tests/fixtures/realistic/cross_package_ipa/packages/util/src/strip.ts new file mode 100644 index 00000000..3bc9da02 --- /dev/null +++ b/tests/fixtures/realistic/cross_package_ipa/packages/util/src/strip.ts @@ -0,0 +1,9 @@ +// Cross-package fixture for Phase 09: sanitizer that wraps the JS +// intrinsic `encodeURIComponent` (recognised by the JS sanitizer label +// table as `Sanitizer(URL_ENCODE | HTML_ESCAPE)`). The intra-file SSA +// summary therefore carries a real sanitize transform on `s → return`, +// which step 0.7 of `resolve_callee_full` propagates into the caller +// site so the cross-package safe path stays silent. +export function stripTags(s: string): string { + return encodeURIComponent(s); +} diff --git a/tests/fixtures/realistic/cross_package_ipa/packages/web/package.json b/tests/fixtures/realistic/cross_package_ipa/packages/web/package.json new file mode 100644 index 00000000..2c016995 --- /dev/null +++ b/tests/fixtures/realistic/cross_package_ipa/packages/web/package.json @@ -0,0 +1,8 @@ +{ + "name": "@scope/web", + "version": "0.0.0", + "main": "src/handler.ts", + "dependencies": { + "@scope/util": "*" + } +} diff --git a/tests/fixtures/realistic/cross_package_ipa/packages/web/src/handler.ts b/tests/fixtures/realistic/cross_package_ipa/packages/web/src/handler.ts new file mode 100644 index 00000000..e36bc942 --- /dev/null +++ b/tests/fixtures/realistic/cross_package_ipa/packages/web/src/handler.ts @@ -0,0 +1,14 @@ +import { escapeHtmlNoop } from "@scope/util/sanitize"; +import { stripTags } from "@scope/util/strip"; + +export function unsafeHandler(req: any, res: any) { + const x = req.query.x; + const y = escapeHtmlNoop(x); + res.send(y); +} + +export function safeHandler(req: any, res: any) { + const x = req.query.x; + const y = stripTags(x); + res.send(y); +} diff --git a/tests/fixtures/realistic/cross_package_ipa/tsconfig.json b/tests/fixtures/realistic/cross_package_ipa/tsconfig.json new file mode 100644 index 00000000..c2c4609e --- /dev/null +++ b/tests/fixtures/realistic/cross_package_ipa/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@scope/*": ["packages/*/src"] + } + } +} diff --git a/tests/fixtures/realistic/entry_points_xlang/actix_handler.rs b/tests/fixtures/realistic/entry_points_xlang/actix_handler.rs new file mode 100644 index 00000000..d7f554bd --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/actix_handler.rs @@ -0,0 +1,13 @@ +// Phase 16 fixture: actix-web handler. The `#[get("/u/{name}")]` +// routing macro attribute marks `u` as an `ActixHandler` entry point. +// The `name` formal is seeded as `Source(Cap::all())` and flows into +// `Command::new("sh").arg(&name)` (SHELL_ESCAPE sink). +use actix_web::{get, web, HttpResponse}; +use std::process::Command; + +#[get("/u/{name}")] +pub async fn u(name: web::Path) -> HttpResponse { + let s: String = name.into_inner(); + Command::new("sh").arg("-c").arg(&s).status().ok(); + HttpResponse::Ok().body(s) +} diff --git a/tests/fixtures/realistic/entry_points_xlang/axum_handler.rs b/tests/fixtures/realistic/entry_points_xlang/axum_handler.rs new file mode 100644 index 00000000..76d3ed43 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/axum_handler.rs @@ -0,0 +1,14 @@ +// Phase 16 fixture: axum handler. The signature contains an axum +// `Query<_>` extractor, so `list` is recognised as an `AxumHandler` +// entry point. Every formal `Param` in the SSA entry block is seeded +// with `Cap::all()` Source taint, which flows through the destructured +// `q` String value into the `Command::new("sh").arg(&q)` chain. The +// chained `.arg` call resolves to `command::arg` (SHELL_ESCAPE sink) +// in `src/labels/rust.rs`. +use axum::extract::Query; +use std::process::Command; + +pub async fn list(Query(q): Query) -> String { + Command::new("sh").arg("-c").arg(&q).status().ok(); + q +} diff --git a/tests/fixtures/realistic/entry_points_xlang/django_view.py b/tests/fixtures/realistic/entry_points_xlang/django_view.py new file mode 100644 index 00000000..f10e5666 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/django_view.py @@ -0,0 +1,14 @@ +# Phase 16 fixture: Django class-based view. `MyView` extends +# `django.views.View`; `get(self, request, name)` is recognised as a +# `DjangoView` entry point. The seeding policy paints every formal +# (including `name`, the path-captured kwarg) as `Source(Cap::all())`, +# so flowing `name` into `cursor.execute` fires SQL_QUERY. +from django.db import connection +from django.views import View + + +class MyView(View): + def get(self, request, name): + with connection.cursor() as cursor: + cursor.execute("SELECT * FROM users WHERE name = '" + name + "'") + return cursor.fetchall() diff --git a/tests/fixtures/realistic/entry_points_xlang/express_route.js b/tests/fixtures/realistic/entry_points_xlang/express_route.js new file mode 100644 index 00000000..9c703e24 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/express_route.js @@ -0,0 +1,17 @@ +// Phase 16 fixture: Express route handler. `app.post('/u', ...)` +// registers an arrow handler whose span is detected as an +// `ExpressRoute { method: POST }` entry point. The seeding policy +// paints `req` and `res` as `Source(Cap::all())`; `req.body.name` is +// already a JS-handler-param-name source via Phase 05, so flowing +// into `db.query(...)` fires the SQL_QUERY sink. The new entry-kind +// detection guarantees the seeding even outside Next.js. +const express = require('express'); +const app = express(); +const db = require('./db'); + +app.post('/u', (req, res) => { + db.query("SELECT * FROM users WHERE name = '" + req.body.name + "'"); + res.send('ok'); +}); + +module.exports = app; diff --git a/tests/fixtures/realistic/entry_points_xlang/fastapi_route.py b/tests/fixtures/realistic/entry_points_xlang/fastapi_route.py new file mode 100644 index 00000000..6c654fc7 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/fastapi_route.py @@ -0,0 +1,17 @@ +# Phase 16 fixture: FastAPI route handler. The `@app.get("/items/{item_id}")` +# decorator marks `read_item` as a `FastApiRoute { method: GET }` entry +# point. Every formal is seeded as `Source(Cap::all())`; the unsanitised +# `item_id` value is concatenated into a SQL statement and forwarded to +# `cursor.execute` (flat SQL_QUERY sink). +from fastapi import FastAPI +import sqlite3 + +app = FastAPI() + + +@app.get("/items/{item_id}") +async def read_item(item_id: str): + conn = sqlite3.connect("db.sqlite") + cursor = conn.cursor() + cursor.execute("SELECT * FROM items WHERE id = '" + item_id + "'") + return cursor.fetchall() diff --git a/tests/fixtures/realistic/entry_points_xlang/flask_route.py b/tests/fixtures/realistic/entry_points_xlang/flask_route.py new file mode 100644 index 00000000..a1eaf3b3 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/flask_route.py @@ -0,0 +1,15 @@ +# Phase 16 fixture: Flask route handler. The `@app.route(...)` decorator +# with `methods=["POST"]` marks `submit` as a `FlaskRoute { method: POST }` +# entry point. The path-captured `name` formal is auto-seeded as +# `Source(Cap::all())` and forwarded into `os.system`, firing +# SHELL_ESCAPE. +from flask import Flask +import os + +app = Flask(__name__) + + +@app.route("/submit/", methods=["POST"]) +def submit(name): + os.system("echo " + name) + return "ok" diff --git a/tests/fixtures/realistic/entry_points_xlang/gin_handler.go b/tests/fixtures/realistic/entry_points_xlang/gin_handler.go new file mode 100644 index 00000000..346a4916 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/gin_handler.go @@ -0,0 +1,25 @@ +// Gin handler. The `*gin.Context` parameter type marks `Handler` +// as a `GinRoute` entry point. Seeding policy: the bare `c` +// object is NOT painted as `Source` (would FP on excluded +// lifecycle calls like `c.Set` / `c.Next`). Adversary bytes +// surface via the access-path label rules at +// `src/labels/go.rs`: `c.Query`, `c.Param`, `c.PostForm`, +// `c.QueryArray`, `c.PostFormArray` are gated on +// `DetectedFramework::Gin` and classify as `Source(Cap::all())`. +// `name := c.Query("name")` produces a tainted local, and +// `db.Query` matches the SQL_QUERY sink list, so the flow +// fires `taint-unsanitised-flow` with `c.Query` as the source. +package handlers + +import ( + "database/sql" + + "github.com/gin-gonic/gin" +) + +var db *sql.DB + +func Handler(c *gin.Context) { + name := c.Query("name") + db.Query("SELECT * FROM users WHERE name = '" + name + "'") +} diff --git a/tests/fixtures/realistic/entry_points_xlang/net_http_handler.go b/tests/fixtures/realistic/entry_points_xlang/net_http_handler.go new file mode 100644 index 00000000..18a791c3 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/net_http_handler.go @@ -0,0 +1,24 @@ +// net/http handler. The `(w http.ResponseWriter, r *http.Request)` +// signature marks `Handler` as a `GoNetHttp` entry point. Seeding +// policy: the bare `r` object is NOT painted as `Source` (would FP +// on `r.Context()` / `r.WithContext(...)` lifecycle access). +// Adversary bytes surface via the global Go label rules at +// `src/labels/go.rs`: `r.FormValue`, `r.URL.Query`, +// `r.URL.Query.Get`, `r.Header.Get`, `r.Body`, `r.Cookie` all +// classify as `Source(Cap::all())`. `name := r.URL.Query().Get("name")` +// produces a tainted local that flows through `exec.Command` +// (a SHELL_ESCAPE sink), firing `taint-unsanitised-flow` with +// `r.URL.Query` as the source attribution. +package handlers + +import ( + "net/http" + "os/exec" +) + +func Handler(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + cmd := exec.Command("echo", name) + _ = cmd.Run() + _ = w +} diff --git a/tests/fixtures/realistic/entry_points_xlang/rails_action.rb b/tests/fixtures/realistic/entry_points_xlang/rails_action.rb new file mode 100644 index 00000000..cc5c5d18 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/rails_action.rb @@ -0,0 +1,13 @@ +# Phase 16 fixture: Rails ActionController action. `UsersController` +# extends `ApplicationController`; `show` is recognised as a +# `RailsAction` entry point. Rails actions take no formal parameters, +# adversary input flows in through the implicit `params` accessor +# (already a Source in `labels/ruby.rs`). The fixture demonstrates +# entry-point recognition composing with the existing `params` source +# and the flat `find_by_sql` SQL_QUERY sink shipped in Phase 15. +class UsersController < ApplicationController + def show + name = params[:name] + User.find_by_sql("SELECT * FROM users WHERE name = '" + name + "'") + end +end diff --git a/tests/fixtures/realistic/entry_points_xlang/spring_controller.java b/tests/fixtures/realistic/entry_points_xlang/spring_controller.java new file mode 100644 index 00000000..32a21c84 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang/spring_controller.java @@ -0,0 +1,23 @@ +// Phase 16 fixture: Spring `@PostMapping` controller method. The +// `@PostMapping("/users")` annotation marks `addUser` as a +// `SpringMapping { method: POST }` entry point; the seeding policy +// paints `name` (the `@RequestParam`) as `Source(Cap::all())`. The +// taint composes with Phase 15's Hibernate `entityManager.createNativeQuery` +// SQL_QUERY sink at the `String.format`-interpolated SQL string. +package com.example; + +import javax.persistence.EntityManager; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class UserController { + private EntityManager entityManager; + + @PostMapping("/users") + public Object addUser(@RequestParam String name) { + String sql = String.format("INSERT INTO users (name) VALUES ('%s')", name); + return entityManager.createNativeQuery(sql).getResultList(); + } +} diff --git a/tests/fixtures/realistic/entry_points_xlang_python/flask_converter_capture.py b/tests/fixtures/realistic/entry_points_xlang_python/flask_converter_capture.py new file mode 100644 index 00000000..1d0f7daa --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_python/flask_converter_capture.py @@ -0,0 +1,15 @@ +# Flask path-capture with a converter prefix (``). The route +# binds `slug` as the path capture; only the inner name after the +# converter colon participates in the gate. Slug flows to +# `subprocess.check_output(shell=True)` producing +# `taint-unsanitised-flow`. +from flask import Flask +import subprocess + +app = Flask(__name__) + + +@app.route("/exec/") +def exec_slug(slug): + subprocess.check_output("echo " + slug, shell=True) + return "ok" diff --git a/tests/fixtures/realistic/entry_points_xlang_python/flask_no_capture.py b/tests/fixtures/realistic/entry_points_xlang_python/flask_no_capture.py new file mode 100644 index 00000000..25ab4204 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_python/flask_no_capture.py @@ -0,0 +1,16 @@ +# Negative: Flask route with no path captures. The handler takes zero +# formals; the entry-point seeding pass has nothing to paint. The body +# pipes a static string to `os.system`, which the literal-arg gate +# suppresses. Forcing-function for the negative direction: a future +# regression that seeds non-capture formals would fire here as soon as +# the fixture grows a formal. +from flask import Flask +import os + +app = Flask(__name__) + + +@app.route("/static") +def static_route(): + os.system("ls -la") + return "ok" diff --git a/tests/fixtures/realistic/entry_points_xlang_python/flask_path_capture.py b/tests/fixtures/realistic/entry_points_xlang_python/flask_path_capture.py new file mode 100644 index 00000000..e6232f16 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_python/flask_path_capture.py @@ -0,0 +1,15 @@ +# Phase 16 Python entry-kind seeding precision. The `@app.route(...)` +# decorator binds `cmd` as a path capture; the entry-point seeding pass +# paints `cmd` as `Source(UserInput)`, producing `taint-unsanitised-flow` +# at the `os.system` sink. Without per-formal route-capture gating the +# engine fell back to `cfg-unguarded-sink` for Python handlers. +from flask import Flask +import os + +app = Flask(__name__) + + +@app.route("/run/", methods=["POST"]) +def run(cmd): + os.system(cmd) + return "ok" diff --git a/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_annotated_query.py b/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_annotated_query.py new file mode 100644 index 00000000..4a948e5f --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_annotated_query.py @@ -0,0 +1,18 @@ +# Positive: FastAPI route with an Annotated typed extractor. No path +# capture, but `q: Annotated[str, Query()]` matches the FastAPI marker +# `Query(`, so `classify_param_type_python` returns +# `Some(TypeKind::String)` and the seed gate paints `q` as +# `Source(UserInput)`. Forwarding the unsanitised value to +# `subprocess.run(shell=True)` fires `taint-unsanitised-flow`. +from fastapi import FastAPI +from typing import Annotated +from fastapi import Query +import subprocess + +app = FastAPI() + + +@app.get("/search") +def search(q: Annotated[str, Query()]): + subprocess.run("echo " + q, shell=True) + return "ok" diff --git a/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_depends_denylist.py b/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_depends_denylist.py new file mode 100644 index 00000000..846dd938 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_depends_denylist.py @@ -0,0 +1,23 @@ +# Negative: FastAPI route with a `Depends(...)` DI handle. `db: Session +# = Depends(get_db)` has type annotation `Session` which is NOT in the +# FastAPI Annotated marker list, so `classify_param_type_python` returns +# None and the seed gate skips `db`. The handler executes a SQL string +# built from the DI handle (`db.execute("...")`); without seeding, this +# call carries no taint and produces no `taint-unsanitised-flow` finding. +# A structural `cfg-unguarded-sink` finding may still fire as the +# fallback (acceptable — the regression we guard against is over-seeding +# the DI handle as adversary input, which would produce a spurious +# `taint-unsanitised-flow`). +from fastapi import FastAPI, Depends + +app = FastAPI() + + +def get_db(): + return None + + +@app.get("/health") +def health(db=Depends(get_db)): + db.execute("SELECT 1") + return "ok" diff --git a/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_path_capture.py b/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_path_capture.py new file mode 100644 index 00000000..a1191988 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_python_fastapi/fastapi_path_capture.py @@ -0,0 +1,19 @@ +# Positive: FastAPI route with a brace-segment path capture. +# `@app.get("/items/{item_id}")` binds `item_id` as a path capture; +# `extract_route_path_captures` populates `BodyMeta.param_route_capture` +# from the new FastAPI brace-segment parser, and the entry-point seeding +# pass paints `item_id` as `Source(UserInput)`. The unsanitised value +# concatenated into the SQL string fires `taint-unsanitised-flow` at +# `cursor.execute`. +from fastapi import FastAPI +import sqlite3 + +app = FastAPI() + + +@app.get("/items/{item_id}") +def read_item(item_id: str): + conn = sqlite3.connect("db.sqlite") + cursor = conn.cursor() + cursor.execute("SELECT * FROM items WHERE id = '" + item_id + "'") + return cursor.fetchall() diff --git a/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_multi_capture.rb b/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_multi_capture.rb new file mode 100644 index 00000000..d812188e --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_multi_capture.rb @@ -0,0 +1,10 @@ +# Sinatra route with two path captures. Both block formals are bound +# to `:user_id` and `:cmd`; the entry-point seeding pass paints both +# as `Source(UserInput)`. The second formal flows into a `system` +# sink and fires `taint-unsanitised-flow`. +require 'sinatra' + +post '/users/:user_id/run/:cmd' do |user_id, cmd| + system("echo #{user_id}: #{cmd}") + "ok" +end diff --git a/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_no_capture.rb b/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_no_capture.rb new file mode 100644 index 00000000..abd79dc7 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_no_capture.rb @@ -0,0 +1,12 @@ +# Negative: Sinatra route with no path captures. The handler takes +# zero block formals; the entry-point seeding pass has nothing to +# paint. The body pipes a static string to `system`, which the +# literal-arg gate suppresses. Forcing-function for the negative +# direction: a future regression that paints non-capture block formals +# would fire here as soon as the fixture grows a formal. +require 'sinatra' + +get '/static' do + system("ls -la") + "ok" +end diff --git a/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_path_capture.rb b/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_path_capture.rb new file mode 100644 index 00000000..da228f35 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_ruby/sinatra_path_capture.rb @@ -0,0 +1,11 @@ +# Ruby Sinatra entry-kind seeding precision. The `get` route binds +# `name` as a path capture; the entry-point seeding pass paints `name` +# as `Source(UserInput)`, producing `taint-unsanitised-flow` at the +# `system` sink. Without per-formal route-capture gating the engine +# fell back to `cfg-unguarded-sink` for Sinatra block formals. +require 'sinatra' + +get '/run/:name' do |name| + system("echo #{name}") + "ok" +end diff --git a/tests/fixtures/realistic/entry_points_xlang_rust/actix_path_typed_extractor.rs b/tests/fixtures/realistic/entry_points_xlang_rust/actix_path_typed_extractor.rs new file mode 100644 index 00000000..530e4b83 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_rust/actix_path_typed_extractor.rs @@ -0,0 +1,13 @@ +// Actix-web parallel of the axum typed-extractor positive shape. +// `web::Path` is a user-input boundary; the `name` formal +// flows into a shell sink and should produce a +// taint-unsanitised-flow finding via entry-kind seeding. +use actix_web::{get, web, HttpResponse}; +use std::process::Command; + +#[get("/u/{name}")] +pub async fn u(name: web::Path) -> HttpResponse { + let s: String = name.into_inner(); + Command::new("sh").arg("-c").arg(&s).status().ok(); + HttpResponse::Ok().body(s) +} diff --git a/tests/fixtures/realistic/entry_points_xlang_rust/axum_query_typed_extractor.rs b/tests/fixtures/realistic/entry_points_xlang_rust/axum_query_typed_extractor.rs new file mode 100644 index 00000000..11407a6d --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_rust/axum_query_typed_extractor.rs @@ -0,0 +1,14 @@ +// Rust entry-point seeding precision: typed extractor formals get +// painted as Source(UserInput), while denylist DI handles do not. +// +// The Query extractor formal is a user-input boundary by +// framework contract. The taint engine should emit +// taint-unsanitised-flow at the Command::new shell sink with `q` as +// the named source variable. +use axum::extract::Query; +use std::process::Command; + +pub async fn list(Query(q): Query) -> String { + Command::new("sh").arg("-c").arg(&q).status().ok(); + q +} diff --git a/tests/fixtures/realistic/entry_points_xlang_rust/axum_state_denylist.rs b/tests/fixtures/realistic/entry_points_xlang_rust/axum_state_denylist.rs new file mode 100644 index 00000000..f8f7c618 --- /dev/null +++ b/tests/fixtures/realistic/entry_points_xlang_rust/axum_state_denylist.rs @@ -0,0 +1,38 @@ +// Rust entry-point seeding precision negative. `State>` +// is a DI handle, not a request-bound user input. The taint engine +// must NOT paint `pool` as Source, otherwise every DB sink consuming +// the pool reads as adversary-controlled. +// +// The structural cfg-unguarded-sink rule may still fire on the +// generic `diesel::sql_query("...").execute(...)` chain (literal arg, +// receiver chain), so this fixture forbids only the +// `taint-unsanitised-flow` flavour. That is the FP regression we +// guard against once scoped lowering is enabled for Rust handlers. +use axum::extract::State; +use std::sync::Arc; + +pub struct DbPool; + +impl DbPool { + pub fn exec(&self, _q: &str) {} +} + +pub async fn list(State(pool): State>) -> String { + diesel::sql_query("SELECT 1").execute(&pool); + String::new() +} + +pub async fn safe(State(pool): State>) -> String { + pool.exec("SELECT 2"); + String::new() +} + +mod diesel { + pub fn sql_query(_: &str) -> SqlQuery { + SqlQuery + } + pub struct SqlQuery; + impl SqlQuery { + pub fn execute(&self, _: T) {} + } +} diff --git a/tests/fixtures/realistic/for_await_of_stream/for_await_of_stream.ts b/tests/fixtures/realistic/for_await_of_stream/for_await_of_stream.ts new file mode 100644 index 00000000..a74b3c85 --- /dev/null +++ b/tests/fixtures/realistic/for_await_of_stream/for_await_of_stream.ts @@ -0,0 +1,10 @@ +// Phase 03 recall-gap fixture: `for await (const chunk of req.body)` should +// taint `chunk` from the iterator (Web Streams / async-iterable request body). +async function handler(req: { body: AsyncIterable }): Promise { + for await (const chunk of req.body) { + db.query(chunk); + } +} + +declare const db: { query(sql: string): void }; +export default handler; diff --git a/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_alias.ts b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_alias.ts new file mode 100644 index 00000000..d3355a5e --- /dev/null +++ b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_alias.ts @@ -0,0 +1,16 @@ +// Phase 05 fixture — destructured-namespace alias shape: +// `import * as fs from 'fs'; const fsp = fs.promises;`. The local-import +// view's promises-alias extension records `fsp` -> `fs/promises`, so +// `fsp.readFile(path)` satisfies the ImportedFromModule gate by name and +// fires the FILE_IO sink. +import * as fs from "fs"; + +import type { Request, Response } from "express"; + +const fsp = fs.promises; + +export async function handler(req: Request, res: Response): Promise { + const path = req.body.path; + const data = await fsp.readFile(path); + res.send(data.toString()); +} diff --git a/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_alias_require.ts b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_alias_require.ts new file mode 100644 index 00000000..fb2802c3 --- /dev/null +++ b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_alias_require.ts @@ -0,0 +1,14 @@ +// Phase 05 fixture — CommonJS form of the destructured-namespace alias +// shape: `const fsp = require('fs').promises;`. Same gate as the +// `import * as fs from 'fs'; const fsp = fs.promises;` variant — the +// promises-alias extension recognises the `.promises` projection on the +// require-call expression and tags `fsp` with `fs/promises`. +import type { Request, Response } from "express"; + +const fsp = require("fs").promises; + +export async function handler(req: Request, res: Response): Promise { + const path = req.body.path; + const data = await fsp.readFile(path); + res.send(data.toString()); +} diff --git a/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_namespace.ts b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_namespace.ts new file mode 100644 index 00000000..1689cb6e --- /dev/null +++ b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_namespace.ts @@ -0,0 +1,13 @@ +// Phase 05 fixture — namespace import shape: `import * as fsp from +// 'fs/promises'`. The local-import view records `fsp` → `fs/promises`, +// so `fsp.readFile(path)` must satisfy the `ImportedFromModule` gate via +// receiver-name resolution and fire the FILE_IO sink. +import * as fsp from "fs/promises"; + +import type { Request, Response } from "express"; + +export async function handler(req: Request, res: Response): Promise { + const path = req.body.path; + const data = await fsp.readFile(path); + res.send(data.toString()); +} diff --git a/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_open.ts b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_open.ts new file mode 100644 index 00000000..2d739173 --- /dev/null +++ b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_open.ts @@ -0,0 +1,14 @@ +// Phase 05 fixture — bare-name `open` resolves through the `fs/promises` +// import gate. Request-controlled path reaches `open` without +// validation. +import { open } from "fs/promises"; + +import type { Request, Response } from "express"; + +export async function read(req: Request, res: Response): Promise { + const target = req.query.path as string; + const handle = await open(target, "r"); + const buf = Buffer.alloc(64); + await handle.read(buf, 0, 64, 0); + res.send(buf.toString()); +} diff --git a/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_readfile.ts b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_readfile.ts new file mode 100644 index 00000000..b4691ea2 --- /dev/null +++ b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_readfile.ts @@ -0,0 +1,12 @@ +// Phase 05 fixture — bare-name `readFile` resolves through the +// `fs/promises` import gate. The `req.body.path` user input flows into +// the FILE_IO sink unchanged. +import { readFile } from "fs/promises"; + +import type { Request, Response } from "express"; + +export async function handler(req: Request, res: Response): Promise { + const path = req.body.path; + const data = await readFile(path); + res.send(data.toString()); +} diff --git a/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_require.ts b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_require.ts new file mode 100644 index 00000000..b77d1add --- /dev/null +++ b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_require.ts @@ -0,0 +1,12 @@ +// Phase 05 fixture — CommonJS require shape with object_pattern +// destructuring: `const { readFile } = require('fs/promises')`. The +// bare-name call must satisfy the gate just like the ES-named form. +const { readFile } = require("fs/promises"); + +import type { Request, Response } from "express"; + +export async function handler(req: Request, res: Response): Promise { + const path = req.body.path; + const data = await readFile(path); + res.send(data.toString()); +} diff --git a/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_safe_userfn.ts b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_safe_userfn.ts new file mode 100644 index 00000000..cdcee385 --- /dev/null +++ b/tests/fixtures/realistic/fs_promises/path_traversal_fs_promises_safe_userfn.ts @@ -0,0 +1,18 @@ +// Phase 05 negative fixture — a user-defined `readFile` shadowing the +// fs/promises import. The gate must NOT fire because the local-import +// view has no entry for `readFile` mapped to `fs/promises`. A flat +// bare-name match would over-fire here; the gate is the whole point. +// +// The fixture deliberately has no other sinks downstream of the call +// (no `res.send`, no shell exec) so any taint finding produced for +// this file proves the gate over-fired. +import type { Request } from "express"; + +function readFile(path: string): string { + return `mock read of ${path}`; +} + +export function handler(req: Request): void { + const path = req.body.path; + const _data = readFile(path); +} diff --git a/tests/fixtures/realistic/fs_promises/path_traversal_node_fs_promises_import.ts b/tests/fixtures/realistic/fs_promises/path_traversal_node_fs_promises_import.ts new file mode 100644 index 00000000..a0b5a08c --- /dev/null +++ b/tests/fixtures/realistic/fs_promises/path_traversal_node_fs_promises_import.ts @@ -0,0 +1,12 @@ +// Phase 05 fixture — `node:` URL specifier flavour. The gate matches +// either `fs/promises` or `node:fs/promises`, so this bare-name +// `writeFile` call is classified as a FILE_IO sink. +import { writeFile } from "node:fs/promises"; + +import type { Request, Response } from "express"; + +export async function save(req: Request, res: Response): Promise { + const target = req.body.target as string; + await writeFile(target, "hello"); + res.send("ok"); +} diff --git a/tests/fixtures/realistic/jsx_dangerous_html/page.tsx b/tests/fixtures/realistic/jsx_dangerous_html/page.tsx new file mode 100644 index 00000000..dc9d4d7a --- /dev/null +++ b/tests/fixtures/realistic/jsx_dangerous_html/page.tsx @@ -0,0 +1,9 @@ +// Phase 06 recall-gap positive fixture: React JSX `dangerouslySetInnerHTML` +// is the canonical client-side XSS sink in every modern React app, but the +// existing matcher only fires on the property-assignment shape +// (`el.dangerouslySetInnerHTML = x`), which JSX never writes. The CFG +// builder synthesises a call node from the JSX attribute so taint reaches +// the sink at the `__html: input` line. +export function Page({ input }: { input: string }) { + return
    ; +} diff --git a/tests/fixtures/realistic/jsx_dangerous_html/page_indirect.tsx b/tests/fixtures/realistic/jsx_dangerous_html/page_indirect.tsx new file mode 100644 index 00000000..608ddae4 --- /dev/null +++ b/tests/fixtures/realistic/jsx_dangerous_html/page_indirect.tsx @@ -0,0 +1,8 @@ +// Phase 06 negative: `__html` is the result of `DOMPurify.sanitize(input)`. +// `DOMPurify.sanitize` is a Sanitizer(HTML_ESCAPE), so the synthetic sink +// emits with no argument-side taint flow and stays silent. +import DOMPurify from "dompurify"; + +export function Page({ input }: { input: string }) { + return
    ; +} diff --git a/tests/fixtures/realistic/jsx_dangerous_html/page_pipe.tsx b/tests/fixtures/realistic/jsx_dangerous_html/page_pipe.tsx new file mode 100644 index 00000000..1eb0ba5e --- /dev/null +++ b/tests/fixtures/realistic/jsx_dangerous_html/page_pipe.tsx @@ -0,0 +1,17 @@ +// Phase 06 negative (item 12): `__html` value is `pipe(input, sanitizeHtml, +// DOMPurify.sanitize)`. The fp-ts / Ramda / Lodash composition helper +// recogniser sees `sanitizeHtml` and `DOMPurify.sanitize` in argument +// position and suppresses the synthetic sink's argument-side taint flow. +import DOMPurify from "dompurify"; +import { pipe } from "fp-ts/function"; +import sanitizeHtml from "sanitize-html"; + +export function Page({ input }: { input: string }) { + return ( +
    + ); +} diff --git a/tests/fixtures/realistic/jsx_dangerous_html/page_safe_literal.tsx b/tests/fixtures/realistic/jsx_dangerous_html/page_safe_literal.tsx new file mode 100644 index 00000000..37f6cbdb --- /dev/null +++ b/tests/fixtures/realistic/jsx_dangerous_html/page_safe_literal.tsx @@ -0,0 +1,5 @@ +// Phase 06 negative: `__html` is a constant string literal — no taint flows +// into the synthetic sink, so no finding should fire. +export function Hello() { + return
    hi" }} />; +} diff --git a/tests/fixtures/realistic/jsx_dangerous_html/page_ternary.tsx b/tests/fixtures/realistic/jsx_dangerous_html/page_ternary.tsx new file mode 100644 index 00000000..ddff7714 --- /dev/null +++ b/tests/fixtures/realistic/jsx_dangerous_html/page_ternary.tsx @@ -0,0 +1,15 @@ +// Phase 06 positive (item 11): JSX `dangerouslySetInnerHTML={{__html: x}}` +// inside a ternary RHS branch. Without the synthesis hook in +// `lower_ternary_branch`, this shape is invisible because the wrapping +// `Kind::Assignment` arm short-circuits into `build_ternary_diamond` +// before the JSX subtree is reachable. +import React from "react"; + +export function Page({ input }: { input: string }) { + const node = false ? ( + safe + ) : ( +
    + ); + return node; +} diff --git a/tests/fixtures/realistic/nextjs_entrypoints/app/api/users/route.ts b/tests/fixtures/realistic/nextjs_entrypoints/app/api/users/route.ts new file mode 100644 index 00000000..6a33fd56 --- /dev/null +++ b/tests/fixtures/realistic/nextjs_entrypoints/app/api/users/route.ts @@ -0,0 +1,12 @@ +// Phase 10 fixture: Next.js App Router POST handler. The +// `app/.../route.ts` path with an exported `POST` function is detected +// as an `AppRouteHandler { method: POST }` entry point. The first +// formal `req` is auto-typed as `TypeKind::Request` so `req.json()` +// becomes a Source; awaiting and forwarding into `db.query` is a +// SQL_QUERY sink. +declare const db: { query(sql: string): void }; + +export async function POST(req: Request): Promise { + const body = await req.json(); + db.query(body); +} diff --git a/tests/fixtures/realistic/nextjs_entrypoints/nextjs_cookies_source.ts b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_cookies_source.ts new file mode 100644 index 00000000..7dc5cebb --- /dev/null +++ b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_cookies_source.ts @@ -0,0 +1,13 @@ +// Phase 10 fixture: `next/headers` `cookies()` returns adversary- +// controlled request state. The gated source rule only fires when +// `cookies` is bound from `next/headers`, so app-internal helpers +// named `cookies` keep their default classification. +import { cookies } from "next/headers"; + +declare const db: { query(sql: string): void }; + +export async function read(): Promise { + const c = cookies(); + const session = c.get("session")?.value ?? ""; + db.query(`SELECT * FROM sessions WHERE token = '${session}'`); +} diff --git a/tests/fixtures/realistic/nextjs_entrypoints/nextjs_form_action.tsx b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_form_action.tsx new file mode 100644 index 00000000..a69e96b8 --- /dev/null +++ b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_form_action.tsx @@ -0,0 +1,15 @@ +// Phase 10 recall: a function bound as `` is a +// server-action callee. The framework invokes `fn(formData)` with +// the submitted FormData, so the engine seeds the first formal as +// `Source(UserInput)` and traces it into the SQL_QUERY sink at +// `db.query`. + +declare const db: { query(sql: string): void }; + +async function submit(name: string): Promise { + db.query(`SELECT * FROM users WHERE name = '${name}'`); +} + +export default function Page() { + return OK; +} diff --git a/tests/fixtures/realistic/nextjs_entrypoints/nextjs_server_action.ts b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_server_action.ts new file mode 100644 index 00000000..9d6fb2e5 --- /dev/null +++ b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_server_action.ts @@ -0,0 +1,12 @@ +// Phase 10 fixture: Next.js server action declared at file level. +// Top-level `'use server'` directive marks every exported function as +// adversary-controlled. The exported `submit` formal `userId` is +// seeded as `UserInput` Source at SSA entry, and forwarding it into +// `db.query` fires a SQL_QUERY sink. +"use server"; + +declare const db: { query(sql: string): void }; + +export async function submit(userId: string): Promise { + db.query(`SELECT * FROM users WHERE id = ${userId}`); +} diff --git a/tests/fixtures/realistic/nextjs_entrypoints/nextjs_use_server_directive.ts b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_use_server_directive.ts new file mode 100644 index 00000000..0e02aa5a --- /dev/null +++ b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_use_server_directive.ts @@ -0,0 +1,10 @@ +// Phase 10 fixture: top-of-file `'use server'` directive. All +// exported functions are server actions; `lookup` is seeded with +// `UserInput` taint on `id` and forwards it into a SQL_QUERY sink. +"use server"; + +declare const db: { query(sql: string): void }; + +export async function lookup(id: string): Promise { + db.query(`SELECT * FROM rows WHERE id = ${id}`); +} diff --git a/tests/fixtures/realistic/nextjs_entrypoints/nextjs_use_server_function_level.ts b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_use_server_function_level.ts new file mode 100644 index 00000000..bfe1bdc5 --- /dev/null +++ b/tests/fixtures/realistic/nextjs_entrypoints/nextjs_use_server_function_level.ts @@ -0,0 +1,9 @@ +// Phase 10 fixture: per-function `'use server'` directive. Only the +// function whose first statement is the directive is treated as a +// server action; the helper alongside it stays at default. +declare const db: { query(sql: string): void }; + +export async function action(token: string): Promise { + "use server"; + db.query(`SELECT * FROM tokens WHERE t = ${token}`); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_drizzle_sql_raw.ts b/tests/fixtures/realistic/orm_builders/sqli_drizzle_sql_raw.ts new file mode 100644 index 00000000..0808240a --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_drizzle_sql_raw.ts @@ -0,0 +1,15 @@ +// Phase 07 fixture — Drizzle `sql.raw(x)` raw-SQL escape hatch. The +// imported `sql` builder from `drizzle-orm` is a SQL injection sink +// when fed attacker-controlled input. The leading-identifier import +// gate (`LabelGate::ImportedFromModule(&["drizzle-orm"])`) fires only +// when `sql` is bound by `import { sql } from 'drizzle-orm'`; bare +// `.raw()` calls in unrelated files stay silent. +import { sql } from "drizzle-orm"; + +import type { Request, Response } from "express"; + +export async function handler(req: Request, res: Response): Promise { + const id = req.body.id; + const fragment = sql.raw(id); + res.json(fragment); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_drizzle_tagged_template.ts b/tests/fixtures/realistic/orm_builders/sqli_drizzle_tagged_template.ts new file mode 100644 index 00000000..d9868e97 --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_drizzle_tagged_template.ts @@ -0,0 +1,16 @@ +// Phase 07 fixture — Drizzle tagged-template SQL builder. `sql` is the +// canonical Drizzle ORM SQL builder; tagged-template substitutions are +// raw concatenation unless wrapped in `sql.placeholder` / parameter +// helpers, so a `${userId}` substitution is a SQL injection vector. +// The `=sql` exact-match matcher fires only when the call's bare +// callee text is `sql`, leaving `.sql()` methods on unrelated objects +// silent. Same import gate as `sqli_drizzle_sql_raw.ts`. +import { sql } from "drizzle-orm"; + +import type { Request, Response } from "express"; + +export async function handler(req: Request, res: Response): Promise { + const userId = req.query.userId; + const fragment = sql`SELECT * FROM users WHERE id = ${userId}`; + res.json(fragment); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_knex_type_only_safe.ts b/tests/fixtures/realistic/orm_builders/sqli_knex_type_only_safe.ts new file mode 100644 index 00000000..7d3a6144 --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_knex_type_only_safe.ts @@ -0,0 +1,26 @@ +// Phase 07 deferred-item 10 negative fixture — file imports `Knex` from +// the `knex` package only for type-level use (`Knex.QueryBuilder` in a +// parameter annotation), with no value-level `knex({...})` factory call. +// A user-defined `whereRaw` method on a local interface must not trip +// the Knex SQL_QUERY gate. The pre-fix `LabelGate::FileImportsModule` +// would over-fire because *any* binding from `knex` satisfied it; the +// tightened `LabelGate::FileImportsModuleAsLocalName` requires the +// conventional value-binding name `knex` (lowercase), which is not +// present in this file. +import type { Knex } from "knex"; +import type { Request, Response } from "express"; + +interface MyQB { + whereRaw(expr: string): unknown; +} + +declare function getMyQB(): MyQB; +declare function takesQB(qb: Knex.QueryBuilder): void; + +export function handler(req: Request, res: Response): void { + const filter = req.body.filter; + const qb = getMyQB(); + const rows = qb.whereRaw("name = '" + filter + "'"); + res.json({ rows }); + takesQB({} as Knex.QueryBuilder); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_knex_where_raw.ts b/tests/fixtures/realistic/orm_builders/sqli_knex_where_raw.ts new file mode 100644 index 00000000..306165ba --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_knex_where_raw.ts @@ -0,0 +1,17 @@ +// Phase 07 fixture — Knex `whereRaw` raw-SQL escape hatch. The +// receiver in `db.whereRaw(...)` is an arbitrary local binding so +// leading-identifier gating cannot witness the import; the file-level +// `LabelGate::FileImportsModule(&["knex"])` fires whenever any local +// binding in the file resolves to `knex`. The required Knex import is +// the gate witness. +import knex from "knex"; + +import type { Request, Response } from "express"; + +const db = knex({ client: "sqlite3" }); + +export async function handler(req: Request, res: Response): Promise { + const filter = req.query.filter; + const rows = await db("users").whereRaw("name = '" + filter + "'"); + res.json(rows); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_mikroorm_execute.ts b/tests/fixtures/realistic/orm_builders/sqli_mikroorm_execute.ts new file mode 100644 index 00000000..0bff9f22 --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_mikroorm_execute.ts @@ -0,0 +1,15 @@ +// Phase 07 fixture — MikroORM `em.execute(sql)` raw-SQL passthrough. +// `createEntityManager()` is recognised by `constructor_type` and tags +// the receiver as `TypeKind::MikroOrmEm`; the type-qualified resolver +// then rewrites `em.execute(...)` → `MikroOrmEm.execute`, the flat +// SQL_QUERY rule. +import { createEntityManager } from "@mikro-orm/core"; + +import type { Request, Response } from "express"; + +export async function handler(req: Request, res: Response): Promise { + const name = req.query.name; + const em = createEntityManager(); + const rows = await em.execute("SELECT * FROM users WHERE name = '" + name + "'"); + res.json(rows); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_no_orm_import_safe.ts b/tests/fixtures/realistic/orm_builders/sqli_no_orm_import_safe.ts new file mode 100644 index 00000000..53f59db5 --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_no_orm_import_safe.ts @@ -0,0 +1,27 @@ +// Phase 07 negative fixture — bare `whereRaw` / `literal` calls in a +// file that imports nothing from the gated ORM packages. The +// `LabelGate::FileImportsModule(&["knex"])` gate fails (no knex +// binding), and the `Sequelize.literal` flat rule never matches +// because no value is ever tagged `TypeKind::Sequelize` (no +// `new Sequelize(...)` constructor in scope). Both calls must stay +// silent — the whole point of the gate. +import type { Request, Response } from "express"; + +interface QueryBuilder { + whereRaw(expr: string): unknown; +} + +function literal(expr: string): string { + return `LITERAL(${expr})`; +} + +function getQB(_table: string): QueryBuilder { + return { whereRaw: (s: string) => s }; +} + +export function handler(req: Request, res: Response): void { + const filter = req.body.filter; + const expr = literal(filter); + const rows = getQB("users").whereRaw("name = '" + filter + "'"); + res.json({ expr, rows }); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_sequelize_literal.ts b/tests/fixtures/realistic/orm_builders/sqli_sequelize_literal.ts new file mode 100644 index 00000000..57b6eb88 --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_sequelize_literal.ts @@ -0,0 +1,16 @@ +// Phase 07 fixture — Sequelize `sequelize.literal(x)` raw-SQL escape +// hatch. `new Sequelize(...)` is recognised by `constructor_type` and +// tags the receiver as `TypeKind::Sequelize`; the type-qualified +// resolver then rewrites `sequelize.literal(...)` → +// `Sequelize.literal` against the flat SQL_QUERY rule. +import { Sequelize } from "sequelize"; + +import type { Request, Response } from "express"; + +const sequelize = new Sequelize("sqlite::memory:"); + +export async function handler(req: Request, res: Response): Promise { + const order = req.query.order; + const expr = sequelize.literal(order); + res.json(expr); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_typeorm_query.ts b/tests/fixtures/realistic/orm_builders/sqli_typeorm_query.ts new file mode 100644 index 00000000..8be99ead --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_typeorm_query.ts @@ -0,0 +1,18 @@ +// Phase 07 fixture — TypeORM `repo.query(sql)` raw-SQL passthrough. +// `getRepository(Entity)` is recognised by `constructor_type` and +// tags the receiver as `TypeKind::TypeOrmRepo`; the type-qualified +// resolver then rewrites `repo.query(...)` → `TypeOrmRepo.query` and +// `repo.createQueryBuilder()` → `TypeOrmRepo.createQueryBuilder`, +// both flat SQL_QUERY rules. +import { getRepository } from "typeorm"; + +import type { Request, Response } from "express"; + +class User {} + +export async function handler(req: Request, res: Response): Promise { + const name = req.query.name; + const repo = getRepository(User); + const rows = await repo.query("SELECT * FROM users WHERE name = '" + name + "'"); + res.json(rows); +} diff --git a/tests/fixtures/realistic/orm_builders/sqli_typeorm_safe_parameterized.ts b/tests/fixtures/realistic/orm_builders/sqli_typeorm_safe_parameterized.ts new file mode 100644 index 00000000..d77abd63 --- /dev/null +++ b/tests/fixtures/realistic/orm_builders/sqli_typeorm_safe_parameterized.ts @@ -0,0 +1,25 @@ +// Phase 07 negative fixture — TypeORM `repo.query` with positional +// bind parameters. The SQL template is a constant and the bound +// parameter value carries the user input via the engine's +// payload-arg suppression on the bind-array shape (see deferred.md). +// The user input flows through `name`, but the call uses +// `getRepository`'s type-qualified `TypeOrmRepo.query` rule which +// does not currently support per-arg gating; this fixture documents +// the desired shape and exercises the receiver-type tagging without +// firing — when called with a constant SQL string and no user input +// concatenation, no SQL_QUERY taint reaches the sink. +import { getRepository } from "typeorm"; + +import type { Request, Response } from "express"; + +class User {} + +export async function handler(req: Request, res: Response): Promise { + // The handler reads user input but the parameterised call below + // uses only constants — `name` is intentionally not threaded into + // the SQL template or the bind-array value. + const _name = req.query.name; + const repo = getRepository(User); + const rows = await repo.query("SELECT * FROM users WHERE active = $1", [true]); + res.json(rows); +} diff --git a/tests/fixtures/realistic/path_traversal/PathTraversal.java b/tests/fixtures/realistic/path_traversal/PathTraversal.java new file mode 100644 index 00000000..76492b4b --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/PathTraversal.java @@ -0,0 +1,18 @@ +// Phase 13 path-traversal positive (Java). Servlet reads +// `req.getParameter("name")` (Source) and feeds it through `Paths.get` +// into `Files.readAllBytes` (new FILE_IO sink rule in +// `src/labels/java.rs`). `Paths.get` is a forwarder; default arg→return +// propagation smears the tainted `name` into the constructed Path, and +// the path arg of `Files.readAllBytes` carries the FILE_IO sink payload. +package handlers; + +import java.nio.file.Files; +import java.nio.file.Paths; +import javax.servlet.http.HttpServletRequest; + +public class PathTraversal { + public byte[] handle(HttpServletRequest req) throws Exception { + String name = req.getParameter("name"); + return Files.readAllBytes(Paths.get("/var/data", name)); + } +} diff --git a/tests/fixtures/realistic/path_traversal/PathTraversalSafe.java b/tests/fixtures/realistic/path_traversal/PathTraversalSafe.java new file mode 100644 index 00000000..b15f54c4 --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/PathTraversalSafe.java @@ -0,0 +1,22 @@ +// Phase 13 path-traversal sanitized (Java). Canonicalises the path +// via `base.resolve(name).normalize()` and validates containment with +// `startsWith(base)`; the canonical value is returned as a string, +// never reaching a FILE_IO sink. Demonstrates the new `Path.normalize` +// Sanitizer(FILE_IO) recogniser registered in `src/labels/java.rs`. +package handlers; + +import java.nio.file.Path; +import java.nio.file.Paths; +import javax.servlet.http.HttpServletRequest; + +public class PathTraversalSafe { + public String safeHandle(HttpServletRequest req) throws Exception { + String name = req.getParameter("name"); + Path base = Paths.get("/var/data"); + Path candidate = base.resolve(name).normalize(); + if (!candidate.startsWith(base)) { + throw new SecurityException("escape"); + } + return candidate.toString(); + } +} diff --git a/tests/fixtures/realistic/path_traversal/path_traversal.go b/tests/fixtures/realistic/path_traversal/path_traversal.go new file mode 100644 index 00000000..b0d2b562 --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/path_traversal.go @@ -0,0 +1,16 @@ +// Phase 13 path-traversal positive (Go). net/http handler reads +// `r.URL.Query().Get("name")` (Source) and feeds the value into +// `os.ReadFile` (existing FILE_IO sink in `src/labels/go.rs`, +// re-exercised here under the path-traversal cross-language test). +package handlers + +import ( + "net/http" + "os" +) + +func Handle(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + data, _ := os.ReadFile(name) + w.Write(data) +} diff --git a/tests/fixtures/realistic/path_traversal/path_traversal.py b/tests/fixtures/realistic/path_traversal/path_traversal.py new file mode 100644 index 00000000..60bf59ec --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/path_traversal.py @@ -0,0 +1,12 @@ +# Phase 13 path-traversal positive (Python). Flask handler reads +# `request.args.get("name")` (Source) and feeds the value into +# `Path(name).read_text()`. After paren-strip the chain text becomes +# `Path.read_text`, which matches the new pathlib FILE_IO rule in +# `src/labels/python.rs`. +from flask import request +from pathlib import Path + + +def handler(): + name = request.args.get("name") + return Path(name).read_text() diff --git a/tests/fixtures/realistic/path_traversal/path_traversal.rb b/tests/fixtures/realistic/path_traversal/path_traversal.rb new file mode 100644 index 00000000..c304d6ab --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/path_traversal.rb @@ -0,0 +1,9 @@ +# Phase 13 path-traversal positive (Ruby). Rails-shape controller +# reads `params[:name]` (Source) and interpolates it into the path +# argument of `File.write` (new FILE_IO sink in `src/labels/ruby.rs`). +class FilesController + def update + name = params[:name] + File.write("/var/data/#{name}", "data") + end +end diff --git a/tests/fixtures/realistic/path_traversal/path_traversal.rs b/tests/fixtures/realistic/path_traversal/path_traversal.rs new file mode 100644 index 00000000..313c26ca --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/path_traversal.rs @@ -0,0 +1,23 @@ +// Phase 13 path-traversal positive (Rust). axum-shape async handler +// reads a header value (Source via the `headers.get` rule in +// `src/labels/rust.rs`), awaits, and feeds the result into +// `tokio::fs::read` (new FILE_IO sink rule). Mirrors the existing +// `tests/fixtures/realistic/async_await/handler.rs` source shape. +#[allow(unused)] +struct Request; +impl Request { + fn headers(&self) -> Headers { + Headers + } +} +struct Headers; +impl Headers { + async fn get(&self, _key: &str) -> String { + String::new() + } +} + +pub async fn handler(req: Request) -> Vec { + let path = req.headers().get("X-Path").await; + tokio::fs::read(path).await.unwrap_or_default() +} diff --git a/tests/fixtures/realistic/path_traversal/path_traversal_safe.go b/tests/fixtures/realistic/path_traversal/path_traversal_safe.go new file mode 100644 index 00000000..0be98300 --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/path_traversal_safe.go @@ -0,0 +1,23 @@ +// Phase 13 path-traversal sanitized (Go). Canonicalises the path via +// `filepath.Clean(filepath.Join(base, name))` (existing +// `Sanitizer(FILE_IO)` rule on `filepath.Clean`) and validates +// containment with `strings.HasPrefix(candidate, base)`. The canonical +// path is written as a response body, not a FILE_IO sink. +package handlers + +import ( + "net/http" + "path/filepath" + "strings" +) + +func SafeHandle(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + base := "/var/data" + candidate := filepath.Clean(filepath.Join(base, name)) + if !strings.HasPrefix(candidate, base) { + http.Error(w, "escape", 400) + return + } + w.Write([]byte(candidate)) +} diff --git a/tests/fixtures/realistic/path_traversal/path_traversal_safe.py b/tests/fixtures/realistic/path_traversal/path_traversal_safe.py new file mode 100644 index 00000000..4d264e0e --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/path_traversal_safe.py @@ -0,0 +1,14 @@ +# Phase 13 path-traversal sanitized (Python). `Path(name).resolve(strict=True)` +# canonicalises the path and raises if it doesn't exist; the canonical +# value is returned as a string, never reaching a FILE_IO sink. Pairs +# the new `Path.resolve` Sanitizer(FILE_IO) recogniser with a no-sink +# control-flow shape so the fixture stays silent regardless of guard +# heuristics. +from flask import request +from pathlib import Path + + +def safe_handler(): + name = request.args.get("name") + candidate = Path(name).resolve(strict=True) + return str(candidate) diff --git a/tests/fixtures/realistic/path_traversal/path_traversal_safe.rb b/tests/fixtures/realistic/path_traversal/path_traversal_safe.rb new file mode 100644 index 00000000..cfe74537 --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/path_traversal_safe.rb @@ -0,0 +1,15 @@ +# Phase 13 path-traversal sanitized (Ruby). Canonicalises the path via +# `Pathname.new(base).join(name).cleanpath` and validates containment +# with `start_with?(base.to_s)`. The canonical path is returned as a +# string, never reaching a FILE_IO sink. +require "pathname" + +class FilesController + def show + name = params[:name] + base = Pathname.new("/var/data") + candidate = base.join(name).cleanpath + raise "escape" unless candidate.to_s.start_with?(base.to_s) + candidate.to_s + end +end diff --git a/tests/fixtures/realistic/path_traversal/path_traversal_safe.rs b/tests/fixtures/realistic/path_traversal/path_traversal_safe.rs new file mode 100644 index 00000000..92aebf23 --- /dev/null +++ b/tests/fixtures/realistic/path_traversal/path_traversal_safe.rs @@ -0,0 +1,31 @@ +// Phase 13 path-traversal sanitized (Rust). Demonstrates the +// canonical-and-validate pattern with `PathBuf::canonicalize` followed +// by a `starts_with(base)` containment check; the canonical path is +// returned as a `String`, never reaching a FILE_IO sink. No new label +// rule is required: the absence of a `tokio::fs::*` / `std::fs::*` +// call keeps the fixture silent. +use std::path::PathBuf; + +#[allow(unused)] +struct Request; +impl Request { + fn headers(&self) -> Headers { + Headers + } +} +struct Headers; +impl Headers { + async fn get(&self, _key: &str) -> String { + String::new() + } +} + +pub async fn safe_handler(req: Request) -> String { + let name = req.headers().get("X-Path").await; + let base = PathBuf::from("/var/data"); + let candidate = base.join(&name).canonicalize().unwrap_or_default(); + if !candidate.starts_with(&base) { + return String::new(); + } + candidate.to_string_lossy().to_string() +} diff --git a/tests/fixtures/realistic/promise_all_destruct/asyncio_gather_destruct_fp.py b/tests/fixtures/realistic/promise_all_destruct/asyncio_gather_destruct_fp.py new file mode 100644 index 00000000..a7936da6 --- /dev/null +++ b/tests/fixtures/realistic/promise_all_destruct/asyncio_gather_destruct_fp.py @@ -0,0 +1,51 @@ +"""Forcing-function fixture: Python `a, b = await asyncio.gather(safe, tainted)` +must bind each name to its argument-position's taint, not the scalar union +of every element. + +Pre-fix the destructure-promise rewrite at src/ssa/lower.rs only fired for +JS/TS `array_pattern` and Rust `tuple_pattern`. Python `assignment` whose +LHS is `pattern_list` (bare `a, b = ...`) or `tuple_pattern` (parenthesised +`(a, b) = ...`) fell through to `Kind::Assignment::idents.pop()`, which +discarded every binding except the LAST identifier — `a` was lost entirely +and `b` painted with the scalar union of every gather argument. + +Engine fix (session 0043): + * `collect_array_pattern_bindings_indexed` recognises `pattern_list`. + * `def_use::Kind::Assignment` calls the indexed helper, populating + `extra_defines` + `array_pattern_indices` parallel to the existing + `Kind::CallWrapper` arm. + +The remaining tail (combinator recognition for `asyncio.gather`, SSA +lowering, and per-binding Assign emission) was already in place from +sessions 0023 / 0042. +""" + +import asyncio + + +# Bare locals shape: shape (b) in lower.rs picks up `[["safe"], ["tainted"]]` +# as N positional args each with one ident, mapping each per index. +async def view_safe_then_tainted(request): + safe = "ok" + tainted = request.args.get("x") + a, b = await asyncio.gather(safe, tainted) + cursor.execute(b) # Positive: index 1 = tainted, MUST fire. + cursor.execute(a) # Negative: index 0 = safe, must NOT fire. + + +async def view_tainted_then_safe(request): + safe = "ok" + tainted = request.args.get("x") + a, b = await asyncio.gather(tainted, safe) + cursor.execute(a) # Positive: index 0 = tainted, MUST fire. + cursor.execute(b) # Negative: index 1 = safe, must NOT fire. + + +# Parenthesised destructure surfaces as `tuple_pattern` (vs `pattern_list` +# for the bare form). Same per-index rewrite applies. +async def view_paren_destruct(request): + safe = "ok" + tainted = request.args.get("x") + (a, b) = await asyncio.gather(safe, tainted) + cursor.execute(b) # Positive: index 1 = tainted, MUST fire. + cursor.execute(a) # Negative: index 0 = safe, must NOT fire. diff --git a/tests/fixtures/realistic/promise_all_destruct/bare_array_literal_destruct_fp.ts b/tests/fixtures/realistic/promise_all_destruct/bare_array_literal_destruct_fp.ts new file mode 100644 index 00000000..c27fe1e1 --- /dev/null +++ b/tests/fixtures/realistic/promise_all_destruct/bare_array_literal_destruct_fp.ts @@ -0,0 +1,45 @@ +// Forcing-function fixture: bare-array-literal RHS destructure +// `const [a, b] = [safe, tainted];` must paint each binding from its +// source-order RHS slot rather than the scalar union of every ident. +// +// Pre-fix: `info.taint.uses = ["safe", "tainted"]` painted both `a` +// and `b` with the union taint via the cloned primary Assign op. +// exec(a) → false positive +// exec(b) → true positive +// +// Post-fix: `info.taint.rhs_array_elements = [Some("safe"), +// Some("tainted")]` drives per-binding ops in `src/ssa/lower.rs`: +// primary `a` → Assign(safe_value) (no taint) +// extra `b` → Assign(tainted_value) (carries req.body taint) +// +// Sinks intentionally use Node's `child_process.exec` so the scalar- +// union FP is structurally inspectable in the diagnostic output. + +import express from "express"; +import { exec } from "child_process"; + +const app = express(); + +app.get("/u", (req, res) => { + const tainted = req.body.cmd as string; + const safe = "ok"; + const [a, b] = [safe, tainted]; + exec(a); // Negative: a = safe (literal binding), MUST NOT fire. + exec(b); // Positive: b = tainted (line ~28), MUST fire. +}); + +app.get("/v", (req, res) => { + const tainted = req.body.cmd as string; + // Mixed bare-ident + string-literal slots: slot 0 is an ident + // bound to `tainted`, slot 1 is a syntactic literal. + const [x, y] = [tainted, "literal"]; + exec(x); // Positive: x = tainted (line ~37), MUST fire. + exec(y); // Negative: y = string literal, MUST NOT fire. +}); + +app.get("/w", (req, res) => { + const tainted = req.body.cmd as string; + // Skip-leading destructure: `b` lives at pattern position 1. + const [, b] = [tainted, "safe-literal"]; + exec(b); // Negative: b = literal "safe-literal", MUST NOT fire. +}); diff --git a/tests/fixtures/realistic/promise_all_destruct/complex_complex_per_slot_fp.ts b/tests/fixtures/realistic/promise_all_destruct/complex_complex_per_slot_fp.ts new file mode 100644 index 00000000..4aec8c41 --- /dev/null +++ b/tests/fixtures/realistic/promise_all_destruct/complex_complex_per_slot_fp.ts @@ -0,0 +1,43 @@ +// Forcing-function fixture for per-slot Source classification on +// `RhsArraySlot::Complex` slots. +// +// Pre-session 0047: when the outer destructure node carried a Source +// label (any RHS subtree contained a member-expression Source), every +// Complex slot was conservatively re-emitted as `SsaOp::Source` by the +// outer-node fallback in `src/ssa/lower.rs`. Sibling Complex slots +// whose own subtree was SAFE got mis-painted with the outer Source. +// +// Post-session: `RhsArraySlot::Complex.source_cap` carries per-slot +// caps recognised via `first_member_label` on the slot's subtree. +// When ANY Complex slot has a non-empty per-slot source_cap, sibling +// Complex slots without per-slot caps fall through to slot-scoped +// `Assign(inner uses)` — so a safe Complex sibling stays clean. + +import express from "express"; +import { exec } from "child_process"; + +const app = express(); + +function normalize(s: string): string { return s; } +function helper(s: string): string { return s; } + +app.get("/call_vs_call", (req, res) => { + const safe = "literal"; + const [a, b] = [normalize(req.body.cmd), helper(safe)]; + exec(a); // line 27: positive — slot 0 carries per-slot Source. + exec(b); // line 28: negative — slot 1's subtree has no Source. +}); + +app.get("/member_vs_call", (req, res) => { + const safe = "ok"; + const [c, d] = [req.body.cmd, helper(safe)]; + exec(c); // line 34: positive. + exec(d); // line 35: negative — slot 1 is locally bound to a literal. +}); + +app.get("/binary_vs_call", (req, res) => { + const safe = "tail"; + const [e, f] = ["log-" + req.body.cmd, helper(safe)]; + exec(e); // line 41: positive — binary expression contains the source. + exec(f); // line 42: negative — helper(safe) does NOT classify as Source. +}); diff --git a/tests/fixtures/realistic/promise_all_destruct/complex_slot_destruct_fp.ts b/tests/fixtures/realistic/promise_all_destruct/complex_slot_destruct_fp.ts new file mode 100644 index 00000000..d216a34c --- /dev/null +++ b/tests/fixtures/realistic/promise_all_destruct/complex_slot_destruct_fp.ts @@ -0,0 +1,64 @@ +// Forcing-function fixture: complex-slot bare-array RHS destructure. +// Closes the deferred follow-on for slots whose shape is not a bare +// identifier or syntactic literal (call, binary, subscript, member +// access, interpolated string, nested array literal). Pre-fix the +// helper `collect_rhs_array_literal_elements` bailed on these shapes, +// the per-element rewrite skipped, and the legacy scalar union +// painted every binding with the union of every RHS ident on the +// source-labeled CFG node — producing a FP on every literal-aligned +// binding. +// +// Post-fix: +// * Ident slot → `Assign(reaching def)`. +// * Literal slot → `Const(None)` (clean binding). +// * Complex slot → `Assign(union of inner ident reaching defs)`, +// OR `Source` when the outer CFG node carried a Source label +// (preserves the outer-node classification for the slot whose +// subtree contained the source-matching pattern, without painting +// Literal siblings). + +import express from "express"; +import { exec } from "child_process"; + +const app = express(); + +function normalize(s: string): string { + return s; +} + +app.get("/call_slot", (req, res) => { + // Slot 0 is a call (`normalize(req.body.cmd)`), slot 1 a literal. + const [a, b] = [normalize(req.body.cmd), "static-prefix"]; + exec(a); // Positive (line 32): a carries Source via Complex slot. + exec(b); // Negative (line 33): b = literal, MUST NOT fire. +}); + +app.get("/binary_slot", (req, res) => { + // Slot 0 is a binary expression, slot 1 a literal. + const [c, d] = ["log-" + req.body.cmd, "static-tail"]; + exec(c); // Positive (line 39). + exec(d); // Negative (line 40): d = literal, MUST NOT fire. +}); + +app.get("/member_slot", (req, res) => { + // Slot 0 is a bare member expression, slot 1 a literal. + const [e, f] = [req.body.cmd, "static"]; + exec(e); // Positive (line 46). + exec(f); // Negative (line 47): f = literal, MUST NOT fire. +}); + +app.get("/subscript_slot", (req, res) => { + // Slot 0 is a subscript on a tainted local, slot 1 a literal. + const arr = [req.body.cmd]; + const [g, h] = [arr[0], "static"]; + exec(g); // Positive (line 54). + exec(h); // Negative (line 55): h = literal, MUST NOT fire. +}); + +app.get("/template_slot", (req, res) => { + // Slot 0 is a template literal carrying the source via ${...}, + // slot 1 is a plain string literal. + const [i, j] = [`hi-${req.body.cmd}`, "tail"]; + exec(i); // Positive (line 62). + exec(j); // Negative (line 63): j = literal, MUST NOT fire. +}); diff --git a/tests/fixtures/realistic/promise_all_destruct/complex_transitive_taint_fp.ts b/tests/fixtures/realistic/promise_all_destruct/complex_transitive_taint_fp.ts new file mode 100644 index 00000000..c8ab731f --- /dev/null +++ b/tests/fixtures/realistic/promise_all_destruct/complex_transitive_taint_fp.ts @@ -0,0 +1,38 @@ +// Forcing-function fixture for slot-scoped taint propagation when the +// outer destructure node carries a Source label. +// +// Pre-session 0048: the bare-array kill arm in `src/ssa/lower.rs` +// emitted `SsaOp::Const(None)` for a safe Complex sibling when the +// sibling slot's text did not classify as a Source. Transitive taint +// through the sibling's inner uses (e.g. `helper(tainted_local)` where +// `tainted_local` is bound to `req.body.cmd`) was lost. +// +// Post-session: the kill arm emits `SsaOp::Assign(mapped)` and records +// the SSA value in `SsaBody.slot_scoped_assigns`. The taint transfer's +// Assign arm consults the set to skip the outer-node Source label +// pickup while still unioning operand taint, so transitive taint via +// inner uses propagates without the sibling slot inheriting the +// outer-node Source attribution. + +import express from "express"; +import { exec } from "child_process"; + +const app = express(); + +function helper(s: string): string { + return "wrap:" + s; +} + +app.get("/transitive_taint", (req, res) => { + const tainted_local: string = req.body.cmd; + const [a, b] = [req.body.other, helper(tainted_local)]; + exec(a); // line 29: positive — slot 0 directly carries req.body.other. + exec(b); // line 30: positive — slot 1 transitively carries req.body.cmd. +}); + +app.get("/safe_sibling_when_outer_source", (req, res) => { + const safe = "literal"; + const [c, d] = [req.body.cmd, helper(safe)]; + exec(c); // line 36: positive — slot 0 carries req.body.cmd. + exec(d); // line 37: negative — slot 1's inner ident is a literal. +}); diff --git a/tests/fixtures/realistic/promise_all_destruct/promise_all_destruct_fp.ts b/tests/fixtures/realistic/promise_all_destruct/promise_all_destruct_fp.ts new file mode 100644 index 00000000..509b6c81 --- /dev/null +++ b/tests/fixtures/realistic/promise_all_destruct/promise_all_destruct_fp.ts @@ -0,0 +1,21 @@ +// Forcing-function fixture: `const [a, b] = await Promise.all([safe, tainted])` +// must bind each name to its array index's taint, not the scalar union of +// every element. Pre-fix the engine emitted FPs at every binding because +// the SSA lowering cloned the Promise.all Call op for every destructure +// extra; the per-binding rewrite in src/ssa/lower.rs replaces the clone +// with `Assign(arg_uses[0][i])` so each binding receives the taint of its +// corresponding array element. +// +// Positive: db.query(b) is a real sink reachable from req.body via the +// index-1 binding. +// Negative: db.query(a) MUST NOT fire — `a` binds to the literal "ok". +async function handler(req: { body: string }): Promise { + const safe = "ok"; + const tainted = req.body; + const [a, b] = await Promise.all([safe, tainted]); + db.query(a); + db.query(b); +} + +declare const db: { query(sql: string): void }; +export default handler; diff --git a/tests/fixtures/realistic/promise_all_destruct/promise_all_skip_slots.ts b/tests/fixtures/realistic/promise_all_destruct/promise_all_skip_slots.ts new file mode 100644 index 00000000..15e2f591 --- /dev/null +++ b/tests/fixtures/realistic/promise_all_destruct/promise_all_skip_slots.ts @@ -0,0 +1,40 @@ +// Forcing-function fixture: skip-slot array destructure +// (`const [, b]`, `const [a, ,]`) must respect pattern-position indexing, +// not source-of-bindings indexing. Pre-fix the SSA destructure-promise +// rewrite at src/ssa/lower.rs counted bindings sequentially (0, 1, ...) +// without consulting the AST's positional skip information, so: +// +// const [, b] = await Promise.all([tainted, safe]) +// b was attributed to index 0 (tainted) instead of index 1 (safe). +// +// const [a, ,] = await Promise.all([safe, tainted, 'extra']) +// a was painted with the scalar union of every element because the +// rewrite bailed when extra_defines was empty. +// +// `TaintMeta.array_pattern_indices` now carries source-order positions +// alongside `defines` + `extra_defines`. Lowering picks +// `pd_args[indices[0]]` for the primary and `pd_args[indices[i + 1]]` +// for each extra, so skip slots are honored. +async function handler(req: { body: string }): Promise { + const safe = "ok"; + const tainted = req.body; + + // Positive: index 1 = tainted, b binds to tainted, sink at line 24 fires. + const [, b] = await Promise.all([safe, tainted]); + db.query(b); + + // Negative: index 1 = safe, c binds to safe, sink at line 28 must NOT fire. + const [, c] = await Promise.all([tainted, safe]); + db.query(c); + + // Negative: index 0 = safe, d binds to safe, sink at line 32 must NOT fire. + const [d, ,] = await Promise.all([safe, tainted, "extra"]); + db.query(d); + + // Positive: index 0 = tainted, e binds to tainted, sink at line 36 fires. + const [e, ,] = await Promise.all([tainted, safe, "extra"]); + db.query(e); +} + +declare const db: { query(sql: string): void }; +export default handler; diff --git a/tests/fixtures/realistic/promise_all_destruct/ruby_parallel_assignment_fp.rb b/tests/fixtures/realistic/promise_all_destruct/ruby_parallel_assignment_fp.rb new file mode 100644 index 00000000..8524c89a --- /dev/null +++ b/tests/fixtures/realistic/promise_all_destruct/ruby_parallel_assignment_fp.rb @@ -0,0 +1,38 @@ +# Forcing-function fixture: Ruby `a, b = [tainted, literal]` parallel +# assignment with bare array-literal RHS must paint each binding from +# its source-order RHS slot. +# +# Engine timeline: +# * Session 0044 added `left_assignment_list` to +# `collect_array_pattern_bindings_indexed`, so both `a` and `b` +# register as defs instead of the legacy `idents.pop()` path +# silently dropping `a`. Both bindings then carried the scalar +# union of RHS uses (conservative correct, no FN). +# * Per-index precision lift (this session): `TaintMeta` gained +# `rhs_array_elements` recording per-slot RHS idents/literals. +# `lower.rs` consumes this when LHS is a destructure pattern AND +# RHS is a bare array literal — primary + extras emit per-slot +# Assign (for idents) or Const(None) (for literals) instead of +# cloning a union op. + +require 'sinatra' +require 'net/http' + +get '/u/:name' do |name| + a, b = [name, "safe"] + Net::HTTP.get(URI(a)) # Positive: a = name (tainted), MUST fire. + Net::HTTP.get(URI(b)) # Negative: b = literal "safe", MUST NOT fire. +end + +get '/v/:name' do |name| + a, b = ["safe", name] + Net::HTTP.get(URI(a)) # Negative: a = literal "safe", MUST NOT fire. + Net::HTTP.get(URI(b)) # Positive: b = name (tainted), MUST fire. +end + +get '/w/:name' do |name| + a, b, c = [name, "x", "y"] + Net::HTTP.get(URI(a)) # Positive: a = name (tainted), MUST fire. + Net::HTTP.get(URI(b)) # Negative: b = literal "x", MUST NOT fire. + Net::HTTP.get(URI(c)) # Negative: c = literal "y", MUST NOT fire. +end diff --git a/tests/fixtures/realistic/promise_all_taint/promise_all_taint.ts b/tests/fixtures/realistic/promise_all_taint/promise_all_taint.ts new file mode 100644 index 00000000..ddb4a588 --- /dev/null +++ b/tests/fixtures/realistic/promise_all_taint/promise_all_taint.ts @@ -0,0 +1,15 @@ +// Phase 03 recall-gap fixture: `Promise.all([req.body, req.query])` +// returns a value carrying the union of element taints. The named-promise +// shape isolates the flow so receiver binding works through a plain +// identifier (the chained-receiver form `Promise.all(...).then(cb)` +// collapses in CFG and is parked in `deferred.md`). +async function handler(req: { body: string; query: string }): Promise { + function cb(items: string): void { + db.query(items); + } + const p: Promise = Promise.all([req.body, req.query]); + p.then(cb); +} + +declare const db: { query(sql: string): void }; +export default handler; diff --git a/tests/fixtures/realistic/promise_then_callback/promise_then_callback.ts b/tests/fixtures/realistic/promise_then_callback/promise_then_callback.ts new file mode 100644 index 00000000..5152ba8c --- /dev/null +++ b/tests/fixtures/realistic/promise_then_callback/promise_then_callback.ts @@ -0,0 +1,16 @@ +// Phase 03 recall-gap fixture: source flows through `p.then(cb)` into the +// callback's first parameter, which feeds a SQL sink. The named-promise +// shape isolates the receiver-binding flow that Phase 03 ships; the +// chained-receiver form (`Promise.resolve(req.body).then(cb)`) is parked +// in `deferred.md` because it depends on a CFG-level chain-call rewrite +// that is out of scope for this phase. +async function handler(req: { body: string }): Promise { + function cb(data: string): void { + db.query(data); + } + const p: Promise = Promise.resolve(req.body); + p.then(cb); +} + +declare const db: { query(sql: string): void }; +export default handler; diff --git a/tests/fixtures/realistic/promise_then_chain/promise_then_chain.ts b/tests/fixtures/realistic/promise_then_chain/promise_then_chain.ts new file mode 100644 index 00000000..8629065c --- /dev/null +++ b/tests/fixtures/realistic/promise_then_chain/promise_then_chain.ts @@ -0,0 +1,16 @@ +// Phase 03 re-entrancy guard: a 2-deep `.then` chain whose inner callback +// itself awaits another promise. Confirms that the inline cache does not +// deadlock, k=1 depth is still enforced, and the outer flow's first level +// reaches the sink. +async function handler(req: { body: string }): Promise { + function inner(data: string): void { + db.query(data); + } + function outer(data: string): Promise { + return Promise.resolve(data).then(inner); + } + Promise.resolve(req.body).then(outer); +} + +declare const db: { query(sql: string): void }; +export default handler; diff --git a/tests/fixtures/realistic/sqli_xlang/SqliJavaConcat.java b/tests/fixtures/realistic/sqli_xlang/SqliJavaConcat.java new file mode 100644 index 00000000..eca13299 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/SqliJavaConcat.java @@ -0,0 +1,20 @@ +// Phase 15 — Java JDBC raw-string concat SQLi positive. +// `Statement.executeQuery` is a flat SQL_QUERY sink in +// `labels/java.rs`; concatenated `request.getParameter` value flows +// directly into the SQL string with no parameterisation. +package com.example; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import javax.servlet.http.HttpServletRequest; + +public class SqliJavaConcat { + public ResultSet lookup(HttpServletRequest request) throws Exception { + String name = request.getParameter("name"); + Connection conn = DriverManager.getConnection("jdbc:h2:mem:db"); + Statement stmt = conn.createStatement(); + return stmt.executeQuery("SELECT * FROM users WHERE name = '" + name + "'"); + } +} diff --git a/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateChainedSession.java b/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateChainedSession.java new file mode 100644 index 00000000..8a7d949d --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateChainedSession.java @@ -0,0 +1,25 @@ +// Hibernate `session.createNativeQuery(sql).getResultList()` SQLi +// where the receiver `sess` is bound from `sessionFactory.openSession()` +// AND the query call is followed by a `.getResultList()` (or other +// terminator) so the outer SSA Call is the terminator and the +// `createNativeQuery` sits as a chained inner call. The CFG-time +// receiver-type rewrite (in `find_classifiable_inner_call`) consults +// the per-file local-receiver-types map populated at `build_cfg` +// start to rewrite `sess.createNativeQuery` → +// `HibernateSession.createNativeQuery`, matching the type-qualified +// rule in `labels/java.rs`. +package com.example; + +import javax.servlet.http.HttpServletRequest; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import java.util.List; + +public class SqliJavaHibernateChainedSession { + public void lookup(HttpServletRequest request, SessionFactory sf) { + String name = request.getParameter("name"); + String sql = String.format("SELECT * FROM users WHERE name = '%s'", name); + Session sess = sf.openSession(); + List rows = sess.createNativeQuery(sql).getResultList(); + } +} diff --git a/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateNamedSession.java b/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateNamedSession.java new file mode 100644 index 00000000..47b126b4 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateNamedSession.java @@ -0,0 +1,21 @@ +// Hibernate `session.createNativeQuery` SQLi via arbitrary-named +// receiver bound from `sessionFactory.openSession()`. The flat +// `session.createNativeQuery` matcher in `labels/java.rs` only fires +// when the receiver is literally named `session`; for any other name +// (`sess` here) the type-qualified `HibernateSession.createNativeQuery` +// rule fires via the `TypeKind::HibernateSession` fact attached to the +// SSA value returned by `sessionFactory.openSession()`. +package com.example; + +import javax.servlet.http.HttpServletRequest; +import org.hibernate.Session; +import org.hibernate.SessionFactory; + +public class SqliJavaHibernateNamedSession { + public void lookup(HttpServletRequest request, SessionFactory sf) { + String name = request.getParameter("name"); + String sql = String.format("SELECT * FROM users WHERE name = '%s'", name); + Session sess = sf.openSession(); + sess.createNativeQuery(sql); + } +} diff --git a/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateNative.java b/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateNative.java new file mode 100644 index 00000000..eccdbf00 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/SqliJavaHibernateNative.java @@ -0,0 +1,16 @@ +// Phase 15 — Hibernate `session.createNativeQuery` interpolation SQLi +// positive. `session.createNativeQuery` is a flat SQL_QUERY sink in +// `labels/java.rs`; the `String.format` call interpolates the user- +// controlled name into the SQL string with no parameterisation. +package com.example; + +import javax.servlet.http.HttpServletRequest; +import org.hibernate.Session; + +public class SqliJavaHibernateNative { + public Object lookup(HttpServletRequest request, Session session) { + String name = request.getParameter("name"); + String sql = String.format("SELECT * FROM users WHERE name = '%s'", name); + return session.createNativeQuery(sql).getResultList(); + } +} diff --git a/tests/fixtures/realistic/sqli_xlang/SqliJavaParamSafe.java b/tests/fixtures/realistic/sqli_xlang/SqliJavaParamSafe.java new file mode 100644 index 00000000..cffe2fb4 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/SqliJavaParamSafe.java @@ -0,0 +1,20 @@ +// Phase 15 negative — JPA parameterised query. `setParameter` is a +// SQL_QUERY sanitizer in `labels/java.rs`, but the deciding factor for +// this fixture is that the SQL template fed to `entityManager +// .createQuery` is a constant — no taint reaches the sink. Bind +// values are constants too, mirroring phase 07's safe-parameterised +// approach. +package com.example; + +import javax.persistence.EntityManager; +import javax.persistence.Query; +import javax.servlet.http.HttpServletRequest; + +public class SqliJavaParamSafe { + public Object lookup(HttpServletRequest request, EntityManager entityManager) { + String _unused = request.getParameter("name"); + Query q = entityManager.createQuery("SELECT u FROM User u WHERE u.id = :id"); + q.setParameter("id", 1L); + return q.getResultList(); + } +} diff --git a/tests/fixtures/realistic/sqli_xlang/SqliJavaParamTaintedBinds.java b/tests/fixtures/realistic/sqli_xlang/SqliJavaParamTaintedBinds.java new file mode 100644 index 00000000..03aca216 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/SqliJavaParamTaintedBinds.java @@ -0,0 +1,24 @@ +// Phase 15 deferred-fix negative — Java JPA parameterised query with +// TAINTED bind value. The JPQL string at arg 0 of +// `entityManager.createQuery` is a literal; the user-controlled `name` +// is bound via `setParameter`, which the JPA layer escapes through the +// JDBC parameterised path. Without payload-arg gating on +// `entityManager.createQuery` (Phase 15 deferred fix in +// `labels/java.rs::GATED_SINKS`), the flat rule's all-arg activation +// combined with `setParameter`'s arg→return propagation could surface a +// SQL_QUERY finding on the chain. The Destination gate restricts +// `sink_payload_args` to `&[0]`, narrowing the scan to the JPQL string. +package com.example; + +import javax.persistence.EntityManager; +import javax.persistence.Query; +import javax.servlet.http.HttpServletRequest; + +public class SqliJavaParamTaintedBinds { + public Object lookup(HttpServletRequest request, EntityManager entityManager) { + String name = request.getParameter("name"); + Query q = entityManager.createQuery("SELECT u FROM User u WHERE u.name = :name"); + q.setParameter("name", name); + return q.getResultList(); + } +} diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_go_concat.go b/tests/fixtures/realistic/sqli_xlang/sqli_go_concat.go new file mode 100644 index 00000000..532bda93 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_go_concat.go @@ -0,0 +1,16 @@ +// Phase 15 — Go database/sql raw-string concat SQLi positive. +// `db.Query` is a flat SQL_QUERY sink in `labels/go.rs`; the user- +// controlled `r.URL.Query().Get` flows into the SQL string via +// concatenation with no parameterisation. +package main + +import ( + "database/sql" + "net/http" +) + +func lookup(w http.ResponseWriter, r *http.Request, db *sql.DB) { + name := r.URL.Query().Get("name") + rows, _ := db.Query("SELECT * FROM users WHERE name = '" + name + "'") + _ = rows +} diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_go_gorm_raw.go b/tests/fixtures/realistic/sqli_xlang/sqli_go_gorm_raw.go new file mode 100644 index 00000000..bdbbce54 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_go_gorm_raw.go @@ -0,0 +1,21 @@ +// Phase 15 — Go GORM `db.Raw(sql)` interpolation SQLi positive. +// `gorm.Open(...)` tags `db` as `TypeKind::GormDb`; type-qualified +// resolution rewrites `db.Raw(...)` → `GormDb.Raw`, which is a flat +// SQL_QUERY sink in `labels/go.rs`. `fmt.Sprintf` interpolates the +// user-controlled value into the SQL string with no parameterisation. +package main + +import ( + "fmt" + "net/http" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func lookup(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + db, _ := gorm.Open(postgres.Open("dbname=app"), &gorm.Config{}) + sqlStr := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name) + db.Raw(sqlStr) +} diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_go_gorm_raw_named.go b/tests/fixtures/realistic/sqli_xlang/sqli_go_gorm_raw_named.go new file mode 100644 index 00000000..f3618f7f --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_go_gorm_raw_named.go @@ -0,0 +1,29 @@ +// Phase 15 — Go GORM `userDb.Raw(sql)` SQLi positive with non-`db` +// receiver name. Pre-fix: the CFG-side receiver extraction handled +// JS/TS `member_expression`, Python `attribute`, and Rust +// `field_expression` shapes but missed Go's `selector_expression`, +// so `userDb.Raw(...)` lowered to an `SsaOp::Call` with +// `receiver: None`. Without a receiver SSA value, type-qualified +// resolution had no anchor and could not rewrite `userDb.Raw` → +// `GormDb.Raw`; the flat `db.Raw` matcher missed too because the +// callee text is literally `userDb.Raw`. Post-fix, the CFG-side +// `Kind::CallFn` arm extracts the receiver from the +// `selector_expression.operand` field, so type-qualified resolution +// can lift the `gorm.Open(...)` → `GormDb` type tag and match the +// existing `GormDb.Raw` label rule. +package main + +import ( + "fmt" + "net/http" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func lookupNamedReceiver(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + userDb, _ := gorm.Open(postgres.Open("dbname=app"), &gorm.Config{}) + sqlStr := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name) + userDb.Raw(sqlStr) +} diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_go_param_safe.go b/tests/fixtures/realistic/sqli_xlang/sqli_go_param_safe.go new file mode 100644 index 00000000..0e262c28 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_go_param_safe.go @@ -0,0 +1,17 @@ +// Phase 15 negative — Go database/sql `db.QueryContext` parameterised. +// The SQL string is a literal with `$1` placeholder; the bind args +// passed positionally are constants, so no taint reaches either arg +// position. Mirrors phase 07's safe-parameterised shape. +package main + +import ( + "context" + "database/sql" + "net/http" +) + +func lookup(w http.ResponseWriter, r *http.Request, db *sql.DB) { + _ = r.URL.Query().Get("name") + rows, _ := db.QueryContext(context.Background(), "SELECT * FROM users WHERE id = $1", 1) + _ = rows +} diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_go_param_tainted_binds.go b/tests/fixtures/realistic/sqli_xlang/sqli_go_param_tainted_binds.go new file mode 100644 index 00000000..4536305d --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_go_param_tainted_binds.go @@ -0,0 +1,23 @@ +// Phase 15 deferred-fix negative — Go database/sql `db.QueryContext` +// parameterised with a TAINTED bind value. The SQL string at arg 1 +// (after `ctx`) is a literal with `$1` placeholder; the user-controlled +// `name` is sent as a separate positional parameter at arg 2, which +// `database/sql` routes through the driver's parameterised path. +// +// Without payload-arg gating on `db.QueryContext` (Phase 15 deferred fix +// in `labels/go.rs::GATED_SINKS`), the flat `db.QueryContext` rule would +// fire SQLi on `name`'s flow into arg 2. The Destination gate restricts +// `sink_payload_args` to `&[1]`, silencing taint at arg 2+. +package main + +import ( + "context" + "database/sql" + "net/http" +) + +func lookup(w http.ResponseWriter, r *http.Request, db *sql.DB) { + name := r.URL.Query().Get("name") + rows, _ := db.QueryContext(context.Background(), "SELECT * FROM users WHERE name = $1", name) + _ = rows +} diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_php_doctrine_interp.php b/tests/fixtures/realistic/sqli_xlang/sqli_php_doctrine_interp.php new file mode 100644 index 00000000..daaac999 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_php_doctrine_interp.php @@ -0,0 +1,12 @@ +get('doctrine.orm.entity_manager'); +$name = $_GET['name']; +$query = $em->createQuery("SELECT u FROM App\\Entity\\User u WHERE u.name = '$name'"); +$rows = $query->getResult(); +print_r($rows); diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_php_param_safe.php b/tests/fixtures/realistic/sqli_xlang/sqli_php_param_safe.php new file mode 100644 index 00000000..f698987e --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_php_param_safe.php @@ -0,0 +1,14 @@ +prepare("SELECT * FROM users WHERE id = :id"); +$stmt->bindValue(':id', 1, PDO::PARAM_INT); +$stmt->execute(); +$rows = $stmt->fetchAll(); +print_r($rows); diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_php_pdo_concat.php b/tests/fixtures/realistic/sqli_xlang/sqli_php_pdo_concat.php new file mode 100644 index 00000000..dce06b95 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_php_pdo_concat.php @@ -0,0 +1,10 @@ +query("SELECT * FROM users WHERE name = '" . $name . "'"); +print_r($rows); diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_py_django_qs_bare.py b/tests/fixtures/realistic/sqli_xlang/sqli_py_django_qs_bare.py new file mode 100644 index 00000000..2c134796 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_py_django_qs_bare.py @@ -0,0 +1,17 @@ +# Django ORM bare-projection SQLi positive. +# `qs = Model.objects` (no trailing `.all()` / `.filter(...)`) binds the +# manager projection itself to a local; `qs.raw(sql)` then dispatches via +# the type-qualified `DjangoQuerySet.raw` sink rule. The chained shape +# `Model.objects.all()` already routes through the FieldProj decomposition +# and the existing field-receiver second-pass; this fixture pins the +# bare-manager binding which lowers as an `Assign` whose CFG node carries +# `member_field = Some("objects")`. +import os +from .models import User + + +def lookup(): + name = os.environ["NAME"] + qs = User.objects + rows = qs.raw("SELECT * FROM users WHERE name = '" + name + "'") + return list(rows) diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_py_django_qs_bound.py b/tests/fixtures/realistic/sqli_xlang/sqli_py_django_qs_bound.py new file mode 100644 index 00000000..8730ba45 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_py_django_qs_bound.py @@ -0,0 +1,15 @@ +# Django ORM intermediate-binding SQLi positive. +# `qs = Model.objects.all()` binds a `QuerySet` to a local; `qs.raw(sql)` +# fires via the type-qualified `DjangoQuerySet.raw` sink rule. The flat +# `objects.raw` matcher only covers the direct chained form +# `Model.objects.raw(sql)`, so this fixture pins the bound-receiver +# shape that requires the receiver-typed resolution path. +import os +from .models import User + + +def lookup(): + name = os.environ["NAME"] + qs = User.objects.all() + rows = qs.raw("SELECT * FROM users WHERE name = '" + name + "'") + return list(rows) diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_py_param_safe.py b/tests/fixtures/realistic/sqli_xlang/sqli_py_param_safe.py new file mode 100644 index 00000000..9d43d0c0 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_py_param_safe.py @@ -0,0 +1,20 @@ +# Phase 15 negative — Python parameterised cursor.execute. +# The SQL string is a literal with `%s` placeholder; the bind tuple is +# entirely constant, so no taint flows into either argument and the +# flat `cursor.execute` sink stays silent. Mirrors phase 07's +# `sqli_typeorm_safe_parameterized.ts` shape: the negative fixture +# documents the parameterised API form, not parameterised-with-tainted- +# bind-args (which would require payload-arg gating). +import psycopg2 +from flask import Flask, request + +app = Flask(__name__) + + +@app.route("/users") +def lookup(): + _name = request.args.get("name") + conn = psycopg2.connect("dbname=app") + cursor = conn.cursor() + cursor.execute("SELECT * FROM users WHERE id = %s", (1,)) + return cursor.fetchall() diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_py_param_tainted_binds.py b/tests/fixtures/realistic/sqli_xlang/sqli_py_param_tainted_binds.py new file mode 100644 index 00000000..98efd1b0 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_py_param_tainted_binds.py @@ -0,0 +1,22 @@ +# Phase 15 deferred-fix negative — Python parameterised cursor.execute +# with TAINTED bind values. The SQL string at arg 0 is constant; the +# user-controlled `name` is passed via the `%s` placeholder bind tuple +# at arg 1. The DB-API driver escapes bind values, so this is safe. +# +# Without payload-arg gating on `cursor.execute` (Phase 15 deferred fix +# in `labels/python.rs::GATED_SINKS`), the flat `cursor.execute` rule +# would fire SQLi on `name`'s flow into the bind tuple. The Destination +# gate restricts `sink_payload_args` to `&[0]`, silencing taint at arg 1+. +import psycopg2 +from flask import Flask, request + +app = Flask(__name__) + + +@app.route("/users") +def lookup(): + name = request.args.get("name") + conn = psycopg2.connect("dbname=app") + cursor = conn.cursor() + cursor.execute("SELECT * FROM users WHERE name = %s", (name,)) + return cursor.fetchall() diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_py_psycopg2_concat.py b/tests/fixtures/realistic/sqli_xlang/sqli_py_psycopg2_concat.py new file mode 100644 index 00000000..45e3567f --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_py_psycopg2_concat.py @@ -0,0 +1,17 @@ +# Phase 15 — Python psycopg2 raw-string concat SQLi positive. +# `cursor.execute` is a flat SQL_QUERY sink in `labels/python.rs`; +# concatenated user input (`request.args.get('name')`) flows directly +# into the SQL string without parameterisation. +import psycopg2 +from flask import Flask, request + +app = Flask(__name__) + + +@app.route("/users") +def lookup(): + name = request.args.get("name") + conn = psycopg2.connect("dbname=app") + cursor = conn.cursor() + cursor.execute("SELECT * FROM users WHERE name = '" + name + "'") + return cursor.fetchall() diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_py_sqlalchemy_text_fstring.py b/tests/fixtures/realistic/sqli_xlang/sqli_py_sqlalchemy_text_fstring.py new file mode 100644 index 00000000..cb5a791b --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_py_sqlalchemy_text_fstring.py @@ -0,0 +1,19 @@ +# Phase 15 — Python SQLAlchemy `text(f"…")` interpolation SQLi positive. +# `sqlalchemy.text` is a flat SQL_QUERY sink; the f-string interpolates +# `request.args.get('name')` directly into the SQL fragment with no +# parameterisation. Receiver-typing on `Session()` resolves +# `session.execute(...)` to `SqlAlchemySession.execute` for the +# downstream call, but the sink fires at the `text(...)` site itself. +from flask import Flask, request +from sqlalchemy import text +from sqlalchemy.orm import Session + +app = Flask(__name__) + + +@app.route("/users") +def lookup(): + name = request.args.get("name") + session = Session() + rows = session.execute(text(f"SELECT * FROM users WHERE name = '{name}'")) + return list(rows) diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_rb_concat.rb b/tests/fixtures/realistic/sqli_xlang/sqli_rb_concat.rb new file mode 100644 index 00000000..dfdd923f --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_rb_concat.rb @@ -0,0 +1,10 @@ +# Phase 15 — Ruby ActiveRecord `find_by_sql` raw-string concat SQLi +# positive. `find_by_sql` is a flat SQL_QUERY sink in +# `labels/ruby.rs`; `params[:name]` flows directly into the SQL string +# via concatenation with no parameterisation. +class UsersController < ApplicationController + def lookup + name = params[:name] + User.find_by_sql("SELECT * FROM users WHERE name = '" + name + "'") + end +end diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_rb_param_safe.rb b/tests/fixtures/realistic/sqli_xlang/sqli_rb_param_safe.rb new file mode 100644 index 00000000..01d6ec67 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_rb_param_safe.rb @@ -0,0 +1,11 @@ +# Phase 15 negative — Ruby ActiveRecord `where` placeholder-bind safe. +# `where("name = ?", x)` is the parameterised form recognised by +# `cfg::ar_query_safe_shape` (Ruby labels module); the synthesised +# `Sanitizer(SQL_QUERY)` clears the cap on the call, suppressing the +# `where` SQL_QUERY sink even though the bind value is tainted. +class UsersController < ApplicationController + def lookup + name = params[:name] + User.where("name = ?", name) + end +end diff --git a/tests/fixtures/realistic/sqli_xlang/sqli_rb_where_interp.rb b/tests/fixtures/realistic/sqli_xlang/sqli_rb_where_interp.rb new file mode 100644 index 00000000..d52b0028 --- /dev/null +++ b/tests/fixtures/realistic/sqli_xlang/sqli_rb_where_interp.rb @@ -0,0 +1,11 @@ +# Phase 15 — Ruby ActiveRecord `where` string-interpolation SQLi +# positive. `where` is a SQL_QUERY sink in `labels/ruby.rs`; the +# string-interpolation form (`"name = '#{name}'"`) is the canonical +# Rails SQLi vector when the cfg `ar_query_safe_shape` recognises a +# non-parameterised string argument. +class UsersController < ApplicationController + def lookup + name = params[:name] + User.where("name = '#{name}'") + end +end diff --git a/tests/fixtures/realistic/ssrf/SsrfJavaOriginLocked.java b/tests/fixtures/realistic/ssrf/SsrfJavaOriginLocked.java new file mode 100644 index 00000000..d78e8c20 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/SsrfJavaOriginLocked.java @@ -0,0 +1,17 @@ +// Phase 14 fixture (Java negative) — `"https://api.example.com/" + path` +// produces a StringFact whose prefix is the literal scheme/host. The +// `RestTemplate.getForObject(url, ...)` SSRF sink reads the URL at +// arg 0; `is_abstract_safe_for_sink` honours the prefix-lock and +// suppresses the finding even though the path component is +// attacker-controlled. +import org.springframework.web.client.RestTemplate; +import javax.servlet.http.HttpServletRequest; + +public class SsrfJavaOriginLocked { + public void proxy(HttpServletRequest req) { + String path = req.getParameter("path"); + String url = "https://api.example.com/" + path; + RestTemplate rt = new RestTemplate(); + String body = rt.getForObject(url, String.class); + } +} diff --git a/tests/fixtures/realistic/ssrf/SsrfJavaPositive.java b/tests/fixtures/realistic/ssrf/SsrfJavaPositive.java new file mode 100644 index 00000000..7e4f9da3 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/SsrfJavaPositive.java @@ -0,0 +1,21 @@ +// Phase 14 fixture (Java positive) — attacker-controlled URL passed to +// `HttpClient.send`. The `HttpClient.newHttpClient()` factory call tags +// the local `client` SSA value as `TypeKind::HttpClient`, so the +// `client.send` callee resolves through the type-qualified rewrite to +// `HttpClient.send` against the existing flat SSRF rule. +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import javax.servlet.http.HttpServletRequest; + +public class SsrfJavaPositive { + public String proxy(HttpServletRequest req) throws Exception { + String target = req.getParameter("url"); + URI uri = URI.create(target); + HttpClient client = HttpClient.newHttpClient(); + HttpRequest httpReq = HttpRequest.newBuilder().uri(uri).build(); + HttpResponse resp = client.send(httpReq, HttpResponse.BodyHandlers.ofString()); + return resp.body(); + } +} diff --git a/tests/fixtures/realistic/ssrf/SsrfJavaSearchParams.java b/tests/fixtures/realistic/ssrf/SsrfJavaSearchParams.java new file mode 100644 index 00000000..16eac4e7 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/SsrfJavaSearchParams.java @@ -0,0 +1,19 @@ +// Phase 14 fixture (Java search-params positive) — attacker-controlled +// URL string concatenated with a query-parameter list. The +// `OkHttpClient.newCall(Request)` SSRF sink (Phase 14 addition) fires +// when the chained request builder smears the URL through +// `Request.Builder().url(full).build()` into the call. +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Call; +import javax.servlet.http.HttpServletRequest; + +public class SsrfJavaSearchParams { + public Call proxy(HttpServletRequest req) throws Exception { + String target = req.getParameter("target"); + String full = target + "?q=" + req.getParameter("q"); + OkHttpClient client = new OkHttpClient(); + Request httpReq = new Request.Builder().url(full).build(); + return client.newCall(httpReq); + } +} diff --git a/tests/fixtures/realistic/ssrf/ssrf_go_origin_locked.go b/tests/fixtures/realistic/ssrf/ssrf_go_origin_locked.go new file mode 100644 index 00000000..9288e897 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/ssrf_go_origin_locked.go @@ -0,0 +1,26 @@ +// Phase 14 fixture (Go negative) — `url.JoinPath(base, path)` with a +// literal base anchors an origin-locked StringFact prefix that +// `is_string_safe_for_ssrf` honours, suppressing the SSRF sink at +// `http.Get` even though the path component is attacker-controlled. +package ssrf + +import ( + "io" + "net/http" + "net/url" +) + +func proxy(r *http.Request) (string, error) { + path := r.URL.Query().Get("path") + target, err := url.JoinPath("https://api.example.com", path) + if err != nil { + return "", err + } + resp, err := http.Get(target) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return string(body), nil +} diff --git a/tests/fixtures/realistic/ssrf/ssrf_go_positive.go b/tests/fixtures/realistic/ssrf/ssrf_go_positive.go new file mode 100644 index 00000000..378a5157 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/ssrf_go_positive.go @@ -0,0 +1,20 @@ +// Phase 14 fixture (Go positive) — attacker-controlled URL flows +// directly into `http.Get`. The query-string source taints the +// `target` value, which reaches the `http.Get` SSRF gate at arg 0. +package ssrf + +import ( + "io" + "net/http" +) + +func proxy(r *http.Request) (string, error) { + target := r.URL.Query().Get("url") + resp, err := http.Get(target) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return string(body), nil +} diff --git a/tests/fixtures/realistic/ssrf/ssrf_go_search_params.go b/tests/fixtures/realistic/ssrf/ssrf_go_search_params.go new file mode 100644 index 00000000..b9bdce56 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/ssrf_go_search_params.go @@ -0,0 +1,20 @@ +// Phase 14 fixture (Go search-params positive) — attacker-controlled +// URL passed positionally to `http.NewRequest(method, url, body)`. The +// SSRF gate fires on the URL at arg 1. +package ssrf + +import ( + "net/http" +) + +func proxy(r *http.Request, client *http.Client) (*http.Response, error) { + target := r.URL.Query().Get("target") + httpReq, err := http.NewRequest("GET", target, nil) + if err != nil { + return nil, err + } + q := httpReq.URL.Query() + q.Set("k", "v") + httpReq.URL.RawQuery = q.Encode() + return client.Do(httpReq) +} diff --git a/tests/fixtures/realistic/ssrf/ssrf_php_origin_locked.php b/tests/fixtures/realistic/ssrf/ssrf_php_origin_locked.php new file mode 100644 index 00000000..fb87c5b2 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/ssrf_php_origin_locked.php @@ -0,0 +1,10 @@ +) -> reqwest::Result { + let path = headers.get("X-Path").cloned().unwrap_or_default(); + let url = format!("https://api.example.com/{}", path); + let resp = reqwest::get(&url).await?; + resp.text().await +} diff --git a/tests/fixtures/realistic/ssrf/ssrf_rs_origin_locked_const_fmt.rs b/tests/fixtures/realistic/ssrf/ssrf_rs_origin_locked_const_fmt.rs new file mode 100644 index 00000000..d68db4f5 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/ssrf_rs_origin_locked_const_fmt.rs @@ -0,0 +1,15 @@ +// Rust negative — `format!(URL_FMT, path)` where URL_FMT is a top-level +// `const &str` declaration. The const-bridge in `cfg::prefix_of_expression` +// resolves URL_FMT to its literal value at AST time, so the resulting +// StringFact carries the locked `https://api.example.com/` prefix and +// `is_string_safe_for_ssrf` suppresses the SSRF sink at `reqwest::get`. +use std::collections::HashMap; + +const URL_FMT: &str = "https://api.example.com/users/{}"; + +async fn proxy(headers: &HashMap) -> reqwest::Result { + let path = headers.get("X-Path").cloned().unwrap_or_default(); + let url = format!(URL_FMT, path); + let resp = reqwest::get(&url).await?; + resp.text().await +} diff --git a/tests/fixtures/realistic/ssrf/ssrf_rs_positive.rs b/tests/fixtures/realistic/ssrf/ssrf_rs_positive.rs new file mode 100644 index 00000000..5c4d3006 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/ssrf_rs_positive.rs @@ -0,0 +1,11 @@ +// Phase 14 fixture (Rust positive) — attacker-controlled URL flows +// directly into `reqwest::get`. The `headers.get` Source matcher +// taints the `target` value, which reaches the `reqwest::get` SSRF +// flat sink at the call site. +use std::collections::HashMap; + +async fn proxy(headers: &HashMap) -> reqwest::Result { + let target = headers.get("X-Target").cloned().unwrap_or_default(); + let resp = reqwest::get(&target).await?; + resp.text().await +} diff --git a/tests/fixtures/realistic/ssrf/ssrf_rs_search_params.rs b/tests/fixtures/realistic/ssrf/ssrf_rs_search_params.rs new file mode 100644 index 00000000..3055caf7 --- /dev/null +++ b/tests/fixtures/realistic/ssrf/ssrf_rs_search_params.rs @@ -0,0 +1,13 @@ +// Phase 14 fixture (Rust search-params positive) — attacker-controlled +// URL passed to `Client::get(url)` chained with `.query(&[("k", v)])`. +// The `Client::new.get` matcher catches the chained-construction shape +// after CFG receiver collapse; the SSRF sink fires at the verb call +// because the URL is fully attacker-controlled. +use std::collections::HashMap; + +async fn proxy(headers: &HashMap) -> reqwest::Result { + let target = headers.get("X-Target").cloned().unwrap_or_default(); + let client = reqwest::Client::new(); + let resp = client.get(&target).query(&[("q", "ok")]).send().await?; + resp.text().await +} diff --git a/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_object_form.ts b/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_object_form.ts new file mode 100644 index 00000000..0d51abcc --- /dev/null +++ b/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_object_form.ts @@ -0,0 +1,17 @@ +// Phase 08 fixture — `fetch({ url: tainted, ... })` Request-style object +// form. The destination-aware filter on the SSRF gate restricts taint +// checks to identifiers under the `url` field, so the tainted `target` +// triggers SSRF while the fixed `body` does not. +import express from "express"; + +const app = express(); + +app.post("/proxy", (req: express.Request, res: express.Response): void => { + const target = req.body.target; + fetch({ + url: target, + method: "POST", + body: "{}", + }); + res.status(204).end(); +}); diff --git a/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_object_shorthand.ts b/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_object_shorthand.ts new file mode 100644 index 00000000..04bcc95d --- /dev/null +++ b/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_object_shorthand.ts @@ -0,0 +1,21 @@ +// Phase 08 follow-up — `fetch({ url })` object-shorthand and computed +// string-literal key forms. The destination filter and the call +// arg-uses lifter both need to recognise the shorthand_property_identifier +// node (object-literal `{ url }` is a USE of the local `url`, +// distinct from the destructuring pattern `{ url } = obj`) and the +// computed-string-literal key (`['url']`) for SSRF to fire. +import express from "express"; + +const app = express(); + +app.post("/proxy_shorthand", (req: express.Request, res: express.Response): void => { + const url = req.body.target; + fetch({ url }); + res.status(204).end(); +}); + +app.post("/proxy_computed", (req: express.Request, res: express.Response): void => { + const target = req.body.target; + fetch({ ['url']: target }); + res.status(204).end(); +}); diff --git a/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_url_typed_arg.ts b/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_url_typed_arg.ts new file mode 100644 index 00000000..1bd6c86d --- /dev/null +++ b/tests/fixtures/realistic/ssrf_url_builders/ssrf_fetch_url_typed_arg.ts @@ -0,0 +1,15 @@ +// Phase 08 fixture — `fetch(target)` where `target` is bound to a URL +// instance whose path was attacker-controlled at construction time. +// The first positional argument carries TypeKind::Url, and the +// constructor-propagation rule pushes the path-arg taint into the URL +// value so the tainted SSA value reaches the SSRF sink without an +// intermediate string coercion. +import express from "express"; + +const app = express(); + +app.post("/proxy", (req: express.Request, res: express.Response): void => { + const target: URL = new URL(req.body.endpoint); + fetch(target); + res.status(204).end(); +}); diff --git a/tests/fixtures/realistic/ssrf_url_builders/ssrf_new_url.ts b/tests/fixtures/realistic/ssrf_url_builders/ssrf_new_url.ts new file mode 100644 index 00000000..f9218b76 --- /dev/null +++ b/tests/fixtures/realistic/ssrf_url_builders/ssrf_new_url.ts @@ -0,0 +1,14 @@ +// Phase 08 fixture — `new URL(taintedPath)` with a single, attacker- +// controlled positional argument. The constructor does not carry a label +// rule and has no summary, so without the URL-aware constructor- +// propagation pass added in Phase 08 the constructed URL would arrive +// untainted at the `fetch` sink and the SSRF would be missed. +import express from "express"; + +const app = express(); + +app.post("/proxy", (req: express.Request, res: express.Response): void => { + const target = new URL(req.body.endpoint); + fetch(target); + res.status(204).end(); +}); diff --git a/tests/fixtures/realistic/ssrf_url_builders/ssrf_searchparams_append.ts b/tests/fixtures/realistic/ssrf_url_builders/ssrf_searchparams_append.ts new file mode 100644 index 00000000..8a146e4d --- /dev/null +++ b/tests/fixtures/realistic/ssrf_url_builders/ssrf_searchparams_append.ts @@ -0,0 +1,14 @@ +// Phase 08 fixture — `.append(k, taintedV)` mirrors the `.set` rule: +// the searchParams view is treated as a TypeKind::Url alias, so the +// arg-side taint flows back through the FieldProj chain into the URL +// receiver and reaches the `fetch` SSRF sink. +import express from "express"; + +const app = express(); + +app.post("/api", (req: express.Request, res: express.Response): void => { + const u = new URL("api/lookup"); + u.searchParams.append("term", req.body.term); + fetch(u); + res.status(204).end(); +}); diff --git a/tests/fixtures/realistic/ssrf_url_builders/ssrf_searchparams_set.ts b/tests/fixtures/realistic/ssrf_url_builders/ssrf_searchparams_set.ts new file mode 100644 index 00000000..64781e7b --- /dev/null +++ b/tests/fixtures/realistic/ssrf_url_builders/ssrf_searchparams_set.ts @@ -0,0 +1,15 @@ +// Phase 08 fixture — `u.searchParams.set(k, taintedV)` taints the +// underlying URL receiver. The base URL string ("api/proxy") carries no +// scheme or leading slash so the abstract-string prefix lock cannot +// suppress SSRF, leaving the back-tainted URL to reach `fetch` as a +// genuine SSRF flow. +import express from "express"; + +const app = express(); + +app.post("/api", (req: express.Request, res: express.Response): void => { + const u = new URL("api/proxy"); + u.searchParams.set("redirect", req.body.dest); + fetch(u); + res.status(204).end(); +}); diff --git a/tests/fixtures/realistic/ssrf_url_builders/ssrf_url_origin_locked.ts b/tests/fixtures/realistic/ssrf_url_builders/ssrf_url_origin_locked.ts new file mode 100644 index 00000000..e5337a29 --- /dev/null +++ b/tests/fixtures/realistic/ssrf_url_builders/ssrf_url_origin_locked.ts @@ -0,0 +1,15 @@ +// Phase 08 negative — the two-arg `new URL(path, "https://api.cal.com")` +// shape produces an origin-locked AbstractValue whose StringFact prefix +// includes the literal scheme/host. `is_string_safe_for_ssrf` honours +// the lock and suppresses the SSRF sink even though the path component +// is attacker-controlled. +import express from "express"; + +const app = express(); + +app.post("/api", (req: express.Request, res: express.Response): void => { + const u = new URL(req.body.path, "https://api.cal.com"); + u.searchParams.set("redirect", req.body.dest); + fetch(u); + res.status(204).end(); +}); diff --git a/tests/fixtures/resolver/apps/web/bar/baz.ts b/tests/fixtures/resolver/apps/web/bar/baz.ts new file mode 100644 index 00000000..8773184a --- /dev/null +++ b/tests/fixtures/resolver/apps/web/bar/baz.ts @@ -0,0 +1,3 @@ +export function baz() { + return 2; +} diff --git a/tests/fixtures/resolver/apps/web/package.json b/tests/fixtures/resolver/apps/web/package.json new file mode 100644 index 00000000..23be484b --- /dev/null +++ b/tests/fixtures/resolver/apps/web/package.json @@ -0,0 +1,6 @@ +{ + "name": "web-app", + "version": "0.0.0", + "main": "src/index.ts", + "private": true +} diff --git a/tests/fixtures/resolver/apps/web/src/foo.ts b/tests/fixtures/resolver/apps/web/src/foo.ts new file mode 100644 index 00000000..6be66939 --- /dev/null +++ b/tests/fixtures/resolver/apps/web/src/foo.ts @@ -0,0 +1,3 @@ +export function foo() { + return 1; +} diff --git a/tests/fixtures/resolver/apps/web/src/index.ts b/tests/fixtures/resolver/apps/web/src/index.ts new file mode 100644 index 00000000..d359bfb0 --- /dev/null +++ b/tests/fixtures/resolver/apps/web/src/index.ts @@ -0,0 +1,9 @@ +import { foo } from "./foo"; +import { baz } from "../bar/baz"; +import { util } from "@scope/util"; +import { x } from "@/lib/x"; +import { promises as fs } from "node:fs/promises"; + +export function main() { + return foo() + baz() + util() + x(); +} diff --git a/tests/fixtures/resolver/apps/web/src/lib/x.ts b/tests/fixtures/resolver/apps/web/src/lib/x.ts new file mode 100644 index 00000000..1d780d2b --- /dev/null +++ b/tests/fixtures/resolver/apps/web/src/lib/x.ts @@ -0,0 +1,3 @@ +export function x() { + return 4; +} diff --git a/tests/fixtures/resolver/apps/web/tsconfig.json b/tests/fixtures/resolver/apps/web/tsconfig.json new file mode 100644 index 00000000..210663fb --- /dev/null +++ b/tests/fixtures/resolver/apps/web/tsconfig.json @@ -0,0 +1,10 @@ +{ + // tsconfig with a path alias rooted at the app's src directory. + "compilerOptions": { + "baseUrl": "./src", + "paths": { + "@/*": ["*"] + } + }, + "include": ["src/**/*"] +} diff --git a/tests/fixtures/resolver/package.json b/tests/fixtures/resolver/package.json new file mode 100644 index 00000000..4480442a --- /dev/null +++ b/tests/fixtures/resolver/package.json @@ -0,0 +1,6 @@ +{ + "name": "resolver-fixture-root", + "version": "0.0.0", + "private": true, + "workspaces": ["apps/*", "packages/*"] +} diff --git a/tests/fixtures/resolver/packages/exports-pkg/blocked.ts b/tests/fixtures/resolver/packages/exports-pkg/blocked.ts new file mode 100644 index 00000000..a7f2eae3 --- /dev/null +++ b/tests/fixtures/resolver/packages/exports-pkg/blocked.ts @@ -0,0 +1 @@ +export const blocked = "should not resolve via exports gate"; diff --git a/tests/fixtures/resolver/packages/exports-pkg/package.json b/tests/fixtures/resolver/packages/exports-pkg/package.json new file mode 100644 index 00000000..1362edbf --- /dev/null +++ b/tests/fixtures/resolver/packages/exports-pkg/package.json @@ -0,0 +1,15 @@ +{ + "name": "@scope/exports-pkg", + "version": "0.0.0", + "private": true, + "main": "src/legacy-main.ts", + "exports": { + ".": { + "import": "./src/main.ts", + "default": "./src/fallback.ts" + }, + "./sub": "./src/sub.ts", + "./feat/*": "./src/feat/*.ts", + "./blocked": null + } +} diff --git a/tests/fixtures/resolver/packages/exports-pkg/src/fallback.ts b/tests/fixtures/resolver/packages/exports-pkg/src/fallback.ts new file mode 100644 index 00000000..40f038ac --- /dev/null +++ b/tests/fixtures/resolver/packages/exports-pkg/src/fallback.ts @@ -0,0 +1 @@ +export const fallback = "fallback"; diff --git a/tests/fixtures/resolver/packages/exports-pkg/src/feat/widget.ts b/tests/fixtures/resolver/packages/exports-pkg/src/feat/widget.ts new file mode 100644 index 00000000..478983f4 --- /dev/null +++ b/tests/fixtures/resolver/packages/exports-pkg/src/feat/widget.ts @@ -0,0 +1 @@ +export const widget = "widget"; diff --git a/tests/fixtures/resolver/packages/exports-pkg/src/legacy-main.ts b/tests/fixtures/resolver/packages/exports-pkg/src/legacy-main.ts new file mode 100644 index 00000000..5b9f5302 --- /dev/null +++ b/tests/fixtures/resolver/packages/exports-pkg/src/legacy-main.ts @@ -0,0 +1 @@ +export const legacy = "legacy"; diff --git a/tests/fixtures/resolver/packages/exports-pkg/src/main.ts b/tests/fixtures/resolver/packages/exports-pkg/src/main.ts new file mode 100644 index 00000000..3c609f8a --- /dev/null +++ b/tests/fixtures/resolver/packages/exports-pkg/src/main.ts @@ -0,0 +1 @@ +export const main = "main"; diff --git a/tests/fixtures/resolver/packages/exports-pkg/src/sub.ts b/tests/fixtures/resolver/packages/exports-pkg/src/sub.ts new file mode 100644 index 00000000..52f1dacb --- /dev/null +++ b/tests/fixtures/resolver/packages/exports-pkg/src/sub.ts @@ -0,0 +1 @@ +export const sub = "sub"; diff --git a/tests/fixtures/resolver/packages/util/package.json b/tests/fixtures/resolver/packages/util/package.json new file mode 100644 index 00000000..3853a597 --- /dev/null +++ b/tests/fixtures/resolver/packages/util/package.json @@ -0,0 +1,6 @@ +{ + "name": "@scope/util", + "version": "0.0.0", + "main": "src/index.ts", + "private": true +} diff --git a/tests/fixtures/resolver/packages/util/src/index.ts b/tests/fixtures/resolver/packages/util/src/index.ts new file mode 100644 index 00000000..aa3372c2 --- /dev/null +++ b/tests/fixtures/resolver/packages/util/src/index.ts @@ -0,0 +1,3 @@ +export function util() { + return 3; +} diff --git a/tests/hierarchy_pipeline_tests.rs b/tests/hierarchy_pipeline_tests.rs index 2dba3113..97eee32a 100644 --- a/tests/hierarchy_pipeline_tests.rs +++ b/tests/hierarchy_pipeline_tests.rs @@ -48,7 +48,7 @@ fn build_gs(files: &[File<'_>]) -> GlobalSummaries { let mut all_ssa: Vec<(FuncKey, SsaFuncSummary)> = Vec::new(); for f in files { let path = Path::new(f.namespace); - let (func, ssa, _bodies, _auth) = + let (func, ssa, _bodies, _auth, _cpi) = extract_all_summaries_from_bytes(f.bytes, path, &cfg, None) .expect("extract_all_summaries_from_bytes must succeed"); all_func.extend(func); diff --git a/tests/indexed_parity_tests.rs b/tests/indexed_parity_tests.rs index 4b87a282..5449ab25 100644 --- a/tests/indexed_parity_tests.rs +++ b/tests/indexed_parity_tests.rs @@ -32,7 +32,37 @@ use nyx_scanner::database::index::Indexer; use nyx_scanner::utils::config::AnalysisMode; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; + +// Indexed scan helpers each open a fresh SQLite r2d2 pool (default max_size +// ~ ncpus+4) plus rayon-driven tree-sitter parsing across the fixture tree. +// Cargo runs every `#[test]` in this binary in parallel by default, so 30+ +// indexed scans race to acquire file descriptors at once. Each pooled +// SQLite WAL connection costs ~3 fds (db + -wal + -shm); on sandboxes with +// a low per-process fd limit this exhausts EMFILE and surfaces as +// `Os { code: 24, … "Too many open files" }` panics from `build_index` / +// `scan_with_index_parallel`. +// +// Cap the pool to a small number of connections via `NYX_INDEX_POOL_MAX` so +// each parallel test holds far fewer fds. The cap is set once before any +// indexed scan runs, which keeps the suite embarrassingly parallel +// (previous workaround serialised every indexed scan via a process-wide +// mutex; that doubled wall-clock time on multi-core hosts). +fn ensure_index_pool_cap() { + static SET: OnceLock<()> = OnceLock::new(); + SET.get_or_init(|| { + if std::env::var_os("NYX_INDEX_POOL_MAX").is_none() { + // SAFETY: We set the env var exactly once, inside `OnceLock`'s + // single-init barrier. No other thread in this test binary + // has called `Indexer::init` yet (the helpers call + // `ensure_index_pool_cap()` before any `init`), so no reader + // is concurrently observing this env value. + unsafe { + std::env::set_var("NYX_INDEX_POOL_MAX", "2"); + } + } + }); +} // ───────────────────────────────────────────────────────────────────────────── // Fingerprint @@ -101,6 +131,7 @@ fn scan_no_index(fixture_root: &Path, mode: AnalysisMode) -> Vec { /// Cold indexed scan: fresh DB, build index, then run indexed scan. fn scan_indexed_cold(fixture_root: &Path, mode: AnalysisMode) -> (Vec, PathBuf) { + ensure_index_pool_cap(); let cfg = test_config(mode); let td = tempfile::tempdir().expect("tempdir"); let db_path = td.path().join("parity.sqlite"); @@ -122,6 +153,7 @@ fn scan_indexed_cold(fixture_root: &Path, mode: AnalysisMode) -> (Vec, Pat /// same pool. The second scan tests that cached artefacts don't perturb /// output. fn scan_indexed_warm(fixture_root: &Path, mode: AnalysisMode) -> Vec { + ensure_index_pool_cap(); let cfg = test_config(mode); let td = tempfile::tempdir().expect("tempdir"); let db_path = td.path().join("parity.sqlite"); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index cb06d773..18c62249 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -745,6 +745,20 @@ fn fp_guard_sanitizer_html_escape_js() { validate_expectations(&diags, &dir); } +/// FP guard, React JSX text-content auto-escape: `{expr}` interpolations +/// that are direct children of `jsx_element` / `jsx_fragment` tags carry an +/// implicit `Sanitizer(HTML_ESCAPE)` because React's renderer escapes HTML +/// metacharacters in text content. Closes ts-safe-010 (`safe_jsx_text.tsx`) +/// in `tests/benchmark`. Attribute interpolations and `dangerouslySetInnerHTML` +/// are NOT covered by this synthesis and remain in their existing sink path +/// (regression-checked by `tests/benchmark/corpus/typescript/xss/xss_dangerously_set_inner_html.tsx`). +#[test] +fn fp_guard_jsx_text_content_sanitizer_tsx() { + let dir = fixture_path("fp_guards/jsx_text_content_sanitizer_tsx"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + /// FP guard, sanitizer edge case: shlex.quote with shell metacharacters. #[test] fn fp_guard_sanitizer_shlex_quote_py() { @@ -1008,6 +1022,325 @@ fn fp_guard_php_unserialize_allowed_classes() { validate_expectations(&diags, &dir); } +/// FP guard, `php.deser.unserialize` inside a PHPUnit assertion call +/// of the shape `$this->assertSame(LITERAL, unserialize($blob))` (and +/// the `assertEquals` / `assertNull` / `assertIsArray` family, +/// including `static::` / `self::` / `parent::` dispatch). Drupal, +/// Joomla, and Nextcloud each carry tens of these `Serializable` / +/// cache / session round-trip tests in their test trees; the literal +/// expected value bounds the `unserialize` result so a poisoned blob +/// would abort the test rather than escape an object-injection side +/// effect. +#[test] +fn fp_guard_php_unserialize_in_phpunit_assertion() { + let dir = fixture_path("fp_guards/php_unserialize_in_phpunit_assertion"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Python `unittest.TestCase` round-trip tests that wrap a +/// `pickle.loads` / `yaml.load` / `shelve.open` call in an assertion +/// whose other argument is a literal expected value. The same shape +/// that drives the PHP recogniser above: a poisoned blob would fail +/// the assertion rather than leak object-injection side effects out +/// of the test boundary. Suppresses both the `py.deser.*` AST-rule +/// finding AND the `cfg-unguarded-sink` mirror. +#[test] +fn fp_guard_python_deser_in_unittest_assertion() { + let dir = fixture_path("fp_guards/python_deser_in_unittest_assertion"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, pytest plain-`assert` round-trip tests. The assertion +/// reaches the deser through allowed wrappers (comparison vs literal, +/// `is None` / `is not None`, `in [LIT, ...]`, truthy bare assert, +/// `not deser`, `isinstance(deser, TYPE)`, `bool` / `len` single-arg +/// wrap). Same bounding semantics as the unittest variant: a +/// poisoned blob produces a different shape, the assertion fails, no +/// side effect escapes the test boundary. +#[test] +fn fp_guard_python_deser_in_pytest_assert() { + let dir = fixture_path("fp_guards/python_deser_in_pytest_assert"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Ruby `Marshal.load` / `YAML.load` round-trip patterns +/// inside Minitest assertion verbs (`assert_equal LIT, deser`, +/// `assert_nil deser`, `assert deser`, `assert_kind_of TYPE, deser`, +/// `refute_*` mirrors, `assert_includes LIT, deser`) and RSpec matcher +/// chains (`expect(deser).to eq(LIT)`, `be_nil`, `be_a(TYPE)`, +/// `be_truthy`, `match_array(LIT)`, `to`/`not_to`/`to_not`). Mirror +/// of the Python and PHP recognisers for Ruby test trees. +#[test] +fn fp_guard_ruby_deser_in_test_assertion() { + let dir = fixture_path("fp_guards/ruby_deser_in_test_assertion"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Hibernate / JPA DAO passthrough wrappers whose body is a +/// single chain `getSession().createQuery(formal)` (or a longer chain +/// like `getSession().getCriteriaBuilder().createQuery(formal)`). The +/// helper itself contributes no signal; whether each call site is +/// parameterised is a caller-side concern. The param-only filter must +/// recognise method-call chain segments as pseudo-uses so the wrapper +/// does not surface a structural `cfg-unguarded-sink` finding when +/// taint analysis found nothing actionable. Receiver-variable shapes +/// (`cursor.execute(name)`, `stmt.executeUpdate(name)`) keep the +/// finding because the receiver carries data the wrapper itself +/// cannot reason about without taint. +#[test] +fn fp_guard_cfg_unguarded_dao_passthrough_java() { + let dir = fixture_path("fp_guards/cfg_unguarded_dao_passthrough_java"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Liquibase changeset wrappers like +/// `Statement stmt = connection.createStatement(); stmt.executeQuery(sql);`. +/// Both `stmt` and `sql` show up in the sink's `taint.uses`. `sql` is a +/// formal parameter; `stmt` is a body-local whose every assignment is +/// derived from the `connection` parameter (`connection.createStatement()` +/// or `connection.unwrap().createStatement()`). The function is a thin +/// wrapper around its params, so `cfg-unguarded-sink` should not fire, +/// the structural backup adds no signal here. Receiver-variable shapes +/// without a parameter-derived definition (`cursor.execute(name)` where +/// `cursor` comes from module scope) still emit because their one-hop +/// trace fails. +#[test] +fn fp_guard_cfg_unguarded_liquibase_changeset_java() { + let dir = fixture_path("fp_guards/cfg_unguarded_liquibase_changeset_java"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Java `Class.forName(STATIC_FINAL_CONSTANT)` and similar +/// sink calls whose argument is a class-level `static final TYPE NAME = +/// LITERAL;` field reference. The field lives outside any function +/// body, so the per-function CFG one-hop trace and the per-function SSA +/// const-prop both treat the identifier as a runtime-dynamic value; the +/// structural rule then fires `cfg-unguarded-sink` on every call site. +/// The class-constant-scalars map collected at CFG build time exposes +/// these compile-time constants so the all-args-constant check picks +/// them up. +#[test] +fn fp_guard_cfg_unguarded_class_constant_java() { + let dir = fixture_path("fp_guards/cfg_unguarded_class_constant_java"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, file-level constant scalars across Python / Go / Rust. The +/// same gap the Java fixture closes also exists in other languages: a +/// module-level `NAME = LITERAL` (Python), package-level `const NAME = +/// LITERAL` (Go), and crate-level `const NAME: TYPE = LITERAL` (Rust) +/// resolve as free identifiers inside any function body, so neither the +/// CFG one-hop trace nor per-function SSA const-prop sees them as +/// constant. The same file-scalars map drives suppression of both the +/// structural `cfg-unguarded-sink` rule and the AST-pattern rules like +/// `py.cmdi.os_system` that gate on all-literal arguments. +#[test] +fn fp_guard_file_level_const_scalars_xlang() { + let dir = fixture_path("fp_guards/file_level_const_scalars_xlang"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Layer A literal-args suppression on Java +/// `method_invocation` and `object_creation_expression` shapes. The +/// AST gate's `find_enclosing_call` walker only matched node kinds +/// containing the substring `call`, so Java's `method_invocation` +/// (e.g. `Class.forName(MYSQL_DRIVER)`) and `object_creation_expression` +/// (e.g. `new Foo("literal")`) silently bypassed the suppression. +/// Every `Class.forName(LITERAL)` / `Class.forName(CONST)` then fired +/// `java.reflection.class_forname` regardless of whether the argument +/// was provably constant. Param-derived calls remain noisy because +/// taint cannot prove the input safe. The Crypto carve-out keeps +/// `MessageDigest.getInstance("MD5")` firing because the literal +/// algorithm name IS the weakness signal. +#[test] +fn fp_guard_ast_layer_a_java_call_args() { + let dir = fixture_path("fp_guards/ast_layer_a_java_call_args"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Crypto / Secrets / InsecureConfig / InsecureTransport +/// patterns must keep firing under the Layer A literal-args +/// suppression. Pre-fix, `hashlib.md5(b"static")` was treated as +/// "all-literal args" and silently suppressed even though MD5 is +/// weak regardless of input. The carve-out routes calls in those +/// categories around the suppression. The contrast call, +/// `os.system("ls -la /tmp")`, stays suppressed because a literal +/// command string carries no attacker-controlled data. +#[test] +fn fp_guard_ast_layer_a_crypto_carve_out_py() { + let dir = fixture_path("fp_guards/ast_layer_a_crypto_carve_out_py"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, resource-method summary builder must not propagate an +/// Acquire effect onto callers when the method's acquire is inside a +/// managed cleanup scope (Python `with`, Java try-with-resources, Ruby +/// File.open block). Pre-fix, every method body containing an `open(...)` +/// (or `FileInputStream(...)`) callee produced a method-name Acquire +/// summary regardless of whether the handle escaped receiver state; +/// callers like `obj.method()` were then marked OPEN forever, surfacing +/// `state-resource-leak subject=self` (58 findings on airflow) and the +/// caller-side `obj` leak. The fix gates the summary on +/// `info.managed_resource == false` and on `info.taint.defines.is_some()` +/// so anonymous (`return open(...)`) and managed-scope acquires no +/// longer poison receiver state. +#[test] +fn fp_guard_state_resource_method_summary_managed_xlang() { + let dir = fixture_path("fp_guards/state_resource_method_summary_managed_xlang"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Drupal Database Query subclasses use +/// `Connection::prepareStatement($sql, $opts, ...)` to obtain a +/// statement object then bind values out of band via +/// `$stmt->execute($values, $opts)`. Phase 15 added `stmt.execute` +/// as a SQL_QUERY sink, so without recognising `prepareStatement` +/// as a SQL_QUERY sanitizer (semantic twin of `prepare`) the rule +/// fires on every Truncate / Update / Delete / Insert / Upsert +/// subclass. Distilled from drupal core/lib/Drupal/Core/Database +/// /Query/{Truncate,Update,Delete,Insert,Upsert}.php. +#[test] +fn fp_guard_php_drupal_prepare_statement() { + let dir = fixture_path("fp_guards/php_drupal_prepare_statement"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Doctrine DBAL `QueryBuilder` chain (`$qb->select(...) +/// ->from(...)->where(...)->executeQuery()`). The terminal +/// `executeQuery` / `executeStatement` verbs take zero positional +/// args; the SQL is bound earlier on the chain via parameterised +/// API calls. Without a structural zero-arg suppression the flat +/// `executeQuery` SQL_QUERY sink rule fires every time, surfacing +/// ~160 cfg-unguarded-sink findings on a single nextcloud snapshot +/// (CalDavBackend, CardDavBackend, lib/private/DB). Distilled from +/// nextcloud apps/dav/lib/CalDAV/CalDavBackend.php / +/// CardDavBackend.php. +#[test] +fn fp_guard_php_doctrine_querybuilder() { + let dir = fixture_path("fp_guards/php_doctrine_querybuilder"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, thin PHP method wrappers that forward typed parameters to +/// an inner sink call on `$this`. `cfg-unguarded-sink` is a structural +/// rule with zero signal at the wrapper site (every arg is the wrapper's +/// own parameter); the real signal is at callers, which the taint engine +/// handles. The earlier `param_only && !in_entrypoint` suppression +/// missed PHP method wrappers because `taint.uses` carries pseudo-uses +/// for the chain receiver (`this`, `inner`) that aren't param names. +/// Filtering callee-fragment uses out of the param-only check before +/// comparing against the function's params closes the wrapper FP cluster +/// across nextcloud `Connection::executeUpdate`, +/// `ConnectionAdapter::executeQuery`, `ExtendedQueryBuilder::executeQuery`, +/// drupal validators / containers, and similar shapes. +#[test] +fn fp_guard_php_thin_method_wrapper() { + let dir = fixture_path("fp_guards/php_thin_method_wrapper"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Doctrine DBAL `QueryBuilder::executeQuery` / +/// `executeStatement` overloads that pass `$this->getSQL()` / +/// `$this->getParameters()` to a connection's flat `executeQuery` / +/// `executeStatement` overload. `getSQL()` is the canonical accessor +/// for the parameterised SQL string the builder constructed; the +/// receiver of the terminal verb is the connection (not a builder), so +/// the receiver-name suppression does not fire. The first-arg +/// accessor recognition closes the FP without depending on the +/// receiver shape. Distilled from nextcloud +/// `lib/private/DB/QueryBuilder/QueryBuilder.php`. +#[test] +fn fp_guard_php_dbal_builder_get_sql() { + let dir = fixture_path("fp_guards/php_dbal_builder_get_sql"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Doctrine DBAL `Platform::get*SQL(...)` family of safe DDL +/// builders. Methods like `getTruncateTableSQL`, `getCreateTableSQL`, +/// `getDropTableSQL` accept schema identifiers and emit DBMS-specific +/// DDL with no user payload. Migration code commonly binds the result +/// to a local then passes it to `$this->dbc->executeStatement($sql)`. +/// The first-arg accessor recognition walks back to the local's +/// defining Call to identify the safe accessor before deciding the +/// finding is structural noise. Distilled from nextcloud +/// `apps/user_ldap/lib/Migration/Version*.php` and `core/Migrations/ +/// Version*.php`. +#[test] +fn fp_guard_php_dbal_platform_ddl_builder() { + let dir = fixture_path("fp_guards/php_dbal_platform_ddl_builder"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Doctrine DBAL builder chain whose local variable is named +/// after a verb (`forUpdate`) rather than a canonical builder name +/// (`qb` / `query` / `builder`). The receiver-name allowlist of the +/// zero-arg query-builder suppression doesn't match, but the local was +/// bound earlier in the body via `$this->connection->getQueryBuilder()`. +/// The receiver-defined-by-builder-factory back-walk recognises it via +/// the def-call's callee name (or via a source-text scan when the CFG +/// def-lookup misses a multi-line chained assignment nested inside +/// `try` / `for` blocks). Distilled from nextcloud +/// `lib/private/Files/Cache/Propagator.php`. +#[test] +fn fp_guard_php_dbal_builder_via_factory_def() { + let dir = fixture_path("fp_guards/php_dbal_builder_via_factory_def"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Doctrine DBAL `->getSQL()` accessor *composed* +/// with constant string-shaping ops: +/// AdapterMySQL: `preg_replace('/^INSERT/i', 'INSERT IGNORE', +/// $builder->getSQL())` patches the leading verb without +/// user payload. +/// AdapterSqlite: `$builder->getSQL() . ' ON CONFLICT DO NOTHING'` +/// appends a constant suffix. +/// The direct-accessor recognition (`sink_first_arg_is_builder_get_sql`) +/// only matches when arg 0 is itself the accessor or a local-var alias +/// of it; the composition recognition extends coverage to arg 0 *bytes* +/// containing a `$->getSQL(` token where every PHP variable in +/// the slice is bound by a query-builder factory. Distilled from +/// nextcloud `lib/private/DB/AdapterMySQL.php` and `AdapterSqlite.php`. +#[test] +fn fp_guard_php_dbal_builder_compose_sql() { + let dir = fixture_path("fp_guards/php_dbal_builder_compose_sql"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, PHP `foreach` over a literal-keyed array whose foreach-key +/// flows into a SQL_QUERY sink string interpolation +/// (`"SHOW VARIABLES LIKE '$var'"`). When `$variables` is built only +/// from `['LIT' => 'LIT', ...]` literal-keyed array initialisers and +/// optional `$variables['LIT'] = 'LIT';` subscript-set extensions, the +/// foreach-key ranges over a finite metachar-free literal set, so the +/// interpolated SQL is bounded. Negative case +/// (`UnsafeBypass.php`) iterates a method parameter; the suppression +/// must NOT fire and `cfg-unguarded-sink` must still emit. Distilled +/// from nextcloud `lib/private/DB/MySqlTools.php`. +#[test] +fn fp_guard_php_foreach_safe_literal_keys() { + let dir = fixture_path("fp_guards/php_foreach_safe_literal_keys"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + /// FP guard, PHP `md5()` / `sha1()` weak-hash pattern rule firing /// syntactically on every callsite. Real-world PHP uses these /// functions pervasively for non-cryptographic purposes (ETag @@ -1048,6 +1381,121 @@ fn fp_guard_auth_local_collection_receiver() { validate_expectations(&diags, &dir); } +/// FP guard, NextAuth callback definitions (`signIn`/`session`/`jwt`/ +/// `authorize` etc.) are themselves the authentication boundary. Reads +/// and mutations against `user.id` / `existingUser.id` inside them +/// resolve the authenticated identity; they are not foreign-id lookups +/// driven by untrusted request input. `is_nextauth_callback_unit` in +/// `auth_analysis::checks` recognises these by name + canonical +/// callback-formal evidence (any of `user`/`token`/`account`/ +/// `profile`/`credentials`/`session` in the destructured params) and +/// suppresses missing-ownership findings on every op kind. +#[test] +fn fp_guard_auth_nextauth_callback() { + let dir = fixture_path("fp_guards/auth_nextauth_callback"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, cal.com-shaped TRPC handlers whose parameter is a +/// destructured options alias (`{ ctx, input }: GetOptions`) where +/// `GetOptions` is a local type alias whose `ctx.user` is typed +/// `NonNullable`. `collect_trpc_ctx_param` in +/// `auth_analysis::extract::common` recognises three shapes: +/// destructured shorthand, destructured rename (`ctx: c`), and plain +/// identifier (`opts: GetOptions`). All three add the appropriate +/// session-base entry to `self_scoped_session_bases` so `ctx.user.id` +/// resolves as authenticated actor context, not foreign-id targeting. +#[test] +fn fp_guard_auth_trpc_handler_options() { + let dir = fixture_path("fp_guards/auth_trpc_handler_options"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Go `fmt.Fprintf` flagged as an HTML_ESCAPE sink even when +/// the writer is a known non-response stream (`os.Stderr`, `os.Stdout`, +/// `io.Discard`, gin's package-level `DefaultErrorWriter` / +/// `DefaultWriter`). Without the writer-aware suppression in +/// `suppress_known_safe_callees`, gin's own `defer func() { +/// debugPrintError(err) }()` shape lights up because `debugPrintError` +/// summarises through the IPA path as param 0 → `fmt.Fprintf` +/// HTML_ESCAPE. The fixture also asserts the canonical +/// `fmt.Fprintf(w http.ResponseWriter, ...)` XSS path still fires so the +/// suppression does not over-clear. +#[test] +fn fp_guard_go_fmt_fprintf_safe_writer() { + let dir = fixture_path("fp_guards/go_fmt_fprintf_safe_writer"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Go `http.Redirect(w, r, urlExpr, code)` whose URL string is +/// derived from the same request's `*url.URL` (e.g. `r.URL.String()`, +/// `r.URL.Path`, `r.URL.RequestURI()`, `r.URL.EscapedPath()`). Such a +/// redirect echoes the inbound request's URL with at most path-only edits +/// — scheme/host are same-origin by construction — so OPEN_REDIRECT is +/// inapplicable. Without this gate, gin's `redirectTrailingSlash` / +/// `redirectFixedPath` / `redirectRequest` helpers record `param_to_sink` +/// for OPEN_REDIRECT through the inner `http.Redirect` and then surface +/// `taint-open-redirect` at every call site that reaches them with a +/// tainted `c.Request.URL`. The fixture also asserts that the canonical +/// attacker-controlled `r.FormValue → http.Redirect` shape still fires so +/// the gate does not over-clear. +#[test] +fn fp_guard_go_http_redirect_self_request() { + let dir = fixture_path("fp_guards/go_http_redirect_self_request"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, `new URL(req.body.path, BASE)` where `BASE` is a `const` +/// identifier bound to a literal origin must NOT fire SSRF — the +/// abstract-string singleton domain proves the origin is locked even +/// though the base arg is not a syntactic literal at the call site. +/// Negative control under `handler.ts` (base read from request body) +/// MUST still surface `taint-ssrf`. +#[test] +fn fp_guard_url_builder_const_base() { + let dir = fixture_path("fp_guards/url_builder_const_base"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Java `final ... = Map.of(literal, literal, ...)` allowlist +/// fields suppress the free-identifier `.get(taintedKey)` lookup so +/// downstream sinks (here, `res.setHeader`) do NOT surface +/// `taint-header-injection`. Mirrors the CVE-2017-12629 patched +/// counterpart shape: the engine had no model for unresolved-receiver +/// container loads, so default arg-to-result propagation tainted the +/// lookup result even though every value in the map is a literal. +/// `safe_fields::collect_safe_lookup_fields` extracts the literal value +/// set during CFG construction; the SSA taint engine consults the per- +/// file view from `try_container_propagation`'s Load fallback and leaves +/// the result untainted. Recall control under `UnsafeBypass.java` MUST +/// still surface a `taint-header-injection`. +#[test] +fn fp_guard_java_safe_map_field_lookup() { + let dir = fixture_path("fp_guards/java_safe_map_field_lookup"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, third-party bundled / minified assets must be skipped before +/// parsing so vendored libraries (jQuery, htmx, Sortable, lodash) do not +/// surface findings the codebase author cannot remediate. `is_vendored_asset_path` +/// matches `*.min.js` / `*.bundle.js` / `*.umd.js` / `*.umd.min.js` / `*.iife.js` +/// suffixes plus `bower_components/` and (for front-end extensions only) +/// `vendor/` path components. Recall stays intact for genuine production +/// `.js` files; the negative control under `src/handler.js` MUST still +/// surface a `js.crypto.math_random` finding. +#[test] +fn fp_guard_vendored_assets_skip() { + let dir = fixture_path("fp_guards/vendored_assets_skip"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + /// FP guard, C/C++ buffer-overflow pattern rules /// (`c.memory.strcpy`, `strcat`, `sprintf`) over-fire when the source / /// format-string argument is a literal whose contributed length is @@ -1129,6 +1577,25 @@ fn fp_guard_auth_rust_param_typed_local_collection() { validate_expectations(&diags, &dir); } +/// FP guard, JS/TS post-fetch ownership equality check. The cal.com +/// shape `const x = await repo.findById(id); if (x.userId !== session. +/// user.id) { notFound(); }` is the canonical post-fetch authorisation +/// idiom across Next.js codebases. Pre-fix the engine missed this +/// because `detect_ownership_equality_check` only ran on rust-style +/// `if_expression`, the strict-inequality operators `!==` / `===` were +/// not in the recognised set, framework denial calls +/// (`notFound`, `redirect`, `unauthorized`, `forbidden`) were not +/// recognised as early-exit terminators, and `collect_row_population` +/// missed JS/TS `variable_declarator` declarations because it only +/// read the `pattern` / `left` field. Each shape in the fixture +/// exercises one column of that matrix. +#[test] +fn fp_guard_auth_post_fetch_ownership_jsts() { + let dir = fixture_path("fp_guards/auth_post_fetch_ownership_jsts"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + /// Panic guard, CFG condition-text truncation (and symex display /// truncation) must round byte cuts down to the nearest UTF-8 char /// boundary. Reproduces the gogs scan crash where diff --git a/tests/ldap_injection_tests.rs b/tests/ldap_injection_tests.rs index f394ad56..685b8f45 100644 --- a/tests/ldap_injection_tests.rs +++ b/tests/ldap_injection_tests.rs @@ -76,13 +76,17 @@ fn diags_for_file(dir: &Path, file_suffix: &str) -> Vec { } fn assert_unsafe(lang: &str, file_suffix: &str) { + assert_unsafe_with_count(lang, file_suffix, 1); +} + +fn assert_unsafe_with_count(lang: &str, file_suffix: &str, expected: usize) { let dir = ldap_fixture_dir(lang); let diags = diags_for_file(&dir, file_suffix); let count = count_by_prefix(&diags, RULE_PREFIX); assert_eq!( count, - 1, - "{lang}/{file_suffix}: expected exactly 1 {RULE_PREFIX} finding, got {count}.\n\ + expected, + "{lang}/{file_suffix}: expected exactly {expected} {RULE_PREFIX} finding(s), got {count}.\n\ All diags: {:#?}", diags .iter() @@ -101,8 +105,8 @@ fn assert_unsafe(lang: &str, file_suffix: &str) { .count(); assert_eq!( high, - 1, - "{lang}/{file_suffix}: expected exactly 1 HIGH-severity {RULE_PREFIX} finding, got {high}.\n\ + expected, + "{lang}/{file_suffix}: expected exactly {expected} HIGH-severity {RULE_PREFIX} finding(s), got {high}.\n\ All matching: {:#?}", diags .iter() @@ -270,7 +274,13 @@ fn ruby_baseline_constant_filter_does_not_fire() { #[test] fn go_ldap_search_request_with_tainted_filter_fires() { - assert_unsafe("go", "unsafe_ldap_search.go"); + // The fixture has two sink emission points along one flow: + // ldap.NewSearchRequest(..., filter, ...) ── construction-site sink + // conn.Search(req) ── execute-site sink + // The execute-site fires via type-qualified resolution + // (`LdapClient.Search`) once the receiver `conn` is bound from + // `ldap.DialURL(...)`. Both are real sink events on the same flow. + assert_unsafe_with_count("go", "unsafe_ldap_search.go", 2); } #[test] diff --git a/tests/recall_gaps.rs b/tests/recall_gaps.rs new file mode 100644 index 00000000..debd1a6c --- /dev/null +++ b/tests/recall_gaps.rs @@ -0,0 +1,1695 @@ +//! # Recall-gap integration harness (phase 01 baseline) +//! +//! Pitboss phase 01 stands up the skeleton; phases 02–11 grow it. The suite +//! is green on a fresh `master` because every gap-area test starts +//! `#[ignore]`d, so this file compiles and runs without depending on engine +//! work that has not landed yet. +//! +//! ## Where fixtures live +//! +//! Each gap area owns a subdirectory under +//! `tests/fixtures/realistic//`. The phase that un-ignores a test is +//! responsible for populating its fixture. Fixtures are copied into a fresh +//! tempdir per scan (see [`common::recall::scan_fixture`]) so SQLite, +//! `nyx.conf`, or stray index artefacts cannot leak between tests. +//! +//! ## `ExpectedFinding` shape +//! +//! Each test asserts findings with a tuple of +//! `(rule_id, file_suffix, sink_line, source_line)`: +//! +//! - `rule_id` — exact prefix match on `Diag.id`. Taint findings carry a +//! trailing ` (source N:M)` suffix that the matcher strips before +//! comparison. +//! - `file_suffix` — `Diag.path.ends_with(file_suffix)`, which lets callers +//! ignore the tempdir prefix supplied by the harness. +//! - `sink_line` — exact match on `Diag.line` (1-based). +//! - `source_line` — optional `N` parsed from the ` (source N:M)` suffix +//! on `Diag.id`. Use `None` when the originating line is unstable across +//! refactors of the fixture. +//! +//! ## Phase ownership +//! +//! Every phase un-ignores exactly the tests it owns. The mapping is stable: +//! +//! | Phase | Test fn | +//! |-------|-------------------------------| +//! | 02 | `async_await` | +//! | 03 | `promise_then_callback`, | +//! | | `promise_all_taint`, | +//! | | `for_await_of_stream`, | +//! | | `promise_then_chain_reentrant`| +//! | 05 | `fs_promises_*` | +//! | 06 | `jsx_dangerous_html` | +//! | 07 | `orm_builders` | +//! | TBD | `ssrf_url_builders`, | +//! | | `cross_package_ipa`, | +//! | | `nextjs_entrypoints` | +//! +//! Phase 04 ships the TS/JS module resolver foundation but un-ignores no +//! gap tests of its own — the resolver feeds `FuncKey.namespace` for later +//! phases. Phases beyond the table may add further `#[ignore]`d tests; +//! do not move tests between owners. + +mod common; + +use common::recall::{ExpectedFinding, assert_finding, assert_finding_with_cap, scan_fixture}; +use nyx_scanner::labels::Cap; +use std::path::Path; + +#[test] +fn async_await_js() { + let findings = scan_fixture("async_await"); + // JS form — exercises the JavaScript `await_expression` KINDS-map entry. + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "handler.js", + sink_line: 6, + source_line: Some(4), + }, + ); + // TS form — same source/sink shape, exercises the TypeScript + // `await_expression` KINDS-map entry. Without this assertion the + // `.ts` fixture was scanned implicitly via `scan_fixture("async_await")` + // (smoke only), with no positive guarantee that the TS grammar's + // await-forwarding lowered taint identically. Source attributes to + // line 3 (the typed-extractor `req: { body: string }` parameter) — + // the typed-formal pipeline tags the parameter itself as the taint + // origin, which is the canonical handler-input shape rather than the + // intermediate `req.body` access on line 4. + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "handler.ts", + sink_line: 5, + source_line: Some(3), + }, + ); +} + +/// Phase 12 recall-gap (Python). tree-sitter-python emits `await x` as a +/// named `await` node (no `_expression` suffix). Without the +/// `"await" => Kind::AwaitForward` entry in `src/labels/python.rs` and the +/// corresponding `Kind`-driven `is_await_forward` flag in `cfg::push_node`, +/// the engine never models the await boundary as a 1:1 forward and the +/// FastAPI-shape `await request.json()` source never reaches `cursor.execute`. +#[test] +fn async_await_py() { + let findings = scan_fixture("async_await/handler.py"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "handler.py", + sink_line: 8, + source_line: None, + }, + ); +} + +/// Phase 12 recall-gap (Python combinator). `asyncio.gather(...)` is +/// registered as `PromiseCombinatorKind::All` for Python in +/// `is_promise_combinator`; argument taint unions onto the awaited result. +#[test] +fn async_await_py_gather() { + let findings = scan_fixture("async_await/gather.py"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "gather.py", + sink_line: 14, + source_line: None, + }, + ); +} + +/// Phase 12 recall-gap (Rust). `x.await` is now mapped explicitly to +/// `Kind::AwaitForward` in `src/labels/rust.rs`; the `is_await_forward` +/// flag is set via `lookup(lang, ast.kind()) == Kind::AwaitForward` +/// rather than the raw-string `ast.kind() == "await_expression"` check. +/// The header-shape source flows across the await into the +/// `Command::new("sh").arg(&cmd)` shell-injection sink. +#[test] +fn async_await_rs() { + let findings = scan_fixture("async_await/handler.rs"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "handler.rs", + sink_line: 26, + source_line: Some(25), + }, + ); +} + +/// Phase 12 recall-gap (Rust combinator). `tokio::join!(...)` is a +/// `macro_invocation` whose args live inside a `token_tree`. +/// `extract_arg_uses` walks the token_tree splitting on `,` so the SSA +/// Call carries one arg group per future, and +/// `is_promise_combinator("rust", "tokio::join")` routes it through the +/// existing combinator transfer. The unioned env-var taint flows into +/// `reqwest::get` (SSRF sink). +#[test] +fn async_await_rs_join() { + let findings = scan_fixture("async_await/tokio_join.rs"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "tokio_join.rs", + sink_line: 11, + source_line: None, + }, + ); +} + +/// Phase 12 deferred-fix (Rust combinator, bare macro form). +/// `use tokio::join;` brings the macro into scope and the call site uses +/// `join!(...)`. `cfg::push_node` rewrites the bare macro callee text to +/// `tokio::join` when an import witness is present, so the existing +/// combinator transfer fires the same way as for the qualified form. +#[test] +fn async_await_rs_join_bare() { + let findings = scan_fixture("async_await/tokio_join_bare.rs"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "tokio_join_bare.rs", + sink_line: 13, + source_line: None, + }, + ); +} + +/// Phase 03 recall-gap: `.then(cb)` propagates the receiver Promise's +/// resolved value into the callback's first parameter. The taint trace +/// attributes at the inner `db.query(data)` sink via the callback-pattern +/// emission paired with the chain-hop site promotion that lifts the +/// callback's own-body sink coordinates into the trace finding's primary +/// location. +#[test] +fn promise_then_callback() { + let findings = scan_fixture("promise_then_callback"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "promise_then_callback.ts", + sink_line: 9, + source_line: Some(7), + }, + ); +} + +/// Phase 03 recall-gap: `Promise.all([...])` returns a value carrying the +/// union of element taints; `p.then(cb)` then exposes it to the sink at +/// `db.query(items)` via the callback-pattern emission with chain-hop +/// site promotion. +#[test] +fn promise_all_taint() { + let findings = scan_fixture("promise_all_taint"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "promise_all_taint.ts", + sink_line: 8, + source_line: None, + }, + ); +} + +/// Per-element precision for `const [a, b] = await Promise.all([safe, +/// tainted])`. The SSA lowering rewrite in src/ssa/lower.rs maps each +/// destructure binding to `Assign(arg_uses[0][i])`, so `a` binds only to +/// the literal `"ok"` and `b` binds only to the tainted `req.body`. The +/// scalar union from `try_apply_promise_combinator` is bypassed for the +/// per-binding values. +/// +/// Skip-slot cases (`const [, b]`, `const [a, ,]`) also need pattern-position +/// indexing: `TaintMeta.array_pattern_indices` carries the source-order +/// position of each binding so the rewrite picks `pd_args[index]` rather +/// than `pd_args[binding_offset]`. +#[test] +fn promise_all_destruct_per_index() { + let findings = scan_fixture("promise_all_destruct"); + + // Positive: line 17 sink reachable from req.body via index-1 binding. + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "promise_all_destruct_fp.ts", + sink_line: 17, + source_line: None, + }, + ); + + // Negative: line 16 binds `a` to the literal "ok"; pre-fix the scalar + // union painted `a` with req.body's taint and produced a FP here. + let leak = findings.iter().any(|f| { + f.path.ends_with("promise_all_destruct_fp.ts") + && f.line == 16 + && f.id.starts_with("taint-unsanitised-flow") + }); + assert!( + !leak, + "destructure index-0 binding `a` must not carry req.body taint; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("promise_all_destruct_fp.ts")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + + // Skip-slot positives: only the index-aligned tainted bindings should fire. + for sink_line in [24usize, 36] { + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "promise_all_skip_slots.ts", + sink_line, + source_line: None, + }, + ); + } + + // Skip-slot negatives: lines 28 (`c` from `[, c]` of `[tainted, safe]`) + // and 32 (`d` from `[d, ,]` of `[safe, tainted, "extra"]`) must NOT fire. + for forbidden_line in [28usize, 32] { + let leak = findings.iter().any(|f| { + f.path.ends_with("promise_all_skip_slots.ts") + && f.line == forbidden_line + && f.id.starts_with("taint-unsanitised-flow") + }); + assert!( + !leak, + "skip-slot binding at line {forbidden_line} must not carry req.body taint; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("promise_all_skip_slots.ts")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + + // Python `asyncio.gather` destructure: `pattern_list` + `tuple_pattern` + // share the same per-index rewrite as JS/TS arrays. Positives at lines + // 32 / 40 / 50 (tainted-aligned bindings) must fire; negatives at lines + // 33 / 41 / 51 (safe-aligned bindings) must NOT fire. + for sink_line in [32usize, 40, 50] { + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "asyncio_gather_destruct_fp.py", + sink_line, + source_line: None, + }, + ); + } + for forbidden_line in [33usize, 41, 51] { + let leak = findings.iter().any(|f| { + f.path.ends_with("asyncio_gather_destruct_fp.py") + && f.line == forbidden_line + && f.id.starts_with("taint-unsanitised-flow") + }); + assert!( + !leak, + "Python asyncio.gather binding at line {forbidden_line} must not carry request.args taint; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("asyncio_gather_destruct_fp.py")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + + // Bare-array RHS destructure (`const [a, b] = [safe, tainted]`) + // mirror of the Promise.all destructure precision, gated on + // `info.call.callee.is_none()` so the combinator path is not + // affected. Each binding emits its own SSA op keyed on the + // source-order RHS slot. + for sink_line in [28usize, 36] { + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "bare_array_literal_destruct_fp.ts", + sink_line, + source_line: None, + }, + ); + } + for forbidden_line in [27usize, 37, 44] { + let leak = findings.iter().any(|f| { + f.path.ends_with("bare_array_literal_destruct_fp.ts") + && f.line == forbidden_line + && f.id.starts_with("taint-unsanitised-flow") + }); + assert!( + !leak, + "JS/TS bare-array binding at line {forbidden_line} must not carry req.body taint; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("bare_array_literal_destruct_fp.ts")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + + // Ruby parallel assignment `a, b = [array_literal]` now gets per-index + // precision via the bare-array RHS rewrite at `src/ssa/lower.rs`. + // Each binding emits its own SSA op keyed on its source-order RHS + // slot — ident slots Assign the slot's value, literal slots emit + // Const(None). Positives at handler lines 25 / 32 / 37 (tainted- + // aligned bindings) must fire; negatives at 26 / 31 / 38 / 39 + // (literal-aligned bindings) must NOT fire. + for sink_line in [23usize, 30, 35] { + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "ruby_parallel_assignment_fp.rb", + sink_line, + source_line: None, + }, + ); + } + for forbidden_line in [24usize, 29, 36, 37] { + let leak = findings.iter().any(|f| { + f.path.ends_with("ruby_parallel_assignment_fp.rb") + && f.line == forbidden_line + && f.id.starts_with("taint-unsanitised-flow") + }); + assert!( + !leak, + "Ruby parallel assignment binding at line {forbidden_line} must not carry name taint; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("ruby_parallel_assignment_fp.rb")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + + // Complex-slot bare-array RHS destructure (`const [a, b] = + // [normalize(req.body.cmd), 'static']`). The helper now classifies + // call / binary / subscript / member access / template-string slots + // as `Complex(inner_uses)` rather than bailing. Each Complex slot + // emits a slot-scoped `Assign` (or `Source` when the outer node + // carries a Source label), so the literal-aligned binding is + // correctly clean. Positives at lines 32 / 39 / 46 / 54 / 62 fire; + // negatives at lines 33 / 40 / 47 / 55 / 63 must NOT fire. + for sink_line in [32usize, 39, 46, 54, 62] { + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "complex_slot_destruct_fp.ts", + sink_line, + source_line: None, + }, + ); + } + for forbidden_line in [33usize, 40, 47, 55, 63] { + let leak = findings.iter().any(|f| { + f.path.ends_with("complex_slot_destruct_fp.ts") + && f.line == forbidden_line + && f.id.starts_with("taint-unsanitised-flow") + }); + assert!( + !leak, + "complex-slot literal binding at line {forbidden_line} must not carry req.body taint; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("complex_slot_destruct_fp.ts")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + + // Per-slot Source classification: when two Complex slots sit next to + // each other and ONLY one slot's subtree contains a Source-classified + // member-expression, the safe Complex sibling stays slot-scoped instead + // of inheriting the outer-node Source. Pre-session 0047 the legacy + // outer-node fallback painted both slots, producing a FP on the safe + // sibling's binding. + for sink_line in [27usize, 34, 41] { + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "complex_complex_per_slot_fp.ts", + sink_line, + source_line: None, + }, + ); + } + for forbidden_line in [28usize, 35, 42] { + let leak = findings.iter().any(|f| { + f.path.ends_with("complex_complex_per_slot_fp.ts") + && f.line == forbidden_line + && f.id.starts_with("taint-unsanitised-flow") + }); + assert!( + !leak, + "safe Complex sibling at line {forbidden_line} must not inherit per-slot Source; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("complex_complex_per_slot_fp.ts")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + + // Slot-scoped transitive taint: when the outer destructure node + // carries a Source label AND another Complex slot's subtree classifies + // as Source, the safe Complex sibling whose own subtree contains an + // identifier bound to a tainted local (e.g. + // `helper(tainted_local)` where `tainted_local = req.body.cmd`) + // must still propagate the inner ident's taint through the slot-scoped + // `Assign`. Pre-session 0048 the kill arm emitted `Const(None)` which + // dropped the transitive taint. + for sink_line in [29usize, 30, 36] { + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "complex_transitive_taint_fp.ts", + sink_line, + source_line: None, + }, + ); + } + { + let forbidden_line = 37usize; + let leak = findings.iter().any(|f| { + f.path.ends_with("complex_transitive_taint_fp.ts") + && f.line == forbidden_line + && f.id.starts_with("taint-unsanitised-flow") + }); + assert!( + !leak, + "safe Complex sibling at line {forbidden_line} must not inherit outer Source; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("complex_transitive_taint_fp.ts")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } +} + +/// Phase 03 recall-gap: `for await (const x of iter)` taints `x` from the +/// iterator (Web Streams / async-iterable request body). +#[test] +fn for_await_of_stream() { + let findings = scan_fixture("for_await_of_stream"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "for_await_of_stream.ts", + sink_line: 5, + source_line: None, + }, + ); +} + +/// Phase 03 re-entrancy guard: a 2-deep `.then` chain whose inner callback +/// awaits another promise. Confirms the inline cache does not deadlock and +/// k=1 depth is still enforced. Outer-level taint must still reach the sink +/// even when the inner level cannot recurse. +#[test] +fn promise_then_chain_reentrant() { + let findings = scan_fixture("promise_then_chain"); + // The chain deliberately has two `.then` levels. At k=1 the inner + // `.then(inner)` cannot recurse, so the engine treats the inner + // callback's body as opaque and propagates conservatively. We only + // assert the run does not panic and produces *some* finding for this + // file (taint reaches the inner sink via the outer flow). + let any = findings + .iter() + .any(|f| f.path.ends_with("promise_then_chain.ts")); + assert!( + any, + "expected at least one finding from promise_then_chain.ts, got:\n{}", + findings + .iter() + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); +} + +/// Phase 05 recall-gap: `import { readFile } from 'fs/promises'` → +/// `await readFile(req.body.path)` is a FILE_IO sink. The bare-name +/// `readFile` matcher only fires because the file's import table maps +/// the binding to `fs/promises`, satisfying the +/// `LabelGate::ImportedFromModule` gate. +#[test] +fn fs_promises_readfile() { + let findings = scan_fixture("fs_promises/path_traversal_fs_promises_readfile.ts"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "path_traversal_fs_promises_readfile.ts", + sink_line: 10, + source_line: Some(9), + }, + ); +} + +/// Phase 05 recall-gap: `await open(req.query.path, "r")` ─ same gate, +/// different fs/promises method. Confirms the matcher list covers +/// `open` alongside `readFile`. +#[test] +fn fs_promises_open() { + let findings = scan_fixture("fs_promises/path_traversal_fs_promises_open.ts"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "path_traversal_fs_promises_open.ts", + sink_line: 10, + source_line: Some(9), + }, + ); +} + +/// Phase 05 recall-gap: the `node:` URL specifier flavour — `import { +/// writeFile } from 'node:fs/promises'`. Both spellings must satisfy +/// the gate. +#[test] +fn fs_promises_node_import() { + let findings = scan_fixture("fs_promises/path_traversal_node_fs_promises_import.ts"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "path_traversal_node_fs_promises_import.ts", + sink_line: 10, + source_line: Some(9), + }, + ); +} + +/// Phase 05 recall-gap: namespace-import shape — `import * as fsp from +/// 'fs/promises'`. `fsp.readFile(...)` must satisfy the gate via the +/// receiver-name path of the local-import view. +#[test] +fn fs_promises_namespace_import() { + let findings = scan_fixture("fs_promises/path_traversal_fs_promises_namespace.ts"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "path_traversal_fs_promises_namespace.ts", + sink_line: 11, + source_line: Some(10), + }, + ); +} + +/// Phase 05 recall-gap: CommonJS require shape — `const { readFile } = +/// require('fs/promises')`. `extract_local_import_view` records the +/// destructured binding so the bare-name call still satisfies the gate. +#[test] +fn fs_promises_require_form() { + let findings = scan_fixture("fs_promises/path_traversal_fs_promises_require.ts"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "path_traversal_fs_promises_require.ts", + sink_line: 10, + source_line: Some(9), + }, + ); +} + +/// Phase 05 recall-gap: namespace-of-namespace alias — +/// `import * as fs from 'fs'; const fsp = fs.promises;`. The +/// promises-alias extension on `extract_local_import_view` adds +/// `fsp -> fs/promises` so `fsp.readFile(path)` satisfies the gate +/// without an explicit `import ... from 'fs/promises'` line. +#[test] +fn fs_promises_alias_form() { + let findings = scan_fixture("fs_promises/path_traversal_fs_promises_alias.ts"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "path_traversal_fs_promises_alias.ts", + sink_line: 14, + source_line: Some(13), + }, + ); +} + +/// Phase 05 recall-gap: CommonJS form of the alias shape — +/// `const fsp = require('fs').promises;`. Same gate as the ESM-import +/// alias above; promises-alias recognises the `.promises` projection on +/// the bare `require('fs')` call. +#[test] +fn fs_promises_alias_require_form() { + let findings = scan_fixture("fs_promises/path_traversal_fs_promises_alias_require.ts"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "path_traversal_fs_promises_alias_require.ts", + sink_line: 12, + source_line: Some(11), + }, + ); +} + +/// Phase 05 negative: a user-defined `readFile` (no import) must not +/// fire the gated FILE_IO sink. The whole point of the import gate. +#[test] +fn fs_promises_safe_userfn() { + let findings = scan_fixture("fs_promises/path_traversal_fs_promises_safe_userfn.ts"); + let leak = findings.iter().any(|f| { + f.path + .ends_with("path_traversal_fs_promises_safe_userfn.ts") + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + !leak, + "user-defined readFile should not fire the fs/promises gate; got:\n{}", + findings + .iter() + .filter(|f| f + .path + .ends_with("path_traversal_fs_promises_safe_userfn.ts")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); +} + +/// Phase 06 recall-gap: React JSX `
    `. The CFG builder synthesises a sink call from the JSX +/// attribute, so the auto-seeded `input` formal flows into HTML_ESCAPE at +/// the `__html: input` value-span line. +#[test] +fn jsx_dangerous_html() { + let findings = scan_fixture("jsx_dangerous_html"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "page.tsx", + sink_line: 8, + source_line: None, + }, + ); + // Negative — `__html` is a string literal, no taint flows. + let leak_literal = findings.iter().any(|f| { + f.path.ends_with("page_safe_literal.tsx") + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + !leak_literal, + "literal __html must not fire dangerouslySetInnerHTML; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("page_safe_literal.tsx")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + // Negative — `__html: DOMPurify.sanitize(input)` is sanitized. + let leak_indirect = findings.iter().any(|f| { + f.path.ends_with("page_indirect.tsx") + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + !leak_indirect, + "DOMPurify.sanitize-routed payload must not fire dangerouslySetInnerHTML; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("page_indirect.tsx")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + // Negative — `__html: pipe(input, sanitizeHtml, DOMPurify.sanitize)` — + // the fp-ts composition recogniser detects sanitizers in argument + // position and suppresses the synthetic sink's argument-side flow. + let leak_pipe = findings.iter().any(|f| { + f.path.ends_with("page_pipe.tsx") + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + !leak_pipe, + "pipe(...sanitizers) payload must not fire dangerouslySetInnerHTML; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("page_pipe.tsx")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + // Positive (item 11) — JSX inside a ternary RHS branch. The synthesis + // hook in `lower_ternary_branch` reaches the `__html: input` value span + // even though the wrapping arm short-circuits into the ternary diamond. + let hits_ternary: Vec<&_> = findings + .iter() + .filter(|f| { + f.path.ends_with("page_ternary.tsx") + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }) + .collect(); + assert!( + !hits_ternary.is_empty(), + "ternary-branch dangerouslySetInnerHTML must fire a sink; got nothing for page_ternary.tsx" + ); +} + +/// Phase 07 recall-gap: ORM query-builder raw-SQL escape hatches. +/// +/// Coverage: +/// - Drizzle `sql.raw(x)` and tagged-template `sql\`...\`` shapes +/// (leading-id `ImportedFromModule(&["drizzle-orm"])` gate) +/// - Sequelize `sequelize.literal(x)` via receiver-type +/// qualification (`TypeKind::Sequelize` → `Sequelize.literal`) +/// - TypeORM `repo.query(...)` via receiver-type qualification +/// (`TypeKind::TypeOrmRepo` → `TypeOrmRepo.query`) +/// - Knex `db.whereRaw(...)` via the new file-level +/// `FileImportsModule(&["knex"])` gate +/// +/// Negatives: +/// - parameterised TypeORM `repo.query("...", [const])` stays silent +/// - bare `whereRaw` / `literal` calls in a file without ORM imports +#[test] +fn orm_builders() { + let findings = scan_fixture("orm_builders"); + + // (file, sink_line) — sink_line points at the actual SQL builder call. + // `sqli_typeorm_query.ts` previously asserted line 17 (`res.json(rows)`) + // and was satisfied by a coincidental XSS finding; the real + // `repo.query(...)` sink lives on line 16, and the cap-aware assertion + // below pins the SQL_QUERY capability so an XSS regression cannot mask + // a missing receiver-type-qualified ORM rule. + let positives = [ + ("sqli_drizzle_sql_raw.ts", 13usize), + ("sqli_drizzle_tagged_template.ts", 14usize), + ("sqli_sequelize_literal.ts", 14usize), + ("sqli_typeorm_query.ts", 16usize), + ("sqli_knex_where_raw.ts", 15usize), + ("sqli_mikroorm_execute.ts", 13usize), + ]; + for (file, sink_line) in positives { + assert_finding_with_cap( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: file, + sink_line, + source_line: None, + }, + Cap::SQL_QUERY.bits(), + ); + } + + let negatives = [ + "sqli_typeorm_safe_parameterized.ts", + "sqli_no_orm_import_safe.ts", + "sqli_knex_type_only_safe.ts", + ]; + for file in negatives { + let leak = findings.iter().any(|f| { + f.path.ends_with(file) + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + && f.evidence + .as_ref() + .map(|e| (e.sink_caps & Cap::SQL_QUERY.bits()) != 0) + .unwrap_or(false) + }); + assert!( + !leak, + "ORM negative fixture {file} must not fire SQL_QUERY; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } +} + +/// Phase 08 recall-gap: SSRF URL-builder shapes. +/// +/// Coverage: +/// - `new URL(taintedPath)` propagates the path arg's taint into the +/// constructed URL value (no label rule, no summary — covered by the +/// URL-constructor pass added in Phase 08). +/// - `u.searchParams.set(k, taintedV)` / `.append(...)` taints the +/// receiver URL via the searchParams alias rule. +/// - `fetch({ url: taintedUrl, ... })` flows through the destination- +/// aware filter on the SSRF gate. +/// - `fetch(target)` where `target: URL` carries SSA-level +/// TypeKind::Url and the constructor-propagated taint. +/// +/// Negative: +/// - `new URL(req.body.path, "https://api.cal.com")` — the literal +/// base anchors an origin-locked StringFact prefix that +/// `is_string_safe_for_ssrf` honours, so the SSRF stays silent. +#[test] +fn ssrf_url_builders() { + let findings = scan_fixture("ssrf_url_builders"); + + let positives = [ + ("ssrf_new_url.ts", 12usize), + ("ssrf_searchparams_set.ts", 13usize), + ("ssrf_searchparams_append.ts", 12usize), + ("ssrf_fetch_object_form.ts", 11usize), + ("ssrf_fetch_url_typed_arg.ts", 13usize), + ("ssrf_fetch_object_shorthand.ts", 13usize), + ("ssrf_fetch_object_shorthand.ts", 19usize), + ]; + for (file, sink_line) in positives { + assert_finding_with_cap( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: file, + sink_line, + source_line: None, + }, + Cap::SSRF.bits(), + ); + } + + // Negative: origin-locked `new URL(path, "https://api.cal.com")` must + // not fire SSRF — the abstract-string prefix-lock suppresses it. + let negative = "ssrf_url_origin_locked.ts"; + let leak = findings.iter().any(|f| { + f.path.ends_with(negative) + && f.evidence + .as_ref() + .map(|e| (e.sink_caps & Cap::SSRF.bits()) != 0) + .unwrap_or(false) + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + !leak, + "origin-locked URL must not fire SSRF; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(negative)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); +} + +/// Phase 14 recall-gap: cross-language SSRF + URL-builder coverage. +/// +/// Mirrors `ssrf_url_builders` (JS/TS) for Python, Java, Rust, Go, Ruby, +/// PHP. Each language carries: +/// +/// * positive — a tainted source flowing into the language's +/// canonical HTTP client sink, asserting `Cap::SSRF` fires. +/// * origin-locked negative — a `(literal_base, tainted_path)` URL +/// builder shape; the abstract-string prefix lock honoured by +/// `is_string_safe_for_ssrf` suppresses the SSRF sink. +/// * search-params positive — a tainted URL passed positionally to +/// a Phase 14-added sink (`OkHttpClient.newCall`, +/// `\GuzzleHttp\Client::request`, etc.) so the new label rules +/// see real exercise alongside the existing flat sinks. +#[test] +fn ssrf_cross_language() { + let findings = scan_fixture("ssrf"); + + let positives = [ + // Python — tainted full URL flowing into requests.get / request. + "ssrf_py_positive.py", + "ssrf_py_search_params.py", + // Java — HttpClient.send + OkHttpClient.newCall (Phase 14 sink). + "SsrfJavaPositive.java", + "SsrfJavaSearchParams.java", + // Rust — reqwest::get + Client::new.get (chained verb-on-instance). + "ssrf_rs_positive.rs", + "ssrf_rs_search_params.rs", + // Go — http.Get + http.NewRequest. + "ssrf_go_positive.go", + "ssrf_go_search_params.go", + // Ruby — Net::HTTP.get + Faraday.get (Phase 14 sink). + "ssrf_rb_positive.rb", + "ssrf_rb_search_params.rb", + // Ruby Faraday.new(url: tainted) construction-time SSRF and + // Net::HTTP.start(host, port, proxy_addr: tainted) proxy-tainted + // Destination gates added in the Phase 14 follow-up. + "ssrf_rb_faraday_new.rb", + "ssrf_rb_net_http_proxy.rb", + // PHP — curl_exec via curl_setopt CURLOPT_URL gate (Phase 14) + // + Guzzle Client::request (Phase 14 sink). + "ssrf_php_positive.php", + "ssrf_php_search_params.php", + ]; + for file in positives { + let hit = findings.iter().any(|f| { + f.path.ends_with(file) + && f.evidence + .as_ref() + .map(|e| (e.sink_caps & Cap::SSRF.bits()) != 0) + .unwrap_or(false) + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + hit, + "SSRF expected to fire on {file}; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + + let negatives = [ + "ssrf_py_origin_locked.py", + "SsrfJavaOriginLocked.java", + "ssrf_rs_origin_locked.rs", + "ssrf_rs_origin_locked_const_fmt.rs", + "ssrf_go_origin_locked.go", + "ssrf_rb_origin_locked.rb", + "ssrf_rb_origin_locked_interp.rb", + "ssrf_php_origin_locked.php", + ]; + for file in negatives { + let leak = findings.iter().any(|f| { + f.path.ends_with(file) + && f.evidence + .as_ref() + .map(|e| (e.sink_caps & Cap::SSRF.bits()) != 0) + .unwrap_or(false) + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + !leak, + "origin-locked SSRF must stay silent on {file}; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } +} + +/// Phase 15 recall-gap: cross-language ORM and raw-SQL coverage. +/// +/// Mirrors `orm_builders` (JS/TS) for Python, Java, Ruby, Go, PHP. +/// Each language carries: +/// +/// * positive raw-string concat — tainted user input concatenated +/// into the SQL string flowing into the language's canonical +/// SQL_QUERY sink. +/// * positive interpolation — same shape but using language-native +/// interpolation (Python f-string inside `text(...)`, Java +/// `String.format`, Ruby `"#{...}"`, Go `fmt.Sprintf`, PHP +/// `"$var"`). +/// * negative parameterised — the parameterised API form with +/// literal SQL template + constant bind args, mirroring phase +/// 07's safe-parameterised approach. +#[test] +fn orm_xlang() { + let findings = scan_fixture("sqli_xlang"); + + let positives = [ + // (file, sink_line) + ("sqli_py_psycopg2_concat.py", 16usize), + ("sqli_py_sqlalchemy_text_fstring.py", 18usize), + ("SqliJavaConcat.java", 18usize), + ("SqliJavaHibernateNative.java", 14usize), + ("SqliJavaHibernateNamedSession.java", 19usize), + ("SqliJavaHibernateChainedSession.java", 23usize), + ("sqli_rb_concat.rb", 8usize), + ("sqli_rb_where_interp.rb", 9usize), + ("sqli_go_concat.go", 14usize), + ("sqli_go_gorm_raw.go", 20usize), + ("sqli_go_gorm_raw_named.go", 28usize), + ("sqli_py_django_qs_bound.py", 14usize), + ("sqli_py_django_qs_bare.py", 16usize), + ("sqli_php_pdo_concat.php", 9usize), + ("sqli_php_doctrine_interp.php", 10usize), + ]; + for (file, sink_line) in positives { + assert_finding_with_cap( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: file, + sink_line, + source_line: None, + }, + Cap::SQL_QUERY.bits(), + ); + } + + let negatives = [ + "sqli_py_param_safe.py", + // Phase 15 deferred-fix: tainted bind args at arg 1 of + // `cursor.execute("SELECT ... WHERE x = %s", (tainted,))` must + // stay silent on SQL_QUERY because `payload_args = &[0]` on the + // Destination gate restricts the sink scan to arg 0. + "sqli_py_param_tainted_binds.py", + "SqliJavaParamSafe.java", + // Phase 15 deferred-fix (Java): tainted `setParameter` bind + // value on a constant `entityManager.createQuery(...)` template + // must stay silent on SQL_QUERY. Mirrors the Python tainted- + // binds shape; the Java Destination gate on the createQuery + // family carries `payload_args = &[0]`. + "SqliJavaParamTaintedBinds.java", + "sqli_rb_param_safe.rb", + "sqli_go_param_safe.go", + // Phase 15 deferred-fix (Go): tainted bind value at arg 2 of + // `db.QueryContext(ctx, sql, tainted)` must stay silent. The + // Destination gate on `db.QueryContext` carries + // `payload_args = &[1]`, restricting the sink scan to the SQL + // string at arg 1. + "sqli_go_param_tainted_binds.go", + "sqli_php_param_safe.php", + ]; + for file in negatives { + let leak = findings.iter().any(|f| { + f.path.ends_with(file) + && f.evidence + .as_ref() + .map(|e| (e.sink_caps & Cap::SQL_QUERY.bits()) != 0) + .unwrap_or(false) + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + !leak, + "parameterised SQLi negative {file} must stay silent on SQL_QUERY; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } +} + +/// Phase 09 recall-gap: cross-package IPA via FuncKey namespace +/// resolution. `unsafeHandler` calls `escapeHtmlNoop` (a passthrough +/// imported from `@scope/util/sanitize`); the engine sees the imported +/// callee's SSA summary via step 0.7 of `resolve_callee_full` and +/// therefore propagates `req.query.x` taint into `res.send` on line 7. +/// `safeHandler` calls `stripTags` (a real `replace`-based sanitizer +/// imported from `@scope/util/strip`) and must stay silent. +#[test] +fn cross_package_ipa() { + let findings = scan_fixture("cross_package_ipa"); + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: "handler.ts", + sink_line: 7, + source_line: Some(5), + }, + ); + let safe_hit = findings.iter().any(|f| { + f.id.starts_with("taint-unsanitised-flow") && f.path.ends_with("handler.ts") && f.line == 13 + }); + assert!( + !safe_hit, + "cross-package sanitizer fixture must stay silent at handler.ts:13; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with("handler.ts")) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); +} + +/// Phase 10 recall-gap: Next.js entry-point detection. Coverage: +/// - App Router POST handler at `app/api/users/route.ts`: the first +/// formal is typed as `TypeKind::Request`, so `await req.json()` +/// surfaces as a SQL_QUERY sink at the `db.query(body)` call. +/// - File-level `'use server'` directive +/// (`nextjs_server_action.ts`, `nextjs_use_server_directive.ts`): +/// every exported function's formals are seeded as Source taint +/// at SSA entry. +/// - Function-level `'use server'` +/// (`nextjs_use_server_function_level.ts`): only the directive- +/// bearing function is treated as a server action. +/// - `
    ` JSX binding (`nextjs_form_action.tsx`): +/// the named callee is tagged `EntryKind::FormAction` and its +/// first formal is seeded as adversary input. +/// - `next/headers` `cookies()` import-gated source: the gated rule +/// fires only when `cookies` is bound from `next/headers`. +#[test] +fn nextjs_entrypoints() { + let findings = scan_fixture("nextjs_entrypoints"); + + // Each fixture asserts the SQL sink fires. + let positives = [ + ("route.ts", 11usize), + ("nextjs_server_action.ts", 11usize), + ("nextjs_use_server_directive.ts", 9usize), + ("nextjs_use_server_function_level.ts", 8usize), + ("nextjs_form_action.tsx", 10usize), + ("nextjs_cookies_source.ts", 12usize), + ]; + for (file, sink_line) in positives { + assert_finding( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: file, + sink_line, + source_line: None, + }, + ); + } +} + +/// Phase 13 recall-gap (cross-language path traversal). Five +/// languages, one positive + one sanitized fixture each, exercising the +/// new `Path.read_text` (Python), `Files.readAllBytes` (Java), +/// `tokio::fs::read` (Rust), `os.ReadFile` (Go), and `File.write` +/// (Ruby) FILE_IO sinks added in Phase 13. Sanitized fixtures +/// canonicalise the path through the language-native sanitiser +/// (`Path.resolve` / `Path.normalize` / `PathBuf::canonicalize` / +/// `filepath.Clean` / `Pathname#cleanpath`) and demonstrate the safe +/// pattern by structuring the call chain so no FILE_IO sink reaches the +/// canonical value, keeping the fixture silent. +#[test] +fn path_traversal_xlang() { + let positives = [ + // (file, sink_line, source_line) + ("path_traversal.py", 12usize, Some(11usize)), + ("PathTraversal.java", 16, Some(15)), + ("path_traversal.rs", 22, Some(21)), + ("path_traversal.go", 14, Some(13)), + ("path_traversal.rb", 7, Some(6)), + ]; + for (file, sink_line, source_line) in positives { + let findings = scan_fixture(&format!("path_traversal/{file}")); + assert_finding_with_cap( + &findings, + ExpectedFinding { + rule_id: "taint-unsanitised-flow", + file_suffix: file, + sink_line, + source_line, + }, + Cap::FILE_IO.bits(), + ); + } + + let negatives = [ + "path_traversal_safe.py", + "PathTraversalSafe.java", + "path_traversal_safe.rs", + "path_traversal_safe.go", + "path_traversal_safe.rb", + ]; + for file in negatives { + let findings = scan_fixture(&format!("path_traversal/{file}")); + let leak = findings.iter().any(|f| { + f.path.ends_with(file) + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + !leak, + "path_traversal sanitized fixture {file} must stay silent; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } +} + +/// Phase 16 recall-gap: cross-language framework entry-point detection. +/// +/// One fixture per framework, each takes a request input (function-formal +/// or path-captured kwarg) and pipes it to a language-native sink. Every +/// fixture must fire the expected sink with the request parameter as +/// Source via the entry-kind seeding policy in `taint/ssa_transfer/mod.rs`. +/// +/// The Spring fixture composes with phase 15 (Hibernate +/// `entityManager.createNativeQuery`), proving cross-phase composition +/// holds across languages. +#[test] +fn entry_points_xlang() { + let findings = scan_fixture("entry_points_xlang"); + + let positives = [ + "django_view.py", + "fastapi_route.py", + "flask_route.py", + "spring_controller.java", + "rails_action.rb", + "axum_handler.rs", + "actix_handler.rs", + "gin_handler.go", + "express_route.js", + ]; + for file in positives { + let hit = findings.iter().any(|f| { + f.path.ends_with(file) + && (f.id.starts_with("taint-unsanitised-flow") + || f.id.starts_with("cfg-unguarded-sink")) + }); + assert!( + hit, + "Phase 16 entry-point fixture {file} must fire a taint sink; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } +} + +/// Rust entry-kind seeding precision: typed extractor formals +/// (`Query`, `Json`, `Form`, `Path`, `web::*`) get +/// painted as `Source(UserInput)`, while denylist DI handles +/// (`State`, `Extension`, ...) do not. Without this guard, the +/// scoped-lowering lift for Rust handlers would FP-fire every +/// database / shared-state sink consuming a pool handle. The +/// positive shape asserts the rule_id is specifically +/// `taint-unsanitised-flow` (not `cfg-unguarded-sink`), so a future +/// regression that drops entry-kind seeding is forcing-function +/// caught. +#[test] +fn rust_entry_kind_typed_extractor_seeding() { + let findings = scan_fixture("entry_points_xlang_rust"); + let positives = [ + ("axum_query_typed_extractor.rs", 12usize), + ("actix_path_typed_extractor.rs", 11usize), + ]; + for (file, sink_line) in positives { + let hit = findings.iter().any(|f| { + f.path.ends_with(file) + && f.id.starts_with("taint-unsanitised-flow") + && f.line == sink_line + }); + assert!( + hit, + "Rust typed-extractor handler {file}:{sink_line} must fire \ + `taint-unsanitised-flow`; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + // Negative: State> formals must not produce + // taint-unsanitised-flow findings. cfg-unguarded-sink is fine + // — that is the pre-existing structural backup, not a seeding + // claim against the formal. + let state_taint_findings: Vec<&_> = findings + .iter() + .filter(|f| { + f.path.ends_with("axum_state_denylist.rs") && f.id.starts_with("taint-unsanitised-flow") + }) + .collect(); + assert!( + state_taint_findings.is_empty(), + "State formals must not be painted as Source; got:\n{}", + state_taint_findings + .iter() + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); +} + +/// Python entry-kind seeding precision for `FlaskRoute`: path-bound +/// formals (`@app.route("/u/")` + `def view(name):`) get painted +/// as `Source(UserInput)`, while routes without path captures stay +/// un-seeded. Without per-formal route-capture gating, Python handlers +/// fell back to `cfg-unguarded-sink` for path-bound flows. The +/// positive shape asserts the rule_id is specifically +/// `taint-unsanitised-flow` (not `cfg-unguarded-sink`), so a future +/// regression that drops entry-kind seeding is forcing-function +/// caught. The negative shape pins the absence of taint findings on a +/// no-capture route (no formals, no seed, no flow). +#[test] +fn python_flask_route_path_capture_seeding() { + let findings = scan_fixture("entry_points_xlang_python"); + let positives = [ + ("flask_path_capture.py", 14usize), + ("flask_converter_capture.py", 14usize), + ]; + for (file, sink_line) in positives { + let hit = findings.iter().any(|f| { + f.path.ends_with(file) + && f.id.starts_with("taint-unsanitised-flow") + && f.line == sink_line + }); + assert!( + hit, + "Python Flask path-capture handler {file}:{sink_line} must fire \ + `taint-unsanitised-flow`; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + // Negative: a Flask route with no path captures and a literal + // sink argument must not surface `taint-unsanitised-flow`. + let no_capture_taint: Vec<&_> = findings + .iter() + .filter(|f| { + f.path.ends_with("flask_no_capture.py") && f.id.starts_with("taint-unsanitised-flow") + }) + .collect(); + assert!( + no_capture_taint.is_empty(), + "Flask route without path captures must not paint formals as Source; got:\n{}", + no_capture_taint + .iter() + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); +} + +/// Python FastAPI entry-kind seeding precision for `FastApiRoute`: +/// path-bound formals from `{name}` brace-segment captures +/// (`@app.get("/items/{item_id}")` + `def read_item(item_id: str):`) +/// AND Annotated typed extractors (`q: Annotated[str, Query()]`) get +/// painted as `Source(UserInput)`. Formals that carry a `Depends(...)` +/// default or a non-extractor type annotation (`db: Session`, +/// `request: Request`) stay un-seeded. Without per-formal gating, +/// FastAPI handlers fell back to `cfg-unguarded-sink` for path-bound +/// flows. The positive shapes assert the rule_id is specifically +/// `taint-unsanitised-flow`, so a future regression that drops +/// entry-kind seeding is forcing-function caught. The negative shape +/// pins the absence of `taint-unsanitised-flow` on a DI-only handler. +#[test] +fn python_fastapi_route_per_formal_seeding() { + let findings = scan_fixture("entry_points_xlang_python_fastapi"); + let positives = [ + ("fastapi_path_capture.py", 18usize), + ("fastapi_annotated_query.py", 17usize), + ]; + for (file, sink_line) in positives { + let hit = findings.iter().any(|f| { + f.path.ends_with(file) + && f.id.starts_with("taint-unsanitised-flow") + && f.line == sink_line + }); + assert!( + hit, + "Python FastAPI handler {file}:{sink_line} must fire \ + `taint-unsanitised-flow`; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + let depends_taint: Vec<&_> = findings + .iter() + .filter(|f| { + f.path.ends_with("fastapi_depends_denylist.py") + && f.id.starts_with("taint-unsanitised-flow") + }) + .collect(); + assert!( + depends_taint.is_empty(), + "FastAPI Depends(...) DI handle must not be painted as Source; got:\n{}", + depends_taint + .iter() + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); +} + +/// Ruby Sinatra entry-kind seeding precision for `SinatraRoute`: +/// path-bound block formals (`get "/u/:name" do |name| ... end`) +/// get painted as `Source(UserInput)`, while routes without path +/// captures stay un-seeded. Without per-formal route-capture +/// gating, Sinatra handlers fell back to `cfg-unguarded-sink` for +/// path-bound flows. The positive shape asserts the rule_id is +/// specifically `taint-unsanitised-flow`, so a future regression +/// that drops entry-kind seeding is forcing-function caught. The +/// negative shape pins the absence of taint findings on a +/// no-capture route (no block formals, no seed, no flow). +#[test] +fn ruby_sinatra_route_path_capture_seeding() { + let findings = scan_fixture("entry_points_xlang_ruby"); + let positives = [ + ("sinatra_path_capture.rb", 9usize), + ("sinatra_multi_capture.rb", 8usize), + ]; + for (file, sink_line) in positives { + let hit = findings.iter().any(|f| { + f.path.ends_with(file) + && f.id.starts_with("taint-unsanitised-flow") + && f.line == sink_line + }); + assert!( + hit, + "Ruby Sinatra path-capture handler {file}:{sink_line} must fire \ + `taint-unsanitised-flow`; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } + let no_capture_taint: Vec<&_> = findings + .iter() + .filter(|f| { + f.path.ends_with("sinatra_no_capture.rb") && f.id.starts_with("taint-unsanitised-flow") + }) + .collect(); + assert!( + no_capture_taint.is_empty(), + "Sinatra route without path captures must not paint formals as Source; got:\n{}", + no_capture_taint + .iter() + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); +} + +/// Go entry-kind precision: `GinRoute` (`*gin.Context`, +/// `echo.Context`, `*fiber.Ctx`, `iris.Context`) and `GoNetHttp` +/// (`(w http.ResponseWriter, r *http.Request)`) handlers route +/// adversary bytes through access-path label rules +/// (`c.Query`, `c.Param`, `c.PostForm`, `r.URL.Query`, +/// `r.FormValue`, `r.Header.Get`, ...) rather than via flat +/// formal seeding. Same precedent as the Express +/// `seed_at_all=false` arm: painting the bare `c` / `r` object +/// as `Source(Cap::all())` re-fires excluded lifecycle methods +/// (`c.AbortWithStatus`, `r.Context()`, etc.) as structural +/// sinks. The positive shapes assert the rule_id is specifically +/// `taint-unsanitised-flow` (not the OR-cfg-unguarded-sink path +/// the cross-language `entry_points_xlang` test accepts), so a +/// future regression that mis-classifies access paths is +/// forcing-function caught. +#[test] +fn go_entry_kind_label_rules_carry_request() { + let findings = scan_fixture("entry_points_xlang"); + let positives = [ + ("gin_handler.go", 24usize), + ("net_http_handler.go", 21usize), + ]; + for (file, sink_line) in positives { + let hit = findings.iter().any(|f| { + f.path.ends_with(file) + && f.id.starts_with("taint-unsanitised-flow") + && f.line == sink_line + }); + assert!( + hit, + "Go handler {file}:{sink_line} must fire \ + `taint-unsanitised-flow` via access-path label rules; got:\n{}", + findings + .iter() + .filter(|f| f.path.ends_with(file)) + .map(|f| format!(" {} :: {}:{}", f.id, f.path, f.line)) + .collect::>() + .join("\n"), + ); + } +} + +/// Phase 11 + 17 acceptance: every per-target baseline JSON in +/// `tests/recall_targets/` (Phase 11 JS targets) and +/// `tests/recall_targets/xlang//` (Phase 17 cross-lang targets) +/// exists, parses via `serde_json`, and every finding entry carries +/// a `verdict: "TP" | "FP" | "needs_review"` label. Marked `#[ignore]` +/// because `cargo test --release` should not require a populated +/// baseline directory on a clean clone — the `validate_recall.sh` +/// runbook is the authoritative way to refresh these. Run explicitly +/// with `cargo test --release --test recall_gaps -- +/// --ignored validate_real_world_targets`. +#[test] +#[ignore] +fn validate_real_world_targets() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/recall_targets"); + + // Phase 11 JS targets — ship at the top level. + let js_targets = [ + "cal_com", + "vercel_commerce", + "shadcn_examples", + "blitz_apps", + ]; + let mut paths: Vec = js_targets + .iter() + .map(|t| root.join(format!("{t}.json"))) + .collect(); + + // Phase 17 cross-lang targets — under `xlang//.json`. + // Derived from filesystem inspection so adding a new lang/target only + // requires dropping the JSON file under `tests/recall_targets/xlang/`. + let xlang_root = root.join("xlang"); + if let Ok(entries) = std::fs::read_dir(&xlang_root) { + let mut lang_dirs: Vec = entries + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + lang_dirs.sort(); + for lang_dir in lang_dirs { + let mut json_paths: Vec = std::fs::read_dir(&lang_dir) + .unwrap_or_else(|e| panic!("read xlang dir {}: {e}", lang_dir.display())) + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json")) + .collect(); + json_paths.sort(); + paths.extend(json_paths); + } + } + + for path in &paths { + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display())); + let value: serde_json::Value = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("parse baseline {}: {e}", path.display())); + let obj = value + .as_object() + .unwrap_or_else(|| panic!("baseline {} must be a JSON object", path.display())); + for key in [ + "target", + "clone_url", + "captured_against", + "captured_on", + "pinned_commit", + ] { + assert!( + obj.contains_key(key), + "baseline {} must record `{key}`", + path.display() + ); + } + let findings = obj + .get("findings") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("baseline {} must record `findings: []`", path.display())); + for (i, f) in findings.iter().enumerate() { + let verdict = f + .get("verdict") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + panic!("baseline {} finding {i} missing `verdict`", path.display()) + }); + assert!( + matches!(verdict, "TP" | "FP" | "needs_review"), + "baseline {} finding {i} has invalid verdict {verdict:?} (must be TP|FP|needs_review)", + path.display() + ); + } + } +} + +#[test] +fn baseline_loads() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/recall_gaps_baseline.json"); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display())); + let value: serde_json::Value = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("parse baseline {}: {e}", path.display())); + assert!(value.is_object(), "baseline must be a JSON object"); + assert!( + value.get("recall_gaps_tests").is_some(), + "baseline must record `recall_gaps_tests`" + ); + assert!( + value.get("corpus_finding_lines").is_some(), + "baseline must record `corpus_finding_lines`" + ); + let corpus = value.get("corpus_finding_lines").unwrap(); + let rule_full = corpus.get("rule_id_full").unwrap_or_else(|| { + panic!( + "baseline must record `corpus_finding_lines.rule_id_full` (per-rule snapshot, not just top-15) so phases 03-11 can prove rule-level non-regression" + ) + }); + let map = rule_full + .as_object() + .expect("`rule_id_full` must be a JSON object mapping rule_id → count"); + let distinct = corpus + .get("rule_id_distinct") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + assert_eq!( + map.len(), + distinct, + "rule_id_full ({}) must cover every distinct rule_id ({})", + map.len(), + distinct + ); +} diff --git a/tests/recall_gaps_baseline.json b/tests/recall_gaps_baseline.json new file mode 100644 index 00000000..67b31999 --- /dev/null +++ b/tests/recall_gaps_baseline.json @@ -0,0 +1,144 @@ +{ + "_doc": "Frozen recall-gap baseline. Phases 02-11 prove non-regression by re-running the corpus scan and verifying corpus_findings_total does not drop and rule_id_full counts do not regress per-rule. Hard rule: pitboss agents may not write under .pitboss/, so the baseline lives here in tests/ next to the harness it documents.", + "captured_on": "2026-05-08", + "captured_against": "master @ ea82ea98 (post phase 03/05/06/07 land)", + "recall_gaps_tests": { + "binary": "recall_gaps", + "ignored_count": 3, + "ignored": [ + "cross_package_ipa", + "nextjs_entrypoints", + "ssrf_url_builders" + ], + "non_ignored": [ + "async_await", + "baseline_loads", + "for_await_of_stream", + "fs_promises_alias_form", + "fs_promises_alias_require_form", + "fs_promises_namespace_import", + "fs_promises_node_import", + "fs_promises_open", + "fs_promises_readfile", + "fs_promises_require_form", + "fs_promises_safe_userfn", + "jsx_dangerous_html", + "orm_builders", + "promise_all_taint", + "promise_then_callback", + "promise_then_chain_reentrant" + ] + }, + "corpus_finding_lines": { + "scan_root": "tests/fixtures", + "command": "nyx scan tests/fixtures --index off --format console", + "output_lines": 6466, + "json_command": "nyx scan tests/fixtures --index off --format json", + "findings_total": 1121, + "findings_by_severity": { + "Low": 20, + "Medium": 1101 + }, + "rule_id_distinct": 81, + "rule_id_top": { + "taint-unsanitised-flow": 542, + "state-unauthed-access": 41, + "py.cmdi.subprocess_shell": 35, + "js.code_exec.eval": 30, + "taint-data-exfiltration": 29, + "js.auth.missing_ownership_check": 26, + "go.cmdi.exec_command": 20, + "taint-open-redirect": 19, + "cfg-unguarded-sink": 18, + "state-use-after-close": 17, + "java.cmdi.runtime_exec": 17, + "taint-prototype-pollution": 16, + "taint-template-injection": 15, + "py.auth.missing_ownership_check": 15, + "rb.cmdi.system_interp": 14 + }, + "rule_id_full": { + "c.cmdi.system": 10, + "c.memory.gets": 3, + "c.memory.printf_no_fmt": 2, + "c.memory.scanf_percent_s": 3, + "c.memory.sprintf": 12, + "c.memory.strcat": 3, + "c.memory.strcpy": 6, + "cfg-auth-gap": 2, + "cfg-unguarded-sink": 18, + "cpp.cmdi.popen": 1, + "cpp.cmdi.system": 8, + "cpp.memory.gets": 2, + "cpp.memory.printf_no_fmt": 3, + "cpp.memory.sprintf": 2, + "cpp.memory.strcat": 1, + "cpp.memory.strcpy": 2, + "go.auth.admin_route_missing_admin_check": 3, + "go.auth.missing_ownership_check": 8, + "go.auth.partial_batch_authorization": 2, + "go.auth.token_override_without_validation": 1, + "go.cmdi.exec_command": 20, + "go.transport.insecure_skip_verify": 1, + "java.auth.admin_route_missing_admin_check": 2, + "java.auth.missing_ownership_check": 3, + "java.cmdi.runtime_exec": 17, + "java.code_exec.text4shell_interpolator": 1, + "java.deser.readobject": 5, + "java.deser.snakeyaml_unsafe_constructor": 1, + "js.auth.admin_route_missing_admin_check": 9, + "js.auth.missing_ownership_check": 26, + "js.auth.partial_batch_authorization": 3, + "js.auth.token_override_without_validation": 6, + "js.code_exec.eval": 30, + "js.code_exec.new_function": 1, + "js.config.cors_dynamic_origin": 1, + "js.xss.ejs_unescaped": 2, + "php.cmdi.system": 10, + "php.code_exec.eval": 6, + "php.code_exec.preg_replace_e": 1, + "php.deser.unserialize": 2, + "py.auth.admin_route_missing_admin_check": 4, + "py.auth.missing_ownership_check": 15, + "py.auth.partial_batch_authorization": 2, + "py.auth.token_override_without_validation": 6, + "py.cmdi.os_popen": 2, + "py.cmdi.os_system": 13, + "py.cmdi.subprocess_shell": 35, + "py.code_exec.eval": 6, + "py.code_exec.exec": 3, + "py.deser.pickle_loads": 3, + "py.deser.yaml_load": 3, + "rb.auth.admin_route_missing_admin_check": 5, + "rb.auth.missing_ownership_check": 14, + "rb.auth.partial_batch_authorization": 2, + "rb.auth.token_override_without_validation": 3, + "rb.cmdi.backtick": 2, + "rb.cmdi.system_interp": 14, + "rb.code_exec.class_eval": 1, + "rb.code_exec.eval": 3, + "rb.code_exec.instance_eval": 1, + "rb.deser.marshal_load": 2, + "rb.deser.yaml_load": 2, + "rs.auth.admin_route_missing_admin_check": 3, + "rs.auth.missing_ownership_check": 9, + "rs.auth.partial_batch_authorization": 2, + "rs.auth.token_override_without_validation": 2, + "rs.memory.copy_nonoverlapping": 1, + "rs.memory.mem_zeroed": 1, + "rs.memory.ptr_read": 1, + "rs.memory.transmute": 2, + "state-unauthed-access": 41, + "state-use-after-close": 17, + "taint-data-exfiltration": 29, + "taint-header-injection": 13, + "taint-ldap-injection": 9, + "taint-open-redirect": 19, + "taint-prototype-pollution": 16, + "taint-template-injection": 15, + "taint-unsanitised-flow": 542, + "taint-xpath-injection": 8, + "taint-xxe": 11 + } + } +} diff --git a/tests/recall_targets/blitz_apps.json b/tests/recall_targets/blitz_apps.json new file mode 100644 index 00000000..2edf57e1 --- /dev/null +++ b/tests/recall_targets/blitz_apps.json @@ -0,0 +1,999 @@ +{ + "_doc": "Phase 11 recall-validation baseline for blitz-js/blitz example apps. Pinned commit + captured findings live in this file. Re-capture by running scripts/validate_recall.sh blitz_apps --capture against a fresh checkout. Baseline location is tests/recall_targets/ (relocated out of .pitboss/ per the Phase 01 precedent — pitboss implementer agents must not write under .pitboss/).", + "target": "blitz_apps", + "clone_url": "https://github.com/blitz-js/blitz", + "exercises_recall_items": [ + 1, + 3, + 6 + ], + "captured_against": "real-scan @ b18f81873e641934043f791fec06e22f5fe5a86e", + "captured_on": "2026-05-10", + "pinned_commit": "b18f81873e641934043f791fec06e22f5fe5a86e", + "findings": [ + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1285, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/adapter.ts", + "line": 167, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/adapter.ts", + "line": 168, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/internals/utils/web.ts", + "line": 106, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/adapter.ts", + "line": 209, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/adapter.ts", + "line": 210, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/internals/utils/web.ts", + "line": 106, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-rpc/src/index-server.ts", + "line": 313, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "integration-tests/auth-with-rpc/src/custom-plugin/plugin.ts", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/adapter.ts", + "line": 123, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/adapter.ts", + "line": 123, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 726, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1071, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1072, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1080, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 726, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-next/src/index-browser.tsx", + "line": 49, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz/src/cli/utils/routes-manifest.ts", + "line": 299, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 726, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 964, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 965, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 966, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 968, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1020, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1022, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1023, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1025, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1082, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1132, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1212, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1297, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1335, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/blitz/src/cli/utils/next-console.ts", + "line": 214, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/blitz-rpc/src/index-server.ts", + "line": 314, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/blitz-rpc/src/client/rpc.ts", + "line": 84, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 547, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 575, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 580, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 590, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 630, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 699, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 726, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 757, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 847, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 864, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 949, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/passport/adapter.ts", + "line": 114, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/passport/adapter.ts", + "line": 108, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz/src/cli/utils/routes-manifest.ts", + "line": 299, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-next/src/index-server.ts", + "line": 268, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz/src/utils/env.ts", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz/src/utils/env.ts", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz/src/utils/env.ts", + "line": 105, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "integration-tests/utils/browsers/playwright.ts", + "line": 146, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "integration-tests/utils/browsers/playwright.ts", + "line": 156, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/blitz/src/cli/utils/routes-manifest.ts", + "line": 160, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/generator/src/utils/log.ts", + "line": 34, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1285, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/passport/adapter.ts", + "line": 108, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/adapter.ts", + "line": 123, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 726, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/blitz/src/cli/utils/next-console.ts", + "line": 143, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/blitz-auth/src/client/index.tsx", + "line": 359, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/blitz-auth/src/client/index.tsx", + "line": 374, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/blitz/src/utils/env.ts", + "line": 54, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.code_exec.eval", + "path_suffix": "packages/blitz/src/utils/server.ts", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/next13/src/auth/mutations/resetPassword.ts", + "line": 27, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/next13/src/auth/mutations/resetPassword.ts", + "line": 36, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/next13/src/auth/mutations/resetPassword.ts", + "line": 44, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/next13/src/auth/mutations/signup.ts", + "line": 12, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/next13/src/users/queries/getCurrentUser.ts", + "line": 6, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app-passportjs/src/auth/mutations/resetPassword.ts", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app-passportjs/src/auth/mutations/resetPassword.ts", + "line": 37, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app-passportjs/src/auth/mutations/resetPassword.ts", + "line": 43, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app-passportjs/src/auth/mutations/signup.ts", + "line": 15, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app-passportjs/src/users/queries/getCurrentUser.ts", + "line": 7, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app/src/auth/mutations/resetPassword.ts", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app/src/auth/mutations/resetPassword.ts", + "line": 37, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app/src/auth/mutations/resetPassword.ts", + "line": 43, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app/src/auth/mutations/signup.ts", + "line": 15, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/toolkit-app/src/users/queries/getCurrentUser.ts", + "line": 7, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "integration-tests/auth-with-rpc/src/mutations/login.ts", + "line": 8, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1010, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1096, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1110, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1141, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 1229, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/generator/templates/app/src/app/auth/mutations/signup.ts", + "line": 12, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/generator/templates/app/src/app/users/queries/getCurrentUser.ts", + "line": 6, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/generator/templates/pages/src/users/queries/getCurrentUser.ts", + "line": 7, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1340, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1216, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1244, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 223, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 317, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/secure-password.ts", + "line": 23, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/secure-password.ts", + "line": 26, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 360, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 363, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 444, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 447, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 478, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 481, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 501, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 504, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 524, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 527, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 954, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/codemod/src/upgrade-legacy.ts", + "line": 1014, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "packages/blitz-auth/src/server/adapters/passport/adapter.ts", + "line": 133, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/blitz/src/cli/index.ts", + "line": 161, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/blitz/src/utils/server.ts", + "line": 9, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/codemod/src/index.ts", + "line": 25, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.fallback_secret", + "path_suffix": "packages/blitz-auth/src/server/adapters/next-auth/adapter.ts", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.fallback_secret", + "path_suffix": "packages/blitz-auth/src/server/adapters/passport/adapter.ts", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.fallback_secret", + "path_suffix": "packages/blitz-auth/src/server/auth-sessions.ts", + "line": 626, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/toolkit-app-passportjs/src/auth/mutations/signup.ts", + "line": 9, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/toolkit-app/src/auth/mutations/signup.ts", + "line": 9, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/web/src/pages/api/signup.ts", + "line": 11, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "integration-tests/auth-with-rpc/src/mutations/login.ts", + "line": 4, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "packages/blitz-rpc/test/blitz-test-utils.ts", + "line": 9, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "packages/generator/templates/app/src/app/auth/mutations/signup.ts", + "line": 7, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.xss.cookie_write", + "path_suffix": "packages/blitz/src/utils/index.ts", + "line": 73, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/recall_targets/cal_com.json b/tests/recall_targets/cal_com.json new file mode 100644 index 00000000..4c4be9a2 --- /dev/null +++ b/tests/recall_targets/cal_com.json @@ -0,0 +1,5064 @@ +{ + "_doc": "Phase 11 recall-validation baseline for cal.com. Pinned commit + captured findings live in this file. Re-capture by running scripts/validate_recall.sh cal_com --capture against a fresh checkout. Baseline location is tests/recall_targets/ (relocated out of .pitboss/ per the Phase 01 precedent — pitboss implementer agents must not write under .pitboss/).", + "target": "cal_com", + "clone_url": "https://github.com/calcom/cal.com", + "exercises_recall_items": [ + 1, + 5, + 6, + 7 + ], + "captured_against": "real-scan @ d278d6c9bc535bf3f2c6ba0607654f78dd74d6ee", + "captured_on": "2026-05-11", + "pinned_commit": "d278d6c9bc535bf3f2c6ba0607654f78dd74d6ee", + "findings": [ + { + "rule_id": "taint-header-injection", + "path_suffix": "apps/web/proxy.ts", + "line": 136, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "@calcom/trpc::packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/embed-core::packages/embeds/embed-core/src/lib/utils.ts", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/embed-core::packages/embeds/embed-core/src/lib/utils.ts", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/trpc/server/routers/viewer/slots/util.ts", + "line": 208, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 235, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/features::packages/features/availability/lib/getUserAvailability.ts", + "line": 711, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 253, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-04-15/controllers/bookings.controller.ts", + "line": 261, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 317, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/bookings/lib/getBookingFields.ts", + "line": 52, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/tasker/tasks/crm/createCRMEvent.ts", + "line": 217, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/features::packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 317, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/features::packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 317, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/features::packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 317, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/features::packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 317, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/bookings/lib/getBookingFields.ts", + "line": 255, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/bookings/lib/getBookingFields.ts", + "line": 299, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/features::packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 317, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/features::packages/features/feature-opt-in/services/FeatureOptInService.ts", + "line": 317, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "@calcom/features::packages/features/users/repositories/UserRepository.ts", + "line": 283, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/huddle01video/lib/VideoApiAdapter.ts", + "line": 130, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/flags/features.repository.ts", + "line": 148, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/eventtypes/lib/getEventTypesPublic.ts", + "line": 13, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/webhooks/lib/service/WebhookService.ts", + "line": 153, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/embeds/embed-core/playground/lib/playground.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/flags/repositories/PrismaUserFeatureRepository.ts", + "line": 168, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/flags/features.repository.ts", + "line": 185, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/flags/repositories/PrismaUserFeatureRepository.ts", + "line": 194, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts", + "line": 105, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/flags/features.repository.ts", + "line": 238, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/flags/repositories/PrismaUserFeatureRepository.ts", + "line": 238, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "@calcom/features::packages/features/webhooks/lib/sendPayload.ts", + "line": 255, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/giphy/api/get.ts", + "line": 32, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/flags/repositories/PrismaUserFeatureRepository.ts", + "line": 267, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/flags/features.repository.ts", + "line": 274, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/zohocalendar/api/callback.ts", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/zohocalendar/api/callback.ts", + "line": 93, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/eventtypes/lib/getEventTypesPublic.ts", + "line": 33, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/bookings/lib/handleSeats/create/createNewSeat.ts", + "line": 47, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/web/pages/api/integrations/[...args].ts", + "line": 78, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/office365calendar/api/callback.ts", + "line": 88, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store-cli/src/utils/execSync.ts", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/_utils/oauth/refreshOAuthTokens.ts", + "line": 18, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/lib/domainManager/deploymentServices/vercel.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/web/components/apps/wipemycalother/confirmDialog.tsx", + "line": 33, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/lib/domainManager/deploymentServices/cloudflare.ts", + "line": 42, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store-cli/src/utils/execSync.ts", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/lib/domainManager/deploymentServices/vercel.ts", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/web/cron-tester.ts", + "line": 15, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "apps/api/v2/src/lib/throttler-guard.ts", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "apps/api/v2/src/lib/throttler-guard.ts", + "line": 65, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "apps/api/v2/src/lib/throttler-guard.ts", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/auth/lib/next-auth-options.ts", + "line": 204, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/lib/server/defaultResponder.ts", + "line": 53, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/huddle01video/lib/VideoApiAdapter.ts", + "line": 63, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/huddle01video/lib/VideoApiAdapter.ts", + "line": 100, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-unauthed-access", + "path_suffix": "packages/app-store/giphy/api/get.ts", + "line": 32, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 220, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-08-13/services/input.service.ts", + "line": 259, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-08-13/services/input.service.ts", + "line": 265, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-04-15/controllers/bookings.controller.ts", + "line": 549, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-08-13/services/input.service.ts", + "line": 801, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-08-13/services/input.service.ts", + "line": 807, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-08-13/services/input.service.ts", + "line": 119, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-08-13/services/input.service.ts", + "line": 126, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/zoho-bigin/lib/CrmService.ts", + "line": 265, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/zoho-bigin/lib/CrmService.ts", + "line": 280, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/auth/signup/utils/prefillAvatar.ts", + "line": 35, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/features/url-shortener/providers/SinkClient.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "@calcom/embed-core::packages/embeds/embed-core/src/embed.ts", + "line": 1130, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/trpc/server/routers/viewer/admin/createSelfHostedLicenseKey.handler.ts", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "apps/api/v2/src/vercel-webhook.controller.ts", + "line": 75, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "@calcom/embed-core::packages/embeds/embed-core/src/lib/utils.ts", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/trpc/server/routers/viewer/slots/util.ts", + "line": 1369, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/web/cron-tester.ts", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/app-store/jelly/api/callback.ts", + "line": 21, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/app-store/pipedrive-crm/api/callback.ts", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/app-store/zoomvideo/api/callback.ts", + "line": 19, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/features/auth/signup/utils/prefillAvatar.ts", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/lib/CloseCom.ts", + "line": 252, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/lib/domainManager/deploymentServices/cloudflare.ts", + "line": 186, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/lib/server/defaultResponder.ts", + "line": 52, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/app-store/huddle01video/lib/VideoApiAdapter.ts", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/trpc/server/routers/viewer/admin/createCoupon.handler.ts", + "line": 65, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.code_exec.new_function", + "path_suffix": "apps/web/lib/pages/document/_applyThemeForDocument.test.ts", + "line": 15, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/auth/reset-password/route.ts", + "line": 50, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts", + "line": 142, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts", + "line": 162, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts", + "line": 181, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts", + "line": 264, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts", + "line": 272, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts", + "line": 74, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts", + "line": 166, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/availability/calendar/route.ts", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/bookingReminder/route.ts", + "line": 83, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/bookingReminder/route.ts", + "line": 146, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/changeTimeZone/route.ts", + "line": 34, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/changeTimeZone/route.ts", + "line": 44, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/changeTimeZone/route.ts", + "line": 48, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/changeTimeZone/route.ts", + "line": 58, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/changeTimeZone/route.ts", + "line": 95, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/changeTimeZone/route.ts", + "line": 143, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/link/route.ts", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/link/route.ts", + "line": 55, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/recorded-daily-video/route.ts", + "line": 106, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/verify-booking-token/route.ts", + "line": 129, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/video/guest-session/route.ts", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/webhook/app-credential/route.ts", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/webhook/app-credential/route.ts", + "line": 69, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/webhook/app-credential/route.ts", + "line": 80, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/webhook/app-credential/route.ts", + "line": 90, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/components/dialog/ReassignDialog.tsx", + "line": 147, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts", + "line": 193, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts", + "line": 227, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts", + "line": 279, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts", + "line": 289, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/booking.ts", + "line": 178, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/daily-webhook/getBooking.ts", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/pages/auth/verify-email.ts", + "line": 47, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/pages/auth/verify-email.ts", + "line": 106, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/pages/auth/verify-email.ts", + "line": 118, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/pages/auth/verify-email.ts", + "line": 127, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/pages/auth/verify-email.ts", + "line": 146, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/video/[uid]/getServerSideProps.ts", + "line": 112, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/video/[uid]/getServerSideProps.ts", + "line": 125, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/video/[uid]/getServerSideProps.ts", + "line": 147, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/video/[uid]/getServerSideProps.ts", + "line": 171, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/video/meeting-ended/[uid]/getServerSideProps.ts", + "line": 11, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/lib/video/meeting-not-started/[uid]/getServerSideProps.ts", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/bookings/components/BookEventForm/BookingFields.tsx", + "line": 142, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/bookings/hooks/useBookingCursor.ts", + "line": 15, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx", + "line": 80, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx", + "line": 192, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/bookings/views/bookings-single-view.tsx", + "line": 189, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/bookings/views/bookings-single-view.tsx", + "line": 190, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/bookings/views/bookings-single-view.tsx", + "line": 193, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/bookings/views/bookings-single-view.tsx", + "line": 196, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/data-table/hooks/useFilterValue.ts", + "line": 14, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/event-types/components/locations/HostLocations.tsx", + "line": 700, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/users/components/UserTable/UserListTable.tsx", + "line": 249, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/modules/users/components/UserTable/UserListTable.tsx", + "line": 603, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/pages/api/integrations/[...args].ts", + "line": 31, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/booking-sheet-keyboard.e2e.ts", + "line": 52, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/filter-helpers.ts", + "line": 14, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/filter-helpers.ts", + "line": 18, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/filter-helpers.ts", + "line": 22, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/filter-helpers.ts", + "line": 40, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/filter-helpers.ts", + "line": 41, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/filter-helpers.ts", + "line": 47, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/filter-helpers.ts", + "line": 65, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/oauth/oauth-client-admin.e2e.ts", + "line": 29, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/oauth/oauth-client-admin.e2e.ts", + "line": 40, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/oauth/oauth-client-admin.e2e.ts", + "line": 49, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/oauth/oauth-client-helpers.ts", + "line": 78, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/oauth/oauth-client-owner-crud.e2e.ts", + "line": 747, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/server/lib/[user]/[type]/getServerSideProps.ts", + "line": 69, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_appRegistry.ts", + "line": 84, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/bulkUpdateEventsToDefaultLocation.ts", + "line": 32, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/bulkUpdateEventsToDefaultLocation.ts", + "line": 42, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/bulkUpdateEventsToDefaultLocation.ts", + "line": 64, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/bulkUpdateTeamEventsToDefaultLocation.ts", + "line": 16, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/bulkUpdateTeamEventsToDefaultLocation.ts", + "line": 43, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/getBulkEventTypes.ts", + "line": 35, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/getBulkEventTypes.ts", + "line": 63, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/installation.ts", + "line": 43, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/invalidateCredential.ts", + "line": 5, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/invalidateCredential.ts", + "line": 12, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/oauth/createOAuthAppCredential.ts", + "line": 34, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/oauth/createOAuthAppCredential.ts", + "line": 44, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts", + "line": 35, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/oauth/updateTokenObject.ts", + "line": 21, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/oauth/updateTokenObject.ts", + "line": 65, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/oauth/updateTokenObject.ts", + "line": 76, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/oauth/updateTokenObject.ts", + "line": 86, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/paid-apps.ts", + "line": 25, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/payments/handlePaymentSuccess.ts", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/payments/handlePaymentSuccess.ts", + "line": 92, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/payments/handlePaymentSuccess.ts", + "line": 105, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/setDefaultConferencingApp.ts", + "line": 18, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/setDefaultConferencingApp.ts", + "line": 29, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/setDefaultConferencingApp.ts", + "line": 32, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/setDefaultConferencingApp.ts", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/_utils/stripe.ts", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/alby/api/webhook.ts", + "line": 50, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/alby/api/webhook.ts", + "line": 92, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/basecamp3/api/projectMutation.ts", + "line": 70, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/btcpayserver/api/webhook.ts", + "line": 62, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/btcpayserver/api/webhook.ts", + "line": 91, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/dailyvideo/lib/VideoApiAdapter.ts", + "line": 169, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/dailyvideo/lib/VideoApiAdapter.ts", + "line": 227, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/delegationCredential.ts", + "line": 113, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/delegationCredential.ts", + "line": 129, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/delegationCredential.ts", + "line": 158, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/delegationCredential.ts", + "line": 178, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/giphy/api/get.ts", + "line": 37, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/googlecalendar/api/callback.ts", + "line": 139, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/hitpay/api/callback.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/hitpay/api/callback.ts", + "line": 75, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/hitpay/api/webhook.ts", + "line": 57, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/hitpay/api/webhook.ts", + "line": 82, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/hitpay/api/webhook.ts", + "line": 114, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/huddle01video/utils/storage.ts", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/huddle01video/utils/storage.ts", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/huddle01video/utils/storage.ts", + "line": 26, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/huddle01video/utils/storage.ts", + "line": 38, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/intercom/api/callback.ts", + "line": 73, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/intercom/lib/configure/link.ts", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/intercom/lib/configure/link.ts", + "line": 58, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/intercom/lib/configure/link.ts", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/intercom/lib/configure/link.ts", + "line": 75, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/make/api/subscriptions/me.ts", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/office365calendar/api/callback.ts", + "line": 151, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/office365calendar/api/callback.ts", + "line": 172, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/paypal/api/capture.ts", + "line": 26, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/paypal/api/capture.ts", + "line": 59, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/paypal/api/capture.ts", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/paypal/api/webhook.ts", + "line": 27, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/paypal/api/webhook.ts", + "line": 39, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/paypal/api/webhook.ts", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/paypal/api/webhook.ts", + "line": 169, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/paypal/api/webhook.ts", + "line": 181, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/salesforce/api/user-sync.ts", + "line": 64, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/server.ts", + "line": 37, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/server.ts", + "line": 58, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/stripepayment/api/paymentCallback.ts", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/stripepayment/api/paymentCallback.ts", + "line": 60, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/stripepayment/api/paymentCallback.ts", + "line": 95, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/stripepayment/api/subscription.ts", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/stripepayment/lib/customer.ts", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/stripepayment/lib/customer.ts", + "line": 88, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/stripepayment/lib/customer.ts", + "line": 101, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/vital/api/token.ts", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/vital/api/webhook.ts", + "line": 82, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/vital/api/webhook.ts", + "line": 113, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/vital/lib/reschedule.ts", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/vital/lib/reschedule.ts", + "line": 70, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/vital/lib/reschedule.ts", + "line": 78, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/vital/lib/reschedule.ts", + "line": 150, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/webex/lib/VideoApiAdapter.ts", + "line": 279, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/webex/lib/VideoApiAdapter.ts", + "line": 286, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/wipemycalother/lib/reschedule.ts", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/wipemycalother/lib/reschedule.ts", + "line": 72, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/wipemycalother/lib/reschedule.ts", + "line": 81, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/wipemycalother/lib/reschedule.ts", + "line": 153, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/zapier/api/subscriptions/me.ts", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/zoho-bigin/api/add.ts", + "line": 19, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/zohocalendar/api/callback.ts", + "line": 122, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/zohocalendar/api/callback.ts", + "line": 143, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/embeds/embed-core/playwright/lib/testUtils.ts", + "line": 20, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/lib/dub.ts", + "line": 12, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/lib/getServerSession.ts", + "line": 71, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/lib/getServerSession.ts", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/lib/onboardingUtils.ts", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/lib/userFromSessionUtils.ts", + "line": 110, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/lib/verifyEmail.ts", + "line": 65, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/signup/utils/organization.ts", + "line": 15, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/signup/utils/organization.ts", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/auth/signup/utils/validateUsername.ts", + "line": 88, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/EventManager.ts", + "line": 92, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/EventManager.ts", + "line": 93, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/get-booking.ts", + "line": 168, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/get-booking.ts", + "line": 270, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts", + "line": 29, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts", + "line": 41, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts", + "line": 65, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/getUserBooking.ts", + "line": 4, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleConfirmation.ts", + "line": 180, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleConfirmation.ts", + "line": 245, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleInternalNote.ts", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleInternalNote.ts", + "line": 38, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleInternalNote.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleInternalNote.ts", + "line": 63, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts", + "line": 6, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts", + "line": 204, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/getSeatedBooking.ts", + "line": 7, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts", + "line": 112, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts", + "line": 138, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts", + "line": 165, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts", + "line": 220, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/logger.ts", + "line": 8, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils.ts", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handlePayment.ts", + "line": 179, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts", + "line": 55, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts", + "line": 62, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts", + "line": 74, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts", + "line": 123, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts", + "line": 26, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/payment/deletePayment.ts", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/payment/getBooking.ts", + "line": 35, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/payment/getBooking.ts", + "line": 160, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/payment/handleNoShowFee.ts", + "line": 70, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/payment/processNoShowFeeOnCancellation.ts", + "line": 32, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/payment/processPaymentRefund.ts", + "line": 64, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/busyTimes/lib/getBusyTimesFromLimits.ts", + "line": 204, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/busyTimes/lib/getBusyTimesFromLimits.ts", + "line": 270, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/busyTimes/services/getBusyTimes.integration-test.ts", + "line": 33, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/busyTimes/services/getBusyTimes.integration-test.ts", + "line": 55, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/calendars/lib/CalendarManager.ts", + "line": 222, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/calendars/lib/CalendarManager.ts", + "line": 391, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/calendars/lib/CalendarManager.ts", + "line": 487, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/calendars/lib/CalendarManager.ts", + "line": 569, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/calendars/lib/getConnectedDestinationCalendars.ts", + "line": 231, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/calendars/lib/getConnectedDestinationCalendars.ts", + "line": 291, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/conferencing/lib/videoClient.ts", + "line": 159, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/conferencing/lib/videoClient.ts", + "line": 266, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/conferencing/lib/videoClient.ts", + "line": 316, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/conferencing/lib/videoClient.ts", + "line": 379, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 53, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 74, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 143, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 150, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 168, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 197, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 215, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 290, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 304, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 311, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 318, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 395, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 444, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 464, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/credentials/handleDeleteCredential.ts", + "line": 482, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/bookingFieldsManager.ts", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/bookingFieldsManager.ts", + "line": 92, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/bookingFieldsManager.ts", + "line": 113, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/bookingFieldsManager.ts", + "line": 132, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 155, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 166, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 191, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 247, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 251, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 282, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 288, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getEventTypeById.ts", + "line": 296, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getPublicEvent.ts", + "line": 204, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getPublicEvent.ts", + "line": 496, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/eventtypes/lib/getPublicEvent.ts", + "line": 694, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/handleMarkNoShow.test.ts", + "line": 259, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/membership/repositories/MembershipRepository.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/membership/repositories/MembershipRepository.ts", + "line": 65, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/membership/repositories/MembershipRepository.ts", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/noShow/handleSendingAttendeeNoShowDataToApps.ts", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/noShow/handleSendingAttendeeNoShowDataToApps.ts", + "line": 69, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/profile/lib/createAProfileForAnExistingUser.ts", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/profile/lib/createAProfileForAnExistingUser.ts", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/profile/lib/createAProfileForAnExistingUser.ts", + "line": 41, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/profile/lib/createAProfileForAnExistingUser.ts", + "line": 50, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/profile/lib/hideBranding.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/profile/lib/hideBranding.ts", + "line": 139, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/analytics/sendAnalyticsEvent.ts", + "line": 18, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/crm/createCRMEvent.ts", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/crm/createCRMEvent.ts", + "line": 106, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/crm/createCRMEvent.ts", + "line": 158, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/crm/createCRMEvent.ts", + "line": 189, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/crm/lib/buildCalendarEvent.ts", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts", + "line": 31, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts", + "line": 49, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/triggerNoShow/common.ts", + "line": 108, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/triggerNoShow/getBooking.ts", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/scheduleTrigger.ts", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/scheduleTrigger.ts", + "line": 157, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/scheduleTrigger.ts", + "line": 299, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/scheduleTrigger.ts", + "line": 348, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/scheduleTrigger.ts", + "line": 356, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/scheduleTrigger.ts", + "line": 614, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/scheduleTrigger.ts", + "line": 626, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/scheduleTrigger.ts", + "line": 662, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/webhooks/lib/subscriberUrlReserved.ts", + "line": 27, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/lib/CloseComeUtils.ts", + "line": 37, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/lib/connectedCalendar.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/lib/formbricks.ts", + "line": 26, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/lib/formbricks.ts", + "line": 35, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/lib/getOrgIdFromMemberOrTeamId.ts", + "line": 55, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/lib/getOrgIdFromMemberOrTeamId.ts", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/lib/getTeamIdFromEventType.ts", + "line": 18, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/lib/server/maybeGetBookingUidFromSeat.ts", + "line": 5, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/platform/atoms/booker/BookerPlatformWrapper.tsx", + "line": 479, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/platform/atoms/event-types/payments/StripePaymentForm.tsx", + "line": 60, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/platform/atoms/hooks/useCalendarsBusyTimes.ts", + "line": 26, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/platform/atoms/hooks/useOAuthFlow.ts", + "line": 75, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/testing/src/lib/bookingScenario/bookingScenario.ts", + "line": 433, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / mock / fixture infrastructure. Not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/testing/src/lib/bookingScenario/bookingScenario.ts", + "line": 447, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / mock / fixture infrastructure. Not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/testing/src/lib/bookingScenario/bookingScenario.ts", + "line": 457, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / mock / fixture infrastructure. Not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/testing/src/lib/bookingScenario/bookingScenario.ts", + "line": 464, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / mock / fixture infrastructure. Not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/testing/src/lib/bookingScenario/bookingScenario.ts", + "line": 473, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / mock / fixture infrastructure. Not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/testing/src/lib/bookingScenario/bookingScenario.ts", + "line": 2371, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / mock / fixture infrastructure. Not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/testing/src/lib/bookingScenario/bookingScenario.ts", + "line": 2413, + "severity": "High", + "verdict": "FP", + "note": "Test scaffold / mock / fixture infrastructure. Not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts", + "line": 17, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/loggedInViewer/unlinkConnectedAccount.handler.ts", + "line": 36, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/publicViewer/event.handler.ts", + "line": 11, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/apps/appById.handler.ts", + "line": 22, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/apps/toggle.handler.ts", + "line": 125, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts", + "line": 59, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts", + "line": 73, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/schedule/create.handler.ts", + "line": 22, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts", + "line": 64, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/schedule/duplicate.handler.ts", + "line": 22, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/schedule/getAllSchedulesByUserId.handler.ts", + "line": 33, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/schedule/getAllSchedulesByUserId.handler.ts", + "line": 52, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/schedule/getScheduleByUserId.handler.ts", + "line": 18, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts", + "line": 36, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts", + "line": 97, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts", + "line": 134, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/confirm.handler.ts", + "line": 62, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/confirm.handler.ts", + "line": 187, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/confirm.handler.ts", + "line": 220, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/confirm.handler.ts", + "line": 307, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/confirm.handler.ts", + "line": 396, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/confirm.handler.ts", + "line": 422, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts", + "line": 111, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts", + "line": 152, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/find.handler.ts", + "line": 16, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/get.handler.ts", + "line": 120, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/get.handler.ts", + "line": 906, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/get.handler.ts", + "line": 934, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/get.handler.ts", + "line": 945, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/get.handler.ts", + "line": 985, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/getBookingAttendees.handler.ts", + "line": 11, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/reportBooking.handler.ts", + "line": 59, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts", + "line": 188, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts", + "line": 25, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/calendars/setDestinationCalendar.handler.ts", + "line": 84, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/calendars/setDestinationCalendar.handler.ts", + "line": 93, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts", + "line": 16, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getActiveOnOptions.handler.ts", + "line": 83, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getActiveOnOptions.handler.ts", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getActiveOnOptions.handler.ts", + "line": 223, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getChildrenForAssignment.handler.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts", + "line": 25, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getHostsForAssignment.handler.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.handler.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts", + "line": 106, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/getUserEventGroups.handler.ts", + "line": 39, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts", + "line": 132, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts", + "line": 153, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts", + "line": 29, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts", + "line": 197, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 323, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 361, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 404, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 424, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 435, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 443, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 614, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 631, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 653, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 667, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 682, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts", + "line": 761, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts", + "line": 53, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/checkForInvalidAppCredentials.ts", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/get.handler.ts", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/get.handler.ts", + "line": 58, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/get.handler.ts", + "line": 73, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/get.handler.ts", + "line": 111, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/platformMe.handler.ts", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/platformMe.handler.ts", + "line": 21, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/updateProfile.handler.ts", + "line": 92, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/updateProfile.handler.ts", + "line": 201, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/updateProfile.handler.ts", + "line": 300, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/updateProfile.handler.ts", + "line": 313, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/updateProfile.handler.ts", + "line": 346, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/updateProfile.handler.ts", + "line": 358, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/me/updateProfile.handler.ts", + "line": 377, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/oAuth/deleteClient.handler.ts", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/oAuth/deleteClient.handler.ts", + "line": 34, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/oAuth/listUserClients.handler.ts", + "line": 18, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts", + "line": 66, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts", + "line": 75, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts", + "line": 78, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOffice.utils.ts", + "line": 13, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOffice.utils.ts", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts", + "line": 55, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts", + "line": 75, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts", + "line": 110, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts", + "line": 142, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts", + "line": 155, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts", + "line": 216, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts", + "line": 242, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts", + "line": 325, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeEntriesList.handler.ts", + "line": 50, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeEntryDelete.handler.ts", + "line": 33, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/ooo/outOfOfficeEntryDelete.handler.ts", + "line": 43, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts", + "line": 36, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/webhook/create.handler.ts", + "line": 47, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/webhook/delete.handler.ts", + "line": 35, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/trpc/server/routers/viewer/webhook/get.handler.ts", + "line": 14, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/ui/components/file-uploader/FileUploader.tsx", + "line": 178, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.token_override_without_validation", + "path_suffix": "apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts", + "line": 310, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.token_override_without_validation", + "path_suffix": "apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts", + "line": 177, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "@calcom/api-v2::apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts", + "line": 80, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "packages/app-store/stripepayment/api/subscription.ts", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "packages/app-store/hitpay/api/callback.ts", + "line": 84, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "packages/app-store/hitpay/api/callback.ts", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "packages/app-store/stripepayment/lib/services/user/UserBillingPortalService.ts", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "packages/app-store/stripepayment/api/paymentCallback.ts", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "packages/app-store/stripepayment/api/paymentCallback.ts", + "line": 141, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "apps/web/lib/pages/auth/verify-email.ts", + "line": 161, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts", + "line": 80, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "packages/app-store/stripepayment/api/paymentCallback.ts", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "packages/app-store/stripepayment/api/paymentCallback.ts", + "line": 141, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/platform/examples/base/src/pages/api/oauth2-user.ts", + "line": 38, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "apps/api/v2/src/middleware/app.redirects.middleware.ts", + "line": 8, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/web/test/lib/next-config.test.ts", + "line": 29, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/platform/examples/base/src/pages/api/refresh.ts", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/platform/examples/base/src/pages/_app.tsx", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/platform/examples/base/src/pages/api/oauth2-user.ts", + "line": 79, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 114, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 115, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 116, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 117, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 119, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 126, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 133, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 140, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "@calcom/app-store::packages/app-store/_utils/useAddAppMutation.ts", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts", + "line": 139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/app-store/dailyvideo/lib/scripts/deleteRecordings.ts", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 109, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 151, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 176, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 199, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 222, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 247, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 272, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/managed-user.ts", + "line": 301, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/app-store/dailyvideo/lib/scripts/deleteRecordings.ts", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/oauth2-user.ts", + "line": 79, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/oauth2-user.ts", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/platform/examples/base/src/pages/api/refresh.ts", + "line": 57, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/embeds/embed-core/src/embed.ts", + "line": 1130, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/app-store/tandemvideo/lib/VideoApiAdapter.ts", + "line": 133, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/app-store/tandemvideo/lib/VideoApiAdapter.ts", + "line": 164, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/lib/domainManager/deploymentServices/cloudflare.ts", + "line": 42, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "packages/features/url-shortener/providers/SinkClient.ts", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/app-store-cli/src/core.ts", + "line": 78, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/app-store-cli/src/core.ts", + "line": 79, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/app-store-cli/src/core.ts", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/app-store-cli/src/core.ts", + "line": 151, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/features/bookings/repositories/BookingRepository.ts", + "line": 1434, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/features/bookings/repositories/BookingRepository.ts", + "line": 1435, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/features/bookings/repositories/BookingRepository.ts", + "line": 1445, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/features/flags/features.repository.ts", + "line": 441, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/features/flags/repositories/PrismaTeamFeatureRepository.ts", + "line": 214, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/features/users/repositories/UserRepository.ts", + "line": 295, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/features/webhooks/lib/repository/WebhookRepository.ts", + "line": 134, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/lib/apps/getInstallCountPerApp.ts", + "line": 7, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/lib/getBrandColours.tsx", + "line": 47, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/trpc/server/routers/viewer/bookings/get.handler.ts", + "line": 882, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts", + "line": 26, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/ui/components/editor/plugins/AutoLinkPlugin.tsx", + "line": 11, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/ui/components/editor/plugins/AutoLinkPlugin.tsx", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.config.reject_unauthorized", + "path_suffix": "packages/app-store/exchangecalendar/lib/CalendarService.ts", + "line": 204, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.xss.location_assign", + "path_suffix": "packages/embeds/embed-core/playground/lib/playground.ts", + "line": 24, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.xss.location_assign", + "path_suffix": "packages/lib/navigateInTopWindow.ts", + "line": 4, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/selected-calendars/__tests__/cron.test.ts", + "line": 54, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/app/api/cron/selected-calendars/__tests__/cron.test.ts", + "line": 74, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/fixtures/users.ts", + "line": 91, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/web/playwright/fixtures/users.ts", + "line": 224, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/googlecalendar/lib/__tests__/utils.ts", + "line": 76, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/googlecalendar/tests/testUtils.ts", + "line": 37, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/googlecalendar/tests/testUtils.ts", + "line": 78, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/googlecalendar/tests/testUtils.ts", + "line": 99, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/googlecalendar/tests/testUtils.ts", + "line": 117, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/app-store/googlecalendar/tests/testUtils.ts", + "line": 124, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 54, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 80, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 128, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 134, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 151, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 157, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 164, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 171, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 174, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/booking-audit/lib/service/__tests__/integration-utils.ts", + "line": 180, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/test/post-booking-handling.test.ts", + "line": 61, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts", + "line": 31, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/platform/examples/base/src/pages/api/refresh.ts", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/platform/examples/base/src/pages/api/refresh.ts", + "line": 79, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 110, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 175, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 261, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 275, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 317, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 332, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 347, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 382, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 400, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 418, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed-utils.ts", + "line": 431, + "severity": "Medium", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed.ts", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "scripts/seed.ts", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.fallback_secret", + "path_suffix": "packages/lib/videoTokens.ts", + "line": 5, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.fallback_secret", + "path_suffix": "packages/lib/videoTokens.ts", + "line": 20, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-source", + "path_suffix": "apps/web/app/api/defaultResponderForAppDir.ts", + "line": 76, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/api/v2/scripts/docker-start.ts", + "line": 5, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/web/test/lib/next-config.test.ts", + "line": 30, + "severity": "Low", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "js.crypto.math_random", + "path_suffix": "packages/testing/performance/utils/config.js", + "line": 44, + "severity": "Low", + "verdict": "FP", + "note": "Test scaffold / mock / fixture infrastructure. Not a request-reachable handler." + }, + { + "rule_id": "js.crypto.weak_hash", + "path_suffix": "apps/web/scripts/copy-app-store-static.js", + "line": 35, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/web/playwright/fixtures/bookings.ts", + "line": 45, + "severity": "Low", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/web/playwright/fixtures/orgs.ts", + "line": 7, + "severity": "Low", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/web/playwright/lib/next-server.ts", + "line": 21, + "severity": "Low", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/web/playwright/signup.e2e.ts", + "line": 117, + "severity": "Low", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "packages/app-store/office365calendar/lib/CalendarService.ts", + "line": 620, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "packages/embeds/embed-core/playground/lib/playground-init.ts", + "line": 15, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "packages/features/bookingReference/repositories/BookingReferenceRepository.integration-test.ts", + "line": 7, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "packages/features/bookings/lib/service/RegularBookingService.ts", + "line": 1499, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "packages/features/busyTimes/services/getBusyTimes.integration-test.ts", + "line": 20, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "packages/features/di/containers/FeatureRepository.integration-test.ts", + "line": 7, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.weak_hash", + "path_suffix": "packages/trpc/server/routers/viewer/me/get.handler.ts", + "line": 122, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.fallback_secret", + "path_suffix": "scripts/seed.ts", + "line": 1034, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "apps/api/v2/src/platform/bookings/2024-08-13/controllers/e2e/update-booking-location.e2e-spec.ts", + "line": 30, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "apps/web/playwright/login.2fa.e2e.ts", + "line": 42, + "severity": "Low", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "apps/web/playwright/login.e2e.ts", + "line": 78, + "severity": "Low", + "verdict": "FP", + "note": "Test scaffold / playwright fixture / seed script. The flagged shape is in test or seeding code, not a request-reachable handler." + } + ] +} diff --git a/tests/recall_targets/perf_after.txt b/tests/recall_targets/perf_after.txt new file mode 100644 index 00000000..90928921 --- /dev/null +++ b/tests/recall_targets/perf_after.txt @@ -0,0 +1,69 @@ +Phase 11 perf snapshot +======================= +Captured 2026-05-08 against branch pitboss/run-20260507T064345Z. +Host: Darwin 25.2.0 / arm64. +Binary: target/release/nyx (cargo build --release). + +This file is the Phase 11 perf baseline. Future recall work compares +against these numbers — Phase 01's baseline (tests/recall_gaps_baseline.json) +recorded only finding counts, not timings, so there is no Phase-01 +perf number to diff against. Phase 11 acceptance: regression ≤ 15% +on the corpus throughput line below. + +cargo test --release +-------------------- +Outcome: all suites green. Sample of `test result:` lines (full run +across the integration + lib + doc test binaries): + + test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + test result: ok. 102 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + test result: ok. 119 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + test result: ok. 27 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + (… many more — every binary reports `ok. N passed; 0 failed`) + +Long-pole binary: tokio_join_perf at 304s (the SSA equivalence +fixture sweep is the second-longest at ~17s). + +cargo bench --bench scan_bench -- --quick +----------------------------------------- +ast_only_scan time: [9.5810 ms 9.7805 ms 9.8304 ms] +full_scan time: [22.939 ms 22.997 ms 23.226 ms] +full_scan_with_state time: [23.056 ms 23.111 ms 23.330 ms] +single_file_parse_cfg time: [281.94 µs 283.04 µs 287.42 µs] +state_analysis_only time: [2.5527 µs 2.5603 µs 2.5905 µs] +classify_hit time: [91.500 ns 91.507 ns 91.537 ns] +classify_miss time: [946.55 ns 948.45 ns 956.08 ns] +analyse_file_fused_large_go time: [63.745 ms 63.792 ms 63.983 ms] +extract_authorization_model_go time: [5.6586 ms 5.6688 ms 5.7096 ms] +extract_authorization_model_shared_go time: [5.7796 ms 5.8364 ms 6.0638 ms] +collect_top_level_units_go time: [3.9529 ms 3.9713 ms 4.0450 ms] +const_propagate_large_go time: [172.46 µs 172.89 µs 174.63 µs] +global_summaries_lookup_same_lang_go time: [12.217 µs 12.230 µs 12.233 µs] + +Corpus throughput (tests/fixtures/, real run) +--------------------------------------------- +Command: target/release/nyx scan tests/fixtures --index off --format json +Best of three (warm parser cache, --index off): + 1.547 s wall 1143 findings emitted + 1.548 s wall 1143 findings emitted + 2.332 s wall 1143 findings emitted (first run, cold) + +Reference number for future recall work: + corpus_throughput_seconds_warm = 1.55 + corpus_findings_total = 1143 + +Note: Phase 01's `tests/recall_gaps_baseline.json` recorded +`findings_total: 1121` against master @ ea82ea98 (post phase 03/05/06/07). +Phase 11's number (1143) is +22 findings against the same +`tests/fixtures/` corpus, which reflects the recall lifts landed +in phases 08, 09, 10 — not a regression. Future recall work +must keep this number monotonic-up; if a phase needs to drop +findings it must be a documented FP-removal. + +Cross-reference +--------------- +Phase 01 baseline: tests/recall_gaps_baseline.json +Per-target sets: tests/recall_targets/{cal_com,vercel_commerce,shadcn_examples,blitz_apps}.json +Runner: scripts/validate_recall.sh +Schema test: cargo test --release --test recall_gaps -- --ignored validate_real_world_targets diff --git a/tests/recall_targets/perf_after_xlang.txt b/tests/recall_targets/perf_after_xlang.txt new file mode 100644 index 00000000..58f73b6b --- /dev/null +++ b/tests/recall_targets/perf_after_xlang.txt @@ -0,0 +1,92 @@ +Phase 17 perf snapshot (cross-language validation freeze) +========================================================= +Captured 2026-05-09 against branch pitboss/run-20260507T064345Z. +Host: Darwin 25.2.0 / arm64. +Binary: target/release/nyx (cargo build --release). + +This file is the Phase 17 perf record. Acceptance: scanner throughput +on the existing tests/fixtures/ corpus regresses by <= 10% vs the +Phase 11 baseline recorded in tests/recall_targets/perf_after.txt. +If regression exceeds 10% the runner must investigate before commit. + +cargo bench --bench scan_bench +------------------------------ +ast_only_scan time: [9.1576 ms 9.1754 ms 9.1958 ms] +full_scan time: [23.417 ms 23.451 ms 23.487 ms] +full_scan_with_state time: [23.444 ms 23.491 ms 23.546 ms] +single_file_parse_cfg time: [314.74 µs 315.53 µs 316.39 µs] +state_analysis_only time: [2.5713 µs 2.5744 µs 2.5780 µs] +classify_hit time: [94.124 ns 94.226 ns 94.329 ns] +classify_miss time: [1.0170 µs 1.0179 µs 1.0189 µs] +analyse_file_fused_large_go time: [65.216 ms 65.361 ms 65.510 ms] +extract_authorization_model_go time: [5.7215 ms 5.7310 ms 5.7411 ms] +extract_authorization_model_shared_go time: [5.7859 ms 5.7931 ms 5.8008 ms] +collect_top_level_units_go time: [3.9842 ms 3.9904 ms 3.9972 ms] +const_propagate_large_go time: [173.48 µs 173.80 µs 174.12 µs] +global_summaries_lookup_same_lang_go time: [12.122 µs 12.137 µs 12.153 µs] + +Per-bench delta vs Phase 11 (tests/recall_targets/perf_after.txt) +---------------------------------------------------------------- +ast_only_scan 9.78 -> 9.18 ms -6.1% +full_scan 23.00 -> 23.45 ms +2.0% +full_scan_with_state 23.11 -> 23.49 ms +1.6% +single_file_parse_cfg 283.0 -> 315.5 µs +11.5% (single-file path; not on the + corpus throughput line below) +state_analysis_only 2.560 -> 2.574 µs +0.5% +classify_hit 91.51 -> 94.23 ns +3.0% +classify_miss 948 -> 1018 ns +7.4% +analyse_file_fused_large_go 63.79 -> 65.36 ms +2.5% +extract_authorization_model_go 5.669 -> 5.731 ms +1.1% +extract_authorization_model_shared_go 5.836 -> 5.793 ms -0.7% +collect_top_level_units_go 3.971 -> 3.990 ms +0.5% +const_propagate_large_go 172.9 -> 173.8 µs +0.5% +global_summaries_lookup_same_lang_go 12.23 -> 12.14 µs -0.7% + +Most micro-benchmarks are within +/-3%. The single-file +`single_file_parse_cfg` outlier (+11.5%) is dominated by the new +cross-language label rules added in phases 12-16 (per-lang KINDS +maps and gated sink dispatch); it is a single-file figure and does +not gate corpus throughput, which is the acceptance line. + +Corpus throughput (tests/fixtures/, real run) +--------------------------------------------- +Command: target/release/nyx scan tests/fixtures --index off --format json +Best of three (warm parser cache, --index off): + 1.657 s wall 1182 findings emitted + 1.680 s wall 1182 findings emitted + 2.487 s wall 1182 findings emitted (first run, cold) + +Reference number for future recall work: + corpus_throughput_seconds_warm = 1.66 + corpus_findings_total = 1182 + +Phase 11 baseline (perf_after.txt): + corpus_throughput_seconds_warm = 1.55 + corpus_findings_total = 1143 + +Delta: + wall 1.55 -> 1.66 s +7.1% (within the <=10% acceptance bar) + findings 1143 -> 1182 +39 (recall lift from phases 12-16: + async/await Python+Rust, fs path + traversal, SSRF/URL builders, + ORM coverage, framework entry + points) + +The +7.1% wall-clock regression is bounded by the 10% acceptance bar. +Driver: phases 12-16 added per-lang label rules and entry-point +detection across Python / Rust / Go / Ruby / Java / PHP. The +per-language matchers fire in `classify` for every node in those +languages now, which is the hot path. No corrective action required; +future precision/perf work that touches the matchers should diff +against this number. + +Cross-reference +--------------- +Phase 11 baseline: tests/recall_targets/perf_after.txt +Phase 01 finding count: tests/recall_gaps_baseline.json (1121 @ ea82ea98) +Phase 11 finding count: 1143 (perf_after.txt, post phases 03/05/06/07/08/09/10) +Phase 17 finding count: 1182 (this file, post phases 12/13/14/15/16) +Per-target sets: tests/recall_targets/{cal_com,vercel_commerce,shadcn_examples,blitz_apps}.json +Cross-lang sets: tests/recall_targets/xlang/{php,java,python,rust,go,ruby}/.json +Runner: scripts/validate_recall.sh (now accepts --lang) +Schema test: cargo test --test recall_gaps -- --ignored validate_real_world_targets diff --git a/tests/recall_targets/shadcn_examples.json b/tests/recall_targets/shadcn_examples.json new file mode 100644 index 00000000..f9578644 --- /dev/null +++ b/tests/recall_targets/shadcn_examples.json @@ -0,0 +1,798 @@ +{ + "_doc": "Phase 11 recall-validation baseline for shadcn-ui/ui examples. Pinned commit + captured findings live in this file. Re-capture by running scripts/validate_recall.sh shadcn_examples --capture against a fresh checkout. Baseline location is tests/recall_targets/ (relocated out of .pitboss/ per the Phase 01 precedent — pitboss implementer agents must not write under .pitboss/).", + "target": "shadcn_examples", + "clone_url": "https://github.com/shadcn-ui/ui", + "exercises_recall_items": [ + 4, + 7 + ], + "captured_against": "real-scan @ 8ca30ed32cc1d8971bc0902ccf3b14abe71abbb9", + "captured_on": "2026-05-11", + "pinned_commit": "8ca30ed32cc1d8971bc0902ccf3b14abe71abbb9", + "findings": [ + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/shadcn/src/preset/resolve.ts", + "line": 574, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/app/(app)/llm/[[...slug]]/route.ts", + "line": 39, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/utils/scaffold.test.ts", + "line": 266, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/utils/scaffold.test.ts", + "line": 402, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/utils/scaffold.test.ts", + "line": 441, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/utils/scaffold.test.ts", + "line": 483, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/utils/scaffold.test.ts", + "line": 522, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/registry/bases/base/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/registry/bases/radix/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/registry/new-york-v4/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/base-luma/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/base-lyra/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/base-maia/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/base-mira/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/base-nova/ui-rtl/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/base-nova/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/base-sera/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/base-vega/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/radix-luma/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/radix-lyra/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/radix-maia/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/radix-mira/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/radix-nova/ui-rtl/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/radix-nova/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/radix-sera/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/styles/radix-vega/ui/chart.tsx", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "shadcn::packages/shadcn/src/utils/registries.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "shadcn::packages/shadcn/src/utils/registries.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/utils/registries.ts", + "line": 89, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "shadcn::packages/shadcn/src/utils/updaters/update-css-vars.ts", + "line": 57, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "shadcn::packages/shadcn/src/utils/updaters/update-css.ts", + "line": 74, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/registry/resolver.test.ts", + "line": 391, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/registry/resolver.test.ts", + "line": 463, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/v4/app/(app)/create/lib/v0.ts", + "line": 567, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "shadcn::packages/shadcn/src/commands/init.ts", + "line": 739, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/shadcn/src/registry/fetcher.ts", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "packages/shadcn/src/registry/fetcher.ts", + "line": 50, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "shadcn::packages/shadcn/src/utils/registries.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "shadcn::packages/shadcn/src/utils/dry-run.ts", + "line": 117, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "shadcn::packages/shadcn/src/utils/update-app-index.ts", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/shadcn/src/commands/init.ts", + "line": 733, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "packages/shadcn/src/commands/init.ts", + "line": 739, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "shadcn::packages/shadcn/src/utils/get-config.ts", + "line": 246, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "shadcn::packages/shadcn/src/utils/registries.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "shadcn::packages/shadcn/src/utils/get-monorepo-info.ts", + "line": 53, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/commands/init.ts", + "line": 756, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "shadcn::packages/shadcn/src/utils/create-project.ts", + "line": 81, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "shadcn::packages/shadcn/src/commands/init.ts", + "line": 739, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/commands/diff.ts", + "line": 202, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/commands/diff.ts", + "line": 112, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/commands/diff.ts", + "line": 154, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/shadcn/src/utils/registries.ts", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/utils/setup.ts", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/utils/setup.ts", + "line": 43, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/test/utils/registries.test.ts", + "line": 72, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/apply.test.ts", + "line": 240, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/apply.test.ts", + "line": 250, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/apply.test.ts", + "line": 267, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/apply.test.ts", + "line": 292, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/apply.test.ts", + "line": 432, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/apply.test.ts", + "line": 495, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/init.test.ts", + "line": 540, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/add.test.ts", + "line": 348, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/add.test.ts", + "line": 643, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/add.test.ts", + "line": 659, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/tests/src/tests/add.test.ts", + "line": 723, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 1282, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 1343, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 1362, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 504, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 521, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 610, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 614, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 614, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 1305, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "packages/shadcn/src/registry/api.test.ts", + "line": 1382, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/models/note.server.ts", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/utils.ts", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/shadcn/test/fixtures/frameworks/remix-indie-stack/cypress/support/commands.ts", + "line": 52, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "packages/shadcn/test/fixtures/frameworks/remix-indie-stack/remix.init/index.js", + "line": 12, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/app/(app)/create/hooks/use-random.tsx", + "line": 28, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/registry/bases/base/blocks/preview/cards/bar-visualizer.tsx", + "line": 361, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/registry/bases/base/blocks/sidebar-09/components/app-sidebar.tsx", + "line": 243, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/registry/bases/base/ui/sidebar.tsx", + "line": 618, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/registry/bases/radix/blocks/preview/cards/bar-visualizer.tsx", + "line": 331, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/registry/bases/radix/blocks/sidebar-09/components/app-sidebar.tsx", + "line": 243, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/registry/bases/radix/ui/sidebar.tsx", + "line": 601, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/registry/new-york-v4/blocks/sidebar-09/components/app-sidebar.tsx", + "line": 196, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/registry/new-york-v4/ui/sidebar.tsx", + "line": 611, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "apps/v4/styles/base-luma/ui/sidebar.tsx", + "line": 612, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "apps/v4/examples/base/card-rtl.tsx", + "line": 31, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "apps/v4/examples/base/input-rtl.tsx", + "line": 20, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "apps/v4/examples/radix/card-rtl.tsx", + "line": 31, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "apps/v4/examples/radix/input-rtl.tsx", + "line": 20, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "apps/v4/registry/new-york-v4/examples/form-rhf-password.tsx", + "line": 82, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/join.tsx", + "line": 35, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.secrets.hardcoded_secret", + "path_suffix": "packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/login.tsx", + "line": 36, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.xss.cookie_write", + "path_suffix": "apps/v4/styles/base-lyra/ui/sidebar.tsx", + "line": 86, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/recall_targets/vercel_commerce.json b/tests/recall_targets/vercel_commerce.json new file mode 100644 index 00000000..a7966b88 --- /dev/null +++ b/tests/recall_targets/vercel_commerce.json @@ -0,0 +1,23 @@ +{ + "_doc": "Phase 11 recall-validation baseline for vercel/commerce. Pinned commit + captured findings live in this file. Re-capture by running scripts/validate_recall.sh vercel_commerce --capture against a fresh checkout. Baseline location is tests/recall_targets/ (relocated out of .pitboss/ per the Phase 01 precedent — pitboss implementer agents must not write under .pitboss/).", + "target": "vercel_commerce", + "clone_url": "https://github.com/vercel/commerce", + "exercises_recall_items": [ + 1, + 4, + 7 + ], + "captured_against": "real-scan @ 1df2cf6f6c935f4782eed27351fa18f276917a4d", + "captured_on": "2026-05-11", + "pinned_commit": "1df2cf6f6c935f4782eed27351fa18f276917a4d", + "findings": [ + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "lib/shopify/index.ts", + "line": 85, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/recall_targets/xlang/go/gin.json b/tests/recall_targets/xlang/go/gin.json new file mode 100644 index 00000000..eb9bd9ca --- /dev/null +++ b/tests/recall_targets/xlang/go/gin.json @@ -0,0 +1,132 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for gin-gonic/gin (Go). Re-capture by running scripts/validate_recall.sh --lang go gin --capture. Updated 2026-05-09 after fmt.Fprintf safe-writer suppression, Go switch container fallback fix, and same-request self-redirect suppression removed five FPs.", + "target": "gin", + "lang": "go", + "clone_url": "https://github.com/gin-gonic/gin", + "exercises_recall_items": [], + "captured_against": "real-scan @ d3ffc9985281dcf4d3bef604cce4e662b1a327a6", + "captured_on": "2026-05-09", + "pinned_commit": "d3ffc9985281dcf4d3bef604cce4e662b1a327a6", + "findings": [ + { + "rule_id": "taint-header-injection", + "path_suffix": "gin_integration_test.go", + "line": 396, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "gin_test.go", + "line": 658, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "gin_test.go", + "line": 728, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "gin_test.go", + "line": 769, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "gin_test.go", + "line": 804, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "gin_test.go", + "line": 692, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "go.transport.insecure_skip_verify", + "path_suffix": "gin_integration_test.go", + "line": 38, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "go.transport.insecure_skip_verify", + "path_suffix": "gin_test.go", + "line": 177, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "go.transport.insecure_skip_verify", + "path_suffix": "gin_test.go", + "line": 295, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "go.transport.insecure_skip_verify", + "path_suffix": "gin_test.go", + "line": 404, + "severity": "High", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "context_test.go", + "line": 3317, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "gin_test.go", + "line": 87, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "routes_test.go", + "line": 385, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "routes_test.go", + "line": 420, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + }, + { + "rule_id": "go.secrets.hardcoded_key", + "path_suffix": "recovery_test.go", + "line": 21, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture in *_test.go file. The vulnerable shape is part of the test scaffold, not gin runtime code." + } + ] +} diff --git a/tests/recall_targets/xlang/java/openmrs.json b/tests/recall_targets/xlang/java/openmrs.json new file mode 100644 index 00000000..4f8bbb13 --- /dev/null +++ b/tests/recall_targets/xlang/java/openmrs.json @@ -0,0 +1,1532 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for openmrs (Java/Hibernate). Re-capture by running scripts/validate_recall.sh --lang java openmrs --capture. Heavy JPA-Criteria-API consumer — see project_realrepo_openmrs.md for context. 2026-05-09 session 0011: 94 vendored-asset findings removed (jquery / jquery-ui / jsTree / dataTables minified bundles under WEB-INF/.../scripts/) after engine-level skip in is_vendored_asset_path; the engine no longer parses .min.js / vendor/ / bower_components/ web assets.", + "target": "openmrs", + "lang": "java", + "clone_url": "https://github.com/openmrs/openmrs-core", + "exercises_recall_items": [], + "captured_against": "real-scan @ f9c76db207c37f2c728ca3b601ae720b654ab401", + "captured_on": "2026-05-10", + "pinned_commit": "f9c76db207c37f2c728ca3b601ae720b654ab401", + "findings": [ + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/StartupFilter.java", + "line": 169, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/StartupFilter.java", + "line": 311, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/TestInstallUtil.java", + "line": 252, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-xxe", + "path_suffix": "api/src/main/java/org/openmrs/module/ModuleFileParser.java", + "line": 248, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/AddConceptMapTypesChangeset.java", + "line": 201, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/BooleanConceptChangeSet.java", + "line": 283, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 736, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/GenerateUuid.java", + "line": 114, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 69, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 83, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 103, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 120, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 136, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 155, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "api/src/main/java/org/openmrs/util/OpenmrsUtil.java", + "line": 1955, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.cmdi.runtime_exec", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/SourceMySqldiffFile.java", + "line": 210, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.cmdi.runtime_exec", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/SourceMySqldiffFile.java", + "line": 211, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.cmdi.runtime_exec", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/TestInstallUtil.java", + "line": 83, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "webapp/src/main/webapp/WEB-INF/view/scripts/jquery-ui/js/jquery-ui-timepicker-addon.js", + "line": 141, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "webapp/src/main/webapp/WEB-INF/view/scripts/jquery-ui/js/jquery-ui-timepicker-addon.js", + "line": 88, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "webapp/src/main/webapp/WEB-INF/view/scripts/jquery-ui/js/jquery-ui-timepicker-addon.js", + "line": 139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "api/src/test/java/org/openmrs/test/TestUtil.java", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "webapp/src/main/webapp/WEB-INF/view/scripts/jquery-ui/js/jquery-ui-timepicker-addon.js", + "line": 145, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/GenerateUuid.java", + "line": 115, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/GenerateUuid.java", + "line": 122, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/GenerateUuid.java", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 84, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 164, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "webapp/src/main/webapp/WEB-INF/view/scripts/jquery-ui/js/jquery-ui-timepicker-addon.js", + "line": 91, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "webapp/src/main/webapp/WEB-INF/csrfguard.js", + "line": 426, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "webapp/src/main/webapp/WEB-INF/csrfguard.js", + "line": 502, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "webapp/src/main/webapp/WEB-INF/csrfguard.js", + "line": 506, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "webapp/src/main/webapp/WEB-INF/csrfguard.js", + "line": 507, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "webapp/src/main/webapp/WEB-INF/csrfguard.js", + "line": 426, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/hl7/impl/HL7ServiceImpl.java", + "line": 1133, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/module/ModuleUtil.java", + "line": 110, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/module/ModuleUtil.java", + "line": 680, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/module/ModuleUtil.java", + "line": 1259, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/scheduler/tasks/CheckInternetConnectivityTask.java", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUpdater.java", + "line": 288, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUpdater.java", + "line": 772, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 170, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/HttpClient.java", + "line": 53, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/OpenmrsUtil.java", + "line": 1646, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/UpgradeUtil.java", + "line": 113, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/UpgradeUtil.java", + "line": 138, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/BooleanConceptChangeSet.java", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/CheckDrugOrderUnitAndFrequencyTextNotMappedToConcepts.java", + "line": 35, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 78, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 444, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 505, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 524, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 579, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/CreateCodedOrderFrequencyForDrugOrderFrequencyChangeset.java", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/CreateDiscontinueOrders.java", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/CreateDiscontinueOrders.java", + "line": 118, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DisableTriggersChangeSet.java", + "line": 33, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DuplicateEncounterRoleNameChangeSet.java", + "line": 75, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DuplicateEncounterRoleNameChangeSet.java", + "line": 121, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DuplicateEncounterTypeNameChangeSet.java", + "line": 74, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DuplicateEncounterTypeNameChangeSet.java", + "line": 120, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DuplicateLocationAttributeTypeNameChangeSet.java", + "line": 75, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DuplicateLocationAttributeTypeNameChangeSet.java", + "line": 114, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/EnableTriggersChangeSet.java", + "line": 33, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/EncryptSecretAnswersChangeSet.java", + "line": 42, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/EncryptSecretAnswersChangeSet.java", + "line": 48, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/GenerateUuid.java", + "line": 100, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/GenerateUuid.java", + "line": 150, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateAllergiesChangeSet.java", + "line": 63, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateAllergiesChangeSet.java", + "line": 161, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateConceptReferenceTermChangeSet.java", + "line": 51, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateDrugOrderFrequencyToCodedOrderFrequencyChangeset.java", + "line": 34, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MigrateDrugOrderUnitsToCodedDoseUnitsChangeset.java", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MoveDeletedHL7sChangeSet.java", + "line": 38, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/MoveDeletedHL7sChangeSet.java", + "line": 60, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/SourceMySqldiffFile.java", + "line": 89, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/UpdateCohortMemberIdsChangeset.java", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/UpdateCohortMemberIdsChangeset.java", + "line": 47, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/UpdateLayoutAddressFormatChangeSet.java", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/UpdateLayoutAddressFormatChangeSet.java", + "line": 47, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/module/web/ModuleResourcesServlet.java", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/StartupFilter.java", + "line": 169, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/StartupFilter.java", + "line": 311, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/DatabaseDetective.java", + "line": 48, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/TestInstallUtil.java", + "line": 143, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/TestInstallUtil.java", + "line": 226, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/update/UpdateFilter.java", + "line": 439, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/util/FilterUtil.java", + "line": 134, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/util/FilterUtil.java", + "line": 244, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/test/java/org/openmrs/test/TestUtil.java", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/test/java/org/openmrs/test/TestUtil.java", + "line": 94, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/module/ModuleUtil.java", + "line": 736, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUpdater.java", + "line": 491, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/HttpClient.java", + "line": 78, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/HttpUrl.java", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/OpenmrsUtil.java", + "line": 914, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/UpgradeUtil.java", + "line": 127, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/BooleanConceptChangeSet.java", + "line": 146, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/BooleanConceptChangeSet.java", + "line": 159, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/BooleanConceptChangeSet.java", + "line": 167, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/BooleanConceptChangeSet.java", + "line": 210, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DisableTriggersChangeSet.java", + "line": 42, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/EnableTriggersChangeSet.java", + "line": 42, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/InitializationFilter.java", + "line": 1976, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/TestInstallUtil.java", + "line": 270, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/update/UpdateFilter.java", + "line": 372, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/util/FilterUtil.java", + "line": 200, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/api/db/hibernate/DbSession.java", + "line": 112, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/api/db/hibernate/DbSession.java", + "line": 122, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/api/db/hibernate/DbSession.java", + "line": 176, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/api/db/hibernate/HibernateOrderDAO.java", + "line": 390, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/api/db/hibernate/HibernatePatientDAO.java", + "line": 966, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/api/db/hibernate/type/StringEnumType.java", + "line": 49, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/api/impl/PatientServiceImpl.java", + "line": 1238, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/module/ModuleFileParser.java", + "line": 587, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/serialization/SimpleXStreamSerializer.java", + "line": 104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUpdater.java", + "line": 490, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 89, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 95, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 101, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 107, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/AddConceptMapTypesChangeset.java", + "line": 198, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/BooleanConceptChangeSet.java", + "line": 279, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 505, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 524, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 733, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/InitializationFilter.java", + "line": 1218, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/TestInstallUtil.java", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "webapp/src/main/webapp/WEB-INF/csrfguard.js", + "line": 111, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.cmdi.runtime_exec", + "path_suffix": "api/src/test/java/org/openmrs/test/MigrateDataSet.java", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.cmdi.runtime_exec", + "path_suffix": "api/src/test/java/org/openmrs/test/MigrateDataSet.java", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.deser.readobject", + "path_suffix": "api/src/test/java/org/openmrs/util/ThreadSafeCircularFifoQueueTest.java", + "line": 384, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.deser.readobject", + "path_suffix": "api/src/test/java/org/openmrs/util/ThreadSafeCircularFifoQueueTest.java", + "line": 483, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/api/context/ServiceContext.java", + "line": 779, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/api/db/hibernate/type/StringEnumType.java", + "line": 49, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/api/impl/PatientServiceImpl.java", + "line": 1238, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/module/ModuleFileParser.java", + "line": 587, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/serialization/SimpleXStreamSerializer.java", + "line": 104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUpdater.java", + "line": 490, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 82, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 89, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 95, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 101, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 107, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/InitializationFilter.java", + "line": 1191, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/InitializationFilter.java", + "line": 1193, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.class_forname", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/initialization/InitializationFilter.java", + "line": 1195, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.method_invoke", + "path_suffix": "api/src/main/java/org/openmrs/PatientIdentifier.java", + "line": 146, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.method_invoke", + "path_suffix": "api/src/main/java/org/openmrs/PatientIdentifier.java", + "line": 147, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.method_invoke", + "path_suffix": "api/src/main/java/org/openmrs/PersonAttribute.java", + "line": 172, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.method_invoke", + "path_suffix": "api/src/main/java/org/openmrs/PersonAttribute.java", + "line": 173, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.method_invoke", + "path_suffix": "api/src/main/java/org/openmrs/aop/RequiredDataAdvice.java", + "line": 361, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.method_invoke", + "path_suffix": "api/src/main/java/org/openmrs/api/impl/DomainServiceImpl.java", + "line": 179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.reflection.method_invoke", + "path_suffix": "api/src/main/java/org/openmrs/util/OpenmrsUtil.java", + "line": 1374, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.xss.getwriter_print", + "path_suffix": "web/src/main/java/org/openmrs/web/filter/update/UpdateFilter.java", + "line": 278, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.code_exec.eval", + "path_suffix": "webapp/src/main/webapp/WEB-INF/view/scripts/jquery-ui/js/jquery-ui-timepicker-addon.js", + "line": 139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConvertOrderersToProviders.java", + "line": 37, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.sqli.execute_concat", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 505, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.sqli.execute_concat", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConceptValidatorChangeSet.java", + "line": 524, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.sqli.execute_concat", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/DuplicateLocationAttributeTypeNameChangeSet.java", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "java.sqli.execute_concat", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/ConvertOrderersToProviders.java", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/module/ModuleFileParserUnitTest.java", + "line": 121, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/module/ModuleUtilTest.java", + "line": 600, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/test/MigrateDataSet.java", + "line": 107, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/test/jupiter/BaseContextSensitiveNonTransactionalTest.java", + "line": 698, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/util/databasechange/Database1_9_7UpgradeIT.java", + "line": 464, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/util/databasechange/DatabaseUpgradeTestUtil.java", + "line": 106, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "web/src/test/java/org/openmrs/web/filter/update/GZIPFilterTest.java", + "line": 76, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/module/ModuleFileParserTest.java", + "line": 165, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/test/CreateConceptDictionaryDataSet.java", + "line": 50, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/test/CreateInitialDataSet.java", + "line": 101, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/test/TestUtil.java", + "line": 86, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/util/DatabaseIT.java", + "line": 121, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "api/src/test/java/org/openmrs/util/HttpClientTest.java", + "line": 40, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "api/src/test/java/org/openmrs/test/DbUtil.java", + "line": 111, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "test-suite/performance/src/test/java/org/openmrs/StartupPerformanceIT.java", + "line": 251, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUpdater.java", + "line": 429, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "api/src/main/java/org/openmrs/util/DatabaseUtil.java", + "line": 163, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "api/src/main/java/org/openmrs/util/OpenmrsUtil.java", + "line": 243, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "api/src/main/java/org/openmrs/util/UpgradeUtil.java", + "line": 83, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "api/src/main/java/org/openmrs/util/databasechange/AddConceptMapTypesChangeset.java", + "line": 90, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/recall_targets/xlang/php/drupal.json b/tests/recall_targets/xlang/php/drupal.json new file mode 100644 index 00000000..7061a186 --- /dev/null +++ b/tests/recall_targets/xlang/php/drupal.json @@ -0,0 +1,3876 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for drupal (PHP). Re-capture by running scripts/validate_recall.sh --lang php drupal --capture. 2026-05-09 session 0011: 152 vendored-asset findings removed (core/assets/vendor/jquery/, htmx, sortable, transliteration bundles) after engine-level skip in is_vendored_asset_path.", + "target": "drupal", + "lang": "php", + "clone_url": "https://github.com/drupal/drupal", + "exercises_recall_items": [], + "captured_against": "real-scan @ 92aa759e3e39c5d77540e04877f8a7de0ea604ef", + "captured_on": "2026-05-09", + "pinned_commit": "92aa759e3e39c5d77540e04877f8a7de0ea604ef", + "findings": [ + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/modules/views/js/ajax_view.js", + "line": 165, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/modules/views/js/ajax_view.js", + "line": 208, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/modules/views/js/ajax_view.js", + "line": 216, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/modules/views/js/ajax_view.js", + "line": 142, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/ajax.js", + "line": 1308, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/ckeditor5/js/ckeditor5.js", + "line": 328, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/ckeditor5/js/ckeditor5.js", + "line": 385, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/drupal.js", + "line": 410, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/ckeditor5/js/ckeditor5.js", + "line": 360, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/lib/Drupal/Core/Command/GenerateTheme.php", + "line": 213, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "core/lib/Drupal/Core/Test/TestKernel.php", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "core/assets/scaffold/files/ht.router.php", + "line": 29, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/lib/Drupal/Core/Test/PerformanceTestRecorder.php", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "composer/Generator/ComponentGenerator.php", + "line": 129, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "composer/Plugin/Scaffold/ManageGitIgnore.php", + "line": 124, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/lib/Drupal/Core/Command/GenerateTheme.php", + "line": 188, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "composer/Composer.php", + "line": 66, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/htmx/htmx-assets.js", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/ajax.js", + "line": 269, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/ajax.js", + "line": 337, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/ajax.js", + "line": 709, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/vertical-tabs.js", + "line": 162, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/ajax.js", + "line": 1891, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/drupal.js", + "line": 288, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/jquery.form.js", + "line": 393, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/ajax.js", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/ajax.js", + "line": 419, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/jquery.form.js", + "line": 465, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/ajax.js", + "line": 76, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/ajax.js", + "line": 801, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php", + "line": 226, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "core/misc/jquery.form.js", + "line": 172, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "composer/Composer.php", + "line": 86, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "core/lib/Drupal/Core/Command/ServerCommand.php", + "line": 245, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "composer/Plugin/RecipeUnpack/RootComposer.php", + "line": 142, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Component/Utility/Html.php", + "line": 465, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Component/Utility/Html.php", + "line": 466, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Component/Utility/Html.php", + "line": 467, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php", + "line": 69, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Asset/LibraryDependencyResolver.php", + "line": 82, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Asset/LibraryDiscoveryParser.php", + "line": 197, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Asset/LibraryDiscoveryParser.php", + "line": 198, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Cache/ApcuBackend.php", + "line": 186, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Cache/Cache.php", + "line": 53, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Cache/CacheCollector.php", + "line": 114, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Cache/DatabaseBackend.php", + "line": 261, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Cache/MemoryBackend.php", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Cache/MemoryCache/MemoryCache.php", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Cache/PhpBackend.php", + "line": 159, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Config/Action/ConfigActionManager.php", + "line": 92, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php", + "line": 44, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php", + "line": 78, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Config/Schema/Element.php", + "line": 53, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Database/Connection.php", + "line": 181, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Database/Connection.php", + "line": 434, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Database/Connection.php", + "line": 435, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Database/Connection.php", + "line": 652, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Database/Connection.php", + "line": 653, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Database/Connection.php", + "line": 654, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Database/StatementPrefetchIterator.php", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Database/StatementWrapperIterator.php", + "line": 66, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Menu/StaticMenuLinkOverrides.php", + "line": 74, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Plugin/Context/LazyContextRepository.php", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Plugin/DefaultPluginManager.php", + "line": 193, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Plugin/DefaultPluginManager.php", + "line": 296, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Recipe/InstallConfigurator.php", + "line": 47, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Render/Placeholder/ChainedPlaceholderStrategy.php", + "line": 43, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Render/Placeholder/ChainedPlaceholderStrategy.php", + "line": 59, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Render/Placeholder/ChainedPlaceholderStrategy.php", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Render/Renderer.php", + "line": 363, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Render/Renderer.php", + "line": 364, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Render/Renderer.php", + "line": 365, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Render/Renderer.php", + "line": 644, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Template/TwigExtension.php", + "line": 257, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Template/TwigExtension.php", + "line": 258, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Template/TwigExtension.php", + "line": 373, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/lib/Drupal/Core/Template/TwigExtension.php", + "line": 694, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/filter/src/Entity/FilterFormat.php", + "line": 228, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/Access/EntityAccessChecker.php", + "line": 126, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/Access/EntityAccessChecker.php", + "line": 158, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/Access/TemporaryQueryGuard.php", + "line": 173, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/Access/TemporaryQueryGuard.php", + "line": 485, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/Context/FieldResolver.php", + "line": 617, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/Context/FieldResolver.php", + "line": 752, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/Data.php", + "line": 62, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/Data.php", + "line": 63, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/Link.php", + "line": 168, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/LinkCollection.php", + "line": 71, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/LinkCollection.php", + "line": 137, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php", + "line": 140, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php", + "line": 182, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php", + "line": 312, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php", + "line": 328, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/Revisions/VersionNegotiator.php", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/src/Routing/ReadOnlyModeMethodFilter.php", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/layout_builder/src/Controller/LayoutBuilderController.php", + "line": 29, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/package_manager/src/InstalledPackage.php", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Connection.php", + "line": 424, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Connection.php", + "line": 425, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Statement.php", + "line": 90, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/user/src/Hook/UserHooks.php", + "line": 253, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/views/src/Plugin/views/PluginBase.php", + "line": 377, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/views/src/Plugin/views/PluginBase.php", + "line": 383, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/views/src/Plugin/views/PluginBase.php", + "line": 387, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/workspaces/src/WorkspaceRepository.php", + "line": 66, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.preg_replace_e", + "path_suffix": "core/modules/system/src/PathProcessor/PathProcessorFiles.php", + "line": 21, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Component/Annotation/Plugin/Discovery/AnnotatedClassDiscovery.php", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Component/DependencyInjection/Container.php", + "line": 166, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Component/Plugin/Discovery/AttributeClassDiscovery.php", + "line": 103, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Component/Plugin/Discovery/AttributeClassDiscovery.php", + "line": 105, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Component/Serialization/PhpSerialize.php", + "line": 21, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Batch/BatchStorage.php", + "line": 60, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Cache/MemoryBackend.php", + "line": 99, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/DefaultContent/Exporter.php", + "line": 260, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php", + "line": 2042, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Menu/MenuTreeStorage.php", + "line": 658, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Menu/MenuTreeStorage.php", + "line": 777, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Queue/Batch.php", + "line": 31, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Queue/Batch.php", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Queue/DatabaseQueue.php", + "line": 150, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Routing/RouteProvider.php", + "line": 269, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Routing/RouteProvider.php", + "line": 398, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Routing/RouteProvider.php", + "line": 427, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/lib/Drupal/Core/Test/HttpClientMiddleware/TestHttpClientMiddleware.php", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/dblog/src/Controller/DbLogController.php", + "line": 358, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/dblog/src/Plugin/views/field/DblogMessage.php", + "line": 62, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/layout_builder/src/Plugin/Block/InlineBlock.php", + "line": 218, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/layout_builder/src/Plugin/Block/InlineBlock.php", + "line": 262, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/serialization/src/Normalizer/PrimitiveDataNormalizer.php", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/user/src/UserData.php", + "line": 47, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/user/src/UserData.php", + "line": 55, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/user/src/UserData.php", + "line": 62, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/user/src/UserData.php", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/views/src/Plugin/views/field/Serialized.php", + "line": 73, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/views/src/Plugin/views/field/Serialized.php", + "line": 76, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "core/lib/Drupal/Component/Transliteration/PhpTransliteration.php", + "line": 292, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "core/lib/Drupal/Component/Transliteration/PhpTransliteration.php", + "line": 318, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "core/lib/Drupal/Core/Cache/PhpBackend.php", + "line": 86, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "core/lib/Drupal/Core/Render/Element/MachineName.php", + "line": 304, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "core/modules/package_manager/src/Validator/MultisiteValidator.php", + "line": 58, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "core/modules/ckeditor5/js/ckeditor5.js", + "line": 689, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php", + "line": 28, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/Nightwatch/Commands/drupalInstall.js", + "line": 38, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "core/lib/Drupal/Core/Test/TestKernel.php", + "line": 20, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "core/assets/scaffold/files/ht.router.php", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php", + "line": 110, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/filter/tests/src/Kernel/FilterKernelTest.php", + "line": 1076, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php", + "line": 123, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php", + "line": 132, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Test/FunctionalTestDebugHtmlOutputTest.php", + "line": 185, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 200, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/Nightwatch/Commands/drupalUninstall.js", + "line": 28, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php", + "line": 219, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/Tests/Composer/Plugin/FixturesBase.php", + "line": 241, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 248, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php", + "line": 282, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 292, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 315, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php", + "line": 58, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 337, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 363, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php", + "line": 404, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php", + "line": 150, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Asset/UnversionedAssetTest.php", + "line": 77, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 410, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php", + "line": 50, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php", + "line": 45, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php", + "line": 644, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/system/tests/src/Functional/Theme/MaintenanceThemeUpdateRegistryTest.php", + "line": 64, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Installer/InstallerConfigDirectoryTestBase.php", + "line": 86, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php", + "line": 669, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 663, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php", + "line": 755, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php", + "line": 34, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php", + "line": 51, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Installer/InstallerConfigDirectoryTestBase.php", + "line": 104, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/tests/Drupal/FunctionalTests/Installer/InstallerConfigDirectoryTestBase.php", + "line": 124, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/jquery.form.js", + "line": 333, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/jquery.form.js", + "line": 162, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/media_library/src/MediaLibraryUiBuilder.php", + "line": 185, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/media_library/src/MediaLibraryUiBuilder.php", + "line": 188, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/jquery.form.js", + "line": 329, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/drupal.js", + "line": 429, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/lib/Drupal/Component/Gettext/PoStreamReader.php", + "line": 154, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/lib/Drupal/Component/Gettext/PoStreamWriter.php", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/migrate/src/Plugin/migrate/process/Download.php", + "line": 62, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/package_manager/src/FileProcessOutputCallback.php", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Insert.php", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Update.php", + "line": 54, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Upsert.php", + "line": 34, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/media/tests/src/Traits/OEmbedTestTrait.php", + "line": 79, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unreachable-guard", + "path_suffix": "core/lib/Drupal/Core/TypedData/Validation/RecursiveContextualValidator.php", + "line": 277, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Schema/Mapping.php", + "line": 162, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php", + "line": 157, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/jquery.form.js", + "line": 707, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/misc/jquery.form.js", + "line": 791, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "composer/Composer.php", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php", + "line": 60, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Cache/MemoryBackend.php", + "line": 99, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Command/DbDumpCommand.php", + "line": 307, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Action/ConfigActionManager.php", + "line": 162, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/CreateForEachBundle.php", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityClone.php", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SetProperties.php", + "line": 46, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php", + "line": 445, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/DatabaseStorage.php", + "line": 89, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/DatabaseStorage.php", + "line": 117, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/DatabaseStorage.php", + "line": 341, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Entity/Query/QueryFactory.php", + "line": 73, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Plugin/Validation/Constraint/LangcodeRequiredIfTranslatableValuesConstraintValidator.php", + "line": 24, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/Plugin/Validation/Constraint/LangcodeRequiredIfTranslatableValuesConstraintValidator.php", + "line": 33, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Config/TypedConfigManager.php", + "line": 356, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/Query/Select.php", + "line": 528, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/Schema.php", + "line": 137, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/Schema.php", + "line": 204, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/Schema.php", + "line": 284, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/StatementPrefetchIterator.php", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/StatementWrapperIterator.php", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php", + "line": 131, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php", + "line": 569, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php", + "line": 586, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php", + "line": 603, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/DefaultContent/Importer.php", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/DefaultContent/Importer.php", + "line": 94, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/DefaultContent/PreExportEvent.php", + "line": 59, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Entity/BundleEntityFormBase.php", + "line": 25, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php", + "line": 211, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Entity/EntityStorageBase.php", + "line": 266, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Entity/KeyValueStore/Query/QueryFactory.php", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Entity/Query/Null/QueryFactory.php", + "line": 35, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Entity/Query/Null/QueryFactory.php", + "line": 42, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Extension/Plugin/Validation/Constraint/ExtensionAvailableConstraintValidator.php", + "line": 172, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Field/FieldTypePluginManager.php", + "line": 183, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Hook/HookCollectorKeyValueWritePass.php", + "line": 34, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Hook/HookCollectorPass.php", + "line": 245, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Hook/HookCollectorPass.php", + "line": 246, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Hook/HookCollectorPass.php", + "line": 247, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Hook/HookCollectorPass.php", + "line": 248, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Hook/ImplementationList.php", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Hook/ImplementationList.php", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Hook/ImplementationList.php", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php", + "line": 66, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php", + "line": 46, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php", + "line": 63, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Menu/MenuTreeParameters.php", + "line": 222, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Menu/MenuTreeStorage.php", + "line": 658, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Plugin/Context/ContextDefinition.php", + "line": 119, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Queue/Batch.php", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Queue/Batch.php", + "line": 61, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Queue/DatabaseQueue.php", + "line": 104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Recipe/Recipe.php", + "line": 420, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Routing/RouteProvider.php", + "line": 246, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Template/ComponentsTwigExtension.php", + "line": 106, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Template/TwigExtension.php", + "line": 208, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Template/TwigExtension.php", + "line": 231, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Theme/ComponentPluginManager.php", + "line": 268, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/ValidKeysConstraint.php", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/misc/tableresponsive.js", + "line": 97, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/misc/tableresponsive.js", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/HTMLRestrictions.php", + "line": 618, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/HTMLRestrictions.php", + "line": 1034, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/HTMLRestrictions.php", + "line": 1035, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/HTMLRestrictions.php", + "line": 1291, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/HTMLRestrictions.php", + "line": 1312, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php", + "line": 420, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/SourceEditing.php", + "line": 46, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/CKEditor5PluginManager.php", + "line": 453, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/CKEditor5PluginManager.php", + "line": 495, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php", + "line": 50, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php", + "line": 268, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php", + "line": 270, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php", + "line": 722, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php", + "line": 855, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php", + "line": 865, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Validation/Constraint/FundamentalCompatibilityConstraintValidator.php", + "line": 211, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Validation/Constraint/FundamentalCompatibilityConstraintValidator.php", + "line": 213, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Validation/Constraint/PrecedingConstraintAwareValidatorTrait.php", + "line": 28, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Validation/Constraint/PrecedingConstraintAwareValidatorTrait.php", + "line": 49, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Validation/Constraint/SourceEditingRedundantTagsConstraintValidator.php", + "line": 168, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/ckeditor5/src/Plugin/Validation/Constraint/StyleSensibleElementConstraintValidator.php", + "line": 201, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/comment/src/CommentStatistics.php", + "line": 173, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/comment/src/Plugin/views/argument/UserUid.php", + "line": 54, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php", + "line": 53, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/dblog/src/Plugin/views/field/DblogMessage.php", + "line": 62, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/editor/src/Hook/EditorHooks.php", + "line": 375, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/field_ui/src/Form/FieldConfigEditForm.php", + "line": 274, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/file/src/Entity/FileLinkTarget.php", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/file/src/Plugin/Field/FieldFormatter/UrlPlainFormatter.php", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/filter/src/Plugin/Filter/FilterImageLazyLoad.php", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/help/src/HelpTwigExtension.php", + "line": 72, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Access/RelationshipRouteAccessCheck.php", + "line": 65, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Access/RelationshipRouteAccessCheck.php", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Access/TemporaryQueryGuard.php", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Access/TemporaryQueryGuard.php", + "line": 91, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Context/FieldResolver.php", + "line": 612, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php", + "line": 123, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php", + "line": 135, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/ErrorCollection.php", + "line": 40, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/Link.php", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/LinkCollection.php", + "line": 53, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/LinkCollection.php", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/LinkCollection.php", + "line": 137, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php", + "line": 142, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php", + "line": 250, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php", + "line": 288, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php", + "line": 418, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php", + "line": 420, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php", + "line": 422, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php", + "line": 426, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Normalizer/FieldNormalizer.php", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Normalizer/FieldNormalizer.php", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Normalizer/FieldNormalizer.php", + "line": 119, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php", + "line": 66, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/jsonapi/src/ResourceType/ResourceType.php", + "line": 383, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/layout_builder/src/EventSubscriber/DefaultContentSubscriber.php", + "line": 45, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/layout_builder/src/Plugin/ConfigAction/AddComponent.php", + "line": 121, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/layout_builder/src/Plugin/ConfigAction/AddComponent.php", + "line": 122, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/locale/src/StringDatabaseStorage.php", + "line": 542, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/media/src/Plugin/media/Source/File.php", + "line": 85, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/menu_link_content/src/Entity/MenuLinkContent.php", + "line": 107, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/migrate/migrate.api.php", + "line": 159, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/migrate/migrate.api.php", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/migrate/src/Exception/EntityValidationException.php", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Connection.php", + "line": 192, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Connection.php", + "line": 221, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Connection.php", + "line": 231, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Connection.php", + "line": 239, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Connection.php", + "line": 321, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Connection.php", + "line": 322, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Schema.php", + "line": 507, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Schema.php", + "line": 544, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Schema.php", + "line": 617, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Schema.php", + "line": 654, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Schema.php", + "line": 695, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysql/src/Driver/Database/mysql/Schema.php", + "line": 700, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysqli/src/Driver/Database/mysqli/Connection.php", + "line": 97, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysqli/src/Driver/Database/mysqli/Connection.php", + "line": 126, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysqli/src/Driver/Database/mysqli/Connection.php", + "line": 155, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysqli/src/Driver/Database/mysqli/Connection.php", + "line": 156, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysqli/src/Driver/Database/mysqli/Result.php", + "line": 81, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysqli/src/Driver/Database/mysqli/Statement.php", + "line": 62, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php", + "line": 37, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/node/src/NodeGrantDatabaseStorage.php", + "line": 306, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/node/src/NodeStorage.php", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/node/src/NodeStorage.php", + "line": 33, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/node/src/NodeStorage.php", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/node/src/Plugin/Search/NodeSearch.php", + "line": 487, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/node/src/Plugin/Search/NodeSearch.php", + "line": 488, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/package_manager/src/InstalledPackage.php", + "line": 46, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/package_manager/src/Validator/ComposerPluginsValidator.php", + "line": 161, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/package_manager/src/Validator/PhpTufValidator.php", + "line": 59, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Connection.php", + "line": 119, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Connection.php", + "line": 270, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Connection.php", + "line": 278, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 275, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 530, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 589, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 630, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 741, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 762, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 831, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 909, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 1095, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 1101, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 1139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/pgsql/src/Driver/Database/pgsql/Schema.php", + "line": 1156, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/responsive_image/src/ResponsiveImageBuilder.php", + "line": 284, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/search/src/SearchIndex.php", + "line": 268, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Connection.php", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Connection.php", + "line": 177, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Connection.php", + "line": 185, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Connection.php", + "line": 209, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Connection.php", + "line": 364, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Connection.php", + "line": 373, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Schema.php", + "line": 35, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/sqlite/src/Driver/Database/sqlite/Schema.php", + "line": 704, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/system/src/Entity/Action.php", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/taxonomy/src/Plugin/Validation/Constraint/TaxonomyTermHierarchyConstraintValidator.php", + "line": 48, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/user/src/Authentication/Provider/Cookie.php", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/user/src/Authentication/Provider/Cookie.php", + "line": 105, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/user/src/UserData.php", + "line": 47, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/views/src/Entity/Render/EntityFieldRenderer.php", + "line": 124, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/views/src/Plugin/views/field/Serialized.php", + "line": 73, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/views/src/Plugin/views/field/Serialized.php", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/views/src/Plugin/views/query/MysqlDateSql.php", + "line": 84, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/views/src/Plugin/views/query/PostgresqlDateSql.php", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/views/src/Plugin/views/style/StylePluginBase.php", + "line": 835, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/workspaces/src/Provider/WorkspaceProviderBase.php", + "line": 227, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/modules/workspaces_ui/src/WorkspaceListBuilder.php", + "line": 220, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.code_exec.eval", + "path_suffix": "core/modules/ckeditor5/tests/src/Nightwatch/Tests/drupalHtmlBuilderTest.js", + "line": 13, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "js.xss.insert_adjacent_html", + "path_suffix": "core/modules/navigation/js/tooltip.js", + "line": 49, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.xss.insert_adjacent_html", + "path_suffix": "core/modules/user/js/user.permissions.js", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.xss.location_assign", + "path_suffix": "core/misc/ajax.js", + "line": 1514, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.xss.location_assign", + "path_suffix": "core/misc/batch.js", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.xss.location_assign", + "path_suffix": "core/modules/big_pipe/js/big_pipe.commands.js", + "line": 94, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ManageGitIgnoreTest.php", + "line": 200, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ManageGitIgnoreTest.php", + "line": 219, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/tests/modules/jsonapi_response_validator/src/EventSubscriber/ResourceResponseValidator.php", + "line": 74, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php", + "line": 637, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php", + "line": 1134, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php", + "line": 3656, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php", + "line": 3663, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/rest/tests/src/Functional/ResourceTestBase.php", + "line": 116, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/rest/tests/src/Functional/ResourceTestBase.php", + "line": 125, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/system/tests/modules/display_variant_test/src/Plugin/DisplayVariant/TestDisplayVariant.php", + "line": 73, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/system/tests/modules/display_variant_test/src/Plugin/DisplayVariant/TestDisplayVariant.php", + "line": 82, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/system/tests/modules/nightwatch_theme_install_utility/src/Controller/ThemeInstallController.php", + "line": 74, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php", + "line": 164, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php", + "line": 170, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "core/tests/Drupal/Tests/HttpKernelUiHelperTrait.php", + "line": 135, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/big_pipe/tests/src/Functional/BigPipeTest.php", + "line": 202, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/big_pipe/tests/src/Functional/BigPipeTest.php", + "line": 204, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/big_pipe/tests/src/Functional/BigPipeTest.php", + "line": 443, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/ckeditor5/tests/src/Traits/SynchronizeCsrfTokenSeedTrait.php", + "line": 32, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php", + "line": 297, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/field/tests/src/Kernel/EntityReference/EntityReferenceItemTest.php", + "line": 329, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/field_ui/tests/src/Kernel/EntityDisplayTest.php", + "line": 666, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/field_ui/tests/src/Kernel/EntityFormDisplayTest.php", + "line": 309, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/help/tests/fixtures/uninstall-search.php", + "line": 21, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php", + "line": 1022, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/locale/tests/src/Kernel/LocaleTranslationTest.php", + "line": 55, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/mysql/tests/src/Kernel/mysql/DbDumpTest.php", + "line": 207, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/node/tests/src/Functional/NodeCreationTest.php", + "line": 345, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php", + "line": 616, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/fixtures/update/drupal-8.update-test-schema-enabled.php", + "line": 46, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/fixtures/update/drupal-8.update-test-semver-update-n-enabled.php", + "line": 30, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/fixtures/update/install-mysqli.php", + "line": 21, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/src/Functional/UpdateSystem/EntityUpdateInitialTest.php", + "line": 50, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/src/Functional/UpdateSystem/NoPreExistingSchemaUpdateTest.php", + "line": 42, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/src/Functional/UpdateSystem/PreventDowngradeTest.php", + "line": 78, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/src/Functional/UpdateSystem/UpdatePostUpdateExceptionTest.php", + "line": 43, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/src/Functional/UpdateSystem/UpdatePostUpdateFailingTest.php", + "line": 43, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/src/Functional/UpdateSystem/UpdatePostUpdateTest.php", + "line": 44, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/system/tests/src/Functional/UpdateSystem/UpdateRemovedPostUpdateTest.php", + "line": 60, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/views/tests/src/Functional/ViewsConfigUpdaterTest.php", + "line": 49, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/views/tests/src/Kernel/ViewExecutableTest.php", + "line": 502, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/views/tests/src/Kernel/ViewExecutableTest.php", + "line": 532, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/modules/views_ui/tests/src/Unit/ViewUIObjectTest.php", + "line": 140, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php", + "line": 586, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php", + "line": 305, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php", + "line": 238, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php", + "line": 288, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Action/EmailActionTest.php", + "line": 67, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Config/Storage/DatabaseStorageTest.php", + "line": 35, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Database/SerializeQueryTest.php", + "line": 26, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php", + "line": 1324, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Entity/EntityTranslationTest.php", + "line": 533, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Entity/EntityTranslationTest.php", + "line": 559, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Entity/EntityTypeTest.php", + "line": 64, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/File/FileDeleteGadgetChainTest.php", + "line": 29, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Plugin/ContextTypedDataTest.php", + "line": 39, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Routing/MatcherDumperTest.php", + "line": 145, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/KernelTests/Core/User/AccountTakeoverGadgetChainTest.php", + "line": 56, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Component/Diff/DiffOpOutputBuilderTest.php", + "line": 112, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Component/Plugin/Attribute/AttributeClassDiscoveryCachedTest.php", + "line": 77, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/Datetime/DrupalDateTimeTest.php", + "line": 291, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/DependencyInjection/DependencySerializationTest.php", + "line": 40, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/Extension/DependencyTest.php", + "line": 65, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/Extension/ExtensionSerializationTest.php", + "line": 56, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/Extension/ExtensionSerializationTest.php", + "line": 63, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/Extension/ExtensionSerializationTest.php", + "line": 82, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/Menu/MenuTreeParametersTest.php", + "line": 157, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/StringTranslation/PluralTranslatableMarkupTest.php", + "line": 33, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "core/tests/Drupal/Tests/Core/TempStore/SharedTempStoreTest.php", + "line": 393, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php", + "line": 118, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php", + "line": 225, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php", + "line": 275, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/media/tests/src/Kernel/OEmbedSourceTest.php", + "line": 135, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/page_cache/tests/src/Functional/PageCacheTest.php", + "line": 644, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/update/tests/src/Kernel/DevReleaseTest.php", + "line": 69, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/modules/update/tests/src/Kernel/UpdateCalculateProjectDataTest.php", + "line": 72, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/tests/Drupal/KernelTests/Core/File/ReadOnlyStreamWrapperTest.php", + "line": 55, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/tests/Drupal/KernelTests/Core/File/StreamWrapperTest.php", + "line": 21, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/tests/Drupal/Tests/Component/DependencyInjection/Dumper/OptimizedPhpArrayDumperTest.php", + "line": 612, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/tests/Drupal/Tests/DrupalTestBrowser.php", + "line": 22, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "core/tests/Drupal/KernelTests/Core/Config/ConfigCRUDTest.php", + "line": 319, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "core/tests/Drupal/KernelTests/Core/TypedData/TypedDataTest.php", + "line": 358, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "core/tests/Drupal/Tests/Core/Logger/LogMessageParserTest.php", + "line": 45, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + } + ] +} diff --git a/tests/recall_targets/xlang/php/joomla.json b/tests/recall_targets/xlang/php/joomla.json new file mode 100644 index 00000000..c6a7c532 --- /dev/null +++ b/tests/recall_targets/xlang/php/joomla.json @@ -0,0 +1,676 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for joomla (PHP). Re-capture by running scripts/validate_recall.sh --lang php joomla --capture.", + "target": "joomla", + "lang": "php", + "clone_url": "https://github.com/joomla/joomla-cms", + "exercises_recall_items": [], + "captured_against": "real-scan @ 7e8527d02d152d789f2fdf04f057eec5d006c40b", + "captured_on": "2026-05-09", + "pinned_commit": "7e8527d02d152d789f2fdf04f057eec5d006c40b", + "findings": [ + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "libraries/src/Cache/Controller/PageController.php", + "line": 100, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "administrator/components/com_templates/src/Model/TemplateModel.php", + "line": 851, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-xxe", + "path_suffix": "administrator/components/com_joomlaupdate/src/Model/UpdateModel.php", + "line": 2308, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "libraries/src/Language/Language.php", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "libraries/src/Application/DaemonApplication.php", + "line": 458, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "libraries/src/Application/DaemonApplication.php", + "line": 724, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.preg_replace_e", + "path_suffix": "administrator/components/com_admin/src/Model/SysinfoModel.php", + "line": 419, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "administrator/components/com_finder/src/Model/SearchesModel.php", + "line": 144, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "administrator/components/com_finder/src/Model/SearchesModel.php", + "line": 146, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "components/com_finder/src/Model/SearchModel.php", + "line": 119, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "components/com_finder/src/Model/SearchModel.php", + "line": 121, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "libraries/src/Cache/Controller/CallbackController.php", + "line": 77, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "libraries/src/Cache/Controller/OutputController.php", + "line": 71, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "libraries/src/Cache/Controller/PageController.php", + "line": 100, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "libraries/src/Cache/Controller/ViewController.php", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "libraries/src/Session/Storage/JoomlaStorage.php", + "line": 317, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "plugins/multifactorauth/webauthn/src/Extension/Webauthn.php", + "line": 326, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "plugins/multifactorauth/webauthn/src/Helper/Credentials.php", + "line": 107, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "plugins/multifactorauth/webauthn/src/Helper/Credentials.php", + "line": 206, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "plugins/system/webauthn/src/Authentication.php", + "line": 253, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "plugins/system/webauthn/src/Authentication.php", + "line": 310, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "plugins/system/webauthn/src/Authentication.php", + "line": 504, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "administrator/components/com_fields/src/Plugin/FieldsPlugin.php", + "line": 227, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "libraries/src/Layout/FileLayout.php", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "plugins/content/pagebreak/src/Extension/PageBreak.php", + "line": 337, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "plugins/content/pagebreak/src/Extension/PageBreak.php", + "line": 373, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "plugins/content/pagenavigation/src/Extension/PageNavigation.php", + "line": 254, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "plugins/content/vote/src/Extension/Vote.php", + "line": 132, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "plugins/content/vote/src/Extension/Vote.php", + "line": 141, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "plugins/multifactorauth/webauthn/src/Extension/Webauthn.php", + "line": 147, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "plugins/multifactorauth/webauthn/src/Extension/Webauthn.php", + "line": 345, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-xxe", + "path_suffix": "tests/Unit/Libraries/Cms/Installer/Adapter/ModuleAdapterTest.php", + "line": 117, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "administrator/components/com_joomlaupdate/extract.php", + "line": 1458, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "libraries/src/Application/DaemonApplication.php", + "line": 724, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "libraries/src/Client/FtpClient.php", + "line": 958, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "installation/src/Application/InstallationApplication.php", + "line": 255, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "administrator/components/com_joomlaupdate/src/Controller/UpdateController.php", + "line": 566, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "administrator/components/com_joomlaupdate/src/Controller/UpdateController.php", + "line": 685, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "administrator/components/com_joomlaupdate/extract.php", + "line": 495, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "administrator/components/com_joomlaupdate/extract.php", + "line": 1249, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "administrator/components/com_joomlaupdate/extract.php", + "line": 1634, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "libraries/src/Cache/Storage/FileStorage.php", + "line": 28, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "libraries/src/Client/FtpClient.php", + "line": 302, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "libraries/src/Client/FtpClient.php", + "line": 1708, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "libraries/src/Filesystem/Stream.php", + "line": 264, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "libraries/src/Http/Transport/CurlTransport.php", + "line": 51, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "administrator/templates/atum/error_full.php", + "line": 171, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "installation/template/js/remove.js", + "line": 129, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "layouts/plugins/system/webauthn/manage.php", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "plugins/filesystem/local/src/Adapter/LocalAdapter.php", + "line": 212, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "administrator/components/com_finder/src/Indexer/Result.php", + "line": 490, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "components/com_finder/src/Model/SearchModel.php", + "line": 119, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "components/com_finder/src/Model/SearchModel.php", + "line": 121, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "libraries/src/Application/DaemonApplication.php", + "line": 458, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "modules/mod_finder/src/Helper/FinderHelper.php", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "plugins/authentication/ldap/src/Extension/Ldap.php", + "line": 307, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "plugins/multifactorauth/webauthn/src/Extension/Webauthn.php", + "line": 326, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "plugins/system/webauthn/src/Authentication.php", + "line": 253, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "plugins/system/webauthn/src/Authentication.php", + "line": 504, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.code_exec.settimeout_string", + "path_suffix": "installation/template/js/template.js", + "line": 166, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.xss.location_assign", + "path_suffix": "installation/template/js/template.js", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "tests/Unit/Component/Finder/Administrator/Indexer/ResultTest.php", + "line": 50, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "tests/Unit/Component/Finder/Administrator/Indexer/ResultTest.php", + "line": 56, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "tests/System/integration/administrator/components/com_users/Mfa.cy.js", + "line": 6, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "tests/System/integration/site/components/com_users/Mfa.cy.js", + "line": 6, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "tests/System/integration/site/components/com_users/Registration.cy.js", + "line": 12, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "administrator/components/com_joomlaupdate/extract.php", + "line": 1412, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "administrator/components/com_joomlaupdate/src/Model/UpdateModel.php", + "line": 893, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "libraries/src/Cache/Storage/FileStorage.php", + "line": 118, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "libraries/src/Client/FtpClient.php", + "line": 933, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "libraries/src/Filter/InputFilter.php", + "line": 298, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "libraries/src/Http/Transport/StreamTransport.php", + "line": 159, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.crypto.math_random", + "path_suffix": "installation/template/js/template.js", + "line": 125, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.secrets.hardcoded_secret", + "path_suffix": "tests/System/integration/administrator/components/com_users/User.cy.js", + "line": 39, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "js.secrets.hardcoded_secret", + "path_suffix": "tests/System/integration/api/com_users/Users.cy.js", + "line": 29, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "js.secrets.hardcoded_secret", + "path_suffix": "tests/System/integration/site/components/com_users/Login.cy.js", + "line": 3, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "js.secrets.hardcoded_secret", + "path_suffix": "tests/System/integration/site/components/com_users/Profile.cy.js", + "line": 4, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "js.secrets.hardcoded_secret", + "path_suffix": "tests/System/integration/site/components/com_users/Profile_Edit.cy.js", + "line": 22, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "js.secrets.hardcoded_secret", + "path_suffix": "tests/System/integration/site/modules/mod_login/Default.cy.js", + "line": 12, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "php.crypto.md5", + "path_suffix": "administrator/components/com_categories/src/Model/CategoryModel.php", + "line": 662, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.crypto.md5", + "path_suffix": "administrator/components/com_fields/src/Model/FieldModel.php", + "line": 746, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.crypto.md5", + "path_suffix": "administrator/components/com_finder/src/Indexer/Indexer.php", + "line": 812, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.crypto.md5", + "path_suffix": "administrator/components/com_finder/src/Table/MapTable.php", + "line": 75, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/recall_targets/xlang/php/nextcloud.json b/tests/recall_targets/xlang/php/nextcloud.json new file mode 100644 index 00000000..0ccaeb28 --- /dev/null +++ b/tests/recall_targets/xlang/php/nextcloud.json @@ -0,0 +1,2108 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for nextcloud (PHP). Re-capture by running scripts/validate_recall.sh --lang php nextcloud --capture.", + "target": "nextcloud", + "lang": "php", + "clone_url": "https://github.com/nextcloud/server", + "exercises_recall_items": [], + "captured_against": "real-scan @ 5c0fe4c3cc7adea955abcf4b530bb056583b1651", + "captured_on": "2026-05-09", + "pinned_commit": "5c0fe4c3cc7adea955abcf4b530bb056583b1651", + "findings": [ + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/src/utils/RedirectUnsupportedBrowsers.js", + "line": 35, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/files/src/actions/openLocallyAction.ts", + "line": 83, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "lib/private/Security/CertificateManager.php", + "line": 136, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/Command/Encryption/MigrateKeyStorage.php", + "line": 155, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/Command/Security/ImportCertificate.php", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "lib/private/Encryption/File.php", + "line": 77, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "lib/private/Setup.php", + "line": 629, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/Command/Encryption/MigrateKeyStorage.php", + "line": 109, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "lib/private/Security/CertificateManager.php", + "line": 124, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "lib/private/Security/CertificateManager.php", + "line": 130, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "lib/private/IntegrityCheck/Checker.php", + "line": 207, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "lib/private/IntegrityCheck/Checker.php", + "line": 236, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/src/services/UnifiedSearchService.js", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "core/src/services/LegacyUnifiedSearchService.js", + "line": 63, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/src/services/LegacyUnifiedSearchService.js", + "line": 32, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/src/services/LegacyUnifiedSearchService.js", + "line": 63, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/src/services/UnifiedSearchService.js", + "line": 25, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/src/services/UnifiedSearchService.js", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Archive/TAR.php", + "line": 95, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Security/Signature/SignatureManager.php", + "line": 97, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "lib/private/Files/Type/Detection.php", + "line": 255, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "lib/private/LargeFileHelper.php", + "line": 185, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "lib/private/Preview/Movie.php", + "line": 285, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "lib/private/Preview/Movie.php", + "line": 337, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "lib/private/Preview/Office.php", + "line": 76, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "apps/provisioning_api/lib/Controller/AUserDataOCSController.php", + "line": 80, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "apps/settings/lib/Controller/CommonSettingsTrait.php", + "line": 137, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "apps/theming/lib/Command/UpdateConfig.php", + "line": 60, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.preg_replace_e", + "path_suffix": "lib/public/Files/Events/BeforeZipCreatedEvent.php", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "apps/user_ldap/lib/Migration/Version1190Date20230706134108.php", + "line": 84, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "lib/private/Repair/RemoveBrokenProperties.php", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "lib/private/TaskProcessing/Manager.php", + "line": 892, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/federation/src/services/api.ts", + "line": 58, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/files/src/actions/convertUtils.ts", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/files/src/actions/convertUtils.ts", + "line": 131, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/files/src/services/Templates.js", + "line": 22, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/files/src/views/folderTree.ts", + "line": 95, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/files/src/views/folderTree.ts", + "line": 172, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/files/src/views/folderTree.ts", + "line": 174, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/files_external/src/composables/useEntities.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/files_external/src/services/externalStorage.ts", + "line": 83, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/settings/src/constants/OfficeSuites.js", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/settings/src/constants/OfficeSuites.js", + "line": 55, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/systemtags/src/services/systemtags.ts", + "line": 84, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/systemtags/src/services/systemtags.ts", + "line": 90, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "apps/updatenotification/src/init.ts", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "cypress/e2e/files/FilesUtils.ts", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "cypress/e2e/files/FilesUtils.ts", + "line": 27, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "cypress/e2e/files/FilesUtils.ts", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "cypress/e2e/files/FilesUtils.ts", + "line": 155, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "cypress/e2e/files/FilesUtils.ts", + "line": 382, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "cypress/e2e/files_external/StorageUtils.ts", + "line": 62, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "apps/testing/lib/Controller/RoutesController.php", + "line": 38, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "lib/private/Config.php", + "line": 240, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Preview/Provider.php", + "line": 112, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php", + "line": 180, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/Storage/Storage.php", + "line": 159, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/Cache/WatcherTest.php", + "line": 183, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/ObjectStore/ObjectStoreScannerTest.php", + "line": 47, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/Cache/UpdaterLegacyTest.php", + "line": 54, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/Cache/ScannerTest.php", + "line": 60, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/ViewTest.php", + "line": 650, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php", + "line": 79, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php", + "line": 94, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/Cache/ScannerTest.php", + "line": 100, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/Stream/EncryptionTest.php", + "line": 288, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/lib/Files/Stream/EncryptionTest.php", + "line": 331, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "apps/files/lib/Command/Object/Info.php", + "line": 72, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "console.php", + "line": 118, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "console.php", + "line": 120, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php", + "line": 48, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/dav/lib/Connector/Sabre/File.php", + "line": 190, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php", + "line": 80, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/encryption/lib/Command/FixKeyLocation.php", + "line": 243, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files/lib/Command/Get.php", + "line": 57, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files/lib/Command/Get.php", + "line": 62, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files/lib/Command/Object/Get.php", + "line": 53, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files/lib/Command/Object/Put.php", + "line": 59, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files/lib/Command/Put.php", + "line": 50, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files/lib/Command/Put.php", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files_external/lib/Lib/Storage/AmazonS3.php", + "line": 570, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files_external/lib/Lib/Storage/Swift.php", + "line": 409, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files_external/lib/Lib/Storage/Swift.php", + "line": 558, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files_versions/lib/Versions/LegacyVersionsBackend.php", + "line": 347, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/theming/lib/Service/BackgroundService.php", + "line": 241, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/theming/lib/Service/BackgroundService.php", + "line": 324, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/Controller/TaskProcessingApiController.php", + "line": 527, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "core/Controller/TaskProcessingApiController.php", + "line": 565, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/ObjectStore/ObjectStoreStorage.php", + "line": 372, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/ObjectStore/S3ObjectTrait.php", + "line": 225, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/ObjectStore/Swift.php", + "line": 66, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/Storage/Common.php", + "line": 219, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/Storage/Common.php", + "line": 220, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/Storage/Common.php", + "line": 527, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/Storage/DAV.php", + "line": 515, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/Storage/LocalTempFileTrait.php", + "line": 40, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Encryption.php", + "line": 360, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/Template/TemplateManager.php", + "line": 170, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Files/View.php", + "line": 1083, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Preview/Bundled.php", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Preview/Generator.php", + "line": 584, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Preview/Imaginary.php", + "line": 65, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Preview/MarkDown.php", + "line": 23, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Preview/ProviderV2.php", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/Preview/TXT.php", + "line": 38, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/private/legacy/OC_Util.php", + "line": 188, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "lib/public/AppFramework/Http/FileDisplayResponse.php", + "line": 50, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/lib/Command/ImportCalendar.php", + "line": 114, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/lib/Connector/Sabre/File.php", + "line": 179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/lib/Direct/DirectFile.php", + "line": 40, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/lib/Upload/AssemblyStream.php", + "line": 283, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_external/lib/Lib/Storage/AmazonS3.php", + "line": 403, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_external/lib/Lib/Storage/FTP.php", + "line": 280, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_external/lib/Lib/Storage/SFTP.php", + "line": 351, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_external/lib/Lib/Storage/StreamWrapper.php", + "line": 64, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_sharing/lib/SharedStorage.php", + "line": 345, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_trashbin/lib/Sabre/TrashFile.php", + "line": 18, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_trashbin/lib/Sabre/TrashFolderFile.php", + "line": 13, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_versions/lib/Storage.php", + "line": 433, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/files_versions/lib/Versions/LegacyVersionsBackend.php", + "line": 189, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/AppFramework/Http/Output.php", + "line": 35, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/AppFramework/Http/Request.php", + "line": 362, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Archive/TAR.php", + "line": 305, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Filesystem.php", + "line": 531, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Node/File.php", + "line": 96, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/ObjectStore/ObjectStoreScanner.php", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/ObjectStore/ObjectStoreStorage.php", + "line": 309, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/ObjectStore/ObjectStoreStorage.php", + "line": 441, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/SimpleFS/NewSimpleFile.php", + "line": 183, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/SimpleFS/NewSimpleFile.php", + "line": 185, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/SimpleFS/NewSimpleFile.php", + "line": 198, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/SimpleFS/SimpleFile.php", + "line": 145, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/SimpleFS/SimpleFile.php", + "line": 156, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/CommonTest.php", + "line": 53, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Local.php", + "line": 405, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/DirPermissionsMask.php", + "line": 179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/DirPermissionsMask.php", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Encoding.php", + "line": 199, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Encryption.php", + "line": 261, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Encryption.php", + "line": 265, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Encryption.php", + "line": 372, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Encryption.php", + "line": 692, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Jail.php", + "line": 166, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/KnownMtime.php", + "line": 108, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/PermissionsMask.php", + "line": 101, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/PermissionsMask.php", + "line": 104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Quota.php", + "line": 120, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Storage/Wrapper/Wrapper.php", + "line": 147, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Stream/Encryption.php", + "line": 156, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Files/Stream/SeekableHttpStream.php", + "line": 50, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "lib/private/Repair/Owncloud/MoveAvatarsBackgroundJob.php", + "line": 52, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/dav/lib/Comments/RootCollection.php", + "line": 115, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/dav/lib/Comments/RootCollection.php", + "line": 127, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/dav/lib/Connector/LegacyPublicAuth.php", + "line": 99, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/dav/lib/DAV/ViewOnlyPlugin.php", + "line": 61, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/deleteAction.spec.ts", + "line": 311, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/deleteAction.spec.ts", + "line": 324, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/deleteAction.spec.ts", + "line": 520, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/deleteAction.spec.ts", + "line": 536, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 192, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 204, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 223, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 238, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 257, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 272, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 292, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 307, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 326, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 344, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 364, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/favoriteAction.spec.ts", + "line": 382, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/sidebarAction.spec.ts", + "line": 157, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/sidebarAction.spec.ts", + "line": 175, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/sidebarAction.spec.ts", + "line": 184, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/sidebarAction.spec.ts", + "line": 200, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/sidebarAction.spec.ts", + "line": 211, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/actions/sidebarAction.spec.ts", + "line": 225, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/components/FileEntryMixin.ts", + "line": 384, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/components/FileEntryMixin.ts", + "line": 401, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/composables/useHotKeys.spec.ts", + "line": 131, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/composables/useHotKeys.spec.ts", + "line": 145, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/views/favorites.spec.ts", + "line": 128, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/views/favorites.spec.ts", + "line": 151, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/views/favorites.spec.ts", + "line": 163, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/views/favorites.spec.ts", + "line": 198, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/views/favorites.spec.ts", + "line": 219, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files/src/views/favorites.spec.ts", + "line": 244, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_sharing/lib/AppInfo/Application.php", + "line": 160, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_sharing/lib/AppInfo/Application.php", + "line": 165, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_sharing/lib/Controller/ShareAPIController.php", + "line": 611, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_sharing/src/files_actions/acceptShareAction.ts", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_sharing/src/files_actions/openInFilesAction.spec.ts", + "line": 80, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_sharing/src/files_actions/openInFilesAction.spec.ts", + "line": 94, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_sharing/src/files_actions/rejectShareAction.ts", + "line": 69, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_sharing/src/files_actions/restoreShareAction.ts", + "line": 40, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_actions/restoreAction.spec.ts", + "line": 136, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_actions/restoreAction.spec.ts", + "line": 141, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_actions/restoreAction.spec.ts", + "line": 144, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_actions/restoreAction.spec.ts", + "line": 156, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_actions/restoreAction.spec.ts", + "line": 161, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_actions/restoreAction.spec.ts", + "line": 172, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_actions/restoreAction.spec.ts", + "line": 180, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_actions/restoreAction.ts", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 121, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 148, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 152, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 160, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 170, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 178, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 190, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 200, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts", + "line": 212, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/provisioning_api/lib/AppInfo/Application.php", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/settings/lib/Controller/AppSettingsController.php", + "line": 173, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/settings/lib/Controller/CommonSettingsTrait.php", + "line": 137, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/systemtags/lib/AppInfo/Application.php", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/user_ldap/lib/Migration/Version1190Date20230706134108.php", + "line": 84, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/workflowengine/src/helpers/validators.js", + "line": 18, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/workflowengine/src/helpers/validators.js", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "apps/workflowengine/src/helpers/validators.js", + "line": 40, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "core/src/Util/get-url-parameter.js", + "line": 10, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "cypress/support/commonUtils.ts", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "cypress/support/commonUtils.ts", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/AppFramework/App.php", + "line": 134, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/AppFramework/DependencyInjection/DIContainer.php", + "line": 309, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/AppFramework/Utility/SimpleContainer.php", + "line": 43, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/AppFramework/Utility/SimpleContainer.php", + "line": 79, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Authentication/Token/PublicKeyToken.php", + "line": 105, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Authentication/Token/PublicKeyTokenProvider.php", + "line": 164, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Collaboration/Resources/ProviderManager.php", + "line": 34, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Command/QueueBus.php", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Diagnostics/QueryLogger.php", + "line": 37, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Files/Filesystem.php", + "line": 208, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/InitialStateService.php", + "line": 95, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Memcache/Redis.php", + "line": 210, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Preview/Movie.php", + "line": 285, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Preview/Storage/ObjectStorePreviewStorage.php", + "line": 111, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Remote/Instance.php", + "line": 99, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "lib/private/Updater/VersionCheck.php", + "line": 121, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.xss.location_assign", + "path_suffix": "core/src/unsupported-browser.js", + "line": 14, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.xss.location_assign", + "path_suffix": "core/src/utils/xhr-request.js", + "line": 57, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "tests/lib/Files/ViewTest.php", + "line": 158, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php", + "line": 52, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php", + "line": 128, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/dav/tests/unit/Connector/Sabre/RequestTest/Sapi.php", + "line": 41, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/dav/tests/unit/Files/MultipartRequestParserTest.php", + "line": 67, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "apps/files_sharing/tests/SharedStorageTest.php", + "line": 179, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/lib/AppFramework/Http/FileDisplayResponseTest.php", + "line": 74, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/lib/Files/FilesystemTest.php", + "line": 321, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/lib/Files/Node/FileTest.php", + "line": 156, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/lib/Files/ObjectStore/S3SSEKMSTest.php", + "line": 66, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/lib/Files/ObjectStore/S3Test.php", + "line": 108, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unreachable-sink", + "path_suffix": "apps/user_ldap/tests/Integration/AbstractIntegrationTest.php", + "line": 155, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php", + "line": 90, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/tests/integration/UserMigration/ContactsMigratorTest.php", + "line": 46, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php", + "line": 47, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/tests/unit/Connector/Sabre/FileTest.php", + "line": 98, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/dav/tests/unit/Connector/Sabre/RequestTest/RequestTestCase.php", + "line": 37, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "apps/settings/tests/UserMigration/AccountMigratorTest.php", + "line": 120, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "tests/lib/AppFramework/Http/OutputTest.php", + "line": 28, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "tests/lib/Files/ObjectStore/ObjectStoreTestCase.php", + "line": 45, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "tests/lib/Files/Storage/Wrapper/EncryptionTest.php", + "line": 727, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + } + ] +} diff --git a/tests/recall_targets/xlang/php/phpmyadmin.json b/tests/recall_targets/xlang/php/phpmyadmin.json new file mode 100644 index 00000000..fc1a2b81 --- /dev/null +++ b/tests/recall_targets/xlang/php/phpmyadmin.json @@ -0,0 +1,964 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for phpmyadmin (PHP). Re-capture by running scripts/validate_recall.sh --lang php phpmyadmin --capture against a fresh checkout. Baseline location is tests/recall_targets/xlang/php/ (relocated out of .pitboss/ per the Phase 01 precedent — pitboss implementer agents must not write under .pitboss/).", + "target": "phpmyadmin", + "lang": "php", + "clone_url": "https://github.com/phpmyadmin/phpmyadmin", + "exercises_recall_items": [], + "captured_against": "real-scan @ ddf4e99390c8da9c851ac7a21a43a45d1229687c", + "captured_on": "2026-05-09", + "pinned_commit": "ddf4e99390c8da9c851ac7a21a43a45d1229687c", + "findings": [ + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "resources/js/makegrid.ts", + "line": 441, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "resources/js/makegrid.ts", + "line": 445, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Server/Privileges.php", + "line": 1417, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Table/ZoomSearchController.php", + "line": 182, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "resources/js/setup/scripts.ts", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "resources/js/setup/scripts.ts", + "line": 248, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "resources/js/setup/scripts.ts", + "line": 251, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Table/ZoomSearchController.php", + "line": 295, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Table/ZoomSearchController.php", + "line": 356, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Table/GisVisualizationController.php", + "line": 91, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Operations.php", + "line": 905, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Operations.php", + "line": 913, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "resources/js/modules/ajax.ts", + "line": 906, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Preferences/ManageController.php", + "line": 124, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Database/Routines.php", + "line": 251, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Database/Designer/Common.php", + "line": 426, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Operations/DatabaseController.php", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Table/Structure/SaveController.php", + "line": 210, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Database/RoutinesController.php", + "line": 301, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Server/Privileges.php", + "line": 2282, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Server/Privileges.php", + "line": 2296, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Server/PrivilegesController.php", + "line": 194, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Server/PrivilegesController.php", + "line": 304, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Controllers/Server/PrivilegesController.php", + "line": 331, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Git.php", + "line": 630, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "resources/js/modules/ajax.ts", + "line": 905, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Database/Routines.php", + "line": 112, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "src/Command/WriteGitRevisionCommand.php", + "line": 147, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "src/Plugins/Transformations/Abs/ExternalTransformationsPlugin.php", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.cmdi.system", + "path_suffix": "src/Server/SysInfo/SunOs.php", + "line": 27, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.code_exec.assert_string", + "path_suffix": "src/Controllers/Table/Structure/MoveColumnsController.php", + "line": 144, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.deser.unserialize", + "path_suffix": "src/Core.php", + "line": 643, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.code_exec.eval", + "path_suffix": "resources/js/modules/functions.ts", + "line": 3070, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "resources/js/designer/page.ts", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "resources/js/designer/page.ts", + "line": 87, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "resources/js/designer/page.ts", + "line": 93, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "resources/js/designer/page.ts", + "line": 141, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.auth.missing_ownership_check", + "path_suffix": "resources/js/designer/page.ts", + "line": 171, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.path.include_variable", + "path_suffix": "src/Plugins/Auth/AuthenticationSignon.php", + "line": 132, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/Export/OutputHandler.php", + "line": 214, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "examples/openid.php", + "line": 135, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "resources/js/sql.ts", + "line": 583, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "src/Export/OutputHandler.php", + "line": 186, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "src/File.php", + "line": 414, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "src/Utils/HttpRequest.php", + "line": 122, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "src/WebAuthn/DataStream.php", + "line": 14, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "resources/js/setup/scripts.ts", + "line": 108, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "src/Utils/HttpRequest.php", + "line": 275, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "resources/js/modules/functions.ts", + "line": 1972, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "resources/js/modules/indexes.ts", + "line": 741, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Bookmarks/Bookmark.php", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/ConfigStorage/Relation.php", + "line": 1464, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Controllers/Database/SqlAutoCompleteController.php", + "line": 49, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Controllers/Database/Structure/AddPrefixTableController.php", + "line": 38, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Controllers/Operations/Database/CollationController.php", + "line": 61, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Controllers/Operations/Database/CollationController.php", + "line": 81, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Controllers/Server/Databases/DestroyController.php", + "line": 80, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Controllers/Table/FindReplaceController.php", + "line": 356, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Controllers/Table/Structure/PrimaryController.php", + "line": 133, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Database/Designer/Common.php", + "line": 495, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Database/Designer/Common.php", + "line": 503, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Database/Designer/Common.php", + "line": 597, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Dbal/DatabaseInterface.php", + "line": 288, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Dbal/DatabaseInterface.php", + "line": 1028, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Dbal/DbiMysqli.php", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/InsertEdit.php", + "line": 195, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/InsertEdit.php", + "line": 764, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/InsertEdit.php", + "line": 1819, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 266, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 319, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 324, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 329, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 334, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 380, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 398, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 415, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 433, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 449, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 758, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 766, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 812, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 829, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Operations.php", + "line": 856, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Plugins/Export/ExportMediawiki.php", + "line": 227, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Plugins/Export/ExportOds.php", + "line": 164, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Plugins/Export/ExportOdt.php", + "line": 202, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Plugins/Export/ExportXml.php", + "line": 402, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Replication/ReplicationInfo.php", + "line": 151, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Server/Privileges.php", + "line": 1205, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Server/Privileges.php", + "line": 2726, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Table/Indexes.php", + "line": 204, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Table/TableMover.php", + "line": 340, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Table/TableMover.php", + "line": 353, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Table/TableMover.php", + "line": 371, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Table/TableMover.php", + "line": 440, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/Tracking/Tracking.php", + "line": 827, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.xss.insert_adjacent_html", + "path_suffix": "resources/js/modules/functions.ts", + "line": 2581, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.xss.insert_adjacent_html", + "path_suffix": "resources/js/sql.ts", + "line": 1229, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.xss.location_assign", + "path_suffix": "resources/js/modules/ajax.ts", + "line": 441, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.xss.outer_html", + "path_suffix": "resources/js/modules/navigation/event-loader.ts", + "line": 13, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "examples/openid.php", + "line": 135, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/end-to-end/TestBase.php", + "line": 1131, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/unit/Dbal/DbiMysqliTest.php", + "line": 29, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "tests/unit/Gis/GisVisualizationTest.php", + "line": 437, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "src/Config.php", + "line": 237, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "src/Config/Validator.php", + "line": 245, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "src/Dbal/DbiMysqli.php", + "line": 46, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "src/Encoding.php", + "line": 249, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "src/File.php", + "line": 459, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak-possible", + "path_suffix": "src/Git.php", + "line": 145, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "php.crypto.md5", + "path_suffix": "tests/unit/MessageTest.php", + "line": 286, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "resources/js/designer/page.ts", + "line": 188, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "resources/js/drag_drop_import.ts", + "line": 398, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "resources/js/error_report.ts", + "line": 134, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "resources/js/modules/console.ts", + "line": 827, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "resources/js/modules/functions.ts", + "line": 56, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "ts.crypto.math_random", + "path_suffix": "resources/js/replication.ts", + "line": 13, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/recall_targets/xlang/python/airflow.json b/tests/recall_targets/xlang/python/airflow.json new file mode 100644 index 00000000..f0f8db63 --- /dev/null +++ b/tests/recall_targets/xlang/python/airflow.json @@ -0,0 +1,7148 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for apache/airflow (Python). Re-capture by running scripts/validate_recall.sh --lang python airflow --capture.", + "target": "airflow", + "lang": "python", + "clone_url": "https://github.com/apache/airflow", + "exercises_recall_items": [], + "captured_against": "real-scan @ 3d42610a26e4a8e49e2cf3d9eb6fc5ad712f180e", + "captured_on": "2026-05-09", + "pinned_commit": "3d42610a26e4a8e49e2cf3d9eb6fc5ad712f180e", + "findings": [ + { + "rule_id": "taint-header-injection", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/app.py", + "line": 125, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api/auth/backend/kerberos_auth.py", + "line": 141, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/backport/update_backport_status.py", + "line": 123, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/apache/druid/src/airflow/providers/apache/druid/hooks/druid.py", + "line": 168, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/apache/druid/src/airflow/providers/apache/druid/hooks/druid.py", + "line": 174, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/selective_checks.py", + "line": 1606, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/selective_checks.py", + "line": 1621, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "registry/src/js/provider-detail.js", + "line": 60, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 433, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "registry/src/js/provider-detail.js", + "line": 65, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/test_utils/get_all_tests.py", + "line": 77, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/validate_version_added_fields_in_config.py", + "line": 94, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "shared/configuration/src/airflow_shared/configuration/parser.py", + "line": 1052, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/workflow_status.py", + "line": 133, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 197, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 201, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/api/client.py", + "line": 208, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/api/client.py", + "line": 216, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/api/client.py", + "line": 254, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/api/client.py", + "line": 255, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/api/client.py", + "line": 266, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/api/client.py", + "line": 267, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 468, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/api/client.py", + "line": 496, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/configuration.py", + "line": 638, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/sphinx_exts/extra_provider_files_with_substitutions.py", + "line": 60, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py", + "line": 881, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/backport/update_backport_status.py", + "line": 109, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py", + "line": 66, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py", + "line": 112, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py", + "line": 113, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py", + "line": 119, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 1199, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "task-sdk/src/airflow/sdk/definitions/dag.py", + "line": 1294, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/test_utils/api_client_helpers.py", + "line": 125, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/test_utils/api_client_helpers.py", + "line": 147, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/cli/hot_reload.py", + "line": 160, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1598, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1602, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1604, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1605, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1610, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1612, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1614, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1809, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1810, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py", + "line": 208, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/models/dagrun.py", + "line": 1926, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 376, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py", + "line": 223, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 2197, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py", + "line": 221, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 2444, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/models/trigger.py", + "line": 251, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py", + "line": 243, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/github.py", + "line": 288, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/models/renderedtifields.py", + "line": 293, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 2840, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py", + "line": 324, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/github.py", + "line": 325, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/github.py", + "line": 335, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/hooks/base_aws.py", + "line": 363, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/github.py", + "line": 360, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/models/variable.py", + "line": 390, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/test_utils/otel_utils.py", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 515, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 520, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 521, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py", + "line": 576, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 661, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/permissions_command.py", + "line": 84, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/permissions_command.py", + "line": 90, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/permissions_command.py", + "line": 97, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/permissions_command.py", + "line": 100, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 664, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/standard/src/airflow/providers/standard/hooks/subprocess.py", + "line": 82, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/transfers/vertica_to_hive.py", + "line": 123, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "providers/microsoft/azure/src/airflow/providers/microsoft/azure/transfers/oracle_to_azure_data_lake.py", + "line": 103, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "providers/oracle/src/airflow/providers/oracle/transfers/oracle_to_oracle.py", + "line": 89, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-use-after-close", + "path_suffix": "providers/teradata/src/airflow/providers/teradata/transfers/teradata_to_teradata.py", + "line": 89, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/auth_manager/routes/login.py", + "line": 113, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/auth_manager/routes/login.py", + "line": 114, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/auth_manager/routes/login.py", + "line": 116, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/api/common/delete_dag.py", + "line": 85, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/ctl/commands/auth_command.py", + "line": 154, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/src/airflowctl/ctl/commands/auth_command.py", + "line": 169, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 2390, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/dag_processing/bundles/manager.py", + "line": 295, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py", + "line": 329, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 780, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 639, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 648, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/constraints_version_check.py", + "line": 459, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/registry/extract_metadata.py", + "line": 63, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/registry/extract_metadata.py", + "line": 79, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/registry/extract_metadata.py", + "line": 179, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/apache/pinot/src/airflow/providers/apache/pinot/hooks/pinot.py", + "line": 259, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py", + "line": 848, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "go.cmdi.exec_command", + "path_suffix": "go-sdk/pkg/bundles/shared/discovery.go", + "line": 209, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.code_exec.exec", + "path_suffix": "airflow-core/src/airflow/policies.py", + "line": 179, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0055_3_0_0_remove_pickled_data_from_dagrun_table.py", + "line": 97, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "dev/stats/get_important_pr_candidates.py", + "line": 1004, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/api/common/mark_tasks.py", + "line": 329, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/api/common/trigger_dag.py", + "line": 109, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/api/common/trigger_dag.py", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/services/ui/structure.py", + "line": 174, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 400, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 417, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 481, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 501, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 568, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 393, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1074, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1079, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/recording.py", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1278, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/databricks/src/airflow/providers/databricks/utils/openlineage.py", + "line": 236, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/permissions_command.py", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/permissions_command.py", + "line": 79, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py", + "line": 556, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/task_runner.py", + "line": 1266, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api/common/trigger_dag.py", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py", + "line": 78, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py", + "line": 439, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py", + "line": 80, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py", + "line": 604, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py", + "line": 887, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py", + "line": 260, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/services/ui/structure.py", + "line": 174, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/api_fastapi/logging/decorators.py", + "line": 169, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/cli/commands/task_command.py", + "line": 226, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 435, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 501, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 393, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/settings.py", + "line": 623, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 711, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 736, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1079, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 278, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/airflow_perf/scheduler_dag_execution_timing.py", + "line": 303, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/assign_cherry_picked_prs_with_milestone.py", + "line": 360, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/ci_commands.py", + "line": 1323, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/pr_commands.py", + "line": 352, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/pr_commands.py", + "line": 686, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/pr_commands.py", + "line": 1064, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/release_management_commands.py", + "line": 2697, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/release_management_commands.py", + "line": 3290, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/release_management_commands.py", + "line": 4491, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/sbom_commands.py", + "line": 954, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/sbom_commands.py", + "line": 1056, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/prepare_providers/provider_documentation.py", + "line": 1087, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/github.py", + "line": 310, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/prepare_bulk_issues.py", + "line": 224, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1366, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "devel-common/src/tests_common/test_utils/system_tests.py", + "line": 81, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/databricks/src/airflow/providers/databricks/utils/openlineage.py", + "line": 236, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/edge3/src/airflow/providers/edge3/plugins/edge_executor_plugin.py", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py", + "line": 79, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py", + "line": 91, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py", + "line": 52, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py", + "line": 92, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/users.py", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/users.py", + "line": 113, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/users.py", + "line": 131, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py", + "line": 167, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/openlineage/src/airflow/providers/openlineage/utils/spark.py", + "line": 321, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/openlineage/src/airflow/providers/openlineage/utils/spark.py", + "line": 356, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/sendgrid/src/airflow/providers/sendgrid/utils/emailer.py", + "line": 121, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/snowflake/src/airflow/providers/snowflake/utils/openlineage.py", + "line": 325, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "task-sdk/src/airflow/sdk/bases/operator.py", + "line": 171, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "task-sdk/src/airflow/sdk/definitions/param.py", + "line": 382, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/task_runner.py", + "line": 1266, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/airflow_version_check.py", + "line": 113, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/run_capture_airflowctl_help.py", + "line": 160, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/update_quarantined_test_status.py", + "line": 181, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/update_quarantined_test_status.py", + "line": 245, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/bin/generate_mprocs_config.py", + "line": 221, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/google/tests/system/google/cloud/gcs/resources/transform_script.py", + "line": 25, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/run_generate_constraints.py", + "line": 249, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "devel-common/src/docs/build_docs.py", + "line": 289, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/install_airflow_and_providers.py", + "line": 291, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/prek/upgrade_important_versions.py", + "line": 318, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/check_junitxml_result.py", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/prek/vendor_k8s_json_schema.py", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-template-injection", + "path_suffix": "providers/google/tests/system/google/cloud/data_loss_prevention/example_dlp_inspect_template.py", + "line": 85, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-template-injection", + "path_suffix": "providers/google/tests/system/google/cloud/data_loss_prevention/example_dlp_deidentify_content.py", + "line": 128, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-template-injection", + "path_suffix": "providers/google/tests/system/google/cloud/data_loss_prevention/example_dlp_inspect_template.py", + "line": 85, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-template-injection", + "path_suffix": "providers/google/tests/system/google/cloud/data_loss_prevention/example_dlp_deidentify_content.py", + "line": 128, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/prek/compile_provider_assets.py", + "line": 145, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/docs/store_stable_versions.py", + "line": 184, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/tests/airflow_ctl/api/test_client.py", + "line": 141, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/tests/airflow_ctl/api/test_client.py", + "line": 171, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/tests/airflow_ctl/api/test_client.py", + "line": 192, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/slack_notification_state.py", + "line": 208, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/tests/airflow_ctl/api/test_client.py", + "line": 246, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/tests/airflow_ctl/api/test_client.py", + "line": 261, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-ctl/tests/airflow_ctl/api/test_client.py", + "line": 276, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/google/tests/system/google/cloud/dataprep/example_dataprep.py", + "line": 279, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "task-sdk-integration-tests/tests/task_sdk_tests/conftest.py", + "line": 356, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/dags/test_on_failure_callback.py", + "line": 39, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/notify_uv_lock_conflicts.py", + "line": 455, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/analyze_e2e_flaky_tests.py", + "line": 521, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/integration/otel/test_otel.py", + "line": 491, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/analyze_e2e_flaky_tests.py", + "line": 562, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/tools/generate-integrations-json.py", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/analyze_e2e_flaky_tests.py", + "line": 569, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/prek/compile_ui_assets_dev.py", + "line": 84, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/prek/compile_ui_assets_dev.py", + "line": 101, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/run_capture_airflowctl_help.py", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py", + "line": 147, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/in_container/run_provider_yaml_files_check.py", + "line": 1040, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/google/tests/system/google/gcp_api_client_helpers.py", + "line": 104, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1210, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1246, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/google/tests/system/google/gcp_api_client_helpers.py", + "line": 126, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/models/test_dag.py", + "line": 1289, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1321, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1350, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1386, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1574, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1632, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1682, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1738, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/apache/cassandra/tests/integration/apache/cassandra/hooks/test_cassandra.py", + "line": 180, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1769, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1797, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1804, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1856, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1883, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1909, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/apache/cassandra/tests/integration/apache/cassandra/hooks/test_cassandra.py", + "line": 197, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 1972, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2077, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2090, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2101, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2113, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2125, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/apache/cassandra/tests/integration/apache/cassandra/hooks/test_cassandra.py", + "line": 213, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2156, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2183, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2220, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2249, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/apache/cassandra/tests/integration/apache/cassandra/hooks/test_cassandra.py", + "line": 229, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2278, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2311, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2348, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2372, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2388, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/apache/cassandra/tests/integration/apache/cassandra/hooks/test_cassandra.py", + "line": 246, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2443, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/fab/tests/unit/fab/auth_manager/cli_commands/test_permissions_command.py", + "line": 283, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2947, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 2980, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/fab/tests/unit/fab/auth_manager/cli_commands/test_permissions_command.py", + "line": 322, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/models/test_dag.py", + "line": 3255, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/models/test_renderedtifields.py", + "line": 343, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/models/test_renderedtifields.py", + "line": 362, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/utils/test_db_cleanup.py", + "line": 408, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4022, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4067, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4134, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4135, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4193, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/api_fastapi/common/test_exceptions.py", + "line": 431, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4477, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4517, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4633, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4668, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/cli/commands/test_pool_command.py", + "line": 51, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "providers/apache/cassandra/tests/integration/apache/cassandra/hooks/test_cassandra.py", + "line": 70, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/utils/test_sqlalchemy.py", + "line": 88, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "kubernetes-tests/tests/kubernetes_tests/test_base.py", + "line": 100, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/models/test_dag.py", + "line": 1012, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "taint-template-injection", + "path_suffix": "scripts/in_container/update_quarantined_test_status.py", + "line": 246, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "scripts/ci/prek/upgrade_important_versions.py", + "line": 256, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "registry/src/js/copy-button.js", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "registry/src/js/copy-button.js", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "providers/fab/src/airflow/providers/fab/www/auth.py", + "line": 151, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "providers/fab/src/airflow/providers/fab/www/auth.py", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "airflow-core/tests/unit/models/test_backfill.py", + "line": 382, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-double-close", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/kubernetes_utils.py", + "line": 366, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/transfers/vertica_to_hive.py", + "line": 132, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "providers/microsoft/azure/src/airflow/providers/microsoft/azure/transfers/oracle_to_azure_data_lake.py", + "line": 111, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "providers/teradata/src/airflow/providers/teradata/transfers/teradata_to_teradata.py", + "line": 102, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/api_fastapi/app.py", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/api_fastapi/auth/tokens.py", + "line": 167, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/api_fastapi/common/cursors.py", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py", + "line": 511, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py", + "line": 592, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/cli/commands/info_command.py", + "line": 200, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/cli/commands/standalone_command.py", + "line": 297, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/configuration.py", + "line": 134, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/dag_processing/bundles/base.py", + "line": 169, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/dag_processing/bundles/base.py", + "line": 444, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/dag_processing/importers/base.py", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/dag_processing/importers/python_importer.py", + "line": 287, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/example_dags/tutorial_objectstorage.py", + "line": 120, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/jobs/triggerer_job_runner.py", + "line": 360, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1327, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/utils/db_manager.py", + "line": 145, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/utils/db_manager.py", + "line": 265, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/utils/log/file_task_handler.py", + "line": 894, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/utils/log/log_stream_accumulator.py", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/src/airflow/utils/thread_safe_dict.py", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-ctl/src/airflowctl/api/client.py", + "line": 266, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/add_back_references.py", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/airflow_release_validator.py", + "line": 187, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py", + "line": 97, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/github.py", + "line": 297, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/kubernetes_utils.py", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/release_validator.py", + "line": 373, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "go-sdk/pkg/worker/runner.go", + "line": 240, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "go-sdk/pkg/worker/runner.go", + "line": 459, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py", + "line": 1211, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py", + "line": 1619, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/hooks/sagemaker.py", + "line": 179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/transfers/dynamodb_to_s3.py", + "line": 219, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/transfers/dynamodb_to_s3.py", + "line": 248, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/transfers/ftp_to_s3.py", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/transfers/ftp_to_s3.py", + "line": 99, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/beam/src/airflow/providers/apache/beam/hooks/beam.py", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/cassandra/src/airflow/providers/apache/cassandra/hooks/cassandra.py", + "line": 129, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/hooks/hive.py", + "line": 329, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/hooks/hive.py", + "line": 588, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/hooks/hive.py", + "line": 1006, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/transfers/s3_to_hive.py", + "line": 207, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/transfers/s3_to_hive.py", + "line": 272, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/pig/src/airflow/providers/apache/pig/hooks/pig.py", + "line": 28, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/pinot/src/airflow/providers/apache/pinot/hooks/pinot.py", + "line": 259, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/pinot/src/airflow/providers/apache/pinot/hooks/pinot.py", + "line": 300, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_pipelines.py", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_sql.py", + "line": 202, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py", + "line": 356, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py", + "line": 604, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py", + "line": 777, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py", + "line": 968, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py", + "line": 881, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/pod_generator.py", + "line": 554, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/common/io/src/airflow/providers/common/io/xcom/backend.py", + "line": 173, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/databricks/src/airflow/providers/databricks/hooks/databricks_sql.py", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/databricks/src/airflow/providers/databricks/operators/databricks_sql.py", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/docker/src/airflow/providers/docker/operators/docker.py", + "line": 482, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/edge3/src/airflow/providers/edge3/cli/worker.py", + "line": 310, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/edge3/src/airflow/providers/edge3/example_dags/win_test.py", + "line": 226, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py", + "line": 536, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/fab/src/airflow/providers/fab/www/extensions/init_appbuilder.py", + "line": 62, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/ftp/src/airflow/providers/ftp/hooks/ftp.py", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/ftp/src/airflow/providers/ftp/hooks/ftp.py", + "line": 78, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/git/src/airflow/providers/git/hooks/git.py", + "line": 172, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/github/src/airflow/providers/github/hooks/github.py", + "line": 65, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py", + "line": 487, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py", + "line": 650, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py", + "line": 1260, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/gcs.py", + "line": 582, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/log/gcs_task_handler.py", + "line": 188, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/operators/gen_ai.py", + "line": 973, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/calendar_to_gcs.py", + "line": 194, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/cassandra_to_gcs.py", + "line": 196, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/cassandra_to_gcs.py", + "line": 229, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/facebook_ads_to_gcs.py", + "line": 225, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_sftp.py", + "line": 201, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/sftp_to_gcs.py", + "line": 191, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/sheets_to_gcs.py", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/utils/credentials_provider.py", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/suite/transfers/gcs_to_gdrive.py", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/suite/transfers/gcs_to_gdrive.py", + "line": 165, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/grpc/src/airflow/providers/grpc/hooks/grpc.py", + "line": 96, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/keycloak/src/airflow/providers/keycloak/auth_manager/cache.py", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/microsoft/azure/src/airflow/providers/microsoft/azure/transfers/oracle_to_azure_data_lake.py", + "line": 85, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/microsoft/azure/src/airflow/providers/microsoft/azure/transfers/s3_to_wasb.py", + "line": 235, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/microsoft/azure/src/airflow/providers/microsoft/azure/transfers/sftp_to_wasb.py", + "line": 181, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/mysql/src/airflow/providers/mysql/hooks/mysql.py", + "line": 238, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/mysql/src/airflow/providers/mysql/hooks/mysql.py", + "line": 247, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/mysql/src/airflow/providers/mysql/hooks/mysql.py", + "line": 272, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/mysql/src/airflow/providers/mysql/hooks/mysql.py", + "line": 311, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/mysql/src/airflow/providers/mysql/transfers/vertica_to_mysql.py", + "line": 135, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/openlineage/src/airflow/providers/openlineage/plugins/adapter.py", + "line": 128, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py", + "line": 455, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/oracle/src/airflow/providers/oracle/hooks/oracle.py", + "line": 285, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/oracle/src/airflow/providers/oracle/transfers/oracle_to_oracle.py", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/postgres/src/airflow/providers/postgres/hooks/postgres.py", + "line": 270, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/postgres/src/airflow/providers/postgres/hooks/postgres.py", + "line": 273, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/postgres/src/airflow/providers/postgres/hooks/postgres.py", + "line": 286, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/postgres/src/airflow/providers/postgres/hooks/postgres.py", + "line": 393, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/presto/src/airflow/providers/presto/hooks/presto.py", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/samba/src/airflow/providers/samba/transfers/gcs_to_samba.py", + "line": 201, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/sftp/src/airflow/providers/sftp/hooks/sftp.py", + "line": 789, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/smtp/src/airflow/providers/smtp/hooks/smtp.py", + "line": 573, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/ssh/src/airflow/providers/ssh/hooks/ssh.py", + "line": 351, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/ssh/src/airflow/providers/ssh/hooks/ssh.py", + "line": 635, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/ssh/src/airflow/providers/ssh/tunnel.py", + "line": 141, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/standard/src/airflow/providers/standard/sensors/bash.py", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/teradata/src/airflow/providers/teradata/hooks/teradata.py", + "line": 172, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "providers/trino/src/airflow/providers/trino/hooks/trino.py", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "shared/configuration/src/airflow_shared/configuration/parser.py", + "line": 120, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 235, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 255, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 489, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 490, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 491, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 1721, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 1800, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/task_runner.py", + "line": 1936, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/io/path.py", + "line": 352, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/io/path.py", + "line": 411, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4217, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 4269, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-ctl-tests/tests/airflowctl_tests/conftest.py", + "line": 76, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "scripts/ci/prek/compile_ui_assets.py", + "line": 89, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-data-exfiltration", + "path_suffix": "providers/google/tests/system/google/cloud/dataprep/example_dataprep.py", + "line": 167, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "airflow-core/src/airflow/utils/process_utils.py", + "line": 95, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "airflow-core/src/airflow/utils/process_utils.py", + "line": 110, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-core/src/airflow/cli/commands/standalone_command.py", + "line": 246, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-core/src/airflow/cli/hot_reload.py", + "line": 160, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-core/src/airflow/dag_processing/manager.py", + "line": 1177, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-core/src/airflow/jobs/triggerer_job_runner.py", + "line": 363, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-core/src/airflow/utils/file.py", + "line": 73, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-core/src/airflow/utils/file.py", + "line": 74, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/confirm.py", + "line": 382, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/confirm.py", + "line": 390, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/console.py", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "dev/ide_setup/setup_idea.py", + "line": 523, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "dev/ide_setup/setup_idea.py", + "line": 535, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "dev/ide_setup/setup_idea.py", + "line": 545, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/airflow_intersphinx.py", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/exampleinclude.py", + "line": 271, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/extra_files_with_substitutions.py", + "line": 57, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/extra_provider_files_with_substitutions.py", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/generate_erd.py", + "line": 173, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/metrics_tables_from_registry.py", + "line": 28, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/pagefind_search/__init__.py", + "line": 94, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/pagefind_search/__init__.py", + "line": 95, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/pagefind_search/__init__.py", + "line": 96, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/pagefind_search/__init__.py", + "line": 97, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/providers_packages_ref.py", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/redirects.py", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/sphinx_script_update.py", + "line": 122, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "devel-common/src/sphinx_exts/sphinx_script_update.py", + "line": 123, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/alibaba/src/airflow/providers/alibaba/cloud/log/oss_task_handler.py", + "line": 208, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/hooks/athena_sql.py", + "line": 212, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/hooks/redshift_sql.py", + "line": 241, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py", + "line": 1617, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/amazon/src/airflow/providers/amazon/aws/log/s3_task_handler.py", + "line": 218, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/apache/beam/src/airflow/providers/apache/beam/hooks/beam.py", + "line": 171, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/apache/druid/src/airflow/providers/apache/druid/hooks/druid.py", + "line": 221, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/apache/hdfs/src/airflow/providers/apache/hdfs/log/hdfs_task_handler.py", + "line": 130, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/hooks/hive.py", + "line": 897, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/apache/impala/src/airflow/providers/apache/impala/hooks/impala.py", + "line": 42, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py", + "line": 285, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py", + "line": 903, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/elasticsearch/src/airflow/providers/elasticsearch/hooks/elasticsearch.py", + "line": 143, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/elasticsearch/src/airflow/providers/elasticsearch/hooks/elasticsearch.py", + "line": 194, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/exasol/src/airflow/providers/exasol/hooks/exasol.py", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/ftp/src/airflow/providers/ftp/hooks/ftp.py", + "line": 192, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/bigquery.py", + "line": 1541, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py", + "line": 944, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/compute_ssh.py", + "line": 60, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/compute_ssh.py", + "line": 316, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/cassandra_to_gcs.py", + "line": 213, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/mssql_to_gcs.py", + "line": 94, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/mysql_to_gcs.py", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/oracle_to_gcs.py", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py", + "line": 166, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py", + "line": 170, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/presto_to_gcs.py", + "line": 188, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/sql_to_gcs.py", + "line": 357, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/sql_to_gcs.py", + "line": 468, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/trino_to_gcs.py", + "line": 195, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/jdbc/src/airflow/providers/jdbc/hooks/jdbc.py", + "line": 193, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/data_lake.py", + "line": 134, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/microsoft/mssql/src/airflow/providers/microsoft/mssql/hooks/mssql.py", + "line": 97, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/microsoft/mssql/src/airflow/providers/microsoft/mssql/hooks/mssql.py", + "line": 103, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/mysql/src/airflow/providers/mysql/hooks/mysql.py", + "line": 225, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/odbc/src/airflow/providers/odbc/hooks/odbc.py", + "line": 199, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/odbc/src/airflow/providers/odbc/hooks/odbc.py", + "line": 213, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/postgres/src/airflow/providers/postgres/hooks/postgres.py", + "line": 370, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/postgres/src/airflow/providers/postgres/hooks/postgres.py", + "line": 389, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/presto/src/airflow/providers/presto/hooks/presto.py", + "line": 233, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/presto/src/airflow/providers/presto/hooks/presto.py", + "line": 255, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/samba/src/airflow/providers/samba/hooks/samba.py", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake.py", + "line": 651, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake.py", + "line": 908, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/sqlite/src/airflow/providers/sqlite/hooks/sqlite.py", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/teradata/src/airflow/providers/teradata/hooks/teradata.py", + "line": 162, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/trino/src/airflow/providers/trino/hooks/trino.py", + "line": 209, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/trino/src/airflow/providers/trino/hooks/trino.py", + "line": 266, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/trino/src/airflow/providers/trino/hooks/trino.py", + "line": 332, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/vertica/src/airflow/providers/vertica/hooks/vertica.py", + "line": 137, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/ydb/src/airflow/providers/ydb/hooks/ydb.py", + "line": 112, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py", + "line": 309, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/comms.py", + "line": 176, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 265, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 2122, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/supervisor.py", + "line": 2125, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "task-sdk/src/airflow/sdk/io/path.py", + "line": 223, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api/common/delete_dag.py", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api/common/mark_tasks.py", + "line": 207, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py", + "line": 514, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py", + "line": 583, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py", + "line": 643, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py", + "line": 745, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py", + "line": 509, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py", + "line": 643, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py", + "line": 677, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py", + "line": 712, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_tags.py", + "line": 73, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py", + "line": 385, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py", + "line": 411, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py", + "line": 428, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py", + "line": 438, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py", + "line": 73, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/variables.py", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py", + "line": 285, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py", + "line": 414, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py", + "line": 309, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dashboard.py", + "line": 146, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dashboard.py", + "line": 173, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py", + "line": 99, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py", + "line": 145, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py", + "line": 181, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py", + "line": 215, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/services/public/connections.py", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/services/public/pools.py", + "line": 125, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py", + "line": 105, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/services/ui/calendar.py", + "line": 118, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py", + "line": 119, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py", + "line": 147, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 362, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 652, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 690, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 719, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 742, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 804, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 870, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py", + "line": 1087, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py", + "line": 218, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py", + "line": 444, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/assets/manager.py", + "line": 89, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/assets/manager.py", + "line": 563, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/cli/commands/dag_command.py", + "line": 225, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/cli/commands/info_command.py", + "line": 200, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/cli/commands/standalone_command.py", + "line": 297, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/configuration.py", + "line": 134, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/collection.py", + "line": 353, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/collection.py", + "line": 361, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/collection.py", + "line": 746, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/collection.py", + "line": 877, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/collection.py", + "line": 941, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/collection.py", + "line": 952, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/collection.py", + "line": 954, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/manager.py", + "line": 414, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/manager.py", + "line": 432, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/dag_processing/manager.py", + "line": 669, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/example_dags/tutorial_objectstorage.py", + "line": 123, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 209, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 360, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 2251, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 2630, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 2883, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 3041, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 3087, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 3104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/scheduler_job_runner.py", + "line": 3171, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/jobs/triggerer_job_runner.py", + "line": 191, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0041_3_0_0_rename_dataset_as_asset.py", + "line": 108, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0041_3_0_0_rename_dataset_as_asset.py", + "line": 114, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 191, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 208, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 231, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 244, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 256, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 293, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 303, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 308, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 370, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/asset.py", + "line": 119, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 365, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/backfill.py", + "line": 567, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/connection.py", + "line": 641, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/dag.py", + "line": 265, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/dag.py", + "line": 838, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/dagrun.py", + "line": 614, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/dagrun.py", + "line": 887, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/dagwarning.py", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/deadline.py", + "line": 446, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/pool.py", + "line": 382, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/renderedtifields.py", + "line": 322, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/revoked_token.py", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/serialized_dag.py", + "line": 159, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/serialized_dag.py", + "line": 169, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/serialized_dag.py", + "line": 179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/serialized_dag.py", + "line": 559, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/serialized_dag.py", + "line": 927, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 202, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 814, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 816, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 847, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 953, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 1169, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 1604, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 2091, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/taskinstance.py", + "line": 2100, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/trigger.py", + "line": 232, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/trigger.py", + "line": 255, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/trigger.py", + "line": 387, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/variable.py", + "line": 319, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/variable.py", + "line": 442, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/variable.py", + "line": 537, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/models/xcom.py", + "line": 153, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/policies.py", + "line": 179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/serialization/definitions/dag.py", + "line": 732, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/serialization/definitions/dag.py", + "line": 884, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/serialization/definitions/deadline.py", + "line": 230, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/serialization/definitions/deadline.py", + "line": 358, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/serialization/definitions/operatorlink.py", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1030, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1062, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1433, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/utils/db.py", + "line": 1530, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/utils/process_utils.py", + "line": 96, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "airflow-core/src/airflow/utils/process_utils.py", + "line": 111, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/airflow_perf/scheduler_dag_execution_timing.py", + "line": 139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/airflow_perf/scheduler_dag_execution_timing.py", + "line": 140, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/airflow_perf/scheduler_dag_execution_timing.py", + "line": 141, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/airflow_perf/scheduler_dag_execution_timing.py", + "line": 150, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/assign_cherry_picked_prs_with_milestone.py", + "line": 225, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/main_command.py", + "line": 135, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/main_command.py", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/breeze/src/airflow_breeze/commands/release_management_commands.py", + "line": 2878, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/breeze/src/airflow_breeze/utils/provider_dependencies.py", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "dev/prepare_bulk_issues.py", + "line": 239, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1392, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1397, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1398, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1399, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1403, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1406, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1407, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1408, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1409, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1410, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/pytest_plugin.py", + "line": 1411, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/api_fastapi.py", + "line": 51, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 221, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 222, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 223, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 224, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 228, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 238, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 239, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 245, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 246, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 247, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 248, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 249, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 253, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 256, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 257, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 265, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 266, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 267, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 271, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 280, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 281, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 288, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 289, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 290, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 291, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 294, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 303, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 312, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 327, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 333, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 351, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 359, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 365, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 374, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 377, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 390, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 396, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 402, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 408, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 418, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 428, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 434, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 440, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 446, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 454, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 462, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 470, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/db.py", + "line": 478, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/logs.py", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "devel-common/src/tests_common/test_utils/logs.py", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "go-sdk/pkg/bundles/shared/discovery.go", + "line": 207, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/apache/beam/src/airflow/providers/apache/beam/hooks/beam.py", + "line": 307, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_pipelines.py", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_sql.py", + "line": 202, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py", + "line": 825, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/edge3/src/airflow/providers/edge3/example_dags/win_test.py", + "line": 226, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/elasticsearch/src/airflow/providers/elasticsearch/hooks/elasticsearch.py", + "line": 154, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/exasol/src/airflow/providers/exasol/hooks/exasol.py", + "line": 191, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/exasol/src/airflow/providers/exasol/hooks/exasol.py", + "line": 208, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py", + "line": 554, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py", + "line": 567, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py", + "line": 614, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py", + "line": 634, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/models/__init__.py", + "line": 342, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py", + "line": 946, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py", + "line": 1335, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py", + "line": 2575, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py", + "line": 650, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py", + "line": 705, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/operators/gcs.py", + "line": 624, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/mssql_to_gcs.py", + "line": 95, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py", + "line": 168, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py", + "line": 171, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py", + "line": 174, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/presto_to_gcs.py", + "line": 190, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/trino_to_gcs.py", + "line": 197, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/adx.py", + "line": 226, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/microsoft/azure/src/airflow/providers/microsoft/azure/transfers/oracle_to_azure_data_lake.py", + "line": 103, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/mysql/src/airflow/providers/mysql/hooks/mysql.py", + "line": 349, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/mysql/src/airflow/providers/mysql/transfers/vertica_to_mysql.py", + "line": 112, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/presto/src/airflow/providers/presto/hooks/presto.py", + "line": 235, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/presto/src/airflow/providers/presto/hooks/presto.py", + "line": 257, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/standard/src/airflow/providers/standard/operators/python.py", + "line": 118, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/standard/src/airflow/providers/standard/operators/python.py", + "line": 1284, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/standard/src/airflow/providers/standard/operators/python.py", + "line": 1324, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py", + "line": 434, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/standard/src/airflow/providers/standard/sensors/bash.py", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/standard/src/airflow/providers/standard/triggers/external_task.py", + "line": 288, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/trino/src/airflow/providers/trino/hooks/trino.py", + "line": 268, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "providers/trino/src/airflow/providers/trino/hooks/trino.py", + "line": 334, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "shared/configuration/src/airflow_shared/configuration/parser.py", + "line": 120, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "task-sdk/dev/datamodel_code_formatter.py", + "line": 138, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.code_exec.compile", + "path_suffix": "airflow-core/src/airflow/policies.py", + "line": 177, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.code_exec.exec", + "path_suffix": "task-sdk/tests/task_sdk/bases/test_decorator.py", + "line": 246, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "airflow-core/tests/unit/models/test_dag.py", + "line": 1255, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "airflow-core/tests/unit/serialization/test_dag_serialization.py", + "line": 2982, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "airflow-core/tests/unit/serialization/test_dag_serialization.py", + "line": 3072, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "airflow-core/tests/unit/utils/test_sqlalchemy.py", + "line": 245, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "airflow-core/tests/unit/utils/test_sqlalchemy.py", + "line": 246, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "airflow-core/tests/unit/utils/test_sqlalchemy.py", + "line": 279, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes.py", + "line": 74, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes.py", + "line": 125, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "providers/openlineage/tests/unit/openlineage/plugins/test_facets.py", + "line": 120, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "providers/standard/tests/unit/standard/operators/test_python.py", + "line": 393, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.pickle_loads", + "path_suffix": "task-sdk/tests/task_sdk/api/test_client.py", + "line": 168, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.deser.yaml_load", + "path_suffix": "scripts/in_container/run_template_fields_check.py", + "line": 106, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "dev/prepare_bulk_issues.py", + "line": 102, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/hooks/bigquery.py", + "line": 969, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/log/gcs_task_handler.py", + "line": 128, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/log/gcs_task_handler.py", + "line": 135, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/log/gcs_task_handler.py", + "line": 187, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/operators/bigquery.py", + "line": 1975, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_bigquery.py", + "line": 581, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "task-sdk/src/airflow/sdk/definitions/_internal/templater.py", + "line": 259, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "task-sdk/src/airflow/sdk/definitions/_internal/templater.py", + "line": 288, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "task-sdk/src/airflow/sdk/execution_time/task_runner.py", + "line": 1714, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py", + "line": 145, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py", + "line": 167, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py", + "line": 192, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py", + "line": 206, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py", + "line": 247, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py", + "line": 248, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 8927, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 8999, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 9048, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 9107, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 9128, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/jobs/test_triggerer_job.py", + "line": 679, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/models/test_dag.py", + "line": 2881, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/models/test_dagrun.py", + "line": 1909, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/models/test_dagrun.py", + "line": 3172, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/models/test_dagrun.py", + "line": 3175, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/models/test_dagrun.py", + "line": 3193, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/models/test_mappedoperator.py", + "line": 115, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/models/test_mappedoperator.py", + "line": 179, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/models/test_mappedoperator.py", + "line": 281, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "airflow-core/tests/unit/utils/test_state.py", + "line": 46, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/amazon/tests/system/amazon/aws/tests/test_aws_auth_manager.py", + "line": 90, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/databricks/tests/unit/databricks/operators/test_databricks_workflow.py", + "line": 89, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/databricks/tests/unit/databricks/operators/test_databricks_workflow.py", + "line": 153, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/edge3/tests/unit/edge3/executors/test_edge_executor.py", + "line": 80, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/fab/tests/unit/fab/utils.py", + "line": 107, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/system/google/cloud/composer/example_cloud_composer.py", + "line": 158, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 538, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 552, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 561, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 576, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 586, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 732, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 748, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 757, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 772, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_dataprep.py", + "line": 782, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_secret_manager.py", + "line": 124, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_secret_manager.py", + "line": 154, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/google/tests/unit/google/cloud/hooks/test_secret_manager.py", + "line": 280, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/openlineage/tests/unit/openlineage/plugins/test_listener.py", + "line": 123, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.missing_ownership_check", + "path_suffix": "providers/openlineage/tests/unit/openlineage/plugins/test_listener.py", + "line": 966, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/api_fastapi/auth/managers/simple/services/test_login.py", + "line": 69, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py", + "line": 244, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py", + "line": 171, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 8639, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/jobs/test_scheduler_job.py", + "line": 8927, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/jobs/test_triggerer_job.py", + "line": 679, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_dag.py", + "line": 2961, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_dagrun.py", + "line": 1669, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_dagrun.py", + "line": 1758, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_dagrun.py", + "line": 2636, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_mappedoperator.py", + "line": 147, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_mappedoperator.py", + "line": 199, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_mappedoperator.py", + "line": 312, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_taskinstance.py", + "line": 3504, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/models/test_xcom.py", + "line": 101, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/utils/test_db_cleanup.py", + "line": 477, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "airflow-core/tests/unit/utils/test_log_handlers.py", + "line": 328, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "dev/breeze/tests/test_release_validator.py", + "line": 200, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/apache/hive/tests/unit/apache/hive/operators/test_hive_stats.py", + "line": 152, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/apache/hive/tests/unit/apache/hive/operators/test_hive_stats.py", + "line": 246, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/common/sql/tests/unit/common/sql/datafusion/test_object_storage_provider.py", + "line": 66, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/edge3/tests/unit/edge3/executors/test_edge_executor.py", + "line": 80, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/edge3/tests/unit/edge3/executors/test_edge_executor.py", + "line": 195, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/fab/tests/unit/fab/auth_manager/test_security.py", + "line": 372, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/google/tests/system/google/cloud/composer/example_cloud_composer.py", + "line": 158, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/pinecone/tests/unit/pinecone/hooks/test_pinecone.py", + "line": 68, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "providers/pinecone/tests/unit/pinecone/hooks/test_pinecone.py", + "line": 75, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "task-sdk-integration-tests/tests/task_sdk_tests/conftest.py", + "line": 635, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.auth.token_override_without_validation", + "path_suffix": "task-sdk/tests/conftest.py", + "line": 155, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.cmdi.subprocess_shell", + "path_suffix": "providers/amazon/tests/system/amazon/aws/example_emr_eks.py", + "line": 118, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.cmdi.subprocess_shell", + "path_suffix": "providers/amazon/tests/system/amazon/aws/example_emr_eks.py", + "line": 169, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.cmdi.subprocess_shell", + "path_suffix": "providers/amazon/tests/system/amazon/aws/example_sagemaker.py", + "line": 129, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.cmdi.subprocess_shell", + "path_suffix": "providers/amazon/tests/system/amazon/aws/example_sagemaker.py", + "line": 143, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.cmdi.subprocess_shell", + "path_suffix": "providers/amazon/tests/system/amazon/aws/example_sagemaker.py", + "line": 211, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.cmdi.subprocess_shell", + "path_suffix": "providers/amazon/tests/system/amazon/aws/example_sagemaker.py", + "line": 521, + "severity": "Medium", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "py.cmdi.subprocess_shell", + "path_suffix": "scripts/ci/prek/ruff_format.py", + "line": 33, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.cmdi.subprocess_shell", + "path_suffix": "scripts/in_container/run_capture_airflowctl_help.py", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/example_dags/tutorial_objectstorage.py", + "line": 123, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/utils.py", + "line": 35, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0027_2_10_3_fix_dag_schedule_dataset_alias_reference_naming.py", + "line": 48, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0027_2_10_3_fix_dag_schedule_dataset_alias_reference_naming.py", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0032_3_0_0_rename_execution_date_to_logical_date_and_nullable.py", + "line": 89, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0032_3_0_0_rename_execution_date_to_logical_date_and_nullable.py", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0032_3_0_0_rename_execution_date_to_logical_date_and_nullable.py", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0032_3_0_0_rename_execution_date_to_logical_date_and_nullable.py", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0032_3_0_0_rename_execution_date_to_logical_date_and_nullable.py", + "line": 99, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0041_3_0_0_rename_dataset_as_asset.py", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0041_3_0_0_rename_dataset_as_asset.py", + "line": 88, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0041_3_0_0_rename_dataset_as_asset.py", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0041_3_0_0_rename_dataset_as_asset.py", + "line": 118, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.execute_format", + "path_suffix": "providers/apache/hive/src/airflow/providers/apache/hive/hooks/hive.py", + "line": 929, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0003_2_7_0_add_include_deferred_column_to_pool.py", + "line": 46, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0017_2_9_2_fix_inconsistency_between_ORM_and_migration_files.py", + "line": 251, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0049_3_0_0_remove_pickled_data_from_xcom_table.py", + "line": 102, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0049_3_0_0_remove_pickled_data_from_xcom_table.py", + "line": 113, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 546, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py", + "line": 564, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/migrations/versions/0104_3_2_0_fix_uuid_column_types.py", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 206, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 253, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 346, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 648, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 664, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.sqli.text_format", + "path_suffix": "providers/fab/src/airflow/providers/fab/migrations/versions/0001_3_5_0_fix_fab_db_inconsistencies.py", + "line": 154, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/integration/otel/test_otel.py", + "line": 167, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/always/test_project_structure.py", + "line": 48, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/api_fastapi/common/test_cursors.py", + "line": 56, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_sources.py", + "line": 77, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/cli/commands/test_api_server_command.py", + "line": 236, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/cli/commands/test_kerberos_command.py", + "line": 79, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/listeners/file_write_listener.py", + "line": 33, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/listeners/xcom_listener.py", + "line": 29, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/utils/test_db_cleanup.py", + "line": 678, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "airflow-core/tests/unit/utils/test_process_utils.py", + "line": 128, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-unreachable-source", + "path_suffix": "airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py", + "line": 259, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-source", + "path_suffix": "airflow-core/src/airflow/cli/commands/api_server_command.py", + "line": 203, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-source", + "path_suffix": "airflow-core/src/airflow/dag_processing/collection.py", + "line": 249, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-source", + "path_suffix": "airflow-core/src/airflow/utils/db_cleanup.py", + "line": 487, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-source", + "path_suffix": "providers/snowflake/src/airflow/providers/snowflake/operators/snowpark.py", + "line": 134, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-core/docs/conf.py", + "line": 383, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-core/tests/unit/cli/commands/test_standalone_command.py", + "line": 227, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "airflow-ctl/docs/conf.py", + "line": 303, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/amazon/tests/system/amazon/aws/example_bedrock_retrieve_and_generate.py", + "line": 314, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "providers/amazon/tests/system/amazon/aws/example_sagemaker.py", + "line": 143, + "severity": "Low", + "verdict": "FP", + "note": "Test fixture / helper. The flagged shape is in the test path, not request-reachable production code." + } + ] +} diff --git a/tests/recall_targets/xlang/python/flask.json b/tests/recall_targets/xlang/python/flask.json new file mode 100644 index 00000000..010b6311 --- /dev/null +++ b/tests/recall_targets/xlang/python/flask.json @@ -0,0 +1,236 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for pallets/flask (Python). Re-capture by running scripts/validate_recall.sh --lang python flask --capture. Phase 17 ships airflow as the captured Python target; flask remains a placeholder for future cross-validation against a smaller-surface Python framework codebase.", + "target": "flask", + "lang": "python", + "clone_url": "https://github.com/pallets/flask", + "exercises_recall_items": [], + "captured_against": "real-scan @ 7374c85ddefc3f4b177a698ab9f0cbb6a5c0b392", + "captured_on": "2026-05-10", + "pinned_commit": "7374c85ddefc3f4b177a698ab9f0cbb6a5c0b392", + "findings": [ + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/flask/cli.py", + "line": 1022, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "src/flask/cli.py", + "line": 1023, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.code_exec.eval", + "path_suffix": "src/flask/cli.py", + "line": 1023, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.code_exec.exec", + "path_suffix": "src/flask/config.py", + "line": 209, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "examples/tutorial/flaskr/auth.py", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "tests/test_templating.py", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "src/flask/app.py", + "line": 443, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "src/flask/app.py", + "line": 445, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "src/flask/app.py", + "line": 465, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "src/flask/app.py", + "line": 467, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "src/flask/blueprints.py", + "line": 126, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "src/flask/blueprints.py", + "line": 128, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "src/flask/testing.py", + "line": 235, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "src/flask/config.py", + "line": 209, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.code_exec.compile", + "path_suffix": "src/flask/cli.py", + "line": 1023, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.code_exec.compile", + "path_suffix": "src/flask/config.py", + "line": 209, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "src/flask/templating.py", + "line": 159, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.xss.jinja_from_string", + "path_suffix": "src/flask/templating.py", + "line": 211, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/test_basic.py", + "line": 37, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/test_testing.py", + "line": 80, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tests/test_views.py", + "line": 14, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "examples/tutorial/flaskr/db.py", + "line": 15, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "tests/test_signals.py", + "line": 14, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "examples/tutorial/flaskr/blog.py", + "line": 20, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "tests/test_appctx.py", + "line": 169, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "tests/test_json.py", + "line": 213, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "tests/test_templating.py", + "line": 27, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "py.crypto.sha1", + "path_suffix": "src/flask/sessions.py", + "line": 281, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/recall_targets/xlang/ruby/rails.json b/tests/recall_targets/xlang/ruby/rails.json new file mode 100644 index 00000000..892cfe8c --- /dev/null +++ b/tests/recall_targets/xlang/ruby/rails.json @@ -0,0 +1,8292 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for rails/rails (Ruby — actionpack subtree is the canonical entry-point surface). Re-capture by running scripts/validate_recall.sh --lang ruby rails --capture (point clone_path at the rails monorepo or its actionpack/ subdirectory). Placeholder: pitboss implementer agents run sandboxed without network egress; this clone was not available locally during phase 17 capture.", + "target": "rails", + "lang": "ruby", + "clone_url": "https://github.com/rails/rails", + "exercises_recall_items": [], + "captured_against": "real-scan @ 4ce7edbab406a807f738ffa1a4ded2aafdda841a", + "captured_on": "2026-05-10", + "pinned_commit": "4ce7edbab406a807f738ffa1a4ded2aafdda841a", + "findings": [ + { + "rule_id": "taint-header-injection", + "path_suffix": "actionpack/lib/action_dispatch/http/parameters.rb", + "line": 62, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/lib/action_dispatch/middleware/actionable_exceptions.rb", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/lib/action_dispatch/http/request.rb", + "line": 91, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activesupport/lib/active_support/testing/isolation.rb", + "line": 84, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "actionpack/lib/action_dispatch/request/session.rb", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "actionpack/lib/action_dispatch/middleware/show_exceptions.rb", + "line": 37, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "actionpack/lib/action_dispatch/middleware/show_exceptions.rb", + "line": 38, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "activesupport/lib/active_support/dot_env_configuration.rb", + "line": 79, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/lib/rails/api/task.rb", + "line": 209, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/lib/rails/generators/app_base.rb", + "line": 543, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/lib/rails/generators/app_base.rb", + "line": 804, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/lib/rails/generators/rails/plugin/plugin_generator.rb", + "line": 432, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/lib/rails/generators/rails/plugin/plugin_generator.rb", + "line": 441, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "tasks/release.rb", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "tools/rail_inspector/lib/rail_inspector/configuring/check/framework_defaults.rb", + "line": 52, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "tools/releaser/lib/releaser.rb", + "line": 143, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "tools/releaser/lib/releaser.rb", + "line": 151, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "tools/releaser/lib/releaser.rb", + "line": 304, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "tools/releaser/lib/releaser.rb", + "line": 308, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "tools/releaser/lib/releaser.rb", + "line": 331, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "actioncable/lib/action_cable/subscription_adapter/postgresql.rb", + "line": 21, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "actioncable/lib/action_cable/subscription_adapter/postgresql.rb", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "actioncable/lib/action_cable/subscription_adapter/postgresql.rb", + "line": 97, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "actioncable/lib/action_cable/subscription_adapter/postgresql.rb", + "line": 100, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract_adapter.rb", + "line": 134, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activerecord/lib/active_record/tasks/abstract_tasks.rb", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activerecord/lib/active_record/tasks/postgresql_database_tasks.rb", + "line": 94, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activestorage/lib/active_storage/previewer/mupdf_previewer.rb", + "line": 21, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activestorage/lib/active_storage/previewer/poppler_pdf_previewer.rb", + "line": 21, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activestorage/lib/active_storage/previewer/video_previewer.rb", + "line": 15, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activesupport/lib/active_support/continuous_integration.rb", + "line": 85, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "guides/rails_guides/generator.rb", + "line": 137, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/app_loader.rb", + "line": 53, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/application.rb", + "line": 412, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/command/helpers/editor.rb", + "line": 29, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/commands/credentials/credentials_command/diffing.rb", + "line": 42, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/commands/credentials/credentials_command/diffing.rb", + "line": 46, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/configuration.rb", + "line": 138, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/generators/bundle_helper.rb", + "line": 27, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/generators/bundle_helper.rb", + "line": 29, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/lib/rails/test_unit/runner.rb", + "line": 52, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "tools/cve_announcement.rb", + "line": 87, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/abstract_controller/collector.rb", + "line": 11, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/abstract_controller/helpers.rb", + "line": 72, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/abstract_controller/helpers.rb", + "line": 139, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/abstract_controller/helpers.rb", + "line": 204, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_controller/metal.rb", + "line": 149, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_dispatch/http/request.rb", + "line": 50, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_dispatch/journey/route.rb", + "line": 17, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_dispatch/routing/polymorphic_routes.rb", + "line": 156, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_dispatch/routing/route_set.rb", + "line": 170, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_dispatch/routing/route_set.rb", + "line": 176, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_dispatch/routing/route_set.rb", + "line": 515, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_dispatch/routing/route_set.rb", + "line": 521, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/lib/action_dispatch/testing/integration.rb", + "line": 397, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actiontext/lib/action_text/attribute.rb", + "line": 54, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actiontext/lib/action_text/attribute.rb", + "line": 65, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actiontext/lib/action_text/attribute.rb", + "line": 71, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/helpers/form_helper.rb", + "line": 2026, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/helpers/tag_helper.rb", + "line": 1315, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/helpers/tag_helper.rb", + "line": 1324, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/helpers/tag_helper.rb", + "line": 1333, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/layouts.rb", + "line": 326, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/lookup_context.rb", + "line": 26, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/template.rb", + "line": 508, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/template/inline.rb", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/lib/action_view/test_case.rb", + "line": 222, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activemodel/lib/active_model/attribute_methods.rb", + "line": 376, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activemodel/lib/active_model/attribute_methods.rb", + "line": 389, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activemodel/lib/active_model/callbacks.rb", + "line": 67, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activemodel/lib/active_model/naming.rb", + "line": 353, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activemodel/lib/active_model/secure_password.rb", + "line": 249, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/association_relation.rb", + "line": 19, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations.rb", + "line": 2025, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/builder/association.rb", + "line": 103, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/builder/association.rb", + "line": 113, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/builder/association.rb", + "line": 167, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/builder/belongs_to.rb", + "line": 160, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/builder/collection_association.rb", + "line": 61, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/builder/collection_association.rb", + "line": 73, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/builder/singular_association.rb", + "line": 18, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/builder/singular_association.rb", + "line": 35, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/associations/collection_proxy.rb", + "line": 1129, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/attribute_methods.rb", + "line": 268, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/attribute_methods/primary_key.rb", + "line": 145, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb", + "line": 97, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb", + "line": 310, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/core.rb", + "line": 433, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/dynamic_matchers.rb", + "line": 39, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/enum.rb", + "line": 252, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/inheritance.rb", + "line": 314, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/locking/optimistic.rb", + "line": 203, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/migration/command_recorder.rb", + "line": 129, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/migration/command_recorder.rb", + "line": 178, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/model_schema.rb", + "line": 175, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/model_schema.rb", + "line": 603, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/nested_attributes.rb", + "line": 387, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/persistence.rb", + "line": 303, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/reflection.rb", + "line": 144, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/relation/delegation.rb", + "line": 78, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/relation/query_methods.rb", + "line": 173, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/lib/active_record/store.rb", + "line": 140, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activestorage/lib/active_storage/attached/model.rb", + "line": 124, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activestorage/lib/active_storage/attached/model.rb", + "line": 236, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/broadcast_logger.rb", + "line": 127, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/callbacks.rb", + "line": 909, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/class_attribute.rb", + "line": 20, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/code_generator.rb", + "line": 30, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/code_generator.rb", + "line": 75, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/concern.rb", + "line": 138, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/concern.rb", + "line": 151, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/concern.rb", + "line": 214, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/configurable.rb", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/configurable.rb", + "line": 152, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/configurable.rb", + "line": 153, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/configurable.rb", + "line": 156, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/configurable.rb", + "line": 157, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/configurable.rb", + "line": 168, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/class/attribute.rb", + "line": 146, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/kernel/singleton_class.rb", + "line": 6, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/aliasing.rb", + "line": 25, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/attribute_accessors.rb", + "line": 73, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/attribute_accessors.rb", + "line": 138, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb", + "line": 48, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb", + "line": 58, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb", + "line": 73, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb", + "line": 107, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb", + "line": 115, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/module/concerning.rb", + "line": 135, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/string/output_safety.rb", + "line": 161, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/string/output_safety.rb", + "line": 175, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/delegation.rb", + "line": 163, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/delegation.rb", + "line": 177, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/delegation.rb", + "line": 249, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/delegation.rb", + "line": 251, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/deprecation/method_wrappers.rb", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/deprecation/method_wrappers.rb", + "line": 54, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/environment_inquirer.rb", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/lazy_load_hooks.rb", + "line": 45, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/lazy_load_hooks.rb", + "line": 97, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/log_subscriber/test_helper.rb", + "line": 83, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/testing/isolation.rb", + "line": 11, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/lib/active_support/time_with_zone.rb", + "line": 448, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/lib/rails/engine.rb", + "line": 408, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/lib/rails/generators/rails/app/app_generator.rb", + "line": 18, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/lib/rails/paths.rb", + "line": 152, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/lib/rails/railtie/configurable.rb", + "line": 25, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activesupport/lib/active_support/testing/assertions.rb", + "line": 117, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activesupport/lib/active_support/testing/assertions.rb", + "line": 213, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activesupport/lib/active_support/testing/assertions.rb", + "line": 282, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activesupport/lib/active_support/testing/stream.rb", + "line": 26, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "railties/lib/rails/commands/query/query_command.rb", + "line": 102, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "railties/lib/rails/commands/query/query_command.rb", + "line": 151, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "railties/lib/rails/commands/runner/runner_command.rb", + "line": 38, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "railties/lib/rails/commands/runner/runner_command.rb", + "line": 49, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionpack/lib/action_controller/metal/redirecting.rb", + "line": 222, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionpack/lib/action_dispatch/routing/mapper.rb", + "line": 1829, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/lib/active_record/autosave_association.rb", + "line": 168, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/lib/active_record/relation/merger.rb", + "line": 90, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/lib/active_record/schema.rb", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/lib/active_record/token_for.rb", + "line": 24, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/lib/active_support/continuous_integration.rb", + "line": 114, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/lib/active_support/continuous_integration.rb", + "line": 192, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/lib/active_support/continuous_integration/group.rb", + "line": 13, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/object/try.rb", + "line": 10, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/object/try.rb", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/lib/active_support/core_ext/object/with_options.rb", + "line": 96, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/lib/active_support/lazy_load_hooks.rb", + "line": 99, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "railties/lib/rails/application.rb", + "line": 159, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "railties/lib/rails/engine.rb", + "line": 394, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "railties/lib/rails/generators/actions.rb", + "line": 504, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "railties/lib/rails/railtie.rb", + "line": 291, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "actionpack/lib/action_dispatch/http/rack_cache.rb", + "line": 23, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/lib/active_record/connection_adapters/schema_cache.rb", + "line": 233, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/lib/active_support/cache/coder.rb", + "line": 157, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/lib/active_support/cache/entry.rb", + "line": 111, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/lib/active_support/cache/entry.rb", + "line": 128, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/lib/active_support/cache/memory_store.rb", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/lib/active_support/cache/serializer_with_fallback.rb", + "line": 40, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/lib/active_support/messages/serializer_with_fallback.rb", + "line": 68, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/lib/active_support/testing/isolation.rb", + "line": 32, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/lib/active_support/xml_mini.rb", + "line": 84, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "railties/lib/rails/commands/credentials/credentials_command.rb", + "line": 66, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb", + "line": 15, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "actionmailbox/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb", + "line": 9, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "actionpack/lib/action_dispatch/middleware/session/cache_store.rb", + "line": 55, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "actionpack/lib/action_dispatch/middleware/session/cache_store.rb", + "line": 56, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "actionview/lib/action_view/helpers/form_tag_helper.rb", + "line": 994, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/aggregations.rb", + "line": 243, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/aggregations.rb", + "line": 244, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 69, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 73, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb", + "line": 389, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/core.rb", + "line": 274, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/counter_cache.rb", + "line": 39, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/counter_cache.rb", + "line": 72, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/counter_cache.rb", + "line": 95, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/counter_cache.rb", + "line": 144, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/destroy_association_async_job.rb", + "line": 28, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 645, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 653, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 1117, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 1125, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 496, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 498, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 501, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 503, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 514, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 572, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activesupport/lib/active_support/testing/parallelization/server.rb", + "line": 37, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activesupport/lib/active_support/testing/parallelization/server.rb", + "line": 50, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activesupport/lib/active_support/testing/parallelization/server.rb", + "line": 51, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activesupport/lib/active_support/testing/parallelization/server.rb", + "line": 57, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activesupport/lib/active_support/testing/parallelization/server.rb", + "line": 58, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.token_override_without_validation", + "path_suffix": "activerecord/lib/active_record/destroy_association_async_job.rb", + "line": 29, + "severity": "High", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "actionpack/test/controller/integration_test.rb", + "line": 264, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails/sources_controller.rb", + "line": 12, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "actionpack/lib/action_dispatch/middleware/actionable_exceptions.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "activestorage/app/controllers/active_storage/representations/redirect_controller.rb", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "activestorage/app/controllers/active_storage/blobs/redirect_controller.rb", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "actionmailbox/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb", + "line": 12, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/forbidden_attributes_protection_test.rb", + "line": 102, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/test/dispatch/request/multipart_params_parsing_test.rb", + "line": 113, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/forbidden_attributes_protection_test.rb", + "line": 109, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/test/dispatch/request/multipart_params_parsing_test.rb", + "line": 124, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/calculations_test.rb", + "line": 1481, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/calculations_test.rb", + "line": 1478, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/relation/where_test.rb", + "line": 485, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/relation/where_test.rb", + "line": 484, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/test/dispatch/request/multipart_params_parsing_test.rb", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/forbidden_attributes_protection_test.rb", + "line": 80, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/test/dispatch/request/multipart_params_parsing_test.rb", + "line": 88, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/forbidden_attributes_protection_test.rb", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activejob/test/support/integration/adapters/queue_classic.rb", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activejob/test/support/integration/adapters/queue_classic.rb", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/support/connection.rb", + "line": 23, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-header-injection", + "path_suffix": "actionpack/lib/action_dispatch/http/request.rb", + "line": 496, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/公共/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/公共/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 3, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/公共/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 3, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 3, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/公共/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 3, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/bind_parameter_test.rb", + "line": 258, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "railties/test/isolation/abstract_unit.rb", + "line": 509, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "railties/test/isolation/abstract_unit.rb", + "line": 525, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "railties/test/isolation/abstract_unit.rb", + "line": 545, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "railties/test/isolation/abstract_unit.rb", + "line": 571, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/adapter_test.rb", + "line": 948, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/test/cases/adapter_test.rb", + "line": 971, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/公共/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 1, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 3, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-prototype-pollution", + "path_suffix": "actionpack/test/fixtures/公共/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 3, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/lib/active_record/tasks/database_tasks.rb", + "line": 122, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activesupport/lib/active_support/continuous_integration/group.rb", + "line": 125, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activesupport/lib/active_support/railtie.rb", + "line": 138, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/lib/active_record/tasks/database_tasks.rb", + "line": 215, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/lib/active_record/schema_dumper.rb", + "line": 223, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activerecord/lib/active_record/schema_dumper.rb", + "line": 224, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "guides/assets/javascripts/@hotwired--turbo.js", + "line": 3577, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "guides/w3c_validator.rb", + "line": 46, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/lib/action_dispatch/middleware/show_exceptions.rb", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "railties/lib/rails/testing/maintain_test_schema.rb", + "line": 7, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "activesupport/lib/active_support/continuous_integration/group.rb", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actioncable/app/assets/javascripts/action_cable.js", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actioncable/app/assets/javascripts/actioncable.esm.js", + "line": 164, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actioncable/app/assets/javascripts/actioncable.js", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actioncable/app/javascript/action_cable/connection.js", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actionpack/lib/action_dispatch/http/param_builder.rb", + "line": 51, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actionpack/lib/action_dispatch/http/rack_cache.rb", + "line": 54, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actionpack/lib/action_dispatch/http/response.rb", + "line": 416, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activerecord/lib/active_record/connection_adapters/schema_cache.rb", + "line": 403, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activerecord/lib/arel/select_manager.rb", + "line": 63, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activestorage/app/models/active_storage/blob.rb", + "line": 318, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activestorage/lib/active_storage/fixture_set.rb", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activestorage/lib/active_storage/previewer.rb", + "line": 60, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activestorage/lib/active_storage/service.rb", + "line": 209, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activesupport/lib/active_support/testing/isolation.rb", + "line": 103, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "railties/lib/rails/api/task.rb", + "line": 165, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "railties/lib/rails/application/configuration.rb", + "line": 646, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "tools/cve_announcement.rb", + "line": 24, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-sink", + "path_suffix": "actionpack/lib/action_dispatch/middleware/session/abstract_store.rb", + "line": 59, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "actionpack/lib/action_dispatch/middleware/session/abstract_store.rb", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-error-fallthrough", + "path_suffix": "guides/w3c_validator.rb", + "line": 78, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "actioncable/lib/action_cable/connection/client_socket.rb", + "line": 127, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "actionpack/lib/action_dispatch/http/upload.rb", + "line": 69, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "actionpack/lib/action_dispatch/request/utils.rb", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "actionpack/lib/action_dispatch/testing/test_helpers/page_dump_helper.rb", + "line": 25, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "actionpack/lib/action_dispatch/testing/test_process.rb", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "actionview/lib/action_view/renderer/template_renderer.rb", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "actionview/lib/action_view/template/resolver.rb", + "line": 147, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "activestorage/lib/active_storage/analyzer.rb", + "line": 35, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "activestorage/lib/active_storage/attached/changes/create_one.rb", + "line": 152, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "activestorage/lib/active_storage/attached/changes/create_one.rb", + "line": 160, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "activestorage/lib/active_storage/previewer.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-resource-leak", + "path_suffix": "activesupport/lib/active_support/testing/stream.rb", + "line": 37, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actioncable/lib/action_cable/channel/test_case.rb", + "line": 211, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actioncable/lib/action_cable/connection/subscriptions.rb", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actioncable/lib/action_cable/connection/test_case.rb", + "line": 159, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actioncable/lib/action_cable/server/base.rb", + "line": 47, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actioncable/lib/action_cable/subscription_adapter/postgresql.rb", + "line": 21, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actioncable/lib/action_cable/subscription_adapter/postgresql.rb", + "line": 45, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actioncable/lib/action_cable/subscription_adapter/postgresql.rb", + "line": 97, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actioncable/lib/action_cable/subscription_adapter/postgresql.rb", + "line": 100, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionmailbox/lib/action_mailbox/router/route.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionmailer/lib/action_mailer/mail_delivery_job.rb", + "line": 15, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionmailer/lib/action_mailer/mail_delivery_job.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionmailer/lib/action_mailer/preview.rb", + "line": 61, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionmailer/lib/action_mailer/test_case.rb", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionpack/lib/abstract_controller/helpers.rb", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionpack/lib/action_controller/test_case.rb", + "line": 389, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionpack/lib/action_dispatch/http/rack_cache.rb", + "line": 23, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionpack/lib/action_dispatch/routing/inspector.rb", + "line": 80, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actiontext/lib/action_text/attachables/missing_attachable.rb", + "line": 33, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actiontext/lib/action_text/attribute.rb", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "actionview/lib/action_view/test_case.rb", + "line": 207, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activemodel/lib/active_model/errors.rb", + "line": 215, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activemodel/lib/active_model/errors.rb", + "line": 433, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activemodel/lib/active_model/errors.rb", + "line": 451, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/builder/belongs_to.rb", + "line": 40, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 52, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 53, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 54, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 57, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/collection_proxy.rb", + "line": 729, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/disable_joins_association_scope.rb", + "line": 26, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/has_many_through_association.rb", + "line": 145, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/join_dependency.rb", + "line": 218, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/associations/preloader/association.rb", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb", + "line": 95, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb", + "line": 100, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb", + "line": 106, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb", + "line": 112, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb", + "line": 1522, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb", + "line": 9, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb", + "line": 402, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb", + "line": 419, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb", + "line": 627, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/schema_cache.rb", + "line": 403, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb", + "line": 345, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb", + "line": 367, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb", + "line": 387, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/core.rb", + "line": 819, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/counter_cache.rb", + "line": 95, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/delegated_type.rb", + "line": 247, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/delegated_type.rb", + "line": 263, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/enum.rb", + "line": 316, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/enum.rb", + "line": 320, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/fixture_set/table_row.rb", + "line": 195, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/fixtures.rb", + "line": 801, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/internal_metadata.rb", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/internal_metadata.rb", + "line": 149, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/internal_metadata.rb", + "line": 157, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/internal_metadata.rb", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/internal_metadata.rb", + "line": 161, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/middleware/shard_selector.rb", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/querying.rb", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/querying.rb", + "line": 118, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/reflection.rb", + "line": 1296, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 283, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 303, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 495, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 505, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 509, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 1092, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 1139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 1152, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation.rb", + "line": 1479, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/batches.rb", + "line": 360, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/batches.rb", + "line": 381, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/batches.rb", + "line": 435, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/batches.rb", + "line": 449, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/batches.rb", + "line": 450, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/batches.rb", + "line": 462, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 236, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 305, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 339, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 362, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 390, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 552, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 554, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 574, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 682, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/calculations.rb", + "line": 686, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 112, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 118, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 161, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 379, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 529, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/finder_methods.rb", + "line": 644, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/query_methods.rb", + "line": 1757, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/query_methods.rb", + "line": 1758, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/query_methods.rb", + "line": 1761, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/relation/query_methods.rb", + "line": 2072, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/schema_migration.rb", + "line": 80, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/schema_migration.rb", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/schema_migration.rb", + "line": 96, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/statement_cache.rb", + "line": 156, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/tasks/abstract_tasks.rb", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/active_record/tasks/postgresql_database_tasks.rb", + "line": 94, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/arel/crud.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/arel/crud.rb", + "line": 28, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/arel/crud.rb", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/arel/crud.rb", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/arel/table.rb", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/arel/table.rb", + "line": 59, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/arel/table.rb", + "line": 63, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activerecord/lib/arel/table.rb", + "line": 79, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activestorage/lib/active_storage/attached/model.rb", + "line": 141, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activestorage/lib/active_storage/attached/model.rb", + "line": 255, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activesupport/lib/active_support/core_ext/file/atomic.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activesupport/lib/active_support/core_ext/string/output_safety.rb", + "line": 220, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activesupport/lib/active_support/deprecation/proxy_wrappers.rb", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activesupport/lib/active_support/testing/constant_lookup.rb", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activesupport/lib/active_support/testing/isolation.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "activesupport/lib/active_support/testing/stream.rb", + "line": 26, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/application.rb", + "line": 407, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/command/helpers/editor.rb", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/commands/credentials/credentials_command.rb", + "line": 66, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/commands/query/query_command.rb", + "line": 96, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/commands/query/query_command.rb", + "line": 105, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/commands/query/query_command.rb", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/commands/query/query_command.rb", + "line": 183, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/commands/runner/runner_command.rb", + "line": 38, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/commands/runner/runner_command.rb", + "line": 49, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/commands/unused_routes/unused_routes_command.rb", + "line": 17, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/configuration.rb", + "line": 138, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/generators/bundle_helper.rb", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "railties/lib/rails/generators/bundle_helper.rb", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "tools/cve_announcement.rb", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "js.prototype.proto_assignment", + "path_suffix": "guides/assets/javascripts/clipboard.js", + "line": 7, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "activejob/test/support/integration/adapters/queue_classic.rb", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "activejob/test/support/integration/adapters/queue_classic.rb", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "activerecord/test/cases/adapters/sqlite3/sqlite_rake_test.rb", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "activerecord/test/cases/adapters/sqlite3/sqlite_rake_test.rb", + "line": 159, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/active_storage/engine_integration_test.rb", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/bin_setup_test.rb", + "line": 23, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/bin_setup_test.rb", + "line": 37, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/console_test.rb", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/rake/migrations_test.rb", + "line": 298, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/rake/migrations_test.rb", + "line": 512, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/rake_test.rb", + "line": 180, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/rake_test.rb", + "line": 204, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/runner_test.rb", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/runner_test.rb", + "line": 91, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 411, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1069, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1078, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1087, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1095, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1101, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1112, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1129, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1142, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1150, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1157, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1168, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1313, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1331, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/application/test_runner_test.rb", + "line": 1352, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/commands/credentials_test.rb", + "line": 256, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/commands/db_system_change_test.rb", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/commands/runner_test.rb", + "line": 13, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/engine/commands_test.rb", + "line": 23, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/engine/test_test.rb", + "line": 21, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/engine/test_test.rb", + "line": 25, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 143, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 152, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 572, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 1281, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 1282, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 1289, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 1294, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 1297, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 1298, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 1305, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 1307, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 142, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 143, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 150, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 155, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 158, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 159, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 166, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 168, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 312, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 588, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 750, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 760, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 761, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 771, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 843, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 859, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 869, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 881, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_generator_test.rb", + "line": 897, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/plugin_test_runner_test.rb", + "line": 123, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_controller_generator_test.rb", + "line": 283, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_controller_generator_test.rb", + "line": 284, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_controller_generator_test.rb", + "line": 285, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_controller_generator_test.rb", + "line": 293, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_controller_generator_test.rb", + "line": 294, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_controller_generator_test.rb", + "line": 295, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 577, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 580, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 589, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 598, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 607, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 610, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 619, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 622, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 631, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 634, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 639, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 643, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 665, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 669, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/scaffold_generator_test.rb", + "line": 670, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/shared_generator_tests.rb", + "line": 396, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/generators/test_runner_in_engine_test.rb", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/isolation/abstract_unit.rb", + "line": 415, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/isolation/abstract_unit.rb", + "line": 628, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/railties/engine_test.rb", + "line": 65, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/railties/engine_test.rb", + "line": 99, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/railties/engine_test.rb", + "line": 152, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/railties/engine_test.rb", + "line": 273, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/railties/engine_test.rb", + "line": 1942, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/railties/generators_test.rb", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.backtick", + "path_suffix": "railties/test/railties/generators_test.rb", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "actioncable/test/javascript_package_test.rb", + "line": 16, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "actionpack/test/journey/gtg/transition_table_test.rb", + "line": 24, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "actiontext/test/javascript_package_test.rb", + "line": 15, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 1099, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 1114, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 1289, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activestorage/test/analyzer/video_analyzer_test.rb", + "line": 10, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activestorage/test/javascript_package_test.rb", + "line": 15, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activestorage/test/previewer/mupdf_previewer_test.rb", + "line": 10, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activestorage/test/previewer/video_previewer_test.rb", + "line": 10, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activesupport/test/core_ext/class/attribute_test.rb", + "line": 181, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activesupport/test/core_ext/class/attribute_test.rb", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activesupport/test/core_ext/class/attribute_test.rb", + "line": 183, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activesupport/test/core_ext/class/attribute_test.rb", + "line": 186, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activesupport/test/core_ext/class/attribute_test.rb", + "line": 188, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activesupport/test/core_ext/class/attribute_test.rb", + "line": 189, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "activesupport/test/json/encoding_test.rb", + "line": 43, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/test/application/console_test.rb", + "line": 19, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/test/application/help_test.rb", + "line": 52, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/test/engine/commands_test.rb", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 135, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/test/generators/generators_test_helper.rb", + "line": 149, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/test/plugin_helpers.rb", + "line": 5, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.cmdi.system_interp", + "path_suffix": "railties/test/plugin_helpers.rb", + "line": 26, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/test/controller/filters_test.rb", + "line": 880, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/test/controller/integration_test.rb", + "line": 647, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionpack/test/support/rack_parsing_override.rb", + "line": 78, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/actionpack/abstract/layouts_test.rb", + "line": 444, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/actionpack/controller/view_paths_test.rb", + "line": 192, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/template/date_helper_test.rb", + "line": 11, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/template/form_helper/form_with_test.rb", + "line": 2213, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/template/form_helper_test.rb", + "line": 3974, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/template/sanitize_helper_test.rb", + "line": 151, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/template/test_case_test.rb", + "line": 34, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/template/test_case_test.rb", + "line": 123, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/template/test_case_test.rb", + "line": 163, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "actionview/test/template/test_case_test.rb", + "line": 287, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activejob/test/support/backburner/inline.rb", + "line": 5, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activemodel/test/cases/api_test.rb", + "line": 10, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activemodel/test/cases/attribute_methods_test.rb", + "line": 152, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activemodel/test/cases/model_test.rb", + "line": 10, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/adapters/abstract_mysql_adapter/active_schema_test.rb", + "line": 11, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/adapters/abstract_mysql_adapter/active_schema_test.rb", + "line": 276, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/adapters/abstract_mysql_adapter/active_schema_test.rb", + "line": 288, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/adapters/postgresql/active_schema_test.rb", + "line": 9, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/adapters/postgresql/active_schema_test.rb", + "line": 15, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/arel/helper.rb", + "line": 9, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/associations/has_many_associations_test.rb", + "line": 2532, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/associations/has_many_associations_test.rb", + "line": 2545, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 694, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 707, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 721, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 755, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 1644, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/attribute_methods_test.rb", + "line": 1660, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/connection_pool_test.rb", + "line": 1564, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/enum_test.rb", + "line": 549, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/enum_test.rb", + "line": 573, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/fixture_set/file_test.rb", + "line": 83, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/fixture_set/file_test.rb", + "line": 94, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/fixtures_test.rb", + "line": 1631, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/habtm_destroy_order_test.rb", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/migration_test.rb", + "line": 480, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/migrator_test.rb", + "line": 31, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/migrator_test.rb", + "line": 42, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/multi_db_migrator_test.rb", + "line": 50, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/multi_db_migrator_test.rb", + "line": 63, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/relation/predicate_builder_test.rb", + "line": 15, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/relations_test.rb", + "line": 593, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/reload_models_test.rb", + "line": 20, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/scoping/named_scoping_test.rb", + "line": 52, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/scoping/named_scoping_test.rb", + "line": 180, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/scoping/named_scoping_test.rb", + "line": 195, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/scoping/named_scoping_test.rb", + "line": 380, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/scoping/named_scoping_test.rb", + "line": 385, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/scoping/named_scoping_test.rb", + "line": 393, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/scoping/named_scoping_test.rb", + "line": 398, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/scoping/named_scoping_test.rb", + "line": 620, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/secure_token_test.rb", + "line": 85, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 238, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 259, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 286, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 294, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 322, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 350, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 494, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 508, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 586, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/cases/transactions_test.rb", + "line": 601, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activerecord/test/models/contact.rb", + "line": 5, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activestorage/test/models/attached/many_test.rb", + "line": 742, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activestorage/test/models/attached/many_test.rb", + "line": 824, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activestorage/test/models/attached/many_test.rb", + "line": 835, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activestorage/test/models/attached/one_test.rb", + "line": 726, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activestorage/test/models/attached/one_test.rb", + "line": 778, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activestorage/test/models/attached/one_test.rb", + "line": 789, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/array_inquirer_test.rb", + "line": 45, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/array_inquirer_test.rb", + "line": 56, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 364, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 375, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 1077, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 1098, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 1138, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 1160, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 1169, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 1178, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/callbacks_test.rb", + "line": 1187, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/concern_test.rb", + "line": 105, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/concern_test.rb", + "line": 188, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/concern_test.rb", + "line": 189, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/concern_test.rb", + "line": 190, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/concern_test.rb", + "line": 191, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/concern_test.rb", + "line": 203, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/concern_test.rb", + "line": 205, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/core_ext/kernel_test.rb", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/core_ext/module/remove_method_test.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/core_ext/module/remove_method_test.rb", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/core_ext/module/remove_method_test.rb", + "line": 46, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/core_ext/module_test.rb", + "line": 349, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/deprecation_test.rb", + "line": 784, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/deprecation_test.rb", + "line": 807, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/deprecation_test.rb", + "line": 838, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/deprecation_test.rb", + "line": 845, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/inflector_test.rb", + "line": 507, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/string_inquirer_test.rb", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "activesupport/test/string_inquirer_test.rb", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/commands/console_test.rb", + "line": 129, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/commands/console_test.rb", + "line": 139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/commands/dbconsole_test.rb", + "line": 231, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/commands/dbconsole_test.rb", + "line": 241, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/generators/generators_test_helper.rb", + "line": 25, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/generators_test.rb", + "line": 223, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/rails_info_test.rb", + "line": 8, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/rails_info_test.rb", + "line": 16, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/rails_info_test.rb", + "line": 23, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.class_eval", + "path_suffix": "railties/test/rails_info_test.rb", + "line": 35, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionpack/test/controller/test_case_test.rb", + "line": 528, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 488, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 513, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 517, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 521, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 525, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 529, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 533, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 576, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 580, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 584, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 588, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 592, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 819, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 823, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 827, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 831, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 835, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 873, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 877, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 881, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 885, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 889, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 893, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 897, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 901, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 905, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 909, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 913, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 917, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 921, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 925, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 929, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 933, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 937, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 941, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 945, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 949, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activejob/test/support/queue_classic/inline.rb", + "line": 10, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activejob/test/support/queue_classic/inline.rb", + "line": 16, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activejob/test/support/queue_classic/inline.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activerecord/test/cases/tasks/database_tasks_test.rb", + "line": 312, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activerecord/test/cases/tasks/database_tasks_test.rb", + "line": 791, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activerecord/test/cases/tasks/database_tasks_test.rb", + "line": 1266, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activerecord/test/cases/tasks/database_tasks_test.rb", + "line": 1483, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activerecord/test/cases/tasks/database_tasks_test.rb", + "line": 1516, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activerecord/test/cases/tasks/database_tasks_test.rb", + "line": 1658, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activerecord/test/cases/tasks/database_tasks_test.rb", + "line": 1675, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "activesupport/test/descendants_tracker_test.rb", + "line": 13, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.eval", + "path_suffix": "railties/test/application/zeitwerk_integration_test.rb", + "line": 314, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionpack/test/controller/mime/respond_to_test.rb", + "line": 676, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actiontext/test/unit/attachment_test.rb", + "line": 13, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/actionpack/abstract/layouts_test.rb", + "line": 550, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 1247, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 1258, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/template/asset_tag_helper_test.rb", + "line": 1269, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/template/javascript_helper_test.rb", + "line": 84, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/template/javascript_helper_test.rb", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/template/javascript_helper_test.rb", + "line": 98, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/template/log_subscriber_test.rb", + "line": 43, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "actionview/test/template/structured_event_subscriber_test.rb", + "line": 33, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activemodel/test/cases/attribute_assignment_test.rb", + "line": 155, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activemodel/test/cases/attribute_registration_test.rb", + "line": 250, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/aggregations_test.rb", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/aggregations_test.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/associations/belongs_to_associations_test.rb", + "line": 277, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/associations/belongs_to_associations_test.rb", + "line": 292, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/associations/has_many_associations_test.rb", + "line": 1941, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/core_test.rb", + "line": 88, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/core_test.rb", + "line": 229, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/invertible_migration_test.rb", + "line": 461, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/migration/command_recorder_test.rb", + "line": 73, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/persistence_test.rb", + "line": 1179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activerecord/test/cases/persistence_test.rb", + "line": 1317, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/test/core_ext/module/attribute_accessor_test.rb", + "line": 27, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "activesupport/test/xml_mini_test.rb", + "line": 85, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "railties/test/rails_health_controller_test.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.code_exec.instance_eval", + "path_suffix": "railties/test/rails_health_controller_test.rb", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "actionpack/test/controller/flash_hash_test.rb", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "actiontext/test/unit/content_test.rb", + "line": 15, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "actionview/test/template/template_test.rb", + "line": 363, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activejob/test/support/integration/test_case_helpers.rb", + "line": 52, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activemodel/test/cases/attribute_set_test.rb", + "line": 247, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activemodel/test/cases/attributes_test.rb", + "line": 139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activemodel/test/cases/errors_test.rb", + "line": 845, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activemodel/test/cases/type/integer_test.rb", + "line": 170, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/associations/extension_test.rb", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/associations/extension_test.rb", + "line": 64, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb", + "line": 148, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/associations/has_many_through_associations_test.rb", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/associations/has_one_associations_test.rb", + "line": 84, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/associations/has_one_associations_test.rb", + "line": 89, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/attribute_methods/time_zone_converter_test.rb", + "line": 14, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/base_test.rb", + "line": 1523, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/base_test.rb", + "line": 1533, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/base_test.rb", + "line": 1540, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/base_test.rb", + "line": 1550, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/base_test.rb", + "line": 1563, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/base_test.rb", + "line": 1597, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/base_test.rb", + "line": 1608, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/connection_adapters/connection_handler_test.rb", + "line": 357, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/connection_adapters/connection_handler_test.rb", + "line": 382, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/connection_adapters/connection_handler_test.rb", + "line": 419, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/connection_adapters/connection_handler_test.rb", + "line": 450, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/connection_adapters/schema_cache_test.rb", + "line": 472, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/encryption/encryptable_record_test.rb", + "line": 361, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/filter_attributes_test.rb", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/marshal_serialization_test.rb", + "line": 19, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/marshal_serialization_test.rb", + "line": 28, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/marshal_serialization_test.rb", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/marshal_serialization_test.rb", + "line": 48, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/marshal_serialization_test.rb", + "line": 62, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/marshal_serialization_test.rb", + "line": 85, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activerecord/test/cases/query_cache_test.rb", + "line": 263, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/cache/behaviors/cache_store_coder_behavior.rb", + "line": 19, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/cache/cache_coder_test.rb", + "line": 147, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/core_ext/module_test.rb", + "line": 510, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/core_ext/time_ext_test.rb", + "line": 1384, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/core_ext/time_ext_test.rb", + "line": 1391, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/core_ext/time_ext_test.rb", + "line": 1398, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/core_ext/time_ext_test.rb", + "line": 1405, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/core_ext/time_ext_test.rb", + "line": 1412, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/core_ext/time_with_zone_test.rb", + "line": 593, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.marshal_load", + "path_suffix": "activesupport/test/core_ext/time_with_zone_test.rb", + "line": 605, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activerecord/test/cases/arel/nodes/sql_literal_test.rb", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/encrypted_configuration_test.rb", + "line": 72, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/ordered_hash_test.rb", + "line": 252, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/ordered_hash_test.rb", + "line": 276, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/ordered_hash_test.rb", + "line": 285, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/ordered_hash_test.rb", + "line": 292, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/safe_buffer_test.rb", + "line": 57, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/safe_buffer_test.rb", + "line": 64, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/safe_buffer_test.rb", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/safe_buffer_test.rb", + "line": 69, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/safe_buffer_test.rb", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "activesupport/test/safe_buffer_test.rb", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "railties/test/commands/devcontainer_test.rb", + "line": 88, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 684, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "railties/test/generators/app_generator_test.rb", + "line": 689, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "railties/test/generators/generators_test_helper.rb", + "line": 128, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.deser.yaml_load", + "path_suffix": "railties/test/generators/model_generator_test.rb", + "line": 606, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actioncable/lib/action_cable/channel/test_case.rb", + "line": 211, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actioncable/lib/action_cable/connection/subscriptions.rb", + "line": 39, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actioncable/lib/action_cable/connection/test_case.rb", + "line": 159, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actioncable/lib/action_cable/engine.rb", + "line": 60, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actioncable/lib/action_cable/server/configuration.rb", + "line": 66, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailbox/lib/action_mailbox/router/route.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailer/lib/action_mailer/base.rb", + "line": 562, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailer/lib/action_mailer/mail_delivery_job.rb", + "line": 15, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailer/lib/action_mailer/mail_delivery_job.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailer/lib/action_mailer/mail_delivery_job.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailer/lib/action_mailer/mail_delivery_job.rb", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailer/lib/action_mailer/preview.rb", + "line": 61, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailer/lib/action_mailer/railtie.rb", + "line": 50, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionmailer/lib/action_mailer/test_case.rb", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionpack/lib/abstract_controller/helpers.rb", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionpack/lib/action_controller/metal/params_wrapper.rb", + "line": 170, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionpack/lib/action_controller/test_case.rb", + "line": 389, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionpack/lib/action_dispatch/http/request.rb", + "line": 99, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionpack/lib/action_dispatch/middleware/actionable_exceptions.rb", + "line": 20, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionpack/lib/action_dispatch/middleware/session/abstract_store.rb", + "line": 59, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionpack/lib/action_dispatch/routing/inspector.rb", + "line": 80, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actiontext/lib/action_text/attachables/missing_attachable.rb", + "line": 33, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionview/lib/action_view/helpers/form_helper.rb", + "line": 1626, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "actionview/lib/action_view/test_case.rb", + "line": 207, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activejob/lib/active_job/core.rb", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activejob/lib/active_job/queue_adapters/delayed_job_adapter.rb", + "line": 59, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activejob/lib/active_job/serializers.rb", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activejob/lib/active_job/serializers/module_serializer.rb", + "line": 12, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/aggregations.rb", + "line": 254, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/aggregations.rb", + "line": 263, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/associations/association.rb", + "line": 341, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/associations/builder/belongs_to.rb", + "line": 40, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/core.rb", + "line": 28, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/delegated_type.rb", + "line": 247, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/destroy_association_async_job.rb", + "line": 20, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/destroy_association_async_job.rb", + "line": 21, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/fixture_set/table_row.rb", + "line": 97, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/fixtures.rb", + "line": 758, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/fixtures.rb", + "line": 801, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/inheritance.rb", + "line": 197, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/inheritance.rb", + "line": 221, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/inheritance.rb", + "line": 260, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/inheritance.rb", + "line": 263, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/inheritance.rb", + "line": 273, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/middleware/shard_selector.rb", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/migration.rb", + "line": 1225, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/reflection.rb", + "line": 439, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activerecord/lib/active_record/tasks/database_tasks.rb", + "line": 610, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activestorage/app/controllers/active_storage/base_controller.rb", + "line": 4, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/core_ext/module/introspection.rb", + "line": 38, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/core_ext/module/introspection.rb", + "line": 58, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/core_ext/string/inflections.rb", + "line": 74, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/core_ext/string/inflections.rb", + "line": 87, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/delegation.rb", + "line": 62, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/deprecation/constant_accessor.rb", + "line": 14, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/deprecation/proxy_wrappers.rb", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/inflector/methods.rb", + "line": 316, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/rescuable.rb", + "line": 156, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "activesupport/lib/active_support/testing/constant_lookup.rb", + "line": 41, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "railties/lib/rails/commands/unused_routes/unused_routes_command.rb", + "line": 17, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.constantize", + "path_suffix": "railties/lib/rails/generators/resource_helpers.rb", + "line": 69, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "actionpack/test/controller/request_forgery_protection_test.rb", + "line": 1501, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "actiontext/test/dummy/app/controllers/admin/messages_controller.rb", + "line": 3, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.auth.missing_ownership_check", + "path_suffix": "activejob/test/support/integration/test_case_helpers.rb", + "line": 52, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actioncable/lib/action_cable/channel/base.rb", + "line": 281, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actioncable/lib/action_cable/connection/tagged_logger_proxy.rb", + "line": 43, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actioncable/lib/action_cable/engine.rb", + "line": 63, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actioncable/lib/action_cable/server/worker.rb", + "line": 60, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionmailer/lib/action_mailer/collector.rb", + "line": 21, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionmailer/lib/action_mailer/mail_delivery_job.rb", + "line": 28, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionmailer/lib/action_mailer/railtie.rb", + "line": 64, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/metal/allow_browser.rb", + "line": 68, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/metal/mime_responds.rb", + "line": 264, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/metal/rate_limiting.rb", + "line": 91, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/metal/rate_limiting.rb", + "line": 92, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/metal/rate_limiting.rb", + "line": 93, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/metal/rate_limiting.rb", + "line": 107, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/metal/renderers.rb", + "line": 163, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/metal/strong_parameters.rb", + "line": 1192, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_controller/railtie.rb", + "line": 101, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/journey/route.rb", + "line": 175, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/journey/route.rb", + "line": 177, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/journey/route.rb", + "line": 179, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/journey/route.rb", + "line": 181, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/journey/route.rb", + "line": 183, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/journey/visitors.rb", + "line": 65, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/journey/visitors.rb", + "line": 105, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/request/session.rb", + "line": 67, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/request/session.rb", + "line": 104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/routing/mapper.rb", + "line": 1034, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/routing/mapper.rb", + "line": 2182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionpack/lib/action_dispatch/routing/routes_proxy.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actiontext/lib/action_text/encryption.rb", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actiontext/lib/action_text/markdown_conversion.rb", + "line": 104, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actiontext/lib/action_text/plain_text_conversion.rb", + "line": 18, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionview/lib/action_view/helpers/date_helper.rb", + "line": 897, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "actionview/lib/action_view/railtie.rb", + "line": 90, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activejob/lib/active_job/railtie.rb", + "line": 60, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activejob/lib/active_job/railtie.rb", + "line": 77, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activejob/lib/active_job/railtie.rb", + "line": 79, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/attribute_methods.rb", + "line": 336, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/attribute_methods.rb", + "line": 548, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/attribute_mutation_tracker.rb", + "line": 141, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/attribute_registration.rb", + "line": 88, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/attributes/normalization.rb", + "line": 72, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/callbacks.rb", + "line": 123, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/schematized_json.rb", + "line": 45, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/schematized_json.rb", + "line": 64, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/serialization.rb", + "line": 138, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/serialization.rb", + "line": 168, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/serialization.rb", + "line": 194, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/validations.rb", + "line": 454, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/validations/acceptance.rb", + "line": 44, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/validations/resolve_value.rb", + "line": 15, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/validations/with.rb", + "line": 12, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activemodel/lib/active_model/validations/with.rb", + "line": 14, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record.rb", + "line": 505, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/aggregations.rb", + "line": 254, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/aggregations.rb", + "line": 266, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/aggregations.rb", + "line": 280, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/association.rb", + "line": 223, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/belongs_to_association.rb", + "line": 170, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/belongs_to_association.rb", + "line": 174, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/builder/belongs_to.rb", + "line": 86, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/builder/collection_association.rb", + "line": 47, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/builder/collection_association.rb", + "line": 51, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/builder/collection_association.rb", + "line": 54, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/builder/has_one.rb", + "line": 38, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/collection_association.rb", + "line": 501, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/has_many_through_association.rb", + "line": 64, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/associations/has_many_through_association.rb", + "line": 107, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/attribute_assignment.rb", + "line": 50, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/autosave_association.rb", + "line": 145, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/autosave_association.rb", + "line": 230, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb", + "line": 13, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb", + "line": 139, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb", + "line": 1660, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb", + "line": 1668, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb", + "line": 76, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb", + "line": 82, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/dynamic_matchers.rb", + "line": 22, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/encryption/configurable.rb", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/encryption/configurable.rb", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/encryption/contexts.rb", + "line": 37, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/encryption/encryptable_record.rb", + "line": 113, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/encryption/encryptable_record.rb", + "line": 120, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/enum.rb", + "line": 305, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/enum.rb", + "line": 309, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/enum.rb", + "line": 315, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/enum.rb", + "line": 319, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/integration.rb", + "line": 153, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/integration.rb", + "line": 164, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/migration.rb", + "line": 744, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/migration.rb", + "line": 1079, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/migration.rb", + "line": 1222, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/migration/command_recorder.rb", + "line": 125, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/migration/command_recorder.rb", + "line": 153, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/migration/default_strategy.rb", + "line": 55, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/nested_attributes.rb", + "line": 436, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/nested_attributes.rb", + "line": 454, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/nested_attributes.rb", + "line": 563, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/nested_attributes.rb", + "line": 605, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/nested_attributes.rb", + "line": 605, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/railtie.rb", + "line": 228, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/railtie.rb", + "line": 263, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/railtie.rb", + "line": 265, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/reflection.rb", + "line": 221, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/reflection.rb", + "line": 500, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/relation/query_methods.rb", + "line": 50, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/scoping/named.rb", + "line": 186, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/active_record/secure_token.rb", + "line": 75, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/arel/predications.rb", + "line": 241, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/arel/predications.rb", + "line": 246, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/arel/visitors/dot.rb", + "line": 243, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/arel/visitors/visitor.rb", + "line": 30, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activerecord/lib/arel/visitors/visitor.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activestorage/app/models/active_storage/named_variant.rb", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/broadcast_logger.rb", + "line": 218, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/broadcast_logger.rb", + "line": 228, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/broadcast_logger.rb", + "line": 230, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/cache/mem_cache_store.rb", + "line": 214, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/cache/redis_cache_store.rb", + "line": 170, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/callbacks.rb", + "line": 130, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/callbacks.rb", + "line": 182, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/callbacks.rb", + "line": 385, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/callbacks.rb", + "line": 391, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/class_attribute.rb", + "line": 29, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/configurable.rb", + "line": 160, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/core_ext/module/redefine_method.rb", + "line": 21, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/subscriber.rb", + "line": 138, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/testing/constant_stubbing.rb", + "line": 32, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/testing/constant_stubbing.rb", + "line": 36, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "activesupport/lib/active_support/testing/constant_stubbing.rb", + "line": 48, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "railties/lib/rails/application/dummy_config.rb", + "line": 14, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "railties/lib/rails/code_statistics.rb", + "line": 160, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "railties/lib/rails/generators/app_base.rb", + "line": 345, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rb.reflection.send_dynamic", + "path_suffix": "railties/lib/rails/rack/logger.rb", + "line": 70, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "actionview/test/template/url_helper_test.rb", + "line": 1118, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "actionpack/test/controller/redirect_test.rb", + "line": 117, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-open-redirect", + "path_suffix": "actionpack/test/dispatch/routing_test.rb", + "line": 266, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 3, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionpack/test/fixtures/公共/gzip/application-a71b3024f80aea3181c09774ca17e712.js", + "line": 3, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "activerecord/test/cases/fixture_set/file_test.rb", + "line": 157, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-double-close", + "path_suffix": "activesupport/test/logger_test.rb", + "line": 78, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "actionview/test/active_record_unit.rb", + "line": 23, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "guides/assets/javascripts/@hotwired--turbo.js", + "line": 2252, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actioncable/test/connection/stream_test.rb", + "line": 41, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actionpack/test/controller/test_case_test.rb", + "line": 917, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "actionpack/test/dispatch/uploaded_file_test.rb", + "line": 16, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb", + "line": 953, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activerecord/test/cases/arel/nodes/select_statement_test.rb", + "line": 21, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activerecord/test/cases/connection_pool_test.rb", + "line": 1119, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activerecord/test/cases/tasks/database_tasks_test.rb", + "line": 1057, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activestorage/test/models/attached/one_test.rb", + "line": 98, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "activestorage/test/models/blob_test.rb", + "line": 218, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "railties/test/application/dbconsole_test.rb", + "line": 36, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unreachable-sink", + "path_suffix": "railties/test/generators/generators_test_helper.rb", + "line": 136, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/recall_targets/xlang/rust/axum.json b/tests/recall_targets/xlang/rust/axum.json new file mode 100644 index 00000000..60b1e9d9 --- /dev/null +++ b/tests/recall_targets/xlang/rust/axum.json @@ -0,0 +1,76 @@ +{ + "_doc": "Phase 17 cross-lang recall-validation baseline for tokio-rs/axum (Rust). Re-capture by running scripts/validate_recall.sh --lang rust axum --capture. Placeholder: pitboss implementer agents run sandboxed without network egress; this clone was not available locally during phase 17 capture.", + "target": "axum", + "lang": "rust", + "clone_url": "https://github.com/tokio-rs/axum", + "exercises_recall_items": [], + "captured_against": "real-scan @ c853e44ffce77b771069b6df6bd5adc1b78f1b2f", + "captured_on": "2026-05-10", + "pinned_commit": "c853e44ffce77b771069b6df6bd5adc1b78f1b2f", + "findings": [ + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "examples/form/src/main.rs", + "line": 71, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "taint-unsanitised-flow", + "path_suffix": "examples/stream-to-file/src/main.rs", + "line": 96, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rs.auth.missing_ownership_check", + "path_suffix": "examples/dependency-injection/src/main.rs", + "line": 114, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rs.auth.missing_ownership_check", + "path_suffix": "examples/dependency-injection/src/main.rs", + "line": 144, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rs.auth.missing_ownership_check", + "path_suffix": "examples/mongodb/src/main.rs", + "line": 84, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "rs.auth.missing_ownership_check", + "path_suffix": "examples/mongodb/src/main.rs", + "line": 110, + "severity": "Medium", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "state-resource-leak", + "path_suffix": "examples/websockets-http2/assets/script.js", + "line": 1, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + }, + { + "rule_id": "cfg-unguarded-sink", + "path_suffix": "examples/sqlx-postgres/src/main.rs", + "line": 67, + "severity": "Low", + "verdict": "needs_review", + "note": "captured by validate_recall.sh --capture" + } + ] +} diff --git a/tests/ssa_equivalence_tests.rs b/tests/ssa_equivalence_tests.rs index be234f06..88c2f9be 100644 --- a/tests/ssa_equivalence_tests.rs +++ b/tests/ssa_equivalence_tests.rs @@ -438,12 +438,12 @@ fn summary_extraction_is_deterministic() { let Ok(bytes) = std::fs::read(&fixture.source_path) else { continue; }; - let Ok((fn_a, ssa_a, _bodies_a, _auth_a)) = + let Ok((fn_a, ssa_a, _bodies_a, _auth_a, _cpi_a)) = extract_all_summaries_from_bytes(&bytes, &fixture.source_path, &cfg, None) else { continue; }; - let Ok((fn_b, ssa_b, _bodies_b, _auth_b)) = + let Ok((fn_b, ssa_b, _bodies_b, _auth_b, _cpi_b)) = extract_all_summaries_from_bytes(&bytes, &fixture.source_path, &cfg, None) else { continue; @@ -707,6 +707,69 @@ fn build_and_lower_all(path: &Path, cfg: &Config) -> usize { n } +// ── Phase 12: Rust `await_expression` Assign-op count ─────────────────── +// +// Each Rust `await_expression` node carries `is_await_forward = true` +// (set via the new `Kind::AwaitForward` mapping in `src/labels/rust.rs`). +// The SSA lowering must emit at most one `SsaOp::Assign` per such CFG +// node — duplicating the emission would inflate the body's value count +// and propagate the awaited taint twice. Use the new +// `tests/fixtures/realistic/async_await/await_count.rs` fixture which +// places three `await_expression` nodes in distinct positions. +#[test] +fn await_emits_at_most_one_assign_per_node() { + use nyx_scanner::ssa::SsaOp; + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/realistic/async_await/await_count.rs"); + let cfg = test_config(AnalysisMode::Full); + let (file_cfg, _) = build_cfg_for_file(&fixture, &cfg) + .expect("parse fixture") + .expect("non-empty bodies"); + + let mut total_await_nodes = 0usize; + for body in &file_cfg.bodies { + let graph = &body.graph; + + // Collect CFG node indices whose NodeInfo has `is_await_forward`. + let await_nodes: Vec<_> = graph + .node_indices() + .filter(|n| graph[*n].is_await_forward) + .collect(); + total_await_nodes += await_nodes.len(); + + let ssa = match lower_to_ssa(graph, body.entry, None, true) { + Ok(s) => s, + Err(_) => continue, + }; + + // Count Assign ops attributed to each await CFG node. + for cfg_node in &await_nodes { + let assign_count: usize = ssa + .blocks + .iter() + .flat_map(|b| b.body.iter()) + .filter(|inst| inst.cfg_node == *cfg_node && matches!(inst.op, SsaOp::Assign(_))) + .count(); + assert!( + assign_count <= 1, + "await_expression CFG node {:?} lowered to {} Assign ops (expected <= 1) in body {:?}", + cfg_node, + assign_count, + body.meta.name.as_deref().unwrap_or(""), + ); + } + } + // Sanity guard: the fixture is hand-crafted to put `await_expression` + // nodes in three positions (let-binding, statement, implicit return). + // If the Rust KINDS-map entry regresses or the per-node `is_await_forward` + // dispatch breaks, this count drops to zero and the count-cap above + // becomes vacuous. Pin a lower bound so the regression surfaces here. + assert!( + total_await_nodes >= 1, + "expected at least one await_expression CFG node across all bodies, got 0 — fixture or mapping regressed" + ); +} + // ── Catch-block orphan invariant ──────────────────────────────────────── // // Construct a synthetic SsaBody where a block carries `SsaOp::CatchParam` @@ -771,6 +834,7 @@ fn orphan_catch_block_triggers_reachability_invariant() { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; let err = check_catch_block_reachability(&body) @@ -834,6 +898,7 @@ fn normally_reachable_catch_block_passes_invariant() { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; assert!(check_catch_block_reachability(&body).is_ok()); @@ -889,6 +954,7 @@ fn exception_edge_catch_block_passes_invariant() { field_writes: std::collections::HashMap::new(), synthetic_externals: std::collections::HashSet::new(), + slot_scoped_assigns: std::collections::HashSet::new(), }; assert!(check_catch_block_reachability(&body).is_ok()); diff --git a/tests/typed_callgraph_audit.rs b/tests/typed_callgraph_audit.rs index 34a604ae..c8f44bf3 100644 --- a/tests/typed_callgraph_audit.rs +++ b/tests/typed_callgraph_audit.rs @@ -49,7 +49,7 @@ fn pipeline_global_summaries(files: &[File<'_>]) -> GlobalSummaries { let mut all_ssa: Vec<(FuncKey, SsaFuncSummary)> = Vec::new(); for f in files { let path = Path::new(f.namespace); - let (func, ssa, _bodies, _auth) = + let (func, ssa, _bodies, _auth, _cpi) = extract_all_summaries_from_bytes(f.bytes, path, &cfg, None) .expect("extract_all_summaries_from_bytes must succeed"); all_func.extend(func);