mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0003 (20260521T143544Z-f898)
This commit is contained in:
parent
b3766311fb
commit
6341afec59
16 changed files with 346 additions and 48 deletions
|
|
@ -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 (`<router> = X(deps=[…])`) and `<parent>.include_router(<child_module>.<child_var>)` 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 `@<router>.<verb>(...)` 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*`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)*
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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\
|
||||
|
|
|
|||
|
|
@ -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\
|
||||
|
|
|
|||
|
|
@ -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<String, String> = 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\
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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\
|
||||
|
|
|
|||
|
|
@ -537,7 +537,7 @@ fn build_toolchain_lock(spec: &HarnessSpec, root: &Path) -> Result<serde_json::V
|
|||
}))
|
||||
}
|
||||
|
||||
/// Phase 28 — Track H.3. Outcome of [`replay_bundle`].
|
||||
/// Outcome of [`replay_bundle`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReplayResult {
|
||||
/// `reproduce.sh` exited 0 — replay matched the recorded outcome.
|
||||
|
|
@ -548,14 +548,13 @@ pub enum ReplayResult {
|
|||
DockerUnavailable,
|
||||
/// `reproduce.sh` exited 3 — host toolchain mismatched in process mode.
|
||||
ToolchainMismatch,
|
||||
/// Any other non-zero exit code, treated as an unexpected error. The
|
||||
/// Phase 28 m7 Gate 5 inversion treats this as instability.
|
||||
/// Any other non-zero exit code, treated as an unexpected error.
|
||||
UnexpectedError {
|
||||
/// Exit code surfaced by the script.
|
||||
exit_code: i32,
|
||||
},
|
||||
/// `reproduce.sh` could not be invoked at all (script missing,
|
||||
/// permissions, etc.). Phase 28 Gate 5 treats this as instability.
|
||||
/// permissions, etc.).
|
||||
ScriptInvocationFailed {
|
||||
/// Human-readable error.
|
||||
message: String,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
//! Health-score scoring engine, v3.5.
|
||||
//!
|
||||
//! Pure-function scoring over a `HealthInputs` struct. Documented in
|
||||
//! `docs/health-score-audit.md` (calibration, rationale) and
|
||||
//! `docs/health-score.md` (customer methodology).
|
||||
//! Pure-function scoring over a `HealthInputs` struct.
|
||||
//!
|
||||
//! ## Conceptual model
|
||||
//!
|
||||
|
|
@ -37,8 +35,8 @@
|
|||
//! low-confidence HIGHs that got `NotAttempted` from symex doesn't
|
||||
//! pay the same ceiling cost as a repo with 5 `Confirmed` HIGHs.
|
||||
//! * Tighter modifier ranges so they can't flip a band.
|
||||
//! * No `parse_success_rate` (it's actually a cache-miss metric ,
|
||||
//! see `project_parse_success_rate_misnomer.md`).
|
||||
//! * No `parse_success_rate`. It is a cache-miss metric, not a parse
|
||||
//! success metric.
|
||||
|
||||
use crate::commands::scan::Diag;
|
||||
use crate::evidence::{Confidence, Verdict};
|
||||
|
|
@ -47,10 +45,8 @@ use crate::server::models::{BacklogStats, FindingSummary, HealthComponent, Healt
|
|||
|
||||
// ── Tunables ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Calibrated for v0.5.0 scanner FP rate. As Nyx symex coverage and
|
||||
// rule precision improve, the HIGH ceilings should tighten, see
|
||||
// `docs/health-score-audit.md` "Calibration trajectory" for the
|
||||
// roadmap.
|
||||
// Calibrated for the current scanner false-positive rate. As Nyx symex
|
||||
// coverage and rule precision improve, the HIGH ceilings may tighten.
|
||||
|
||||
/// Below this file count, we floor the size divisor at 1.0, tiny
|
||||
/// repos can't claim infinite per-LOC dilution from one finding.
|
||||
|
|
|
|||
|
|
@ -122,8 +122,7 @@ async fn overview(State(state): State<AppState>) -> Json<OverviewResponse> {
|
|||
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`.
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue