From 6341afec59a488291bef2e89a19d01e63ff7fdf4 Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 21 May 2026 12:17:45 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0003 (20260521T143544Z-f898) --- CHANGELOG.md | 6 +- docs/advanced-analysis.md | 10 +-- docs/auth.md | 2 +- docs/language-maturity.md | 3 +- docs/serve.md | 2 +- src/dynamic/framework/adapters/header_go.rs | 77 ++++++++++++++++++- src/dynamic/framework/adapters/header_ruby.rs | 66 +++++++++++++++- src/dynamic/framework/adapters/header_rust.rs | 73 +++++++++++++++++- src/dynamic/framework/adapters/mod.rs | 30 ++++++++ .../framework/adapters/pp_lodash_merge.rs | 73 +++++++++++++++++- src/dynamic/repro.rs | 7 +- src/server/health.rs | 14 ++-- src/server/routes/overview.rs | 8 +- tests/dynamic_layering.rs | 10 +-- tests/eval_corpus/run.sh | 4 +- tests/health_score_calibration.rs | 9 +-- 16 files changed, 346 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d311a1..83a46c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,7 +116,7 @@ A focused release that adds seven new vulnerability classes, ships two SSA sidec - **FastAPI cross-file `include_router` dependency tracking.** `auth_analysis/router_facts.rs` captures per-file router declarations (` = X(deps=[…])`) and `.include_router(.)` edges in pass 1, persists them into `GlobalSummaries::router_facts_by_module`, and resolves them into the active file's `AuthorizationModel::cross_file_router_deps` at pass 2 entry. Transitive lifts (grandparent to parent to child) handled by iterative index walk. Module identity is the file basename without `.py`. Closes the airflow execution-API shape where a child router lives in `routes/task_instances.py` and its auth is declared on the parent in `routes/__init__.py`. - **FastAPI router-level `dependencies=[...]` propagation.** Module-level `router = APIRouter(dependencies=[Security(...)])` is pre-walked once per file and merged onto every `@.(...)` route attached in the same file. Closes airflow execution-API routes that re-use a single `ti_id_router` declared once at module scope. - **FastAPI `Security(callable, scopes=[...])` recognised distinctly from `Depends(callable)`.** Scoped Security promotes the synthetic `AuthCheck` to `AuthCheckKind::Other` (route-level scope-checked authorization), not Login. New scope-tracking boolean threaded through `expand_decorator_calls` and `extract_fastapi_dependencies`. -- **Caller-scope IPA: same-file route-handler-to-helper auth lift.** `apply_caller_scope_propagation` walks every non-route helper unit; if its in-file callers are non-empty AND every caller is itself an authorized route handler (route-level non-Login auth check) or already authorized via this same propagation, the caller's checks lift onto the helper as synthetic `is_route_level=true` `AuthCheck`s. Iterated to a small fixpoint so transitive helper chains (route to mid_helper to leaf_helper) are covered. Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers. Cross-file equivalent deferred. Closes the dominant FastAPI / Django / Flask "route authenticates via decorator/dependency, then delegates to a private helper that performs the sink" FP shape on sentry / saleor / airflow. +- **Caller-scope IPA: same-file route-handler-to-helper auth lift.** `apply_caller_scope_propagation` walks every non-route helper unit; if its in-file callers are non-empty AND every caller is itself an authorized route handler (route-level non-Login auth check) or already authorized via this same propagation, the caller's checks lift onto the helper as synthetic `is_route_level=true` `AuthCheck`s. Iterated to a small fixpoint so transitive helper chains (route to mid_helper to leaf_helper) are covered. Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers. Cross-file lifting is not implemented. Closes the dominant FastAPI / Django / Flask "route authenticates via decorator/dependency, then delegates to a private helper that performs the sink" FP shape on sentry / saleor / airflow. - **Go DAO-helper id-scalar precision pass.** For non-route Go units, a parameter whose declared type is a bounded primitive scalar (`int64`, `uint32`, `string`, `bool`, `byte`, `rune`, `float64`, …) and whose name is id-shaped (`id`, `*Id`, `*_id`, `*ids`) is dropped from `unit.params` before ownership-check evaluation. Real Go HTTP handlers always carry a framework-request-typed param (`*http.Request`, `*gin.Context`, `echo.Context`, `*fiber.Ctx`); per-framework route extractors set `include_id_like_typed=true` so id-shaped path params survive on real routes. Mirrors the existing Python `is_python_id_like_typed_param` filter. Closes ~957 `go.auth.missing_ownership_check` findings on gitea backend DAO helpers (`func GetRunByRepoAndID(ctx, repoID, runID int64)`, `func DeleteRunner(ctx, id int64)`, the entire `models/...` layer where the ownership check sits in the calling route handler) and equivalent shapes in minio / Go ORM codebases. - **Bare-callee verb-name fallback gate.** `list(...)`, `filter(...)`, `update(...)`, `create_audit_entry(...)`, `update_coding_agent_state(...)` (no receiver dot at all) no longer classify as `DbMutation` / `DbCrossTenantRead` via the loose verb-name fallback. Real ORM/DB calls carry a receiver (`User.find(id)`, `Model.objects.filter`, `repo.save(x)`); a bare `list(events)` is the Python builtin and `filter(fn, xs)` is `Iterable.filter`. New helper `receiver_is_simple_chain(callee)` requires a non-chained receiver dot. The realtime / outbound / cache prefix dispatches still match by chain root. @@ -150,7 +150,7 @@ Per-language label rules expanded for the seven new caps. ### CVE corpus -- **C.** CVE-2017-1000117 (git argv injection via `ssh://-oProxyCommand=…`) vulnerable + patched fixtures under `tests/benchmark/cve_corpus/c/CVE-2017-1000117/`. Three-layer engine gap deferred (array-element taint propagation, `c.cmdi.exec*` AST patterns, dash-prefix-byte sanitizer recognition). +- **C.** CVE-2017-1000117 (git argv injection via `ssh://-oProxyCommand=…`) vulnerable + patched fixtures under `tests/benchmark/cve_corpus/c/CVE-2017-1000117/`. Known remaining gap: array-element taint propagation, `c.cmdi.exec*` AST patterns, and dash-prefix-byte sanitizer recognition. - **Python.** CVE-2023-6568 (mlflow reflected XSS), CVE-2024-21513 (langchain SQL / Jinja), CVE-2024-23334 (aiohttp static-file path traversal) vulnerable + patched fixtures. - **PHP.** CVE-2026-33486 (roadiz/documents SSRF) vulnerable + patched fixtures. - **JavaScript.** CVE-2026-42353 (i18next-http-middleware path traversal) vulnerable + patched fixtures. @@ -388,7 +388,7 @@ The biggest release since launch. The taint engine was rebuilt on top of an SSA - Replaced the legacy `app.js` with a React + Vite + TypeScript SPA. - Interactive graph workspace for CFG and call-graph views (Graphology + ELK + Sigma) with neighborhood reduction and a full-page inspector. -- Triage UI with database-backed decisions (true positive, false positive, deferred, suppressed) and `.nyx/triage.json` round-trip. +- Triage UI with database-backed decisions (true positive, false positive, accepted risk, suppressed) and `.nyx/triage.json` round-trip. - Scan history, rules management, and finding detail panels with evidence and flow visualization. - Vitest browser-side test suite wired into CI. - Bumped to React 19, Vite 8, TypeScript 6.0, ESLint 10, `@vitejs/plugin-react` 6, with aligned `@types/react*`. diff --git a/docs/advanced-analysis.md b/docs/advanced-analysis.md index 11211657..d52c27d6 100644 --- a/docs/advanced-analysis.md +++ b/docs/advanced-analysis.md @@ -267,11 +267,11 @@ while the pass stabilises. | CLI flag | `--backwards-analysis` / `--no-backwards-analysis` | | Env var (legacy) | `NYX_BACKWARDS=1` | -**Limitations (first cut).** Reverse call-graph expansion past a -`ReachedParam` is deferred; the walk terminates at function parameters -rather than crossing back into callers. Path-constraint pruning is -conservative: only the accumulated `PredicateSummary` bits are consulted, -not the full symbolic predicate stack. Depth-bounded at k=2 for +**Limitations.** Reverse call-graph expansion stops at `ReachedParam`; the walk +terminates at function parameters rather than crossing back into callers. +Path-constraint pruning is conservative: only the accumulated +`PredicateSummary` bits are consulted, not the full symbolic predicate stack. +Depth-bounded at k=2 for cross-function body expansion. See `DEFAULT_BACKWARDS_DEPTH`, `BACKWARDS_VALUE_BUDGET`, and `MAX_BACKWARDS_CALLEE_BLOCKS` in `src/taint/backwards.rs` for the exact bounds. diff --git a/docs/auth.md b/docs/auth.md index 7b86bc60..1de885fb 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -53,7 +53,7 @@ When a private helper is called only from authorized route handlers in the same - Iterated to a small fixpoint so transitive chains (route to mid_helper to leaf_helper) are covered. - Refuses to authorize helpers with no in-file caller, helpers called from a mix of authorized and unauthorized callers, and helpers called only from un-lifted helpers. -- Cross-file equivalent is deferred. +- Cross-file caller-scope lifting is not implemented yet. This closes the FastAPI / Django / Flask shape where a route authenticates via decorator or dependency, then delegates to a private helper that performs the sink. diff --git a/docs/language-maturity.md b/docs/language-maturity.md index 4a99fd75..22ffb447 100644 --- a/docs/language-maturity.md +++ b/docs/language-maturity.md @@ -138,8 +138,7 @@ use tree-sitter and are stable; parsing is not a differentiator. - **Framework context**: Rails helpers (`sanitize_sql`, `permit`, `require`). - **Known gaps**: string interpolation inside shell and SQL strings is recognized structurally but not modeled as a distinct operator. - `begin/rescue/ensure` exception-edge wiring is documented as deferred - (structurally incompatible with `build_try()`). + `begin/rescue/ensure` exception-edge wiring is not implemented. #### Rust: 100% P / 100% R / 100% F1 *(70-case adversarial corpus)* diff --git a/docs/serve.md b/docs/serve.md index 5207f0a4..758dcfe1 100644 --- a/docs/serve.md +++ b/docs/serve.md @@ -86,7 +86,7 @@ Modifiers in the ±5 range nudge the result for trend (only after the second sca It's a Nyx-finding-pressure metric, not a security audit. Score 100 means Nyx didn't find anything under its current rules and language coverage; it doesn't certify the absence of vulnerabilities. The score doesn't see runtime config, IAM, secret stores, dependency CVEs, or anything outside the source tree being scanned. A repo of mostly Kotlin (where Nyx coverage is thin) will score artificially well because most of the code never gets evaluated. -Ceilings are calibrated for the current scanner false-positive rates. As symex coverage and rule precision improve, the ceilings tighten. Calibration data and the rationale behind each tunable lives in [health-score-audit.md](health-score-audit.md). +Ceilings are calibrated for the current scanner false-positive rates. As symex coverage and rule precision improve, the ceilings may tighten. ### Findings and Finding detail diff --git a/src/dynamic/framework/adapters/header_go.rs b/src/dynamic/framework/adapters/header_go.rs index 874b25f5..1a0d530b 100644 --- a/src/dynamic/framework/adapters/header_go.rs +++ b/src/dynamic/framework/adapters/header_go.rs @@ -21,6 +21,28 @@ fn callee_is_header_setter(name: &str) -> bool { matches!(last, "Set" | "Add" | "Header" | "WriteHeader") } +/// True when `receiver` looks like a Go HTTP response-writer or framework +/// context expression. Filters out `url.Values.Set` / `sync.Map.Store` / +/// `flag.FlagSet.Set` and similar map-like receivers whose `Set` / `Add` +/// names collide with `http.Header.Set` / `Add`. +/// +/// Drilled forms (root_receiver_text reduces `w.Header().Set` to `w`): +/// * `w` / `rw` / `writer` — canonical `http.ResponseWriter` names +/// * `c` / `ctx` — gin / echo / fiber / chi context handles +/// * `resp` / `response` — common response-wrapper names +/// * `headers` — `Header` value handle +/// +/// Non-drilled forms (raw text when drilling fails): +/// * Any expression containing `.Header()` or `.Headers()` — +/// canonical chain accessor returning `http.Header`. +fn receiver_is_go_response_writer(receiver: &str) -> bool { + matches!( + receiver, + "w" | "rw" | "writer" | "c" | "ctx" | "resp" | "response" | "headers" | "header" + ) || receiver.contains(".Header()") + || receiver.contains(".Headers()") +} + fn source_imports_go_http(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"\"net/http\"", @@ -69,7 +91,11 @@ impl FrameworkAdapter for HeaderGoAdapter { if value_routed_through_encoder(file_bytes) { return None; } - let matches_call = super::any_callee_matches(summary, callee_is_header_setter); + let matches_call = super::any_callee_matches_with_receiver( + summary, + callee_is_header_setter, + receiver_is_go_response_writer, + ); let matches_source = source_imports_go_http(file_bytes); if matches_call && matches_source { Some(FrameworkBinding { @@ -125,6 +151,55 @@ mod tests { .is_none()); } + #[test] + fn skips_url_values_set_collision() { + // `params.Set(k, v)` on a `url.Values` collides with `http.Header.Set` + // on the bare callee name. Real CFG-derived callees carry the + // receiver text `params`, which is not in the response-writer + // allowlist, so the adapter rejects. Net/url is intentionally + // imported here to ensure the source-import gate alone would fire. + let src: &[u8] = b"package x\nimport (\"net/http\"; \"net/url\")\n\ + func Run(w http.ResponseWriter, v string) {\n\ + params := url.Values{}\n\ + params.Set(\"k\", v)\n\ + _ = params\n\ + }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Run".into(), + callees: vec![crate::summary::CalleeSite { + name: "Set".into(), + receiver: Some("params".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(HeaderGoAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn fires_on_response_writer_receiver() { + // Receiver-text discriminator accepts `w` (canonical + // `http.ResponseWriter` shorthand). + let src: &[u8] = b"package x\nimport \"net/http\"\n\ + func Run(w http.ResponseWriter, v string) { w.Header().Set(\"X\", v) }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Run".into(), + callees: vec![crate::summary::CalleeSite { + name: "Set".into(), + receiver: Some("w".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(HeaderGoAdapter + .detect(&summary, tree.root_node(), src) + .is_some()); + } + #[test] fn skips_when_value_url_encoded() { let src: &[u8] = b"package x\nimport (\"net/http\"; \"net/url\")\n\ diff --git a/src/dynamic/framework/adapters/header_ruby.rs b/src/dynamic/framework/adapters/header_ruby.rs index 54d3e4a6..879c193f 100644 --- a/src/dynamic/framework/adapters/header_ruby.rs +++ b/src/dynamic/framework/adapters/header_ruby.rs @@ -22,6 +22,23 @@ fn callee_is_header_setter(name: &str) -> bool { matches!(last, "set_header" | "[]=" | "store" | "add_header") } +/// True when `receiver` looks like a Ruby response or headers handle. +/// Filters out `Hash#[]=` / generic `Hash#store` collisions where the +/// receiver is an unrelated local (`h`, `params`, `attrs`, etc.). +/// +/// Drilled forms covered: +/// * `response` / `resp` / `res` — `Rack::Response` / Rails / Sinatra response +/// * `headers` — bare headers handle +/// * `@response` / `@headers` — instance-var equivalents +/// * Any expression containing `.headers` or `.response` (chain access). +fn receiver_is_ruby_response(receiver: &str) -> bool { + matches!( + receiver, + "response" | "resp" | "res" | "headers" | "@response" | "@headers" + ) || receiver.contains(".headers") + || receiver.contains(".response") +} + fn source_uses_ruby_web(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"Rack::Response", @@ -73,7 +90,11 @@ impl FrameworkAdapter for HeaderRubyAdapter { if value_routed_through_encoder(file_bytes) { return None; } - let matches_call = super::any_callee_matches(summary, callee_is_header_setter); + let matches_call = super::any_callee_matches_with_receiver( + summary, + callee_is_header_setter, + receiver_is_ruby_response, + ); let matches_source = source_uses_ruby_web(file_bytes); if matches_call && matches_source { Some(FrameworkBinding { @@ -129,6 +150,49 @@ mod tests { .is_none()); } + #[test] + fn skips_hash_subscript_assign_collision() { + // `h['Set-Cookie'] = value` on a plain `Hash` collides with + // `response['Set-Cookie'] = value` on the bare `[]=` callee + // name. Receiver text `h` is not in the response allowlist, + // so the adapter rejects. + let src: &[u8] = b"require 'rack'\n\ + def run(value)\n h = {}\n h['Set-Cookie'] = value\n h\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite { + name: "[]=".into(), + receiver: Some("h".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(HeaderRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn fires_on_response_receiver() { + // Receiver `response` is in the allowlist. + let src: &[u8] = b"require 'rack'\n\ + def run(value)\n response = Rack::Response.new\n response['Set-Cookie'] = value\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite { + name: "[]=".into(), + receiver: Some("response".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(HeaderRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_some()); + } + #[test] fn skips_when_value_url_encoded() { let src: &[u8] = b"require 'rack'\nrequire 'uri'\n\ diff --git a/src/dynamic/framework/adapters/header_rust.rs b/src/dynamic/framework/adapters/header_rust.rs index d7d21511..dae818d4 100644 --- a/src/dynamic/framework/adapters/header_rust.rs +++ b/src/dynamic/framework/adapters/header_rust.rs @@ -23,6 +23,25 @@ fn callee_is_header_setter(name: &str) -> bool { matches!(last, "insert" | "append" | "insert_header" | "header") } +/// True when `receiver` looks like a Rust `HeaderMap` / response handle. +/// Filters out `BTreeMap::insert` / `HashMap::insert` / `Vec::insert` +/// collisions where the receiver is an unrelated local (`map`, `cache`, +/// `entries`, etc.). +/// +/// Drilled forms covered: +/// * `headers` / `headers_mut` — canonical `axum` / `hyper` handles +/// * `response` / `resp` / `res` — `actix_web::HttpResponse` / hyper builder +/// * `builder` — `axum::http::Response::builder()` chain root +/// * Any expression containing `.headers_mut()` or `.headers()` — +/// chain accessor returning `&mut HeaderMap` / `&HeaderMap`. +fn receiver_is_rust_header_map(receiver: &str) -> bool { + matches!( + receiver, + "headers" | "headers_mut" | "response" | "resp" | "res" | "builder" + ) || receiver.contains(".headers_mut()") + || receiver.contains(".headers()") +} + fn source_imports_rust_http(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"use http::HeaderMap", @@ -71,7 +90,11 @@ impl FrameworkAdapter for HeaderRustAdapter { if value_routed_through_encoder(file_bytes) { return None; } - let matches_call = super::any_callee_matches(summary, callee_is_header_setter); + let matches_call = super::any_callee_matches_with_receiver( + summary, + callee_is_header_setter, + receiver_is_rust_header_map, + ); let matches_source = source_imports_rust_http(file_bytes); if matches_call && matches_source { Some(FrameworkBinding { @@ -127,6 +150,54 @@ mod tests { .is_none()); } + #[test] + fn skips_btreemap_insert_collision() { + // `map.insert(k, v)` on a `BTreeMap` / `HashMap` collides with + // `headers.insert(k, v)` on `HeaderMap` at the bare callee name. + // Receiver text `map` is not in the HeaderMap allowlist, so the + // adapter rejects. `headers_mut()` substring is present in the + // file so source-import gate alone would fire. + let src: &[u8] = b"use std::collections::BTreeMap;\nuse axum::http::HeaderMap;\n\ + fn run(headers: &mut HeaderMap, value: String) {\n\ + let mut map: BTreeMap = BTreeMap::new();\n\ + map.insert(\"k\".into(), value);\n\ + let _ = headers.headers_mut();\n\ + }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite { + name: "insert".into(), + receiver: Some("map".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(HeaderRustAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn fires_on_headers_receiver() { + // Receiver `headers` is in the HeaderMap allowlist. + let src: &[u8] = b"use axum::http::HeaderMap;\n\ + fn run(headers: &mut HeaderMap, value: &str) { headers.insert(\"X\", value.parse().unwrap()); }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite { + name: "insert".into(), + receiver: Some("headers".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(HeaderRustAdapter + .detect(&summary, tree.root_node(), src) + .is_some()); + } + #[test] fn skips_when_value_url_encoded() { let src: &[u8] = b"use axum::http::HeaderMap;\n\ diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 013fb93c..de81d408 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -227,6 +227,36 @@ fn any_callee_matches( .any(|c| predicate(c.name.as_str())) } +/// True when any callee in `summary.callees` matches `name_pred` AND +/// (its receiver matches `receiver_pred` OR its receiver is `None`). +/// +/// Used by adapters where the callee name is ambiguous (e.g. Go's bare +/// `Set` / `Add` collides with `url.Values.Set`, Rust's `insert` collides +/// with `BTreeMap::insert`) and the receiver text provides the only +/// non-type-aware discriminator. +/// +/// Receivers of `None` fall through to acceptance to preserve backward +/// compatibility with synthetic unit-test summaries built via +/// `CalleeSite::bare(...)` and with adapters whose callees are free +/// functions (no receiver). Real CFG-derived callees populate +/// `CalleeSite.receiver` whenever the call is a method invocation, so +/// the gate engages on production scans. +fn any_callee_matches_with_receiver( + summary: &crate::summary::FuncSummary, + name_pred: impl Fn(&str) -> bool, + receiver_pred: impl Fn(&str) -> bool, +) -> bool { + summary.callees.iter().any(|c| { + if !name_pred(c.name.as_str()) { + return false; + } + match c.receiver.as_deref() { + Some(r) => receiver_pred(r), + None => true, + } + }) +} + /// True when `arg_text` resolves to a function parameter whose 0-based /// index participates in taint flow — either listed in /// `summary.tainted_sink_params` (param reaches an internal sink) or diff --git a/src/dynamic/framework/adapters/pp_lodash_merge.rs b/src/dynamic/framework/adapters/pp_lodash_merge.rs index 8b89ccdd..095f4c4e 100644 --- a/src/dynamic/framework/adapters/pp_lodash_merge.rs +++ b/src/dynamic/framework/adapters/pp_lodash_merge.rs @@ -16,6 +16,19 @@ fn callee_is_lodash_merge(name: &str) -> bool { matches!(last, "merge" | "mergeWith" | "defaultsDeep" | "set" | "setWith") } +/// True when `receiver` looks like a lodash module handle (`_`, `lodash`, +/// or any expression where lodash sits to the left of the dot). +/// +/// Filters out `state.set(k, v)` on `Map`, `cache.set(k, v)` on `LRU`, +/// `tokens.merge(...)` on a user class, and similar same-name collisions +/// outside lodash scope. Receivers of `None` (bare callees like +/// `set(state, key, value)` from `const { set } = require('lodash')` +/// or unit-test `CalleeSite::bare`) pass through to preserve the +/// standalone-import path. +fn receiver_is_lodash(receiver: &str) -> bool { + matches!(receiver, "_" | "lodash" | "lodashImport") || receiver.starts_with("_.") +} + fn source_imports_lodash(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"require('lodash')", @@ -68,7 +81,11 @@ impl FrameworkAdapter for PpLodashMergeJsAdapter { if super::source_filters_proto_keys(file_bytes) { return None; } - let matches_call = super::any_callee_matches(summary, callee_is_lodash_merge); + let matches_call = super::any_callee_matches_with_receiver( + summary, + callee_is_lodash_merge, + receiver_is_lodash, + ); let matches_source = source_imports_lodash(file_bytes); if matches_call && matches_source { Some(build_binding(JS_ADAPTER_NAME)) @@ -100,7 +117,11 @@ impl FrameworkAdapter for PpLodashMergeTsAdapter { if super::source_filters_proto_keys(file_bytes) { return None; } - let matches_call = super::any_callee_matches(summary, callee_is_lodash_merge); + let matches_call = super::any_callee_matches_with_receiver( + summary, + callee_is_lodash_merge, + receiver_is_lodash, + ); let matches_source = source_imports_lodash(file_bytes); if matches_call && matches_source { Some(build_binding(TS_ADAPTER_NAME)) @@ -149,6 +170,54 @@ mod tests { .is_none()); } + #[test] + fn skips_map_set_collision() { + // `state.set(k, v)` on a Map collides with `_.set(state, k, v)` + // on the bare callee name. Receiver text `state` is not in the + // lodash allowlist, so the adapter rejects. The lodash import + // is intentionally present to ensure the source-import gate + // alone would have fired. + let src: &[u8] = b"const _ = require('lodash');\n\ + function run(payload) {\n\ + const state = new Map();\n\ + state.set('key', payload);\n\ + return state;\n\ + }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite { + name: "set".into(), + receiver: Some("state".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(PpLodashMergeJsAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn fires_on_underscore_receiver() { + // Receiver `_` is the canonical lodash binding. + let src: &[u8] = b"const _ = require('lodash');\n\ + function run(payload) { return _.merge({}, payload); }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite { + name: "merge".into(), + receiver: Some("_".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(PpLodashMergeJsAdapter + .detect(&summary, tree.root_node(), src) + .is_some()); + } + #[test] fn skips_when_proto_key_filter_present() { let src: &[u8] = b"const _ = require('lodash');\n\ diff --git a/src/dynamic/repro.rs b/src/dynamic/repro.rs index d43aca3c..0e4192e0 100644 --- a/src/dynamic/repro.rs +++ b/src/dynamic/repro.rs @@ -537,7 +537,7 @@ fn build_toolchain_lock(spec: &HarnessSpec, root: &Path) -> Result) -> Json { fixed_since_last, reintroduced: reintroduced_count, // Files-scanned proxy for repo size, used for size-aware - // severity dampening in `health::compute`. See - // `docs/health-score-audit.md` for calibration data. + // severity dampening in `health::compute`. repo_files: scanner_quality .as_ref() .map(|q| q.files_scanned) @@ -1128,7 +1127,4 @@ fn plural(n: usize) -> &'static str { if n == 1 { "" } else { "s" } } -// `compute_health_score` moved to `crate::server::health::compute` -// after the v2 audit (2026-04-28). See `docs/health-score-audit.md` -// for calibration data and the rationale, and `docs/health-score.md` -// for the customer-facing methodology. +// `compute_health_score` moved to `crate::server::health::compute`. diff --git a/tests/dynamic_layering.rs b/tests/dynamic_layering.rs index 6bbb476f..33453d28 100644 --- a/tests/dynamic_layering.rs +++ b/tests/dynamic_layering.rs @@ -14,10 +14,10 @@ //! | `src/main.rs` | binary entry point; wires --features dynamic| //! | `src/lib.rs` | crate root; `#[cfg(feature="dynamic")]` mod| //! | `src/commands/scan.rs` | enrichment loop lives here | -//! | `src/commands/mod.rs` | `verify-feedback` subcommand (§21.2) | +//! | `src/commands/mod.rs` | `verify-feedback` subcommand | //! | `src/server/` (any file) | server start_scan verify wiring | -//! | `src/rank.rs` | M7 rank-delta telemetry hook (§21 / M7) | -//! | `src/chain/reverify.rs` | Phase 26 — composite chain re-verification | +//! | `src/rank.rs` | dynamic-verdict rank scoring | +//! | `src/chain/reverify.rs` | composite chain re-verification | use std::fs; use std::path::{Path, PathBuf}; @@ -31,8 +31,8 @@ const ALLOWED: &[&str] = &[ "commands/mod.rs", "server/", "rank.rs", - // Phase 26 — Track G.3: composite chain re-verification is the - // public bridge between the chain composer and the dynamic verifier. + // Composite chain re-verification is the public bridge between the chain + // composer and the dynamic verifier. "chain/reverify.rs", // The dynamic module itself is obviously allowed. "dynamic/", diff --git a/tests/eval_corpus/run.sh b/tests/eval_corpus/run.sh index 9290092a..0407b8ba 100755 --- a/tests/eval_corpus/run.sh +++ b/tests/eval_corpus/run.sh @@ -24,12 +24,12 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -# ── Defaults ────────────────────────────────────────────────────────────────── +# Defaults OUTPUT_DIR="" NYX_BIN="${NYX_BIN:-${REPO_ROOT}/target/release/nyx}" CORPUS_CACHE="${NYX_EVAL_CORPUS_DIR:-${HOME}/.cache/nyx/eval_corpus}" SETS="owasp,sard,inhouse" -# Phase 29 (Track I): per-cell budgets + monotonic-improvement diff. +# Optional per-cell budgets and monotonic-improvement diff. BUDGET_FILE="" DIFF_FILE="" diff --git a/tests/health_score_calibration.rs b/tests/health_score_calibration.rs index e3a8a319..4e212416 100644 --- a/tests/health_score_calibration.rs +++ b/tests/health_score_calibration.rs @@ -1,9 +1,8 @@ //! Health-score calibration regression net (v3.5). //! -//! Pins synthetic reference scenarios catalogued in -//! `docs/health-score-audit.md` to expected score bands. When a -//! constant or weight in `src/server/health.rs` changes, this test -//! fails fast if the change silently re-grades the boundary cases. +//! Pins synthetic reference scenarios to expected score bands. When a constant +//! or weight in `src/server/health.rs` changes, this test fails fast if the +//! change silently re-grades the boundary cases. //! //! Bands are deliberately wide (±5 points around the calibration //! number) so honest curve-shape adjustments don't trip the test , @@ -142,7 +141,7 @@ fn sev(h: &HealthScore) -> u8 { .score } -// ── Calibration cases (synthetic, mirror docs/health-score-audit.md) ───────── +// Calibration cases #[test] fn calibration_clean_first_scan() {