Release/0.5.0 (#35)

* 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>
This commit is contained in:
Eli Peter 2026-04-25 17:59:11 -04:00 committed by GitHub
parent c4ce08b452
commit 41128177d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2144 changed files with 201812 additions and 8927 deletions

View file

@ -165,6 +165,14 @@ impl Lattice for AuthDomainState {
pub struct ProductState {
pub resource: ResourceDomainState,
pub auth: AuthDomainState,
/// Maps receiver symbol → class group (BodyId) for proxy resource tracking.
/// Populated when a proxy acquire fires; checked during proxy release to
/// ensure the same class context.
pub receiver_class_group: HashMap<SymbolId, crate::cfg::BodyId>,
/// Maps receiver symbol → original acquire span for proxy resources.
/// Used by `extract_findings` to attribute leaks to the original resource
/// operation (e.g., fs.openSync at line 7) rather than the proxy call.
pub proxy_acquire_spans: HashMap<SymbolId, (usize, usize)>,
}
impl ProductState {
@ -172,6 +180,8 @@ impl ProductState {
Self {
resource: ResourceDomainState::new(),
auth: AuthDomainState::new(),
receiver_class_group: HashMap::new(),
proxy_acquire_spans: HashMap::new(),
}
}
}
@ -181,13 +191,22 @@ impl Lattice for ProductState {
Self {
resource: ResourceDomainState::bot(),
auth: AuthDomainState::bot(),
receiver_class_group: HashMap::new(),
proxy_acquire_spans: HashMap::new(),
}
}
fn join(&self, other: &Self) -> Self {
// Merge proxy tracking: union of mappings
let mut class_group = self.receiver_class_group.clone();
class_group.extend(other.receiver_class_group.iter());
let mut proxy_spans = self.proxy_acquire_spans.clone();
proxy_spans.extend(other.proxy_acquire_spans.iter());
Self {
resource: self.resource.join(&other.resource),
auth: self.auth.join(&other.auth),
receiver_class_group: class_group,
proxy_acquire_spans: proxy_spans,
}
}

View file

@ -2,7 +2,7 @@ use super::lattice::Lattice;
use crate::cfg::{Cfg, EdgeKind, NodeInfo};
use petgraph::graph::NodeIndex;
use petgraph::visit::EdgeRef;
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
/// Maximum tracked variables per function (guarded degradation).
pub const MAX_TRACKED_VARS: usize = 64;
@ -44,7 +44,7 @@ pub trait Transfer<S: Lattice> {
pub struct DataflowResult<S, E> {
/// Converged state at the entry of each node.
pub states: HashMap<NodeIndex, S>,
/// Events emitted during Phase 2 transfer over converged states.
/// Events emitted during the second pass over converged states.
pub events: Vec<E>,
/// Whether the analysis converged (false if budget was hit).
#[allow(dead_code)]
@ -53,9 +53,9 @@ pub struct DataflowResult<S, E> {
/// Run a forward worklist dataflow analysis over the CFG.
///
/// Two-phase design:
/// - Phase 1: fixed-point iteration to converge states (no event collection).
/// - Phase 2: single pass over converged states to collect events.
/// Two-pass design:
/// - First pass: fixed-point iteration to converge states (no event collection).
/// - Second pass: single pass over converged states to collect events.
///
/// Termination is guaranteed by lattice finiteness + iteration budget.
pub fn run_forward<S: Lattice, T: Transfer<S>>(
@ -70,20 +70,27 @@ pub fn run_forward<S: Lattice, T: Transfer<S>>(
// Initialize entry node
states.insert(entry, initial);
// ── Phase 1: fixed-point iteration (compute converged states) ─────
// ── First pass: fixed-point iteration (compute converged states) ──
let _phase1_span = tracing::debug_span!("state_engine_phase1").entered();
let mut worklist: VecDeque<NodeIndex> = VecDeque::new();
let mut in_worklist: HashSet<NodeIndex> = HashSet::new();
worklist.push_back(entry);
in_worklist.insert(entry);
let mut iterations: usize = 0;
let mut converged = true;
while let Some(node) = worklist.pop_front() {
in_worklist.remove(&node);
iterations += 1;
if iterations > budget {
converged = !transfer.on_budget_exceeded();
if !converged {
let should_continue = transfer.on_budget_exceeded();
if !should_continue {
converged = false;
break;
}
// Budget exceeded but transfer requested continuation — mark non-converged
converged = false;
}
let node_state = match states.get(&node) {
@ -98,7 +105,21 @@ pub fn run_forward<S: Lattice, T: Transfer<S>>(
continue;
}
for (edge_kind, target) in edges {
for &(edge_kind, target) in &edges {
// Skip redundant Seq edges when a True or False edge reaches the
// same target. The CFG builder may emit both a Seq edge (from
// build_sub chaining) and a True/False edge (from explicit If
// wiring) to the same successor. The Seq edge carries no
// branch-aware state, so it dilutes the auth elevation that
// the True edge provides. Dropping it preserves correct semantics.
if matches!(edge_kind, EdgeKind::Seq)
&& edges
.iter()
.any(|&(k, t)| t == target && matches!(k, EdgeKind::True | EdgeKind::False))
{
continue;
}
let info = &cfg[node];
let (out_state, _events) =
transfer.apply(node, info, Some(edge_kind), node_state.clone());
@ -113,14 +134,18 @@ pub fn run_forward<S: Lattice, T: Transfer<S>>(
let changed = target_state.is_none_or(|existing| *existing != new_target);
if changed {
states.insert(target, new_target);
if !worklist.contains(&target) {
if in_worklist.insert(target) {
worklist.push_back(target);
}
}
}
}
// ── Phase 2: single pass over converged states to collect events ──
tracing::debug!(iterations, converged, "state_engine_phase1 complete");
drop(_phase1_span);
// ── Second pass: single pass over converged states to collect events ──
let _phase2_span = tracing::debug_span!("state_engine_phase2").entered();
let mut events: Vec<T::Event> = Vec::new();
let mut seen_edges: std::collections::HashSet<(NodeIndex, NodeIndex)> =
std::collections::HashSet::new();
@ -141,7 +166,15 @@ pub fn run_forward<S: Lattice, T: Transfer<S>>(
continue;
}
for (edge_kind, target) in edges {
for &(edge_kind, target) in &edges {
// Same redundant-Seq-edge skip as the first pass.
if matches!(edge_kind, EdgeKind::Seq)
&& edges
.iter()
.any(|&(k, t)| t == target && matches!(k, EdgeKind::True | EdgeKind::False))
{
continue;
}
if !seen_edges.insert((node, target)) {
continue;
}
@ -162,7 +195,7 @@ pub fn run_forward<S: Lattice, T: Transfer<S>>(
#[cfg(test)]
mod tests {
use super::*;
use crate::cfg::{EdgeKind, NodeInfo, StmtKind};
use crate::cfg::{CallMeta, EdgeKind, NodeInfo, StmtKind, TaintMeta};
use crate::cfg_analysis::rules;
use crate::state::domain::ResourceLifecycle;
use crate::state::symbol::SymbolInterner;
@ -173,16 +206,7 @@ mod tests {
fn make_node(kind: StmtKind) -> NodeInfo {
NodeInfo {
kind,
span: (0, 0),
label: None,
defines: None,
uses: vec![],
callee: None,
enclosing_func: None,
call_ordinal: 0,
condition_text: None,
condition_vars: vec![],
condition_negated: false,
..Default::default()
}
}
@ -195,15 +219,27 @@ mod tests {
let entry = cfg.add_node(make_node(StmtKind::Entry));
let open_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
defines: Some("f".into()),
callee: Some("fopen".into()),
..make_node(StmtKind::Call)
taint: TaintMeta {
defines: Some("f".into()),
..Default::default()
},
call: CallMeta {
callee: Some("fopen".into()),
..Default::default()
},
..Default::default()
});
let close_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
uses: vec!["f".into()],
callee: Some("fclose".into()),
..make_node(StmtKind::Call)
taint: TaintMeta {
uses: vec!["f".into()],
..Default::default()
},
call: CallMeta {
callee: Some("fclose".into()),
..Default::default()
},
..Default::default()
});
let exit = cfg.add_node(make_node(StmtKind::Exit));
@ -216,6 +252,7 @@ mod tests {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
};
let result = run_forward(&cfg, entry, &transfer, ProductState::initial());
@ -247,16 +284,28 @@ mod tests {
let entry = cfg.add_node(make_node(StmtKind::Entry));
let open_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
defines: Some("f".into()),
callee: Some("fopen".into()),
..make_node(StmtKind::Call)
taint: TaintMeta {
defines: Some("f".into()),
..Default::default()
},
call: CallMeta {
callee: Some("fopen".into()),
..Default::default()
},
..Default::default()
});
let if_node = cfg.add_node(make_node(StmtKind::If));
let close_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
uses: vec!["f".into()],
callee: Some("fclose".into()),
..make_node(StmtKind::Call)
taint: TaintMeta {
uses: vec!["f".into()],
..Default::default()
},
call: CallMeta {
callee: Some("fclose".into()),
..Default::default()
},
..Default::default()
});
let no_close = cfg.add_node(make_node(StmtKind::Seq));
let exit = cfg.add_node(make_node(StmtKind::Exit));
@ -273,6 +322,7 @@ mod tests {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
};
let result = run_forward(&cfg, entry, &transfer, ProductState::initial());
@ -285,4 +335,177 @@ mod tests {
ResourceLifecycle::OPEN | ResourceLifecycle::CLOSED
);
}
// ── Budget / on_budget_exceeded tests ──────────────────────────────────
/// Minimal lattice for budget tests.
#[derive(Clone, Debug, PartialEq, Eq)]
struct UnitState;
impl Lattice for UnitState {
fn bot() -> Self {
UnitState
}
fn join(&self, _other: &Self) -> Self {
UnitState
}
fn leq(&self, _other: &Self) -> bool {
true
}
}
/// Transfer that always bails on budget (returns false).
struct BailTransfer;
impl Transfer<UnitState> for BailTransfer {
type Event = ();
fn apply(
&self,
_node: NodeIndex,
_info: &NodeInfo,
_edge: Option<EdgeKind>,
state: UnitState,
) -> (UnitState, Vec<()>) {
(state, vec![])
}
fn iteration_budget(&self) -> usize {
2 // very small budget
}
fn on_budget_exceeded(&self) -> bool {
false // bail
}
}
/// Transfer that continues on budget (returns true).
struct ContinueTransfer;
impl Transfer<UnitState> for ContinueTransfer {
type Event = ();
fn apply(
&self,
_node: NodeIndex,
_info: &NodeInfo,
_edge: Option<EdgeKind>,
state: UnitState,
) -> (UnitState, Vec<()>) {
(state, vec![])
}
fn iteration_budget(&self) -> usize {
2
}
fn on_budget_exceeded(&self) -> bool {
true // keep going
}
}
fn make_chain_cfg() -> (Cfg, NodeIndex) {
// Entry → A → B → C → Exit (4 iterations for the worklist)
let mut cfg: Cfg = Graph::new();
let entry = cfg.add_node(make_node(StmtKind::Entry));
let a = cfg.add_node(make_node(StmtKind::Seq));
let b = cfg.add_node(make_node(StmtKind::Seq));
let c = cfg.add_node(make_node(StmtKind::Seq));
let exit = cfg.add_node(make_node(StmtKind::Exit));
cfg.add_edge(entry, a, EdgeKind::Seq);
cfg.add_edge(a, b, EdgeKind::Seq);
cfg.add_edge(b, c, EdgeKind::Seq);
cfg.add_edge(c, exit, EdgeKind::Seq);
(cfg, entry)
}
#[test]
fn budget_exceeded_bail_stops_immediately_and_marks_non_converged() {
let (cfg, entry) = make_chain_cfg();
let result = run_forward(&cfg, entry, &BailTransfer, UnitState);
// Must NOT be converged when on_budget_exceeded returns false
assert!(!result.converged, "bail transfer must mark converged=false");
}
#[test]
fn budget_exceeded_continue_marks_non_converged() {
let (cfg, entry) = make_chain_cfg();
let result = run_forward(&cfg, entry, &ContinueTransfer, UnitState);
// Even when continuing past budget, converged must be false
assert!(
!result.converged,
"continue-past-budget must still mark converged=false"
);
}
#[test]
fn within_budget_marks_converged() {
// Use a generous budget so the analysis converges normally
struct GenerousTransfer;
impl Transfer<UnitState> for GenerousTransfer {
type Event = ();
fn apply(
&self,
_node: NodeIndex,
_info: &NodeInfo,
_edge: Option<EdgeKind>,
state: UnitState,
) -> (UnitState, Vec<()>) {
(state, vec![])
}
fn iteration_budget(&self) -> usize {
100_000
}
}
let (cfg, entry) = make_chain_cfg();
let result = run_forward(&cfg, entry, &GenerousTransfer, UnitState);
assert!(result.converged, "within-budget analysis should converge");
}
#[test]
fn worklist_membership_dedup_with_nodeindex() {
use petgraph::graph::NodeIndex;
use std::collections::{HashSet, VecDeque};
let mut wl: VecDeque<NodeIndex> = VecDeque::new();
let mut in_wl: HashSet<NodeIndex> = HashSet::new();
let n0 = NodeIndex::new(0);
let n1 = NodeIndex::new(1);
let n2 = NodeIndex::new(2);
// Push n0
assert!(in_wl.insert(n0));
wl.push_back(n0);
// Push n1
assert!(in_wl.insert(n1));
wl.push_back(n1);
// Duplicate n0 — should not insert
assert!(!in_wl.insert(n0));
// wl still has only 2 entries
assert_eq!(wl.len(), 2);
// Pop n0
let popped = wl.pop_front().unwrap();
in_wl.remove(&popped);
assert_eq!(popped, n0);
assert!(!in_wl.contains(&n0));
assert!(in_wl.contains(&n1));
// Re-enqueue n0 (state changed)
assert!(in_wl.insert(n0));
wl.push_back(n0);
// Push n2
assert!(in_wl.insert(n2));
wl.push_back(n2);
assert_eq!(wl.len(), 3);
assert_eq!(in_wl.len(), 3);
}
}

View file

@ -1,3 +1,5 @@
#![allow(clippy::collapsible_if, clippy::unnecessary_map_or)]
use super::domain::{AuthLevel, ProductState, ResourceLifecycle};
use super::engine::DataflowResult;
use super::symbol::SymbolInterner;
@ -13,6 +15,29 @@ fn sanitize_desc(s: &str) -> String {
crate::fmt::normalize_snippet(s)
}
/// Returns true if `idx` is the terminal exit of a function body — the
/// convergence node where all execution paths join before leaving the function.
///
/// **Invariant:** Only terminal exits carry the complete merged lifecycle state
/// needed for leak analysis. Return nodes are intermediate (they flow into the
/// terminal exit) and must NOT be analyzed for terminal resource state.
///
/// Detection is purely topological: a node inside a function is terminal when
/// it has no successor within the same function scope. This works for both
/// per-body graphs (Exit node is a sink) and legacy supergraphs (the
/// synthesized Return's successor is the file-level Exit with
/// `enclosing_func = None`).
fn is_terminal_function_exit(
idx: petgraph::graph::NodeIndex,
info: &crate::cfg::NodeInfo,
cfg: &Cfg,
) -> bool {
info.ast.enclosing_func.is_some()
&& !cfg
.neighbors_directed(idx, petgraph::Direction::Outgoing)
.any(|succ| cfg[succ].ast.enclosing_func == info.ast.enclosing_func)
}
/// A finding produced by state analysis.
#[derive(Debug, Clone)]
pub struct StateFinding {
@ -31,12 +56,20 @@ pub struct StateFinding {
}
/// Extract findings from converged dataflow state + transfer events.
///
/// `path_safe_suppressed_sink_spans` lists CFG sink spans whose tainted
/// inputs were proved path-safe by the SSA taint engine; the privileged
/// `state-unauthed-access` finding is suppressed on those spans because
/// the user-controlled input has already been proved unable to escape
/// into a privileged location.
pub fn extract_findings(
result: &DataflowResult<ProductState, TransferEvent>,
cfg: &Cfg,
interner: &SymbolInterner,
lang: Lang,
func_summaries: &crate::cfg::FuncSummaries,
enable_auth: bool,
path_safe_suppressed_sink_spans: &std::collections::HashSet<(usize, usize)>,
) -> Vec<StateFinding> {
let mut findings = Vec::new();
@ -49,7 +82,7 @@ pub fn extract_findings(
findings.push(StateFinding {
rule_id: "state-use-after-close".into(),
severity: Severity::High,
span: info.span,
span: info.ast.span,
message: format!("variable `{var_name}` used after close"),
machine: "resource",
subject: Some(var_name.to_string()),
@ -61,7 +94,7 @@ pub fn extract_findings(
findings.push(StateFinding {
rule_id: "state-double-close".into(),
severity: Severity::Medium,
span: info.span,
span: info.ast.span,
message: format!("variable `{var_name}` closed twice"),
machine: "resource",
subject: Some(var_name.to_string()),
@ -73,29 +106,50 @@ pub fn extract_findings(
}
// ── 2. Resource leaks at Exit and function-Return nodes ──────────────
// Collect variables with a deferred release call (Go `defer f.Close()`).
// These remain OPEN at function exit because transfer skips deferred
// releases, but the runtime guarantees cleanup.
let deferred_close_vars: std::collections::HashSet<super::symbol::SymbolId> = {
let pairs = crate::cfg_analysis::rules::resource_pairs(lang);
cfg.node_references()
.filter(|(_, ni)| {
ni.in_defer
&& ni.kind == StmtKind::Call
&& ni.call.callee.as_ref().is_some_and(|c| {
let cl = c.to_ascii_lowercase();
pairs.iter().any(|p| {
p.release.iter().any(|r| {
let rl = r.to_ascii_lowercase();
if rl.starts_with('.') {
cl.ends_with(&rl)
} else {
cl.ends_with(&rl) || cl == rl
}
})
})
})
})
.flat_map(|(_, ni)| {
let scope = ni.ast.enclosing_func.clone();
ni.taint
.uses
.iter()
.filter_map(move |v| interner.get_scoped(scope.as_deref(), v))
})
.collect()
};
for (idx, info) in cfg.node_references() {
// Check both the file-level Exit node and the *synthesised* function
// exit node (a Return node). Skip early-return nodes — they flow
// into the synthesised exit and carry only path-specific state.
// The synthesised exit is the one Return node that does NOT have an
// outgoing edge to another Return in the same function.
let is_exit = info.kind == StmtKind::Exit;
let is_func_exit = info.kind == StmtKind::Return && info.enclosing_func.is_some();
if !is_exit && !is_func_exit {
// File-level Exit (program termination, no enclosing function).
let is_file_exit = info.kind == StmtKind::Exit && info.ast.enclosing_func.is_none();
// Terminal function exit — the convergence node where all paths join.
// Return nodes are intermediate and carry only path-specific state;
// only the terminal exit carries the complete merged lifecycle.
let is_func_terminal = is_terminal_function_exit(idx, info, cfg);
if !is_file_exit && !is_func_terminal {
continue;
}
if is_func_exit {
use petgraph::Direction;
let is_early_return = cfg
.neighbors_directed(idx, Direction::Outgoing)
.any(|succ| {
let s = &cfg[succ];
s.kind == StmtKind::Return && s.enclosing_func == info.enclosing_func
});
if is_early_return {
continue;
}
}
let Some(state) = result.states.get(&idx) else {
continue;
};
@ -105,17 +159,112 @@ pub fn extract_findings(
continue;
}
let var_name = interner.resolve(sym);
let scope = if is_func_terminal {
info.ast.enclosing_func.as_deref()
} else {
None
};
let acquire_node = find_acquire_node(cfg, sym, interner, scope);
// At the file-level Exit, skip variables whose acquire site is
// inside a function — those are already handled by the per-
// function exit checks above. Without this, the file-level Exit
// would duplicate leak findings with a misleading acquire span
// (the first global match instead of the correct function-local one).
if is_file_exit {
if let Some(acq) = acquire_node {
if cfg[acq].ast.enclosing_func.is_some() {
continue;
}
}
}
// Suppress leaks for resources acquired inside managed scopes
// (Python `with`, Java try-with-resources). The suppression is
// tied to the specific acquire site, not the variable name.
if let Some(acq) = acquire_node {
if cfg[acq].managed_resource {
continue;
}
}
// Suppress leaks for variables with a deferred close call
// (Go `defer f.Close()`). The deferred call guarantees cleanup
// at function exit even though transfer didn't mark it CLOSED.
if deferred_close_vars.contains(&sym) {
continue;
}
// Prefer direct acquire node span; fall back to proxy span
// from ResourceMethodSummary (cross-body resource tracking).
let acquire_span = acquire_node
.map(|n| cfg[n].ast.span)
.or_else(|| state.proxy_acquire_spans.get(&sym).copied());
// Suppress/downgrade leaks for variables returned from the
// function (factory pattern). Only suppress when ALL
// predecessors that have the variable OPEN also return it.
// Mixed cases (some paths return, some leak) are downgraded
// to state-resource-leak-possible.
if is_func_terminal {
let scope = info.ast.enclosing_func.as_deref();
let mut returned_open = 0u32;
let mut non_returned_open = 0u32;
for pred in cfg.neighbors_directed(idx, petgraph::Direction::Incoming) {
let Some(ps) = result.states.get(&pred) else {
continue;
};
let pred_has_open = ps
.resource
.vars
.get(&sym)
.map_or(false, |lc| lc.contains(ResourceLifecycle::OPEN));
if !pred_has_open {
continue;
}
// Only Return nodes can transfer resource ownership to the
// caller. Non-Return predecessors (exception edges, implicit
// fallthrough) with OPEN resources represent genuine leaks.
let returns_var = cfg[pred].kind == StmtKind::Return
&& cfg[pred]
.taint
.uses
.iter()
.any(|u| interner.get_scoped(scope, u) == Some(sym));
if returns_var {
returned_open += 1;
} else {
non_returned_open += 1;
}
}
if returned_open > 0 && non_returned_open == 0 {
continue; // all OPEN paths transfer ownership to caller
}
if returned_open > 0 && non_returned_open > 0 {
// Mixed: some paths return resource, some leak it.
findings.push(StateFinding {
rule_id: "state-resource-leak-possible".into(),
severity: Severity::Low,
span: acquire_span.unwrap_or(info.ast.span),
message: format!("resource `{var_name}` may not be closed on all paths"),
machine: "resource",
subject: Some(var_name.to_string()),
from_state: "open",
to_state: "possibly_leaked",
});
continue;
}
// returned_open == 0: fall through to normal leak detection
}
if !lifecycle.contains(ResourceLifecycle::CLOSED)
&& !lifecycle.contains(ResourceLifecycle::MOVED)
{
// Definite leak: open on all paths, never closed
// Find the acquire span by scanning backwards for this variable's define
let acquire_span = find_acquire_span(cfg, sym, interner);
findings.push(StateFinding {
rule_id: "state-resource-leak".into(),
severity: Severity::Medium,
span: acquire_span.unwrap_or(info.span),
span: acquire_span.unwrap_or(info.ast.span),
message: format!("resource `{var_name}` is never closed"),
machine: "resource",
subject: Some(var_name.to_string()),
@ -124,11 +273,59 @@ pub fn extract_findings(
});
} else if lifecycle.contains(ResourceLifecycle::CLOSED) {
// May-leak: open on some paths, closed on others
let acquire_span = find_acquire_span(cfg, sym, interner);
findings.push(StateFinding {
rule_id: "state-resource-leak-possible".into(),
severity: Severity::Low,
span: acquire_span.unwrap_or(info.span),
span: acquire_span.unwrap_or(info.ast.span),
message: format!("resource `{var_name}` may not be closed on all paths"),
machine: "resource",
subject: Some(var_name.to_string()),
from_state: "open",
to_state: "possibly_leaked",
});
}
}
}
// ── 2b. Proxy-acquired possible leaks (exception-path heuristic) ────
// In JS/TS, any call can throw. If a proxy-acquired resource is fully
// CLOSED at function exit (no OPEN paths), check whether there are
// intervening calls between the proxy acquire and release nodes that
// could throw and bypass the release. If so, emit a possible leak.
for (idx, info) in cfg.node_references() {
if !is_terminal_function_exit(idx, info, cfg) {
continue;
}
let Some(state) = result.states.get(&idx) else {
continue;
};
for (&sym, &lifecycle) in &state.resource.vars {
// Only for proxy-acquired resources that are fully CLOSED at exit
if !state.proxy_acquire_spans.contains_key(&sym) {
continue;
}
if lifecycle.contains(ResourceLifecycle::OPEN) {
continue; // Already handled by the normal leak detection above
}
if !lifecycle.contains(ResourceLifecycle::CLOSED) {
continue;
}
// Check if there are intervening Call nodes between acquire and release
// in the CFG (these could throw and bypass the release)
let has_intervening_calls = cfg.node_references().any(|(_, ni)| {
ni.kind == StmtKind::Call
&& ni.ast.enclosing_func == info.ast.enclosing_func
&& ni.call.callee.is_some()
// Not the acquire or release proxy itself
&& !state.proxy_acquire_spans.values().any(|s| *s == ni.ast.span)
});
if has_intervening_calls {
let var_name = interner.resolve(sym);
let acquire_span = state.proxy_acquire_spans.get(&sym).copied();
findings.push(StateFinding {
rule_id: "state-resource-leak-possible".into(),
severity: Severity::Low,
span: acquire_span.unwrap_or(info.ast.span),
message: format!("resource `{var_name}` may not be closed on all paths"),
machine: "resource",
subject: Some(var_name.to_string()),
@ -140,14 +337,16 @@ pub fn extract_findings(
}
// ── 3. Auth-required sinks ───────────────────────────────────────────
// Only run auth analysis when explicitly enabled (higher FP rate).
// Check if any function is a web entrypoint
let has_web_entrypoint = cfg.node_references().any(|(_, info)| {
if let Some(ref func_name) = info.enclosing_func {
is_web_entrypoint_simple(func_name, lang, func_summaries, cfg)
} else {
false
}
});
let has_web_entrypoint = enable_auth
&& cfg.node_references().any(|(_, info)| {
if let Some(ref func_name) = info.ast.enclosing_func {
is_web_entrypoint_simple(func_name, lang, func_summaries, cfg)
} else {
false
}
});
if has_web_entrypoint {
for (idx, info) in cfg.node_references() {
@ -158,11 +357,24 @@ pub fn extract_findings(
continue;
};
if state.auth.auth_level == AuthLevel::Unauthed {
let callee_desc = sanitize_desc(info.callee.as_deref().unwrap_or("(sensitive op)"));
// Suppress when the SSA taint engine has already proved
// the tainted input flowing into this sink is path-safe
// (PathFact `dotdot=No && absolute=No`). A web handler
// reading a sanitised user-controlled path is not the
// same shape as a handler reading any user-controlled
// path — the auth concern reduces once the data cannot
// escape into a privileged location. Note this is per
// CFG-node span, so co-located unrelated sinks are
// unaffected.
if path_safe_suppressed_sink_spans.contains(&info.ast.span) {
continue;
}
let callee_desc =
sanitize_desc(info.call.callee.as_deref().unwrap_or("(sensitive op)"));
findings.push(StateFinding {
rule_id: "state-unauthed-access".into(),
severity: Severity::High,
span: info.span,
span: info.ast.span,
message: format!(
"sensitive operation `{callee_desc}` reached without authentication"
),
@ -182,19 +394,30 @@ pub fn extract_findings(
findings
}
/// Find the span where a variable was acquired (defined via Call node).
fn find_acquire_span(
/// Find the CFG node where a variable was acquired (defined via Call node).
fn find_acquire_node(
cfg: &Cfg,
sym: super::symbol::SymbolId,
interner: &SymbolInterner,
) -> Option<(usize, usize)> {
enclosing_func: Option<&str>,
) -> Option<petgraph::graph::NodeIndex> {
let var_name = interner.resolve(sym);
for (_idx, info) in cfg.node_references() {
if info.kind == StmtKind::Call
&& let Some(ref def) = info.defines
&& def == var_name
{
return Some(info.span);
// Try function-scoped match first (correct for multi-function files
// where the same variable name appears in multiple functions).
if let Some(func) = enclosing_func {
for (idx, info) in cfg.node_references() {
if info.kind == StmtKind::Call
&& info.ast.enclosing_func.as_deref() == Some(func)
&& info.taint.defines.as_deref() == Some(var_name)
{
return Some(idx);
}
}
}
// Fallback: first global match (for file-level Exit or top-level code).
for (idx, info) in cfg.node_references() {
if info.kind == StmtKind::Call && info.taint.defines.as_deref() == Some(var_name) {
return Some(idx);
}
}
None
@ -202,10 +425,13 @@ fn find_acquire_span(
/// Check if a node is a privileged sink (shell execution or file I/O).
fn is_privileged_sink(info: &crate::cfg::NodeInfo) -> bool {
match info.label {
Some(DataLabel::Sink(caps)) => caps.intersects(Cap::SHELL_ESCAPE | Cap::FILE_IO),
_ => false,
}
info.taint.labels.iter().any(|l| {
if let DataLabel::Sink(caps) = l {
caps.intersects(Cap::SHELL_ESCAPE | Cap::FILE_IO)
} else {
false
}
})
}
/// Simplified web entrypoint check (avoids AnalysisContext dependency).
@ -249,10 +475,9 @@ fn is_web_entrypoint_simple(
.any(|p| web_params.contains(&p.to_ascii_lowercase().as_str()))
});
// Strong handler names are enough even without web params
let strong_name = name_lower.starts_with("handle_")
|| name_lower.starts_with("route_")
|| name_lower.starts_with("api_");
// Only handle_* and route_* are strong enough to skip param confirmation.
// api_*, serve_*, process_* require web parameter evidence.
let strong_name = name_lower.starts_with("handle_") || name_lower.starts_with("route_");
has_web_params || strong_name
}
@ -260,7 +485,7 @@ fn is_web_entrypoint_simple(
#[cfg(test)]
mod tests {
use super::*;
use crate::cfg::{EdgeKind, NodeInfo};
use crate::cfg::{AstMeta, CallMeta, EdgeKind, NodeInfo, TaintMeta};
use crate::cfg_analysis::rules;
use crate::state::domain::ProductState;
use crate::state::engine;
@ -272,16 +497,7 @@ mod tests {
fn make_node(kind: StmtKind) -> NodeInfo {
NodeInfo {
kind,
span: (0, 0),
label: None,
defines: None,
uses: vec![],
callee: None,
enclosing_func: None,
call_ordinal: 0,
condition_text: None,
condition_vars: vec![],
condition_negated: false,
..Default::default()
}
}
@ -292,10 +508,19 @@ mod tests {
let entry = cfg.add_node(make_node(StmtKind::Entry));
let open_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
span: (10, 20),
defines: Some("f".into()),
callee: Some("fopen".into()),
..make_node(StmtKind::Call)
ast: AstMeta {
span: (10, 20),
..Default::default()
},
taint: TaintMeta {
defines: Some("f".into()),
..Default::default()
},
call: CallMeta {
callee: Some("fopen".into()),
..Default::default()
},
..Default::default()
});
let exit = cfg.add_node(make_node(StmtKind::Exit));
@ -307,10 +532,19 @@ mod tests {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
};
let result = engine::run_forward(&cfg, entry, &transfer, ProductState::initial());
let findings = extract_findings(&result, &cfg, &interner, Lang::C, &HashMap::new());
let findings = extract_findings(
&result,
&cfg,
&interner,
Lang::C,
&HashMap::new(),
false,
&std::collections::HashSet::new(),
);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_id, "state-resource-leak");
@ -324,15 +558,27 @@ mod tests {
let entry = cfg.add_node(make_node(StmtKind::Entry));
let open_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
defines: Some("f".into()),
callee: Some("fopen".into()),
..make_node(StmtKind::Call)
taint: TaintMeta {
defines: Some("f".into()),
..Default::default()
},
call: CallMeta {
callee: Some("fopen".into()),
..Default::default()
},
..Default::default()
});
let close_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
uses: vec!["f".into()],
callee: Some("fclose".into()),
..make_node(StmtKind::Call)
taint: TaintMeta {
uses: vec!["f".into()],
..Default::default()
},
call: CallMeta {
callee: Some("fclose".into()),
..Default::default()
},
..Default::default()
});
let exit = cfg.add_node(make_node(StmtKind::Exit));
@ -345,11 +591,223 @@ mod tests {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
};
let result = engine::run_forward(&cfg, entry, &transfer, ProductState::initial());
let findings = extract_findings(&result, &cfg, &interner, Lang::C, &HashMap::new());
let findings = extract_findings(
&result,
&cfg,
&interner,
Lang::C,
&HashMap::new(),
false,
&std::collections::HashSet::new(),
);
assert!(findings.is_empty());
}
fn make_func_node(kind: StmtKind, func: &str) -> NodeInfo {
NodeInfo {
kind,
ast: AstMeta {
enclosing_func: Some(func.to_string()),
..Default::default()
},
..Default::default()
}
}
#[test]
fn terminal_exit_is_topological() {
// Per-body graph: Entry → Call → Return → Exit (all enclosing_func=Some)
// Only Exit should be terminal (no successors in same scope).
let mut cfg: Cfg = Graph::new();
let entry = cfg.add_node(make_func_node(StmtKind::Entry, "f"));
let call = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
callee: Some("fopen".into()),
..Default::default()
},
taint: TaintMeta {
defines: Some("x".into()),
..Default::default()
},
ast: AstMeta {
enclosing_func: Some("f".into()),
..Default::default()
},
..Default::default()
});
let ret = cfg.add_node(NodeInfo {
kind: StmtKind::Return,
taint: TaintMeta {
uses: vec!["x".into()],
..Default::default()
},
ast: AstMeta {
enclosing_func: Some("f".into()),
..Default::default()
},
..Default::default()
});
let exit = cfg.add_node(make_func_node(StmtKind::Exit, "f"));
cfg.add_edge(entry, call, EdgeKind::Seq);
cfg.add_edge(call, ret, EdgeKind::Seq);
cfg.add_edge(ret, exit, EdgeKind::Seq);
assert!(
!is_terminal_function_exit(entry, &cfg[entry], &cfg),
"Entry must not be terminal"
);
assert!(
!is_terminal_function_exit(call, &cfg[call], &cfg),
"Call must not be terminal"
);
assert!(
!is_terminal_function_exit(ret, &cfg[ret], &cfg),
"Return must not be terminal — it flows into Exit"
);
assert!(
is_terminal_function_exit(exit, &cfg[exit], &cfg),
"Exit must be terminal — no successors in same scope"
);
}
#[test]
fn per_body_factory_returned_resource_no_finding() {
// Per-body graph: Entry → fopen(f) → return f → Exit
// All nodes have enclosing_func=Some("factory").
// The resource is returned — no leak finding expected.
let func = "factory";
let mut cfg: Cfg = Graph::new();
let entry = cfg.add_node(make_func_node(StmtKind::Entry, func));
let open_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (10, 20),
enclosing_func: Some(func.into()),
},
taint: TaintMeta {
defines: Some("f".into()),
..Default::default()
},
call: CallMeta {
callee: Some("fopen".into()),
..Default::default()
},
..Default::default()
});
let ret = cfg.add_node(NodeInfo {
kind: StmtKind::Return,
taint: TaintMeta {
uses: vec!["f".into()],
..Default::default()
},
ast: AstMeta {
enclosing_func: Some(func.into()),
..Default::default()
},
..Default::default()
});
let exit = cfg.add_node(make_func_node(StmtKind::Exit, func));
cfg.add_edge(entry, open_node, EdgeKind::Seq);
cfg.add_edge(open_node, ret, EdgeKind::Seq);
cfg.add_edge(ret, exit, EdgeKind::Seq);
let interner = SymbolInterner::from_cfg_scoped(&cfg);
let transfer = DefaultTransfer {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
};
let result = engine::run_forward(&cfg, entry, &transfer, ProductState::initial());
let findings = extract_findings(
&result,
&cfg,
&interner,
Lang::C,
&HashMap::new(),
false,
&std::collections::HashSet::new(),
);
assert!(
findings.is_empty(),
"Resource returned from factory must not produce leak finding.\n Got: {:?}",
findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
);
}
#[test]
fn per_body_non_returned_resource_leaks() {
// Per-body graph: Entry → fopen(f) → return (no uses) → Exit
// All nodes have enclosing_func=Some("leaker").
// Resource is NOT returned — exactly one state-resource-leak expected.
let func = "leaker";
let mut cfg: Cfg = Graph::new();
let entry = cfg.add_node(make_func_node(StmtKind::Entry, func));
let open_node = cfg.add_node(NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (10, 20),
enclosing_func: Some(func.into()),
},
taint: TaintMeta {
defines: Some("f".into()),
..Default::default()
},
call: CallMeta {
callee: Some("fopen".into()),
..Default::default()
},
..Default::default()
});
let ret = cfg.add_node(NodeInfo {
kind: StmtKind::Return,
ast: AstMeta {
enclosing_func: Some(func.into()),
..Default::default()
},
..Default::default()
});
let exit = cfg.add_node(make_func_node(StmtKind::Exit, func));
cfg.add_edge(entry, open_node, EdgeKind::Seq);
cfg.add_edge(open_node, ret, EdgeKind::Seq);
cfg.add_edge(ret, exit, EdgeKind::Seq);
let interner = SymbolInterner::from_cfg_scoped(&cfg);
let transfer = DefaultTransfer {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
};
let result = engine::run_forward(&cfg, entry, &transfer, ProductState::initial());
let findings = extract_findings(
&result,
&cfg,
&interner,
Lang::C,
&HashMap::new(),
false,
&std::collections::HashSet::new(),
);
assert_eq!(
findings.len(),
1,
"Non-returned resource must produce exactly one finding.\n Got: {:?}",
findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
);
assert_eq!(findings[0].rule_id, "state-resource-leak");
}
}

View file

@ -16,6 +16,30 @@ pub trait Lattice: Clone + Eq + Sized {
fn leq(&self, other: &Self) -> bool;
}
/// Full abstract domain with widening and top element.
///
/// Extends [`Lattice`] with operations required for abstract interpretation:
/// - `top()`: maximally imprecise element (no information)
/// - `meet()`: greatest lower bound (refine with new info)
/// - `widen()`: extrapolation operator ensuring termination
///
/// Implementations must satisfy:
/// - `top()` is the greatest element: `leq(x, top()) == true` for all x
/// - `meet(a, b) ⊑ a` and `meet(a, b) ⊑ b`
/// - `widen(a, b) ⊒ join(a, b)` (widening is at least as imprecise as join)
/// - Ascending chains under `widen` stabilize in finite steps
#[allow(dead_code)]
pub trait AbstractDomain: Lattice {
/// Top element (no information / maximally imprecise).
fn top() -> Self;
/// Greatest lower bound: refine with new information.
fn meet(&self, other: &Self) -> Self;
/// Widening: extrapolate to ensure termination of ascending chains.
fn widen(&self, other: &Self) -> Self;
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -9,17 +9,55 @@ use crate::cfg::{Cfg, FuncSummaries};
use crate::cfg_analysis::rules;
use crate::summary::GlobalSummaries;
use crate::symbol::Lang;
use domain::ProductState;
use domain::{AuthLevel, ProductState};
use engine::MAX_TRACKED_VARS;
use facts::StateFinding;
use petgraph::graph::NodeIndex;
use symbol::SymbolInterner;
use transfer::DefaultTransfer;
/// Classify decorator/annotation/attribute names against the language's auth
/// rules and return the resulting `AuthLevel`. Any admin-like match produces
/// `Admin`; any generic auth match produces `Authed`; otherwise `Unauthed`.
pub fn classify_auth_decorators(lang: Lang, decorators: &[String]) -> AuthLevel {
if decorators.is_empty() {
return AuthLevel::Unauthed;
}
let auth_rules = rules::auth_rules(lang);
let mut level = AuthLevel::Unauthed;
for dec in decorators {
let d = dec.to_ascii_lowercase();
// Admin patterns — match the same static list used by the call-site
// transfer so decorators and runtime checks agree on privilege.
if d.contains("admin") || d.contains("hasrole") || d.contains("superuser") {
return AuthLevel::Admin;
}
let matches = auth_rules.iter().any(|rule| {
rule.matchers.iter().any(|m| {
let ml = m.to_ascii_lowercase();
d == ml || d.ends_with(&ml)
})
});
if matches && level < AuthLevel::Authed {
level = AuthLevel::Authed;
}
}
level
}
/// Run state-model dataflow analysis on a single function's CFG.
///
/// Returns findings for use-after-close, double-close, resource leaks,
/// and unauthenticated access to sensitive sinks.
///
/// `path_safe_suppressed_sink_spans` lists CFG sink spans whose tainted
/// inputs were proved path-safe by the SSA taint engine. When a
/// privileged sink at one of those spans is reached without
/// authentication, `state-unauthed-access` is suppressed: the taint
/// engine has already proved the user-controlled input cannot escape
/// into a privileged location, so the auth concern is structurally
/// reduced.
#[allow(clippy::too_many_arguments)]
pub fn run_state_analysis(
cfg: &Cfg,
entry: NodeIndex,
@ -27,36 +65,113 @@ pub fn run_state_analysis(
_source_bytes: &[u8],
func_summaries: &FuncSummaries,
_global_summaries: Option<&GlobalSummaries>,
enable_auth: bool,
resource_method_summaries: &[transfer::ResourceMethodSummary],
auth_decorators: &[String],
path_safe_suppressed_sink_spans: &std::collections::HashSet<(usize, usize)>,
) -> Vec<StateFinding> {
let _span = tracing::debug_span!("run_state_analysis").entered();
// 1. Build symbol interner from CFG
let interner = SymbolInterner::from_cfg(cfg);
let interner = SymbolInterner::from_cfg_scoped(cfg);
// Guarded degradation: cap tracked variables
if interner.len() > MAX_TRACKED_VARS {
tracing::warn!(
symbols = interner.len(),
max = MAX_TRACKED_VARS,
"state analysis: too many variables, capping tracking"
);
// Still run — the interner has all symbols, but transfer will only
// track the first MAX_TRACKED_VARS due to HashMap insertion order.
// This is conservative but safe.
}
// 2. Construct transfer function
let resource_pairs = rules::resource_pairs(lang);
let transfer = DefaultTransfer {
lang,
resource_pairs,
interner: &interner,
resource_method_summaries,
};
// 3. Run forward dataflow engine
let initial = ProductState::initial();
// Seed initial auth level from decorator-based authorization markers.
// Functions tagged with an auth decorator/annotation/attribute start in
// `Authed` (or `Admin`) instead of `Unauthed`, so the privileged-sink
// check in `extract_findings` suppresses findings framework-level auth
// already enforces.
let mut initial = ProductState::initial();
initial.auth.auth_level = classify_auth_decorators(lang, auth_decorators);
let result = engine::run_forward(cfg, entry, &transfer, initial);
// 4. Extract findings
facts::extract_findings(&result, cfg, &interner, lang, func_summaries)
facts::extract_findings(
&result,
cfg,
&interner,
lang,
func_summaries,
enable_auth,
path_safe_suppressed_sink_spans,
)
}
/// Build resource method summaries by pre-scanning all method bodies for known
/// resource acquire/release operations. Only creates summaries for methods whose
/// bodies actually contain matching operations — never infers from names alone.
pub fn build_resource_method_summaries(
bodies: &[crate::cfg::BodyCfg],
lang: Lang,
) -> Vec<transfer::ResourceMethodSummary> {
use petgraph::visit::IntoNodeReferences;
let resource_pairs = rules::resource_pairs(lang);
let mut summaries = Vec::new();
for body in bodies {
let method_name = match &body.meta.name {
Some(name) => name.clone(),
None => continue,
};
let class_group = match body.meta.parent_body_id {
Some(pid) => pid,
None => continue, // top-level functions are not class methods
};
for (_, info) in body.graph.node_references() {
// Check both Call and Seq (Assignment) nodes — resource operations
// can appear as RHS of assignments (e.g., `this.fd = fs.openSync(...)`).
if !matches!(
info.kind,
crate::cfg::StmtKind::Call | crate::cfg::StmtKind::Seq
) {
continue;
}
let callee = match &info.call.callee {
Some(c) => c.to_ascii_lowercase(),
None => continue,
};
for pair in resource_pairs {
if pair
.acquire
.iter()
.any(|a| transfer::callee_matches_pub(&callee, a))
{
summaries.push(transfer::ResourceMethodSummary {
method_name: method_name.clone(),
effect: transfer::ResourceEffect::Acquire,
class_group,
original_span: info.ast.span,
});
}
if pair
.release
.iter()
.any(|r| transfer::callee_matches_pub(&callee, r))
{
summaries.push(transfer::ResourceMethodSummary {
method_name: method_name.clone(),
effect: transfer::ResourceEffect::Release,
class_group,
original_span: info.ast.span,
});
}
}
}
}
summaries
}

View file

@ -6,12 +6,27 @@ use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SymbolId(pub(crate) u32);
/// Per-function interner: maps `String` ↔ [`SymbolId`].
/// Function-scope discriminator for symbol interning.
///
/// This provides **function-level isolation only** — not full lexical/block
/// scope modeling. Variables in different functions with the same name get
/// distinct [`SymbolId`]s. Top-level / module-scope code uses `scope: None`.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct ScopedKey {
scope: Option<String>,
name: String,
}
/// Per-analysis interner: maps variable names ↔ [`SymbolId`].
///
/// Built once from CFG node `defines`/`uses`, reused throughout analysis.
/// Two construction modes:
/// - [`from_cfg`](Self::from_cfg): flat (unscoped) interning — used by taint/SSA pipeline
/// - [`from_cfg_scoped`](Self::from_cfg_scoped): function-scoped interning — used by state analysis
#[derive(Default)]
pub struct SymbolInterner {
to_id: HashMap<String, SymbolId>,
to_id: HashMap<ScopedKey, SymbolId>,
/// Clean variable names for user-facing resolution (not scoped keys).
to_str: Vec<String>,
}
@ -20,23 +35,56 @@ impl SymbolInterner {
Self::default()
}
/// Intern a name, returning its stable [`SymbolId`].
pub fn intern(&mut self, name: &str) -> SymbolId {
if let Some(&id) = self.to_id.get(name) {
/// Intern a name with function-scope context, returning its stable [`SymbolId`].
///
/// The `scope` parameter is typically `NodeInfo::enclosing_func`. `None`
/// means top-level / module scope. The stored name (returned by
/// [`resolve`](Self::resolve)) is always the clean variable name, not the
/// scoped key.
pub fn intern_scoped(&mut self, scope: Option<&str>, name: &str) -> SymbolId {
// Member expressions (e.g. `this.fd`, `self.conn`) are shared class/
// instance state — keep them in the global (None) scope so that
// `open()` and `close()` methods can track the same resource symbol.
// Only plain local variables get function-scoped isolation.
let effective_scope = if name.contains('.') { None } else { scope };
let key = ScopedKey {
scope: effective_scope.map(|s| s.to_owned()),
name: name.to_owned(),
};
if let Some(&id) = self.to_id.get(&key) {
return id;
}
let id = SymbolId(self.to_str.len() as u32);
self.to_str.push(name.to_owned());
self.to_id.insert(name.to_owned(), id);
self.to_id.insert(key, id);
id
}
/// Look up a name without interning it.
pub fn get(&self, name: &str) -> Option<SymbolId> {
self.to_id.get(name).copied()
/// Look up a name by function scope without interning it.
pub fn get_scoped(&self, scope: Option<&str>, name: &str) -> Option<SymbolId> {
let effective_scope = if name.contains('.') { None } else { scope };
let key = ScopedKey {
scope: effective_scope.map(|s| s.to_owned()),
name: name.to_owned(),
};
self.to_id.get(&key).copied()
}
/// Resolve an id back to its string.
/// Intern a name (unscoped — equivalent to `intern_scoped(None, name)`).
///
/// Used by the taint/SSA pipeline and unit tests that don't need
/// function-scope isolation.
pub fn intern(&mut self, name: &str) -> SymbolId {
self.intern_scoped(None, name)
}
/// Look up a name without interning it (unscoped — equivalent to
/// `get_scoped(None, name)`).
pub fn get(&self, name: &str) -> Option<SymbolId> {
self.get_scoped(None, name)
}
/// Resolve an id back to its clean variable name.
pub fn resolve(&self, id: SymbolId) -> &str {
&self.to_str[id.0 as usize]
}
@ -52,19 +100,43 @@ impl SymbolInterner {
self.to_str.is_empty()
}
/// Build from a CFG: walk all nodes, intern every `defines`/`uses` string.
/// Build from a CFG with flat (unscoped) interning.
///
/// Every `defines`/`uses` variable is interned without function-scope
/// context. Used by the taint/SSA pipeline where SSA value numbering
/// already provides per-function scoping.
pub fn from_cfg(cfg: &Cfg) -> Self {
let mut interner = Self::new();
for (_idx, info) in cfg.node_references() {
if let Some(ref d) = info.defines {
if let Some(ref d) = info.taint.defines {
interner.intern(d);
}
for u in &info.uses {
for u in &info.taint.uses {
interner.intern(u);
}
}
interner
}
/// Build from a CFG with function-scoped interning.
///
/// Variables are keyed by `(enclosing_func, name)` so that same-name
/// variables in different functions get distinct [`SymbolId`]s. This is
/// the constructor used by the state analysis pipeline (resource lifecycle,
/// auth).
pub fn from_cfg_scoped(cfg: &Cfg) -> Self {
let mut interner = Self::new();
for (_idx, info) in cfg.node_references() {
let scope = info.ast.enclosing_func.as_deref();
if let Some(ref d) = info.taint.defines {
interner.intern_scoped(scope, d);
}
for u in &info.taint.uses {
interner.intern_scoped(scope, u);
}
}
interner
}
}
#[cfg(test)]
@ -98,4 +170,89 @@ mod tests {
interner.intern("a"); // duplicate
assert_eq!(interner.len(), 2);
}
#[test]
fn scoped_different_funcs_get_different_ids() {
let mut interner = SymbolInterner::new();
let a = interner.intern_scoped(Some("funcA"), "f");
let b = interner.intern_scoped(Some("funcB"), "f");
assert_ne!(
a, b,
"same variable name in different functions must get different IDs"
);
}
#[test]
fn scoped_same_func_same_id() {
let mut interner = SymbolInterner::new();
let a = interner.intern_scoped(Some("funcA"), "f");
let a2 = interner.intern_scoped(Some("funcA"), "f");
assert_eq!(a, a2);
}
#[test]
fn scoped_resolve_returns_clean_name() {
let mut interner = SymbolInterner::new();
let id = interner.intern_scoped(Some("my_function"), "resource");
assert_eq!(
interner.resolve(id),
"resource",
"resolve must return clean name, not scoped key"
);
}
#[test]
fn unscoped_get_does_not_find_scoped() {
let mut interner = SymbolInterner::new();
interner.intern_scoped(Some("funcA"), "f");
assert!(
interner.get("f").is_none(),
"unscoped get must not find a function-scoped entry"
);
}
#[test]
fn scoped_get_does_not_find_unscoped() {
let mut interner = SymbolInterner::new();
interner.intern("f");
assert!(
interner.get_scoped(Some("funcA"), "f").is_none(),
"scoped get must not find an unscoped entry"
);
}
#[test]
fn toplevel_scope_is_none() {
let mut interner = SymbolInterner::new();
let a = interner.intern_scoped(None, "x");
let b = interner.intern("x");
assert_eq!(
a, b,
"intern() and intern_scoped(None, ..) must produce the same ID"
);
}
#[test]
fn member_expressions_shared_across_methods() {
let mut interner = SymbolInterner::new();
// this.fd in open() and this.fd in close() must share the same ID
// because member expressions are instance/class state, not locals.
let a = interner.intern_scoped(Some("open"), "this.fd");
let b = interner.intern_scoped(Some("close"), "this.fd");
assert_eq!(
a, b,
"member expressions (containing '.') must be shared across function scopes"
);
}
#[test]
fn plain_locals_isolated_across_methods() {
let mut interner = SymbolInterner::new();
let a = interner.intern_scoped(Some("open"), "fd");
let b = interner.intern_scoped(Some("close"), "fd");
assert_ne!(
a, b,
"plain local variables must be isolated across function scopes"
);
}
}

File diff suppressed because it is too large Load diff