[pitboss] phase 10: Track J.8 + Track L.8 — PROTOTYPE_POLLUTION corpus + JS/TS prototype chain hook

This commit is contained in:
pitboss 2026-05-18 08:02:10 -05:00
parent 97e4dfff30
commit d8f88d97bb
20 changed files with 1406 additions and 22 deletions

View file

@ -26,6 +26,9 @@ pub mod ldap_python;
pub mod ldap_spring;
pub mod php_twig;
pub mod php_unserialize;
pub mod pp_json_deep_assign;
pub mod pp_lodash_merge;
pub mod pp_object_assign;
pub mod python_jinja2;
pub mod python_pickle;
pub mod redirect_go;
@ -62,6 +65,9 @@ pub use ldap_python::LdapPythonAdapter;
pub use ldap_spring::LdapSpringAdapter;
pub use php_twig::PhpTwigAdapter;
pub use php_unserialize::PhpUnserializeAdapter;
pub use pp_json_deep_assign::{PpJsonDeepAssignJsAdapter, PpJsonDeepAssignTsAdapter};
pub use pp_lodash_merge::{PpLodashMergeJsAdapter, PpLodashMergeTsAdapter};
pub use pp_object_assign::{PpObjectAssignJsAdapter, PpObjectAssignTsAdapter};
pub use python_jinja2::PythonJinja2Adapter;
pub use python_pickle::PythonPickleAdapter;
pub use redirect_go::RedirectGoAdapter;

View file

@ -0,0 +1,156 @@
//! JavaScript / TypeScript [`super::super::FrameworkAdapter`] matching
//! the `JSON.parse`-followed-by-deep-assign prototype-pollution
//! gadget: the host parses an attacker-controlled JSON string and
//! then walks the resulting object into a vanilla target through a
//! hand-rolled recursive merge.
//!
//! Phase 10 (Track J.8). Fires when the function body invokes
//! `JSON.parse` and the surrounding source carries a recursive merge
//! helper (literal `function merge`, `function deepAssign`,
//! `function extend`, etc.) — the static-side signal that an
//! attacker-controlled JSON tree can reach `Object.prototype`.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
fn callee_is_json_parse(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "parse")
}
fn source_has_deep_merge_helper(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"function deepMerge",
b"function deepAssign",
b"function extend",
b"function merge",
b"function setByPath",
b"deepMerge =",
b"deepAssign =",
b"JSON.parse",
];
let mut json_parse = false;
let mut deep_merge = false;
for n in NEEDLES {
if file_bytes.windows(n.len()).any(|w| w == *n) {
if *n == b"JSON.parse" {
json_parse = true;
} else {
deep_merge = true;
}
}
}
json_parse && deep_merge
}
fn build_binding(adapter_name: &'static str) -> FrameworkBinding {
FrameworkBinding {
adapter: adapter_name.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
}
}
pub struct PpJsonDeepAssignJsAdapter;
const JS_ADAPTER_NAME: &str = "pp-json-deep-assign-js";
impl FrameworkAdapter for PpJsonDeepAssignJsAdapter {
fn name(&self) -> &'static str {
JS_ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::JavaScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_json_parse);
let matches_source = source_has_deep_merge_helper(file_bytes);
if matches_call && matches_source {
Some(build_binding(JS_ADAPTER_NAME))
} else {
None
}
}
}
pub struct PpJsonDeepAssignTsAdapter;
const TS_ADAPTER_NAME: &str = "pp-json-deep-assign-ts";
impl FrameworkAdapter for PpJsonDeepAssignTsAdapter {
fn name(&self) -> &'static str {
TS_ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::TypeScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_json_parse);
let matches_source = source_has_deep_merge_helper(file_bytes);
if matches_call && matches_source {
Some(build_binding(TS_ADAPTER_NAME))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_json_parse_with_deep_merge() {
let src: &[u8] = b"function deepMerge(t, s) { for (const k of Object.keys(s)) t[k] = s[k]; return t; }\n\
function run(payload) { return deepMerge({}, JSON.parse(payload)); }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("JSON.parse")],
..Default::default()
};
assert!(PpJsonDeepAssignJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_json_parse_without_merge() {
let src: &[u8] = b"function run(payload) { return JSON.parse(payload); }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("JSON.parse")],
..Default::default()
};
assert!(PpJsonDeepAssignJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,145 @@
//! JavaScript / TypeScript [`super::super::FrameworkAdapter`] matching
//! `lodash.merge` (and the equivalent `lodash.defaultsDeep`,
//! `lodash.set`) prototype-pollution sinks.
//!
//! Phase 10 (Track J.8). Fires when the function body invokes one of
//! the canonical lodash deep-merge entry points and the surrounding
//! source imports lodash.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
fn callee_is_lodash_merge(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "merge" | "mergeWith" | "defaultsDeep" | "set" | "setWith")
}
fn source_imports_lodash(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"require('lodash')",
b"require(\"lodash\")",
b"require('lodash.merge')",
b"require(\"lodash.merge\")",
b"from 'lodash'",
b"from \"lodash\"",
b"from 'lodash/merge'",
b"from \"lodash/merge\"",
b"_.merge",
b"_.defaultsDeep",
b"_.set",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn build_binding(adapter_name: &'static str) -> FrameworkBinding {
FrameworkBinding {
adapter: adapter_name.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
}
}
pub struct PpLodashMergeJsAdapter;
const JS_ADAPTER_NAME: &str = "pp-lodash-merge-js";
impl FrameworkAdapter for PpLodashMergeJsAdapter {
fn name(&self) -> &'static str {
JS_ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::JavaScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_lodash_merge);
let matches_source = source_imports_lodash(file_bytes);
if matches_call && matches_source {
Some(build_binding(JS_ADAPTER_NAME))
} else {
None
}
}
}
pub struct PpLodashMergeTsAdapter;
const TS_ADAPTER_NAME: &str = "pp-lodash-merge-ts";
impl FrameworkAdapter for PpLodashMergeTsAdapter {
fn name(&self) -> &'static str {
TS_ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::TypeScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_lodash_merge);
let matches_source = source_imports_lodash(file_bytes);
if matches_call && matches_source {
Some(build_binding(TS_ADAPTER_NAME))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_lodash_merge_call() {
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::bare("merge")],
..Default::default()
};
assert!(PpLodashMergeJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_function_without_lodash_import() {
let src: &[u8] = b"function add(a, b) { return a + b; }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(PpLodashMergeJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,136 @@
//! JavaScript / TypeScript [`super::super::FrameworkAdapter`] matching
//! `Object.assign` invocations with attacker-controlled RHS — the
//! shallowest prototype-pollution gadget. Fires on bare
//! `Object.assign(target, src)` plus the spread form (`{ ...src }`
//! desugars to `Object.assign({}, src)`).
//!
//! Phase 10 (Track J.8).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
fn callee_is_object_assign(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "assign" | "create")
&& (name == "Object.assign" || name == "Object.create" || name == "assign" || name == "create")
}
fn source_uses_object_assign(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"Object.assign",
b"Object.create",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn build_binding(adapter_name: &'static str) -> FrameworkBinding {
FrameworkBinding {
adapter: adapter_name.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
}
}
pub struct PpObjectAssignJsAdapter;
const JS_ADAPTER_NAME: &str = "pp-object-assign-js";
impl FrameworkAdapter for PpObjectAssignJsAdapter {
fn name(&self) -> &'static str {
JS_ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::JavaScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_object_assign);
let matches_source = source_uses_object_assign(file_bytes);
if matches_call && matches_source {
Some(build_binding(JS_ADAPTER_NAME))
} else {
None
}
}
}
pub struct PpObjectAssignTsAdapter;
const TS_ADAPTER_NAME: &str = "pp-object-assign-ts";
impl FrameworkAdapter for PpObjectAssignTsAdapter {
fn name(&self) -> &'static str {
TS_ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::TypeScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_object_assign);
let matches_source = source_uses_object_assign(file_bytes);
if matches_call && matches_source {
Some(build_binding(TS_ADAPTER_NAME))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_object_assign_call() {
let src: &[u8] = b"function run(payload) { return Object.assign({}, payload); }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("Object.assign")],
..Default::default()
};
assert!(PpObjectAssignJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_unrelated_assign() {
let src: &[u8] = b"function add(a, b) { return a + b; }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(PpObjectAssignJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -214,14 +214,14 @@ mod tests {
}
#[test]
fn registry_baseline_after_phase_09() {
// Phase 09 (Track J.7) adds the open-redirect adapter for
// every language carrying the OPEN_REDIRECT corpus: Java /
// Python / PHP / Ruby / JavaScript / Go / Rust. Java /
// Python / PHP each grow from 6 → 7; Ruby from 4 → 5;
// JavaScript from 3 → 4; Go from 2 → 3; Rust from 1 → 2.
// C / Cpp / TypeScript still carry the Phase-01 empty
// baseline.
fn registry_baseline_after_phase_10() {
// Phase 10 (Track J.8) adds three prototype-pollution
// adapters (`pp-lodash-merge`, `pp-object-assign`,
// `pp-json-deep-assign`) to both the JavaScript and
// TypeScript slices. Java / Python / PHP each still carry
// the J.1..J.7 adapters (7 entries); Ruby still has 5; Go
// still has 3; Rust still has 2. JavaScript grows from 4 →
// 7; TypeScript grows from 0 → 3. C / Cpp stay empty.
for lang in [Lang::Java, Lang::Python, Lang::Php] {
let registered = registry::adapters_for(lang);
assert_eq!(
@ -246,12 +246,21 @@ mod tests {
let js_registered = registry::adapters_for(Lang::JavaScript);
assert_eq!(
js_registered.len(),
4,
"JavaScript must have J.2 + J.5 + J.6 + J.7 adapters",
7,
"JavaScript must have J.2 + J.5 + J.6 + J.7 + J.8(×3) adapters",
);
for adapter in js_registered {
assert_eq!(adapter.lang(), Lang::JavaScript);
}
let ts_registered = registry::adapters_for(Lang::TypeScript);
assert_eq!(
ts_registered.len(),
3,
"TypeScript must have the J.8(×3) prototype-pollution adapters",
);
for adapter in ts_registered {
assert_eq!(adapter.lang(), Lang::TypeScript);
}
let go_registered = registry::adapters_for(Lang::Go);
assert_eq!(
go_registered.len(),
@ -270,7 +279,7 @@ mod tests {
for adapter in rust_registered {
assert_eq!(adapter.lang(), Lang::Rust);
}
for lang in [Lang::C, Lang::Cpp, Lang::TypeScript] {
for lang in [Lang::C, Lang::Cpp] {
assert!(
registry::adapters_for(lang).is_empty(),
"{:?} should still have zero adapters before its Track-L phase",

View file

@ -89,10 +89,17 @@ static RUBY: &[&dyn FrameworkAdapter] = &[
&super::adapters::RubyMarshalAdapter,
&super::adapters::XxeRubyAdapter,
];
static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[];
static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[
&super::adapters::PpJsonDeepAssignTsAdapter,
&super::adapters::PpLodashMergeTsAdapter,
&super::adapters::PpObjectAssignTsAdapter,
];
static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderJsAdapter,
&super::adapters::JsHandlebarsAdapter,
&super::adapters::PpJsonDeepAssignJsAdapter,
&super::adapters::PpLodashMergeJsAdapter,
&super::adapters::PpObjectAssignJsAdapter,
&super::adapters::RedirectJsAdapter,
&super::adapters::XpathJsAdapter,
];