nyx/src/auth_analysis/config.rs

1618 lines
60 KiB
Rust
Raw Normal View History

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>
2026-04-25 17:59:11 -04:00
use crate::auth_analysis::model::SinkClass;
use crate::utils::config::Config;
#[derive(Debug, Clone)]
pub struct AuthAnalysisRules {
pub enabled: bool,
pub finding_prefix: String,
pub admin_path_patterns: Vec<String>,
pub admin_guard_names: Vec<String>,
pub login_guard_names: Vec<String>,
pub authorization_check_names: Vec<String>,
pub mutation_indicator_names: Vec<String>,
pub read_indicator_names: Vec<String>,
pub token_lookup_names: Vec<String>,
pub token_expiry_fields: Vec<String>,
pub token_recipient_fields: Vec<String>,
pub non_sink_receiver_types: Vec<String>,
pub non_sink_receiver_name_prefixes: Vec<String>,
/// Built-in / framework receivers whose first-segment, when matched
/// exactly (case-sensitive), classifies the call as inherently
/// non-data-layer. Used for browser/DOM globals (`document`,
/// `window`, `localStorage`, `console`, ...) and stdlib helpers
/// (`Math`, `JSON`, `Date`) where method names like `getById` /
/// `addEventListener` would otherwise prefix-match the configured
/// `read_indicator_names` / `mutation_indicator_names`.
pub non_sink_global_receivers: Vec<String>,
/// Method-name allowlist: when the LAST segment of a callee matches
/// (case-sensitive exact), the call is classified as non-sink
/// regardless of receiver. Used for DOM-API methods
/// (`addEventListener`, `getElementById`, `appendChild`, ...) that
/// are categorically client-side and never authorization-relevant.
pub non_sink_method_names: Vec<String>,
/// Receiver-chain first-segment prefixes that classify a call as a
/// realtime publish (pub/sub, websocket, event stream).
pub realtime_receiver_prefixes: Vec<String>,
/// Receiver-chain prefixes that classify a call as an outbound
/// network sink (HTTP client, RPC caller).
pub outbound_network_receiver_prefixes: Vec<String>,
/// Receiver-chain prefixes that classify a call as a cross-tenant
/// cache access.
pub cache_receiver_prefixes: Vec<String>,
/// ACL tables that, when JOIN-ed in a SELECT and pinned via
/// `WHERE <ACL>.user_id = ?N`, make every returned row
/// membership-gated. See `sql_semantics::classify_sql_query`.
pub acl_tables: Vec<String>,
}
impl AuthAnalysisRules {
pub fn disabled() -> Self {
Self {
enabled: false,
finding_prefix: "auth".into(),
admin_path_patterns: Vec::new(),
admin_guard_names: Vec::new(),
login_guard_names: Vec::new(),
authorization_check_names: Vec::new(),
mutation_indicator_names: Vec::new(),
read_indicator_names: Vec::new(),
token_lookup_names: Vec::new(),
token_expiry_fields: Vec::new(),
token_recipient_fields: Vec::new(),
non_sink_receiver_types: Vec::new(),
non_sink_receiver_name_prefixes: Vec::new(),
non_sink_global_receivers: Vec::new(),
non_sink_method_names: Vec::new(),
realtime_receiver_prefixes: Vec::new(),
outbound_network_receiver_prefixes: Vec::new(),
cache_receiver_prefixes: Vec::new(),
acl_tables: Vec::new(),
}
}
/// Last path segment of a type name (e.g. `std::collections::HashMap` → `HashMap`).
/// Accepts either `::` or `.` as the path separator.
fn type_last_segment(ty: &str) -> &str {
let trimmed = ty
.trim()
.trim_start_matches('&')
.trim_start_matches("mut ")
.trim();
let after_colons = trimmed.rsplit("::").next().unwrap_or(trimmed);
after_colons.rsplit('.').next().unwrap_or(after_colons)
}
/// Does `ty` (last path segment, case-sensitive) match a
/// non-sink receiver type? The angle-bracket generic suffix is
/// stripped first: `HashMap<i64, String>` → `HashMap`.
pub fn is_non_sink_receiver_type(&self, ty: &str) -> bool {
let base = Self::type_last_segment(ty);
let base = base.split('<').next().unwrap_or(base).trim();
self.non_sink_receiver_types
.iter()
.any(|allowed| allowed == base)
}
/// Does the callee of a constructor expression (e.g. `HashMap::new`,
/// `SmallVec::from`, `Vec::with_capacity`) produce a non-sink
/// receiver? Matches when the type prefix is in
/// `non_sink_receiver_types` AND the method is a known
/// constructor verb.
///
/// The callee string may use either `::` or `.` as the path
/// separator (nyx's `callee_name` normalizes both via
/// `member_chain`).
pub fn is_non_sink_constructor_callee(&self, callee: &str) -> bool {
let normalized = callee.replace("::", ".");
let Some((ty, method)) = normalized.rsplit_once('.') else {
return false;
};
if !self.is_non_sink_receiver_type(ty) {
return false;
}
matches!(
method,
"new"
| "with_capacity"
| "with_capacity_and_hasher"
| "with_hasher"
| "from"
| "from_iter"
| "new_in"
| "default"
)
}
/// Does the first segment of a callee receiver chain look like a
/// non-sink local variable, based on configured name prefixes?
/// Used as a fallback when the type/binding cannot be resolved.
pub fn receiver_matches_non_sink_prefix(&self, first_segment: &str) -> bool {
if first_segment.is_empty() {
return false;
}
self.non_sink_receiver_name_prefixes
.iter()
.any(|prefix| !prefix.is_empty() && first_segment.starts_with(prefix.as_str()))
}
/// Should a call on `callee` be skipped for Read/Mutation
/// classification because its receiver is a local non-sink
/// collection? The `non_sink_vars` set lists variable names
/// flagged during the unit walk (e.g. `let mut counts = HashMap::new()`).
pub fn callee_has_non_sink_receiver(
&self,
callee: &str,
non_sink_vars: &std::collections::HashSet<String>,
) -> bool {
let first = first_receiver_segment(callee);
if first.is_empty() {
return false;
}
if non_sink_vars.contains(first) {
return true;
}
self.receiver_matches_non_sink_prefix(first)
}
/// Does the first receiver-chain segment match a configured
/// non-sink global (case-sensitive exact)? Used to recognise
/// browser/DOM globals (`document.getElementById` →
/// first-segment `document`) and stdlib helpers
/// (`Math.random`, `JSON.stringify`).
pub fn callee_has_non_sink_global_receiver(&self, callee: &str) -> bool {
let first = first_receiver_segment(callee);
if first.is_empty() {
return false;
}
self.non_sink_global_receivers
.iter()
.any(|name| name == first)
}
/// Does the LAST segment of the callee match a configured non-sink
/// method name (case-sensitive exact)? Used to recognise DOM-API
/// methods like `addEventListener` / `appendChild` regardless of
/// receiver — `someElement.addEventListener` is just as
/// categorically client-side as `document.addEventListener`.
pub fn callee_has_non_sink_method(&self, callee: &str) -> bool {
let last = callee.rsplit('.').next().unwrap_or(callee);
let last = last.rsplit("::").next().unwrap_or(last);
if last.is_empty() {
return false;
}
self.non_sink_method_names.iter().any(|name| name == last)
}
/// Does the first segment of the callee's receiver chain match any
/// configured prefix in `prefixes`? Comparison is case-insensitive
/// on the first segment and uses starts-with on each prefix.
fn receiver_matches_any_prefix(&self, first_segment: &str, prefixes: &[String]) -> bool {
if first_segment.is_empty() {
return false;
}
let lower = first_segment.to_ascii_lowercase();
prefixes.iter().any(|prefix| {
!prefix.is_empty() && lower.starts_with(prefix.to_ascii_lowercase().as_str())
})
}
/// Classify a call into a [`SinkClass`].
///
/// Dispatch order (first match wins):
/// 1. `InMemoryLocal` — receiver is a known non-sink collection
/// (tracked in `non_sink_vars` or matches a configured
/// non-sink prefix).
/// 2. `RealtimePublish` — receiver first-segment matches a
/// configured realtime prefix (e.g. `realtime`, `pubsub`).
/// 3. `OutboundNetwork` — receiver first-segment matches a
/// configured outbound-network prefix (e.g. `http`, `reqwest`).
/// 4. `CacheCrossTenant` — receiver first-segment matches a
/// configured cache prefix (e.g. `cache`, `redis`).
/// 5. `DbMutation` — callee name matches `mutation_indicator_names`.
/// 6. `DbCrossTenantRead` — callee name matches `read_indicator_names`.
///
/// Returns `None` when the callee matches none of the above — the
/// call site is ignored by ownership-gap checks.
pub fn classify_sink_class(
&self,
callee: &str,
non_sink_vars: &std::collections::HashSet<String>,
) -> Option<SinkClass> {
if self.callee_has_non_sink_receiver(callee, non_sink_vars) {
return Some(SinkClass::InMemoryLocal);
}
// Browser/DOM globals (`document.getElementById`, `window.scrollTo`,
// `Math.random`, `JSON.parse`) and DOM-API methods on any receiver
// (`el.addEventListener`, `parent.appendChild`) are categorically
// not data-layer auth-relevant operations. These shapes would
// otherwise prefix-match read/mutation indicators (`get`, `add`,
// `remove`) — `getElementById` canonicalises to `getelementbyid`
// which `starts_with("get")` — and falsely classify as
// `DbCrossTenantRead` / `DbMutation`.
if self.callee_has_non_sink_global_receiver(callee)
|| self.callee_has_non_sink_method(callee)
{
return Some(SinkClass::InMemoryLocal);
}
let first = first_receiver_segment(callee);
if self.receiver_matches_any_prefix(first, &self.realtime_receiver_prefixes) {
return Some(SinkClass::RealtimePublish);
}
if self.receiver_matches_any_prefix(first, &self.outbound_network_receiver_prefixes) {
return Some(SinkClass::OutboundNetwork);
}
if self.receiver_matches_any_prefix(first, &self.cache_receiver_prefixes) {
return Some(SinkClass::CacheCrossTenant);
}
if self.is_mutation(callee) {
return Some(SinkClass::DbMutation);
}
if self.is_read(callee) {
return Some(SinkClass::DbCrossTenantRead);
}
None
}
pub fn requires_admin_path(&self, path: &str) -> bool {
let lower = path.to_ascii_lowercase();
let normalized = if lower.starts_with('/') {
lower.clone()
} else {
format!("/{lower}")
};
self.admin_path_patterns
.iter()
.map(|p| p.to_ascii_lowercase())
.any(|p| normalized.contains(&p) || lower.contains(p.trim_matches('/')))
}
pub fn is_admin_guard(&self, name: &str, args: &[String]) -> bool {
if matches_name(name, "PreAuthorize")
|| matches_name(name, "Secured")
|| matches_name(name, "RolesAllowed")
|| matches_name(name, "hasRole")
|| matches_name(name, "hasAuthority")
{
return args.iter().any(|arg| {
let lower = strip_quotes(arg).to_ascii_lowercase();
lower.contains("admin")
|| lower.contains("role_admin")
|| lower.contains("manage")
|| lower.contains("superuser")
});
}
if self
.admin_guard_names
.iter()
.any(|pattern| matches_name(name, pattern))
{
return true;
}
if matches_name(name, "requireRole")
&& args
.first()
.is_some_and(|arg| strip_quotes(arg).eq_ignore_ascii_case("admin"))
{
return true;
}
if matches_name(name, "permission_required")
|| matches_name(name, "PermissionRequiredMixin")
|| matches_name(name, "user_passes_test")
{
return args.iter().any(|arg| {
let lower = strip_quotes(arg).to_ascii_lowercase();
lower.contains("admin")
|| lower.contains("staff")
|| lower.contains("manage")
|| lower.contains("auth.")
|| lower.contains("change_")
|| lower.contains("delete_")
|| lower.contains("add_")
});
}
false
}
pub fn is_login_guard(&self, name: &str) -> bool {
if matches_name(name, "isAuthenticated")
|| matches_name(name, "authenticated")
|| matches_name(name, "hasRole")
|| matches_name(name, "hasAuthority")
|| matches_name(name, "Secured")
|| matches_name(name, "RolesAllowed")
|| matches_name(name, "PreAuthorize")
{
return true;
}
self.login_guard_names
.iter()
.any(|pattern| matches_name(name, pattern))
}
pub fn is_authorization_check(&self, name: &str) -> bool {
if self
.authorization_check_names
.iter()
.any(|pattern| matches_name(name, pattern))
{
return true;
}
// Structural recogniser for the canonical Rust / cross-language
// `require_<resource>_<role>` shape (`require_trip_member`,
// `require_doc_owner`, `require_project_admin`). The resource
// segment is project-specific so cannot be enumerated in the
// per-language defaults; the `<role>` suffix is a closed set of
// authorization vocabulary. This recogniser closes a real-repo
// FP cluster where a project-named membership helper was
// shadowing every realtime/db sink in the file.
is_require_resource_role_call(name)
}
pub fn is_token_lookup(&self, name: &str) -> bool {
self.token_lookup_names
.iter()
.any(|pattern| matches_name(name, pattern))
}
pub fn is_token_lookup_call(&self, name: &str, call_text: &str) -> bool {
if self.is_token_lookup(name) {
return true;
}
let lower = call_text.to_ascii_lowercase();
let looks_like_token_query = lower.contains("token=")
|| lower.contains("token =")
|| lower.contains("invite")
|| lower.contains("invitation")
|| lower.contains("accept_key");
looks_like_token_query
&& (self.is_read(name)
|| matches_name(name, "get")
|| matches_name(name, "filter")
|| matches_name(name, "first")
|| matches_name(name, "one"))
}
pub fn is_mutation(&self, name: &str) -> bool {
self.mutation_indicator_names
.iter()
.any(|pattern| matches_name(name, pattern))
}
pub fn is_read(&self, name: &str) -> bool {
self.read_indicator_names
.iter()
.any(|pattern| matches_name(name, pattern))
}
pub fn has_expiry_field(&self, text: &str) -> bool {
let lower = text.to_ascii_lowercase();
self.token_expiry_fields
.iter()
.map(|field| field.to_ascii_lowercase())
.any(|field| lower.contains(&field))
}
pub fn has_recipient_field(&self, text: &str) -> bool {
let lower = text.to_ascii_lowercase();
self.token_recipient_fields
.iter()
.map(|field| field.to_ascii_lowercase())
.any(|field| lower.contains(&field))
}
pub fn rule_id(&self, suffix: &str) -> String {
format!("{}.{}", self.finding_prefix, suffix)
}
}
fn auth_finding_prefix(lang_slug: &str) -> Option<&'static str> {
match lang_slug {
"javascript" | "typescript" => Some("js.auth"),
"python" => Some("py.auth"),
"ruby" => Some("rb.auth"),
"go" => Some("go.auth"),
"java" => Some("java.auth"),
"rust" => Some("rs.auth"),
_ => None,
}
}
fn auth_config_slugs(lang_slug: &str) -> &'static [&'static str] {
match lang_slug {
"typescript" => &["javascript", "typescript"],
"javascript" => &["javascript"],
"python" => &["python"],
"ruby" => &["ruby"],
"go" => &["go"],
"java" => &["java"],
"rust" => &["rust"],
_ => &[],
}
}
pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
let Some(finding_prefix) = auth_finding_prefix(lang_slug) else {
return AuthAnalysisRules::disabled();
};
let mut rules = if matches!(lang_slug, "python") {
AuthAnalysisRules {
enabled: true,
finding_prefix: finding_prefix.into(),
admin_path_patterns: vec!["/admin/".into()],
admin_guard_names: vec![
"admin_required".into(),
"staff_member_required".into(),
"is_admin".into(),
"is_staff".into(),
"permission_required".into(),
"PermissionRequiredMixin".into(),
"AdminRequiredMixin".into(),
],
login_guard_names: vec![
"login_required".into(),
"LoginRequiredMixin".into(),
"require_login".into(),
"ensure_authenticated".into(),
"require_auth".into(),
],
authorization_check_names: vec![
"check_membership".into(),
"has_membership".into(),
"require_membership".into(),
"ensure_membership".into(),
"is_member".into(),
"check_ownership".into(),
"has_ownership".into(),
"require_ownership".into(),
"ensure_ownership".into(),
"is_owner".into(),
"owns_".into(),
"permission_required".into(),
"has_perm".into(),
"has_permission".into(),
"has_object_permission".into(),
"user_passes_test".into(),
"verify_access".into(),
"authorize".into(),
],
mutation_indicator_names: vec![
"update".into(),
"delete".into(),
"create".into(),
"save".into(),
"bulk_update".into(),
"bulk_create".into(),
"archive".into(),
"publish".into(),
"remove".into(),
"add".into(),
"confirm".into(),
"invite".into(),
"accept".into(),
],
read_indicator_names: vec![
"get".into(),
"filter".into(),
"find".into(),
"fetch".into(),
"load".into(),
"list".into(),
"retrieve".into(),
],
token_lookup_names: vec![
"find_by_token".into(),
"lookup_by_token".into(),
"get_by_token".into(),
"get_invitation_by_token".into(),
"Invitation.objects.get".into(),
"invite_lookup".into(),
],
token_expiry_fields: vec![
"expires_at".into(),
"expiresat".into(),
"expiry".into(),
"expires".into(),
"expired".into(),
"has_expired".into(),
],
token_recipient_fields: vec![
"email".into(),
"recipient_email".into(),
"recipientemail".into(),
"invited_email".into(),
"invitedemail".into(),
"recipient".into(),
],
non_sink_receiver_types: Vec::new(),
non_sink_receiver_name_prefixes: Vec::new(),
non_sink_global_receivers: Vec::new(),
non_sink_method_names: Vec::new(),
realtime_receiver_prefixes: Vec::new(),
outbound_network_receiver_prefixes: Vec::new(),
cache_receiver_prefixes: Vec::new(),
acl_tables: Vec::new(),
}
} else if matches!(lang_slug, "ruby") {
AuthAnalysisRules {
enabled: true,
finding_prefix: finding_prefix.into(),
admin_path_patterns: vec!["/admin/".into()],
admin_guard_names: vec![
"require_admin".into(),
"require_admin!".into(),
"authenticate_admin".into(),
"authenticate_admin!".into(),
"ensure_admin".into(),
"ensure_admin!".into(),
"admin_only".into(),
"admin_only!".into(),
"admin_required".into(),
"admin_required!".into(),
],
login_guard_names: vec![
"require_login".into(),
"require_login!".into(),
"authenticate_user".into(),
"authenticate_user!".into(),
"authenticate".into(),
"authenticate!".into(),
"ensure_authenticated".into(),
"ensure_authenticated!".into(),
"login_required".into(),
"login_required!".into(),
],
authorization_check_names: vec![
"authorize".into(),
"authorize!".into(),
"check_membership".into(),
"check_membership!".into(),
"has_membership".into(),
"has_membership?".into(),
"require_membership".into(),
"require_membership!".into(),
"ensure_membership".into(),
"ensure_membership!".into(),
"member_of?".into(),
"member?".into(),
"check_ownership".into(),
"check_ownership!".into(),
"has_ownership".into(),
"has_ownership?".into(),
"require_ownership".into(),
"require_ownership!".into(),
"ensure_ownership".into(),
"ensure_ownership!".into(),
"owner?".into(),
"owns?".into(),
"verify_access".into(),
"verify_access!".into(),
"can_access?".into(),
"can?".into(),
],
mutation_indicator_names: vec![
"update".into(),
"update!".into(),
"delete".into(),
"delete!".into(),
"destroy".into(),
"destroy!".into(),
"create".into(),
"create!".into(),
"save".into(),
"save!".into(),
"archive".into(),
"archive!".into(),
"publish".into(),
"publish!".into(),
"remove".into(),
"remove!".into(),
"add".into(),
"add!".into(),
"confirm".into(),
"confirm!".into(),
"invite".into(),
"invite!".into(),
"accept".into(),
"accept!".into(),
],
read_indicator_names: vec![
"find".into(),
"find_by".into(),
"find_by!".into(),
"where".into(),
"first".into(),
"last".into(),
"take".into(),
"pluck".into(),
"load".into(),
"fetch".into(),
"get".into(),
"lookup".into(),
"retrieve".into(),
],
token_lookup_names: vec![
"find_by_token".into(),
"find_by_token!".into(),
"find_by_invite_token".into(),
"find_by_invite_token!".into(),
"find_by_invitation_token".into(),
"find_by_invitation_token!".into(),
"find_by_accept_token".into(),
"find_by_accept_token!".into(),
"find_signed".into(),
"find_signed!".into(),
"lookup_invitation".into(),
"lookup_invitation!".into(),
"Invitation.find_by".into(),
"Invitation.find_by!".into(),
"Invite.find_by".into(),
"Invite.find_by!".into(),
],
token_expiry_fields: vec![
"expires_at".into(),
"expiry".into(),
"expires".into(),
"expired".into(),
"expired?".into(),
"expired_at".into(),
"valid_until".into(),
],
token_recipient_fields: vec![
"email".into(),
"recipient_email".into(),
"recipient".into(),
"invited_email".into(),
"invitee_email".into(),
"user_email".into(),
],
non_sink_receiver_types: Vec::new(),
non_sink_receiver_name_prefixes: Vec::new(),
non_sink_global_receivers: Vec::new(),
non_sink_method_names: Vec::new(),
realtime_receiver_prefixes: Vec::new(),
outbound_network_receiver_prefixes: Vec::new(),
cache_receiver_prefixes: Vec::new(),
acl_tables: Vec::new(),
}
} else if matches!(lang_slug, "go") {
AuthAnalysisRules {
enabled: true,
finding_prefix: finding_prefix.into(),
admin_path_patterns: vec!["/admin/".into()],
admin_guard_names: vec![
"RequireAdmin".into(),
"AdminOnly".into(),
"EnsureAdmin".into(),
"requireAdmin".into(),
"adminOnly".into(),
"ensureAdmin".into(),
],
login_guard_names: vec![
"RequireLogin".into(),
"RequireAuth".into(),
"EnsureAuthenticated".into(),
"AuthMiddleware".into(),
"requireLogin".into(),
"requireAuth".into(),
"ensureAuthenticated".into(),
],
authorization_check_names: vec![
"CheckMembership".into(),
"HasMembership".into(),
"RequireMembership".into(),
"EnsureMembership".into(),
"IsMember".into(),
"CheckOwnership".into(),
"HasOwnership".into(),
"RequireOwnership".into(),
"EnsureOwnership".into(),
"IsOwner".into(),
"Authorize".into(),
"VerifyAccess".into(),
"HasPermission".into(),
"CanAccess".into(),
],
mutation_indicator_names: vec![
"Update".into(),
"Delete".into(),
"Create".into(),
"Save".into(),
"Archive".into(),
"Publish".into(),
"Remove".into(),
"Add".into(),
"Confirm".into(),
"Invite".into(),
"Accept".into(),
],
read_indicator_names: vec![
"Find".into(),
"Get".into(),
"List".into(),
"Load".into(),
"Fetch".into(),
"Lookup".into(),
"Query".into(),
],
token_lookup_names: vec![
"FindByToken".into(),
"LookupByToken".into(),
"FindInvitationByToken".into(),
"FindInviteByToken".into(),
"GetInvitationByToken".into(),
"LookupInvitation".into(),
],
token_expiry_fields: vec![
"expires_at".into(),
"expiresat".into(),
"expiresAt".into(),
"expiry".into(),
"expired".into(),
"validUntil".into(),
],
token_recipient_fields: vec![
"email".into(),
"recipient_email".into(),
"recipientEmail".into(),
"invited_email".into(),
"invitedEmail".into(),
"invitee_email".into(),
"inviteeEmail".into(),
"recipient".into(),
],
non_sink_receiver_types: Vec::new(),
non_sink_receiver_name_prefixes: Vec::new(),
non_sink_global_receivers: Vec::new(),
non_sink_method_names: Vec::new(),
realtime_receiver_prefixes: Vec::new(),
outbound_network_receiver_prefixes: Vec::new(),
cache_receiver_prefixes: Vec::new(),
acl_tables: Vec::new(),
}
} else if matches!(lang_slug, "java") {
AuthAnalysisRules {
enabled: true,
finding_prefix: finding_prefix.into(),
admin_path_patterns: vec!["/admin/".into()],
admin_guard_names: vec![
"RequireAdmin".into(),
"AdminOnly".into(),
"EnsureAdmin".into(),
"adminOnly".into(),
],
login_guard_names: vec![
"RequireLogin".into(),
"LoginRequired".into(),
"EnsureAuthenticated".into(),
"Authenticated".into(),
"isAuthenticated".into(),
],
authorization_check_names: vec![
"checkMembership".into(),
"hasMembership".into(),
"requireMembership".into(),
"ensureMembership".into(),
"isMember".into(),
"checkOwnership".into(),
"hasOwnership".into(),
"requireOwnership".into(),
"ensureOwnership".into(),
"isOwner".into(),
"authorize".into(),
"verifyAccess".into(),
"hasPermission".into(),
"canAccess".into(),
],
mutation_indicator_names: vec![
"update".into(),
"delete".into(),
"create".into(),
"save".into(),
"archive".into(),
"publish".into(),
"remove".into(),
"add".into(),
"confirm".into(),
"invite".into(),
"accept".into(),
],
read_indicator_names: vec![
"find".into(),
"get".into(),
"load".into(),
"fetch".into(),
"lookup".into(),
"read".into(),
"query".into(),
],
token_lookup_names: vec![
"findByToken".into(),
"findByInviteToken".into(),
"findByInvitationToken".into(),
"findByAcceptToken".into(),
"getByToken".into(),
"lookupByToken".into(),
"lookupInvitation".into(),
],
token_expiry_fields: vec![
"expires_at".into(),
"expiresAt".into(),
"expiry".into(),
"expired".into(),
"validUntil".into(),
],
token_recipient_fields: vec![
"email".into(),
"recipient_email".into(),
"recipientEmail".into(),
"invited_email".into(),
"invitedEmail".into(),
"invitee_email".into(),
"inviteeEmail".into(),
"recipient".into(),
],
non_sink_receiver_types: Vec::new(),
non_sink_receiver_name_prefixes: Vec::new(),
non_sink_global_receivers: Vec::new(),
non_sink_method_names: Vec::new(),
realtime_receiver_prefixes: Vec::new(),
outbound_network_receiver_prefixes: Vec::new(),
cache_receiver_prefixes: Vec::new(),
acl_tables: Vec::new(),
}
} else if matches!(lang_slug, "rust") {
AuthAnalysisRules {
enabled: true,
finding_prefix: finding_prefix.into(),
admin_path_patterns: vec!["/admin/".into()],
admin_guard_names: vec![
"require_admin".into(),
"ensure_admin".into(),
"admin_only".into(),
"admin_guard".into(),
"AdminUser".into(),
"AdminGuard".into(),
"RequireAdmin".into(),
],
login_guard_names: vec![
"require_login".into(),
"require_auth".into(),
"ensure_authenticated".into(),
"authenticated".into(),
"CurrentUser".into(),
"SessionUser".into(),
"AuthUser".into(),
"RequireLogin".into(),
"RequireAuth".into(),
],
authorization_check_names: vec![
"check_membership".into(),
"has_membership".into(),
"require_membership".into(),
"ensure_membership".into(),
"is_member".into(),
"check_ownership".into(),
"has_ownership".into(),
"require_ownership".into(),
"ensure_ownership".into(),
"is_owner".into(),
"authorize".into(),
"verify_access".into(),
"has_permission".into(),
"can_access".into(),
"can_manage".into(),
// Common project-specific helpers seen in real Axum/Rocket
// codebases — kept as defaults so user code that names
// its membership helper after the resource still gets
// recognised. Users can extend via `nyx.toml`.
"require_group_member".into(),
"require_org_member".into(),
"require_workspace_member".into(),
"require_tenant_member".into(),
"require_team_member".into(),
],
mutation_indicator_names: vec![
"update".into(),
"delete".into(),
"destroy".into(),
"create".into(),
"save".into(),
"archive".into(),
"publish".into(),
"remove".into(),
"insert".into(),
"add".into(),
"confirm".into(),
"invite".into(),
"accept".into(),
"set".into(),
],
read_indicator_names: vec![
"find".into(),
"find_by_id".into(),
"get".into(),
"load".into(),
"fetch".into(),
"lookup".into(),
"list".into(),
"read".into(),
"query".into(),
],
token_lookup_names: vec![
"find_by_token".into(),
"lookup_by_token".into(),
"get_by_token".into(),
"find_invitation_by_token".into(),
"find_invite_by_token".into(),
"lookup_invitation".into(),
"get_invitation".into(),
"find_by_invite_token".into(),
"find_by_invitation_token".into(),
"find_signed".into(),
],
token_expiry_fields: vec![
"expires_at".into(),
"expiresat".into(),
"expiresAt".into(),
"expiry".into(),
"expires".into(),
"expired".into(),
"valid_until".into(),
"validUntil".into(),
],
token_recipient_fields: vec![
"email".into(),
"recipient_email".into(),
"recipientEmail".into(),
"invited_email".into(),
"invitedEmail".into(),
"invitee_email".into(),
"inviteeEmail".into(),
"recipient".into(),
],
non_sink_receiver_types: vec![
"HashMap".into(),
"HashSet".into(),
"BTreeMap".into(),
"BTreeSet".into(),
"Vec".into(),
"VecDeque".into(),
"BinaryHeap".into(),
"IndexMap".into(),
"IndexSet".into(),
"LinkedList".into(),
"SmallVec".into(),
"FxHashMap".into(),
"FxHashSet".into(),
"DashMap".into(),
"DashSet".into(),
// `serde_json::Map` (last-segment `Map`) — common JSON
// body builder where `m.insert("k", v)` is a string-key
// assignment on an in-memory object, not a DB write.
"Map".into(),
],
non_sink_receiver_name_prefixes: vec![
"local_map".into(),
"local_set".into(),
"local_cache".into(),
"visited".into(),
"seen".into(),
"idx_".into(),
"index_".into(),
"lookup_".into(),
"_tmp_map".into(),
"counts".into(),
"buckets".into(),
"pending".into(),
"queue".into(),
"stack".into(),
],
non_sink_global_receivers: Vec::new(),
non_sink_method_names: Vec::new(),
realtime_receiver_prefixes: vec![
"realtime".into(),
"pubsub".into(),
"broker".into(),
"broadcast".into(),
"notifier".into(),
"channels".into(),
],
outbound_network_receiver_prefixes: vec![
"http".into(),
"reqwest".into(),
"hyper".into(),
"client".into(),
"webhook".into(),
"fetcher".into(),
],
cache_receiver_prefixes: vec!["redis".into(), "memcache".into(), "memcached".into()],
acl_tables: vec![
"group_members".into(),
"org_memberships".into(),
"workspace_members".into(),
"tenant_members".into(),
"members".into(),
"share_grants".into(),
],
}
} else {
AuthAnalysisRules {
enabled: true,
finding_prefix: finding_prefix.into(),
admin_path_patterns: vec!["/admin/".into()],
admin_guard_names: vec![
"requireAdmin".into(),
"isAdmin".into(),
"adminOnly".into(),
"requireRole".into(),
],
login_guard_names: vec![
"requireLogin".into(),
"authenticate".into(),
"requireAuth".into(),
"ensureAuthenticated".into(),
"ensureAuth".into(),
"require_login".into(),
],
authorization_check_names: vec![
"checkMembership".into(),
"hasWorkspaceMembership".into(),
"checkOwnership".into(),
"authorize".into(),
"hasAccess".into(),
"isOwner".into(),
"isMember".into(),
"requireMembership".into(),
"requireOwnership".into(),
"verifyAccess".into(),
"hasPermission".into(),
"requireRole".into(),
"canAccess".into(),
],
mutation_indicator_names: vec![
"update".into(),
"delete".into(),
"create".into(),
"archive".into(),
"publish".into(),
"remove".into(),
"insert".into(),
"add".into(),
"confirm".into(),
"invite".into(),
"run".into(),
"accept".into(),
],
read_indicator_names: vec![
"findById".into(),
"find".into(),
"list".into(),
"get".into(),
"fetch".into(),
"load".into(),
],
token_lookup_names: vec!["findByToken".into(), "lookupByToken".into()],
token_expiry_fields: vec!["expires_at".into(), "expiresAt".into(), "expiry".into()],
token_recipient_fields: vec![
"email".into(),
"recipient_email".into(),
"recipientEmail".into(),
"invited_email".into(),
"invitedEmail".into(),
],
non_sink_receiver_types: Vec::new(),
non_sink_receiver_name_prefixes: Vec::new(),
// Browser/DOM globals — calls on these receivers are
// categorically client-side (no server-side authorization
// semantics). Without this list, `document.getElementById`
// would prefix-match the read-indicator `get`,
// `window.scrollTo` would match `scroll`, etc. Case-sensitive
// exact match against the first receiver-chain segment.
non_sink_global_receivers: vec![
"document".into(),
"window".into(),
"localStorage".into(),
"sessionStorage".into(),
"console".into(),
"navigator".into(),
"location".into(),
"history".into(),
"screen".into(),
"performance".into(),
"crypto".into(),
"Math".into(),
"JSON".into(),
"Date".into(),
"Number".into(),
"String".into(),
"Boolean".into(),
"Array".into(),
"Object".into(),
"Promise".into(),
"Symbol".into(),
"RegExp".into(),
"Error".into(),
"Map".into(),
"Set".into(),
"WeakMap".into(),
"WeakSet".into(),
],
// DOM-API methods — when the LAST segment of the callee
// matches, the call is non-data-layer regardless of receiver
// (`el.addEventListener`, `parent.appendChild`). These
// methods would otherwise prefix-match `add`, `remove`,
// `get`, `set` indicators.
non_sink_method_names: vec![
"addEventListener".into(),
"removeEventListener".into(),
"dispatchEvent".into(),
"appendChild".into(),
"removeChild".into(),
"replaceChild".into(),
"insertBefore".into(),
"cloneNode".into(),
"getElementById".into(),
"getElementsByClassName".into(),
"getElementsByTagName".into(),
"getElementsByName".into(),
"querySelector".into(),
"querySelectorAll".into(),
"getAttribute".into(),
"setAttribute".into(),
"removeAttribute".into(),
"hasAttribute".into(),
"toggleAttribute".into(),
"createElement".into(),
"createTextNode".into(),
"createDocumentFragment".into(),
"getBoundingClientRect".into(),
"getComputedStyle".into(),
"scrollIntoView".into(),
"scrollTo".into(),
"scrollBy".into(),
"focus".into(),
"blur".into(),
"submit".into(),
"reset".into(),
"click".into(),
"matches".into(),
"contains".into(),
"closest".into(),
"getItem".into(),
"setItem".into(),
"removeItem".into(),
],
realtime_receiver_prefixes: Vec::new(),
outbound_network_receiver_prefixes: Vec::new(),
cache_receiver_prefixes: Vec::new(),
acl_tables: Vec::new(),
}
};
for config_slug in auth_config_slugs(lang_slug) {
let Some(lang_cfg) = config.analysis.languages.get(*config_slug) else {
continue;
};
rules.enabled = lang_cfg.auth.enabled;
extend_unique(
&mut rules.admin_path_patterns,
&lang_cfg.auth.admin_path_patterns,
);
extend_unique(
&mut rules.admin_guard_names,
&lang_cfg.auth.admin_guard_names,
);
extend_unique(
&mut rules.login_guard_names,
&lang_cfg.auth.login_guard_names,
);
extend_unique(
&mut rules.authorization_check_names,
&lang_cfg.auth.authorization_check_names,
);
extend_unique(
&mut rules.mutation_indicator_names,
&lang_cfg.auth.mutation_indicator_names,
);
extend_unique(
&mut rules.read_indicator_names,
&lang_cfg.auth.read_indicator_names,
);
extend_unique(
&mut rules.token_lookup_names,
&lang_cfg.auth.token_lookup_names,
);
extend_unique(
&mut rules.token_expiry_fields,
&lang_cfg.auth.token_expiry_fields,
);
extend_unique(
&mut rules.token_recipient_fields,
&lang_cfg.auth.token_recipient_fields,
);
extend_unique(
&mut rules.non_sink_receiver_types,
&lang_cfg.auth.non_sink_receiver_types,
);
extend_unique(
&mut rules.non_sink_receiver_name_prefixes,
&lang_cfg.auth.non_sink_receiver_name_prefixes,
);
extend_unique(
&mut rules.non_sink_global_receivers,
&lang_cfg.auth.non_sink_global_receivers,
);
extend_unique(
&mut rules.non_sink_method_names,
&lang_cfg.auth.non_sink_method_names,
);
extend_unique(
&mut rules.realtime_receiver_prefixes,
&lang_cfg.auth.realtime_receiver_prefixes,
);
extend_unique(
&mut rules.outbound_network_receiver_prefixes,
&lang_cfg.auth.outbound_network_receiver_prefixes,
);
extend_unique(
&mut rules.cache_receiver_prefixes,
&lang_cfg.auth.cache_receiver_prefixes,
);
extend_unique(&mut rules.acl_tables, &lang_cfg.auth.acl_tables);
}
rules
}
pub fn extend_unique(dst: &mut Vec<String>, src: &[String]) {
for item in src {
if !dst.contains(item) {
dst.push(item.clone());
}
}
}
pub fn canonical_name(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
/// Return the first segment of a callee's receiver chain.
/// For `map.insert` → `"map"`; for `self.cache.insert` → `"self"`;
/// for a callee with no receiver (`HashMap::new`) → the full name.
pub fn first_receiver_segment(callee: &str) -> &str {
callee.split('.').next().unwrap_or(callee)
}
/// Recognise `require_<resource>_<role>` / `ensure_<resource>_<role>`
/// shapes where `<role>` is a closed-vocabulary authorization noun
/// (`member`, `owner`, `admin`, `access`, `permission`, `manager`,
/// `editor`, `viewer`). The resource segment is project-specific
/// (`trip`, `doc`, `project`, `workspace`, …) and cannot be enumerated
/// in the static defaults — but the prefix+role pattern is unambiguous
/// enough that recognising it as an authorization check is safe.
///
/// Strips path-namespace and method prefixes before matching:
/// `authz::require_trip_member` → `require_trip_member`;
/// `obj.require_trip_member` → `require_trip_member`.
fn is_require_resource_role_call(name: &str) -> bool {
let last = name.rsplit("::").next().unwrap_or(name);
let last = last.rsplit('.').next().unwrap_or(last);
let lower = last.to_ascii_lowercase();
let after_prefix = if let Some(rest) = lower.strip_prefix("require_") {
rest
} else if let Some(rest) = lower.strip_prefix("ensure_") {
rest
} else {
return false;
};
let Some(last_underscore) = after_prefix.rfind('_') else {
return false;
};
// Must have at least one resource char before the role and a
// non-empty role after. Rejects degenerate `require__member`,
// `require_member` (no resource).
if last_underscore == 0 || last_underscore == after_prefix.len() - 1 {
return false;
}
let role = &after_prefix[last_underscore + 1..];
matches!(
role,
"member"
| "members"
| "owner"
| "owners"
| "admin"
| "admins"
| "access"
| "permission"
| "permissions"
| "manager"
| "managers"
| "editor"
| "editors"
| "viewer"
| "viewers"
| "role"
)
}
pub fn matches_name(name: &str, pattern: &str) -> bool {
let name_last = name.rsplit('.').next().unwrap_or(name);
let pattern_last = pattern.rsplit('.').next().unwrap_or(pattern);
let name_norm = canonical_name(name_last);
let pattern_norm = canonical_name(pattern_last);
!pattern_norm.is_empty() && (name_norm == pattern_norm || name_norm.starts_with(&pattern_norm))
}
pub fn strip_quotes(input: &str) -> String {
input
.trim()
.trim_matches('\'')
.trim_matches('"')
.trim_matches('`')
.to_string()
}
#[cfg(test)]
mod tests {
use super::build_auth_rules;
use crate::utils::config::{AuthAnalysisConfig, Config, LanguageAnalysisConfig};
#[test]
fn typescript_uses_javascript_rule_prefix() {
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "typescript");
assert_eq!(
rules.rule_id("missing_ownership_check"),
"js.auth.missing_ownership_check"
);
}
#[test]
fn typescript_inherits_javascript_auth_overrides_and_applies_ts_specific_overlay() {
let mut cfg = Config::default();
cfg.analysis.languages.insert(
"javascript".into(),
LanguageAnalysisConfig {
auth: AuthAnalysisConfig {
admin_guard_names: vec!["requirePlatformAdmin".into()],
token_lookup_names: vec!["findInviteToken".into()],
..AuthAnalysisConfig::default()
},
..LanguageAnalysisConfig::default()
},
);
cfg.analysis.languages.insert(
"typescript".into(),
LanguageAnalysisConfig {
auth: AuthAnalysisConfig {
authorization_check_names: vec!["requireTypedOwnership".into()],
..AuthAnalysisConfig::default()
},
..LanguageAnalysisConfig::default()
},
);
let rules = build_auth_rules(&cfg, "typescript");
assert!(
rules
.admin_guard_names
.contains(&"requirePlatformAdmin".to_string())
);
assert!(
rules
.token_lookup_names
.contains(&"findInviteToken".to_string())
);
assert!(
rules
.authorization_check_names
.contains(&"requireTypedOwnership".to_string())
);
}
#[test]
fn rust_non_sink_receiver_defaults_include_std_collections() {
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "rust");
assert!(rules.is_non_sink_receiver_type("HashMap"));
assert!(rules.is_non_sink_receiver_type("HashSet"));
assert!(rules.is_non_sink_receiver_type("Vec"));
assert!(rules.is_non_sink_receiver_type("std::collections::HashMap"));
assert!(rules.is_non_sink_receiver_type("HashMap<i64, usize>"));
assert!(!rules.is_non_sink_receiver_type("Database"));
}
#[test]
fn rust_non_sink_constructor_callee_matches_known_forms() {
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "rust");
assert!(rules.is_non_sink_constructor_callee("HashMap::new"));
assert!(rules.is_non_sink_constructor_callee("HashMap::with_capacity"));
assert!(rules.is_non_sink_constructor_callee("SmallVec::from"));
assert!(rules.is_non_sink_constructor_callee("std::collections::HashMap::new"));
assert!(!rules.is_non_sink_constructor_callee("HashMap::get"));
assert!(!rules.is_non_sink_constructor_callee("Database::connect"));
assert!(!rules.is_non_sink_constructor_callee("plain_function"));
}
#[test]
fn callee_has_non_sink_receiver_matches_var_set_and_prefixes() {
use std::collections::HashSet;
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "rust");
let mut vars = HashSet::new();
vars.insert("map".to_string());
// First receiver segment in non_sink_vars → skipped.
assert!(rules.callee_has_non_sink_receiver("map.insert", &vars));
// First segment not in vars, not a known prefix → not skipped.
assert!(!rules.callee_has_non_sink_receiver("db.insert", &vars));
// Deep receiver: "self.cache.insert" → first segment "self" → ambiguous.
assert!(!rules.callee_has_non_sink_receiver("self.cache.insert", &vars));
// Prefix-match on configured name prefix ("counts" is in defaults).
assert!(rules.callee_has_non_sink_receiver("counts.insert", &HashSet::new()));
assert!(rules.callee_has_non_sink_receiver("visited_nodes.insert", &HashSet::new()));
}
#[test]
fn classify_sink_class_dispatches_on_receiver_and_name() {
use crate::auth_analysis::model::SinkClass;
use std::collections::HashSet;
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "rust");
let mut vars = HashSet::new();
vars.insert("map".to_string());
// In-memory local: tracked var → InMemoryLocal (trumps name-based match).
assert_eq!(
rules.classify_sink_class("map.insert", &vars),
Some(SinkClass::InMemoryLocal)
);
// In-memory local: configured name prefix.
assert_eq!(
rules.classify_sink_class("visited.insert", &HashSet::new()),
Some(SinkClass::InMemoryLocal)
);
// Realtime: default prefix `realtime` → RealtimePublish even when
// the method name (`publish_to_group`) would also match the
// mutation list.
assert_eq!(
rules.classify_sink_class("realtime.publish_to_group", &HashSet::new()),
Some(SinkClass::RealtimePublish)
);
// Outbound network: default prefix `http`.
assert_eq!(
rules.classify_sink_class("http.post", &HashSet::new()),
Some(SinkClass::OutboundNetwork)
);
// Cache: default prefix `redis`.
assert_eq!(
rules.classify_sink_class("redis.set", &HashSet::new()),
Some(SinkClass::CacheCrossTenant)
);
// DB mutation fallback: `db.insert` → mutation indicator →
// DbMutation (no receiver prefix matches `db`).
assert_eq!(
rules.classify_sink_class("db.insert", &HashSet::new()),
Some(SinkClass::DbMutation)
);
// DB cross-tenant read fallback: `db.find_by_id` → read indicator.
assert_eq!(
rules.classify_sink_class("db.find_by_id", &HashSet::new()),
Some(SinkClass::DbCrossTenantRead)
);
// Unknown verb with unknown receiver → None.
assert_eq!(
rules.classify_sink_class("widget.frobnicate", &HashSet::new()),
None
);
}
#[test]
fn sink_class_is_auth_relevant_only_for_non_local_classes() {
use crate::auth_analysis::model::SinkClass;
assert!(SinkClass::DbMutation.is_auth_relevant());
assert!(SinkClass::DbCrossTenantRead.is_auth_relevant());
assert!(SinkClass::RealtimePublish.is_auth_relevant());
assert!(SinkClass::OutboundNetwork.is_auth_relevant());
assert!(SinkClass::CacheCrossTenant.is_auth_relevant());
assert!(!SinkClass::InMemoryLocal.is_auth_relevant());
}
/// Pin the JS DOM-globals / DOM-methods allowlist that closes the
/// real-repo FP cluster of `document.getElementById` /
/// `el.addEventListener` shapes prefix-matching read/mutation
/// indicators (`get`, `add`).
#[test]
fn js_dom_globals_and_methods_classify_as_in_memory_local() {
use crate::auth_analysis::model::SinkClass;
use std::collections::HashSet;
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "javascript");
let empty: HashSet<String> = HashSet::new();
// Globals — receiver-first-segment match.
assert_eq!(
rules.classify_sink_class("document.getElementById", &empty),
Some(SinkClass::InMemoryLocal)
);
assert_eq!(
rules.classify_sink_class("window.scrollTo", &empty),
Some(SinkClass::InMemoryLocal)
);
assert_eq!(
rules.classify_sink_class("localStorage.getItem", &empty),
Some(SinkClass::InMemoryLocal)
);
assert_eq!(
rules.classify_sink_class("Math.random", &empty),
Some(SinkClass::InMemoryLocal)
);
// Method allowlist — last-segment match regardless of receiver.
assert_eq!(
rules.classify_sink_class("input.addEventListener", &empty),
Some(SinkClass::InMemoryLocal)
);
assert_eq!(
rules.classify_sink_class("dropdown.appendChild", &empty),
Some(SinkClass::InMemoryLocal)
);
assert_eq!(
rules.classify_sink_class("el.querySelector", &empty),
Some(SinkClass::InMemoryLocal)
);
// Real data-layer reads/mutations on plausible names still
// classify (no over-suppression): `db.find_by_id` reads,
// `repo.save` mutates.
assert_eq!(
rules.classify_sink_class("UserRepo.findById", &empty),
Some(SinkClass::DbCrossTenantRead)
);
assert_eq!(
rules.classify_sink_class("repo.update", &empty),
Some(SinkClass::DbMutation)
);
}
/// `require_<resource>_<role>` structural recogniser for project
/// helpers like `require_trip_member`, `require_doc_owner`.
#[test]
fn is_authorization_check_recognises_require_resource_role_shapes() {
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "rust");
assert!(rules.is_authorization_check("require_trip_member"));
assert!(rules.is_authorization_check("require_doc_owner"));
assert!(rules.is_authorization_check("require_project_admin"));
assert!(rules.is_authorization_check("ensure_workspace_access"));
assert!(rules.is_authorization_check("authz::require_trip_member"));
assert!(rules.is_authorization_check("self.require_album_editor"));
// Negatives — random `require_*` calls without a known role
// suffix do NOT count as authorization.
assert!(!rules.is_authorization_check("require_db"));
assert!(!rules.is_authorization_check("require_user"));
assert!(!rules.is_authorization_check("require_login"));
// Bare `require_member` / `require_owner` (no resource segment)
// aren't enough — the resource segment is what makes the helper
// unambiguous.
assert!(!rules.is_authorization_check("require_member"));
assert!(!rules.is_authorization_check("require_owner"));
}
}