Prerelease cleanup (#46)

* feat: Add const_bound_vars tracking to prevent false positives in ownership checks

* feat: Introduce field interner and typed bounded vars for enhanced type tracking

* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking

* feat: Centralize method name extraction with bare_method_name helper

* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch

* feat: Enhance C++ taint tracking with additional container operations and inline method resolution

* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking

* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis

* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations

* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details

* test: Add comprehensive tests for lattice algebra laws and SSA edge cases

* feat: Add destructured session user handling and safe user ID access patterns

* feat: Implement row-population reverse-walk for enhanced authorization checks

* feat: Enhance authorization checks with local alias chain for self-actor types

* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction

* feat: Implement chained method call inner-gate rebinding for SSRF prevention

* feat: Add observability and error modules, enhance debug functionality, and implement theme context

* feat: Remove Auth Analysis page and update navigation to redirect to Explorer

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity

* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build

The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(closure-capture): flip JS/TS fixtures to required-finding

The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.

Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".

Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis

* feat: Introduce health module and enhance health score computation with calibration tests

* feat: Add expectations configuration and cleanup .gitignore for log files

* feat: Implement theme selection and enhance settings panel for triage sync

* feat: Suppress false positives for strcpy calls with literal sources in AST

* feat: Update analyse_function_ssa to return body CFG for accurate analysis

* feat: Add bug report and feature request templates for improved issue tracking

* feat: removed dev scripts

* feat: update README.md for clarity and consistency in fixture descriptions

* feat: removed dev docs

* feat: clean up error handling and UI elements for improved user experience

* feat: adjust button sizes in HeaderBar for better UI consistency

* feat: enhance taint analysis with additional context for sanitizer and taint findings

* cargo fmt

* prettier

* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts

* feat: add script to frame PNG screenshots with brand gradient

* feat: add fuzzing support with new targets and CI workflows

* refactor: streamline match expressions and improve formatting in CLI and output handling

* feat: enhance configuration display with detailed output options

* feat: stage demo configuration for improved CLI screenshot output

* feat: expose merge_configs function for user-configurable settings

* refactor: simplify code structure and improve readability in config handling

* refactor: improve descriptions for vulnerability patterns in various languages

* feat: update MIT License section with additional usage details and copyright information

* feat: update screenshots

* refactor: update build process and paths for frontend assets

* feat: add cross-file taint fuzzing target and supporting dictionary

* refactor: clean up formatting and comments in fuzz configuration and example files

* refactor: remove outdated comments and clean up CI configuration files

* chore: update changelog dates and improve formatting in documentation

* refactor: update Cargo.toml and CI configuration for improved packaging and build process

* refactor: enhance quote-stripping logic to prevent panics and add regression tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eli Peter 2026-04-29 00:58:38 -04:00 committed by GitHub
parent 79c29b394d
commit 82f18184b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
348 changed files with 48731 additions and 2925 deletions

View file

@ -2,7 +2,7 @@
## Corpus philosophy
The benchmark corpus is a curated set of ~267 minimal synthetic files (8-20 lines each) across 10 languages: JavaScript, TypeScript, Python, Java, Go, PHP, Ruby, Rust, C, and C++. Each file contains exactly one vulnerability (positive case) or demonstrates a specific safe pattern (negative case). The corpus additionally carries a small set of real-CVE replay cases (see `cve_corpus/` and the "Real-CVE Corpus" section in `RESULTS.md`).
The benchmark corpus is a curated set of ~430 minimal synthetic files (8-20 lines each) across 10 languages: JavaScript, TypeScript, Python, Java, Go, PHP, Ruby, Rust, C, and C++. Each file contains exactly one vulnerability (positive case) or demonstrates a specific safe pattern (negative case). The corpus additionally carries a small set of real-CVE replay cases (see `cve_corpus/` and the "Real CVE coverage" section in `RESULTS.md`).
Design principles:
- **One vuln per file**: isolates the detection signal from noise.
@ -35,8 +35,8 @@ When `expected_sink_lines` is present, checks that a matching finding falls with
## What metrics mean and don't mean
- **Precision** measures false positive rate how often a flagged file truly has a vulnerability.
- **Recall** measures detection rate how many real vulnerabilities the scanner catches.
- **Precision** measures false positive rate: how often a flagged file truly has a vulnerability.
- **Recall** measures detection rate: how many real vulnerabilities the scanner catches.
- **F1** is the harmonic mean, balancing precision and recall.
Caveats:
@ -48,7 +48,7 @@ Caveats:
## How to run
```bash
# Full benchmark (all 273 cases)
# Full benchmark (every case in ground_truth.json)
cargo test benchmark_evaluation -- --ignored --nocapture
# Filter by language (python, typescript, javascript, java, go, php, ruby, rust, c, cpp)
@ -80,7 +80,7 @@ NYX_BENCH_TAG=express cargo test benchmark_evaluation -- --ignored --nocapture
## How to fix a case
If a case outcome is unexpected:
1. Investigate the root cause is the scanner wrong, or is the ground truth wrong?
1. Investigate the root cause: is the scanner wrong, or is the ground truth wrong?
2. If the scanner is wrong, fix the scanner (not the ground truth).
3. If the ground truth is wrong (e.g., wrong expected_rule_ids), update it with justification.
4. Never auto-normalize ground truth to match scanner output.
@ -105,17 +105,18 @@ cargo test --release --all-features --test benchmark_test -- --ignored --nocaptu
and fails if the corpus rule-level metrics fall below the thresholds encoded
at the bottom of `tests/benchmark_test.rs`:
| Metric | Floor | Current baseline (273 cases) |
| Metric | Floor | Current baseline (~432 cases) |
|---|---|---|
| Precision | ≥ 0.861 | 0.942 |
| Recall | ≥ 0.944 | 0.994 |
| F1 | ≥ 0.901 | 0.967 |
| Precision | ≥ 0.861 | 0.991 |
| Recall | ≥ 0.944 | 0.995 |
| F1 | ≥ 0.901 | 0.993 |
The floors sit ~8 pp below the current 273-case baseline — a single-case
flip is ~0.6 pp on this corpus, so the headroom absorbs honest FP↔TN trades while
still tripping on a real regression in a whole vulnerability class. The `results/latest.json` artifact is
uploaded from the CI job for comparison across runs. The Rust job cache is
warm, so the gate typically adds only a few seconds on top of the build.
The floors sit roughly 8 pp below the current baseline. A single-case flip
is about 0.2 pp on this corpus, so the headroom absorbs honest FP/TN
trades while still tripping on a real regression in a whole vulnerability
class. The `results/latest.json` artifact is uploaded from the CI job for
comparison across runs. The Rust job cache is warm, so the gate typically
adds only a few seconds on top of the build.
Updating the thresholds is a deliberate change — raise them when you land a
Updating the thresholds is a deliberate change. Raise them when you land a
measurable, durable improvement; never relax them to paper over a regression.

View file

@ -1,552 +1,94 @@
# Nyx Benchmark Results
Current baseline (as of Auth Rule FP-Remediation Phase B5 corpus add, 2026-04-23):
| Metric | File-level | Rule-level | CI floor |
|-----------------------|------------|------------|----------|
| Precision | 0.946 | 0.946 | 0.861 |
| Recall | 1.000 | 0.994 | 0.944 |
| F1 | 0.972 | 0.970 | 0.901 |
Corpus: 305 cases across 10 languages — 267 synthetic + 28 real-CVE cases (14 vulnerable/patched pairs) + 10 auth-rule cases (3 positive + 7 negative). Scanner 0.5.0, full analysis mode. CI floors are unchanged from Phase CF-7; the Auth-B5 delta is within 1 pp and does not warrant tightening.
Machine-readable per-run data lives in `tests/benchmark/results/` (`latest.json` plus dated snapshots). This file is a narrative changelog — only the two most recent phases are kept in full detail; earlier phases are condensed into the history table at the end.
---
## Auth Rule FP Remediation — Phase B5 (2026-04-23)
### Motivation
Until B5, the `rs.auth.missing_ownership_check` rule (introduced as part of `auth_analysis`) was defended only by `cargo test --test auth_analysis_tests` integration assertions; it had **zero** entries in the benchmark corpus. So precision/recall regressions on auth fixtures wouldn't show up in the headline P/R/F1 numbers, and the Phase A1A3 / B1B4 fixture work in `tests/fixtures/auth_analysis/` was invisible to anyone reading `RESULTS.md`. This phase mirrors the relevant Rust auth fixtures into `tests/benchmark/corpus/rust/auth/`, adds ground-truth cases for each, and surfaces the resulting `auth` vuln-class metrics in the by-class breakdown.
### What changed
- **10 new fixtures** copied to `tests/benchmark/corpus/rust/auth/` (mirrors of the integration fixtures — keep both in sync when fixtures evolve).
- **Ground truth**: 10 new cases (`rs-auth-001``rs-auth-003` positive, `rs-auth-101``rs-auth-107` negative); `corpus_size` bumped 295 → 305.
- **Positive cases** assert `expected_rule_ids: ["rs.auth.missing_ownership_check"]` and `expected_sink_lines` pinned to the specific call line; one of them (`rs-auth-002`) is the Phase-A "true positive control" mandated by the FP-remediation plan.
- **Negative cases** assert `is_vulnerable: false` + `forbidden_rule_ids: ["rs.auth.missing_ownership_check"]` (the schema's noise-budget-zero shape), one per Phase A1/A2/A3/B2/B3/B4 fixture so each regression has a dedicated wire.
- **Regression thresholds unchanged**: floors stay at `P≥0.861 R≥0.944 F1≥0.901`.
### Auth Corpus
| Case ID | Fixture | Phase covered | Vulnerable | Why it's in the corpus |
|--------------|----------------------------------------------|---------------|------------|------------------------|
| rs-auth-001 | actix_scoped_write_missing.rs | regression | yes | Original positive baseline — must keep flagging |
| rs-auth-002 | true_positive_missing_check.rs | A control | yes | Phase A's positive control — must still flag after every FP fix |
| rs-auth-003 | row_ownership_no_early_exit.rs | A2 guard | yes | Equality without early exit — A2 must NOT silence this |
| rs-auth-101 | hashmap_local_noise.rs | A1 | no | std::collections noise — A1 receiver-type/var gate suppresses |
| rs-auth-102 | helper_scoped_params.rs | A1 | no | Library helper with locally-bound HashSet — A1 suppresses |
| rs-auth-103 | row_ownership_equality.rs | A2 | no | `if owner_id != user.id { return … }` covers downstream column reads |
| rs-auth-104 | self_scoped_user.rs | A3 | no | `let user = require_auth(..).await?``user.id` is self, not a foreign id |
| rs-auth-105 | db_connection_type_inferred.rs | B2 | no | SSA-derived `DatabaseConnection` type drives sink classification |
| rs-auth-106 | sql_join_acl.rs | B3 | no | `JOIN group_members WHERE gm.user_id = ?1` makes returned rows membership-gated |
| rs-auth-107 | transitive_helper.rs | B4 | no | Helper-summary lifting recognises `validate_target(group_id, user.id)` as an auth check |
### Delta
Aggregate rule-level metrics on the 305-case corpus: **P = 0.946, R = 0.994, F1 = 0.970** (vs Phase 15's `P = 0.945, R = 0.994, F1 = 0.969` on 295 cases — auth cases all hit P=1.0 R=1.0 and net to 3 TP + 7 TN, lifting precision by 1 pp via dilution). The new `by_vuln_class` row is `auth: TP=3 FP=0 FN=0 TN=0 P=1.000 R=1.000 F1=1.000`; the seven negatives roll into the existing `safe` class. The Rust per-language line moves from **TP=22 FP=0 FN=0 TN=13** (before) to **TP=25 FP=0 FN=0 TN=20** (after).
### Notes
- Fixture mirroring is an explicit choice over a symlink: `scan_corpus_file` copies the case into a tempdir before scanning, and absolute symlinks would break that path. When the integration fixture changes, copy the new file into the corpus mirror as well.
- The negative cases are the regression wires for the FP-remediation work. Each one corresponds to a phase landed in the project memory tracker; if a future change reintroduces the FP, the matching `rs-auth-1xx` case flips to FP and the Rust precision drops below the floor.
- `actix_scoped_write_missing.rs` is the only auth fixture that overlaps the "generic_ownership_check_is_consistent_across_languages" integration test — keep both wires alive (the integration test exercises the multi-language consistency, the bench wire defends the precision number).
---
## Phase 15 — Real-CVE language-gap expansion (2026-04-23)
### Motivation
Phase 13 and Phase 14 covered 9 CVEs across Python, JavaScript, TypeScript, Go, Java, Ruby, and PHP — every Stable and Beta tier language. The Preview-tier languages (C, C++) had zero real-CVE coverage, so the memory-safety and command-injection pattern rules for those languages were defended only by synthetic micro-fixtures. Phase 15 fills that gap with 4 Preview-tier CVEs (2 C, 2 C++) and adds a second Java CVE in the Runtime.exec class (to complement the existing Commons Collections deserialization case). Rust was considered but dropped: its code-quality pattern rules (`rs.memory.*`, `rs.quality.*`) are not CVE-class, and the taint-flow sink set (sqlx / rusqlite / diesel / reqwest / `std::process::Command`) did not yield a permissive-licensed, well-documented CVE reducible to ~30 LOC with a clean patched variant. Go was considered for a second CVE but dropped: idiomatic prepared statements make Go SQLi CVEs rare, `gob` decoding is niche, and published `InsecureSkipVerify` CVEs mostly describe receiver-side TLS bypass rather than the client-side pattern the rule detects.
### What changed
- **Five additional CVE pairs** added to `tests/benchmark/cve_corpus/` (10 fixture files, vulnerable + patched per CVE). Same header convention, same minimal-reproducer discipline, same `provenance: "real_cve"` marker. First entries ever for `cve_corpus/c/` and `cve_corpus/cpp/`.
- **Ground truth**: 10 new cases; `corpus_size` bumped 285 → 295. Vulnerable fixtures assert on an `expected_rule_ids` entry (the pattern-rule that fires on the disclosed sink) plus `taint-unsanitised-flow` as an acceptable alternative. Patched fixtures assert on `forbidden_rule_ids` (the CVE's class-specific rule plus the cross-cutting taint ID) so Nyx does not refire on the fix.
- **No harness changes**: the `cve_corpus/` path resolution and `real_cve` provenance scaffolding landed in Phase 13; Phase 15 is pure fixture + ground-truth expansion.
- **Regression thresholds unchanged**: floors stay at `P≥0.861 R≥0.944 F1≥0.901`.
### Real-CVE Corpus
| CVE | Language | Project | License | Vuln class | Vulnerable outcome | Patched outcome |
|------------------|------------|------------------------------|----------------------|------------------|--------------------|-----------------|
| CVE-2023-48022 | Python | Ray | Apache-2.0 | CMDI | TP (rule + line) | TN |
| CVE-2017-18342 | Python | PyYAML | MIT | Deserialization | TP (rule + line) | TN |
| CVE-2019-14939 | JavaScript | mongo-express | MIT | code_exec | TP (rule + line) | TN |
| CVE-2023-26159 | TypeScript | follow-redirects | MIT | SSRF | TP (rule + line) | TN |
| CVE-2022-30323 | Go | hashicorp/go-getter | MPL-2.0 | CMDI | TP (rule + line) | TN |
| CVE-2015-7501 | Java | Apache Commons Collections | Apache-2.0 | Deserialization | TP (rule + line) | TN |
| CVE-2013-0156 | Ruby | Ruby on Rails | MIT | Deserialization | TP (rule) | TN |
| CVE-2017-9841 | PHP | PHPUnit | BSD-3-Clause | code_exec | TP (rule + line) | TN |
| CVE-2018-15133 | PHP | Laravel | MIT | Deserialization | TP (rule + line) | TN |
| CVE-2016-3714 | C | ImageMagick (ImageTragick) | ImageMagick License | CMDI | TP (rule + line) | TN |
| CVE-2019-18634 | C | sudo (pwfeedback) | ISC | memory_safety | TP (rule + line) | TN |
| CVE-2019-13132 | C++ | ZeroMQ libzmq | MPL-2.0 | memory_safety | TP (rule + line) | TN |
| CVE-2022-1941 | C++ | Protocol Buffers | BSD-3-Clause | memory_safety | TP (rule + line) | TN |
| CVE-2017-12629 | Java | Apache Solr | Apache-2.0 | CMDI | TP (rule + line) | TN |
New-in-Phase-15 detail:
- **CVE-2016-3714** (ImageMagick "ImageTragick" delegate RCE). Vulnerable fixture: user-controlled filename is substituted into a shell template and handed to `system()` — Nyx fires `c.cmdi.system` at the documented sink line. Patched fixture: in-process coder + basename check, no `system()` path — zero findings.
- **CVE-2019-18634** (sudo pwfeedback stack overflow). Vulnerable fixture: stdin-sourced token `strcpy`'d into a fixed on-stack feedback buffer — Nyx fires `c.memory.strcpy`. Patched fixture: a bounded `copy_bounded` helper replaces the unchecked copy — zero findings.
- **CVE-2019-13132** (ZeroMQ libzmq V2 metadata overflow). Vulnerable fixture: peer-controlled bytes `strcpy`'d into a fixed on-stack identity buffer, mirroring the ZMTP v2 decode path — Nyx fires `cpp.memory.strcpy`. Patched fixture: bounded `std::string.assign` + hard length cap — zero findings.
- **CVE-2022-1941** (Protocol Buffers C++ `ParseContext` unknown-field overflow). Vulnerable fixture: wire-declared length trusted and `strcpy`'d into a scratch buffer — Nyx fires `cpp.memory.strcpy`. Patched fixture: bounded `std::string.assign` + `MAX_LABEL` cap — zero findings.
- **CVE-2017-12629** (Apache Solr XSLT response writer RCE). Vulnerable fixture: `req.getParameter("tr") → Runtime.getRuntime().exec(new String[]{"/bin/sh","-c","xsltproc "+tr})` — Nyx fires `java.cmdi.runtime_exec` and `taint-unsanitised-flow` (source line 29 → sink line 33). Patched fixture: fixed allowlist of transformer names mapped to classpath resources, no `Runtime.exec` path — zero findings.
Per-CVE precision/recall: each vulnerable case contributes 1 TP (Java CVE-2017-12629 contributes 2 — pattern-rule + taint edge) and its patched sibling 1 TN, so per-CVE precision and recall are both 1.000 at the rule level.
### Delta
Aggregate rule-level F1 on the 295-case corpus is **0.969** (P=**0.945**, R=**0.994**), a +0.001 delta vs the Phase 14 baseline (F1=0.968, P=0.944, R=0.994). File-level F1 **0.972** (P=0.945, R=1.000). The precision win is the ten new cases contributing 10 TP + 5 TN with no spurious firings on the fixes, diluting the existing FP rate slightly.
### Notes on selection
Phase 15 followed the same criteria as Phase 13/14: publicly disclosed CVE with a stable NVD advisory URL, vulnerability class already covered by a Nyx pattern rule so the vulnerable fixture produces a concrete `expected_rule_ids` hit (not just a generic `taint-unsanitised-flow`), extractable to ~30 LOC, permissive upstream license. The C/C++ picks target the two most abundant CVE classes for those languages — unchecked-copy memory-safety bugs (`strcpy` / `sprintf`-family) and shell-injection command-execution (`system()`-family). Each picked CVE is a well-known, historically damaging bug: ImageTragick mass-exploited image-upload endpoints in 2016, sudo pwfeedback gave any local user root on default-configured Linux distros in 2019, libzmq CVE-2019-13132 was pre-auth RCE on curve-disabled sockets, protobuf CVE-2022-1941 exposed every gRPC or Envoy binary decoding untrusted bytes, and Solr CVE-2017-12629 was a flagship unauthenticated-RCE vector for the entire Lucene / Solr fleet. Fixtures are minimal reproducers of the unsafe sink pattern, with explicit disclaimers — they are not verbatim excerpts of upstream internals.
---
## Phase 14 — Real-CVE corpus expansion (2026-04-23)
### Motivation
Phase 13 seeded the real-CVE subtree with one CVE per stable-tier language (Python / JavaScript / TypeScript). Six fixtures is enough to demonstrate the mechanism but not enough to defend the Beta- and Preview-tier languages against regressions on real-world code. Phase 14 extends the subtree to cover Go, Java, Ruby, and PHP, plus a second Python CVE in a different vulnerability class (deserialization, not CMDI). The goal is the same as Phase 13: regression protection on demonstrably real disclosed bugs, not synthetic analogues.
### What changed
- **Six additional CVE pairs** added to `tests/benchmark/cve_corpus/` (12 fixture files, vulnerable + patched per CVE). Same header convention, same minimal-reproducer discipline, same `provenance: "real_cve"` marker.
- **Ground truth**: 12 new cases; `corpus_size` bumped 273 → 285. Vulnerable fixtures assert on an `expected_rule_ids` entry (the pattern-rule that fires on the disclosed sink) plus `taint-unsanitised-flow` as an acceptable alternative. Patched fixtures assert on `forbidden_rule_ids` (the CVE's class-specific rule) so Nyx does not refire on the fix.
- **No harness changes**: the `cve_corpus/` path resolution and `real_cve` provenance scaffolding landed in Phase 13; Phase 14 is pure fixture + ground-truth expansion.
- **Regression thresholds unchanged**: floors stay at `P≥0.861 R≥0.944 F1≥0.901`.
### Real-CVE Corpus
| CVE | Language | Project | License | Vuln class | Vulnerable outcome | Patched outcome |
|------------------|------------|------------------------------|--------------|------------------|--------------------|-----------------|
| CVE-2023-48022 | Python | Ray | Apache-2.0 | CMDI | TP (rule + line) | TN |
| CVE-2017-18342 | Python | PyYAML | MIT | Deserialization | TP (rule + line) | TN |
| CVE-2019-14939 | JavaScript | mongo-express | MIT | code_exec | TP (rule + line) | TN |
| CVE-2023-26159 | TypeScript | follow-redirects | MIT | SSRF | TP (rule + line) | TN |
| CVE-2022-30323 | Go | hashicorp/go-getter | MPL-2.0 | CMDI | TP (rule + line) | TN |
| CVE-2015-7501 | Java | Apache Commons Collections | Apache-2.0 | Deserialization | TP (rule + line) | TN |
| CVE-2013-0156 | Ruby | Ruby on Rails | MIT | Deserialization | TP (rule) | TN |
| CVE-2017-9841 | PHP | PHPUnit | BSD-3-Clause | code_exec | TP (rule + line) | TN |
| CVE-2018-15133 | PHP | Laravel | MIT | Deserialization | TP (rule + line) | TN |
New-in-Phase-14 detail:
- **CVE-2017-18342** (PyYAML `yaml.load` default loader). Vulnerable fixture: `request.get_data → yaml.load` — Nyx fires `py.deser.yaml_load` and `taint-unsanitised-flow` at the documented sink line. Patched fixture: `yaml.safe_load` — zero findings.
- **CVE-2022-30323** (hashicorp/go-getter URL → git argv injection). Vulnerable fixture: `r.URL.Query().Get("src") → exec.Command("git", "clone", url, ...)` — Nyx fires `go.cmdi.exec_command` and `taint-unsanitised-flow`. Patched fixture: scheme allowlist + in-process go-git `PlainClone` removes the `exec.Command` path entirely — zero findings.
- **CVE-2015-7501** (Apache Commons Collections `InvokerTransformer` gadget chain). Vulnerable fixture: `req.getInputStream → new ObjectInputStream(...).readObject()` — Nyx fires `java.deser.readobject` and `taint-unsanitised-flow`. Patched fixture: Jackson JSON codec replaces native Java deserialization — zero findings.
- **CVE-2013-0156** (Rails XML-params YAML tag RCE). Vulnerable fixture: `YAML.load(params[:prefs])` — Nyx fires `rb.deser.yaml_load` (no taint edge because Ruby `params[...]` is not currently labeled as a taint source; the AST pattern is what catches this class). Patched fixture: `JSON.parse` replaces `YAML.load` — zero findings.
- **CVE-2017-9841** (PHPUnit `eval-stdin.php` webshell). Vulnerable fixture: `file_get_contents('php://input') → eval(...)` — Nyx fires `php.code_exec.eval` and `taint-unsanitised-flow`. Patched fixture: SAPI guard and the eval sink removed — zero findings.
- **CVE-2018-15133** (Laravel cookie `unserialize` on leaked APP_KEY). Vulnerable fixture: `$_COOKIE['XSRF-TOKEN'] → base64_decode → unserialize` — Nyx fires `php.deser.unserialize` and `taint-unsanitised-flow`. Patched fixture: HMAC-verified JSON payload — zero findings.
Per-CVE precision/recall: each vulnerable case contributes 1 TP and its patched sibling 1 TN, so per-CVE precision and recall are both 1.000 at the rule level.
### Delta
Aggregate rule-level F1 on the 285-case corpus is **0.968** (P=**0.944**, R=**0.994**), a +0.001 delta vs the Phase 13 baseline (F1=0.967, P=0.942, R=0.994). File-level F1 **0.971** (P=0.944, R=1.000). The precision win is the twelve new cases contributing 6 TP + 6 TN with no spurious firings on the fixes, diluting the existing FP rate slightly.
### Notes on selection
Phase 14's picks followed the Phase 13 criteria: publicly disclosed CVE with a known patch, vulnerability class already covered by a Nyx pattern rule (so the vulnerable fixture produces a concrete `expected_rule_ids` hit, not just a generic `taint-unsanitised-flow`), extractable to ~30 LOC, permissive upstream license. Each added CVE is a well-known, historically damaging bug — mass-scanned webshells (PHPUnit 2017), pre-auth RCE on every Rails app (2013-0156), the original Java deserialization gadget chain (Commons Collections 2015), the go-getter fleet-wide Terraform/Packer/Nomad/Vault exposure (2022), and a textbook Laravel cookie-forgery chain (2018).
---
## Phase 13 — Real-CVE replay corpus (2026-04-23)
### Motivation
The corpus up to Phase CF-7 was 267 synthetic micro-fixtures (820 LOC each). A 95% F1 on toy code does not imply a 95% F1 on real applications. Phase 13 adds a small number of *real* historical CVEs — vulnerable code extracted from the patched upstream project and held under a stable expected rule — so the benchmark floor is now defended by regression protection on demonstrably real bugs, not just synthetic analogues.
### What changed
- **New subtree**: `tests/benchmark/cve_corpus/<lang>/<CVE-ID>/` with a `vulnerable.*` and a `patched.*` file per CVE. Each file carries a header comment with the CVE ID, upstream project, upstream license, and advisory link.
- **Harness**: `tests/benchmark_test.rs::scan_corpus_file` now resolves any `file` entry whose path starts with `cve_corpus/` from the `benchmark_dir` (one level above `corpus/`) instead of the synthetic-corpus root. The change is a single if-branch; all existing synthetic cases are unaffected.
- **Ground truth**: six new cases added with `provenance: "real_cve"`. Vulnerable fixtures assert on `expected_rule_ids`; patched fixtures assert on `forbidden_rule_ids` so Nyx does not *refire* on the fix.
- **Regression thresholds unchanged**: floors stay at `P≥0.861 R≥0.944 F1≥0.901`. The Phase 13 rule-level F1 delta is +0.001 against the CF-7 baseline — the repo's policy ("tighten on durable, measurable improvements") does not justify movement on a corpus-expansion phase.
### Real-CVE Corpus
| CVE | Language | Project | License | Vuln class | Vulnerable outcome | Patched outcome |
|------------------|------------|-----------------|------------|------------|--------------------|-----------------|
| CVE-2023-48022 | Python | Ray | Apache-2.0 | CMDI | TP (rule + line) | TN |
| CVE-2019-14939 | JavaScript | mongo-express | MIT | code_exec | TP (rule + line) | TN |
| CVE-2023-26159 | TypeScript | follow-redirects | MIT | SSRF | TP (rule + line) | TN |
- **CVE-2023-48022** (Ray job-submission RCE). Vulnerable fixture: `request.get_json → os.system` with shell concatenation — Nyx fires `py.cmdi.os_system` and the cross-cutting `taint-unsanitised-flow` at the documented sink line. Patched fixture: `shlex.split → subprocess.run(argv, shell=False)` — zero findings.
- **CVE-2019-14939** (mongo-express `/checkValid` eval RCE). Vulnerable fixture: `req.body.document → eval("(" + document + ")")` — Nyx fires `js.code_exec.eval` and `taint-unsanitised-flow`. Patched fixture: `EJSON.parse(document)` inside a try/catch — zero findings.
- **CVE-2023-26159** (follow-redirects credential-leak / SSRF surface). Vulnerable fixture: `req.query.url → axios.get(target)` — Nyx fires `taint-unsanitised-flow` (no TypeScript SSRF-specific rule ID is emitted on this sink, which matches the rest of the TS SSRF corpus). Patched fixture: allowlist check over the parsed host + fixed internal URL handed to axios — zero findings.
Per-CVE precision/recall: each vulnerable case contributes 1 TP (and its patched sibling 1 TN), so per-CVE precision and recall are both 1.000 at the rule level.
### Delta
Aggregate rule-level F1 on the new 273-case corpus is 0.967 (P=0.942, R=0.994) — a hair above the pre-Phase-13 baseline of 0.966, and materially above the rule-level precision floor (0.894). The win is concentrated in honest regression protection on real code; precision edges up because the six new cases contribute 3 TP + 3 TN and no spurious firings on the fixes.
### Notes on selection
The starter set is intentionally small (1 CVE per stable-tier language, vulnerable + patched pair per CVE). Criteria applied when choosing each CVE: publicly disclosed with a known patch, vulnerability class that Nyx's existing rules cover (CMDI / code_exec / SSRF), extractable to ~30 LOC of representative code, permissive upstream license (Apache-2.0 / MIT) so the attribution header is sufficient. Fixtures are minimal reproducers of the *unsafe sink pattern*, not verbatim excerpts of upstream internals — the goal is regression protection on the documented pattern, not re-running the original exploit end-to-end.
---
## Phase CF-7 — Demand-driven backwards analysis (2026-04-22)
### Motivation
The forward taint engine proceeds source-to-sink, spending budget on
every function the source might touch. Its precision ceiling is fixed
by what summaries + inline re-analysis can preserve on every edge of a
flow — a single lossy edge drops the finding. This phase adds the
opposite direction: start at each sink value and walk *reverse* SSA
edges (and cross-file callee bodies via
`GlobalSummaries.bodies_by_key`) until a source is reached, the
accumulated predicate renders the flow infeasible, or a budget is
exhausted. Off by default; benchmark is neutral.
### Changes
1. **`src/taint/backwards.rs`** — new module with the core types:
`DemandState` (sink-side demand: caps + validated-predicate bits +
cross-function depth), `BackwardFlow` (the reached verdict per
walked value), `BackwardsCtx` (minimal driver-inputs view),
`FindingVerdict` (Confirmed / Inconclusive / Infeasible /
BudgetExhausted), and the `analyse_sink_backwards` driver. The
backwards transfer handles every `SsaOp` variant — `Assign`/`Phi`
fan out to operands, `Call` tries cross-file body expansion before
falling back to arg-fanout, `Source`/`Const`/`Param`/`CatchParam`
terminate. Source recognition also consults the defining CFG
node's `DataLabel::Source(_)` so Python-style call-sites like
`request.args.get` are treated as source terminals. Budgets:
`DEFAULT_BACKWARDS_DEPTH = 2`, `BACKWARDS_VALUE_BUDGET = 1024`,
`MAX_BACKWARDS_CALLEE_BLOCKS = 500`.
2. **Finding annotation** (`src/taint/mod.rs`): after forward taint
and symex complete, if `analysis.engine.backwards_analysis` is on,
the pass walks each finding's sink and writes its verdict onto
`Finding.symbolic.cutoff_notes` via `annotate_finding`. Placed
after symex so its witness-style `symbolic` output survives;
backwards layers `backwards-confirmed` / `backwards-infeasible` /
`backwards-budget-exhausted` onto the notes vector.
3. **Confidence integration** (`src/evidence.rs`):
`compute_taint_confidence` treats `backwards-confirmed` as a
`+1` signal and `backwards-infeasible` as a `-3` penalty (a
smaller-magnitude signal than the symex verdict, which reasons
about concrete payloads). `compute_confidence_limiters` surfaces
infeasible/budget verdicts as user-readable strings.
4. **Switch surfaces**: new `AnalysisOptions.backwards_analysis` field
(default `false`), CLI pair
`--backwards-analysis / --no-backwards-analysis`, and legacy
env-var `NYX_BACKWARDS=1`. Same tri-state pattern as the other
engine toggles.
5. **Docs** (`docs/advanced-analysis.md`): new "Demand-driven
analysis" section documents the pass, how to enable it, and the
first-cut limitations (no reverse-call-graph expansion past
`ReachedParam`; constraint pruning uses predicate-summary bits
only, not the full SMT backend; depth-bounded at k=2).
### Test coverage
* **Unit tests** (`src/taint/backwards.rs` — 12 tests): demand-state
seeding, backward transfer per op (`Source`, `Const`, `Param`,
`Assign`, `Phi`), driver end-to-end on a trivial
Source→Assign→sink body, phi fan-out producing per-predecessor
flows, verdict aggregation (`Confirmed` beats `Infeasible`), and
`annotate_finding` idempotence + inconclusive no-op.
* **Integration** (`tests/backwards_analysis_tests.rs` + 4 fixtures):
`demand_driven_reach_source` confirms a SQL-injection source is
reached and picks up `backwards-confirmed` when the switch is on;
`demand_driven_prove_infeasible` locks in first-cut structural
behaviour (SMT-backed prune is a follow-up); `demand_driven_catch_new_fn`
locks in the first-cut ReachedParam termination; `demand_driven_no_source`
regression-guards against synthetic findings on source-free code. A
fifth sub-case asserts backwards OFF is a strict no-op (no
annotations appear).
### Benchmark delta
Off-by-default posture preserves the benchmark floor byte-for-byte
(P=0.940, R=0.994, F1=0.966 rule-level; P=0.941, R=1.000, F1=0.970
file-level). On-path precision improvements require two follow-ups:
reverse-call-graph expansion for flows that escape a function's
`ReachedParam` boundary, and full SMT integration for the infeasible
path class. Both are tracked as CF-7 follow-up work; the
off-by-default switch lets operators opt in without disturbing CI.
---
## Phase CF-6 — Parameter-granularity points-to summaries (2026-04-22)
### Motivation
Prior to CF-6, the cross-file summary channel had no way to express
"callee mutates a shared heap object through one parameter so another
parameter's alias sees the new taint." Container-op patterns (`push`,
`set`, …) were already captured through `param_to_container_store`, but
direct field writes — `obj.x = val` — fell outside
`classify_container_op`'s recognised-method list, so a common class of
flow (void helper that stores through a parameter) was invisible to
cross-file taint. Whole-program points-to is out of scope for a
security scanner; a minimal parameter-granularity summary closes the
real flows at a negligible cost.
### Changes
1. **`PointsToSummary` data type** (`src/summary/points_to.rs`):
bounded `SmallVec<[AliasEdge; 4]>` of directed `(source, target,
kind)` edges where endpoints are `AliasPosition::Param(u32)` or
`AliasPosition::Return` and `AliasKind` is `MayAlias` only for CF-6.
Edge count is capped at `MAX_ALIAS_EDGES = 8`; overflow sets an
`overflow` flag that callers honour as "any param aliases any other
param and the return" — the conservative greatest-lower-bound over
the alias lattice.
2. **Intra-procedural analysis** (`src/ssa/param_points_to.rs`): a
single bounded pass over the SSA body. For each `SsaOp::Assign`
whose `var_name` is a dotted/indexed path, we resolve the root base
to a formal-parameter index via `formal_param_names` (authoritative
declaration-order map) and trace the RHS through Assign/Phi chains to
another parameter, emitting `Param(src) → Param(dst)`. For each
`Terminator::Return(Some(v))` whose value traces to a parameter we
emit `Param(i) → Return`. Declaration-order indexing matters: SSA
lowering skips formal params that are never read, so SSA-level
indices and caller-side positional indices can diverge. Trusting
formal-order is the only way to keep the summary's edges aligned with
the caller's `args[i]` slots.
3. **`SsaFuncSummary.points_to`** (`src/summary/ssa_summary.rs`): new
`#[serde(default, skip_serializing_if = PointsToSummary::is_empty)]`
field. Legacy on-disk rows deserialise cleanly with an empty
summary, so no engine-version bump is required.
4. **Summary application at cross-file call sites**
(`src/taint/ssa_transfer.rs`): `resolved_points_to` is captured
alongside the other cross-file fields before `callee_summary` is
moved into the main taint branch. Each `Param(src) → Param(dst)`
edge unions caller-`args[src]`'s taint into the heap of caller-
`args[dst]`'s points-to set *and* directly taints the dst SSA
value — the direct channel is necessary when the caller's heap
analysis has no allocation site for the arg (common for plain
constructors in Python / JS / Java). Each `Param(src) → Return`
edge threads caller-`args[src]`'s points-to set through
`dynamic_pts` onto the call's return value. Overflow synthesises
the conservative all-pairs graph.
5. **`ssa_summary_fits_arity`** (`src/summary/mod.rs`): arity filter
extended to reject points-to entries referencing parameters past the
key's declared arity (same guard that `param_to_return` /
`param_to_sink` already use). Prevents synthetic-capture
mis-attributions from leaking into cross-file resolution.
6. **Observable-effects filter** (`src/taint/mod.rs`): summary
filtering in `lower_all_functions` now treats a non-empty
`PointsToSummary` as an observable effect so void helpers whose only
signal is a parameter alias survive the "no effects, skip" filter.
### Test coverage
* **Unit tests** (`src/summary/points_to.rs`): data-structure
invariants (dedup, overflow promotion, serde round-trip, legacy JSON
decodes).
* **Unit tests** (`src/ssa/param_points_to.rs`): 5 structural shapes
(field-write emits edge, return-alias emits edge, self-alias is
dropped, out-of-range param rejected, bounded graph terminates).
* **Summary serde + arity** (`src/summary/tests.rs`): round-trip with
points_to populated, legacy JSON deserialises with empty points_to,
arity filter rejects out-of-range param indices.
* **Cross-file integration** (`tests/cross_file_alias_tests.rs` + 3
fixtures): `cross_file_alias_mutating_helper` (Python void helper →
py.cmdi finding through param alias), `cross_file_alias_returned_alias`
(JS passthrough → shell-exec finding through return alias),
`cross_file_alias_bounded_graph` (Python 20-edge graph → scan
terminates under the overflow fallback).
### Benchmark
Rule-level F1 unchanged at 0.966 (P=0.940, R=0.994); file-level F1
unchanged at 0.970 (P=0.941, R=1.000). Neutral, as expected: the
existing benchmark corpus does not exercise cross-file field-alias
flows, so CF-6's precision win is latent and will surface as the corpus
grows. All 2173 tests pass (1687 lib + 486 integration).
---
## Phase CF-5 — Cross-file SCC joint fixed-point (2026-04-22)
### Motivation
The pass-2 orchestrator already iterates mutually-recursive SCCs to
convergence on merged summaries (`MAX_SCC_FIXPOINT_ITERS`-bounded with a
`SCC_FIXPOINT_SAFETY_CAP = 64` guard). Post-CF-1/CF-2, those iterations
run cross-file inline re-analysis under the *current* merged summaries on
each iteration, so the summary-equality convergence predicate implicitly
covers inline convergence for monotone summaries. What was missing was
an explicit signal distinguishing *cross-file* SCCs (where the recursion
crosses file boundaries and the inline+summary interaction is what drives
precision) from *intra-file* SCCs (where the iteration is purely about
summary fixpoint). Without that signal, cap-hit diagnostics conflated
the two root causes and the orchestrator could not target cross-file
SCCs for specialised handling.
### Changes
1. **`scc_spans_files()` helper + `FileBatch.cross_file` flag**
(`src/callgraph.rs`): an SCC is flagged cross-file when its nodes
belong to more than one namespace. `scc_file_batches_with_metadata`
unions the flag across all SCCs contributing to each topo batch.
`cross_file ⊆ has_mutual_recursion` by construction (a non-recursive
cross-file chain resolves topologically and is not batched).
2. **Inline cache lifecycle hooks** (`src/taint/ssa_transfer.rs`): new
`inline_cache_clear_epoch()` and `inline_cache_fingerprint()` helpers
give the SCC orchestrator a concrete contract for per-iteration cache
semantics. The per-file cache is already reconstructed fresh inside
`analyse_file`, so today these are no-op plumbing — kept explicit so
any future shared-cache refactor has a pre-agreed API.
3. **Cross-file-specific cap-hit tag** (`src/commands/scan.rs`):
`SCC_UNCONVERGED_CROSS_FILE_NOTE_PREFIX` is a strict superset of
`SCC_UNCONVERGED_NOTE_PREFIX`; callers filtering on the base prefix
still match, while consumers that want the narrower cross-file case
can match on the longer constant. `tag_unconverged_findings()`
takes a `cross_file: bool` switch and `run_topo_batches()` threads
the batch flag through.
4. **Observability**: cross-file SCCs emit a dedicated `debug!` log at
iteration start; cap-hit warnings carry the `cross_file = {bool}`
field so operators can root-cause imprecision quickly.
### Fixtures and tests
- `tests/fixtures/cross_file_scc_mutual_recursion/` (Python, 2-file
mutual recursion with CMDI sink): transitive taint must reach the
caller across the cycle.
- `tests/fixtures/cross_file_scc_three_way_cycle/` (Python, 3-file
cycle): pinned iteration envelope proves the SCC fix-point loop does
the work, not topo order.
- `tests/fixtures/cross_file_scc_recursive_with_sanitiser/` (Python,
2-file sanitised cycle): joint convergence carries the `shlex.quote`
sanitizer across the cycle and suppresses the caller's CMDI.
- `tests/scc_cross_file_tests.rs`: wires the three fixtures into the
integration harness.
- Callgraph unit tests: `scc_file_batches_with_metadata_marks_cross_file`,
`scc_file_batches_with_metadata_intra_file_scc_not_cross_file`,
`scc_spans_files_single_node`.
- Inline-cache lifecycle unit tests
(`inline_cache_epoch_tests` in `src/taint/ssa_transfer.rs`):
`clear_epoch_drops_all_entries`,
`fingerprint_is_order_independent`,
`fingerprint_changes_when_return_caps_change`,
`fingerprint_tracks_missing_return_taint_as_zero`.
- Tag-variant unit tests (`scc_tagging_tests` in `src/commands/scan.rs`):
cross-file and non-cross-file variants emit the expected prefixes.
### Benchmark delta
Byte-for-byte neutral vs CF-3 (P/R/F1 unchanged at 0.940 / 0.994 /
0.966). The corpus exercises cross-file SCCs that already converge
cleanly under the existing summary-snapshot loop, so CF-5's value is
diagnostic clarity (tighter cap-hit tag, `cross_file` metric) and an
API surface the future joint-cache refactor can hook into — not a
precision shift on today's fixtures.
### Known limitations
- `inline_cache_clear_epoch` is a semantic hook, not a shared-cache
lifecycle: the per-file cache is already ambient-cleared at each
iteration via `analyse_file` reconstruction. A true cross-file
shared cache would be a more involved refactor (rayon-safe shared
`RefCell<InlineCache>` across SCC files, epoch-tag invalidation on
cache miss/hit).
- Benchmark-visible precision win will require corpus fixtures that
specifically exercise cross-file SCCs with precision-degrading
summary approximation; the current corpus's cross-file cycles all
converge in 05 iterations and land on the same answer at both the
summary and inline path.
---
## Phase CF-3 — Abstract-domain transfer channels in summaries (2026-04-22)
### Motivation
Phase 17 abstract interpretation tracks per-SSA-value intervals, string prefix/suffix facts, and known-bit masks during pass 2 and uses them to suppress findings via `is_abstract_safe_for_sink`. None of those facts crossed function boundaries through summaries: a caller that proved `port ∈ [1024, 65535]` lost the bound the moment the value entered a cross-file callee. CF-3 records, per parameter, a bounded symbolic description of how that parameter's abstract value maps to the return, so callers can synthesise the return abstract at summary-path call sites without re-running the callee.
### Changes
1. **`AbstractTransfer` domain** (`src/abstract_interp/mod.rs`) — product of bounded per-subdomain forms: `IntervalTransfer` (`Top` | `Identity` | `Affine { add, mul }` | `Clamped { lo, hi }`), `StringTransfer` (`Unknown` | `Identity` | `LiteralPrefix(String)` capped at `MAX_LITERAL_PREFIX_LEN = 64`). Bit subdomain is intentionally not carried cross-file.
2. **Summary schema** (`src/summary/ssa_summary.rs`): new `SsaFuncSummary.abstract_transfer: Vec<(usize, AbstractTransfer)>`, serde-gated so old DBs deserialise unchanged and only propagating functions contribute bytes.
3. **Pass-1 extraction** (`src/taint/ssa_transfer.rs::derive_abstract_transfer`): structural inference. *Identity* when every return-block return value traces (through single-use `Assign` and same-param `Phi` merges, depth ≤ 8) to the same `SsaOp::Param { index }`. *Clamped / LiteralPrefix* attached when the callee's baseline `return_abstract` has a bounded interval or known prefix.
4. **Pass-2 application** (`SsaOp::Call` arm of `transfer_inst`): runs whenever the callee was resolved via SSA summary. Per-param transfers evaluate on the caller's current abstract value of the argument, joined then `meet`-ed with baseline `return_abstract` (falling back to the less restrictive side if the meet contradicts).
### Fixtures and tests
- `tests/abstract_transfer_tests.rs` (29 tests): serde round-trip, per-subdomain `apply` semantics, join widening, LCP join on shared literal prefixes, and an end-to-end pass-1 structural test.
- `tests/fixtures/cross_file_abstract_port_range/`, `tests/fixtures/cross_file_abstract_bounded_index/`: cross-file summary-path regression guards.
- `tests/fixtures/cross_file_abstract_url_prefix_lock/`: JS literal-prefix SSRF suppression (landed via follow-up below).
#### CF-3 follow-up — JS literal-prefix SSRF suppression fix (2026-04-22)
Two surgical changes downstream of CF-3:
- **`src/ssa/copy_prop.rs`** — copy-prop now skips single-use `Assign` instructions whose CFG node carries `string_prefix`. Without this, copy-prop + DCE eliminated `url = 'lit' + userInput` in pass 2's optimised SSA, rewriting `fetch(url)`'s arg to the bare param and erasing the prefix. Mirrors the existing `is_numeric_length_access` guard.
- **`src/taint/ssa_transfer.rs::transfer_abstract`** — added a `Call` arm symmetric with the Assign-with-prefix arm: when a `Call` instruction's CFG node carries `string_prefix` (e.g. `url = wrapper('lit' + x)`), seed the call result's `StringFact` with the prefix. Lets `axios.get(url)` consume the prefix lock through cross-file identity-passthrough wrappers like CF-3's `asIs`.
Single-file and cross-file `'lit' + userInput → fetch/axios.get` both now produce zero findings.
### Benchmark delta
Byte-for-byte neutral vs pre-CF-3 (P/R/F1 unchanged at 0.940 / 0.994 / 0.966). Expected: the corpus does not yet exercise call chains where an identity-passthrough cross-file callee is the only thing between a caller-side abstract bound and a downstream suppression. The precision win will materialise when broader corpora exercise cross-file integer-bound propagation.
### Known limitations
- Per-return-path decomposition is CF-4's scope. A callee whose return traces to `param_0` on one branch and `param_1` on another yields `identity_consistent = false` and falls back to the baseline-invariant form (or Top).
- Only single-use `Assign` and consistent-origin `Phi` merges are followed by the Identity tracer; richer alias reasoning is CF-6.
- `Affine` is defined in the domain but the pass-1 structural inferrer never emits it yet.
---
## Phase CF-2 — Cross-file k=1 context-sensitive inline taint (2026-04-22)
Intra-file k=1 inline analysis (Phase 11) was extended to fire on cross-file call edges too. Before CF-2 every cross-file call collapsed into the callee's worst-case `SsaFuncSummary`; CF-2 exposes call-site-specific argument taint, call-site constants, and path-predicate structure to cross-file callees.
### Key changes
- **Cross-file body fallback in `inline_analyse_callee`** (`src/taint/ssa_transfer.rs`): intra-file lookup runs first; on miss, resolves the call via `GlobalSummaries.resolve_callee` and loads the body from `transfer.cross_file_bodies`. Body-size budget, k=1 depth cap, and the `context_sensitive` config switch shared with intra-file path via `InlineCache`.
- **Origin source-span pre-fill in param seed**: populate `source_span` from the caller's CFG before origins cross into a callee body, so cross-file inline preserves caller attribution.
- **Indexed-scan parity (CF-2 follow-up)**: `CrossFileNodeMeta` extended to carry full `NodeInfo` snapshot; `rebuild_body_graph` rehydrates a proxy `Cfg` at DB load time. `build_index` now persists `ssa_bodies` rows at index-build time (prior behaviour silently wrote zero bodies). Engine-version salt bumped to `+cf3-xfile-meta`.
### Fixtures
Four cross-file fixtures under `tests/fixtures/cross_file_context_*`: `two_call_sites` (Python, primary CF-2 win), `callback` (JS, callback-as-argument via summary path), `sanitizer` (JS, regression guard that CF-2 inline doesn't add findings where the summary path strips taint), `deep_chain` (Python three-file chain). Each has in-memory and indexed-scan test variants.
### Benchmark delta
Precision **+2.9pp** vs pre-CF-2 (0.911 → 0.940); recall unchanged (0.994); F1 **+1.5pp** (0.951 → 0.966). No per-language regression; Python/Rust/TypeScript at 1.000, others ≥ 0.889. Indexed-scan parity follow-up was neutral (correctness fix, not a precision delta).
### Known limitations
- k=1 is preserved: cross-file inline will not recursively inline the next cross-file hop. CF-5 (SCC joint fixed-point) revisits this for mutually recursive cross-file SCCs.
---
## History
Earlier phases, most recent first. Metrics are rule-level unless noted.
| Date | Phase | Corpus | P | R | F1 | Notes |
|------------|----------------------------------------|--------|--------|--------|--------|-------|
| 2026-04-20 | Rust Weak Spot Fixes | 262 | 0.906 | 0.994 | 0.948 | Rust FN→0 across FILE_IO/SSRF/SQL/DESERIALIZE sink families; SHELL_ESCAPE added to Phase 10 type suppression; identity-method peeling for constructor typing; Rust rule-level P/R/F1 jumped +7.8/+21.1/+13.2pp. |
| 2026-04-20 | TypeScript Weak Spot Fixes | 262 | 0.899 | 0.981 | 0.938 | Closed all three Phase 19 TS weak spots: encodeURIComponent→axios cap-overlap (StringFact prefix-locked SSRF suppression), Fastify framework detection from in-file imports, TSX/JSX grammar wiring. TS rule-level F1 → 1.000. |
| 2026-04-20 | Rust Honesty Expansion | 262 | 0.891 | 0.961 | 0.925 | Rust corpus expanded 18→31 cases with honest FNs in classes lacking Rust rules (SQL, deserialize, reqwest builder chains). Correction, not a regression. |
| 2026-04-20 | TypeScript Coverage Expansion | 246 | 0.904 | 0.986 | 0.944 | TS corpus 0→32 cases (12 vuln classes, adversarial type-system stressors, framework/cap-overlap/interproc cases). |
| 2026-03-24 | Phase 19 — Benchmark Expansion | 214 | 0.827 | 0.950 | 0.885 | +73 cases (+52%); C, C++, Rust added as first-class languages; interprocedural + path-pruning cases; `buffer_overflow` and `fmt_string` classes for C/C++. Thresholds reset to baseline 5pp. |
| 2026-03-22 | Phase 8.5 — Cross-file SSA validation | 141 | 0.840 | 0.975 | 0.903 | `param_to_sink_param` field on `SsaFuncSummary`; directory-based multi-file benchmark cases; 6 new cross-file cases across PY/JS/Go (propagation, source detection, wrong-cap sanitizer) — all TP. |
| 2026-03-22 | Ruby Parity | 123 | 0.821 | 0.986 | 0.896 | Ruby corpus 1→21 cases across 8 vuln classes; no label rule changes. |
| 2026-03-22 | Phase 5 — SSA Lowering X-lang hardening| 103 | 0.841 | 0.983 | 0.906 | PHP closures + throw; Python try/except + raise; new exception-edge fixtures. Precision +17.0pp vs Phase 30 via confidence scoring / allowlist / type-check guards. |
| 2026-03-21 | Phase 30 — SSRF Semantic Completion | 103 | 0.671 | 0.966 | 0.792 | New SSRF sink matchers (axios, got, undici, httpx, http.NewRequestWithContext, Net::HTTP, HTTParty, requests.*); `flask_request.*` source; Ruby added to corpus. |
| 2026-03-21 | Phase 22.5b — Constant-arg suppression | 95 | 0.654 | 0.964 | 0.779 | AST + CFG suppression of calls with all-literal args; removed buggy `!source_derived` guard in `guards.rs`. |
| 2026-03-21 | Phase 22.5 | 95 | 0.624 | 0.964 | 0.757 | py-ssrf-001 rule-ID fix; bare `exec`/`execSync` as JS cmdi sinks; Python `Template` as XSS sink. |
| 2026-03-21 | Phase 22 baseline | 95 | 0.620 | 0.891 | 0.731 | First benchmarked baseline post-Phase-22 symbolic strings. |
### Recurring known limitations
- **Variable-receiver method calls** (e.g. `client.send(...)` vs `HttpClient.send(...)`): suffix-matching misses without type-qualified resolution. Partly addressed by Phase 10 type-aware callee resolution; residual cases remain where the receiver has no inferred type.
- **Import aliasing**: arbitrary import aliases (`from flask import request as r`) are not traced; only explicitly listed aliases resolve.
- **No SSRF sanitizers as function calls**: URL-parsing doesn't sanitize; allowlist checks are condition patterns, modelled via `classify_condition()` validation markers rather than call-site credits.
- **Rust structural `cfg-unguarded-sink`** still fires for SHELL_ESCAPE when a source is in scope but not flowing to the sink arg — intentional for high-risk sinks; requires plumbing `TypeFactResult` into `AnalysisContext` to suppress.
- **Rust negative-validation `contains` dominators** and **match-arm guards** are not yet modelled by `classify_condition()`.
- **DNS-rebinding / async callback flows**: out of scope for static analysis without runtime context.
# Benchmark Results
Current baseline (2026-04-29):
| Metric | File-level | Rule-level | CI floor |
|-----------|------------|------------|----------|
| Precision | 0.991 | 0.991 | 0.861 |
| Recall | 0.995 | 0.995 | 0.944 |
| F1 | 0.993 | 0.993 | 0.901 |
Corpus: 433 cases across 10 languages, 432 evaluated (1 disabled). Per-run JSON lands in `tests/benchmark/results/` (`latest.json` plus dated snapshots). See `README.md` for what the scoring modes mean and how to run a subset.
The corpus is mostly synthetic 8-20 line fixtures, one vulnerability or one safe pattern per file. A smaller real-CVE replay set under `cve_corpus/` covers 18 published CVEs across all 10 languages. Both contribute to the headline numbers.
## Real CVE coverage
Real disclosed CVEs reduced to minimal reproducers, vulnerable + patched pair per CVE. Vulnerable fixtures must produce a finding for the disclosed sink class. Patched fixtures must produce zero findings.
| CVE | Language | Project | License | Class | Status |
|----------------|------------|----------------------------|----------------------|-----------------|----------|
| CVE-2023-48022 | Python | Ray | Apache-2.0 | CMDI | detected |
| CVE-2017-18342 | Python | PyYAML | MIT | Deserialization | detected |
| CVE-2019-14939 | JavaScript | mongo-express | MIT | code_exec | detected |
| CVE-2025-64430 | JavaScript | Parse Server | Apache-2.0 | SSRF | detected |
| CVE-2023-26159 | TypeScript | follow-redirects | MIT | SSRF | detected |
| CVE-2022-30323 | Go | hashicorp/go-getter | MPL-2.0 | CMDI | detected |
| CVE-2023-3188 | Go | owncast | MIT | SSRF | open FN |
| CVE-2024-31450 | Go | owncast | MIT | path_traversal | detected |
| CVE-2015-7501 | Java | Apache Commons Collections | Apache-2.0 | Deserialization | detected |
| CVE-2017-12629 | Java | Apache Solr | Apache-2.0 | CMDI | detected |
| CVE-2013-0156 | Ruby | Ruby on Rails | MIT | Deserialization | detected |
| CVE-2020-8130 | Ruby | Rake | MIT | CMDI | detected |
| CVE-2017-9841 | PHP | PHPUnit | BSD-3-Clause | code_exec | detected |
| CVE-2018-15133 | PHP | Laravel | MIT | Deserialization | detected |
| CVE-2016-3714 | C | ImageMagick (ImageTragick) | ImageMagick License | CMDI | detected |
| CVE-2019-18634 | C | sudo (pwfeedback) | ISC | memory_safety | detected |
| CVE-2019-13132 | C++ | ZeroMQ libzmq | MPL-2.0 | memory_safety | detected |
| CVE-2022-1941 | C++ | Protocol Buffers | BSD-3-Clause | memory_safety | detected |
Deferred entries are real bugs Nyx can't yet detect. The fixture stays committed with `disabled: true` in ground truth so the gap remains visible.
### How CVEs get picked
- Publicly disclosed with a stable advisory link.
- Class Nyx already has a rule for, so the vulnerable fixture asserts on a concrete rule ID, not just a generic taint flow.
- Reducible to roughly 30 lines without hiding the disclosed sink shape.
- Permissive upstream license (MIT, Apache, BSD, MPL, ISC, ImageMagick).
Fixtures are minimal reproducers of the unsafe pattern, not verbatim upstream code.
## CI floor
CI fails the build if rule-level precision drops below 0.861, recall below 0.944, or F1 below 0.901. Floors sit roughly 8 percentage points below the live baseline. A single-case flip is about 0.6 pp on this corpus, so the headroom absorbs honest FP/TN trades while still tripping on a class-level regression. Floors only move up, when a durable improvement lands. Never relax them to paper over a regression.
The gate runs in the `benchmark-gate` job in `.github/workflows/ci.yml`. Thresholds are encoded at the bottom of `tests/benchmark_test.rs`.
## Recent changes
Most recent first. Metrics are rule-level on the corpus size at that point.
| Date | Change | Corpus | P | R | F1 |
|------------|------------------------------------------------------------------------------|--------|-------|-------|-------|
| 2026-04-28 | Ruby bare `Kernel#open` CMDI sink, exact-match sigil on label matchers | 428 | 0.995 | 1.000 | 0.998 |
| 2026-04-28 | Go SSRF/FILE_IO sink expansion (`http.DefaultClient.*`, `os.Remove`/`WriteFile`) plus Decode-writeback container op | 426 | 0.995 | 1.000 | 0.998 |
| 2026-04-27 | JS chained-method inner-gate classification (`http.get(u, cb).on(...)`) | 422 | 0.994 | 1.000 | 0.997 |
| 2026-04-23 | Auth FP remediation: 10 Rust ownership-check fixtures wired to corpus | 305 | 0.946 | 0.994 | 0.970 |
| 2026-04-23 | C and C++ added as first-class CVE-corpus languages (5 new CVE pairs) | 295 | 0.945 | 0.994 | 0.969 |
| 2026-04-23 | Go, Java, Ruby, PHP, plus second Python CVE pair | 285 | 0.944 | 0.994 | 0.968 |
| 2026-04-23 | Real-CVE replay corpus seeded (Python, JS, TS, one CVE per language) | 273 | 0.942 | 0.994 | 0.967 |
| 2026-04-22 | Cross-file points-to summaries, SCC joint fixed-point, backwards taint | 273 | 0.940 | 0.994 | 0.966 |
| 2026-04-22 | Cross-file context-sensitive inline taint (k=1) | 270 | 0.940 | 0.994 | 0.966 |
| 2026-04-20 | Rust weak-spot fixes across FILE_IO, SSRF, SQL, DESERIALIZE sink families | 262 | 0.906 | 0.994 | 0.948 |
| 2026-04-20 | TypeScript weak-spot fixes, Fastify framework detection, TSX/JSX grammar | 262 | 0.899 | 0.981 | 0.938 |
| 2026-04-20 | Rust corpus expansion: honest FNs in classes lacking Rust rules | 262 | 0.891 | 0.961 | 0.925 |
| 2026-04-20 | TypeScript corpus 0 to 32 cases across 12 vuln classes | 246 | 0.904 | 0.986 | 0.944 |
| 2026-03-24 | Benchmark expansion: C, C++, Rust as first-class; +73 cases | 214 | 0.827 | 0.950 | 0.885 |
| 2026-03-22 | Cross-file SSA validation, multi-file directory cases | 141 | 0.840 | 0.975 | 0.903 |
| 2026-03-22 | Ruby corpus 1 to 21 cases across 8 vuln classes | 123 | 0.821 | 0.986 | 0.896 |
| 2026-03-22 | SSA lowering hardening (PHP closures, Python try/except, exception edges) | 103 | 0.841 | 0.983 | 0.906 |
| 2026-03-21 | SSRF semantic completion (axios, got, undici, httpx, Net::HTTP, HTTParty) | 103 | 0.671 | 0.966 | 0.792 |
| 2026-03-21 | Constant-arg suppression at AST and CFG level | 95 | 0.654 | 0.964 | 0.779 |
| 2026-03-21 | Bare `exec`/`execSync` as JS CMDI sinks; Python `Template` as XSS sink | 95 | 0.624 | 0.964 | 0.757 |
| 2026-03-21 | First baseline after symbolic-strings work | 95 | 0.620 | 0.891 | 0.731 |
## Known limitations
These show up across multiple corpora and aren't fully fixed yet.
- **Variable-receiver method calls** (`client.send(...)` vs `HttpClient.send(...)`) miss without an inferred receiver type. Type-aware callee resolution closes most cases; some residuals remain.
- **Arbitrary import aliases** (`from flask import request as r`) aren't traced. Only explicitly listed aliases resolve.
- **URL-parsing isn't credited as SSRF sanitization.** Allowlist checks in conditions are recognised; call-site sanitizers aren't.
- **Rust unguarded-sink** still fires for shell-escape sinks when a source is in scope but not flowing to the sink arg. Intentional for high-risk classes.
- **Rust negative-validation** patterns (`contains` dominators, match-arm guards) aren't recognised yet.
- **DNS rebinding and async-callback flows** are out of scope for static analysis without runtime context.

View file

@ -0,0 +1,10 @@
/* Vulnerable counterpart for `safe_strcpy_literal_src.c` — Layer D must
* NOT suppress when the source argument is a non-literal that could
* carry attacker-controlled length. Distilled from a typical CLI shape:
* argv[1] is unbounded user input. The strcpy here is the canonical
* `c.memory.strcpy` finding the pattern rule is meant to catch. */
#include <string.h>
void copy_user(char *dst, char **argv) {
strcpy(dst, argv[1]);
}

View file

@ -0,0 +1,27 @@
/* Distilled from postgres `datetime.c::EncodeDateTime` — `sprintf` with
* a literal format string that uses only width/precision-bounded
* specifiers contributes statically-bounded length, so it cannot
* overflow due to attacker input. Layer D suppression accepts:
* - format strings with no `%s` at all (numeric / char specifiers)
* - format strings whose `%s` is precision-bounded (`%.*s`, `%.5s`)
*
* Bare `%s` (`sprintf(buf, "%s", x)`) is intentionally NOT suppressed
* see `cpp/buffer_overflow/buffer_sprintf.cpp` for the vulnerable
* counterpart. */
#include <stdio.h>
#define MAXTZLEN 10
void emit_int(char *cp, long long value, char units) {
/* Numeric-only specifier — bounded by integer-length budget. */
sprintf(cp, "%lld%c", value, units);
}
void emit_tz(char *str, const char *tzn) {
/* Precision-bounded `%.*s` — output capped at MAXTZLEN bytes. */
sprintf(str, " %.*s", MAXTZLEN, tzn);
}
void emit_static(char *buf) {
sprintf(buf, "fixed=%d/%c", 42, 'X');
}

View file

@ -0,0 +1,34 @@
/* Distilled from postgres `pg_prewarm/autoprewarm.c` and `pgrowlocks.c` —
* the canonical "set struct field to a fixed string" pattern. The
* pattern rule `c.memory.strcpy` flags the call syntactically; the source
* is a string literal whose length is bounded at compile time, so the
* call is not exploitable for buffer overflow. Layer D suppression
* recognises both forms (plain literal and ternary of two literals,
* mirroring postgres' `formatting.c::DCH_AM` shape). */
#include <string.h>
struct BackgroundWorker {
char bgw_library_name[96];
char bgw_function_name[96];
char bgw_name[96];
};
void register_bgw(void) {
struct BackgroundWorker worker;
strcpy(worker.bgw_library_name, "pg_prewarm");
strcpy(worker.bgw_function_name, "autoprewarm_main");
strcpy(worker.bgw_name, "autoprewarm leader");
}
#define A_M_STR "a.m."
#define P_M_STR "p.m."
void format_meridian(char *s, int hour) {
/* Postgres `formatting.c::DCH_a_m` ternary-of-literals shape. */
strcpy(s, (hour >= 12) ? P_M_STR : A_M_STR);
}
void append_marker(char *dst) {
/* `strcat` mirror — bounded source. */
strcat(dst, " (done)");
}

View file

@ -0,0 +1,29 @@
// Phase 4 (cpp-precision): a tainted env var flows through an
// inline-defined class member function into `std::system()`.
//
// Pre-Phase-4, the C++ KINDS map left `class_specifier` unmapped,
// which made the CFG walker treat the entire class declaration as a
// single leaf `Seq` node — inline member-function bodies were never
// extracted as separate functions and intra-file calls like
// `inner.run(input)` could not resolve to the body summary.
//
// With `class_specifier` (and `struct_specifier`/`union_specifier`/
// `enum_specifier`/`template_declaration`/`linkage_specification`)
// mapped to `Kind::Block`, the walker descends into the body and
// inline methods participate in summary resolution.
#include <cstdlib>
class Inner {
public:
void run(const char* arg) {
std::system(arg); // SHELL_ESCAPE sink
}
};
int main() {
char *input = std::getenv("USER_CMD");
Inner inner;
inner.run(input); // resolves to Inner::run
return 0;
}

View file

@ -0,0 +1,18 @@
// Phase 3 (cpp-precision): tainted user input flows through a C++ lambda
// that returns its argument unchanged. The default Call-arg propagation
// in the SSA taint engine carries the argument's taint into the result,
// so the downstream system() shell sink fires.
//
// This fixture pins the "lambda as identity-passthrough" behaviour;
// captures and lambda-body sanitisation through summaries are separate
// Phase 7 / summary work and are deliberately not exercised here.
#include <cstdlib>
#include <string>
int main() {
char *input = std::getenv("USER_CMD");
auto echo = [](const char* s) { return s; };
std::system(echo(input)); // SHELL_ESCAPE sink — must fire
return 0;
}

View file

@ -0,0 +1,19 @@
// Phase 2 (cpp-precision): tainted user input is stored in a
// std::vector<std::string>, then read back via `front()` and converted
// to a `char*` via `c_str()`. The c_str() conversion must propagate
// taint from the receiver to the result so that the downstream
// `system()` shell sink fires.
#include <cstdlib>
#include <string>
#include <vector>
int main() {
char *input = std::getenv("USER_CMD");
std::vector<std::string> commands;
commands.push_back(input); // store tainted string
std::string cmd = commands.front(); // load tainted string
std::system(cmd.c_str()); // SHELL_ESCAPE sink
return 0;
}

View file

@ -0,0 +1,23 @@
// Phase 5 (cpp-precision): a fluent builder chain whose host is a
// hardcoded literal. Even though `.connect()` is an SSRF sink, the
// host carries no taint, so the engine must not fire on this chain.
// Counterpart to `ssrf_builder_user_host.cpp`.
#include <cstdlib>
#include <string>
class Socket {
public:
static Socket builder() { return Socket(); }
Socket& host(const std::string& h) { host_ = h; return *this; }
Socket& port(int p) { port_ = p; return *this; }
void connect() {}
private:
std::string host_;
int port_ = 0;
};
int main() {
Socket::builder().host("api.example.com").port(8080).connect();
return 0;
}

View file

@ -0,0 +1,25 @@
// Phase 1 (cpp-precision): a tainted env var is sanitised through
// `std::stoi` *before* being stored in a `std::vector<int>`. The vector
// itself is treated by the engine as a Store/Load container, but every
// value flowing through it is a sanitised integer, so neither the
// sprintf nor the `system()` sink should fire.
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
int main() {
char *input = std::getenv("PORT_NUM");
int port = std::stoi(input); // sanitiser: clears Cap::all()
std::vector<int> ports;
ports.push_back(port); // store sanitised int
int p = ports.front(); // load sanitised int
char buf[256];
std::snprintf(buf, sizeof(buf), "ping -c 1 -p %d localhost", p);
std::system(buf); // shell sink with no tainted data
return 0;
}

View file

@ -0,0 +1,26 @@
// Phase 5 (cpp-precision): a tainted env var flows through a fluent
// builder chain (`Socket::builder().host(h).port(p).connect()`). The
// terminal `.connect()` is the SSRF sink; the chained `.host(...)` /
// `.port(...)` calls return the receiver and carry argument taint
// onto the chain via the engine's default Call-arg propagation, so
// the tainted host reaches the connect sink.
#include <cstdlib>
#include <string>
class Socket {
public:
static Socket builder() { return Socket(); }
Socket& host(const std::string& h) { host_ = h; return *this; }
Socket& port(int p) { port_ = p; return *this; }
void connect() {}
private:
std::string host_;
int port_ = 0;
};
int main() {
char *user_host = std::getenv("REMOTE_HOST");
Socket::builder().host(user_host).port(8080).connect(); // SSRF sink
return 0;
}

View file

@ -0,0 +1,29 @@
package main
// Real-repo precision (2026-04-27): companion vulnerable counterpart to
// `safe/safe_chained_call_response_header.go`.
//
// The chained-call suppression added in
// `src/auth_analysis/config.rs::classify_sink_class` only gates the
// verb-name fallback on shapes whose receiver is itself a call result
// (`w.Header().Get(..)`). Bare-identifier receivers like `repo.Find`
// remain canonical data-layer sinks and must continue to fire
// `go.auth.missing_ownership_check` when invoked with a scoped
// identifier (`id` parameter) without a preceding ownership check.
type Repo struct{}
func (r *Repo) Find(id string) interface{} { return nil }
func (r *Repo) Save(id string, val string) {}
// `repo.Find(id)` — bare-identifier receiver, name matches the `Find`
// read indicator. Still classifies as `DbCrossTenantRead` and still
// fires the ownership check because no auth check precedes it.
func GetByID(ctx interface{}, repo *Repo, id string) interface{} {
return repo.Find(id)
}
// `repo.Save(id, ..)` — bare-identifier receiver, mutation indicator.
func UpdateByID(ctx interface{}, repo *Repo, id string, val string) {
repo.Save(id, val)
}

View file

@ -0,0 +1,15 @@
package main
import (
"net/http"
"os/exec"
)
func handler(w http.ResponseWriter, r *http.Request) {
cmds := []string{}
// No validate() call — tainted user input flows directly into the
// container's ELEM cell, then back out via the W5 `__index_get__`
// synth into exec.Command.
cmds = append(cmds, r.URL.Query().Get("cmd"))
_ = exec.Command(cmds[0]).Run()
}

View file

@ -0,0 +1,18 @@
// Counterpart to safe_error_log_only_function.go. The vulnerable
// shape is `if err != nil { log(...) } sink(tainted)` — the error is
// logged but execution falls through to a real sink that uses
// user-controlled input. This must still fire `cfg-error-fallthrough`
// after the False-edge-only walk fix.
package main
import (
"database/sql"
"fmt"
)
func handle(db *sql.DB, err error, name string) {
if err != nil {
fmt.Println("warn:", err)
}
db.Exec("UPDATE u SET n=" + name)
}

View file

@ -0,0 +1,32 @@
// CVE Hunt Session 4 regression — Go `if init; cond` taint flow.
// Tree-sitter exposes Go's `if x := f(...); x != nil { ... }` initializer
// under the `initializer` field of the `if_statement` node. The CFG
// builder's `Kind::If` arm previously skipped the init subtree entirely —
// any side-effect-bearing call inside the init was invisible to taint
// analysis, so flows like `if name := r.URL.Query().Get("p"); name != ""`
// followed by `os.Remove(filepath.Join(_, name))` looked clean to the
// engine even though the source-to-sink chain was right there.
//
// Owncast CVE-2024-31450 has this exact shape (`if err :=
// json.NewDecoder(r.Body).Decode(emoji); err != nil`) but the full chain
// also depends on chained-method-call lowering (the receiver of `Decode`
// is the result of `NewDecoder(r.Body)`, which Go's CFG currently merges
// into a single Call with text `"json.NewDecoder(r.Body).Decode"` and no
// separate inner-call SSA value). This fixture exercises the simpler
// init-only variant where the source is direct, isolating the if-init
// CFG fix from the chained-call gap.
package main
import (
"net/http"
"os"
"path/filepath"
)
func handler(w http.ResponseWriter, r *http.Request) {
if name := r.URL.Query().Get("name"); name != "" {
target := filepath.Join("data/uploads", name)
os.Remove(target)
}
_ = w
}

View file

@ -0,0 +1,21 @@
// CVE Hunt Session 2 regression — Go path traversal via `os.Remove`.
// Real-world Go path-traversal CVEs (Owncast CVE-2024-31450 emoji delete)
// sink into mutating filesystem helpers — `os.Remove`, `os.WriteFile`,
// `os.RemoveAll` — not the read-side `os.Open` / `os.ReadFile` family the
// Go ruleset previously covered. Joining a directory base to an HTTP-
// supplied filename and forwarding to `os.Remove` is a generic pattern
// across Go HTTP CRUD APIs.
package main
import (
"net/http"
"os"
"path/filepath"
)
func handler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
target := filepath.Join("data/uploads", name)
os.Remove(target)
_ = w
}

View file

@ -0,0 +1,30 @@
// CVE Hunt Session 4 regression — Go if-init taint, sanitized counterpart.
// Same shape as `path_traversal_ifinit.go` but the user-supplied filename
// is cleansed via `filepath.Base` (which is in the Go sanitizer set with
// `Cap::FILE_IO`) before being joined into the target path. The handler
// also runs an auth check so the unrelated `state-unauthed-access` rule
// does not fire — keeps this fixture as a clean negative for path-trav.
package main
import (
"net/http"
"os"
"path/filepath"
)
func handler(w http.ResponseWriter, r *http.Request) {
if !is_authenticated(r) {
http.Error(w, "unauthorized", 401)
return
}
if name := r.URL.Query().Get("name"); name != "" {
safe := filepath.Base(name)
target := filepath.Join("data/uploads", safe)
os.Remove(target)
}
_ = w
}
func is_authenticated(r *http.Request) bool {
return r.Header.Get("Authorization") != ""
}

View file

@ -0,0 +1,26 @@
// CVE Hunt Session 2 regression — negative pair to path_traversal_remove.go.
// `filepath.Base` strips any traversal segments before the join, so the
// resolved `target` is a child of `data/uploads`. Pin so the FILE_IO sink
// matcher additions don't start firing on properly-cleansed flows.
package main
import (
"net/http"
"os"
"path/filepath"
)
func handler(w http.ResponseWriter, r *http.Request) {
if !is_authenticated(r) {
http.Error(w, "unauthorized", 401)
return
}
name := filepath.Base(r.URL.Query().Get("name"))
target := filepath.Join("data/uploads", name)
os.Remove(target)
_ = w
}
func is_authenticated(r *http.Request) bool {
return r.Header.Get("Authorization") != ""
}

View file

@ -0,0 +1,54 @@
package main
import (
"net/http"
)
const (
AmzRequestID = "x-amz-request-id"
AmzRequestHosts = "x-amz-host-id"
)
// Real-repo precision (2026-04-27): minio's `cmd/api-response.go` and
// `cmd/admin-handlers.go` produced 70+ go.auth.missing_ownership_check
// findings on the chained-call shape `w.Header().Get(constName)`.
//
// `w.Header()` returns an `http.Header` (a `map[string][]string`) from
// the response writer's *outgoing* headers — reading a value back by a
// constant key is never auth-relevant. But the auth-analysis sink
// classifier saw the callee text `w.Header().Get`, matched `Get`
// against the `read_indicator_names` list (which prefix-matches), and
// classified the operation as `DbCrossTenantRead`. With the helper's
// `(ctx, w, ...)` parameter shape passing `unit_has_user_input_evidence`
// (the `ctx` name signals a request-context handler), a high-severity
// finding fired on the constant header lookup.
//
// Engine fix: in `src/auth_analysis/config.rs::classify_sink_class`,
// suppress the loose verb-name fallback (`is_read` / `is_mutation`)
// when the receiver chain itself contains a call expression
// (`w.Header().Get`, `r.URL.Query().Get`, `db.Tx(opts).Query`). The
// receiver of the final method is the return value of an earlier
// call — opaque to the analyser — and the bare verb match cannot
// safely conclude a data-layer sink without type tracking.
//
// Companion vulnerable counterpart: `vuln_repo_findbyid_no_auth.go` —
// `repo.Find(id)` (bare-identifier receiver) still classifies as a
// data-layer read and still fires.
func writeErrorResponse(ctx interface{}, w http.ResponseWriter, errCode int) {
// Reading from the response writer's outgoing headers using a
// constant key. Chained-call receiver `w.Header()` returns an
// in-process map; `.Get(...)` on it is in-memory bookkeeping, not
// a data-layer or network sink.
requestID := w.Header().Get(AmzRequestID)
hostID := w.Header().Get(AmzRequestHosts)
_ = requestID
_ = hostID
_ = errCode
}
func parseQueryFlag(r *http.Request) string {
// Same shape on the request side: `r.URL.Query()` returns
// `url.Values` (a map); `.Get(name)` is a constant-keyed lookup.
return r.URL.Query().Get("flag")
}

View file

@ -0,0 +1,44 @@
// Phase 3 of the field-projections rollout (2026-04-25): the textual
// proxy-receiver extractor in `state/transfer.rs` previously collapsed
// chained receivers to the chain root, FP-ing `c` as proxy-acquired.
//
// This fixture exercises the proper FieldProj-aware path: a method call
// `c.writer.header.set(...)` (3-segment receiver) must NOT mark `c` as
// proxy-acquired — `c.writer.header` is a semantically distinct chain
// receiver tracked separately in `ProductState.chain_proxies`.
//
// Companion to `safe_method_receiver_mutex.go` which exercises the
// 2-segment receiver case (`c.mu.Lock()` / `c.mu.Unlock()`). Together
// they pin the contract that the chain root never inherits proxy state
// from any of its field projections.
package main
type ResponseWriter interface {
Header() Header
}
type Header map[string][]string
func (h Header) set(key, value string) {
h[key] = []string{value}
}
type chainContext struct {
writer ResponseWriter
}
// Realistic chained-receiver shape: `c.writer.header.set(...)` lowers to
// 2 FieldProj ops + a Call. The state engine's chain_proxies map
// records the projection chain `c.writer.header` as the receiver, NOT
// the chain root `c`. No leak should fire on `c` even though no
// matching `release` is called.
func (c *chainContext) MarkSafe(key, value string) {
hdr := c.writer.Header()
_ = hdr
}
// Direct receiver (single dot) still routes through the SymbolId-based
// path — preserves all existing 1-dot proxy semantics. No leak on `c`.
func (c *chainContext) DirectReceiver() {
_ = c.writer
}

View file

@ -0,0 +1,30 @@
// `<lang>.auth.missing_ownership_check` previously fired on
// constant-bound `id` locals in test code:
// id := "id"
// value := "1"
// c.AddParam(id, value) // FP: `id` matches is_id_like, no
// // actor-context exemption, so the
// // rule fired.
// The fix tracks variables bound to literal constants (string,
// numeric, boolean) on `let` / `:=` / `var` / `const` declarations
// and excludes them from `is_relevant_target_subject`.
//
// Source: gin/context_test.go:TestContextAddParam
package main
type Context struct{ Params Params }
type Params struct{}
func (c *Context) AddParam(k, v string) {}
func (p Params) Get(k string) (string, bool) { return "", false }
func TestContextAddParam_likeShape() {
c := &Context{}
id := "id"
value := "1"
c.AddParam(id, value)
if v, ok := c.Params.Get(id); ok {
_ = v
}
}

View file

@ -0,0 +1,27 @@
// `cfg-error-fallthrough` previously fired on functions whose ONLY
// statement is `if err != nil { log(...) }` because the rule walked
// `cfg.neighbors(if_node)` (both True and False edges) and the True
// branch's body Fprintf was treated as a "post-if sink".
//
// In gin/debug.go the canonical shape is:
// func debugPrintError(err error) {
// if err != nil && IsDebugging() {
// fmt.Fprintf(DefaultErrorWriter, "[GIN-debug] %v\n", err)
// }
// }
// The function ends right after the if; there is no fallthrough sink.
// The rule must walk only the False edge so the body's Fprintf does
// not get counted.
package main
import "fmt"
var DefaultErrorWriter = (interface{})(nil)
func IsDebugging() bool { return false }
func debugPrintError(err error) {
if err != nil && IsDebugging() {
fmt.Fprintf(DefaultErrorWriter, "[GIN-debug] [ERROR] %v\n", err)
}
}

View file

@ -0,0 +1,28 @@
// Pointer-analysis Phase 2 fixture (gated on `NYX_POINTER_ANALYSIS=1`).
//
// The textual chain decomposition in `state/transfer.rs` already
// handles `c.mu.Lock()` by routing through `chain_proxies`, but the
// SSA-aliased shape below — `m := c.mu; m.Lock(); m.Unlock()` —
// previously fell through to the SymbolId proxy path because the
// receiver of the call is a single bare identifier (`m`), not a chain.
//
// With pointer analysis enabled the per-body PointsToFacts identify
// `m` as `Field(SelfParam, mu)` (PtrProxyHint::FieldOnly) and the
// state engine routes the acquire/release pair into chain_proxies
// instead of marking `m` as a leakable resource. No
// `state-resource-leak` / `state-resource-leak-possible` finding
// should fire here.
package main
import "sync"
type Container struct {
mu sync.Mutex
}
func (c *Container) Update() {
m := c.mu
m.Lock()
defer m.Unlock()
// mutation guarded by the field-aliased mutex
}

View file

@ -0,0 +1,56 @@
// `state-resource-leak-possible` and `state-resource-leak` previously
// fired on Go methods whose internal `c.mu.Lock()`/`defer c.mu.Unlock()`
// pattern was misclassified as proxy-acquiring the receiver `c`.
// Two engine bugs combined:
// 1. The proxy-receiver extractor took the ROOT identifier of any
// multi-segment callee (`c.writer.header().set` → `c`).
// 2. `state-resource-leak-possible` section 2b's exception-path
// heuristic ran on Go even though the comment scoped it to JS/TS.
//
// gin/context.go's `func (c *Context) Set(...)` is the canonical shape:
// a method whose body uses Lock/Unlock to guard mutation and whose
// receiver is NOT a resource handle.
package main
import "sync"
type Context struct {
mu sync.RWMutex
Keys map[any]any
Errors []error
}
func (c *Context) reset() {
c.Keys = nil
}
func (c *Context) Error(e error) error {
c.Errors = append(c.Errors, e)
return e
}
func (c *Context) Set(key any, value any) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Keys == nil {
c.Keys = make(map[any]any)
}
c.Keys[key] = value
}
func (c *Context) Get(key any) (value any, exists bool) {
c.mu.RLock()
defer c.mu.RUnlock()
value, exists = c.Keys[key]
return
}
// Realistic call-site that mixes direct Set with chained-receiver
// helpers — the FP previously fired here too because
// `c.writer.header().set` was misattributed to `c`.
func (c *Context) Process(key, value string) {
c.Set(key, value)
if v, ok := c.Get(key); ok {
_ = v
}
}

View file

@ -0,0 +1,93 @@
package main
// Real-repo precision (2026-04-27): hugo's `cache/filecache/filecache.go`
// produced ~48 go.auth.missing_ownership_check findings on bare-receiver
// `*Cache` method calls inside the `Cache` type's own methods —
// `c.getOrRemove(id)`, `c.removeIfExpired(id)`, `c.Fs.Create(filename)`.
//
// Each `c.foo(...)` call is an intra-struct dispatch from one
// `*Cache` method to another, parameterised by an internal cache key
// (`id`). Without type tracking the auth analyser cannot tell `c.Fs`
// is `afero.Fs` (in-process filesystem abstraction) from `*sql.DB`,
// so it would prefix-match the trailing verb (`Get`, `Save`, `Remove`,
// `Create`) against `read_indicator_names` / `mutation_indicator_names`
// and produce a HIGH-severity ownership-gap finding.
//
// Engine fix: in `src/auth_analysis/extract/common.rs::build_function_unit`,
// when the unit is a Go `method_declaration` (the only declaration kind
// that exposes a `receiver` field in tree-sitter-go), seed the receiver
// variable name into `state.non_sink_vars`. `classify_sink_class` then
// routes any `<receiver>.<chain>(...)` call to `SinkClass::InMemoryLocal`
// — which is excluded from `is_auth_relevant` — closing the entire
// hugo `*Cache`-method cluster in one structural step.
//
// The fix is conservative on the safe side: in `func (s *Service)
// Handle() { s.db.Query(sql) }`, the call would also be
// classified `InMemoryLocal`. When SSA type facts include the
// receiver root (the `apply_var_types_to_model` Phase B2 path), the
// type-derived class still wins via the override pass, so the fix
// only suppresses calls whose receiver type is unknown — which is the
// hugo case where over-firing was the entire problem.
//
// Companion vulnerable counterpart: `vuln_repo_findbyid_no_auth.go` —
// `repo.Find(id)` (bare-identifier receiver, NOT a method receiver)
// still classifies as a data-layer read and still fires.
type Cache struct {
Fs FsProxy
}
type FsProxy struct{}
func (f FsProxy) Create(name string) (string, error) {
_ = name
return "", nil
}
func (f FsProxy) Remove(name string) error {
_ = name
return nil
}
// Constant cache key — not user-controlled but the original FP also
// fired with internal id parameters, so this fixture pins the
// structural shape rather than the literal-bound variant.
const internalKey = "internal-cache-id"
func (c *Cache) getOrRemove(id string) (string, error) {
// Intra-struct dispatch: `c.removeIfExpired` is another `*Cache`
// method, not a data-layer call. Without the receiver-name
// suppression, this would prefix-match `Remove` and fire
// `go.auth.missing_ownership_check`.
if err := c.removeIfExpired(id); err != nil {
return "", err
}
// `c.Fs.Create(...)` — receiver chain `c.Fs`, first segment is
// the method receiver `c`; without the fix this would
// prefix-match `Create` (mutation indicator) and fire.
if path, err := c.Fs.Create(id); err == nil {
_ = path
}
return id, nil
}
func (c *Cache) removeIfExpired(id string) error {
// Same shape as `c.Fs.Create` but with a remove verb on the
// in-process filesystem proxy.
return c.Fs.Remove(id)
}
func (c *Cache) IndexFor(prefix string) string {
// Bare-receiver internal getter — exemplifies the prefix-match
// case. Without the fix this would prefix-match `Get`.
_ = prefix
out, _ := c.getOrRemove(internalKey)
return out
}
func main() {
c := &Cache{}
_, _ = c.getOrRemove("k")
_ = c.removeIfExpired("k")
_ = c.IndexFor("p")
}

View file

@ -0,0 +1,23 @@
package main
import (
"net/http"
"os/exec"
"strings"
)
// validate strips shell metacharacters; the returned string is safe
// to pass to exec.Command. Modeled as a sanitiser by the engine.
func validate(s string) string {
return strings.ReplaceAll(s, ";", "")
}
func handler(w http.ResponseWriter, r *http.Request) {
cmds := []string{}
cmds = append(cmds, validate(r.URL.Query().Get("cmd")))
// Index-read on a slice that only ever received validated values.
// Exercises the W4 cell-shape change: validated_must on `cmds`'s
// ELEM cell must AND-propagate through the W5 `__index_get__`
// synth so the sink sees a sanitised value.
_ = exec.Command(cmds[0]).Run()
}

View file

@ -0,0 +1,22 @@
// CVE Hunt Session 2 regression — negative pair to ssrf_default_client_get.go.
// Hard-coded URL handed to `http.DefaultClient.Get`; no taint reaches the
// sink. Pin so a future overshoot of the new SSRF matcher (`http.DefaultClient.*`)
// can't quietly start firing on safe constant-URL helper code.
package main
import (
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
if !is_authenticated(r) {
http.Error(w, "unauthorized", 401)
return
}
http.DefaultClient.Get("https://api.internal.example.com/health")
_ = w
}
func is_authenticated(r *http.Request) bool {
return r.Header.Get("Authorization") != ""
}

View file

@ -0,0 +1,14 @@
// CVE Hunt Session 2 regression — Go SSRF via `http.DefaultClient.Get`.
// Real-world Go SSRF (Owncast CVE-2023-3188) uses the package-level
// shared client rather than the bare `http.Get` helper. A previous label
// rule that only included `http.Get` missed every CVE in this shape.
package main
import (
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
url := r.URL.Query().Get("url")
http.DefaultClient.Get(url)
}

View file

@ -0,0 +1,40 @@
import javax.persistence.EntityManager;
import java.util.List;
// Distilled from keycloak's
// `model/jpa/src/main/java/org/keycloak/events/jpa/JpaEventStoreProvider.java`:
// `em.createQuery(LITERAL).executeUpdate()` and the bind-parameter chain
// `em.createQuery(LITERAL).setParameter(...).executeUpdate()` are the
// canonical JPA parameterised-execute shapes. The terminal zero-arg
// `executeUpdate` / `executeQuery` is not where SQL is built the SQL
// was bound upstream from a string literal, and any per-call values
// arrive through `setParameter`, which the JPA driver escapes. Engine
// must synthesise a same-node `Sanitizer(SQL_QUERY)` so neither
// `cfg-unguarded-sink` nor `taint-unsanitised-flow` fires.
public class SafeJpaParameterizedExecute {
private final EntityManager em;
public SafeJpaParameterizedExecute(EntityManager em) {
this.em = em;
}
public void clearAll() {
em.createQuery("delete from EventEntity").executeUpdate();
}
public void clearOlder(long olderThan) {
em.createQuery("delete from EventEntity where time < :time")
.setParameter("time", olderThan)
.executeUpdate();
}
public List<?> listAll() {
return em.createQuery("select e from EventEntity e").getResultList();
}
public void clearByRealm(String realmId) {
em.createNativeQuery("delete from event_entity where realm_id = ?")
.setParameter(1, realmId)
.executeUpdate();
}
}

View file

@ -0,0 +1,34 @@
// `cfg-error-fallthrough` previously fired on
// `if (logger.isErrorEnabled()) { print(...) } more_code()`
// because the condition_vars contained `isErrorEnabled` and the
// rule's `lower.contains("err")` heuristic matched it as an error
// identifier. Spring's SpringApplication.java:854 has this exact
// shape a logging-enabled gate, not an error check.
//
// The fix (`is_error_var_ident`) restricts the err identifier to
// `err` / `error` (case-insensitive) or snake-case `err_*` / `*_err`
// / `error_*` / `*_error` camelCase method names like
// `isErrorEnabled` / `getError` no longer trigger the rule.
public class SafeLoggerIsEnabled {
interface Logger {
boolean isErrorEnabled();
}
private final Logger logger;
public SafeLoggerIsEnabled(Logger logger) {
this.logger = logger;
}
public void run(String failure) {
if (logger.isErrorEnabled()) {
System.out.println("Application run failed: " + failure);
}
registerLoggedException(failure);
}
private void registerLoggedException(String failure) {
// sink that runs unconditionally after the if
}
}

View file

@ -0,0 +1,20 @@
import javax.persistence.EntityManager;
// Vulnerable counterpart to SafeJpaParameterizedExecute. When the SQL
// passed to `createQuery` is built by string concatenation, the same
// `.executeUpdate()` chain shape is no longer parameterised the
// receiver-chain walk sees a `binary_expression` at arg 0 of
// `createQuery`, not a `string_literal`, and refuses to synthesise the
// reflexive `Sanitizer(SQL_QUERY)`. The structural sink stays in place
// and a finding is expected.
public class SqliJpaCreateQueryConcat {
private final EntityManager em;
public SqliJpaCreateQueryConcat(EntityManager em) {
this.em = em;
}
public void clearByName(String name) {
em.createQuery("delete from EventEntity where name = '" + name + "'").executeUpdate();
}
}

View file

@ -0,0 +1,14 @@
// Express handler shape: `req.user.id` is the authenticated user
// (passport / express-session). The local `userId` copy of
// `req.user.id` is a self-actor-id and must not trigger the rule.
async function getApiKeysFromUserId(_userId) {
return [];
}
async function handler(req, _res) {
const userId = req.user.id;
const apiKeys = await getApiKeysFromUserId(userId);
return apiKeys;
}
module.exports = { handler };

View file

@ -0,0 +1,10 @@
const express = require('express');
const http = require('http');
const app = express();
app.get('/health', (req, res) => {
http.get('http://internal-health.localhost/ping', response => {
res.send('ok');
}).on('error', e => {
res.status(500).send(e.message);
});
});

View file

@ -0,0 +1,11 @@
const express = require('express');
const http = require('http');
const app = express();
app.get('/probe', (req, res) => {
const target = req.query.url;
http.get(target, response => {
res.send('ok');
}).on('error', e => {
res.status(500).send(e.message);
});
});

View file

@ -0,0 +1,9 @@
<?php
// Vulnerable counterpart to safe_unserialize_allowed_classes.php:
// `allowed_classes => true` is the unsafe default — every class becomes
// constructable, which is what makes PHP unserialize a classic object
// injection sink. This must still fire `php.deser.unserialize`.
$blob = $_POST['payload'];
$obj = unserialize($blob, ['allowed_classes' => true]);
echo $obj;

View file

@ -0,0 +1,11 @@
<?php
// Vulnerable counterpart to safe_include_param_passthrough.php:
// the included variable is built by concatenation inside the function
// from user-controlled input — not a parameter pass-through. This
// must still fire `php.path.include_variable`.
function loadFromQuery(): void {
$base = __DIR__ . '/templates/';
$file = $base . $_GET['template'] . '.php';
include $file;
}

View file

@ -0,0 +1,28 @@
<?php
// Composer-style autoloader: a closure or method takes a file path
// parameter and `include`s it. The pattern rule is heuristic without
// taint, and over-fires here — the include target has no connection to
// user input within this function. If a caller passes a tainted value,
// the engine's separate taint pipeline still flags it.
class ScopeIsolatedLoader {
public static $includeFile = null;
public static function init(): void {
// Closure parameter pass-through (composer ClassLoader idiom).
self::$includeFile = static function ($file) {
include $file;
};
}
// Method parameter pass-through.
protected function requireRouteFile(string $file, string $appName): void {
include $file;
}
private static function includeAppScript(string $script): void {
if (file_exists($script)) {
include $script;
}
}
}

View file

@ -0,0 +1,21 @@
<?php
// PHP 7+ structural mitigation against object injection: explicit
// `allowed_classes` option restricts which classes the deserialiser may
// instantiate. Three safe forms below; none should fire
// `php.deser.unserialize`.
class Demo {
private const ALLOWED = [Foo::class, Bar::class];
public function fromString(string $blob): mixed {
return unserialize($blob, ['allowed_classes' => false]);
}
public function fromArray(string $blob): mixed {
return unserialize($blob, ['allowed_classes' => [Foo::class, Bar::class]]);
}
public function fromConstant(string $blob): mixed {
return unserialize($blob, ['allowed_classes' => self::ALLOWED]);
}
}

View file

@ -0,0 +1,23 @@
"""py-auth-realrepo-004: vulnerable Django invitation-acceptance view.
This is the genuine token-acceptance shape: a request handler that
looks up an invitation by an attacker-supplied token, then writes
through token-bound state (`invitation.user.email`) without
validating the token's expiry or recipient identity. The boolean
`||` fallback into request-supplied data (`new_email or
invitation.user.email`) is the override pattern.
"""
from django.http import HttpResponse
def accept_invitation_view(request, token):
new_email = request.POST.get("email", "")
invitation = invitation_lookup(token)
invitation.user.email = new_email or invitation.user.email
invitation.user.save()
return HttpResponse("ok")
def invitation_lookup(token):
return Invitation.objects.get(token=token)

View file

@ -0,0 +1,19 @@
"""py-auth-realrepo-003: Celery `@app.task` with no parameters.
`handle_promotion_toggle()` is invoked by the Celery scheduler it
takes no arguments and reads no request-shaped state. The
`Promotion.objects.filter(id__in=promotion_ids)` call has an
id-shaped subject (`promotion_ids` ends with `ids`) but the entire
unit is backend computation: there is no caller-supplied identifier,
no request parameter, no session. The ownership-gap rule must not
fire.
"""
from saleor.celeryconf import app
@app.task
def handle_promotion_toggle():
promotions = Promotion.objects.filter(active=True).all()
promotion_ids = [p.id for p in promotions]
Promotion.objects.filter(id__in=promotion_ids).update(notified=True)

View file

@ -0,0 +1,22 @@
"""py-auth-realrepo-001: Django data-migration `RunPython` callback.
Receives `(apps, schema_editor)` from the migration runtime neither
is user-controlled. Writing through `account.token = get_token();
account.save()` looks token-y on its surface (the assignment text
literally contains `token =`) but the function is a backend-only
schema-evolution step. No user reach, so no token-acceptance flow
can possibly happen here.
"""
import uuid
def get_token():
return str(uuid.uuid4())
def create_uuid(apps, schema_editor):
User = apps.get_model("account", "User")
for account in User.objects.all():
account.token = get_token()
account.save()

View file

@ -0,0 +1,21 @@
"""py-auth-realrepo-002: pytest `conftest.py` collection hook.
`pytest_collection_modifyitems(config, items)` is invoked by pytest
itself; it never sees user input. The marker mutation
`item.add_marker(skip_slow)` plus the surrounding boolean-OR
expression text is enough to satisfy the legacy token-override
heuristic by accident, but the function has zero user reach.
"""
import pytest
def pytest_collection_modifyitems(config, items):
if config.getoption("--run-slow"):
return
skip_slow = pytest.mark.skip(reason="test is marked as slow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)

View file

@ -0,0 +1,15 @@
# Counterpart to `safe_post_fetch_ownership_check.rb`: same controller
# shape, but the action *omits* the per-record permission check. The
# row is loaded by user-controlled id and used directly without any
# ownership gate. Engine must keep flagging this as
# `rb.auth.missing_ownership_check` even though the safe-shape fixtures
# train the row-fetch exemption on the same `Issue.find(params[:id])`
# pattern.
class IssuesController < ApplicationController
before_action :authenticate_user!
def reveal
@issue = Issue.find(params[:id])
render :show
end
end

View file

@ -0,0 +1,14 @@
# Synthetic regression for the Ruby `Kernel#open` CMDI gap surfaced by
# CVE-2020-8130 (rake `Rake::FileList#egrep`). Bare `open(path)`
# interprets a path beginning with `|` as a shell command —
# `open("|cmd")` runs `cmd` and pipes its output into the block.
# Pinned via the `=open` exact-matcher in `src/labels/ruby.rs` so a
# refactor that drops the bare-form distinction (e.g. demotes the rule
# back to a generic suffix matcher) re-fires on `File.open` and breaks
# this test.
def grep_logs(params)
filename = params[:file]
open(filename, "r") do |inf|
inf.each_line { |l| puts l }
end
end

View file

@ -0,0 +1,28 @@
# Rails ActiveRecord query methods (where/order/group/having/joins/pluck) are
# SQLi sinks ONLY when arg 0 is a string with `#{...}` interpolation. The hash
# form (`where(id: x)`), symbol form (`order(:created_at)`), and parameterised
# string form (`where("a = ?", x)`) are intrinsically safe — Rails escapes the
# values. This fixture exercises every safe arg-0 shape.
#
# Engine must NOT raise SQL_QUERY-class findings (no taint-unsanitised-flow,
# no cfg-unguarded-sink) on any of these calls. Auth-style findings on the
# methods that read by user-controlled id are deliberately avoided here by
# preceding every action with `before_action :authenticate_user!` (the auth
# analysis recognises this as a class-level guard) and using non-id args.
class IssuesController < ApplicationController
before_action :authenticate_user!
def safe_listings
Issue.where(deleted: false)
Issue.where(:status => 'open')
Issue.where("active = ?", true)
Issue.where("name = :name", name: 'foo')
Project.order(:created_at)
Issue.pluck(:id, :title)
Project.group(:status)
Issue.joins(:project, :assigned_to)
Issue.group(:project_id).having("count(*) > 1")
Issue.where(deleted: false).preload(:project, :status).to_a
Issue.where(deleted: false).order(:created_at).pluck(:id, :title)
end
end

View file

@ -0,0 +1,16 @@
# Negative regression for the Ruby `=open` exact-matcher. `File.open`
# (and `IO.open`, `URI.open`) must not fire the bare-`open` CMDI rule.
# Each namespaced form has non-piping semantics, so even with attacker-
# controlled paths the CMDI vector is closed. Pairs with
# `cmdi/cmdi_kernel_open.rb`.
#
# A hardcoded filename is used so this file is also a clean TN at the
# benchmark level — the existing `File.open` FILE_IO matcher is
# orthogonal to the CMDI regression we're guarding here, so we don't
# want any taint flow either way.
def read_log
filename = "/var/log/audit.log"
File.open(filename, "r") do |inf|
inf.each_line { |l| puts l }
end
end

View file

@ -0,0 +1,41 @@
# Rails canonical "load by id, then check ownership" idiom: the row is
# fetched by user-controlled id, and a per-record permission predicate
# (`visible?`, `editable?`, `deletable?`, …) immediately after is the
# ownership gate. The check is textually AFTER the fetch, so the
# baseline `check.line <= op.line` rule cannot cover the row-fetch op
# directly; the row-fetch exemption (`row_population_data` reverse-walk
# in `auth_check_covers_subject`) closes the gap by recognising the row
# variable as the auth check's subject.
#
# Engine must NOT raise `rb.auth.missing_ownership_check` on any of
# these actions.
class IssuesController < ApplicationController
before_action :authenticate_user!
def show
@issue = Issue.find(params[:id])
raise Unauthorized unless @issue.visible?
render :show
end
def edit
@issue = Issue.find(params[:id])
return render_403 unless @issue.editable?
end
def destroy
@issue = Issue.find(params[:id])
return render_403 unless @issue.deletable?
@issue.destroy
end
def comment
@issue = Issue.find(params[:id])
raise Unauthorized unless @issue.commentable?
end
def via_owned_by
@record = Record.find(params[:id])
raise Unauthorized unless @record.owned_by?(current_user)
end
end

View file

@ -0,0 +1,11 @@
# Real-world chained shape: `Model.where("...#{x}...").preload(...).to_a`.
# The CFG collapses chained calls into one node whose outermost callee is
# `to_a` (no args). The shape suppressor has to walk the receiver chain to
# reach the inner `where(...)` and detect the interpolation. Engine must
# still flag this — chain-walking only suppresses SAFE shapes.
class UsersController < ApplicationController
def search
name = params[:name]
@users = User.where("name = '#{name}'").preload(:roles).to_a
end
end

View file

@ -0,0 +1,11 @@
# Counterpart to `safe_active_record_query_shapes.rb`. The same `where`
# method becomes a real SQLi sink when arg 0 is a string with `#{...}`
# interpolation — Rails inlines the value verbatim. Engine must keep
# flagging this shape (taint-unsanitised-flow) even though the safe-shape
# fixtures sit alongside in the corpus.
class UsersController < ApplicationController
def search
name = params[:name]
User.where("name = '#{name}'")
end
end

View file

@ -0,0 +1,43 @@
// Phase A4: `is_<role>_or_<role>` predicate authorization shape.
// Authorization helpers named as boolean predicates (`is_admin`,
// `is_mod_or_admin`, `is_owner_and_admin`) are the standard Rails /
// Lemmy / Diesel idiom for role checks. The structural recogniser
// accepts the `is_<role>` and `is_<role>_(or|and)_<role>` shapes when
// every conjunct is a known authorization role token.
use std::result::Result;
pub struct LocalUserView;
pub struct Pool;
pub struct CommentView;
pub struct ListCommentLikes { pub comment_id: i64 }
pub struct CommunityRef { pub id: i64 }
pub struct CommentRow { pub community: CommunityRef }
impl CommentView {
pub fn read(_pool: &mut Pool, _id: i64) -> Result<CommentRow, ()> { unimplemented!() }
}
// Predicate auth check: each conjunct (`mod`, `admin`) is a known role.
pub fn is_mod_or_admin(_pool: &mut Pool, _uv: &LocalUserView, _community_id: i64) -> Result<(), ()> {
Ok(())
}
pub async fn list_comment_likes(
req: ListCommentLikes,
pool: &mut Pool,
local_user_view: LocalUserView,
) -> Result<CommentRow, ()> {
// Fetch row first to obtain community id for the role check.
let comment_view = CommentView::read(pool, req.comment_id)?;
// Predicate role check authorises the fetched row. Row-fetch
// exemption suppresses the `CommentView.read` finding above:
// `is_mod_or_admin` matches the `is_<role>_or_<role>` shape and
// its third arg `comment_view.community.id` chains back to the
// row var `comment_view`.
is_mod_or_admin(pool, &local_user_view, comment_view.community.id)?;
Ok(comment_view)
}

View file

@ -0,0 +1,62 @@
// Phase A4: row-level "fetch-then-authorize" idiom. The handler fetches
// the row by id first to obtain the resource it needs to authorize, then
// calls a named authorization function on the fetched row. This is the
// canonical pattern in Lemmy's Actix handlers (and most row-level Rails /
// Django authz code) — the authorization check appears textually after the
// fetch but is the first thing the function does on the row.
use std::result::Result;
pub struct DistinguishComment { pub comment_id: i64 }
pub struct LocalUserView { pub person: Person, pub local_user: LocalUser }
pub struct Person { pub id: i64, pub instance_id: i64 }
pub struct LocalUser;
pub struct Pool;
pub struct CommentView { pub community: Community, pub creator: Creator }
pub struct Community;
pub struct Creator { pub id: i64 }
pub struct Comment;
pub struct CommentResponse;
impl CommentView {
pub fn read(_pool: &mut Pool, _id: i64, _u: Option<&LocalUser>, _i: i64) -> Result<CommentView, ()> {
unimplemented!()
}
}
impl Comment {
pub fn update(_pool: &mut Pool, _id: i64, _form: &()) -> Result<Comment, ()> { unimplemented!() }
}
// Lemmy-style auth function: `check_<resource>_<role>_action`.
pub fn check_community_user_action(_uv: &LocalUserView, _c: &Community, _p: &mut Pool) -> Result<(), ()> { Ok(()) }
pub fn check_community_mod_action(_uv: &LocalUserView, _c: &Community, _b: bool, _p: &mut Pool) -> Result<(), ()> { Ok(()) }
pub async fn distinguish_comment(
req: DistinguishComment,
pool: &mut Pool,
local_user_view: LocalUserView,
) -> Result<CommentResponse, ()> {
let local_instance_id = local_user_view.person.instance_id;
// Fetch the row first to obtain `community` for authz.
let orig_comment = CommentView::read(
pool,
req.comment_id,
Some(&local_user_view.local_user),
local_instance_id,
)?;
// Auth check on the fetched row's resource. Engine recognises the
// `check_<resource>_<role>_action` shape, sees `orig_comment` as a
// subject, and applies the row-fetch exemption to the read above.
check_community_user_action(&local_user_view, &orig_comment.community, pool)?;
if local_user_view.person.id != orig_comment.creator.id {
return Err(());
}
check_community_mod_action(&local_user_view, &orig_comment.community, false, pool)?;
Ok(CommentResponse)
}

View file

@ -0,0 +1,45 @@
// Phase 6 D05: an Axum `Json<UpdateDoc>` extractor whose `doc_id`
// field is declared as `i64`. The DTO field-level taint analysis
// proves the value reaching `db.exec` is numeric and exempts
// `dto.doc_id` from the auth subject classifier — the rule must NOT
// fire because numeric DTO fields cannot bypass ownership.
use axum::extract::Json;
#[derive(serde::Deserialize)]
pub struct UpdateDoc {
pub doc_id: i64,
pub email: String,
}
struct Ctx;
struct Req;
struct User { id: i64 }
struct Db;
impl Db {
fn query_one(&self, _s: &str, _a: &[i64]) -> Row { Row }
fn exec(&self, _s: &str, _a: &[i64]) {}
}
struct Row;
impl Row {
fn get_i64(&self, _c: &str) -> i64 { 0 }
}
mod auth {
pub async fn require_auth(_r: &super::Req, _c: &super::Ctx) -> Result<super::User, ()> {
Ok(super::User { id: 1 })
}
}
pub async fn handle_update_doc(
req: Req,
ctx: Ctx,
Json(dto): Json<UpdateDoc>,
) -> Result<String, ()> {
let _user = auth::require_auth(&req, &ctx).await?;
let db = Db;
let _existing = db.query_one(
"SELECT user_id FROM docs WHERE id = ?1",
&[dto.doc_id],
);
db.exec("UPDATE docs SET updated = 1 WHERE id = ?1", &[dto.doc_id]);
Ok("ok".into())
}

View file

@ -0,0 +1,57 @@
// Real-repo motivation (lemmy `LocalUserView` extractor).
//
// Lemmy's authenticated-actor extractor type is named `LocalUserView`
// — every route handler signature is
// `pub async fn handler(.., local_user_view: LocalUserView)`. The
// previous exact-name list in `is_self_actor_type_text`
// (`CurrentUser`, `SessionUser`, `AuthUser`, `AdminUser`,
// `AuthenticatedUser`, `RequireAuth`, `RequireLogin`,
// `Authenticated`) missed it, so subjects rooted in
// `local_user_view.*` weren't recognised as the caller's own id and
// any access of `local_user_view.person.id` flagged.
//
// The structural `<PREFIX>User<SUFFIX>?` recogniser now accepts
// `LocalUserView`, so this self-actor read on the principal must NOT
// flag `rs.auth.missing_ownership_check`.
use std::result::Result;
pub struct LocalUserView {
pub person: Person,
}
pub struct Person {
pub id: i64,
pub name: String,
}
pub struct Form {
pub note: String,
}
pub struct Pool;
pub struct UserActions;
impl UserActions {
pub fn record_self_note(_pool: &mut Pool, _id: i64, _note: String) -> Result<(), ()> {
Ok(())
}
}
pub fn is_admin(_uv: &LocalUserView) -> Result<(), ()> {
Ok(())
}
pub async fn write_self_note(
req: Form,
pool: &mut Pool,
local_user_view: LocalUserView,
) -> Result<(), ()> {
// Login predicate on the actor itself — subject is the actor.
// No additional ownership check needed because the subject is the
// caller's own row.
let _ = is_admin(&local_user_view);
// `local_user_view.person.id` is the caller's own id. With
// `LocalUserView` recognised as a self-actor type, this passes
// through `is_actor_context_subject`.
UserActions::record_self_note(pool, local_user_view.person.id, req.note)?;
Ok(())
}

View file

@ -0,0 +1,65 @@
// Real-repo regression (lemmy `comment/lock.rs:31`).
//
// `let orig_comment = CommentView::read(...)` split across two lines
// (the call body wraps onto the next line for readability). Before
// the line-counting fix, `row_population_data` recorded the
// `let_declaration`'s start row while `op.line` saw the inner call's
// start row — they differed by one and the row-fetch exemption
// missed. Recording the **call**'s start line aligns the two and
// the exemption fires for the multi-line shape too.
use std::result::Result;
pub struct LockComment {
pub comment_id: i64,
}
pub struct LocalUserView {
pub person: Person,
}
pub struct Person {
pub instance_id: i64,
}
pub struct Pool;
pub struct CommentView {
pub community: Community,
pub comment: Comment,
}
pub struct Community;
pub struct Comment {
pub path: String,
}
impl CommentView {
pub fn read(_pool: &mut Pool, _id: i64, _opt: Option<()>, _i: i64) -> Result<CommentView, ()> {
unimplemented!()
}
}
pub fn check_community_mod_action(
_uv: &LocalUserView,
_c: &Community,
_b: bool,
_p: &mut Pool,
) -> Result<(), ()> {
Ok(())
}
pub async fn lock_comment(
req: LockComment,
pool: &mut Pool,
local_user_view: LocalUserView,
) -> Result<(), ()> {
let comment_id = req.comment_id;
let local_instance_id = local_user_view.person.instance_id;
// Multi-line let — the let_declaration starts on this line, but
// the inner `CommentView::read(..)` call starts on the next line.
// `op.line` for the read sink is the call's line, not the let's.
let orig_comment =
CommentView::read(pool, comment_id, None, local_instance_id)?;
// Auth check on the fetched row.
check_community_mod_action(&local_user_view, &orig_comment.community, false, pool)?;
Ok(())
}

View file

@ -0,0 +1,102 @@
// Real-repo regression (lemmy `community/transfer.rs`,
// `comment/distinguish.rs`).
//
// `let community = Community::read(pool, req.community_id)` records
// `community → [req.community_id]` in `row_population_data`. An auth
// check `check_community_user_action(&user, &community, ..)` then
// authorises the row — and any **downstream** operation that re-uses
// `req.community_id` (a later mutation by the same id, or a related
// view fetched by the same id) is materially covered by that check.
//
// Before the row-population reverse-walk, only the row-fetch site
// itself was exempted. Lemmy-style handlers commonly re-use the
// original request id after the check (delete-by-id, fetch-related-
// view) and those re-uses fired `rs.auth.missing_ownership_check`
// despite the textual auth check on the fetched row.
use std::result::Result;
pub struct TransferCommunity {
pub community_id: i64,
pub person_id: i64,
}
pub struct LocalUserView {
pub person: Person,
pub local_user: LocalUser,
}
pub struct Person {
pub id: i64,
}
pub struct LocalUser;
pub struct Pool;
pub struct Community {
pub id: i64,
}
pub struct CommunityActions;
pub struct CommunityView;
impl Community {
pub fn read(_pool: &mut Pool, _id: i64) -> Result<Community, ()> {
unimplemented!()
}
}
impl CommunityActions {
pub fn delete_mods_for_community(_pool: &mut Pool, _id: i64) -> Result<(), ()> {
Ok(())
}
}
impl CommunityView {
pub fn read(
_pool: &mut Pool,
_id: i64,
_u: Option<&LocalUser>,
_b: bool,
) -> Result<CommunityView, ()> {
unimplemented!()
}
}
pub fn check_community_user_action(
_uv: &LocalUserView,
_c: &Community,
_p: &mut Pool,
) -> Result<(), ()> {
Ok(())
}
pub async fn transfer_community(
req: TransferCommunity,
pool: &mut Pool,
local_user_view: LocalUserView,
) -> Result<(), ()> {
// Row fetch — `community` is populated from `req.community_id`.
let community = Community::read(pool, req.community_id)?;
// Authorisation check on the fetched row. Subject = `community`
// (chain root match in `auth_check_covers_subject`).
check_community_user_action(&local_user_view, &community, pool)?;
// Downstream mutation re-using the original request field. The
// engine's row-population reverse-walk treats `req.community_id`
// as covered by the check above (the check authorised access to
// the row that was fetched with this id).
CommunityActions::delete_mods_for_community(pool, req.community_id)?;
// Local alias of the same request field — `var_alias_chain`
// records `community_id → "req.community_id"` so the reverse-walk
// also covers downstream sinks that pass the bare alias. Before
// the alias-chain fix, the next read fired
// `rs.auth.missing_ownership_check` despite the textual auth
// check on `community` above.
let community_id = req.community_id;
let _community_view = CommunityView::read(
pool,
community_id,
Some(&local_user_view.local_user),
false,
)?;
Ok(())
}

View file

@ -0,0 +1,16 @@
// Phase 5 typed-extractor exclusion: an Axum-style `Path<i64>`
// parameter is a framework-validated numeric extractor. The runtime
// guarantees a numeric value, so even though `project_id` reaches a
// SQL helper, the rule must NOT fire — the value cannot carry an
// injection payload nor bypass ownership.
use axum::extract::Path;
struct Db;
impl Db {
fn fetch(&self, _q: &str, _a: &[i64]) {}
}
pub async fn read_project(Path(project_id): Path<i64>) {
let db = Db;
db.fetch("SELECT * FROM projects WHERE id = ?1", &[project_id]);
}

View file

@ -0,0 +1,43 @@
// Phase 6 D06 (negative): same DTO shape as
// `safe_dto_int_field_axum.rs` but the flow uses the `doc_id` field
// whose declared type is `String`. Phase 6 must NOT exempt the
// member-access subject — String DTO fields can carry an injection
// payload, so the auth rule must continue to fire.
use axum::extract::Json;
#[derive(serde::Deserialize)]
pub struct UpdateDoc {
pub doc_id: String,
pub email: String,
}
struct Ctx;
struct Req;
struct User { id: i64 }
struct Db;
impl Db {
fn query_one(&self, _s: &str, _a: &[&str]) -> Row { Row }
fn exec(&self, _s: &str, _a: &[&str]) {}
}
struct Row;
mod auth {
pub async fn require_auth(_r: &super::Req, _c: &super::Ctx) -> Result<super::User, ()> {
Ok(super::User { id: 1 })
}
}
pub async fn handle_update_doc(
req: Req,
ctx: Ctx,
Json(dto): Json<UpdateDoc>,
) -> Result<String, ()> {
let _user = auth::require_auth(&req, &ctx).await?;
let db = Db;
let doc_id = dto.doc_id.as_str();
let _existing = db.query_one(
"SELECT user_id FROM docs WHERE id = ?1",
&[doc_id],
);
db.exec("UPDATE docs SET updated = 1 WHERE id = ?1", &[doc_id]);
Ok("ok".into())
}

View file

@ -0,0 +1,44 @@
// Negative counterpart for the `LocalUserView` self-actor recogniser.
//
// Even when the handler takes a typed `LocalUserView` extractor, a
// sink that reads or mutates a row by **someone else's** id is still
// missing-ownership-check. The actor recogniser only suppresses
// subjects rooted in `local_user_view.*` (the actor's own fields);
// foreign scoped ids (`req.target_user_id`) must continue to flag.
//
// Regression guard against an over-broad recogniser that would treat
// any handler with a self-actor extractor as "authorised by default".
use std::result::Result;
pub struct LocalUserView {
pub person: Person,
}
pub struct Person {
pub id: i64,
}
pub struct Form {
pub target_user_id: i64,
pub note: String,
}
pub struct Pool;
pub struct UserActions;
impl UserActions {
pub fn add_note_for_user(_pool: &mut Pool, _target: i64, _note: String) -> Result<(), ()> {
Ok(())
}
}
pub async fn add_note_about_user(
req: Form,
pool: &mut Pool,
local_user_view: LocalUserView,
) -> Result<(), ()> {
// Authentication is established (`local_user_view` is a typed
// self-actor extractor) but no ownership/membership check
// gates the foreign id `req.target_user_id`. Must flag.
let _ = local_user_view.person.id;
UserActions::add_note_for_user(pool, req.target_user_id, req.note)?;
Ok(())
}

View file

@ -0,0 +1,27 @@
// Vulnerable counterpart to `row_fetch_then_authorize.rs` — the row is
// fetched by user-supplied id but no authorization function names it.
// The row-fetch exemption must NOT fire here; the rule should still
// flag the read as missing an ownership/membership check.
use std::result::Result;
pub struct DataReq { pub comment_id: i64 }
pub struct LocalUserView;
pub struct Pool;
pub struct CommentView;
impl CommentView {
pub fn read(_p: &mut Pool, _id: i64) -> Result<CommentView, ()> { unimplemented!() }
pub fn delete(_p: &mut Pool, _id: i64) -> Result<(), ()> { unimplemented!() }
}
// NOTE: no authorization check is invoked anywhere in this handler.
pub async fn unsafe_handler(
req: DataReq,
pool: &mut Pool,
_local_user_view: LocalUserView,
) -> Result<(), ()> {
let _orig = CommentView::read(pool, req.comment_id)?;
CommentView::delete(pool, req.comment_id)?;
Ok(())
}

View file

@ -0,0 +1,45 @@
// Negative counterpart for the row-population reverse-walk.
//
// Same fetch-then-mutate shape as `safe_row_population_reverse_walk.rs`
// but **no auth check** is performed on the fetched row. The
// reverse-walk is purely structural: when no check covers `community`,
// the downstream mutation by `req.community_id` must still flag.
// Guards against the fix over-suppressing the unsafe shape.
use std::result::Result;
pub struct TransferCommunity {
pub community_id: i64,
}
pub struct Pool;
pub struct Community {
pub id: i64,
}
pub struct CommunityActions;
impl Community {
pub fn read(_pool: &mut Pool, _id: i64) -> Result<Community, ()> {
unimplemented!()
}
}
impl CommunityActions {
pub fn delete_mods_for_community(_pool: &mut Pool, _id: i64) -> Result<(), ()> {
Ok(())
}
}
pub async fn transfer_community(
req: TransferCommunity,
pool: &mut Pool,
) -> Result<(), ()> {
// Row fetch — populates `community → [req.community_id]` — but
// no `check_*_action(&user, &community, ..)` follows.
let _community = Community::read(pool, req.community_id)?;
// Mutation by id with no preceding ownership/membership check.
// This is the genuine IDOR — must flag.
CommunityActions::delete_mods_for_community(pool, req.community_id)?;
Ok(())
}

View file

@ -6,3 +6,9 @@ edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
# Sources live at the crate root (main.rs) rather than under src/, so
# cargo-machete's manifest scan misses the `use` lines and flags these
# as unused. Nyx scans the .rs files directly; the deps are real.
[package.metadata.cargo-machete]
ignored = ["axum", "tokio"]

View file

@ -0,0 +1,58 @@
// cal.com / NextAuth idiom: destructure `user` from a session
// container, then use `user.id` (or a downstream copy) as a SQL
// parameter. The destructured local IS the authenticated actor —
// its `.id` is the caller's own id, not a foreign tenant id.
//
// Real-repo cluster: ~tens of cal.com handlers in
// `apps/web/.../*/route.ts` shaped like this.
//
// Engine fix: extend `collect_self_actor_binding` (and the
// `variable_declarator` dispatch arm wired in 2026-04-27 morning)
// to handle `object_pattern` patterns when the RHS is a
// session-container chain (`ctx.session`, `req.session`, etc.) or a
// known session-getter call (`getServerSession`). The destructured
// `user` becomes a `self_actor_var`; downstream `user.id` /
// `user.email` accesses then count as actor context, suppressing the
// ownership-gap finding.
import { db } from "../db";
type Ctx = {
session: { user: { id: string; email: string } };
};
// Destructure `user` from `ctx.session` — `user` is the actor.
export async function getMine(ctx: Ctx) {
const { user } = ctx.session;
return await db.findById(user.id);
}
// `user.email` access on the destructured local is also actor context.
export async function emailMine(ctx: Ctx) {
const { user } = ctx.session;
return await db.findByEmail(user.email);
}
// Destructure with rename: `{ user: me }` — `me.id` is still actor
// context because `me` was bound from the session container.
export async function getMineRenamed(ctx: Ctx) {
const { user: me } = ctx.session;
return await db.findById(me.id);
}
// `getServerSession()` call form (NextAuth idiom).
declare function getServerSession(): Promise<{ user: { id: string } }>;
export async function getMineFromServerSession() {
const { user } = await getServerSession();
return await db.findById(user.id);
}
// `const { id } = req.user;` — RHS is the canonical authed-user base
// (`is_self_scoped_session_base_text`); the destructured `id` is the
// actor's own id.
type Req = { user: { id: string } };
export async function getMineFromReqUser(req: Req) {
const { id } = req.user;
return await db.findById(id);
}

View file

@ -0,0 +1,26 @@
// Real-repo shape from cal.com Next.js handlers: the authenticated
// `session.user.id` is copied into a local `userId` and used as the
// scoped identifier for a downstream prisma call. The local
// `userId` is the caller's own id (a copy of `session.user.id`,
// recognised as a self-scoped session subject), so the rule must
// not flag.
//
// Closes a 10+ FP cluster on cal.com; the fix lives in
// `src/auth_analysis/extract/common.rs::value_is_self_scoped_session_id_chain`
// which extends `collect_self_actor_id_binding` to recognise
// session-scoped chains beyond the existing `actor_var.id` shape.
async function getCachedApiKeys(_userId: number) {
return [];
}
export const Page = async () => {
const session = await getServerSession();
if (!session) {
return null;
}
const userId = session.user.id;
const apiKeys = await getCachedApiKeys(userId);
return apiKeys;
};

View file

@ -0,0 +1,81 @@
// cal.com TRPC handler shape — Options-typed parameter destructures
// `ctx` from a TRPC session-context whose `user` field holds the
// authenticated actor.
//
// Real-repo cluster: ~105 of cal.com's 768 remaining
// `js.auth.missing_ownership_check` findings are in
// `packages/trpc/server/routers/{viewer,loggedInViewer}/.../*.handler.ts`
// shaped like this. `ctx.user.id` is the caller's own id, not a
// foreign tenant id.
//
// Engine fix (2026-04-27, JS slice 2): file-level pre-scan walks
// every `type_alias_declaration` / `interface_declaration` in the
// source-file root looking for a body that references a TRPC-marker
// type (`TrpcSessionUser`). Each matched alias name (e.g.
// `GetOptions`, `UpdateOptions`) is added to the per-unit
// `trpc_alias_names`. When a parameter is encountered whose type
// annotation references one of those aliases — or inlines
// `TrpcSessionUser` directly — the destructured `ctx` (with
// shorthand or rename) populates `self_scoped_session_bases` with
// `<localCtx>.user`. `is_actor_context_subject` then treats
// `<localCtx>.user.<id-like>` subjects as actor context.
//
// Conservative: bare `ctx.user` is NOT added to the static
// session-base list. Only TS handlers whose type annotation
// directly demonstrates a TRPC signature are exempted; non-TRPC
// `ctx` patterns continue to flag their cross-tenant lookups.
// Stand-in for `@calcom/trpc/server/types` — the real `TrpcSessionUser`
// import resolves to a NextAuth user struct. Our scanner can't follow
// the import, so the marker is the LITERAL substring "TrpcSessionUser"
// in the alias body — which appears in cal.com's own type definitions.
type TrpcSessionUser = { id: string; email: string };
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: { keyword: string };
};
type UpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
declare const db: {
getMyEvents: (userId: string) => Promise<unknown>;
getMyEmails: (email: string) => Promise<unknown>;
updateMine: (userId: string) => Promise<unknown>;
};
// Destructured shorthand: `{ ctx, input }`. ctx.user.id is actor.
export const getHandler = async ({ ctx, input }: GetOptions) => {
const _ = input;
return await db.getMyEvents(ctx.user.id);
};
// `ctx.user.email` — same path, different field; still actor.
export const emailHandler = async ({ ctx }: GetOptions) => {
return await db.getMyEmails(ctx.user.email);
};
// Destructured rename: `{ ctx: c }`. c.user.id is actor.
export const renamedHandler = async ({ ctx: c }: UpdateOptions) => {
return await db.updateMine(c.user.id);
};
// Plain identifier: `(opts: GetOptions)`. opts.ctx.user.id is actor.
export const optsHandler = async (opts: GetOptions) => {
return await db.getMyEvents(opts.ctx.user.id);
};
// Chained destructure (cal.com webhook handler shape). The TRPC
// param-level pre-pass marks `ctx.user` as a session base; the
// downstream `const { user } = ctx;` then lifts the local `user` to
// self_actor_var via the dynamic self_scoped_session_bases lookup.
export const chainedDestructureHandler = async ({ ctx }: GetOptions) => {
const { user } = ctx;
return await db.getMyEvents(user.id);
};

View file

@ -0,0 +1,13 @@
// Vulnerable counterpart to `safe_session_user_id_copy.ts`: the
// `userId` is bound from a route param (`req.params.targetUserId`,
// not from the session), so the rule must still flag the missing
// ownership check on the downstream prisma call.
async function deleteApiKeysFromUserId(_userId: number) {}
export const Handler = async (req: any, _res: any) => {
const session = await getServerSession();
if (!session) return;
const userId = req.params.targetUserId;
await deleteApiKeysFromUserId(userId);
};

View file

@ -0,0 +1,26 @@
// Vulnerable counterpart to `safe_trpc_ctx_user_options.ts`: same
// TRPC shape, but the SQL parameter is `input.id` (a foreign id from
// the request body), not `ctx.user.id`. The TRPC ctx-typed
// self-actor recognition must NOT also exempt `input.<scoped_id>`
// targets — that would mask real IDOR bugs.
type TrpcSessionUser = { id: string; email: string };
type DeleteOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: { targetUserId: string };
};
declare const db: {
deleteUser: (userId: string) => Promise<unknown>;
};
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
// `input.targetUserId` is a request-body field, NOT the actor's
// own id. The TRPC ctx fix only marks `ctx.user.<id-like>` as
// actor context; `input.<X>` keeps flagging.
const _ = ctx;
return await db.deleteUser(input.targetUserId);
};

View file

@ -0,0 +1,104 @@
// Nyx CVE benchmark fixture.
//
// CVE: CVE-2023-3188
// Project: owncast/owncast
// License: MIT (https://github.com/owncast/owncast/blob/v0.1.0/LICENSE)
// Advisory: https://huntr.dev/bounties/0d0d526a-1c39-4e6a-b081-d3914468e495
// Patched: commit a6dbc37a8 (release 0.1.0), activitypub/webfinger/webfinger.go:14-66
// https://github.com/owncast/owncast/blob/0.1.0/activitypub/webfinger/webfinger.go
//
// Patch added a `utils.IsHostnameInternal(fediverseServer)` early-return
// rejecting any request to internal/loopback addresses, plus a custom
// `*http.Client` with redirects disabled (`http.ErrUseLastResponse`).
//
// Trims: same as vulnerable.go (handler scaffolded around the verbatim
// upstream `GetWebfingerLinks`). The `utils.IsHostnameInternal` helper
// is not pulled in from upstream `github.com/owncast/owncast/utils`;
// instead it's stubbed inline in this fixture so the file builds
// standalone — the validator's name and call-site shape match upstream
// exactly so any classifier-recognized validator pattern triggers.
//
// Patched-fix simplification: stub `IsHostnameInternal` body returns
// `false`; in upstream it parses + walks the host's resolved IPs and
// checks against RFC1918 / loopback ranges.
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
func IsHostnameInternal(host string) bool {
// Stub for the upstream owncast utils.IsHostnameInternal: in the real
// module this resolves the host and rejects RFC1918 / loopback / link-
// local IPs. The validator name + call-site shape match upstream.
_ = host
return false
}
func GetWebfingerLinks(account string) ([]map[string]interface{}, error) {
type webfingerResponse struct {
Links []map[string]interface{} `json:"links"`
}
account = strings.TrimLeft(account, "@") // remove any leading @
accountComponents := strings.Split(account, "@")
fediverseServer := accountComponents[1]
// Reject any requests to our internal network or loopback.
if IsHostnameInternal(fediverseServer) {
return nil, errors.New("unable to use provided host as a valid fediverse server")
}
// HTTPS is required.
requestURL, err := url.Parse("https://" + fediverseServer)
if err != nil {
return nil, fmt.Errorf("unable to parse fediverse server host %s", fediverseServer)
}
requestURL.Path = "/.well-known/webfinger"
query := requestURL.Query()
query.Add("resource", fmt.Sprintf("acct:%s", account))
requestURL.RawQuery = query.Encode()
// Do not support redirects.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
response, err := client.Get(requestURL.String())
if err != nil {
return nil, err
}
defer response.Body.Close()
var links webfingerResponse
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&links); err != nil {
return nil, err
}
return links.Links, nil
}
func RemoteFollow(w http.ResponseWriter, r *http.Request) {
account := r.URL.Query().Get("account")
links, err := GetWebfingerLinks(account)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
_ = links
}
func main() {
http.HandleFunc("/api/remotefollow", RemoteFollow)
http.ListenAndServe(":8080", nil)
}

View file

@ -0,0 +1,96 @@
// Nyx CVE benchmark fixture.
//
// CVE: CVE-2023-3188
// Project: owncast/owncast
// License: MIT (https://github.com/owncast/owncast/blob/v0.0.13/LICENSE)
// Advisory: https://huntr.dev/bounties/0d0d526a-1c39-4e6a-b081-d3914468e495
// Vulnerable: tag v0.0.13, activitypub/webfinger/webfinger.go:11-46
// https://github.com/owncast/owncast/blob/v0.0.13/activitypub/webfinger/webfinger.go
//
// Owncast 0.0.x took an attacker-controlled `account` (parsed from a
// caller-supplied JSON body via `RemoteFollow → GetWebfingerLinks`),
// extracted the host portion after `@`, and issued an outbound
// `http.DefaultClient.Get(requestURL.String())` to that host's
// `.well-known/webfinger` — letting an attacker reach internal
// services. Fixed in 0.1.0 by adding `utils.IsHostnameInternal`
// rejection plus disabled HTTP redirects.
//
// Trims:
// - upstream package was `webfinger`; renamed to `main` so the file
// builds standalone, with a route-registration `main` added.
// - the webfingerResponse JSON-decode block at the bottom (response
// body parsing) is preserved verbatim — it's part of the disclosed
// code path.
// - calling `RemoteFollow` handler omitted; the source is rewritten
// as a single `account := r.URL.Query().Get("account")` so the
// scanner exercises the same source→sink shape the upstream chain
// produces (upstream goes `r.Body → json.NewDecoder.Decode → *request.Account`,
// which is structurally equivalent for taint-flow purposes).
//
// Load-bearing lines copied verbatim from upstream
// `GetWebfingerLinks` (lines 16-37 in v0.0.13/activitypub/webfinger/webfinger.go):
// - the `strings.TrimLeft(account, "@")` and `strings.Split(account, "@")`
// splitter chain,
// - the `requestURL, err := url.Parse("https://" + fediverseServer)` host
// join statement, and
// - the `response, err := http.DefaultClient.Get(requestURL.String())` sink.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
func GetWebfingerLinks(account string) ([]map[string]interface{}, error) {
type webfingerResponse struct {
Links []map[string]interface{} `json:"links"`
}
account = strings.TrimLeft(account, "@") // remove any leading @
accountComponents := strings.Split(account, "@")
fediverseServer := accountComponents[1]
// HTTPS is required.
requestURL, err := url.Parse("https://" + fediverseServer)
if err != nil {
return nil, fmt.Errorf("unable to parse fediverse server host %s", fediverseServer)
}
requestURL.Path = "/.well-known/webfinger"
query := requestURL.Query()
query.Add("resource", fmt.Sprintf("acct:%s", account))
requestURL.RawQuery = query.Encode()
response, err := http.DefaultClient.Get(requestURL.String())
if err != nil {
return nil, err
}
defer response.Body.Close()
var links webfingerResponse
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&links); err != nil {
return nil, err
}
return links.Links, nil
}
func RemoteFollow(w http.ResponseWriter, r *http.Request) {
account := r.URL.Query().Get("account")
links, err := GetWebfingerLinks(account)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
_ = links
}
func main() {
http.HandleFunc("/api/remotefollow", RemoteFollow)
http.ListenAndServe(":8080", nil)
}

View file

@ -0,0 +1,71 @@
// Nyx CVE benchmark fixture.
//
// CVE: CVE-2024-31450
// Project: owncast/owncast
// License: MIT (https://github.com/owncast/owncast/blob/v0.1.3/LICENSE)
// Advisory: https://github.com/advisories/GHSA-9355-27m8-h74v
// Patched: tag v0.1.3, controllers/admin/emoji.go:64-99
// https://github.com/owncast/owncast/blob/v0.1.3/controllers/admin/emoji.go
//
// Patch added a `filepath.IsLocal(targetPath)` early return after the
// `filepath.Join`, rejecting any joined path that escapes the configured
// emoji directory. Body decoding and `filepath.Join` are unchanged from
// the vulnerable version.
//
// Trims: same as vulnerable.go (sibling upload handler omitted, helper
// imports inlined, package renamed to main, response strings simplified
// to literal messages). Load-bearing patched lines 80-94 of upstream
// `DeleteCustomEmoji` are pasted verbatim — only the `filepath.IsLocal`
// guard differentiates the patched fixture from `vulnerable.go`.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
)
const CustomEmojiPath = "data/emoji"
func DeleteCustomEmoji(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
type deleteEmoji struct {
Name string `json:"name"`
}
emoji := new(deleteEmoji)
if err := json.NewDecoder(r.Body).Decode(emoji); err != nil {
fmt.Fprintln(w, "Decode error")
return
}
targetPath := filepath.Join(CustomEmojiPath, emoji.Name)
if !filepath.IsLocal(targetPath) {
fmt.Fprintln(w, "Emoji path is not valid")
return
}
if err := os.Remove(targetPath); err != nil {
if os.IsNotExist(err) {
fmt.Fprintln(w, "Emoji does not exist")
} else {
fmt.Fprintln(w, "Emoji delete error")
}
return
}
fmt.Fprintln(w, "Emoji deleted")
}
func main() {
http.HandleFunc("/api/integrations/emoji/delete", DeleteCustomEmoji)
http.ListenAndServe(":8080", nil)
}

View file

@ -0,0 +1,87 @@
// Nyx CVE benchmark fixture.
//
// CVE: CVE-2024-31450
// Project: owncast/owncast
// License: MIT (https://github.com/owncast/owncast/blob/v0.1.2/LICENSE)
// Advisory: https://github.com/advisories/GHSA-9355-27m8-h74v
// Vulnerable: tag v0.1.2, controllers/admin/emoji.go:64-91
// https://github.com/owncast/owncast/blob/v0.1.2/controllers/admin/emoji.go
//
// Owncast 0.1.2 deleted custom emoji using a body-supplied filename
// joined directly to the emoji directory. The previous cleansing call
// had been removed (note the dead comment), so any admin could supply
// `../../foo` and delete arbitrary files via /api/integrations/emoji/delete.
// Fixed in v0.1.3 with `filepath.IsLocal(targetPath)` early return.
//
// Trims:
// - upstream package was `admin`; renamed to `main` so the file builds
// standalone, and a route-registration `main` was added.
// - `controllers.WriteSimpleResponse` (helper) and `requirePOST` (auth
// middleware) inlined as `fmt.Fprintln(w, ...)` / a `r.Method != "POST"`
// guard — neither is on the disclosed flow path.
// - `config.CustomEmojiPath` package var inlined as a local const.
// - sibling `UploadCustomEmoji` handler omitted (not affected by this CVE).
// - response strings simplified to literal messages (upstream wrote
// `fmt.Sprintf("Emoji %q ...", emoji.Name)` to the response); the
// CVE essence (path-trav via `os.Remove(filepath.Join(_, emoji.Name))`)
// is unchanged, but removing the response-side `emoji.Name` use
// keeps the orthogonal `fmt.Fprintf` XSS-shaped flow off the
// vulnerable/patched diff so the benchmark exercises only the
// path-traversal sink.
//
// Load-bearing lines copied verbatim from upstream
// `DeleteCustomEmoji` (lines 64-91 in v0.1.2/controllers/admin/emoji.go):
// - the `deleteEmoji { Name string }` request type,
// - the `json.NewDecoder(r.Body).Decode(emoji)` source statement,
// - the dead `// var emojiFileName = filepath.Base(emoji.Name)` comment,
// - the `targetPath := filepath.Join(config.CustomEmojiPath, emoji.Name)`
// join statement, and
// - the `os.Remove(targetPath)` sink call.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
)
const CustomEmojiPath = "data/emoji"
func DeleteCustomEmoji(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
type deleteEmoji struct {
Name string `json:"name"`
}
emoji := new(deleteEmoji)
if err := json.NewDecoder(r.Body).Decode(emoji); err != nil {
fmt.Fprintln(w, "Decode error")
return
}
// var emojiFileName = filepath.Base(emoji.Name)
targetPath := filepath.Join(CustomEmojiPath, emoji.Name)
if err := os.Remove(targetPath); err != nil {
if os.IsNotExist(err) {
fmt.Fprintln(w, "Emoji does not exist")
} else {
fmt.Fprintln(w, "Emoji delete error")
}
return
}
fmt.Fprintln(w, "Emoji deleted")
}
func main() {
http.HandleFunc("/api/integrations/emoji/delete", DeleteCustomEmoji)
http.ListenAndServe(":8080", nil)
}

View file

@ -0,0 +1,32 @@
// Nyx CVE benchmark fixture (patched counterpart).
//
// CVE: CVE-2025-64430
// Project: Parse Server (parse-community/parse-server)
// License: Apache-2.0 (https://github.com/parse-community/parse-server/blob/alpha/LICENSE)
// Advisory: https://github.com/parse-community/parse-server/security/advisories/GHSA-x4qj-2f4q-r4rx
// Patched: 97763863b72689a29ad7a311dfb590c3e3c50585 src/Routers/FilesRouter.js (PR #9903)
//
// Patched-fix simplification: upstream removed the entire URI-backed
// upload path (the feature "never worked and reliably crashes the
// server" per the advisory). The `http` import and both
// `downloadFileFromURI` / `addFileDataIfNeeded` URI branches are gone.
// Below is the post-patch shape: only raw bytes are accepted; no
// outbound HTTP fetch from user input is possible.
const express = require('express');
const app = express();
app.use(express.json());
const addFileDataIfNeeded = async file => {
return file;
};
app.post('/files/:filename', async (req, res) => {
if (typeof req.body !== 'object' || req.body.bytes == null) {
res.status(400).json({ error: 'invalid file' });
return;
}
const file = { _data: Buffer.from(req.body.bytes) };
await addFileDataIfNeeded(file);
res.json({ ok: true });
});

View file

@ -0,0 +1,56 @@
// Nyx CVE benchmark fixture.
//
// CVE: CVE-2025-64430
// Project: Parse Server (parse-community/parse-server)
// License: Apache-2.0 (https://github.com/parse-community/parse-server/blob/alpha/LICENSE)
// Advisory: https://github.com/parse-community/parse-server/security/advisories/GHSA-x4qj-2f4q-r4rx
// Vulnerable: 52bfb186fc4790073ac258bb30a7c599f469471e src/Routers/FilesRouter.js:7,10-32
//
// Parse Server's file-upload router accepted a `Parse.File` whose
// `_source.format === 'uri'` and silently fetched `_source.uri` server-
// side via `http.get(uri, ...)`. An attacker passing a crafted URI in
// the upload payload (or anywhere a `Parse.File` round-trips through
// Cloud Code) caused the server to request arbitrary internal hosts —
// classic SSRF.
//
// Trims: imports `express`, `Parse`, `Config`, `logger`, `triggers`,
// `Utils` (only `http` and `express` are load-bearing); class wrapper
// `FilesRouter`, route-mount boilerplate, getHandler/createHandler/
// deleteHandler bodies, master-key/anonymous-user gate, mime sniffing,
// trigger pipeline, file extension validation, content-type handling.
const express = require('express');
const http = require('http');
const app = express();
app.use(express.json());
const downloadFileFromURI = uri => {
return new Promise((res, rej) => {
http
.get(uri, response => {
response.setDefaultEncoding('base64');
let body = `data:${response.headers['content-type']};base64,`;
response.on('data', data => (body += data));
response.on('end', () => res(body));
})
.on('error', e => {
rej(`Error downloading file from ${uri}: ${e.message}`);
});
});
};
const addFileDataIfNeeded = async file => {
if (file._source.format === 'uri') {
const base64 = await downloadFileFromURI(file._source.uri);
file._previousSave = file;
file._data = base64;
file._requestTask = null;
}
return file;
};
app.post('/files/:filename', async (req, res) => {
const file = { _source: req.body };
await addFileDataIfNeeded(file);
res.json({ ok: true });
});

View file

@ -0,0 +1,47 @@
# Nyx CVE benchmark fixture (patched counterpart).
#
# CVE: CVE-2020-8130
# Project: ruby/rake (https://github.com/ruby/rake)
# License: MIT (https://github.com/ruby/rake/blob/v12.3.3/MIT-LICENSE)
# Advisory: https://github.com/advisories/GHSA-jppv-gw3r-w3q8
# Patched: tag v12.3.3, lib/rake/file_list.rb:293-313
# https://github.com/ruby/rake/blob/5b8f8fc41a5d7d7d6a5d767e48464c60884d3aee/lib/rake/file_list.rb
#
# Patched variant: `open(fn, "r", *options)` replaced with
# `File.open(fn, "r", *options)`. `File.open` never interprets a
# leading `|` as a shell pipe, so the CMDI vector is closed.
#
# Patched-fix simplifications (the upstream commit only changed the
# `open` -> `File.open` line; this fixture additionally hardcodes the
# filename list and grep pattern to make this counterpart a clean
# rule-class regression guard):
# - The vulnerable counterpart drives the filename from
# `params[:file]`, surfacing the CMDI sink. CVE-2020-8130 is a
# CMDI bug, not a path-traversal bug: the patched code still
# accepts attacker-controlled paths into File.open and would
# surface a separate `taint-unsanitised-flow` (Cap::FILE_IO)
# finding if seeded from `params`. Since the regression guard
# is for the CMDI vector specifically, this fixture seeds `fn`
# and `pattern` with literals so the patched form is silent on
# all rule classes.
#
# Load-bearing line copied verbatim from upstream `egrep`
# (line 297 in v12.3.3/lib/rake/file_list.rb):
# - `File.open(fn, "r", *options) do |inf|` — the CVE fix
# - the `inf.each do |line|` reader / `pattern.match(line)` test
require 'sinatra/base'
class GrepController < Sinatra::Base
post '/grep' do
pattern = Regexp.new("ERROR")
matched = 0
["log/audit.log"].each do |fn|
File.open(fn, "r") do |inf|
inf.each do |line|
matched += 1 if pattern.match(line)
end
end
end
matched.to_s
end
end

View file

@ -0,0 +1,50 @@
# Nyx CVE benchmark fixture.
#
# CVE: CVE-2020-8130
# Project: ruby/rake (https://github.com/ruby/rake)
# License: MIT (https://github.com/ruby/rake/blob/v12.3.2/MIT-LICENSE)
# Advisory: https://github.com/advisories/GHSA-jppv-gw3r-w3q8
# Vulnerable: tag v12.3.2, lib/rake/file_list.rb:293-313
# https://github.com/ruby/rake/blob/6497ba4d94d12c123df48cc8ab40f0a4eb7fb337/lib/rake/file_list.rb
#
# `Rake::FileList#egrep` opened each filename via `Kernel#open`, which
# interprets a path beginning with `|` as a shell command (`open("|cmd")`
# spawns `cmd` and pipes its output into the block). An attacker who
# could supply a filename to a `Rake::FileList` (build-config field,
# job-runner submission) thereby achieved arbitrary command execution.
# Fixed in v12.3.3 by switching to the explicit `File.open` constructor.
#
# Trims:
# - `Rake::FileList`'s glob-expansion / `each` machinery is replaced
# by a single attacker-supplied filename + plain `Array#each`, since
# the load-bearing flow is the one filename's path into `open`.
# - `puts "#{fn}:#{count}:#{line}"` reporting block at upstream L302-308
# trimmed; not on the source-to-sink path.
#
# Load-bearing lines copied verbatim from upstream `egrep`
# (lines 293-313 in v12.3.2/lib/rake/file_list.rb):
# - `def egrep(pattern, *options)` signature
# - the `each do |fn|` iteration over filenames
# - `open(fn, "r", *options) do |inf|` — the CVE
# - `inf.each do |line|` reader and `pattern.match(line)` test
require 'sinatra/base'
class GrepController < Sinatra::Base
post '/grep' do
files = [params[:file].to_s]
pattern = Regexp.new(params[:pattern].to_s)
matched = 0
files.each do |fn|
begin
open(fn, "r") do |inf|
inf.each do |line|
matched += 1 if pattern.match(line)
end
end
rescue StandardError => ex
puts "Error while processing '#{fn}': #{ex}"
end
end
matched.to_s
end
end

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff