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
|
|
@ -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\
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue