mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
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:
parent
c4ce08b452
commit
41128177d2
2144 changed files with 201812 additions and 8927 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
139
src/state/mod.rs
139
src/state/mod.rs
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue