mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
* feat: Introduce function-scoped variable interning for state analysis with new tests and fixtures * feat: Add Phase 26 symbolic execution enhancements with bitwise operator support, abstract interpretation refinements, and new taint analysis tests * feat: Refine state analysis to handle factory-pattern resource returns with mixed-path tests and leak detection enhancements * feat: Add Phase 27 debug views with symbolic execution, abstract interpretation, SSA, and call graph viewers; integrate with debug layout and styles * feat: Add Phase 31 type-qualified symbolic resolution with receiver-based callee disambiguation and testing * feat: Extend symbolic execution with state iteration, enhanced debug views, and debounced input handling * feat: Add Phase 13 resource and auth pattern extensions with new tests and fixtures * feat: Introduce CFG debug graph renderer with compact mode, toolbar, and DAG layout integration * feat: Add Phase 28 encoding and decoding transform modeling with structural symex enhancements and new taint analysis tests * feat: Extend abstract interpretation with type facts and constant value tracking in debug views and server logic * feat: Add linear path handling and witness extraction to symbolic execution with Phase 28 transform mismatch detection * feat: Refine Go auth and sanitizer handling with enhanced rules, state updates, and benchmark improvements * feat: Enable auth-state analysis by default and update relevant tests in benchmark config * test: Update state_tests to reflect default enablement of auth-state analysis and add auth suppression test * docs: update CHANGELOG.md * feat: Introduce per-index taint tracking in `HeapState` with `HeapSlot`, overflow handling, and revised SSA transfers * feat: Introduce C/C++ language labels and refine heap state tracking in SSA transfers * feat: Implement per-index array slot tracking in symbolic heap with overflow collapse * feat: Add implicit definition handling for uninitialized declarations in SSA value allocation * feat: Refactor function parameters and constants for improved clarity and maintainability * refactor: Reorder module imports and improve formatting for consistency * refactor: Fix formatting erorrs * refactor: Fix clippy warnings * refactor: Fix fmt warnings (again) * chore: Update dependencies and improve feature configuration * Add comprehensive tests for undertested modules (#36) (COPILOT) * Add comprehensive tests for undertested modules Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/f3fc877e-f386-49ba-9793-fc93d3805083 * Add comprehensive tests for ext, project, walk, and errors modules Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/f3fc877e-f386-49ba-9793-fc93d3805083 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * chore: Update dependencies and improve feature configuration * fix: formatting errors in new tests * chore: Update license list in about.toml * chore: made functions input inline * chore: updated cfg graph to take up the full page * chore: add Prettier configuration and update code formatting * Add frontend test suite with Vitest (111 tests) (#37) * Add Vitest test suite for frontend - 111 tests across utils, components, hooks, and graph utilities Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/7cf0dba2-ecff-4740-ba4d-92717e74a0b7 * ci: add frontend test step to CI workflow Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/5bc0ac9f-0a32-4d03-9cb7-7a15aea53fca --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * chore: simplify array initialization in test files for consistency * ran typecheck * feat: add AnalysisWorkspace component and integrate it into CfgViewerPage * feat: update routing in AppLayout and improve empty state message in ExplorerPage * feat: enhance scan progress tracking with additional metrics and stages * feat: update license information and add license check script * feat: implement cross-file symbolic execution with callee body persistence * feat: replace dagre graphs with Graphology + ELK + Sigma for more advanced call stack and cfg rendering * feat: ensure CFG function view is scoped to the selected function, preventing bleed into sibling functions * feat: enhance resource tracking with proxy method summaries and improve finding extraction * feat: add terminal function exit detection for accurate resource leak analysis * feat: add warnings for loops and functions without bodies to improve error recovery * feat: update lambda expression handling to ensure proper function classification and control flow * feat: remove bounded formatting/string ops and add JSON.parse sanitizer for improved data handling * feat: add inline return taint analysis and regression tests for improved security checks * feat: add engine version management and migration handling for database schema updates * feat: enhance first_call_ident to skip nested function bodies and add regression tests * feat: enhance callee name resolution with two-segment normalization and disambiguation * feat: add cross-file context flags and debug assertions for taint analysis * feat: refactor taint analysis structure to unify context handling and improve clarity * feat: enhance dead code elimination to preserve Sink, Source, and Sanitizer labels with new tests * docs: updated CHANGELOG.md * fmt: formatting fixes * fix: fixed frontend formatting and lint warnings * fix: optimized ci * fix: optimized ci * Add comprehensive multi-file test coverage to Nyx (#38) * Initial checklist for multi-file test suite expansion Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/e550cb88-9767-4442-94d4-101bf5bb0e23 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * Add 12 new multi-file test fixtures with TP/TN/near-miss coverage Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/e550cb88-9767-4442-94d4-101bf5bb0e23 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * deleted root repo * rebuilt to test for regressions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Co-authored-by: elipeter <elicpeter@gmail.com> * feat: enhance import alias resolution and taint tracking * feat: implement security hardening with CSRF protection and path validation * feat: add support for import alias bindings in Python, PHP, and Rust * feat: enhance CFG analysis modes and improve code readability * feat: add detection for parameterized SQL queries to enhance security * feat: add safe internal redirect handling and enhance session destroy validation * feat: implement security improvements by addressing vulnerabilities in execAsync, session management, and file downloads * feat: enhance taint detection by adding support for inline source member expressions in call arguments * feat: implement pre-emission of Source nodes for inline source member expressions in call arguments * feat: add support for Throw statement in control flow and error handling * feat: add debug and echo endpoints with potential information leakage * feat: implement internal redirect suppression and enhance taint detection * feat: implement module alias tracking for dynamic dispatch in JS/TS * feat: add authorization analysis module with Express support * feat: add authorization analysis module with Express support * feat: add tests for admin guard requirements and clean checks in authorization analysis * feat: integrate Koa and Fastify frameworks into authorization analysis * feat: add Flask and Django support to authorization analysis module * feat: add support for Rails and Sinatra frameworks in authorization analysis * feat: add support for Axum, ActixWeb, and Rocket frameworks in authorization analysis * feat: add support for ActixWeb, Axum, and Rocket frameworks in authorization analysis * feat: add support for Rails and Sinatra in authorization analysis * chore: add .DS_Store to .gitignore * refactor: simplify conditional checks and improve readability in multiple files * refactor: update usage of Option methods for improved clarity and consistency * refactor: improve code readability by simplifying conditional checks and formatting * refactor: improve code formatting and readability by simplifying conditional checks * refactor: simplify conditional checks and improve readability in multiple files * refactor: simplify conditional checks in axum.rs for improved readability * feat: add CodeQL analysis configuration for enhanced security scanning * test: add comprehensive tests for `src/output.rs` SARIF builder (#39) * chore: start test coverage improvement work Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/cd7ff398-134e-4728-a5e7-0353a0744423 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * test: add comprehensive tests for src/output.rs SARIF builder Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/cd7ff398-134e-4728-a5e7-0353a0744423 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * refactor: improve code formatting and readability in output.rs --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Co-authored-by: elipeter <elicpeter@gmail.com> * refactor: improve code formatting and readability in output.rs * Potential fix for code scanning alert no. 210: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for code scanning alert no. 211: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * refactor: enhance triage file path handling with improved error management and validation * refactor: updated func summaries for richer detail * refactor: update SSA summary extraction to use canonical FuncKey for distinct entries * refactor: enhance callee metadata structure to support arity, receiver, and qualifier for better overload resolution * refactor: add support for keyword arguments in function calls and enhance receiver extraction for method-style calls * refactor: implement new Flask routes for safe and unsafe shell command execution * refactor: separate receiver handling in SSA operations and enhance taint propagation * refactor: improve arity handling by using arg_uses for positional argument count and enhance witness scoring for tainted arguments * refactor: implement auth decorator extraction and classification for multiple languages * refactor: enhance Rust module path resolution and use map handling for cross-file disambiguation * refactor: introduce CalleeQuery struct for structured callee resolution and enhance resolver logic * refactor: implement same-file identity collision handling for `runTask` to ensure correct resolver behavior * refactor: standardize default struct initialization across multiple files * feat: add scripts for formatting checks and auto-fixes with test summaries * refactor: simplify character splitting and enhance namespace qualifier handling * refactor: improve documentation clarity and enhance code readability in resolver logic * refactor: replace default struct initialization with explicit field assignments for clarity * feat: enhance anonymous function naming by deriving context-based bindings * refactor: streamline match expressions for improved readability and performance * refactor: streamline match expressions for improved readability and performance * refactor: replace loop with while let for improved clarity and performance * feat: add SSA constant propagation support to analysis context for improved accuracy * feat: add SSA constant propagation support to analysis context for improved accuracy * feat: implement shell metacharacter validation and bounded-length checks in Rust analysis * feat: add static map analysis for command injection suppression and type safety * refactor: simplify match statements and reduce line breaks for improved readability * feat(summary): phase 1/5 SinkSite data model for primary sink-location attribution Introduce SinkSite (file_rel, line, col, snippet, cap) carrying the primary sink source-location through function summaries. Swap SsaFuncSummary.param_to_sink and FuncSummary.param_to_sink from a coarse Cap map to a deduped SmallVec<[SinkSite; 1]> per parameter, with a backward-compatible cap_sites() helper and serde defaults so pre-phase-1 on-disk rows continue to deserialise cleanly. Extraction: SinkSiteLocator bundles the tree/bytes/file_rel needed by extract_ssa_func_summary; ParsedFile::extract_ssa_artifacts wires the locator in for the persisted pass-1 path, while pass-2 intra-file transient summaries fall back to cap-only sites (behavior unchanged). Merge: GlobalSummaries::insert now unions sink sites with (file_rel, line, col, cap) dedup via shared union_param_sink_sites helper. Database: JSON-serialised summary columns carry the new shape automatically; no schema change needed. Phase 2 will consume SinkSite in build_taint_diag() to overwrite the caller-site Finding.line with the callee's sink line when resolved via summary. Phase 1 keeps behavior unchanged: scanning tests/benchmark/corpus/rust/cmdi/cmdi_indirect.rs still produces the same (wrong) line 10 finding. Adds round-trip tests covering SinkSite solo, SsaFuncSummary with sink sites, legacy-JSON default handling for both summary types, and merge dedup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(taint): phase 2/5 thread SinkSite into SsaTaintEvent and Finding Plumb Phase 1's SinkSite through the event pipeline into Findings, no output change yet. SsaTaintEvent gains `primary_sink_site: Option<SinkSite>`; when the main or callback sink-emission path has non-empty `param_to_sink_sites`, filter to sites whose `(line != 0) && (cap ∩ sink_caps != ∅)` and emit one event per distinct site — the multi-primary collapse keeps each downstream Finding single-primary. Resolution: ResolvedSummary and SinkInfo gain mirror `param_to_sink_sites` fields, populated from `SsaFuncSummary.param_to_sink` (SSA + callback paths) and `FuncSummary.param_to_sink` (global paths). Label, local-summary, and interop resolution paths leave the field empty — they only ever had cap-level info to begin with. Finding: new `primary_location: Option<SinkLocation>` with `file_rel/line/col`. `ssa_events_to_findings` maps `event.primary_sink_site` → `Finding.primary_location`, filtering cap-only sites (`line == 0`) to `None` so the (0,0) sentinel never leaks to formatters. Dedup key extended with the primary location so multi-site events aren't collapsed back together. Invariants (debug_assert!): * every SinkSite reaching emission has `line != 0 && cap ∩ sink_caps != ∅` — enforced by the pick_primary_sink_sites* filters; * every populated Finding.primary_location has `line != 0` AND non-empty `file_rel` — the cap-only → None translation upstream guarantees this. Deliberately independent of `uses_summary`: that flag tracks whether the *taint chain* used a summary, whereas primary attribution requires only that the *sink* itself was summary-resolved. A local source reaching a cross-file sink produces `uses_summary=false` alongside a populated primary_location — documented on Finding.primary_location, covered by `cross_file_sink_finding_carries_primary_location`. build_taint_diag, SARIF/JSON/explanation formatters, and the benchmark scorer remain untouched: finding.line still comes from `cfg_graph[finding.sink]`, so cmdi_indirect.rs still reports line 10 and the benchmark's rs-cmdi-003 row still shows FN in the LOC column. Tests: `cross_file_sink_finding_carries_primary_location` (proves plumbing via a synthetic FuncSummary carrying a SinkSite at 42:5) and `cross_file_sink_cap_only_site_leaves_primary_location_none` (regression guard against cap-only sites surfacing). All 1566 lib tests + integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(output): phase 3/5 consume primary sink location in diag + SARIF When a finding's primary_location (populated in phase 2 from a callee summary's SinkSite) names the dangerous instruction inside a callee body, attribute the diagnostic line to that location instead of the caller's call site. The call site is demoted to a Call step in flow_steps, and a synthetic Sink step at the primary location is appended so analysts still see the full trace. Changes: - Add scan_root parameter to build_taint_diag so file_rel can be resolved back to an absolute path via a shared resolve_file_rel helper. Empty file_rel (single-file scans where namespace == "") resolves to the file under analysis. - Extend SinkLocation with snippet, carried from the upstream SinkSite so the formatter needs no second file read. - Relax the ssa_events_to_findings debug_assert to allow empty file_rel, which is valid when scan root equals the file itself. - SARIF: emit data-flow as codeFlows[0].threadFlows[0].locations[]; locations[0] already reflects the primary sink position via the updated diag line/col. Acceptance: scan on tests/benchmark/corpus/rust/cmdi/cmdi_indirect.rs now reports line 5 (Command::new) as the primary sink, with the call site at line 10 visible in flow_steps. Two expect.json fixtures updated (must_match line_range widened): - javascript/taint/context_sensitive_call: 12-14 -> 7-14 (line 8 is the real sink inside run()). - rust/cfg/closure_async: 10-10 -> 10-11 (line 11 is Command::new inside the closure). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(bench): phase 4/5 validate primary sink attribution across corpus Extend the benchmark scorer and ground truth to lock in phase 3's primary-location behavior, and add fixtures that exercise the new capability end-to-end. Scorer (tests/benchmark_test.rs): - Add optional `expected_call_site_lines: Option<Vec<[usize; 2]>>` on Case. When present, score_location_level additionally requires at least one flow_step in the finding's evidence trace to fall within ±2 of the call-site range. When absent, the check is skipped — fully forward-compatible with existing fixtures. - Retain ±2 tolerance on expected_sink_lines (compared against the now-primary Diag.line post-phase-3). Ground truth edits: - rs-cmdi-cross-001: expected_sink_lines [8,8] -> [9,9]. Line 8 is the transform::wrap call site (a cross-file propagator, not a sink); line 9 is Command::new, the real sink. The ±2 tolerance happened to mask this stale attribution but it was semantically wrong — phase 4 is the right time to correct it. Also adds expected_call_site_lines [8,8] so the new field is exercised on an existing cross-file case. - rs-cmdi-003: adds expected_call_site_lines [10,10] (run_cmd call). This fixture's sink (Command::new inside run_cmd at line 5) was the motivating case for phases 1-3; adding the call-site assertion guards against regression to caller-line attribution. New fixtures: - rust/cmdi/cmdi_indirect_multisink.rs (rs-cmdi-009): helper run_both takes two tainted params and invokes two Command sinks on consecutive lines. Locks in that primary line lands inside the helper (lines 5-6), not at the caller (line 12). Notes document that SinkSite is currently one-per-callee so both findings today collapse onto the first sink; expected_sink_lines=[5,6] and expected_call_site_lines=[12,12] stay valid either way. - python/cmdi/cross_indirect_sink/{app.py,helper.py} (py-cmdi-cross- 004): sink os.system lives in helper.py (cross-file), caller in app.py reads env source and calls run_cmd. Verifies phase 3's cross-file primary attribution: Diag.path = helper.py, Diag.line = 5, with app.py:7 recorded in flow_steps as a Call step. Acceptance: - `cargo test --test benchmark_test -- --ignored --nocapture` passes. - rs-cmdi-003 is TP/TP/TP (the target flip FN->TP at LOC). All pre-existing TP/TP/TP fixtures remain TP/TP/TP; 2 new fixtures are TP/TP/TP. - Aggregate rule-level: TP=158 FP=10 FN=1 TN=97, P=0.940 R=0.994 F1=0.966 on the 266-case corpus (was TP=156 FP=10 FN=1 TN=97 on 264 pre-phase-4, delta is the +2 new cases both resolving TP). - Full `cargo test` green (1566 lib tests + all integration tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(taint): phase 5/5 lock Finding.primary_location contract via regression test Add a regression test in src/taint/ssa_transfer.rs that wires up a synthetic SsaFuncSummary with a SinkSite at other.rs:42:10 and drives the three emission stages (pick_primary_sink_sites → emit_ssa_taint_events → ssa_events_to_findings) against a minimal caller SSA body. Asserts the resulting Finding.primary_location is exactly that triple. The existing integration tests in src/taint/tests.rs cover the coarse FuncSummary path end-to-end through analyse_file. This test locks in the lower-level SSA-side plumbing so a future refactor that silently drops the site between pick → emit → findings fails here rather than only at the benchmark layer. Also refreshes tests/benchmark/results/latest.json (timestamp only; rs-cmdi-003 remains TP/TP/TP and the aggregate P/R/F1 are unchanged from phase 4). Closes the primary sink-location attribution feature (phases 1-5/5): * Phase 1 — SinkSite data model on summaries. * Phase 2 — SinkSite threaded into SsaTaintEvent and Finding. * Phase 3 — diag + SARIF consume primary_location. * Phase 4 — benchmark validates primary_call_site_lines across corpus. * Phase 5 — regression test locks the event→finding contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: clean up formatting and improve readability in multiple files * refactor: simplify type definition for deduplication key in findings * test(harness): add must_not_match expectation for FP regression guards Extends ExpectedFinding with must_not_match field that asserts a diagnostic must NOT fire — presence is a hard failure. Non-consuming scan so it coexists with must_match entries on the same rule_id. Adds forbidden_violations accumulator and updates summary line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(regression): update expectations to ensure must_not_match for various taint and resource leak rules * feat: implement auto-seeding for JS/TS handler parameters to enhance taint tracking * feat: update switch statement handling to improve control flow analysis * feat: implement promisify alias handling for JS/TS to enhance taint tracking * feat: enhance taint tracking by refining expectation handling and adding mode filtering * feat: refine SQL handling in stream processing and enhance auto-seeding for handler parameters * feat: update taint tracking rules to enforce full mode matching and improve flow analysis * feat: enhance Ruby subshell handling to improve taint tracking and flow analysis * feat: update xss_response expectations to refine taint flow analysis and enhance regression guarding * feat: refine framework detection and update expectation handling for Echo and Sinatra * feat: implement max_count for taint tracking expectations and deduplicate findings * feat: add strict_unexpected handling for taint-unsanitised-flow in expectation files * feat: enhance deduplication of taint-unsanitised-flow findings by collapsing based on line and severity * feat: add strict_unexpected handling for taint-unsanitised-flow in multiple expectation files * feat: add structural invariant checks for SSA bodies * feat: ensure deterministic phi emission order using BTreeSet * feat: enhance handling of terminators to ensure authoritative flow through successor edges * feat: enhance Goto terminator handling to ensure all successors are marked executable * feat: refactor code for improved readability and organization * feat: simplify predicate checks and enhance readability in SSA handling * feat: implement per-file parse timeout and enhance file size handling * feat: migrate analysis engine toggles from environment variables to configuration file * feat: remove unnecessary whitespace in hostile_input_tests.rs * feat: remove unnecessary whitespace in hostile_input_tests.rs * feat: update dependencies and enhance documentation on language maturity * feat: enhance security headers and improve request body limits * feat: implement sink capability bits for deduplication and enhance evidence tagging * feat: implement dynamic activation handling for gated sinks and enhance validation logic * feat: enhance configuration documentation and clarify inline analysis cache behavior * feat: implement panic recovery during analysis to continue scans past errors * feat: add expectations configuration for taint analysis and performance metrics * feat: enhance error handling and logging during file reading and mutex locking * feat: add cross-file body loading tests and plumbing for CF-1 phase * feat: implement cross-file k=1 context-sensitive inline taint analysis with new tests and fixtures * feat: implement indexed-scan parity in cross-file inline analysis with new dropdown and copy functionality * feat: enhance classification span handling in CFG and AST for improved source attribution * feat: add new Express routes for handling user input and telemetry data * feat: implement ternary expression handling in CFG with diamond structure for JS/TS * feat: implement Phase CF-3 abstract-domain transfer channels in summaries * feat: add support for string-prefix transfer in cross-file calls and update tests * docs: reduce RESULTS.md doc size * feat: implement Phase CF-4 per-return-path summary decomposition with tests * feat: update parameter handling in pass1 and refactor SsaFuncSummary initialization * feat: implement Phase CF-5 for cross-file SCC joint fixed-point convergence with new flags and tests * feat: implement Phase CF-6 with parameter-granularity points-to summaries and associated tests * refactor: update comments and documentation for clarity and consistency * style: format code for consistency and readability * refactor: simplify verdict handling and improve edge checking logic * refactor: optimize path and identifier collection by avoiding unnecessary cloning * chore: update Cargo.toml for Rust version 1.85 and add ignored files; modify CHANGELOG and README for clarity on state analysis defaults * refactor: update documentation and improve clarity in configuration files * refactor: update documentation and improve clarity in configuration files * feat: add JS/TS pass-2 convergence tests and expectations configuration * feat: add Phase 5 regression tests for inline cache origin attribution and update related logic * feat: implement Phase 7 deduplication and alternative path linking for taint findings * feat: implement structural DFS index for anonymous functions and update naming conventions * feat: add Phase 8 regression tests for container-element taint in JS and Python * feat: add engine-depth profiles and explain-engine option for CLI * feat: update expectations and add new README fixtures for multi-file scan regression * feat: implement Phase 11 callback-alias and factory patterns with regression tests * feat: implement Terminator::Switch for multi-way dispatch and add regression tests * feat: add real-CVE benchmark fixtures for CVE-2023-48022, CVE-2019-14939, and CVE-2023-26159 with corresponding patched variants * refactor: extract cfg and ssa_transfer to submodules * refactor: cargo fmt * refactor: remove unnecessary blank line in cfg_tests.rs * refactor: remove unnecessary planning file * chore: update Rust version to 1.88 and bump dependencies in Cargo files * feat: enhance triage UI with new layout and controls, update README for clarity * feat: enhance triage UI with new layout and controls, update README for clarity * chore: remove outdated section from README for version 0.5.0 * docs: improve clarity and consistency in README content * chore: add "GPL-3.0-or-later" to license options in about.toml * chore: update license handling in about.toml and check-licenses.mjs * style: format code for improved readability in TriagePage component * style: format code for improved readability in TriagePage component * chore: enhance license handling and improve body_id scoping in seed lookup * feat: introduce owner and parent body IDs for enhanced seed scoping * feat: implement direction-aware engine provenance with new CLI flag for strict CI gating * feat: add Undef SSA operation for improved control-flow handling * style: improve code formatting for consistency and readability in multiple files * feat: add 16-function chain SCC across multiple files for enhanced analysis * style: simplify code formatting for improved readability in multiple files * fix: update CapHitReason default implementation and improve README clarity * docs: enhance README with detailed explanations of taint analysis and limitations * docs: refine README for clarity and consistency in taint analysis section * style: improve code formatting for better readability in NewScanModal and scans * fix: update cargo-about command to use --offline for deterministic license generation * fix: update cargo-about command to use --offline for deterministic license generation * ci: add step to prime cargo registry cache for deterministic license generation * feat: add support for non-sink collections in authorization analysis * feat: enhance authorization checks with row-level ownership equality and binding tracking * feat: implement self-scoped user handling and enhance ownership checks * refactor: simplify assertions and formatting in authorization analysis tests * fix: normalize line endings in THIRDPARTY-LICENSES.html generation and update README with AI disclosure * docs: update AI disclosure section for clarity and conciseness * feat: add AI Contribution Policy and update contributing guidelines for AI assistance disclosure * feat: enhance authorization analysis with SSA-derived variable type classification * feat: implement auth_finding_to_diag function for enhanced security diagnostics * feat: add args_value_refs to CallSite struct for enhanced argument tracking * feat: add args_value_refs to CallSite struct for enhanced argument tracking * feat: add direction-aware engine provenance with LossDirection classification and new CLI flag * feat: simplify strip_cap_from_call_args call by removing unnecessary line breaks * feat: enhance error message handling in cli_validation_tests for better Windows compatibility * feat: optimize release profile settings in Cargo.toml and update CodeQL configuration * feat: enhance release build process with SBOM generation and SLSA provenance * feat: update actions/checkout and actions/setup-node to v6, enhance CLI options, and improve auth-check summaries * feat: introduce PathFact handling for path safety checks and rejection logic * feat: introduce PathFact handling for path safety checks and rejection logic * feat: update benchmark data and enhance path sanitization logic with new safety checks * feat: document AI assistance in frontend UI development and human review process * feat: add return path facts for enhanced path safety checks and update documentation * chore: update release date for version 0.5.0 in CHANGELOG.md * chore: clean up ci.yml by removing outdated comments and clarifying steps * feat: implement cross-language path sanitizers and validators for enhanced security * feat: enhance SSA value usage tracking by including block terminators and improve path safety checks * feat: enhance switch statement handling by adding per-case path constraints and support for exclusive cases * refactor: simplify conditional formatting and improve code readability in executor and lower modules * feat: add vulnerable examples for various languages demonstrating authentication and sanitization issues * feat: enhance actor context recognition for self-actor identifiers and add support for global non-sink receivers * feat: enhance actor context recognition for self-actor identifiers and add support for global non-sink receivers * feat: add transform classifiers for Java, Go, and Ruby with corresponding tests * refactor: clarify comments on reassign-to-constant idiom and sink behavior in guards.rs --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
883 lines
31 KiB
Rust
883 lines
31 KiB
Rust
//! Semantic regression suite for the SSA lowering + optimisation + taint
|
||
//! pipeline.
|
||
//!
|
||
//! This file used to be a legacy/SSA equivalence test. After legacy
|
||
//! was removed the file degenerated to "scan each fixture and assert
|
||
//! no panic", which proved almost nothing. It has been restored as a
|
||
//! multi-tier correctness signal. Each `#[test]` fn below verifies a
|
||
//! distinct property:
|
||
//!
|
||
//! * `ssa_structural_invariants_corpus` — every body in every real-world
|
||
//! fixture lowers to well-formed SSA. Enforced via
|
||
//! [`nyx_scanner::ssa::invariants::check_structural_invariants`]:
|
||
//! single-assignment, pred/succ symmetry, terminator/succs agreement,
|
||
//! phi arity and operand sources, value-def coverage, and reachability.
|
||
//!
|
||
//! * `ssa_lowering_is_deterministic` — lowering the same CFG twice produces
|
||
//! structurally identical SSA (equal fingerprint). Catches any incoming
|
||
//! non-determinism introduced by hashing or iteration order.
|
||
//!
|
||
//! * `ssa_optimize_is_idempotent` — `optimize_ssa` reaches a fixpoint on
|
||
//! the first run: re-running it must prune zero branches, eliminate
|
||
//! zero copies, and remove zero dead defs, and must not change the body
|
||
//! fingerprint. Catches optimiser bugs where a second pass would find
|
||
//! new work (indicating the first pass failed to converge).
|
||
//!
|
||
//! * `summary_extraction_is_deterministic` — extracting summaries from the
|
||
//! same bytes twice yields the same `(FuncSummary, SsaFuncSummary)`
|
||
//! sets, compared via stable JSON serialisation. Catches any
|
||
//! non-determinism in summary construction or cross-file key ordering.
|
||
//!
|
||
//! * `scan_is_stable_across_runs` — a full two-pass scan produces the same
|
||
//! diag list when invoked twice on the same input. Runs on a curated
|
||
//! per-language fixture subset to keep wall time bounded; the other
|
||
//! tiers already cover full-corpus behaviour.
|
||
//!
|
||
//! * `ssa_corpus_does_not_panic` — the original smoke check, kept to lock
|
||
//! in termination on the full fixture matrix.
|
||
//!
|
||
//! Run with: `cargo test --test ssa_equivalence_tests`
|
||
//!
|
||
//! Set `NYX_SSA_VERBOSE=1` for per-fixture progress output.
|
||
|
||
mod common;
|
||
|
||
use common::test_config;
|
||
use nyx_scanner::ast::{build_cfg_for_file, extract_all_summaries_from_bytes};
|
||
use nyx_scanner::cfg::BodyCfg;
|
||
use nyx_scanner::commands::scan::Diag;
|
||
use nyx_scanner::ssa::{
|
||
invariants::{body_fingerprint, check_structural_invariants},
|
||
lower_to_ssa, optimize_ssa,
|
||
};
|
||
use nyx_scanner::utils::config::{AnalysisMode, Config};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
// ── Fixture discovery ─────────────────────────────────────────────────────
|
||
|
||
struct Fixture {
|
||
name: String,
|
||
source_path: PathBuf,
|
||
}
|
||
|
||
fn discover_fixtures() -> Vec<Fixture> {
|
||
let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/real_world");
|
||
let mut fixtures = Vec::new();
|
||
|
||
let langs = [
|
||
"rust",
|
||
"c",
|
||
"cpp",
|
||
"java",
|
||
"go",
|
||
"php",
|
||
"python",
|
||
"ruby",
|
||
"typescript",
|
||
"javascript",
|
||
];
|
||
let categories = ["taint", "cfg", "state", "mixed"];
|
||
|
||
for lang in &langs {
|
||
for category in &categories {
|
||
let dir = base.join(lang).join(category);
|
||
if !dir.is_dir() {
|
||
continue;
|
||
}
|
||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||
continue;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
let fname = path.file_name().unwrap().to_string_lossy().to_string();
|
||
if !fname.ends_with(".expect.json") {
|
||
continue;
|
||
}
|
||
let stem = fname.trim_end_matches(".expect.json");
|
||
if let Some(source_path) = find_source_file(&dir, stem) {
|
||
fixtures.push(Fixture {
|
||
name: format!("{lang}/{category}/{stem}"),
|
||
source_path,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
fixtures.sort_by(|a, b| a.name.cmp(&b.name));
|
||
fixtures
|
||
}
|
||
|
||
fn find_source_file(dir: &Path, stem: &str) -> Option<PathBuf> {
|
||
let extensions = [
|
||
"rs", "c", "cpp", "cc", "cxx", "java", "go", "php", "py", "rb", "ts", "tsx", "js", "jsx",
|
||
];
|
||
for ext in extensions {
|
||
let candidate = dir.join(format!("{stem}.{ext}"));
|
||
if candidate.exists() {
|
||
return Some(candidate);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn verbose() -> bool {
|
||
std::env::var("NYX_SSA_VERBOSE")
|
||
.map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
// ── Helpers for scanning a single-file fixture in isolation ──────────────
|
||
|
||
fn scan_single_file(fixture: &Fixture) -> Vec<Diag> {
|
||
let tmp = tempfile::TempDir::with_prefix("nyx_ssa_sem_").expect("tempdir");
|
||
let dest = tmp.path().join(fixture.source_path.file_name().unwrap());
|
||
std::fs::copy(&fixture.source_path, &dest).expect("copy fixture");
|
||
|
||
let cfg = test_config(AnalysisMode::Full);
|
||
let mut diags =
|
||
nyx_scanner::scan_no_index(tmp.path(), &cfg).expect("scan_no_index should succeed");
|
||
|
||
// Normalise paths to filenames so tmp path does not leak into comparisons.
|
||
for d in &mut diags {
|
||
if let Some(fname) = Path::new(&d.path).file_name() {
|
||
d.path = fname.to_string_lossy().to_string();
|
||
}
|
||
}
|
||
diags.sort_by(|a, b| {
|
||
a.id.cmp(&b.id)
|
||
.then(a.line.cmp(&b.line))
|
||
.then(a.col.cmp(&b.col))
|
||
.then(a.path.cmp(&b.path))
|
||
});
|
||
diags
|
||
}
|
||
|
||
/// Render a diag list to a canonical string for equality comparison.
|
||
/// Strips non-deterministic fields (rank_score floats) that should not
|
||
/// affect correctness.
|
||
fn diag_fingerprint(diags: &[Diag]) -> String {
|
||
use std::fmt::Write;
|
||
let mut out = String::new();
|
||
for d in diags {
|
||
let _ = writeln!(
|
||
out,
|
||
"{id}|{path}|{line}|{col}|{sev}|{cat:?}|{pv}|{gk}|{sup}",
|
||
id = d.id,
|
||
path = d.path,
|
||
line = d.line,
|
||
col = d.col,
|
||
sev = d.severity.as_db_str(),
|
||
cat = d.category,
|
||
pv = d.path_validated,
|
||
gk = d.guard_kind.as_deref().unwrap_or(""),
|
||
sup = d.suppressed,
|
||
);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Iterate every body (top-level + functions) across all bodies of a file.
|
||
fn each_body<'a>(bodies: &'a [BodyCfg]) -> impl Iterator<Item = &'a BodyCfg> + 'a {
|
||
bodies.iter()
|
||
}
|
||
|
||
// ── Tier 1: Structural invariants on every body of every fixture ─────────
|
||
|
||
#[test]
|
||
fn ssa_structural_invariants_corpus() {
|
||
let fixtures = discover_fixtures();
|
||
assert!(
|
||
!fixtures.is_empty(),
|
||
"no fixtures discovered — CARGO_MANIFEST_DIR wrong?"
|
||
);
|
||
|
||
let cfg = test_config(AnalysisMode::Full);
|
||
let mut failures: Vec<String> = Vec::new();
|
||
let mut bodies_checked: usize = 0;
|
||
|
||
for fixture in &fixtures {
|
||
let Ok(Some((file_cfg, _lang))) = build_cfg_for_file(&fixture.source_path, &cfg) else {
|
||
continue;
|
||
};
|
||
|
||
for body in each_body(&file_cfg.bodies) {
|
||
let Ok(ssa) = lower_to_ssa(&body.graph, body.entry, None, true) else {
|
||
// Some bodies are legitimately empty / unreachable; skip
|
||
// without flagging. The panic-free smoke test covers that
|
||
// the scan path handles the `Err` correctly.
|
||
continue;
|
||
};
|
||
bodies_checked += 1;
|
||
|
||
let errs = check_structural_invariants(&ssa);
|
||
if !errs.is_empty() {
|
||
failures.push(format!(
|
||
"{} body={:?} ({} block(s)):\n {}",
|
||
fixture.name,
|
||
body.meta.name.as_deref().unwrap_or("<toplevel>"),
|
||
ssa.blocks.len(),
|
||
errs.join("\n ")
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
bodies_checked > 100,
|
||
"sanity: expected >100 bodies across the corpus, got {bodies_checked}"
|
||
);
|
||
if verbose() {
|
||
eprintln!(
|
||
"structural invariants: {} bodies checked across {} fixtures",
|
||
bodies_checked,
|
||
fixtures.len()
|
||
);
|
||
}
|
||
assert!(
|
||
failures.is_empty(),
|
||
"SSA structural invariants violated in {} body/fixture combo(s):\n{}",
|
||
failures.len(),
|
||
failures.join("\n")
|
||
);
|
||
}
|
||
|
||
// ── Tier 2: Lowering determinism ─────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn ssa_lowering_is_deterministic() {
|
||
let fixtures = discover_fixtures();
|
||
let cfg = test_config(AnalysisMode::Full);
|
||
let mut failures: Vec<String> = Vec::new();
|
||
let mut bodies_checked: usize = 0;
|
||
|
||
for fixture in &fixtures {
|
||
let Ok(Some((file_cfg, _))) = build_cfg_for_file(&fixture.source_path, &cfg) else {
|
||
continue;
|
||
};
|
||
for body in each_body(&file_cfg.bodies) {
|
||
let Ok(a) = lower_to_ssa(&body.graph, body.entry, None, true) else {
|
||
continue;
|
||
};
|
||
let Ok(b) = lower_to_ssa(&body.graph, body.entry, None, true) else {
|
||
continue;
|
||
};
|
||
bodies_checked += 1;
|
||
|
||
let fa = body_fingerprint(&a);
|
||
let fb = body_fingerprint(&b);
|
||
if fa != fb {
|
||
failures.push(format!(
|
||
"{} body={:?}: non-deterministic SSA lowering",
|
||
fixture.name,
|
||
body.meta.name.as_deref().unwrap_or("<toplevel>"),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
bodies_checked > 100,
|
||
"sanity: expected >100 bodies, got {bodies_checked}"
|
||
);
|
||
assert!(
|
||
failures.is_empty(),
|
||
"SSA lowering is non-deterministic in {} body/fixture combo(s):\n{}",
|
||
failures.len(),
|
||
failures.join("\n")
|
||
);
|
||
}
|
||
|
||
// ── Tier 2b: Strict 10× determinism on multi-phi bodies ──────────────────
|
||
|
||
/// Stronger determinism check than Tier 2: for every body in the corpus
|
||
/// that carries ≥ 2 phis (where phi ordering is the most likely culprit
|
||
/// for hasher-driven non-determinism), lower the CFG ten times in a row
|
||
/// and assert every fingerprint matches the first — bit-for-bit, with no
|
||
/// sort tolerance. Runs are interleaved across fixtures so that
|
||
/// process-wide hasher state between lowerings is as adversarial as we
|
||
/// can make it without `PYTHONHASHSEED`-style seeding.
|
||
#[test]
|
||
fn ssa_lowering_is_deterministic_strict_10x() {
|
||
let fixtures = discover_fixtures();
|
||
let cfg = test_config(AnalysisMode::Full);
|
||
let mut failures: Vec<String> = Vec::new();
|
||
let mut bodies_checked: usize = 0;
|
||
let mut multi_phi_bodies: usize = 0;
|
||
|
||
for fixture in &fixtures {
|
||
let Ok(Some((file_cfg, _))) = build_cfg_for_file(&fixture.source_path, &cfg) else {
|
||
continue;
|
||
};
|
||
for body in each_body(&file_cfg.bodies) {
|
||
// Lower once up front to detect multi-phi bodies cheaply; skip
|
||
// trivially-phi-less bodies so the 10× loop stays bounded.
|
||
let Ok(first) = lower_to_ssa(&body.graph, body.entry, None, true) else {
|
||
continue;
|
||
};
|
||
let phi_count: usize = first.blocks.iter().map(|b| b.phis.len()).sum();
|
||
if phi_count < 2 {
|
||
continue;
|
||
}
|
||
multi_phi_bodies += 1;
|
||
|
||
let expected = body_fingerprint(&first);
|
||
for i in 1..10 {
|
||
let Ok(again) = lower_to_ssa(&body.graph, body.entry, None, true) else {
|
||
failures.push(format!(
|
||
"{} body={:?}: lowering failed on iteration {i} after succeeding earlier",
|
||
fixture.name,
|
||
body.meta.name.as_deref().unwrap_or("<toplevel>"),
|
||
));
|
||
break;
|
||
};
|
||
let fp = body_fingerprint(&again);
|
||
if fp != expected {
|
||
failures.push(format!(
|
||
"{} body={:?}: fingerprint diverged on iteration {i}\n --- expected ---\n{expected} --- got ---\n{fp}",
|
||
fixture.name,
|
||
body.meta.name.as_deref().unwrap_or("<toplevel>"),
|
||
));
|
||
break;
|
||
}
|
||
bodies_checked += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
multi_phi_bodies >= 10,
|
||
"expected to cover >= 10 multi-phi bodies for a meaningful strict-determinism check, got {multi_phi_bodies}",
|
||
);
|
||
assert!(
|
||
bodies_checked > 80,
|
||
"sanity: expected >80 (body × iteration) samples, got {bodies_checked}"
|
||
);
|
||
assert!(
|
||
failures.is_empty(),
|
||
"SSA lowering is non-deterministic in {} body/fixture combo(s) under 10× strict comparison:\n{}",
|
||
failures.len(),
|
||
failures.join("\n")
|
||
);
|
||
}
|
||
|
||
// ── Tier 3: Optimization idempotence ─────────────────────────────────────
|
||
|
||
#[test]
|
||
fn ssa_optimize_is_idempotent() {
|
||
let fixtures = discover_fixtures();
|
||
let cfg = test_config(AnalysisMode::Full);
|
||
let mut failures: Vec<String> = Vec::new();
|
||
let mut bodies_checked: usize = 0;
|
||
|
||
for fixture in &fixtures {
|
||
let Ok(Some((file_cfg, lang))) = build_cfg_for_file(&fixture.source_path, &cfg) else {
|
||
continue;
|
||
};
|
||
for body in each_body(&file_cfg.bodies) {
|
||
let Ok(mut ssa) = lower_to_ssa(&body.graph, body.entry, None, true) else {
|
||
continue;
|
||
};
|
||
|
||
// First optimisation pass — may do real work.
|
||
let _ = optimize_ssa(&mut ssa, &body.graph, Some(lang));
|
||
let fp_after_first = body_fingerprint(&ssa);
|
||
|
||
// Second pass must be a fixpoint:
|
||
// * body fingerprint unchanged
|
||
// * zero additional branches pruned / copies eliminated /
|
||
// dead defs removed
|
||
let second = optimize_ssa(&mut ssa, &body.graph, Some(lang));
|
||
let fp_after_second = body_fingerprint(&ssa);
|
||
bodies_checked += 1;
|
||
|
||
if fp_after_first != fp_after_second {
|
||
failures.push(format!(
|
||
"{} body={:?}: optimize_ssa changed body fingerprint on second pass",
|
||
fixture.name,
|
||
body.meta.name.as_deref().unwrap_or("<toplevel>"),
|
||
));
|
||
}
|
||
if second.branches_pruned != 0
|
||
|| second.copies_eliminated != 0
|
||
|| second.dead_defs_removed != 0
|
||
{
|
||
failures.push(format!(
|
||
"{} body={:?}: optimize_ssa did not reach fixpoint (branches={}, copies={}, dead_defs={})",
|
||
fixture.name,
|
||
body.meta.name.as_deref().unwrap_or("<toplevel>"),
|
||
second.branches_pruned,
|
||
second.copies_eliminated,
|
||
second.dead_defs_removed,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
bodies_checked > 100,
|
||
"sanity: expected >100 bodies, got {bodies_checked}"
|
||
);
|
||
assert!(
|
||
failures.is_empty(),
|
||
"optimize_ssa is not idempotent in {} body/fixture combo(s):\n{}",
|
||
failures.len(),
|
||
failures.join("\n")
|
||
);
|
||
}
|
||
|
||
// ── Tier 4: Summary-extraction determinism ───────────────────────────────
|
||
|
||
#[test]
|
||
fn summary_extraction_is_deterministic() {
|
||
let fixtures = discover_fixtures();
|
||
let cfg = test_config(AnalysisMode::Full);
|
||
let mut failures: Vec<String> = Vec::new();
|
||
let mut files_checked: usize = 0;
|
||
|
||
for fixture in &fixtures {
|
||
let Ok(bytes) = std::fs::read(&fixture.source_path) else {
|
||
continue;
|
||
};
|
||
let Ok((fn_a, ssa_a, _bodies_a, _auth_a)) =
|
||
extract_all_summaries_from_bytes(&bytes, &fixture.source_path, &cfg, None)
|
||
else {
|
||
continue;
|
||
};
|
||
let Ok((fn_b, ssa_b, _bodies_b, _auth_b)) =
|
||
extract_all_summaries_from_bytes(&bytes, &fixture.source_path, &cfg, None)
|
||
else {
|
||
continue;
|
||
};
|
||
files_checked += 1;
|
||
|
||
// Counts must match exactly.
|
||
if fn_a.len() != fn_b.len() {
|
||
failures.push(format!(
|
||
"{}: FuncSummary count unstable ({} vs {})",
|
||
fixture.name,
|
||
fn_a.len(),
|
||
fn_b.len()
|
||
));
|
||
continue;
|
||
}
|
||
if ssa_a.len() != ssa_b.len() {
|
||
failures.push(format!(
|
||
"{}: SsaFuncSummary count unstable ({} vs {})",
|
||
fixture.name,
|
||
ssa_a.len(),
|
||
ssa_b.len()
|
||
));
|
||
continue;
|
||
}
|
||
|
||
// SSA summaries: compare after sorting by key (order from the extractor
|
||
// is expected-deterministic, but if two runs diverge only in order the
|
||
// test should still pass — what matters is the set identity).
|
||
let mut ssa_a_sorted = ssa_a;
|
||
let mut ssa_b_sorted = ssa_b;
|
||
ssa_a_sorted.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
|
||
ssa_b_sorted.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
|
||
|
||
for (i, ((k_a, s_a), (k_b, s_b))) in
|
||
ssa_a_sorted.iter().zip(ssa_b_sorted.iter()).enumerate()
|
||
{
|
||
if format!("{k_a:?}") != format!("{k_b:?}") {
|
||
failures.push(format!(
|
||
"{}: SsaFuncSummary key {i} differs: {:?} vs {:?}",
|
||
fixture.name, k_a, k_b,
|
||
));
|
||
continue;
|
||
}
|
||
let ja = serde_json::to_string(s_a).expect("serialize SsaFuncSummary a");
|
||
let jb = serde_json::to_string(s_b).expect("serialize SsaFuncSummary b");
|
||
if ja != jb {
|
||
failures.push(format!(
|
||
"{}: SsaFuncSummary for {k_a:?} not bitwise-stable:\n a={}\n b={}",
|
||
fixture.name, ja, jb,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
files_checked > 50,
|
||
"sanity: expected >50 files checked, got {files_checked}"
|
||
);
|
||
assert!(
|
||
failures.is_empty(),
|
||
"summary extraction is non-deterministic in {} case(s):\n{}",
|
||
failures.len(),
|
||
failures.join("\n")
|
||
);
|
||
}
|
||
|
||
// ── Tier 5: Scan stability on a curated subset ───────────────────────────
|
||
|
||
/// Curated one-per-language fixture subset used for cross-run diag stability.
|
||
/// Keeps the test bounded (~10 fixtures × 2 scans) while still touching every
|
||
/// language's full taint pipeline.
|
||
const SCAN_STABILITY_SUBSET: &[&str] = &[
|
||
"rust/taint/env_to_command",
|
||
"rust/taint/actix_xss",
|
||
"c/taint/buffer_overflow",
|
||
"cpp/taint/cmdi_execl",
|
||
"java/taint/cast_to_string_still_tainted",
|
||
"php/taint/closure_taint",
|
||
"python/taint/attribute_taint",
|
||
"ruby/taint/cmdi_backticks",
|
||
"typescript/taint/async_await_taint",
|
||
"javascript/taint/alias_no_sanitize_unsafe",
|
||
"go/taint/cmdi_http",
|
||
];
|
||
|
||
#[test]
|
||
fn scan_is_stable_across_runs() {
|
||
let fixtures = discover_fixtures();
|
||
let by_name: std::collections::HashMap<&str, &Fixture> =
|
||
fixtures.iter().map(|f| (f.name.as_str(), f)).collect();
|
||
|
||
let mut failures: Vec<String> = Vec::new();
|
||
let mut scanned: usize = 0;
|
||
|
||
for &name in SCAN_STABILITY_SUBSET {
|
||
let Some(fixture) = by_name.get(name).copied() else {
|
||
// Not a hard failure — curated names may drift as the corpus
|
||
// evolves. Log but continue so this tier stays useful.
|
||
if verbose() {
|
||
eprintln!("scan_is_stable_across_runs: missing fixture {name}");
|
||
}
|
||
continue;
|
||
};
|
||
|
||
let a = scan_single_file(fixture);
|
||
let b = scan_single_file(fixture);
|
||
scanned += 1;
|
||
|
||
let fa = diag_fingerprint(&a);
|
||
let fb = diag_fingerprint(&b);
|
||
if fa != fb {
|
||
failures.push(format!(
|
||
"{name}: diag set diverges across runs\n --- run A ---\n{fa} --- run B ---\n{fb}"
|
||
));
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
scanned >= 3,
|
||
"scan_is_stable_across_runs: only {scanned} fixtures available — did the corpus paths move?"
|
||
);
|
||
assert!(
|
||
failures.is_empty(),
|
||
"scan is non-deterministic across runs:\n{}",
|
||
failures.join("\n")
|
||
);
|
||
}
|
||
|
||
// ── Tier 6: SSA lowering coverage sanity ─────────────────────────────────
|
||
|
||
/// Guards against a silent regression that would make `lower_to_ssa`
|
||
/// return empty / trivially-satisfying bodies — which would make every
|
||
/// invariant check pass vacuously. Enforces that the corpus produces
|
||
/// non-trivial SSA: many blocks, many instructions, at least one phi
|
||
/// somewhere, at least one loop (back edge), and at least one call.
|
||
#[test]
|
||
fn ssa_lowering_produces_non_trivial_bodies() {
|
||
let fixtures = discover_fixtures();
|
||
let cfg = test_config(AnalysisMode::Full);
|
||
|
||
let mut total_blocks: usize = 0;
|
||
let mut total_insts: usize = 0;
|
||
let mut total_phis: usize = 0;
|
||
let mut total_calls: usize = 0;
|
||
let mut bodies_with_phi: usize = 0;
|
||
let mut bodies_with_call: usize = 0;
|
||
let mut multi_block_bodies: usize = 0;
|
||
let mut bodies: usize = 0;
|
||
|
||
for fixture in &fixtures {
|
||
let Ok(Some((file_cfg, _))) = build_cfg_for_file(&fixture.source_path, &cfg) else {
|
||
continue;
|
||
};
|
||
for body in each_body(&file_cfg.bodies) {
|
||
let Ok(ssa) = lower_to_ssa(&body.graph, body.entry, None, true) else {
|
||
continue;
|
||
};
|
||
bodies += 1;
|
||
total_blocks += ssa.blocks.len();
|
||
if ssa.blocks.len() > 1 {
|
||
multi_block_bodies += 1;
|
||
}
|
||
let mut body_has_phi = false;
|
||
let mut body_has_call = false;
|
||
for block in &ssa.blocks {
|
||
total_insts += block.body.len() + block.phis.len();
|
||
total_phis += block.phis.len();
|
||
if !block.phis.is_empty() {
|
||
body_has_phi = true;
|
||
}
|
||
for inst in &block.body {
|
||
if matches!(inst.op, nyx_scanner::ssa::SsaOp::Call { .. }) {
|
||
total_calls += 1;
|
||
body_has_call = true;
|
||
}
|
||
}
|
||
}
|
||
if body_has_phi {
|
||
bodies_with_phi += 1;
|
||
}
|
||
if body_has_call {
|
||
bodies_with_call += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Thresholds are generous — they only catch gross regressions (e.g. a
|
||
// lowering bug that silently produces single-block bodies with no body
|
||
// instructions). Update if the corpus intentionally shrinks.
|
||
assert!(bodies > 200, "expected >200 bodies, got {bodies}");
|
||
assert!(
|
||
multi_block_bodies > 50,
|
||
"expected >50 multi-block bodies (guard against collapse regression), got {multi_block_bodies}"
|
||
);
|
||
assert!(
|
||
total_blocks > 500,
|
||
"expected >500 blocks across corpus, got {total_blocks}"
|
||
);
|
||
assert!(
|
||
total_insts > 1000,
|
||
"expected >1000 SSA instructions across corpus, got {total_insts}"
|
||
);
|
||
assert!(
|
||
total_phis > 0,
|
||
"expected at least one phi somewhere in the corpus, got 0"
|
||
);
|
||
assert!(
|
||
total_calls > 100,
|
||
"expected >100 call instructions, got {total_calls}"
|
||
);
|
||
assert!(
|
||
bodies_with_phi > 20,
|
||
"expected >20 bodies with phis, got {bodies_with_phi}"
|
||
);
|
||
assert!(
|
||
bodies_with_call > 100,
|
||
"expected >100 bodies with calls, got {bodies_with_call}"
|
||
);
|
||
|
||
if verbose() {
|
||
eprintln!(
|
||
"ssa coverage: bodies={bodies} multi_block={multi_block_bodies} blocks={total_blocks} insts={total_insts} phis={total_phis} calls={total_calls} bodies_with_phi={bodies_with_phi} bodies_with_call={bodies_with_call}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Tier 7: Original panic-free smoke check (preserved) ─────────────────
|
||
|
||
#[test]
|
||
fn ssa_corpus_does_not_panic() {
|
||
let fixtures = discover_fixtures();
|
||
assert!(!fixtures.is_empty(), "no fixtures found");
|
||
let cfg = test_config(AnalysisMode::Full);
|
||
let mut failures: Vec<String> = Vec::new();
|
||
|
||
for fixture in &fixtures {
|
||
let result = std::panic::catch_unwind(|| build_and_lower_all(&fixture.source_path, &cfg));
|
||
if result.is_err() {
|
||
failures.push(format!("PANIC in {}", fixture.name));
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
failures.is_empty(),
|
||
"SSA corpus panics:\n{}",
|
||
failures.join("\n")
|
||
);
|
||
}
|
||
|
||
fn build_and_lower_all(path: &Path, cfg: &Config) -> usize {
|
||
let Ok(Some((file_cfg, _))) = build_cfg_for_file(path, cfg) else {
|
||
return 0;
|
||
};
|
||
let mut n = 0usize;
|
||
for body in &file_cfg.bodies {
|
||
if lower_to_ssa(&body.graph, body.entry, None, true).is_ok() {
|
||
n += 1;
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
// ── Catch-block orphan invariant ────────────────────────────────────────
|
||
//
|
||
// Construct a synthetic SsaBody where a block carries `SsaOp::CatchParam`
|
||
// but is neither reachable from entry via normal flow nor listed as a
|
||
// target of any exception edge. The invariant must report the
|
||
// orphan — this is the CFG-construction-bug signal the invariant is
|
||
// designed to surface.
|
||
//
|
||
// The test stays on the pure-function `check_catch_block_reachability`
|
||
// path to avoid the debug-build panic inside `lower_to_ssa`; it
|
||
// exercises the release-build semantics (warn + error report) which
|
||
// is what production bodies go through when compiled without
|
||
// `debug_assertions`.
|
||
|
||
#[test]
|
||
fn orphan_catch_block_triggers_reachability_invariant() {
|
||
use nyx_scanner::ssa::invariants::check_catch_block_reachability;
|
||
use nyx_scanner::ssa::{
|
||
BlockId, SsaBlock, SsaBody, SsaInst, SsaOp, SsaValue, Terminator, ValueDef,
|
||
};
|
||
use petgraph::graph::NodeIndex;
|
||
use smallvec::smallvec;
|
||
|
||
let dummy_cfg = NodeIndex::new(0);
|
||
|
||
// Block 0: entry — does not reach block 1 via succs.
|
||
// Block 1: orphan — carries CatchParam, not listed in exception_edges.
|
||
let body = SsaBody {
|
||
blocks: vec![
|
||
SsaBlock {
|
||
id: BlockId(0),
|
||
phis: vec![],
|
||
body: vec![],
|
||
terminator: Terminator::Return(None),
|
||
preds: smallvec![],
|
||
succs: smallvec![],
|
||
},
|
||
SsaBlock {
|
||
id: BlockId(1),
|
||
phis: vec![],
|
||
body: vec![SsaInst {
|
||
value: SsaValue(0),
|
||
op: SsaOp::CatchParam,
|
||
cfg_node: dummy_cfg,
|
||
var_name: Some("e".into()),
|
||
span: (0, 0),
|
||
}],
|
||
terminator: Terminator::Return(None),
|
||
preds: smallvec![],
|
||
succs: smallvec![],
|
||
},
|
||
],
|
||
entry: BlockId(0),
|
||
value_defs: vec![ValueDef {
|
||
var_name: Some("e".into()),
|
||
cfg_node: dummy_cfg,
|
||
block: BlockId(1),
|
||
}],
|
||
cfg_node_map: Default::default(),
|
||
exception_edges: vec![], // intentionally empty — the orphan condition
|
||
};
|
||
|
||
let err = check_catch_block_reachability(&body)
|
||
.expect_err("orphan catch block must fail the reachability invariant");
|
||
assert!(
|
||
err.messages
|
||
.iter()
|
||
.any(|m| m.contains("catch-block orphan")),
|
||
"expected orphan-catch message, got: {:?}",
|
||
err.messages,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn normally_reachable_catch_block_passes_invariant() {
|
||
// Regression guard: CatchParam in a block reached from entry via normal
|
||
// flow (not an exception edge) satisfies the invariant.
|
||
use nyx_scanner::ssa::invariants::check_catch_block_reachability;
|
||
use nyx_scanner::ssa::{
|
||
BlockId, SsaBlock, SsaBody, SsaInst, SsaOp, SsaValue, Terminator, ValueDef,
|
||
};
|
||
use petgraph::graph::NodeIndex;
|
||
use smallvec::smallvec;
|
||
|
||
let dummy_cfg = NodeIndex::new(0);
|
||
|
||
let body = SsaBody {
|
||
blocks: vec![
|
||
SsaBlock {
|
||
id: BlockId(0),
|
||
phis: vec![],
|
||
body: vec![],
|
||
terminator: Terminator::Goto(BlockId(1)),
|
||
preds: smallvec![],
|
||
succs: smallvec![BlockId(1)],
|
||
},
|
||
SsaBlock {
|
||
id: BlockId(1),
|
||
phis: vec![],
|
||
body: vec![SsaInst {
|
||
value: SsaValue(0),
|
||
op: SsaOp::CatchParam,
|
||
cfg_node: dummy_cfg,
|
||
var_name: Some("e".into()),
|
||
span: (0, 0),
|
||
}],
|
||
terminator: Terminator::Return(None),
|
||
preds: smallvec![BlockId(0)],
|
||
succs: smallvec![],
|
||
},
|
||
],
|
||
entry: BlockId(0),
|
||
value_defs: vec![ValueDef {
|
||
var_name: Some("e".into()),
|
||
cfg_node: dummy_cfg,
|
||
block: BlockId(1),
|
||
}],
|
||
cfg_node_map: Default::default(),
|
||
exception_edges: vec![],
|
||
};
|
||
|
||
assert!(check_catch_block_reachability(&body).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn exception_edge_catch_block_passes_invariant() {
|
||
// A CatchParam-carrying block reached only via an exception edge
|
||
// (the typical try/catch shape) must pass the invariant.
|
||
use nyx_scanner::ssa::invariants::check_catch_block_reachability;
|
||
use nyx_scanner::ssa::{
|
||
BlockId, SsaBlock, SsaBody, SsaInst, SsaOp, SsaValue, Terminator, ValueDef,
|
||
};
|
||
use petgraph::graph::NodeIndex;
|
||
use smallvec::smallvec;
|
||
|
||
let dummy_cfg = NodeIndex::new(0);
|
||
|
||
let body = SsaBody {
|
||
blocks: vec![
|
||
SsaBlock {
|
||
id: BlockId(0),
|
||
phis: vec![],
|
||
body: vec![],
|
||
terminator: Terminator::Return(None),
|
||
preds: smallvec![],
|
||
succs: smallvec![],
|
||
},
|
||
SsaBlock {
|
||
id: BlockId(1),
|
||
phis: vec![],
|
||
body: vec![SsaInst {
|
||
value: SsaValue(0),
|
||
op: SsaOp::CatchParam,
|
||
cfg_node: dummy_cfg,
|
||
var_name: Some("e".into()),
|
||
span: (0, 0),
|
||
}],
|
||
terminator: Terminator::Return(None),
|
||
preds: smallvec![],
|
||
succs: smallvec![],
|
||
},
|
||
],
|
||
entry: BlockId(0),
|
||
value_defs: vec![ValueDef {
|
||
var_name: Some("e".into()),
|
||
cfg_node: dummy_cfg,
|
||
block: BlockId(1),
|
||
}],
|
||
cfg_node_map: Default::default(),
|
||
exception_edges: vec![(BlockId(0), BlockId(1))],
|
||
};
|
||
|
||
assert!(check_catch_block_reachability(&body).is_ok());
|
||
}
|