nyx/tests/topo_pass2_refinement_tests.rs
Eli Peter 82f18184b1
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>
2026-04-29 00:58:38 -04:00

220 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Regression tests for cross-batch summary refinement in
//! [`run_topo_batches`]'s non-recursive branch.
//!
//! Pass 2 sequences files in callee-first topological order (see
//! `scc_file_batches_with_metadata`). Before this wiring landed, the
//! non-recursive batch path called `run_rules_on_file`, which discards
//! refined SSA / body / auth artifacts. Caller-most batches (run
//! later in topo order) saw only pass-1 summaries — the refined cross-
//! file context produced by callee batches in pass 2 was lost.
//!
//! These tests pin the new contract:
//!
//! 1. Non-recursive batches use `analyse_file_fused` and persist
//! every refined artifact to `global_summaries`.
//! 2. The observable counter
//! [`last_topo_nonrecursive_refinements`] reflects that.
//! 3. The opt-out env var `NYX_TOPO_REFINE=0` restores the legacy
//! `run_rules_on_file` path with no behavioural regression on
//! required findings.
//! 4. The fixture's expectations.json is met under both modes —
//! proving that refinement is a precision-positive optimisation
//! and not a soundness change.
mod common;
use common::{scan_fixture_dir, validate_expectations};
use nyx_scanner::commands::scan::last_topo_nonrecursive_refinements;
use nyx_scanner::utils::config::AnalysisMode;
use std::path::Path;
use std::sync::Mutex;
fn fixture_path(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
/// Serialise tests that read the process-wide
/// `LAST_TOPO_NONRECURSIVE_REFINEMENTS` counter or set
/// `NYX_TOPO_REFINE`. `cargo test` runs tests in parallel by default;
/// without this guard, one test's env or counter read can leak into
/// another's scan.
static TOPO_TEST_GUARD: Mutex<()> = Mutex::new(());
/// Helper: run a closure with `NYX_TOPO_REFINE` set to a specific
/// value, restoring the prior state on drop. We do not use
/// `temp_env` here to avoid a new dev-dep; `unsafe { set_var }` is
/// fine inside the test guard.
struct EnvScope {
key: &'static str,
prior: Option<String>,
}
impl EnvScope {
fn set(key: &'static str, value: &str) -> Self {
let prior = std::env::var(key).ok();
// SAFETY: tests are serialised by `TOPO_TEST_GUARD` so no
// concurrent access; `set_var` is sound under that guard.
unsafe {
std::env::set_var(key, value);
}
EnvScope { key, prior }
}
}
impl Drop for EnvScope {
fn drop(&mut self) {
// SAFETY: see EnvScope::set.
unsafe {
match &self.prior {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
// ─────────────────────────────────────────────────────────────────────
// D1 — Refinement is enabled by default and is observable
// ─────────────────────────────────────────────────────────────────────
/// On a 2-file linear-chain fixture (caller → callee, no recursion),
/// the non-recursive branch must:
/// 1. produce the expected findings (correctness baseline);
/// 2. record at least one refinement on the observability counter,
/// proving refined artifacts were persisted into
/// `global_summaries` between batches.
///
/// `cross_file_alias_returned_alias` is a clean 2-file linear chain
/// (`app.js` calls `passthrough` from `helper.js`) that exercises the
/// non-recursive batch path in pass 2.
#[test]
fn nonrecursive_batches_persist_refinements_by_default() {
let _guard = TOPO_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
// Make sure we're testing the default-on path, not an inherited override.
let _scope = EnvScope::set("NYX_TOPO_REFINE", "1");
let dir = fixture_path("cross_file_alias_returned_alias");
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
validate_expectations(&diags, &dir);
// Observability: at least one refinement must have been persisted.
// The exact count depends on summary detail (FuncSummary +
// SsaFuncSummary + body + auth per function), so a tight upper
// bound would be brittle; the lower bound is what matters.
let n = last_topo_nonrecursive_refinements();
assert!(
n > 0,
"Expected the non-recursive batch path to persist > 0 refinements \
to global_summaries on a multi-file fixture; got {n}. This usually \
means run_topo_batches' non-recursive branch reverted to \
run_rules_on_file or analyse_file_fused stopped emitting \
ssa_summaries / ssa_bodies / auth_summaries."
);
}
// ─────────────────────────────────────────────────────────────────────
// D2 — Opt-out via NYX_TOPO_REFINE=0 restores legacy behaviour
// ─────────────────────────────────────────────────────────────────────
/// With `NYX_TOPO_REFINE=0`, the legacy non-recursive branch runs:
/// `run_rules_on_file` is called and refined artifacts are NOT
/// persisted, so the observability counter stays at zero. The fixture's
/// required findings must STILL be detected — confirming that the
/// refinement is precision-positive but not soundness-load-bearing.
#[test]
fn nonrecursive_batches_legacy_path_when_disabled() {
let _guard = TOPO_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let _scope = EnvScope::set("NYX_TOPO_REFINE", "0");
let dir = fixture_path("cross_file_alias_returned_alias");
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
validate_expectations(&diags, &dir);
let n = last_topo_nonrecursive_refinements();
assert_eq!(
n, 0,
"With NYX_TOPO_REFINE=0, the legacy run_rules_on_file branch is \
expected to persist 0 refinements; got {n}. If this fires, the \
topo_refine_enabled() gate is being ignored by run_topo_batches."
);
}
// ─────────────────────────────────────────────────────────────────────
// D3 — Refinement does not regress findings vs the legacy path
// ─────────────────────────────────────────────────────────────────────
/// Run the same fixture twice (refine on / off) and assert the set of
/// finding rule IDs is the same. Refinement is precision-positive, so
/// the refine-on set is a *superset* of the legacy set; in practice
/// the fixtures exercised here are small enough that the two should be
/// equal. This test guards against the regression where refinement
/// silently *loses* findings — e.g. a refined summary masking a real
/// finding via accidental sanitiser inference.
#[test]
fn refinement_does_not_lose_required_findings_vs_legacy() {
let _guard = TOPO_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let dir = fixture_path("cross_file_alias_returned_alias");
// Run with refinement OFF first.
let off = {
let _scope = EnvScope::set("NYX_TOPO_REFINE", "0");
scan_fixture_dir(&dir, AnalysisMode::Full)
};
let off_ids: std::collections::BTreeSet<String> = off.iter().map(|d| d.id.clone()).collect();
// Run with refinement ON.
let on = {
let _scope = EnvScope::set("NYX_TOPO_REFINE", "1");
scan_fixture_dir(&dir, AnalysisMode::Full)
};
let on_ids: std::collections::BTreeSet<String> = on.iter().map(|d| d.id.clone()).collect();
// Refinement must be a superset of legacy findings. Strict
// equality is too tight (refinement may legitimately surface
// additional findings).
let lost: Vec<&String> = off_ids.difference(&on_ids).collect();
assert!(
lost.is_empty(),
"Refinement-on lost findings present in refinement-off run: {lost:?}. \
This indicates a precision regression — a refined summary is \
erroneously suppressing a finding the legacy path detected."
);
}
// ─────────────────────────────────────────────────────────────────────
// D4 — Counter resets between scans
// ─────────────────────────────────────────────────────────────────────
/// `last_topo_nonrecursive_refinements()` is reset to zero at the
/// start of every `run_topo_batches` invocation. Run two scans
/// back-to-back and confirm the counter reflects only the most-recent
/// invocation (not cumulative across scans).
#[test]
fn refinements_counter_resets_per_scan() {
let _guard = TOPO_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let _scope = EnvScope::set("NYX_TOPO_REFINE", "1");
let dir = fixture_path("cross_file_alias_returned_alias");
// First scan: counter should rise above zero.
let _ = scan_fixture_dir(&dir, AnalysisMode::Full);
let first = last_topo_nonrecursive_refinements();
assert!(first > 0, "first scan must record refinements, got {first}");
// Second scan on the same fixture. Counter must reset to first
// scan's value (or close to it — the fixture is deterministic so
// it should match), NOT accumulate to ~2 × first.
let _ = scan_fixture_dir(&dir, AnalysisMode::Full);
let second = last_topo_nonrecursive_refinements();
assert!(
second > 0,
"second scan must record refinements, got {second}"
);
assert!(
second <= first.saturating_mul(2).saturating_sub(first / 4),
"counter accumulated across scans (first={first}, second={second}); \
it must be reset at the start of each run_topo_batches invocation"
);
}