mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-18 21:21:03 +02:00
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:
parent
79c29b394d
commit
82f18184b1
348 changed files with 48731 additions and 2925 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 A1–A3 / B1–B4 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 (8–20 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 0–5 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.
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
27
tests/benchmark/corpus/c/safe/safe_sprintf_bounded_format.c
Normal file
27
tests/benchmark/corpus/c/safe/safe_sprintf_bounded_format.c
Normal 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');
|
||||
}
|
||||
34
tests/benchmark/corpus/c/safe/safe_strcpy_literal_src.c
Normal file
34
tests/benchmark/corpus/c/safe/safe_strcpy_literal_src.c
Normal 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)");
|
||||
}
|
||||
29
tests/benchmark/corpus/cpp/cmdi/cmdi_class_inline_method.cpp
Normal file
29
tests/benchmark/corpus/cpp/cmdi/cmdi_class_inline_method.cpp
Normal 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;
|
||||
}
|
||||
18
tests/benchmark/corpus/cpp/cmdi/cmdi_lambda_passthrough.cpp
Normal file
18
tests/benchmark/corpus/cpp/cmdi/cmdi_lambda_passthrough.cpp
Normal 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;
|
||||
}
|
||||
19
tests/benchmark/corpus/cpp/cmdi/cmdi_stl_vector_string.cpp
Normal file
19
tests/benchmark/corpus/cpp/cmdi/cmdi_stl_vector_string.cpp
Normal 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;
|
||||
}
|
||||
23
tests/benchmark/corpus/cpp/safe/safe_builder_const_host.cpp
Normal file
23
tests/benchmark/corpus/cpp/safe/safe_builder_const_host.cpp
Normal 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;
|
||||
}
|
||||
25
tests/benchmark/corpus/cpp/safe/safe_stl_vector_int.cpp
Normal file
25
tests/benchmark/corpus/cpp/safe/safe_stl_vector_int.cpp
Normal 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;
|
||||
}
|
||||
26
tests/benchmark/corpus/cpp/ssrf/ssrf_builder_user_host.cpp
Normal file
26
tests/benchmark/corpus/cpp/ssrf/ssrf_builder_user_host.cpp
Normal 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;
|
||||
}
|
||||
29
tests/benchmark/corpus/go/auth/vuln_repo_findbyid_no_auth.go
Normal file
29
tests/benchmark/corpus/go/auth/vuln_repo_findbyid_no_auth.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
18
tests/benchmark/corpus/go/cmdi/vuln_error_log_then_sink.go
Normal file
18
tests/benchmark/corpus/go/cmdi/vuln_error_log_then_sink.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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") != ""
|
||||
}
|
||||
|
|
@ -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") != ""
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
30
tests/benchmark/corpus/go/safe/safe_const_bound_id.go
Normal file
30
tests/benchmark/corpus/go/safe/safe_const_bound_id.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
56
tests/benchmark/corpus/go/safe/safe_method_receiver_mutex.go
Normal file
56
tests/benchmark/corpus/go/safe/safe_method_receiver_mutex.go
Normal 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
|
||||
}
|
||||
}
|
||||
93
tests/benchmark/corpus/go/safe/safe_self_method_receiver.go
Normal file
93
tests/benchmark/corpus/go/safe/safe_self_method_receiver.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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") != ""
|
||||
}
|
||||
14
tests/benchmark/corpus/go/ssrf/ssrf_default_client_get.go
Normal file
14
tests/benchmark/corpus/go/ssrf/ssrf_default_client_get.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
34
tests/benchmark/corpus/java/safe/SafeLoggerIsEnabled.java
Normal file
34
tests/benchmark/corpus/java/safe/SafeLoggerIsEnabled.java
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
14
tests/benchmark/corpus/ruby/cmdi/cmdi_kernel_open.rb
Normal file
14
tests/benchmark/corpus/ruby/cmdi/cmdi_kernel_open.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
43
tests/benchmark/corpus/rust/auth/predicate_role_check.rs
Normal file
43
tests/benchmark/corpus/rust/auth/predicate_role_check.rs
Normal 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)
|
||||
}
|
||||
62
tests/benchmark/corpus/rust/auth/row_fetch_then_authorize.rs
Normal file
62
tests/benchmark/corpus/rust/auth/row_fetch_then_authorize.rs
Normal 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)
|
||||
}
|
||||
45
tests/benchmark/corpus/rust/auth/safe_dto_int_field_axum.rs
Normal file
45
tests/benchmark/corpus/rust/auth/safe_dto_int_field_axum.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
104
tests/benchmark/cve_corpus/go/CVE-2023-3188/patched.go
Normal file
104
tests/benchmark/cve_corpus/go/CVE-2023-3188/patched.go
Normal 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)
|
||||
}
|
||||
96
tests/benchmark/cve_corpus/go/CVE-2023-3188/vulnerable.go
Normal file
96
tests/benchmark/cve_corpus/go/CVE-2023-3188/vulnerable.go
Normal 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)
|
||||
}
|
||||
71
tests/benchmark/cve_corpus/go/CVE-2024-31450/patched.go
Normal file
71
tests/benchmark/cve_corpus/go/CVE-2024-31450/patched.go
Normal 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)
|
||||
}
|
||||
87
tests/benchmark/cve_corpus/go/CVE-2024-31450/vulnerable.go
Normal file
87
tests/benchmark/cve_corpus/go/CVE-2024-31450/vulnerable.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
47
tests/benchmark/cve_corpus/ruby/CVE-2020-8130/patched.rb
Normal file
47
tests/benchmark/cve_corpus/ruby/CVE-2020-8130/patched.rb
Normal 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
|
||||
50
tests/benchmark/cve_corpus/ruby/CVE-2020-8130/vulnerable.rb
Normal file
50
tests/benchmark/cve_corpus/ruby/CVE-2020-8130/vulnerable.rb
Normal 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
Loading…
Add table
Add a link
Reference in a new issue