mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-24 20:28:06 +02:00
Dynamic (#77)
This commit is contained in:
parent
55247b7fcd
commit
991c84a1eb
1464 changed files with 225448 additions and 1985 deletions
113
src/dynamic/build_pool/c.rs
Normal file
113
src/dynamic/build_pool/c.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//! C build pool (Phase 23 / Track O.1).
|
||||
//!
|
||||
//! Wraps the C compiler in `ccache` (when present) backed by a shared object
|
||||
//! cache under the pool cache root, so a finding that recompiles a harness
|
||||
//! whose `main.c` matches a previously-built one gets a cache hit instead of a
|
||||
//! cold `cc` invocation.
|
||||
//!
|
||||
//! `ccache` degrades gracefully: when it is not on `PATH` the pool runs the
|
||||
//! bare compiler, byte-for-byte the same `cc` invocation the legacy
|
||||
//! [`crate::dynamic::build_sandbox::prepare_c`] path uses, so success / failure
|
||||
//! parity holds. The static-link fallback (drop `-static` and retry) mirrors
|
||||
//! the legacy `run_cc` behaviour for chroot-bound Strict-profile harnesses.
|
||||
|
||||
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct CPool {
|
||||
cc_bin: String,
|
||||
ccache_bin: Option<String>,
|
||||
}
|
||||
|
||||
impl CPool {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
let cc_bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned());
|
||||
if !binary_runnable(&cc_bin, "--version") {
|
||||
return Err(format!("c-pool: {cc_bin} not runnable"));
|
||||
}
|
||||
Ok(CPool {
|
||||
cc_bin,
|
||||
ccache_bin: super::detect_ccache(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildPool for CPool {
|
||||
fn name(&self) -> &'static str {
|
||||
"c"
|
||||
}
|
||||
|
||||
/// `args[0]` = binary destination, `args[1]` = `"static"` or `"dynamic"`.
|
||||
fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
let dest = match args.first() {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "c-pool: missing binary destination arg".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let static_link = args.get(1).map(|s| s == "static").unwrap_or(false);
|
||||
|
||||
if static_link {
|
||||
match self.run(workdir, &dest, &["-static", "-O0", "-g"]) {
|
||||
Ok(()) => {
|
||||
return PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
Err(stderr) => {
|
||||
unsafe { std::env::set_var("NYX_BUILD_STATIC_FALLBACK", "1") };
|
||||
eprintln!("nyx: c-pool cc -static failed, retrying without -static: {stderr}");
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.run(workdir, &dest, &["-O0", "-g"]) {
|
||||
Ok(()) => PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Err(stderr) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr,
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
binary_runnable(&self.cc_bin, "--version")
|
||||
}
|
||||
}
|
||||
|
||||
impl CPool {
|
||||
/// Run one compile of `main.c`, optionally fronted by `ccache`.
|
||||
fn run(&self, workdir: &Path, dest: &str, leading_flags: &[&str]) -> Result<(), String> {
|
||||
let mut cmd = match (&self.ccache_bin, pool_cache_dir("c", "ccache")) {
|
||||
(Some(ccache), Some(cache_dir)) => {
|
||||
let mut c = base_command(ccache);
|
||||
c.arg(&self.cc_bin).env("CCACHE_DIR", cache_dir);
|
||||
c
|
||||
}
|
||||
_ => base_command(&self.cc_bin),
|
||||
};
|
||||
cmd.args(leading_flags)
|
||||
.args(["-o", dest, "main.c"])
|
||||
.current_dir(workdir);
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("c-pool: cc: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
83
src/dynamic/build_pool/cpp.rs
Normal file
83
src/dynamic/build_pool/cpp.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! C++ build pool (Phase 23 / Track O.1).
|
||||
//!
|
||||
//! Same shape as the C pool: front the C++ driver with `ccache` backed by a
|
||||
//! shared object cache under the pool cache root. Falls back to a bare
|
||||
//! `c++ -std=c++17` compile — byte-for-byte the legacy
|
||||
//! [`crate::dynamic::build_sandbox::prepare_cpp`] invocation — when `ccache` is
|
||||
//! absent.
|
||||
|
||||
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct CppPool {
|
||||
cxx_bin: String,
|
||||
ccache_bin: Option<String>,
|
||||
}
|
||||
|
||||
impl CppPool {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
let cxx_bin = std::env::var("NYX_CXX_BIN").unwrap_or_else(|_| "c++".to_owned());
|
||||
if !binary_runnable(&cxx_bin, "--version") {
|
||||
return Err(format!("cpp-pool: {cxx_bin} not runnable"));
|
||||
}
|
||||
Ok(CppPool {
|
||||
cxx_bin,
|
||||
ccache_bin: super::detect_ccache(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildPool for CppPool {
|
||||
fn name(&self) -> &'static str {
|
||||
"cpp"
|
||||
}
|
||||
|
||||
/// `args[0]` = absolute path the compiled `nyx_harness` binary lands at.
|
||||
fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
let dest = match args.first() {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "cpp-pool: missing binary destination arg".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let mut cmd = match (&self.ccache_bin, pool_cache_dir("cpp", "ccache")) {
|
||||
(Some(ccache), Some(cache_dir)) => {
|
||||
let mut c = base_command(ccache);
|
||||
c.arg(&self.cxx_bin).env("CCACHE_DIR", cache_dir);
|
||||
c
|
||||
}
|
||||
_ => base_command(&self.cxx_bin),
|
||||
};
|
||||
cmd.args(["-O0", "-g", "-std=c++17", "-o", &dest, "main.cpp"])
|
||||
.current_dir(workdir);
|
||||
|
||||
match cmd.output() {
|
||||
Ok(o) if o.status.success() => PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Ok(o) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Err(e) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("cpp-pool: c++: {e}"),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
binary_runnable(&self.cxx_bin, "--version")
|
||||
}
|
||||
}
|
||||
140
src/dynamic/build_pool/go.rs
Normal file
140
src/dynamic/build_pool/go.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
//! Go build pool (Phase 23 / Track O.1).
|
||||
//!
|
||||
//! The legacy [`crate::dynamic::build_sandbox::prepare_go`] gives each finding
|
||||
//! its own `GOCACHE`/`GOMODCACHE` (default: a per-workdir `.gocache`), so the
|
||||
//! Go toolchain recompiles the standard library and every module from cold on
|
||||
//! every harness.
|
||||
//!
|
||||
//! [`GoPool`] mounts one shared `GOCACHE` + `GOMODCACHE` under the pool cache
|
||||
//! root so compiled std-lib + module artefacts are reused across findings, and
|
||||
//! builds with `-trimpath -buildvcs=false` so the output is reproducible (no
|
||||
//! absolute workdir paths or VCS stamping baked in, which otherwise defeats the
|
||||
//! build cache's keying).
|
||||
|
||||
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct GoPool {
|
||||
go_bin: String,
|
||||
}
|
||||
|
||||
impl GoPool {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
let go_bin = std::env::var("NYX_GO_BIN").unwrap_or_else(|_| "go".to_owned());
|
||||
if !binary_runnable(&go_bin, "version") {
|
||||
return Err(format!("go-pool: {go_bin} not runnable"));
|
||||
}
|
||||
Ok(GoPool { go_bin })
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildPool for GoPool {
|
||||
fn name(&self) -> &'static str {
|
||||
"go"
|
||||
}
|
||||
|
||||
/// `args[0]` = absolute path the compiled `nyx_harness` binary must land
|
||||
/// at.
|
||||
fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
let dest = match args.first() {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "go-pool: missing binary destination arg".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let go_cache = match pool_cache_dir("go", "cache") {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "go-pool: no shared GOCACHE".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let go_mod_cache = match pool_cache_dir("go", "modcache") {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "go-pool: no shared GOMODCACHE".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let go_path = std::env::var("GOPATH").unwrap_or_else(|_| {
|
||||
std::env::var("HOME")
|
||||
.map(|h| format!("{h}/go"))
|
||||
.unwrap_or_else(|_| "/tmp/go".to_owned())
|
||||
});
|
||||
|
||||
// `go mod tidy` resolves imports into the shared module cache.
|
||||
if workdir.join("go.mod").exists() {
|
||||
let tidy = base_command(&self.go_bin)
|
||||
.args(["mod", "tidy"])
|
||||
.current_dir(workdir)
|
||||
.env("GOCACHE", &go_cache)
|
||||
.env("GOPATH", &go_path)
|
||||
.env("GOMODCACHE", &go_mod_cache)
|
||||
.output();
|
||||
match tidy {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => {
|
||||
let mut msg = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
if msg.is_empty() {
|
||||
msg = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
}
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("go mod tidy failed: {msg}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("go-pool: go mod tidy: {e}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = base_command(&self.go_bin)
|
||||
.args(["build", "-trimpath", "-buildvcs=false", "-o", &dest, "."])
|
||||
.current_dir(workdir)
|
||||
.env("GOCACHE", &go_cache)
|
||||
.env("GOPATH", &go_path)
|
||||
.env("GOMODCACHE", &go_mod_cache)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Ok(o) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Err(e) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("go-pool: go build: {e}"),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
binary_runnable(&self.go_bin, "version")
|
||||
}
|
||||
}
|
||||
952
src/dynamic/build_pool/java.rs
Normal file
952
src/dynamic/build_pool/java.rs
Normal file
|
|
@ -0,0 +1,952 @@
|
|||
//! Long-lived `javac` daemon (Phase 22 / Track O.0).
|
||||
//!
|
||||
//! The legacy `try_compile_java_with_toolchain` in `build_sandbox` shell-execs a
|
||||
//! fresh `javac` per harness — every invocation pays the JVM cold-start tax
|
||||
//! (~700ms on the macOS reference machine, ~300ms on Linux CI). At 50
|
||||
//! findings per OWASP-scale run that single line burns > 30s before any
|
||||
//! real work happens.
|
||||
//!
|
||||
//! [`JavacPool`] replaces the shell-exec with a long-running worker JVM:
|
||||
//!
|
||||
//! ```text
|
||||
//! nyx ─┐
|
||||
//! │ framed JSON ┌─────────────┐
|
||||
//! ├──stdin──────► │ NyxJavac │
|
||||
//! │ │ Worker │
|
||||
//! │ ◄──stdout──── │ (live JVM) │
|
||||
//! │ framed JSON └─────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! Bootstrap (paid once per toolchain id):
|
||||
//! 1. Drop `NyxJavacWorker.java` into a cache dir.
|
||||
//! 2. Compile it with `javac` (~1s).
|
||||
//! 3. Spawn `java -cp <dir> NyxJavacWorker` (~700ms cold start).
|
||||
//! 4. Read the worker's `{"ready":true}` banner.
|
||||
//!
|
||||
//! After bootstrap, each [`JavacPool::compile_batch`] is a single JSON
|
||||
//! round-trip — typical wall-clock < 50ms even on small harnesses.
|
||||
//!
|
||||
//! # Robustness
|
||||
//!
|
||||
//! A crashed / hung worker is non-fatal:
|
||||
//! - On any IO error, the pool marks itself unhealthy and the caller
|
||||
//! falls back to the direct-spawn legacy path.
|
||||
//! - The next pool lookup spawns a fresh worker.
|
||||
//!
|
||||
//! # Test hook
|
||||
//!
|
||||
//! `NYX_JAVAC_BIN` + `NYX_JAVA_BIN` override the binaries the pool
|
||||
//! invokes so integration tests can swap in a wrapper.
|
||||
|
||||
use super::{BuildPool, PoolCompileResult};
|
||||
use serde::Deserialize;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Java source compiled at first use to drive the worker.
|
||||
const WORKER_SOURCE: &str = include_str!("java_worker/NyxJavacWorker.java");
|
||||
const WORKER_CLASS: &str = "NyxJavacWorker";
|
||||
const WORKER_FILENAME: &str = "NyxJavacWorker.java";
|
||||
/// Manifest written last (atomically) by `publish_class_set` after every
|
||||
/// class lands, so its presence is the "publish finished" signal a
|
||||
/// lock-free reader keys on. Its *contents* are NOT trusted as the
|
||||
/// completeness oracle -- see `WORKER_CLASS_FILES`.
|
||||
const WORKER_MANIFEST: &str = ".worker-classes";
|
||||
|
||||
/// The exact set of `.class` files the worker JVM must load at runtime:
|
||||
/// the top-level class plus its nested `$Request` / `$Parser` types.
|
||||
///
|
||||
/// Readiness keys on *this fixed set*, not on whatever the on-disk
|
||||
/// manifest happens to name. A bootstrap cache left by an older binary
|
||||
/// can carry a manifest that lists only `NyxJavacWorker.class`; trusting
|
||||
/// that list let the gate pass with the nested classes absent, so the
|
||||
/// worker spawned, announced readiness, then died on the first request
|
||||
/// with `NoClassDefFoundError` surfaced as
|
||||
/// `nyx-javac-worker: parse error: NyxJavacWorker$Parser`. Pinning the
|
||||
/// required set here makes any such partial cache fail the gate and
|
||||
/// trigger a clean recompile. Kept in lock-step with the worker's real
|
||||
/// nested-class layout by `worker_class_files_match_javac_output`.
|
||||
const WORKER_CLASS_FILES: &[&str] = &[
|
||||
"NyxJavacWorker.class",
|
||||
"NyxJavacWorker$Request.class",
|
||||
"NyxJavacWorker$Parser.class",
|
||||
];
|
||||
const WORKER_READY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const COMPILE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Live worker handle. Held inside a `Mutex` so concurrent
|
||||
/// `compile_batch` callers serialise on the single JVM.
|
||||
struct Worker {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
pub struct JavacPool {
|
||||
/// `None` when the worker has crashed and a future call should
|
||||
/// surface the unhealthy state to the dispatcher.
|
||||
inner: Mutex<Option<Worker>>,
|
||||
/// Cache dir holding `NyxJavacWorker.class`. Persisted between
|
||||
/// runs so subsequent process invocations skip the compile step.
|
||||
bootstrap_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl JavacPool {
|
||||
/// Create a fresh pool for `toolchain_id`.
|
||||
///
|
||||
/// Returns `Err` when the worker cannot be bootstrapped (missing
|
||||
/// `javac`, missing `java`, compile failure, spawn failure). The
|
||||
/// caller is expected to fall back to the legacy direct-spawn path
|
||||
/// on any error.
|
||||
pub fn try_new(toolchain_id: &str) -> Result<Self, String> {
|
||||
let bootstrap_dir = bootstrap_dir_for(toolchain_id)?;
|
||||
std::fs::create_dir_all(&bootstrap_dir)
|
||||
.map_err(|e| format!("javac-pool: mkdir {}: {e}", bootstrap_dir.display()))?;
|
||||
|
||||
ensure_worker_compiled(&bootstrap_dir)?;
|
||||
let worker = spawn_worker(&bootstrap_dir)?;
|
||||
Ok(JavacPool {
|
||||
inner: Mutex::new(Some(worker)),
|
||||
bootstrap_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn compile_with_worker(&self, workdir: &Path, args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
|
||||
// If a prior call torched the worker, try one re-spawn here so
|
||||
// the caller doesn't see consecutive failures from a transient
|
||||
// JVM crash.
|
||||
if guard.is_none()
|
||||
&& let Ok(w) = spawn_worker(&self.bootstrap_dir)
|
||||
{
|
||||
*guard = Some(w);
|
||||
}
|
||||
let worker = match guard.as_mut() {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "javac-pool: worker unavailable".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let id = worker.next_id;
|
||||
worker.next_id = worker.next_id.wrapping_add(1);
|
||||
let req = build_request(id, workdir, args);
|
||||
if let Err(e) = worker.stdin.write_all(req.as_bytes()) {
|
||||
*guard = None;
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("javac-pool: write failed: {e}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
if let Err(e) = worker.stdin.flush() {
|
||||
*guard = None;
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("javac-pool: flush failed: {e}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
|
||||
match read_line_with_timeout(
|
||||
&mut worker.child,
|
||||
&mut worker.stdout,
|
||||
COMPILE_RESPONSE_TIMEOUT,
|
||||
"read response",
|
||||
) {
|
||||
Ok(None) => {
|
||||
*guard = None;
|
||||
PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "javac-pool: worker closed stdout".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
*guard = None;
|
||||
PoolCompileResult {
|
||||
success: false,
|
||||
stderr: e,
|
||||
duration: start.elapsed(),
|
||||
}
|
||||
}
|
||||
Ok(Some(line)) => match parse_response(&line) {
|
||||
Some((success, stderr)) => PoolCompileResult {
|
||||
success,
|
||||
stderr,
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
None => {
|
||||
*guard = None;
|
||||
PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("javac-pool: malformed response: {line}"),
|
||||
duration: start.elapsed(),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for JavacPool {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort: close stdin so the worker exits cleanly, then
|
||||
// wait briefly. We don't propagate errors -- pool teardown
|
||||
// happens at process exit, by which point everyone is already
|
||||
// leaving anyway.
|
||||
if let Ok(mut guard) = self.inner.lock()
|
||||
&& let Some(mut worker) = guard.take()
|
||||
{
|
||||
// Dropping stdin sends EOF to the worker's `readLine` loop.
|
||||
drop(worker.stdin);
|
||||
let _ = worker.child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildPool for JavacPool {
|
||||
fn name(&self) -> &'static str {
|
||||
"javac"
|
||||
}
|
||||
|
||||
fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult {
|
||||
self.compile_with_worker(workdir, args)
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
match self.inner.lock() {
|
||||
Ok(g) => g.is_some(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bootstrap_dir_for(toolchain_id: &str) -> Result<PathBuf, String> {
|
||||
if let Ok(custom) = std::env::var("NYX_BUILD_POOL_DIR") {
|
||||
return Ok(PathBuf::from(custom).join("javac").join(toolchain_id));
|
||||
}
|
||||
let base = directories::ProjectDirs::from("dev", "nyx", "nyx")
|
||||
.ok_or_else(|| "javac-pool: no cache dir on this platform".to_owned())?;
|
||||
Ok(base
|
||||
.cache_dir()
|
||||
.join("dynamic")
|
||||
.join("build-pool")
|
||||
.join("javac")
|
||||
.join(toolchain_id))
|
||||
}
|
||||
|
||||
/// Drop `NyxJavacWorker.java` + compile `NyxJavacWorker.class` into
|
||||
/// `dir` if they are not already present. Always re-writes the source
|
||||
/// when the on-disk copy differs from the embedded one so a binary
|
||||
/// upgrade picks up worker fixes without manual cache eviction.
|
||||
///
|
||||
/// The bootstrap dir is shared across every concurrent `nyx` process on
|
||||
/// the host, so the compile-and-publish step is hardened against the
|
||||
/// cross-process race that otherwise hands a half-written
|
||||
/// `NyxJavacWorker.class` to a peer process spawning its worker (which
|
||||
/// then fails to start, manifesting downstream as a flaky build):
|
||||
///
|
||||
/// - The publish is **atomic**: `javac` writes into a private,
|
||||
/// pid-scoped staging dir and the finished class is `rename`d into
|
||||
/// place. A concurrent reader sees either the previous complete
|
||||
/// class or the new one, never a partial file. The old class is
|
||||
/// never `remove`d first.
|
||||
/// - Compiles are **serialised** on a `flock(2)` over `.bootstrap.lock`
|
||||
/// so two processes never run `javac` into the same staging at once
|
||||
/// and a waiter re-checks the now-published class instead of
|
||||
/// recompiling.
|
||||
fn ensure_worker_compiled(dir: &Path) -> Result<(), String> {
|
||||
let src_path = dir.join(WORKER_FILENAME);
|
||||
|
||||
// Fast path: a complete class set already matches the current worker
|
||||
// source. Checked before taking the cross-process lock so steady
|
||||
// state stays lock-free.
|
||||
if worker_class_ready(dir) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Serialise the compile-and-publish across processes sharing `dir`.
|
||||
let _lock = BootstrapLock::acquire(dir)?;
|
||||
|
||||
// Re-check under the lock: another process may have published a good
|
||||
// class set while we were waiting on the lock.
|
||||
if worker_class_ready(dir) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Publish the source (idempotent) so cache inspectors can see what
|
||||
// the class was built from.
|
||||
std::fs::write(&src_path, WORKER_SOURCE)
|
||||
.map_err(|e| format!("javac-pool: write worker source: {e}"))?;
|
||||
|
||||
// Compile into a private staging dir, then atomically publish the
|
||||
// class files into place.
|
||||
let staging = dir.join(format!(".compile-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("javac-pool: mkdir staging: {e}"))?;
|
||||
let javac = std::env::var("NYX_JAVAC_BIN").unwrap_or_else(|_| "javac".to_owned());
|
||||
let compiled = Command::new(&javac)
|
||||
// Pin the source charset so the bootstrap compile is independent of
|
||||
// the host locale (a `C`/`POSIX` CI runner defaults `javac` to
|
||||
// `US-ASCII` and would reject any non-ASCII byte in the worker
|
||||
// source). Mirrors the harness-compile pin in `build_sandbox`.
|
||||
.arg("-encoding")
|
||||
.arg("UTF-8")
|
||||
.arg("-d")
|
||||
.arg(&staging)
|
||||
.arg(&src_path)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||
.output();
|
||||
let output = match compiled {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
return Err(format!("javac-pool: spawn javac: {e}"));
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
return Err(format!(
|
||||
"javac-pool: bootstrap compile failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
));
|
||||
}
|
||||
let publish = publish_class_set(&staging, dir);
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
publish
|
||||
}
|
||||
|
||||
/// Move every `.class` file `javac` emitted from the private `staging`
|
||||
/// dir into the shared `dir`, then write the manifest last.
|
||||
///
|
||||
/// The worker source compiles to the top-level `NyxJavacWorker.class`
|
||||
/// plus the nested `NyxJavacWorker$Request` / `NyxJavacWorker$Parser`
|
||||
/// classes. Every one of them must land in `dir` (the worker JVM's
|
||||
/// classpath), or the worker hits `NoClassDefFoundError` the first time
|
||||
/// it touches a nested class -- which surfaced downstream as a bogus
|
||||
/// `nyx-javac-worker: parse error: NyxJavacWorker$Parser`.
|
||||
///
|
||||
/// Renames are same-filesystem (staging is a child of `dir`) so each is
|
||||
/// atomic. The manifest is written last via a temp-then-rename, so a
|
||||
/// concurrent peer on the lock-free fast path sees either no manifest
|
||||
/// (and serialises on the lock) or a complete one whose every named
|
||||
/// class is already in place.
|
||||
fn publish_class_set(staging: &Path, dir: &Path) -> Result<(), String> {
|
||||
let entries =
|
||||
std::fs::read_dir(staging).map_err(|e| format!("javac-pool: read staging dir: {e}"))?;
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in entries {
|
||||
let path = entry
|
||||
.map_err(|e| format!("javac-pool: read staging entry: {e}"))?
|
||||
.path();
|
||||
if path.extension().is_none_or(|x| x != "class") {
|
||||
continue;
|
||||
}
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
std::fs::rename(&path, dir.join(&name))
|
||||
.map_err(|e| format!("javac-pool: publish {name}: {e}"))?;
|
||||
names.push(name);
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err("javac-pool: bootstrap compile produced no .class files".to_owned());
|
||||
}
|
||||
// Refuse to publish (and to write the readiness-signalling manifest) a
|
||||
// set missing any class the worker loads at runtime. Fail loud here
|
||||
// rather than leave a half-set the worker would die on later.
|
||||
for required in WORKER_CLASS_FILES {
|
||||
if !names.iter().any(|n| n == required) {
|
||||
return Err(format!(
|
||||
"javac-pool: bootstrap compile missing required class {required}; got {names:?}",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Write the manifest atomically (temp + rename) so it appears in one
|
||||
// step after every class is already published.
|
||||
let manifest = dir.join(WORKER_MANIFEST);
|
||||
let tmp = dir.join(format!("{WORKER_MANIFEST}.{}", std::process::id()));
|
||||
std::fs::write(&tmp, names.join("\n"))
|
||||
.map_err(|e| format!("javac-pool: write manifest: {e}"))?;
|
||||
std::fs::rename(&tmp, &manifest).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("javac-pool: publish manifest: {e}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True when `dir` holds a complete, non-empty class set built from the
|
||||
/// current embedded `WORKER_SOURCE`: the source matches, the manifest is
|
||||
/// present, and every class the manifest names exists and is non-empty.
|
||||
fn worker_class_ready(dir: &Path) -> bool {
|
||||
if std::fs::read_to_string(dir.join(WORKER_FILENAME))
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= Some(WORKER_SOURCE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// The manifest is written last by `publish_class_set`, so its presence
|
||||
// is the "publish finished" barrier: a reader that sees it knows no
|
||||
// peer is mid-rename. Absence forces the cross-process lock path.
|
||||
if std::fs::metadata(dir.join(WORKER_MANIFEST)).is_err() {
|
||||
return false;
|
||||
}
|
||||
// Completeness is judged against the fixed required set, never against
|
||||
// the manifest's lines -- a stale or partial manifest must not be able
|
||||
// to vouch for classes it simply fails to name.
|
||||
for name in WORKER_CLASS_FILES {
|
||||
let present = std::fs::metadata(dir.join(name))
|
||||
.map(|m| m.is_file() && m.len() > 0)
|
||||
.unwrap_or(false);
|
||||
if !present {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Cross-process advisory lock guarding the shared bootstrap dir's
|
||||
/// compile-and-publish step. The held lock file lives at
|
||||
/// `<dir>/.bootstrap.lock`; the `flock(2)` releases when the guard (and
|
||||
/// thus the file) drops.
|
||||
struct BootstrapLock {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
impl BootstrapLock {
|
||||
fn acquire(dir: &Path) -> Result<Self, String> {
|
||||
let lock_path = dir.join(".bootstrap.lock");
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.map_err(|e| format!("javac-pool: open bootstrap lock: {e}"))?;
|
||||
lock_file_exclusive(&file).map_err(|e| format!("javac-pool: bootstrap lock: {e}"))?;
|
||||
Ok(BootstrapLock { _file: file })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn lock_file_exclusive(file: &File) -> std::io::Result<()> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
unsafe extern "C" {
|
||||
fn flock(fd: i32, operation: i32) -> i32;
|
||||
}
|
||||
const LOCK_EX: i32 = 2;
|
||||
loop {
|
||||
// SAFETY: `file.as_raw_fd()` is a live fd owned by `file`; `flock`
|
||||
// only reads the scalar args and we check the return value.
|
||||
let ret = unsafe { flock(file.as_raw_fd(), LOCK_EX) };
|
||||
if ret == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn lock_file_exclusive(_file: &File) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_worker(dir: &Path) -> Result<Worker, String> {
|
||||
let java = std::env::var("NYX_JAVA_BIN").unwrap_or_else(|_| "java".to_owned());
|
||||
let mut child = Command::new(&java)
|
||||
// The worker is tiny -- keep the JVM frugal so the pool
|
||||
// overhead stays well below the per-finding cost it
|
||||
// replaces.
|
||||
.arg("-Xss256k")
|
||||
.arg("-XX:+UseSerialGC")
|
||||
.arg("-cp")
|
||||
.arg(dir)
|
||||
.arg(WORKER_CLASS)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||
.spawn()
|
||||
.map_err(|e| format!("javac-pool: spawn java: {e}"))?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "javac-pool: missing stdin".to_owned())?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| "javac-pool: missing stdout".to_owned())?;
|
||||
let mut stdout = BufReader::new(stdout);
|
||||
|
||||
let banner =
|
||||
match read_line_with_timeout(&mut child, &mut stdout, WORKER_READY_TIMEOUT, "read banner")?
|
||||
{
|
||||
Some(line) => line,
|
||||
None => {
|
||||
let _ = child.kill();
|
||||
let stderr_tail = drain_stderr(&mut child);
|
||||
return Err(format!(
|
||||
"javac-pool: worker closed stdout before readiness; stderr: {stderr_tail}",
|
||||
));
|
||||
}
|
||||
};
|
||||
if !banner.contains("\"ready\":true") {
|
||||
// Drain stderr for diagnostic context, then bail.
|
||||
let _ = child.kill();
|
||||
let stderr_tail = drain_stderr(&mut child);
|
||||
return Err(format!(
|
||||
"javac-pool: worker did not announce readiness; got {banner:?}; stderr: {stderr_tail}",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Worker {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
next_id: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn drain_stderr(child: &mut Child) -> String {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
if let Some(mut e) = child.stderr.take() {
|
||||
// Best-effort, non-blocking-ish.
|
||||
let _ = e.read_to_string(&mut buf);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn read_line_with_timeout(
|
||||
child: &mut Child,
|
||||
stdout: &mut BufReader<ChildStdout>,
|
||||
timeout: Duration,
|
||||
context: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::scope(|scope| {
|
||||
scope.spawn(move || {
|
||||
let mut line = String::new();
|
||||
let result = stdout.read_line(&mut line).map(|n| (n, line));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(Ok((0, _))) => Ok(None),
|
||||
Ok(Ok((_n, line))) => Ok(Some(line)),
|
||||
Ok(Err(e)) => Err(format!("javac-pool: {context} failed: {e}")),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
let _ = child.kill();
|
||||
Err(format!("javac-pool: {context} timed out after {timeout:?}"))
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
Err(format!("javac-pool: {context} reader disconnected"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_request(id: u64, workdir: &Path, args: &[String]) -> String {
|
||||
let mut s = String::with_capacity(128 + args.iter().map(|a| a.len() + 4).sum::<usize>());
|
||||
s.push_str("{\"id\":\"");
|
||||
s.push_str(&id.to_string());
|
||||
s.push_str("\",\"cwd\":");
|
||||
append_json_string(&mut s, &workdir.to_string_lossy());
|
||||
s.push_str(",\"args\":[");
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
append_json_string(&mut s, a);
|
||||
}
|
||||
s.push_str("]}\n");
|
||||
s
|
||||
}
|
||||
|
||||
fn append_json_string(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
/// Extract `(success, stderr)` from a worker JSON response line.
|
||||
fn parse_response(line: &str) -> Option<(bool, String)> {
|
||||
let response: JavacWorkerResponse = serde_json::from_str(line).ok()?;
|
||||
let stderr =
|
||||
decode_b64(&response.stderr_b64).unwrap_or_else(|| "<unable to decode stderr>".to_owned());
|
||||
Some((response.success, stderr))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JavacWorkerResponse {
|
||||
success: bool,
|
||||
#[serde(default)]
|
||||
stderr_b64: String,
|
||||
}
|
||||
|
||||
/// Tiny RFC 4648 base64 decoder. Used only for the worker's
|
||||
/// `stderr_b64` field so we can carry raw bytes through the JSON
|
||||
/// envelope without dragging in a base64 crate.
|
||||
fn decode_b64(s: &str) -> Option<String> {
|
||||
static ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut lookup = [0xffu8; 256];
|
||||
for (i, &b) in ALPHABET.iter().enumerate() {
|
||||
lookup[b as usize] = i as u8;
|
||||
}
|
||||
let bytes: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
|
||||
for chunk in bytes.chunks(4) {
|
||||
if chunk.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut vals = [0u8; 4];
|
||||
let mut pads = 0;
|
||||
for (i, &b) in chunk.iter().enumerate() {
|
||||
if b == b'=' {
|
||||
pads += 1;
|
||||
vals[i] = 0;
|
||||
} else {
|
||||
let v = lookup[b as usize];
|
||||
if v == 0xff {
|
||||
return None;
|
||||
}
|
||||
vals[i] = v;
|
||||
}
|
||||
}
|
||||
let triple = ((vals[0] as u32) << 18)
|
||||
| ((vals[1] as u32) << 12)
|
||||
| ((vals[2] as u32) << 6)
|
||||
| (vals[3] as u32);
|
||||
out.push(((triple >> 16) & 0xff) as u8);
|
||||
if pads < 2 {
|
||||
out.push(((triple >> 8) & 0xff) as u8);
|
||||
}
|
||||
if pads < 1 {
|
||||
out.push((triple & 0xff) as u8);
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_envelope_escapes_specials() {
|
||||
let s = build_request(
|
||||
7,
|
||||
Path::new("/tmp/x"),
|
||||
&["a\"b".to_owned(), "c\\d".to_owned()],
|
||||
);
|
||||
assert!(s.contains("\"id\":\"7\""));
|
||||
assert!(s.contains("\"cwd\":\"/tmp/x\""));
|
||||
assert!(s.contains("\"a\\\"b\""));
|
||||
assert!(s.contains("\"c\\\\d\""));
|
||||
assert!(s.ends_with("]}\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_success() {
|
||||
let (ok, err) =
|
||||
parse_response("{\"id\":\"0\",\"success\":true,\"stderr_b64\":\"\"}\n").unwrap();
|
||||
assert!(ok);
|
||||
assert!(err.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_failure_decodes_stderr() {
|
||||
// "boom" -> base64 "Ym9vbQ=="
|
||||
let (ok, err) =
|
||||
parse_response("{\"id\":\"1\",\"success\":false,\"stderr_b64\":\"Ym9vbQ==\"}\n")
|
||||
.unwrap();
|
||||
assert!(!ok);
|
||||
assert_eq!(err, "boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_rejects_off_shape() {
|
||||
assert!(parse_response("not json").is_none());
|
||||
// Missing success field.
|
||||
assert!(parse_response("{\"id\":\"0\",\"stderr_b64\":\"\"}").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_accepts_reordered_fields() {
|
||||
let (ok, err) =
|
||||
parse_response("{\"stderr_b64\":\"YQ==\",\"success\":true,\"id\":\"7\"}\n").unwrap();
|
||||
assert!(ok);
|
||||
assert_eq!(err, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b64_decode_roundtrip() {
|
||||
for (raw, encoded) in &[
|
||||
("", ""),
|
||||
("a", "YQ=="),
|
||||
("ab", "YWI="),
|
||||
("abc", "YWJj"),
|
||||
("hello world", "aGVsbG8gd29ybGQ="),
|
||||
] {
|
||||
assert_eq!(decode_b64(encoded).as_deref(), Some(*raw));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_class_ready_rejects_truncated_or_mismatched() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let src = dir.join(WORKER_FILENAME);
|
||||
let main_class = dir.join(format!("{WORKER_CLASS}.class"));
|
||||
let parser = dir.join(format!("{WORKER_CLASS}$Parser.class"));
|
||||
let request = dir.join(format!("{WORKER_CLASS}$Request.class"));
|
||||
let manifest = dir.join(WORKER_MANIFEST);
|
||||
let manifest_body = format!(
|
||||
"{WORKER_CLASS}.class\n{WORKER_CLASS}$Parser.class\n{WORKER_CLASS}$Request.class"
|
||||
);
|
||||
|
||||
// Nothing on disk yet.
|
||||
assert!(!worker_class_ready(dir));
|
||||
|
||||
// Matching source but no class / manifest.
|
||||
std::fs::write(&src, WORKER_SOURCE).unwrap();
|
||||
assert!(!worker_class_ready(dir));
|
||||
|
||||
// Top-level class + manifest present but the nested classes are
|
||||
// missing -- the stale-cache shape an older binary left behind.
|
||||
std::fs::write(&main_class, b"\xca\xfe\xba\xbe").unwrap();
|
||||
std::fs::write(&manifest, &manifest_body).unwrap();
|
||||
assert!(!worker_class_ready(dir));
|
||||
|
||||
// A zero-byte nested class (the corruption shape a racing peer can
|
||||
// leave behind) must not count as ready.
|
||||
std::fs::write(&parser, b"").unwrap();
|
||||
std::fs::write(&request, b"\xca\xfe\xba\xbe").unwrap();
|
||||
assert!(!worker_class_ready(dir));
|
||||
|
||||
// Every required class non-empty with matching source is ready.
|
||||
std::fs::write(&parser, b"\xca\xfe\xba\xbe").unwrap();
|
||||
assert!(worker_class_ready(dir));
|
||||
|
||||
// A missing manifest invalidates an otherwise-complete class set.
|
||||
std::fs::remove_file(&manifest).unwrap();
|
||||
assert!(!worker_class_ready(dir));
|
||||
std::fs::write(&manifest, &manifest_body).unwrap();
|
||||
assert!(worker_class_ready(dir));
|
||||
|
||||
// Stale source invalidates an otherwise-present class set.
|
||||
std::fs::write(&src, "// not the worker source").unwrap();
|
||||
assert!(!worker_class_ready(dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_class_ready_rejects_manifest_that_omits_nested_classes() {
|
||||
// The exact stale-cache shape that produced
|
||||
// `nyx-javac-worker: parse error: NyxJavacWorker$Parser` on Linux:
|
||||
// a self-consistent manifest that simply does not name the nested
|
||||
// classes, with only the top-level class on disk. The old guard
|
||||
// iterated the manifest's lines and so trusted this; readiness must
|
||||
// now reject it because the fixed required set is incomplete.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::write(dir.join(WORKER_FILENAME), WORKER_SOURCE).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("{WORKER_CLASS}.class")),
|
||||
b"\xca\xfe\xba\xbe",
|
||||
)
|
||||
.unwrap();
|
||||
// Manifest names only the top-level class -- exactly what poisoned
|
||||
// the persisted bootstrap cache.
|
||||
std::fs::write(dir.join(WORKER_MANIFEST), format!("{WORKER_CLASS}.class")).unwrap();
|
||||
assert!(
|
||||
!worker_class_ready(dir),
|
||||
"a manifest omitting the nested classes must not satisfy readiness",
|
||||
);
|
||||
|
||||
// Drop in the nested classes the worker actually loads -> ready.
|
||||
std::fs::write(
|
||||
dir.join(format!("{WORKER_CLASS}$Parser.class")),
|
||||
b"\xca\xfe\xba\xbe",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("{WORKER_CLASS}$Request.class")),
|
||||
b"\xca\xfe\xba\xbe",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(worker_class_ready(dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_worker_compiled_heals_partial_cache() {
|
||||
// End-to-end heal: seed the exact poisoned-cache shape that broke
|
||||
// Linux (top-level class + a one-line manifest, nested classes
|
||||
// absent) and confirm `ensure_worker_compiled` recompiles a full,
|
||||
// loadable class set instead of trusting the stale manifest.
|
||||
let javac = std::env::var("NYX_JAVAC_BIN").unwrap_or_else(|_| "javac".to_owned());
|
||||
let have_javac = std::process::Command::new(&javac)
|
||||
.arg("-version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !have_javac {
|
||||
return; // No JDK on this host: nothing to recompile with.
|
||||
}
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::write(dir.join(WORKER_FILENAME), WORKER_SOURCE).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("{WORKER_CLASS}.class")),
|
||||
b"\xca\xfe\xba\xbe",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join(WORKER_MANIFEST), format!("{WORKER_CLASS}.class")).unwrap();
|
||||
assert!(
|
||||
!worker_class_ready(dir),
|
||||
"poisoned cache must read not-ready"
|
||||
);
|
||||
|
||||
ensure_worker_compiled(dir).expect("recompile heals the cache");
|
||||
|
||||
assert!(worker_class_ready(dir), "healed cache must read ready");
|
||||
for cls in WORKER_CLASS_FILES {
|
||||
let meta = std::fs::metadata(dir.join(cls)).expect("class published");
|
||||
assert!(meta.len() > 0, "{cls} must be a real (non-empty) class");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_class_files_match_javac_output() {
|
||||
// Guards `WORKER_CLASS_FILES` against drift: compile the embedded
|
||||
// worker source and assert the emitted `.class` set is exactly the
|
||||
// pinned required set, so a future nested type added to the worker
|
||||
// can't silently fall outside the readiness gate.
|
||||
let javac = std::env::var("NYX_JAVAC_BIN").unwrap_or_else(|_| "javac".to_owned());
|
||||
let have_javac = std::process::Command::new(&javac)
|
||||
.arg("-version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !have_javac {
|
||||
return; // JRE-only / no JDK: nothing to compile against.
|
||||
}
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let src = tmp.path().join(WORKER_FILENAME);
|
||||
std::fs::write(&src, WORKER_SOURCE).unwrap();
|
||||
let out = tmp.path().join("out");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let status = std::process::Command::new(&javac)
|
||||
.arg("-encoding")
|
||||
.arg("UTF-8")
|
||||
.arg("-d")
|
||||
.arg(&out)
|
||||
.arg(&src)
|
||||
.status()
|
||||
.expect("spawn javac");
|
||||
assert!(status.success(), "worker source must compile");
|
||||
|
||||
let mut emitted: Vec<String> = std::fs::read_dir(&out)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.ends_with(".class"))
|
||||
.collect();
|
||||
emitted.sort();
|
||||
let mut expected: Vec<String> =
|
||||
WORKER_CLASS_FILES.iter().map(|s| (*s).to_owned()).collect();
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
emitted, expected,
|
||||
"WORKER_CLASS_FILES must mirror the worker's javac output",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_class_set_moves_every_class_and_writes_manifest() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let staging = dir.join(".compile-test");
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
// Simulate javac output: top-level + nested classes plus a
|
||||
// non-class artifact that must be ignored.
|
||||
std::fs::write(staging.join("NyxJavacWorker.class"), b"\xca\xfe\xba\xbe").unwrap();
|
||||
std::fs::write(
|
||||
staging.join("NyxJavacWorker$Parser.class"),
|
||||
b"\xca\xfe\xba\xbe",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
staging.join("NyxJavacWorker$Request.class"),
|
||||
b"\xca\xfe\xba\xbe",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(staging.join("notes.txt"), b"ignore me").unwrap();
|
||||
|
||||
publish_class_set(&staging, dir).expect("publish");
|
||||
|
||||
for cls in [
|
||||
"NyxJavacWorker.class",
|
||||
"NyxJavacWorker$Parser.class",
|
||||
"NyxJavacWorker$Request.class",
|
||||
] {
|
||||
assert!(dir.join(cls).is_file(), "{cls} must be published");
|
||||
}
|
||||
// The non-class file stays in staging (not published).
|
||||
assert!(!dir.join("notes.txt").exists());
|
||||
|
||||
let manifest = std::fs::read_to_string(dir.join(WORKER_MANIFEST)).unwrap();
|
||||
let listed: Vec<&str> = manifest.lines().collect();
|
||||
assert_eq!(listed.len(), 3, "manifest lists all 3 classes: {listed:?}");
|
||||
assert!(listed.contains(&"NyxJavacWorker$Parser.class"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_lock_is_reentrant_across_sequential_acquires() {
|
||||
// The flock is released when the guard drops, so back-to-back
|
||||
// acquires from the same process succeed without deadlock.
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
{
|
||||
let _g = BootstrapLock::acquire(dir.path()).expect("first acquire");
|
||||
}
|
||||
let _g = BootstrapLock::acquire(dir.path()).expect("second acquire");
|
||||
assert!(dir.path().join(".bootstrap.lock").exists());
|
||||
}
|
||||
}
|
||||
256
src/dynamic/build_pool/java_worker/NyxJavacWorker.java
Normal file
256
src/dynamic/build_pool/java_worker/NyxJavacWorker.java
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Long-lived javac worker bundled with nyx-scanner. The Rust pool side
|
||||
// compiles + spawns this once per toolchain id; subsequent harness
|
||||
// compiles run in-process via ToolProvider#getSystemJavaCompiler so the
|
||||
// JVM cold-start cost is amortised across every harness in a verify run.
|
||||
//
|
||||
// Wire format: newline-terminated UTF-8 JSON, one request per line:
|
||||
// {"id":"0","cwd":"/path/to/workdir","args":["-d","/tmp/x","Foo.java"]}\n
|
||||
//
|
||||
// Response: newline-terminated UTF-8 JSON, one per request:
|
||||
// {"id":"0","success":true,"stderr_b64":"<base64 of javac stderr>"}\n
|
||||
//
|
||||
// stderr is base64-encoded so it never embeds raw newlines or quotes
|
||||
// inside the JSON envelope -- keeps the parser on both sides tiny.
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import javax.tools.JavaCompiler;
|
||||
import javax.tools.ToolProvider;
|
||||
|
||||
public class NyxJavacWorker {
|
||||
public static void main(String[] argv) throws Exception {
|
||||
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
|
||||
if (compiler == null) {
|
||||
// JRE without javac (rare on dev boxes, possible on slim CI
|
||||
// images). Signal the Rust side so it falls back to the
|
||||
// direct-spawn legacy path.
|
||||
System.err.println("nyx-javac-worker: no system Java compiler (JRE-only install?)");
|
||||
System.exit(2);
|
||||
}
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||
PrintStream out = new PrintStream(System.out, true, StandardCharsets.UTF_8);
|
||||
// Banner line. The Rust side reads this first so it knows the
|
||||
// worker is live before it queues any compile requests.
|
||||
out.println("{\"ready\":true}");
|
||||
out.flush();
|
||||
|
||||
String line;
|
||||
while ((line = in.readLine()) != null) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
Request req;
|
||||
try {
|
||||
req = parse(line);
|
||||
} catch (Throwable t) {
|
||||
// Malformed request -- emit an error response keyed on
|
||||
// an empty id so the Rust side can at least surface it.
|
||||
writeResponse(out, "", false, ("nyx-javac-worker: parse error: " + t.getMessage()).getBytes(StandardCharsets.UTF_8));
|
||||
continue;
|
||||
}
|
||||
ByteArrayOutputStream errBuf = new ByteArrayOutputStream();
|
||||
PrintStream errStream = new PrintStream(errBuf, true, StandardCharsets.UTF_8);
|
||||
int rc;
|
||||
try {
|
||||
String[] args = req.args.toArray(new String[0]);
|
||||
if (req.cwd != null && !req.cwd.isEmpty()) {
|
||||
// The JDK compiler API has no per-task cwd switch,
|
||||
// so we rewrite relative args. The harness build
|
||||
// already supplies absolute paths via the Rust side,
|
||||
// but we still set user.dir defensively so any
|
||||
// relative -d / -cp / source-path entries resolve
|
||||
// against the requested workdir rather than the
|
||||
// worker JVM's launch directory.
|
||||
System.setProperty("user.dir", req.cwd);
|
||||
}
|
||||
rc = compiler.run(null, null, errStream, args);
|
||||
} catch (Throwable t) {
|
||||
t.printStackTrace(errStream);
|
||||
rc = 1;
|
||||
}
|
||||
boolean success = (rc == 0);
|
||||
writeResponse(out, req.id, success, errBuf.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeResponse(PrintStream out, String id, boolean success, byte[] stderr) {
|
||||
String b64 = Base64.getEncoder().encodeToString(stderr);
|
||||
StringBuilder sb = new StringBuilder(64 + b64.length());
|
||||
sb.append("{\"id\":");
|
||||
appendJsonString(sb, id);
|
||||
sb.append(",\"success\":").append(success);
|
||||
sb.append(",\"stderr_b64\":\"").append(b64).append("\"}");
|
||||
out.println(sb);
|
||||
out.flush();
|
||||
}
|
||||
|
||||
private static void appendJsonString(StringBuilder sb, String s) {
|
||||
sb.append('"');
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
}
|
||||
|
||||
private static final class Request {
|
||||
String id = "";
|
||||
String cwd = "";
|
||||
List<String> args = new ArrayList<>();
|
||||
}
|
||||
|
||||
private static Request parse(String s) {
|
||||
Parser p = new Parser(s);
|
||||
Request r = new Request();
|
||||
p.skipWs();
|
||||
p.expect('{');
|
||||
p.skipWs();
|
||||
if (p.peek() == '}') {
|
||||
p.next();
|
||||
return r;
|
||||
}
|
||||
while (true) {
|
||||
p.skipWs();
|
||||
String key = p.parseString();
|
||||
p.skipWs();
|
||||
p.expect(':');
|
||||
p.skipWs();
|
||||
if (key.equals("id")) {
|
||||
r.id = p.parseString();
|
||||
} else if (key.equals("cwd")) {
|
||||
r.cwd = p.parseString();
|
||||
} else if (key.equals("args")) {
|
||||
p.expect('[');
|
||||
p.skipWs();
|
||||
if (p.peek() != ']') {
|
||||
while (true) {
|
||||
p.skipWs();
|
||||
r.args.add(p.parseString());
|
||||
p.skipWs();
|
||||
if (p.peek() == ',') { p.next(); continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
p.skipWs();
|
||||
p.expect(']');
|
||||
} else {
|
||||
skipValue(p);
|
||||
}
|
||||
p.skipWs();
|
||||
if (p.peek() == ',') { p.next(); continue; }
|
||||
break;
|
||||
}
|
||||
p.skipWs();
|
||||
p.expect('}');
|
||||
return r;
|
||||
}
|
||||
|
||||
private static void skipValue(Parser p) {
|
||||
p.skipWs();
|
||||
char c = p.peek();
|
||||
if (c == '"') { p.parseString(); }
|
||||
else if (c == '[') {
|
||||
p.next();
|
||||
p.skipWs();
|
||||
if (p.peek() != ']') {
|
||||
while (true) {
|
||||
skipValue(p); p.skipWs();
|
||||
if (p.peek() == ',') { p.next(); continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
p.skipWs();
|
||||
p.expect(']');
|
||||
} else if (c == '{') {
|
||||
p.next();
|
||||
p.skipWs();
|
||||
if (p.peek() != '}') {
|
||||
while (true) {
|
||||
p.skipWs();
|
||||
p.parseString();
|
||||
p.skipWs();
|
||||
p.expect(':');
|
||||
skipValue(p);
|
||||
p.skipWs();
|
||||
if (p.peek() == ',') { p.next(); continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
p.skipWs();
|
||||
p.expect('}');
|
||||
} else {
|
||||
int start = p.pos;
|
||||
while (p.pos < p.s.length() && "0123456789.-+eEtrufalsn".indexOf(p.s.charAt(p.pos)) >= 0) {
|
||||
p.pos++;
|
||||
}
|
||||
if (p.pos == start) {
|
||||
throw new RuntimeException("bad value at " + p.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Parser {
|
||||
final String s; int pos = 0;
|
||||
Parser(String s) { this.s = s; }
|
||||
char peek() { return s.charAt(pos); }
|
||||
char next() { return s.charAt(pos++); }
|
||||
void skipWs() { while (pos < s.length() && Character.isWhitespace(s.charAt(pos))) pos++; }
|
||||
void expect(char c) {
|
||||
if (pos >= s.length() || s.charAt(pos) != c) {
|
||||
throw new RuntimeException("expected '" + c + "' at " + pos + " of " + s);
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
String parseString() {
|
||||
expect('"');
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (pos < s.length()) {
|
||||
char c = s.charAt(pos++);
|
||||
if (c == '"') return sb.toString();
|
||||
if (c == '\\') {
|
||||
char e = s.charAt(pos++);
|
||||
switch (e) {
|
||||
case '"': sb.append('"'); break;
|
||||
case '\\': sb.append('\\'); break;
|
||||
case '/': sb.append('/'); break;
|
||||
case 'b': sb.append('\b'); break;
|
||||
case 'f': sb.append('\f'); break;
|
||||
case 'n': sb.append('\n'); break;
|
||||
case 'r': sb.append('\r'); break;
|
||||
case 't': sb.append('\t'); break;
|
||||
case 'u': {
|
||||
String hex = s.substring(pos, pos + 4);
|
||||
pos += 4;
|
||||
sb.append((char) Integer.parseInt(hex, 16));
|
||||
break;
|
||||
}
|
||||
default: throw new RuntimeException("bad escape \\" + e);
|
||||
}
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("unterminated string");
|
||||
}
|
||||
}
|
||||
}
|
||||
340
src/dynamic/build_pool/mod.rs
Normal file
340
src/dynamic/build_pool/mod.rs
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
//! Build pools: long-lived compiler / toolchain daemons shared across many
|
||||
//! per-finding harness builds.
|
||||
//!
|
||||
//! The naive `prepare_*` path in [`crate::dynamic::build_sandbox`] spawns a
|
||||
//! fresh `javac` / `tsc` / `cargo build` subprocess for every finding the
|
||||
//! verifier touches. Cold-start dominates the cost: `javac` alone burns
|
||||
//! ~700ms before it has read a single source. A 50-harness OWASP run pays
|
||||
//! that 50× — > 30s of pure JVM startup.
|
||||
//!
|
||||
//! A `BuildPool` is a long-running worker process (or in-process service)
|
||||
//! that compiles batches of harness sources in a single toolchain instance.
|
||||
//! The per-harness wall-clock collapses to milliseconds once the pool is
|
||||
//! warm.
|
||||
//!
|
||||
//! # Lifecycle
|
||||
//!
|
||||
//! `OnceLock<Arc<P>>` per toolchain id, lazily spawned on first request.
|
||||
//! Pools live for the rest of the process; the OS reaps them on exit.
|
||||
//! Crashes are non-fatal: callers fall back to the legacy direct-spawn path
|
||||
//! via [`BuildPool::is_healthy`] and a re-spawn on the next call.
|
||||
//!
|
||||
//! # Future-language plug-in
|
||||
//!
|
||||
//! Per-language sub-modules (`java.rs`, eventually `node.rs`, `python.rs`,
|
||||
//! …) implement the [`BuildPool`] trait. The harness build dispatcher in
|
||||
//! [`crate::dynamic::build_sandbox`] reads `NYX_DYNAMIC_BUILD_POOL` and
|
||||
//! routes each request to the matching pool when enabled.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod c;
|
||||
pub mod cpp;
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod node;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
|
||||
/// Outcome of a single batched compile request.
|
||||
#[derive(Debug)]
|
||||
pub struct PoolCompileResult {
|
||||
/// `true` when the toolchain reported a clean compile.
|
||||
pub success: bool,
|
||||
/// Toolchain stderr — surfaced as `BuildError::BuildFailed` upstream
|
||||
/// when `success == false`.
|
||||
pub stderr: String,
|
||||
/// Wall-clock for the in-pool compile step (excludes any IPC / queue
|
||||
/// wait time). Useful for telemetry; callers may ignore.
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
/// Common contract for every per-language build pool.
|
||||
///
|
||||
/// Implementations are expected to be `Send + Sync` so an `Arc<dyn BuildPool>`
|
||||
/// can be cached in a static `OnceLock` and shared across rayon worker
|
||||
/// threads.
|
||||
pub trait BuildPool: Send + Sync {
|
||||
/// Stable identifier — used in log lines + telemetry so an operator
|
||||
/// can correlate a pool warmup with the harness that triggered it.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Compile every source file under `workdir` matching the pool's
|
||||
/// language convention. On success the toolchain has written
|
||||
/// artefacts back into `workdir` (or wherever the pool's contract
|
||||
/// dictates).
|
||||
fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult;
|
||||
|
||||
/// Cheap health check — when this returns `false`, the harness build
|
||||
/// dispatcher falls back to the direct-spawn legacy path and tears
|
||||
/// down the cached handle so the next request triggers a re-spawn.
|
||||
fn is_healthy(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Languages that ship a [`BuildPool`] implementation and are therefore
|
||||
/// enabled by default. Phase 22 shipped `java`; Phase 23 (Track O.1) adds
|
||||
/// the remaining eight, so every supported language now has a warm fast path
|
||||
/// unless an operator opts out via `NYX_DYNAMIC_BUILD_POOL=<lang>=0`.
|
||||
const POOL_ENABLED_LANGS: &[&str] = &[
|
||||
"java", "node", "python", "php", "ruby", "go", "rust", "c", "cpp",
|
||||
];
|
||||
|
||||
/// Parse the `NYX_DYNAMIC_BUILD_POOL` env var.
|
||||
///
|
||||
/// Format is a comma-separated list of `lang=bit` entries: `java=1,node=0`.
|
||||
/// A missing language returns the default: `true` for every language that
|
||||
/// ships a pool (see `POOL_ENABLED_LANGS`), `false` otherwise.
|
||||
pub fn is_pool_enabled(lang: &str) -> bool {
|
||||
let default = POOL_ENABLED_LANGS.contains(&lang);
|
||||
let raw = match std::env::var("NYX_DYNAMIC_BUILD_POOL") {
|
||||
Ok(v) => v,
|
||||
Err(_) => return default,
|
||||
};
|
||||
for entry in raw.split(',') {
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (k, v) = match entry.split_once('=') {
|
||||
Some(kv) => kv,
|
||||
None => continue,
|
||||
};
|
||||
if k.trim().eq_ignore_ascii_case(lang) {
|
||||
return matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on");
|
||||
}
|
||||
}
|
||||
default
|
||||
}
|
||||
|
||||
/// Shared root for a pool's persistent caches (sccache dir, shared
|
||||
/// `GOCACHE`, opcache file-cache, Bootsnap cache, shared venvs, …).
|
||||
///
|
||||
/// Honours `NYX_BUILD_POOL_DIR` so tests can redirect the cache into a
|
||||
/// `TempDir`; otherwise falls back to the platform cache dir, mirroring
|
||||
/// the javac pool's layout under `dynamic/build-pool/`.
|
||||
///
|
||||
/// Returns `None` only when neither the env override nor a platform cache
|
||||
/// dir is available — callers treat that as "pool unavailable" and fall
|
||||
/// back to the legacy direct-spawn build path.
|
||||
pub(crate) fn pool_cache_dir(lang: &str, sub: &str) -> Option<PathBuf> {
|
||||
let custom = std::env::var("NYX_BUILD_POOL_DIR").ok().map(PathBuf::from);
|
||||
let base = if let Some(custom) = custom.clone() {
|
||||
custom
|
||||
} else {
|
||||
directories::ProjectDirs::from("dev", "nyx", "nyx")?
|
||||
.cache_dir()
|
||||
.join("dynamic")
|
||||
.join("build-pool")
|
||||
};
|
||||
let dir = base.join(lang).join(sub);
|
||||
if ensure_writable_dir(&dir).is_some() {
|
||||
return Some(dir);
|
||||
}
|
||||
if custom.is_some() {
|
||||
return None;
|
||||
}
|
||||
let fallback = std::env::temp_dir()
|
||||
.join("nyx")
|
||||
.join("dynamic")
|
||||
.join("build-pool")
|
||||
.join(lang)
|
||||
.join(sub);
|
||||
ensure_writable_dir(&fallback)
|
||||
}
|
||||
|
||||
fn ensure_writable_dir(dir: &Path) -> Option<PathBuf> {
|
||||
std::fs::create_dir_all(dir).ok()?;
|
||||
let probe = dir.join(format!(".nyx-write-probe-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"ok").ok()?;
|
||||
let _ = std::fs::remove_file(probe);
|
||||
Some(dir.to_path_buf())
|
||||
}
|
||||
|
||||
/// Construct a `Command` for `bin` with a scrubbed environment, matching
|
||||
/// the isolation envelope every legacy `prepare_*` build uses: `env_clear`
|
||||
/// plus an inherited `PATH` + `HOME` only. Pools layer their cache env
|
||||
/// (`CARGO_TARGET_DIR`, `CCACHE_DIR`, `GOCACHE`, …) on top of this.
|
||||
pub(crate) fn base_command(bin: &str) -> Command {
|
||||
let mut cmd = Command::new(bin);
|
||||
let tmp = build_temp_dir();
|
||||
cmd.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||
.env("TMPDIR", &tmp)
|
||||
.env("TMP", &tmp)
|
||||
.env("TEMP", &tmp);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn build_temp_dir() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join("nyx-build-tmp");
|
||||
if std::fs::create_dir_all(&dir).is_ok() {
|
||||
return dir;
|
||||
}
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
/// Hermetic Bundler / RubyGems environment pinned to a writable per-workdir
|
||||
/// vendor directory.
|
||||
///
|
||||
/// Points `GEM_HOME` and `BUNDLE_PATH` at `<workdir>/vendor/bundle` so every
|
||||
/// gem *install* lands in a directory the current user owns. This is the
|
||||
/// load-bearing fix for the harness build invoking `sudo`: legacy Bundler
|
||||
/// (1.x) shells out to `sudo` when the install target — the root-owned system
|
||||
/// gem dir (`/Library/Ruby/Gems/...`) — is not writable, which then blocks on
|
||||
/// a terminal password prompt (`sudo: a terminal is required to read the
|
||||
/// password`). With a writable target there is no privilege escalation and
|
||||
/// no prompt, ever.
|
||||
///
|
||||
/// `GEM_PATH` is deliberately left unset so RubyGems still includes the system
|
||||
/// gem path when *resolving* (paired with `BUNDLE_DISABLE_SHARED_GEMS=false`),
|
||||
/// letting an already-installed gem satisfy the Gemfile without a network
|
||||
/// fetch — while installs of missing gems still land in the writable vendor
|
||||
/// dir. `BUNDLE_APP_CONFIG` keeps Bundler's per-project config writable and
|
||||
/// inside the workdir.
|
||||
///
|
||||
/// Returned as env pairs (not applied to a `Command` here) so both the pooled
|
||||
/// path ([`ruby::RubyPool`]) and the legacy direct-spawn path
|
||||
/// ([`crate::dynamic::build_sandbox`]) layer them on identically. Setting
|
||||
/// these env vars is Bundler-version-agnostic: 1.x and 2.x both honour
|
||||
/// `BUNDLE_*` / `GEM_*`, unlike the 2.x-only `bundle config set` CLI the old
|
||||
/// path relied on (which is a silent no-op on 1.x, leaving the install target
|
||||
/// pointed at the system dir — the original root cause).
|
||||
pub(crate) fn ruby_hermetic_env(workdir: &Path) -> Vec<(&'static str, std::ffi::OsString)> {
|
||||
let gem_dir = workdir.join("vendor").join("bundle");
|
||||
let _ = std::fs::create_dir_all(&gem_dir);
|
||||
vec![
|
||||
("GEM_HOME", gem_dir.clone().into_os_string()),
|
||||
("BUNDLE_PATH", gem_dir.into_os_string()),
|
||||
("BUNDLE_DISABLE_SHARED_GEMS", "false".into()),
|
||||
("BUNDLE_FROZEN", "false".into()),
|
||||
(
|
||||
"BUNDLE_APP_CONFIG",
|
||||
workdir.join(".bundle").into_os_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Merge a process's stdout and stderr into one diagnostic blob.
|
||||
///
|
||||
/// Some build tools split their failure diagnostics across streams — Bundler
|
||||
/// in particular prints "Could not find gem …" to stdout while only an
|
||||
/// unrelated RubyGems extension warning lands on stderr. Capturing both keeps
|
||||
/// the downstream host-limitation classifier from missing the real reason.
|
||||
pub(crate) fn combine_output(stdout: &[u8], stderr: &[u8]) -> String {
|
||||
let out = String::from_utf8_lossy(stdout);
|
||||
let err = String::from_utf8_lossy(stderr);
|
||||
match (out.trim().is_empty(), err.trim().is_empty()) {
|
||||
(true, _) => err.into_owned(),
|
||||
(false, true) => out.into_owned(),
|
||||
(false, false) => format!("{out}\n{err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect a runnable `ccache` binary (honouring `NYX_CCACHE_BIN`). Shared
|
||||
/// by the C and C++ pools to front their compiler with the shared object
|
||||
/// cache; `None` means "compile bare", preserving legacy parity.
|
||||
pub(crate) fn detect_ccache() -> Option<String> {
|
||||
let bin = std::env::var("NYX_CCACHE_BIN").unwrap_or_else(|_| "ccache".to_owned());
|
||||
binary_runnable(&bin, "--version").then_some(bin)
|
||||
}
|
||||
|
||||
/// Cheap "is this binary runnable" probe used by every pool's
|
||||
/// [`BuildPool::is_healthy`] / `try_new`. Runs `bin <probe_arg>` with a
|
||||
/// scrubbed env and reports whether it exited 0.
|
||||
pub(crate) fn binary_runnable(bin: &str, probe_arg: &str) -> bool {
|
||||
base_command(bin)
|
||||
.arg(probe_arg)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct EnvGuard {
|
||||
prior: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(value: Option<&str>) -> Self {
|
||||
let prior = std::env::var("NYX_DYNAMIC_BUILD_POOL").ok();
|
||||
match value {
|
||||
Some(v) => unsafe { std::env::set_var("NYX_DYNAMIC_BUILD_POOL", v) },
|
||||
None => unsafe { std::env::remove_var("NYX_DYNAMIC_BUILD_POOL") },
|
||||
}
|
||||
Self { prior }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.prior.take() {
|
||||
Some(v) => unsafe { std::env::set_var("NYX_DYNAMIC_BUILD_POOL", v) },
|
||||
None => unsafe { std::env::remove_var("NYX_DYNAMIC_BUILD_POOL") },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_enables_every_shipped_pool() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _g = EnvGuard::set(None);
|
||||
for lang in POOL_ENABLED_LANGS {
|
||||
assert!(is_pool_enabled(lang), "{lang} pool must default on");
|
||||
}
|
||||
// A language with no pool stays off.
|
||||
assert!(!is_pool_enabled("cobol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_override_disables_node() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _g = EnvGuard::set(Some("node=0"));
|
||||
assert!(!is_pool_enabled("node"));
|
||||
// Other languages keep their default-on state.
|
||||
assert!(is_pool_enabled("python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_override_disables_java() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _g = EnvGuard::set(Some("java=0"));
|
||||
assert!(!is_pool_enabled("java"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_entry_parses_per_lang() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _g = EnvGuard::set(Some("java=1,node=1,python=0"));
|
||||
assert!(is_pool_enabled("java"));
|
||||
assert!(is_pool_enabled("node"));
|
||||
assert!(!is_pool_enabled("python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_keys() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _g = EnvGuard::set(Some("JAVA=0"));
|
||||
assert!(!is_pool_enabled("java"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_treated_as_disabled() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _g = EnvGuard::set(Some("java=maybe"));
|
||||
assert!(!is_pool_enabled("java"));
|
||||
}
|
||||
}
|
||||
87
src/dynamic/build_pool/node.rs
Normal file
87
src/dynamic/build_pool/node.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! Node.js build pool (Phase 23 / Track O.1).
|
||||
//!
|
||||
//! `prepare_node` already snapshots `node_modules` per `package.json` hash.
|
||||
//! What it lacks is a shared npm download cache: a fresh lock hash re-downloads
|
||||
//! every tarball from cold.
|
||||
//!
|
||||
//! [`NodePool`] points `npm_config_cache` at the shared pool root so package
|
||||
//! tarballs are reused across lock hashes, collapsing a cold `npm install` to
|
||||
//! an unpack of already-fetched tarballs. TypeScript harnesses that do not
|
||||
//! need full type checking are run with `--experimental-strip-types` at
|
||||
//! execution time (the runner reads [`strip_types_flag`]); the pool itself only
|
||||
//! owns the install step.
|
||||
|
||||
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct NodePool {
|
||||
npm_bin: String,
|
||||
}
|
||||
|
||||
impl NodePool {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
let npm_bin = std::env::var("NYX_NPM_BIN").unwrap_or_else(|_| "npm".to_owned());
|
||||
if !binary_runnable(&npm_bin, "--version") {
|
||||
return Err(format!("node-pool: {npm_bin} not runnable"));
|
||||
}
|
||||
Ok(NodePool { npm_bin })
|
||||
}
|
||||
}
|
||||
|
||||
/// The Node flag that lets a TS harness skip a full `tsc` compile when the
|
||||
/// spec does not need type checking. Surfaced as a free function so the
|
||||
/// runner can splice it into the harness exec without holding a pool handle.
|
||||
pub fn strip_types_flag() -> &'static str {
|
||||
"--experimental-strip-types"
|
||||
}
|
||||
|
||||
impl BuildPool for NodePool {
|
||||
fn name(&self) -> &'static str {
|
||||
"node"
|
||||
}
|
||||
|
||||
/// Install dependencies declared by `workdir/package.json` into
|
||||
/// `workdir/node_modules`. Args are unused.
|
||||
fn compile_batch(&self, workdir: &Path, _args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
let mut cmd = base_command(&self.npm_bin);
|
||||
cmd.args(["install", "--no-save", "--no-audit", "--no-fund"])
|
||||
.current_dir(workdir);
|
||||
if let Some(cache) = pool_cache_dir("node", "npm-cache") {
|
||||
cmd.env("npm_config_cache", cache);
|
||||
}
|
||||
|
||||
match cmd.output() {
|
||||
Ok(o) if o.status.success() => PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Ok(o) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Err(e) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("node-pool: npm install: {e}"),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
binary_runnable(&self.npm_bin, "--version")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_types_flag_is_the_node_native_ts_flag() {
|
||||
assert_eq!(strip_types_flag(), "--experimental-strip-types");
|
||||
}
|
||||
}
|
||||
110
src/dynamic/build_pool/php.rs
Normal file
110
src/dynamic/build_pool/php.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! PHP build pool (Phase 23 / Track O.1).
|
||||
//!
|
||||
//! Two warm caches keyed off the Composer lockfile:
|
||||
//! - `COMPOSER_CACHE_DIR` points at the shared pool root so package downloads
|
||||
//! are reused across lock hashes, and
|
||||
//! - an opcache file-cache directory is pre-warmed so the harness `php`
|
||||
//! process skips re-parsing the vendored sources on first run.
|
||||
//!
|
||||
//! Both degrade gracefully: a missing `composer` makes `try_new` fail and the
|
||||
//! caller falls back to the legacy
|
||||
//! [`crate::dynamic::build_sandbox::prepare_php`] path; a missing `php` simply
|
||||
//! skips the opcache warm (the install still succeeds).
|
||||
|
||||
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct PhpPool {
|
||||
composer_bin: String,
|
||||
}
|
||||
|
||||
impl PhpPool {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
let composer_bin =
|
||||
std::env::var("NYX_COMPOSER_BIN").unwrap_or_else(|_| "composer".to_owned());
|
||||
if !binary_runnable(&composer_bin, "--version") {
|
||||
return Err(format!("php-pool: {composer_bin} not runnable"));
|
||||
}
|
||||
Ok(PhpPool { composer_bin })
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildPool for PhpPool {
|
||||
fn name(&self) -> &'static str {
|
||||
"php"
|
||||
}
|
||||
|
||||
/// Install `composer.json` deps into `workdir/vendor` then warm the
|
||||
/// shared opcache file-cache. Args are unused.
|
||||
fn compile_batch(&self, workdir: &Path, _args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
let mut cmd = base_command(&self.composer_bin);
|
||||
cmd.args(["install", "--no-interaction", "--no-dev", "--prefer-dist"])
|
||||
.current_dir(workdir)
|
||||
.env("COMPOSER_ALLOW_SUPERUSER", "1");
|
||||
if let Some(cache) = pool_cache_dir("php", "composer-cache") {
|
||||
cmd.env("COMPOSER_CACHE_DIR", cache);
|
||||
}
|
||||
|
||||
match cmd.output() {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("php-pool: composer install: {e}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
warm_opcache(workdir);
|
||||
|
||||
PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
binary_runnable(&self.composer_bin, "--version")
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort opcache file-cache pre-warm: compile every vendored `.php`
|
||||
/// into the shared opcache file-cache so the harness `php` process boots with
|
||||
/// the bytecode already on disk. A missing `php` or partial failure is
|
||||
/// swallowed — the install already succeeded and opcache is a pure speed win.
|
||||
fn warm_opcache(workdir: &Path) {
|
||||
let vendor = workdir.join("vendor");
|
||||
if !vendor.exists() {
|
||||
return;
|
||||
}
|
||||
let php = std::env::var("NYX_PHP_BIN").unwrap_or_else(|_| "php".to_owned());
|
||||
let file_cache = match pool_cache_dir("php", "opcache") {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
let _ = base_command(&php)
|
||||
.arg("-d")
|
||||
.arg("opcache.enable_cli=1")
|
||||
.arg("-d")
|
||||
.arg(format!("opcache.file_cache={}", file_cache.display()))
|
||||
.arg("-d")
|
||||
.arg("opcache.file_cache_only=1")
|
||||
.arg("-r")
|
||||
.arg(
|
||||
"foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator('vendor')) \
|
||||
as $f){ if(substr($f,-4)==='.php'){ @opcache_compile_file($f); } }",
|
||||
)
|
||||
.current_dir(workdir)
|
||||
.output();
|
||||
}
|
||||
122
src/dynamic/build_pool/python.rs
Normal file
122
src/dynamic/build_pool/python.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
//! Python build pool (Phase 23 / Track O.1).
|
||||
//!
|
||||
//! `prepare_python` already keys its venv on the requirements hash, so the
|
||||
//! venv itself is the "shared venv per `requirements_hash`". What the legacy
|
||||
//! path lacks is a warm bytecode cache: the first harness to import a package
|
||||
//! pays the `.py` -> `.pyc` compile.
|
||||
//!
|
||||
//! [`PythonPool`] runs `python -m compileall` over the venv's `site-packages`
|
||||
//! once at venv-creation time so every later harness import is a `__pycache__`
|
||||
//! hit. The pip download cache is pointed at the shared pool root so repeated
|
||||
//! installs across requirements hashes reuse wheels.
|
||||
|
||||
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct PythonPool;
|
||||
|
||||
impl PythonPool {
|
||||
pub fn try_new(python_bin: &str) -> Result<Self, String> {
|
||||
if !binary_runnable(python_bin, "--version") {
|
||||
return Err(format!("python-pool: {python_bin} not runnable"));
|
||||
}
|
||||
Ok(PythonPool)
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildPool for PythonPool {
|
||||
fn name(&self) -> &'static str {
|
||||
"python"
|
||||
}
|
||||
|
||||
/// `args[0]` = venv path to create, `args[1]` = python interpreter binary.
|
||||
fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
let venv_path = match args.first() {
|
||||
Some(v) => Path::new(v),
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "python-pool: missing venv path arg".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let python = args.get(1).map(String::as_str).unwrap_or("python3");
|
||||
|
||||
// 1. Create the venv.
|
||||
let create = base_command(python)
|
||||
.args(["-m", "venv", "--clear", "--system-site-packages"])
|
||||
.arg(venv_path)
|
||||
.status();
|
||||
match create {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(s) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("venv create failed: exit {s}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("python-pool: venv create: {e}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Install requirements with the shared wheel cache.
|
||||
let req_path = workdir.join("requirements.txt");
|
||||
if req_path.exists() {
|
||||
let pip = venv_path.join("bin").join("pip");
|
||||
let mut cmd = base_command(&pip.to_string_lossy());
|
||||
cmd.args(["install", "-r"]).arg(&req_path);
|
||||
if let Some(cache) = pool_cache_dir("python", "pip-cache") {
|
||||
cmd.env("PIP_CACHE_DIR", cache);
|
||||
} else {
|
||||
cmd.arg("--no-cache-dir");
|
||||
}
|
||||
match cmd.output() {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("python-pool: pip install: {e}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Warm __pycache__ for the whole venv (best-effort: a partial
|
||||
// failure to byte-compile one module must not fail the build).
|
||||
let venv_python = venv_path.join("bin").join("python");
|
||||
let _ = base_command(&venv_python.to_string_lossy())
|
||||
.args(["-m", "compileall", "-q"])
|
||||
.arg(venv_path)
|
||||
.output();
|
||||
|
||||
PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
// The interpreter is resolved per-request via args; treat the pool as
|
||||
// always healthy and let an unrunnable interpreter surface as a build
|
||||
// error, which the dispatcher already falls back from.
|
||||
true
|
||||
}
|
||||
}
|
||||
120
src/dynamic/build_pool/ruby.rs
Normal file
120
src/dynamic/build_pool/ruby.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! Ruby build pool (Phase 23 / Track O.1).
|
||||
//!
|
||||
//! `prepare_ruby` already vendors gems per `Gemfile.lock` hash. What it lacks
|
||||
//! is a warm Bootsnap cache: the first harness to `require` a gem pays the
|
||||
//! load-path scan + compile.
|
||||
//!
|
||||
//! [`RubyPool`] points `BOOTSNAP_CACHE_DIR` at the shared pool root and runs
|
||||
//! `bundle install` with the shared gem cache. Bootsnap then persists its
|
||||
//! compiled require-cache across findings. Falls back to the legacy path when
|
||||
//! `bundle` is not runnable.
|
||||
|
||||
use super::{
|
||||
BuildPool, PoolCompileResult, base_command, binary_runnable, combine_output, pool_cache_dir,
|
||||
ruby_hermetic_env,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct RubyPool {
|
||||
bundle_bin: String,
|
||||
}
|
||||
|
||||
impl RubyPool {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
let bundle_bin = std::env::var("NYX_BUNDLE_BIN").unwrap_or_else(|_| "bundle".to_owned());
|
||||
if !binary_runnable(&bundle_bin, "--version") {
|
||||
return Err(format!("ruby-pool: {bundle_bin} not runnable"));
|
||||
}
|
||||
Ok(RubyPool { bundle_bin })
|
||||
}
|
||||
|
||||
fn bundle(&self, workdir: &Path) -> std::process::Command {
|
||||
let mut cmd = base_command(&self.bundle_bin);
|
||||
cmd.current_dir(workdir);
|
||||
// Writable gem target → no privilege escalation → never `sudo`.
|
||||
for (k, v) in ruby_hermetic_env(workdir) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if let Some(cache) = pool_cache_dir("ruby", "bootsnap") {
|
||||
cmd.env("BOOTSNAP_CACHE_DIR", cache);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildPool for RubyPool {
|
||||
fn name(&self) -> &'static str {
|
||||
"ruby"
|
||||
}
|
||||
|
||||
/// Resolve `Gemfile` deps into `workdir/vendor/bundle`. Args are unused.
|
||||
fn compile_batch(&self, workdir: &Path, _args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
|
||||
// `bundle check` short-circuits when the host already has every gem.
|
||||
//
|
||||
// Run the check with the *runtime* environment — plain system gems, no
|
||||
// `GEM_HOME`/`BUNDLE_PATH` override. The harness is executed as
|
||||
// `ruby harness.rb`, whose `require 'bundler/setup'` resolves against
|
||||
// the system gem path, so the build-time check must consult that same
|
||||
// path to predict whether the run will succeed. The hermetic
|
||||
// `GEM_HOME` override (below) exists only to give `bundle install` a
|
||||
// writable, sudo-free target for *missing* gems; applying it to the
|
||||
// check breaks Bundler 1.x's ability to see an already-installed system
|
||||
// gem (e.g. `rack`), turning a satisfiable Gemfile into a spurious
|
||||
// BuildFailed.
|
||||
let mut check = base_command(&self.bundle_bin);
|
||||
check.current_dir(workdir);
|
||||
if let Some(cache) = pool_cache_dir("ruby", "bootsnap") {
|
||||
check.env("BOOTSNAP_CACHE_DIR", cache);
|
||||
}
|
||||
if let Ok(o) = check.arg("check").output()
|
||||
&& o.status.success()
|
||||
{
|
||||
return PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
|
||||
// The install target is pinned to a writable vendor dir via
|
||||
// `ruby_hermetic_env` (GEM_HOME / BUNDLE_PATH), so the legacy
|
||||
// `bundle config set --local path …` step is gone: it is 2.x-only
|
||||
// syntax that no-ops on Bundler 1.x (leaving the target pointed at
|
||||
// the root-owned system dir — the `sudo` root cause). `--local`
|
||||
// keeps the build offline: missing gems fail fast with a
|
||||
// host-limitation error instead of reaching for the network.
|
||||
let install = self
|
||||
.bundle(workdir)
|
||||
.args(["install", "--local", "--jobs", "4", "--retry", "0"])
|
||||
.output();
|
||||
match install {
|
||||
Ok(o) if o.status.success() => PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Ok(o) => PoolCompileResult {
|
||||
success: false,
|
||||
// Bundler prints its dependency-resolution diagnostics
|
||||
// ("Could not find gem '…' in any of the gem sources …") to
|
||||
// STDOUT, leaving only the RubyGems extension warning on
|
||||
// stderr. Combine both so the host-limitation classifier at
|
||||
// the verify boundary can see the real reason.
|
||||
stderr: combine_output(&o.stdout, &o.stderr),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Err(e) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("ruby-pool: bundle install: {e}"),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
binary_runnable(&self.bundle_bin, "--version")
|
||||
}
|
||||
}
|
||||
369
src/dynamic/build_pool/rust.rs
Normal file
369
src/dynamic/build_pool/rust.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
//! Rust build pool (Phase 23 / Track O.1).
|
||||
//!
|
||||
//! The legacy [`crate::dynamic::build_sandbox::prepare_rust`] runs a fresh
|
||||
//! `cargo build --release` per finding with a per-workdir `target/`. Every
|
||||
//! harness therefore recompiles the (identical) harness scaffold and all of
|
||||
//! its dependencies from cold.
|
||||
//!
|
||||
//! [`RustPool`] keeps two warm caches keyed on the `Cargo.lock` hash:
|
||||
//! - a shared `CARGO_TARGET_DIR` so incremental artefacts survive across
|
||||
//! per-finding workdirs, and
|
||||
//! - `sccache` as `RUSTC_WRAPPER` when it is on `PATH`, which caches the
|
||||
//! per-crate `rustc` invocations across *different* lock hashes too.
|
||||
//!
|
||||
//! Both degrade gracefully: a missing `sccache` simply drops the wrapper and
|
||||
//! a fresh lock hash gets a fresh (empty) shared target dir. The compile
|
||||
//! itself is byte-for-byte the same `cargo build --release` the legacy path
|
||||
//! runs, so success / failure parity holds.
|
||||
|
||||
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
|
||||
use blake3::Hasher;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct RustPool {
|
||||
cargo_bin: String,
|
||||
/// `Some(path)` when an `sccache` binary is runnable. Wired in as
|
||||
/// `RUSTC_WRAPPER`; `None` falls back to plain `rustc`.
|
||||
sccache_bin: Option<String>,
|
||||
}
|
||||
|
||||
impl RustPool {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
let cargo_bin = std::env::var("NYX_CARGO_BIN").unwrap_or_else(|_| "cargo".to_owned());
|
||||
if !binary_runnable(&cargo_bin, "--version") {
|
||||
return Err(format!("rust-pool: {cargo_bin} not runnable"));
|
||||
}
|
||||
let sccache_bin = detect_sccache();
|
||||
Ok(RustPool {
|
||||
cargo_bin,
|
||||
sccache_bin,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_sccache() -> Option<String> {
|
||||
let bin = std::env::var("NYX_SCCACHE_BIN").unwrap_or_else(|_| "sccache".to_owned());
|
||||
binary_runnable(&bin, "--version").then_some(bin)
|
||||
}
|
||||
|
||||
impl BuildPool for RustPool {
|
||||
fn name(&self) -> &'static str {
|
||||
"rust"
|
||||
}
|
||||
|
||||
/// `args[0]` = absolute path the compiled `nyx_harness` binary must land
|
||||
/// at (the caller's cache slot).
|
||||
fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult {
|
||||
let start = Instant::now();
|
||||
let dest = match args.first() {
|
||||
Some(d) => Path::new(d),
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "rust-pool: missing binary destination arg".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Key the shared target dir on the manifest *and* every `src/` file,
|
||||
// not the manifest alone. Two fixtures built for the same cap share a
|
||||
// `Cargo.toml` (identical lock hash) but differ only in their source;
|
||||
// a manifest-only key routed both into the same `release/nyx_harness`
|
||||
// slot, letting cargo skip the second fixture's relink so the copy
|
||||
// below shipped the *first* fixture's binary — cross-fixture verdict
|
||||
// corruption (a vuln / benign pair confirming identically). Folding
|
||||
// the source hash in gives each distinct harness its own target dir.
|
||||
let build_hash = hash_build_inputs(workdir);
|
||||
let target_dir = match pool_cache_dir("rust", &build_hash) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: "rust-pool: no shared target dir".to_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Serialise build + copy across processes for this shared target dir.
|
||||
//
|
||||
// The target dir is keyed only on the Cargo manifest hash, so every
|
||||
// fixture that shares a `Cargo.toml` compiles the same bin name
|
||||
// (`nyx_harness`) into the same `release/nyx_harness` path here.
|
||||
// `cargo` already serialises the *build* across processes via its own
|
||||
// target lock, but releases that lock the moment it exits — before the
|
||||
// copy below moves `release/nyx_harness` to the caller's per-fixture
|
||||
// cache slot. A second process's `cargo build` landing in that window
|
||||
// overwrites `release/nyx_harness`, so we copy a *different* fixture's
|
||||
// binary into our slot and poison its build cache (observed as
|
||||
// cross-fixture verdict corruption under a parallel `cargo test`).
|
||||
// Holding this lock across build+copy folds the copy into the existing
|
||||
// serialised section, so it adds the copy's few milliseconds, not a
|
||||
// new build barrier.
|
||||
let _build_lock = TargetDirLock::acquire(&target_dir);
|
||||
|
||||
let mut cmd = base_command(&self.cargo_bin);
|
||||
cmd.args(["build", "--release"])
|
||||
.current_dir(workdir)
|
||||
.env(
|
||||
"CARGO_HOME",
|
||||
std::env::var("CARGO_HOME").unwrap_or_else(|_| default_cargo_home()),
|
||||
)
|
||||
.env(
|
||||
"RUSTUP_HOME",
|
||||
std::env::var("RUSTUP_HOME").unwrap_or_default(),
|
||||
)
|
||||
.env("CARGO_TARGET_DIR", &target_dir);
|
||||
if let Some(sccache) = &self.sccache_bin {
|
||||
cmd.env("RUSTC_WRAPPER", sccache);
|
||||
}
|
||||
|
||||
let output = match cmd.output() {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("rust-pool: cargo build: {e}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
|
||||
let compiled = target_dir.join("release").join("nyx_harness");
|
||||
if let Err(e) = std::fs::copy(&compiled, dest) {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!(
|
||||
"rust-pool: cargo build ok but copy {} -> {} failed: {e}",
|
||||
compiled.display(),
|
||||
dest.display(),
|
||||
),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
PoolCompileResult {
|
||||
success: true,
|
||||
stderr: String::new(),
|
||||
duration: start.elapsed(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(&self) -> bool {
|
||||
binary_runnable(&self.cargo_bin, "--version")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_cargo_home() -> String {
|
||||
std::env::var("HOME")
|
||||
.map(|h| format!("{h}/.cargo"))
|
||||
.unwrap_or_else(|_| ".cargo".to_owned())
|
||||
}
|
||||
|
||||
/// Cross-process advisory lock guarding build+copy for a shared
|
||||
/// `CARGO_TARGET_DIR` (see the call site in [`RustPool::compile_batch`]).
|
||||
///
|
||||
/// Implemented as an atomic `create_new` (O_EXCL) lockfile so it works across
|
||||
/// the separate processes a parallel `cargo test` spawns — an in-process
|
||||
/// `Mutex` would not. A lock older than `STALE_AFTER` is stolen so a crashed
|
||||
/// holder cannot wedge the pool, and acquisition gives up after `MAX_WAIT`
|
||||
/// (proceeding unlocked) so a pathological case degrades to the pre-fix
|
||||
/// behaviour rather than deadlocking.
|
||||
struct TargetDirLock {
|
||||
path: PathBuf,
|
||||
/// Only the process that created the lockfile removes it on drop, so a
|
||||
/// give-up / steal path never deletes another holder's lock.
|
||||
owned: bool,
|
||||
}
|
||||
|
||||
impl TargetDirLock {
|
||||
fn acquire(target_dir: &Path) -> Self {
|
||||
const MAX_WAIT: Duration = Duration::from_secs(300);
|
||||
const STALE_AFTER: Duration = Duration::from_secs(180);
|
||||
let path = target_dir.join(".nyx-pool-build.lock");
|
||||
let start = Instant::now();
|
||||
let mut spins: u64 = 0;
|
||||
loop {
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut f) => {
|
||||
use std::io::Write;
|
||||
let _ = writeln!(f, "{}", std::process::id());
|
||||
return Self { path, owned: true };
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
// Steal a stale lock left behind by a crashed holder.
|
||||
if let Ok(meta) = std::fs::metadata(&path)
|
||||
&& let Ok(mtime) = meta.modified()
|
||||
&& mtime.elapsed().map(|d| d > STALE_AFTER).unwrap_or(false)
|
||||
{
|
||||
let _ = std::fs::remove_file(&path);
|
||||
continue;
|
||||
}
|
||||
if start.elapsed() > MAX_WAIT {
|
||||
// Best-effort: a slow build beats a deadlock.
|
||||
return Self { path, owned: false };
|
||||
}
|
||||
let nap = 10u64.saturating_add(spins.min(40).saturating_mul(2));
|
||||
std::thread::sleep(Duration::from_millis(nap));
|
||||
spins = spins.saturating_add(1);
|
||||
}
|
||||
Err(_) => {
|
||||
// Cannot create the lockfile (perms / race on dir) — proceed
|
||||
// unlocked rather than fail the build outright.
|
||||
return Self { path, owned: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TargetDirLock {
|
||||
fn drop(&mut self) {
|
||||
if self.owned {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable short hash of the named manifest files under `workdir`.
|
||||
fn hash_files(workdir: &Path, files: &[&str]) -> String {
|
||||
let mut h = Hasher::new();
|
||||
for fname in files {
|
||||
if let Ok(content) = std::fs::read(workdir.join(fname)) {
|
||||
h.update(fname.as_bytes());
|
||||
h.update(&content);
|
||||
}
|
||||
}
|
||||
let out = h.finalize();
|
||||
format!(
|
||||
"{:016x}",
|
||||
u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
/// Hash of every input that determines the compiled `nyx_harness` binary: the
|
||||
/// Cargo manifest/lock *plus* every `.rs` file under `src/`. Used to key the
|
||||
/// shared `CARGO_TARGET_DIR` so source-distinct harnesses never share a
|
||||
/// `release/nyx_harness` slot (see the call site in [`RustPool::compile_batch`]
|
||||
/// for why manifest-only keying corrupted cross-fixture verdicts). Mirrors
|
||||
/// [`crate::dynamic::build_sandbox::compute_rust_lockfile_hash`].
|
||||
fn hash_build_inputs(workdir: &Path) -> String {
|
||||
let manifest = hash_files(workdir, &["Cargo.lock", "Cargo.toml"]);
|
||||
let src_dir = workdir.join("src");
|
||||
let mut rs_files: Vec<PathBuf> = Vec::new();
|
||||
collect_rs_files(&src_dir, &src_dir, &mut rs_files);
|
||||
rs_files.sort();
|
||||
let mut h = Hasher::new();
|
||||
for rel in &rs_files {
|
||||
if let Ok(content) = std::fs::read(src_dir.join(rel)) {
|
||||
h.update(rel.to_string_lossy().as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&content);
|
||||
}
|
||||
}
|
||||
let out = h.finalize();
|
||||
format!(
|
||||
"{manifest}-{:016x}",
|
||||
u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively collect `.rs` file paths (relative to `root`) under `dir`.
|
||||
fn collect_rs_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_rs_files(root, &path, out);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("rs")
|
||||
&& let Ok(rel) = path.strip_prefix(root)
|
||||
{
|
||||
out.push(rel.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_is_deterministic_and_content_sensitive() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let h1 = hash_files(dir.path(), &["Cargo.lock"]);
|
||||
let h2 = hash_files(dir.path(), &["Cargo.lock"]);
|
||||
assert_eq!(h1, h2);
|
||||
std::fs::write(dir.path().join("Cargo.lock"), b"[[package]]\n").unwrap();
|
||||
let h3 = hash_files(dir.path(), &["Cargo.lock"]);
|
||||
assert_ne!(h1, h3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hash_differs_for_same_manifest_distinct_source() {
|
||||
// A vuln / benign pair built for the same cap ships an identical
|
||||
// Cargo.toml but a different `src/entry.rs`. The shared target-dir key
|
||||
// must differ between them, else cargo skips the second relink and the
|
||||
// pool copies out the first fixture's binary (cross-fixture verdict
|
||||
// corruption — the cmdi / data-exfil Rust regression).
|
||||
let manifest = b"[package]\nname=\"nyx_harness\"\nversion=\"0.0.0\"\n";
|
||||
|
||||
let vuln = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(vuln.path().join("src")).unwrap();
|
||||
std::fs::write(vuln.path().join("Cargo.toml"), manifest).unwrap();
|
||||
std::fs::write(vuln.path().join("src/main.rs"), b"fn main(){}\n").unwrap();
|
||||
std::fs::write(
|
||||
vuln.path().join("src/entry.rs"),
|
||||
b"pub fn run(){ /*vuln*/ }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let benign = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(benign.path().join("src")).unwrap();
|
||||
std::fs::write(benign.path().join("Cargo.toml"), manifest).unwrap();
|
||||
std::fs::write(benign.path().join("src/main.rs"), b"fn main(){}\n").unwrap();
|
||||
std::fs::write(
|
||||
benign.path().join("src/entry.rs"),
|
||||
b"pub fn run(){ /*benign*/ }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Identical manifests collide under the old manifest-only key …
|
||||
assert_eq!(
|
||||
hash_files(vuln.path(), &["Cargo.lock", "Cargo.toml"]),
|
||||
hash_files(benign.path(), &["Cargo.lock", "Cargo.toml"]),
|
||||
);
|
||||
// … but the source-aware key separates them.
|
||||
assert_ne!(
|
||||
hash_build_inputs(vuln.path()),
|
||||
hash_build_inputs(benign.path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_dest_arg_is_an_error_not_a_panic() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
// Construct without a toolchain probe so the test runs JDK/cargo-free.
|
||||
let pool = RustPool {
|
||||
cargo_bin: "cargo".to_owned(),
|
||||
sccache_bin: None,
|
||||
};
|
||||
let r = pool.compile_batch(dir.path(), &[]);
|
||||
assert!(!r.success);
|
||||
assert!(r.stderr.contains("missing binary destination"));
|
||||
}
|
||||
}
|
||||
2879
src/dynamic/build_sandbox.rs
Normal file
2879
src/dynamic/build_sandbox.rs
Normal file
File diff suppressed because it is too large
Load diff
214
src/dynamic/corpus.rs
Normal file
214
src/dynamic/corpus.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// Legacy [`Oracle::OutputContains`] is intentionally retained for
|
||||
// pre-Phase-06 corpus entries until they migrate to
|
||||
// [`Oracle::SinkProbe`]. The deprecation warning is informational, not a
|
||||
// signal to migrate inside this module.
|
||||
#![allow(deprecated)]
|
||||
|
||||
//! Per-capability payload corpus, keyed by `(Cap, Lang)`.
|
||||
//!
|
||||
//! Each `(Cap, Lang)` pair maps to a small set of canonical payloads plus a
|
||||
//! matching detection oracle. Payloads are static data — adding a new one
|
||||
//! is a code review, not a runtime config knob, so they cannot drift
|
||||
//! between versions.
|
||||
//!
|
||||
//! Differential confirmation (§4.1): every non-benign payload either
|
||||
//! references a paired benign control (resolved inside the same
|
||||
//! `(cap, lang)` slice) or carries a written
|
||||
//! [`CuratedPayload::no_benign_control_rationale`] explaining why no
|
||||
//! control is meaningful. The [`audit`] module enforces this both at
|
||||
//! compile time and via the runtime `corpus_registry::audit` test.
|
||||
//!
|
||||
//! # Module layout
|
||||
//!
|
||||
//! ```text
|
||||
//! corpus.rs — types, public re-exports, module root
|
||||
//! corpus/registry.rs — CapCorpus, CORPUS, payloads_for{,_lang}
|
||||
//! corpus/audit.rs — compile-time + runtime audits
|
||||
//! corpus/<cap>/<lang>.rs — per-(cap, lang) `pub const PAYLOADS`
|
||||
//! ```
|
||||
//!
|
||||
//! Adding a new language for a cap means: drop a new file under
|
||||
//! `corpus/<cap>/<lang>.rs`, register `pub mod <lang>;` in the cap's
|
||||
//! `mod.rs`, and wire `(Cap::<CAP>, Lang::<Lang>, <cap>::<lang>::PAYLOADS)`
|
||||
//! into `registry::ENTRIES`. No other file needs to change.
|
||||
//!
|
||||
//! # Corpus governance (§16.1)
|
||||
//!
|
||||
//! Every payload carries [`PayloadProvenance`], a [`CuratedPayload::since_corpus_version`],
|
||||
//! and at least one [`CuratedPayload::fixture_paths`] entry. The [`CORPUS_VERSION`] const
|
||||
//! tracks the history of incompatible corpus changes; bumping it
|
||||
//! invalidates all `dynamic_verdict_cache` entries whose spec touched the
|
||||
//! changed cap.
|
||||
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
use crate::labels::Cap;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub mod audit;
|
||||
pub mod registry;
|
||||
|
||||
mod cmdi;
|
||||
mod crypto;
|
||||
mod data_exfil;
|
||||
mod deserialize;
|
||||
mod fmt_string;
|
||||
mod header_injection;
|
||||
mod json_parse;
|
||||
mod ldap;
|
||||
mod open_redirect;
|
||||
// `pub(crate)` so the Java emitter can read the FILE_IO canary filename /
|
||||
// marker consts it must stage into the servlet harness workdir.
|
||||
pub(crate) mod path_trav;
|
||||
mod prototype_pollution;
|
||||
mod sqli;
|
||||
mod ssrf;
|
||||
mod ssti;
|
||||
mod unauthorized_id;
|
||||
mod xpath;
|
||||
mod xss;
|
||||
mod xxe;
|
||||
|
||||
pub use registry::{
|
||||
CORPUS, CORPUS_UNSUPPORTED_LANG_NEUTRAL, audit_marker_collisions, benign_payload_for,
|
||||
benign_payload_for_lang, materialise_bytes, payloads_for, payloads_for_lang,
|
||||
resolve_benign_control, resolve_benign_control_lang,
|
||||
};
|
||||
|
||||
/// Re-exported canonical [`Oracle`] type.
|
||||
///
|
||||
/// The actual enum lives in [`crate::dynamic::oracle`] alongside
|
||||
/// [`crate::dynamic::oracle::ProbePredicate`] and
|
||||
/// [`crate::dynamic::oracle::oracle_fired`]. Re-exported here so the
|
||||
/// `CuratedPayload.oracle: Oracle` field reads naturally and existing
|
||||
/// `crate::dynamic::corpus::Oracle` callers keep working.
|
||||
pub use crate::dynamic::oracle::Oracle;
|
||||
|
||||
/// Bump when the corpus content changes in a way that invalidates previously-
|
||||
/// computed [`crate::dynamic::spec::HarnessSpec::spec_hash`] values.
|
||||
///
|
||||
/// # Bump history
|
||||
///
|
||||
/// | Version | Date | Change |
|
||||
/// |---------|------------|-----------------------------------------------|
|
||||
/// | 1 | 2025-11-01 | Initial corpus (SQLi, CMDI, PATH_TRAV, SSRF, XSS) |
|
||||
/// | 2 | 2025-12-15 | SSRF OOB-variant added; oracle semantics tightened |
|
||||
/// | 3 | 2026-05-12 | Migrated to `CuratedPayload`; provenance + fixture_paths enforced; SSRF OOB-nonce slot added |
|
||||
/// | 4 | 2026-05-14 | Phase 07: `benign_control` paired refs + benign payloads added to SQLI / CMDI / SSRF (file-scheme) |
|
||||
/// | 5 | 2026-05-16 | FMT_STRING SinkCrash payload + benign control (Phase 08 unrelated-crash acceptance fixture) |
|
||||
/// | 6 | 2026-05-17 | Phase 02 / Track J.0: `(Cap, Lang)` registry refactor; `no_benign_control_rationale` field; compile-time provenance audit |
|
||||
/// | 7 | 2026-05-17 | Phase 03 / Track J.1: `DESERIALIZE` cap lit for Java / Python / PHP / Ruby; `ProbeKind::Deserialize` + `ProbePredicate::DeserializeGadgetInvoked` |
|
||||
/// | 8 | 2026-05-17 | Phase 04 / Track J.2: `SSTI` cap lit for Jinja2 / ERB / Twig / Thymeleaf / Handlebars; `ProbePredicate::TemplateEvalEqual` |
|
||||
/// | 9 | 2026-05-17 | Phase 05 / Track J.3: `XXE` cap lit for Java / Python / PHP / Ruby / Go; `ProbeKind::Xxe` + `ProbePredicate::XxeEntityExpanded` |
|
||||
/// | 10 | 2026-05-17 | Phase 06 / Track J.4: `LDAP_INJECTION` cap lit for Java / Python / PHP; `ProbeKind::Ldap` + `ProbePredicate::LdapResultCountGreaterThan`; `StubKind::Ldap` + in-sandbox LDAP server stub |
|
||||
/// | 11 | 2026-05-17 | Phase 07 / Track J.5: `XPATH_INJECTION` cap lit for Java / Python / PHP / JS; `ProbeKind::Xpath`; `LdapResultCountGreaterThan` renamed to `QueryResultCountGreaterThan` (shared by LDAP + XPath); `xpath_corpus.xml` staged in workdir |
|
||||
/// | 12 | 2026-05-18 | Phase 08 / Track J.6: `HEADER_INJECTION` cap lit for Java / Python / PHP / Ruby / JS / Go / Rust; `ProbeKind::HeaderEmit` + `ProbePredicate::HeaderInjected`; per-lang `setHeader` shims |
|
||||
/// | 13 | 2026-05-18 | Phase 09 / Track J.7: `OPEN_REDIRECT` cap lit for Java / Python / PHP / Ruby / JS / Go / Rust; `ProbeKind::Redirect` + `ProbePredicate::RedirectHostNotIn`; per-lang `sendRedirect` / `redirect()` shims |
|
||||
/// | 14 | 2026-05-18 | Phase 10 / Track J.8: `PROTOTYPE_POLLUTION` cap lit for JS / TS; `ProbeKind::PrototypePollution` + `ProbePredicate::PrototypeCanaryTouched`; Node harness installs `Proxy`-style canary trap on `Object.prototype.__nyx_canary` |
|
||||
/// | 15 | 2026-05-18 | Phase 11 / Track J.9: `CRYPTO` (Java/Python/PHP/Go/Rust) + `JSON_PARSE` (JS/Python/Ruby) + `UNAUTHORIZED_ID` (7 langs) + `DATA_EXFIL` (7 langs); `ProbeKind::{WeakKey,IdorAccess,OutboundNetwork}` + `ProbePredicate::{WeakKeyEntropy,IdorBoundaryCrossed,OutboundHostNotIn}`; `UnsupportedReason::SoundOracleUnavailable` for caps with no sound oracle |
|
||||
/// | 16 | 2026-06-01 | Collision-resistant `cmdi` (`CODE_EXEC`) marker: payload `; echo NYX_PWN_$((113*7))_CMDI`, oracle `OutputContains("NYX_PWN_791_CMDI")`. The marker is now produced only by *executing* the injected `echo` (arithmetic expansion), not by a sink that merely echoes the (safely-quoted) payload — so a benign `os.system("echo " + shlex.quote(x))` control no longer false-confirms. Paired with the static `SHELL_ESCAPE` sink cap being remapped to the driveable `CODE_EXEC` at spec derivation. |
|
||||
/// | 17 | 2026-06-01 | Collision-resistant `path_traversal` (`FILE_IO`) Java payload for the entry-driven servlet harness: vuln `../nyx_pt_canary` reads a workdir-root canary the emitter plants; oracle `OutputContains(CANARY_MARKER)` where the marker is the canary's CONTENT (not a substring of the path payload), so a fixture that echoes the requested filename back cannot reproduce it — only an unsanitised read of the canary does. |
|
||||
pub const CORPUS_VERSION: u32 = 17;
|
||||
|
||||
/// Where a payload originated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PayloadProvenance {
|
||||
/// Manually written and reviewed by the Nyx team.
|
||||
Curated,
|
||||
/// Produced by the internal mutation fuzzer (`fuzz/dynamic_corpus/`).
|
||||
/// Still requires human promotion review (§16.4) before landing here.
|
||||
InternalFuzzer,
|
||||
/// Derived from a public CVE or external security report.
|
||||
ExternalReport,
|
||||
}
|
||||
|
||||
/// Reference from a vulnerable payload to its paired benign control.
|
||||
///
|
||||
/// Resolved at call time by scanning the same cap's payload slice for an
|
||||
/// `is_benign == true` entry whose `label` matches. Stored as `&'static
|
||||
/// str` (rather than a back-pointer to [`CuratedPayload`]) so the corpus
|
||||
/// tables stay `const`-declarable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PayloadRef {
|
||||
/// Label of the benign-control entry inside the same cap's payload set.
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
/// A single payload entry in the curated corpus.
|
||||
///
|
||||
/// Governs both static payload bytes (or an OOB-nonce template) and the
|
||||
/// oracle used to confirm the vulnerability fired. All fields are
|
||||
/// `'static` so the corpus can live in read-only memory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CuratedPayload {
|
||||
/// Bytes injected into the [`crate::dynamic::spec::PayloadSlot`].
|
||||
///
|
||||
/// When [`Self::oob_nonce_slot`] is `true` this field is ignored; the
|
||||
/// runner materialises the actual bytes from the OOB listener URL at
|
||||
/// call time.
|
||||
pub bytes: &'static [u8],
|
||||
/// Human label for logs and reports.
|
||||
pub label: &'static str,
|
||||
/// How we decide the sink fired. See [`Oracle`].
|
||||
pub oracle: Oracle,
|
||||
/// If `true`, this is a benign control payload.
|
||||
/// `Confirmed` requires the vuln payload to trigger AND the benign payload
|
||||
/// NOT to trigger (differential confirmation, §4.1).
|
||||
pub is_benign: bool,
|
||||
/// Where this payload came from.
|
||||
pub provenance: PayloadProvenance,
|
||||
/// `CORPUS_VERSION` when this payload was added.
|
||||
pub since_corpus_version: u32,
|
||||
/// `CORPUS_VERSION` at which this payload was deprecated, if any.
|
||||
pub deprecated_at_corpus_version: Option<u32>,
|
||||
/// Source files that exercise this payload in the dynamic harness.
|
||||
/// At least one entry required per §16.1.
|
||||
pub fixture_paths: &'static [&'static str],
|
||||
/// When `true`, the runner generates the actual bytes from the OOB
|
||||
/// listener URL + per-finding nonce at execution time (SSRF OOB variant).
|
||||
/// The `bytes` field is unused for such payloads.
|
||||
pub oob_nonce_slot: bool,
|
||||
/// Structured-oracle predicates evaluated against
|
||||
/// [`crate::dynamic::probe::SinkProbe`] records drained from the run's
|
||||
/// probe channel (Phase 06 — Track C.1). Always populated; empty when
|
||||
/// the payload still relies on the legacy
|
||||
/// [`Oracle::OutputContains`]
|
||||
/// path and has not been migrated to
|
||||
/// [`Oracle::SinkProbe`] yet.
|
||||
pub probe_predicates: &'static [ProbePredicate],
|
||||
/// Paired benign-control payload inside the same cap's slice.
|
||||
///
|
||||
/// `Some(PayloadRef)` on a vulnerable entry means the differential rule
|
||||
/// (Phase 07, §4.1) compares this entry's oracle firing against the
|
||||
/// referenced benign. `None` marks the entry as having no paired
|
||||
/// control — the runner downgrades any would-be `Confirmed` to
|
||||
/// [`crate::evidence::InconclusiveReason::NoBenignControl`].
|
||||
/// Always `None` on benign entries themselves.
|
||||
pub benign_control: Option<PayloadRef>,
|
||||
/// Written rationale required when a non-benign payload has
|
||||
/// `benign_control = None`. Compile-time audit
|
||||
/// ([`audit::audit_benign_controls_runtime`]) rejects any entry that
|
||||
/// elides the paired control without a non-empty explanation here.
|
||||
/// Always `None` on entries that DO carry a `benign_control` and on
|
||||
/// benign entries themselves.
|
||||
pub no_benign_control_rationale: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Backward-compatible type alias.
|
||||
pub type Payload = CuratedPayload;
|
||||
|
||||
/// Read-only registry of `(Cap, Lang)` payload slices.
|
||||
///
|
||||
/// Constructed once as the [`registry::CORPUS`] const. Layered as
|
||||
/// `&'static` slices so the entire registry can live in read-only memory
|
||||
/// and so [`audit`] can walk it in const eval.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CapCorpus {
|
||||
/// `(Cap, Lang, payloads)` triples. A single cap may appear once per
|
||||
/// supported language. See [`registry::payloads_for_lang`] for the
|
||||
/// per-language lookup and [`registry::payloads_for`] for the
|
||||
/// back-compatible union shim.
|
||||
pub entries: &'static [(Cap, Lang, &'static [CuratedPayload])],
|
||||
/// Per-cap probe predicates lifted off individual payloads. Reserved
|
||||
/// for later Track J phases; empty in Phase 02.
|
||||
pub oracles: &'static [(Cap, &'static [ProbePredicate])],
|
||||
}
|
||||
212
src/dynamic/corpus/audit.rs
Normal file
212
src/dynamic/corpus/audit.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
//! Compile-time + runtime audits over the corpus registry.
|
||||
//!
|
||||
//! Two invariants enforced here fail the build (via `const _: () = assert!(...)`)
|
||||
//! if they regress:
|
||||
//!
|
||||
//! 1. **`benign_control` resolves locally.** Every non-benign payload either
|
||||
//! references a benign control whose `label` appears inside the same
|
||||
//! `(cap, lang)` slice, *or* carries an explicit
|
||||
//! [`CuratedPayload::no_benign_control_rationale`] with a non-empty
|
||||
//! written rationale. Without this guard the differential rule
|
||||
//! (§4.1) silently downgrades to `Inconclusive(NoBenignControl)`
|
||||
//! whenever a maintainer forgets to wire a paired benign entry.
|
||||
//!
|
||||
//! 2. **Cap coverage is exhaustive.** The set of caps appearing in
|
||||
//! [`CORPUS`]'s [`entries`](super::CapCorpus::entries) OR [`CORPUS_UNSUPPORTED_LANG_NEUTRAL`] must
|
||||
//! equal [`Cap::all`]. Adding a new `Cap` bit without classifying it
|
||||
//! fails the build.
|
||||
//!
|
||||
//! The runtime `corpus_registry::audit` test mirrors both checks so
|
||||
//! failure surfaces in `cargo test` output, not just `cargo build`.
|
||||
|
||||
use super::CuratedPayload;
|
||||
use super::registry::{CORPUS, CORPUS_UNSUPPORTED_LANG_NEUTRAL};
|
||||
use crate::labels::Cap;
|
||||
|
||||
/// Byte-level equality for `&'static str` usable in const eval.
|
||||
#[allow(dead_code)] // Called from const-eval audit helpers on MSRV/CI compilers.
|
||||
const fn str_eq(a: &str, b: &str) -> bool {
|
||||
let ab = a.as_bytes();
|
||||
let bb = b.as_bytes();
|
||||
if ab.len() != bb.len() {
|
||||
return false;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < ab.len() {
|
||||
if ab[i] != bb[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Walk every `(cap, lang)` slice; for each non-benign payload check that
|
||||
/// either its `benign_control.label` resolves inside the same slice or it
|
||||
/// carries a non-empty `no_benign_control_rationale`.
|
||||
#[allow(dead_code)] // Called from a const assertion; MSRV lints may miss const-eval uses.
|
||||
const fn audit_benign_controls() -> bool {
|
||||
let entries = CORPUS.entries;
|
||||
let mut e = 0;
|
||||
while e < entries.len() {
|
||||
let slice: &[CuratedPayload] = entries[e].2;
|
||||
let mut i = 0;
|
||||
while i < slice.len() {
|
||||
let p = &slice[i];
|
||||
if !p.is_benign {
|
||||
match p.benign_control {
|
||||
Some(r) => {
|
||||
let mut j = 0;
|
||||
let mut found = false;
|
||||
while j < slice.len() {
|
||||
if slice[j].is_benign && str_eq(slice[j].label, r.label) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if !found {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
None => match p.no_benign_control_rationale {
|
||||
Some(rationale) => {
|
||||
if rationale.is_empty() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
None => return false,
|
||||
},
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
e += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// OR of cap bits appearing in `CORPUS.entries`.
|
||||
const fn registered_cap_bits() -> u32 {
|
||||
let entries = CORPUS.entries;
|
||||
let mut bits = 0u32;
|
||||
let mut i = 0;
|
||||
while i < entries.len() {
|
||||
bits |= entries[i].0.bits();
|
||||
i += 1;
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// Compile-time guards. Bumping or breaking these fails `cargo build`.
|
||||
const _: () = assert!(
|
||||
audit_benign_controls(),
|
||||
"corpus audit: a non-benign payload references a `benign_control` whose \
|
||||
label does not resolve inside its own (cap, lang) slice AND carries no \
|
||||
`no_benign_control_rationale` — see src/dynamic/corpus/audit.rs.",
|
||||
);
|
||||
|
||||
const _: () = assert!(
|
||||
registered_cap_bits() | CORPUS_UNSUPPORTED_LANG_NEUTRAL == Cap::all().bits(),
|
||||
"corpus audit: union of (cap, lang) entries and \
|
||||
`CORPUS_UNSUPPORTED_LANG_NEUTRAL` does not cover every `Cap` bit. \
|
||||
Add the missing cap to either a `(cap, lang)` slice or the \
|
||||
lang-neutral unsupported list.",
|
||||
);
|
||||
|
||||
/// Runtime mirror of the compile-time benign-control audit.
|
||||
pub fn audit_benign_controls_runtime() -> Result<(), String> {
|
||||
for &(cap, lang, slice) in CORPUS.entries {
|
||||
for p in slice {
|
||||
if p.is_benign {
|
||||
continue;
|
||||
}
|
||||
match p.benign_control {
|
||||
Some(r) => {
|
||||
let found = slice.iter().any(|q| q.is_benign && q.label == r.label);
|
||||
if !found {
|
||||
return Err(format!(
|
||||
"({:?}, {:?}) vuln payload {:?} references missing \
|
||||
benign_control label {:?}",
|
||||
cap, lang, p.label, r.label,
|
||||
));
|
||||
}
|
||||
}
|
||||
None => match p.no_benign_control_rationale {
|
||||
Some(rationale) if !rationale.is_empty() => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"({:?}, {:?}) vuln payload {:?} has neither a \
|
||||
benign_control nor a written \
|
||||
no_benign_control_rationale",
|
||||
cap, lang, p.label,
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runtime mirror of the compile-time cap-coverage audit.
|
||||
pub fn audit_cap_coverage_runtime() -> Result<(), String> {
|
||||
let covered = registered_cap_bits() | CORPUS_UNSUPPORTED_LANG_NEUTRAL;
|
||||
if covered != Cap::all().bits() {
|
||||
let missing = Cap::all().bits() & !covered;
|
||||
return Err(format!(
|
||||
"Cap bits {missing:#x} are neither registered in CORPUS.entries \
|
||||
nor listed in CORPUS_UNSUPPORTED_LANG_NEUTRAL",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Track J.0 deferred audit: a non-benign payload's `benign_control.label`
|
||||
/// must be unique *within its own `(cap, lang)` slice* — and a benign
|
||||
/// payload's label may not collide with any other benign label inside the
|
||||
/// same cap across lang slices, otherwise the lang-agnostic union shim
|
||||
/// could resolve a vuln payload in language A against a benign payload
|
||||
/// declared in language B (the latent §4.1 bug captured in the deferred
|
||||
/// queue).
|
||||
pub fn audit_benign_label_uniqueness_runtime() -> Result<(), String> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut by_cap: HashMap<u32, HashMap<&'static str, crate::symbol::Lang>> = HashMap::new();
|
||||
for &(cap, lang, slice) in CORPUS.entries {
|
||||
let bucket = by_cap.entry(cap.bits()).or_default();
|
||||
for p in slice {
|
||||
if !p.is_benign {
|
||||
continue;
|
||||
}
|
||||
if let Some(prev_lang) = bucket.insert(p.label, lang)
|
||||
&& prev_lang != lang
|
||||
{
|
||||
return Err(format!(
|
||||
"benign label {:?} for cap {:#x} is registered in both \
|
||||
{:?} and {:?} — lang-agnostic resolve_benign_control \
|
||||
could match the wrong language",
|
||||
p.label,
|
||||
cap.bits(),
|
||||
prev_lang,
|
||||
lang,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod corpus_registry {
|
||||
use super::*;
|
||||
|
||||
/// Plan §02 acceptance: `cargo test corpus_registry::audit` must pass.
|
||||
/// The test name and module name jointly form the required path.
|
||||
#[test]
|
||||
fn audit() {
|
||||
audit_benign_controls_runtime().expect("benign_control audit failed");
|
||||
audit_cap_coverage_runtime().expect("cap coverage audit failed");
|
||||
audit_benign_label_uniqueness_runtime().expect("benign label uniqueness audit failed");
|
||||
}
|
||||
}
|
||||
46
src/dynamic/corpus/cmdi/c.rs
Normal file
46
src/dynamic/corpus/cmdi/c.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! C `Cap::CODE_EXEC` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-c",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/c/cmdi/cmdi_exec.c",
|
||||
"tests/benchmark/corpus/c/cmdi/cmdi_fgets.c",
|
||||
"tests/benchmark/corpus/c/cmdi/cmdi_popen.c",
|
||||
"tests/benchmark/corpus/c/cmdi/cmdi_system.c",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-c",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-c",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/c/cmdi/cmdi_exec.c",
|
||||
"tests/benchmark/corpus/c/cmdi/cmdi_fgets.c",
|
||||
"tests/benchmark/corpus/c/cmdi/cmdi_popen.c",
|
||||
"tests/benchmark/corpus/c/cmdi/cmdi_system.c",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
52
src/dynamic/corpus/cmdi/cpp.rs
Normal file
52
src/dynamic/corpus/cmdi/cpp.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! C++ `Cap::CODE_EXEC` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-cpp",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_class_inline_method.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_exec.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_getline.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_lambda_passthrough.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_popen.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_stl_vector_string.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_system.cpp",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-cpp",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-cpp",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_class_inline_method.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_exec.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_getline.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_lambda_passthrough.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_popen.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_stl_vector_string.cpp",
|
||||
"tests/benchmark/corpus/cpp/cmdi/cmdi_system.cpp",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
46
src/dynamic/corpus/cmdi/go.rs
Normal file
46
src/dynamic/corpus/cmdi/go.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! Go `Cap::CODE_EXEC` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-go",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/go/cmdi/cmdi_direct.go",
|
||||
"tests/benchmark/corpus/go/cmdi/cmdi_indirect.go",
|
||||
"tests/benchmark/corpus/go/cmdi/cmdi_unvalidated_queue_element.go",
|
||||
"tests/benchmark/corpus/go/cmdi/vuln_error_log_then_sink.go",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-go",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-go",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/go/cmdi/cmdi_direct.go",
|
||||
"tests/benchmark/corpus/go/cmdi/cmdi_indirect.go",
|
||||
"tests/benchmark/corpus/go/cmdi/cmdi_unvalidated_queue_element.go",
|
||||
"tests/benchmark/corpus/go/cmdi/vuln_error_log_then_sink.go",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
42
src/dynamic/corpus/cmdi/java.rs
Normal file
42
src/dynamic/corpus/cmdi/java.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//! Java `Cap::CODE_EXEC` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-java",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/java/cmdi/CmdiDirect.java",
|
||||
"tests/benchmark/corpus/java/cmdi/CmdiIndirect.java",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-java",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-java",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/java/cmdi/CmdiDirect.java",
|
||||
"tests/benchmark/corpus/java/cmdi/CmdiIndirect.java",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
42
src/dynamic/corpus/cmdi/javascript.rs
Normal file
42
src/dynamic/corpus/cmdi/javascript.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//! JavaScript `Cap::CODE_EXEC` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-javascript",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/javascript/cmdi/cmdi_direct.js",
|
||||
"tests/benchmark/corpus/javascript/cmdi/cmdi_indirect.js",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-javascript",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-javascript",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/javascript/cmdi/cmdi_direct.js",
|
||||
"tests/benchmark/corpus/javascript/cmdi/cmdi_indirect.js",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
12
src/dynamic/corpus/cmdi/mod.rs
Normal file
12
src/dynamic/corpus/cmdi/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! Command-injection (`Cap::CODE_EXEC`) per-language payload slices.
|
||||
|
||||
pub mod c;
|
||||
pub mod cpp;
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod javascript;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
pub mod typescript;
|
||||
42
src/dynamic/corpus/cmdi/php.rs
Normal file
42
src/dynamic/corpus/cmdi/php.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//! PHP `Cap::CODE_EXEC` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-php",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/php/cmdi/cmdi_direct.php",
|
||||
"tests/benchmark/corpus/php/cmdi/cmdi_indirect.php",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-php",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-php",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/php/cmdi/cmdi_direct.php",
|
||||
"tests/benchmark/corpus/php/cmdi/cmdi_indirect.php",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
48
src/dynamic/corpus/cmdi/python.rs
Normal file
48
src/dynamic/corpus/cmdi/python.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//! Python `Cap::CODE_EXEC` payloads.
|
||||
//!
|
||||
//! Same shell-syntax bytes as [`super::rust::PAYLOADS`]; the per-language
|
||||
//! slice exists so the lookup is a per-language assertion rather than a
|
||||
//! cross-language fallback through [`super::super::registry::payloads_for`].
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-python",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/python/cmdi/cmdi_direct.py",
|
||||
"tests/benchmark/corpus/python/cmdi/cmdi_indirect.py",
|
||||
"tests/benchmark/corpus/python/cmdi/cmdi_popen_shell.py",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-python",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-python",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/python/cmdi/cmdi_direct.py",
|
||||
"tests/benchmark/corpus/python/cmdi/cmdi_indirect.py",
|
||||
"tests/benchmark/corpus/python/cmdi/cmdi_popen_shell.py",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
44
src/dynamic/corpus/cmdi/ruby.rs
Normal file
44
src/dynamic/corpus/cmdi/ruby.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
//! Ruby `Cap::CODE_EXEC` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-ruby",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/ruby/cmdi/cmdi_backtick.rb",
|
||||
"tests/benchmark/corpus/ruby/cmdi/cmdi_kernel_open.rb",
|
||||
"tests/benchmark/corpus/ruby/cmdi/cmdi_system.rb",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-ruby",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-ruby",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/ruby/cmdi/cmdi_backtick.rb",
|
||||
"tests/benchmark/corpus/ruby/cmdi/cmdi_kernel_open.rb",
|
||||
"tests/benchmark/corpus/ruby/cmdi/cmdi_system.rb",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
48
src/dynamic/corpus/cmdi/rust.rs
Normal file
48
src/dynamic/corpus/cmdi/rust.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//! Command-injection payloads exercised by Rust fixtures
|
||||
//! (`tests/benchmark/corpus/rust/cmdi/`).
|
||||
//!
|
||||
//! Bytes are shell-syntax, not Rust-specific; Track J phases 03–11 add
|
||||
//! per-language slices (Python `os.system`, PHP `exec`, …) as new fixtures
|
||||
//! land.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/rust/cmdi/cmdi_command.rs",
|
||||
"tests/benchmark/corpus/rust/cmdi/cmdi_args.rs",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
// Benign control: plain text that should never produce the cmdi marker.
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 4,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/rust/cmdi/cmdi_command.rs",
|
||||
"tests/benchmark/corpus/rust/cmdi/cmdi_args.rs",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
42
src/dynamic/corpus/cmdi/typescript.rs
Normal file
42
src/dynamic/corpus/cmdi/typescript.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//! TypeScript `Cap::CODE_EXEC` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-typescript",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/typescript/cmdi/cmdi_async_wrapper.ts",
|
||||
"tests/benchmark/corpus/typescript/cmdi/cmdi_exec_template.ts",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "cmdi-benign-typescript",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-typescript",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/typescript/cmdi/cmdi_async_wrapper.ts",
|
||||
"tests/benchmark/corpus/typescript/cmdi/cmdi_exec_template.ts",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
50
src/dynamic/corpus/crypto/go.rs
Normal file
50
src/dynamic/corpus/crypto/go.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! Go `Cap::CRYPTO` payloads — `math/rand.Intn` weak-key
|
||||
//! generation.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const WEAK_BITS: u32 = 16;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_WEAK",
|
||||
label: "crypto-go-weak-random",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "crypto-go-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_STRONG",
|
||||
label: "crypto-go-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/go/benign.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
61
src/dynamic/corpus/crypto/java.rs
Normal file
61
src/dynamic/corpus/crypto/java.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//! Java `Cap::CRYPTO` payloads — `java.util.Random.nextBytes`
|
||||
//! weak-key generation.
|
||||
//!
|
||||
//! Vuln payload: marker bytes that signal the harness to drive its
|
||||
//! `java.util.Random` key-generation path. The harness emits a key
|
||||
//! bounded inside a 16-bit search space and writes a
|
||||
//! [`crate::dynamic::probe::ProbeKind::WeakKey`] probe — the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::WeakKeyEntropy`]
|
||||
//! predicate fires for `key_int < 2^16`.
|
||||
//!
|
||||
//! Benign control: marker bytes that route the harness through
|
||||
//! `java.security.SecureRandom`, producing a 256-bit key whose
|
||||
//! integer view trivially exceeds the budget.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const WEAK_BITS: u32 = 16;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_WEAK",
|
||||
label: "crypto-java-weak-random",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/java/vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "crypto-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_STRONG",
|
||||
label: "crypto-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/java/benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
26
src/dynamic/corpus/crypto/mod.rs
Normal file
26
src/dynamic/corpus/crypto/mod.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//! Weak-crypto (`Cap::CRYPTO`) per-language payload slices.
|
||||
//!
|
||||
//! Phase 11 (Track J.9) carves a weak-key entropy oracle across the
|
||||
//! five backend languages where homegrown key generation is common
|
||||
//! enough to matter: Java (`java.util.Random.nextBytes` → key bytes),
|
||||
//! Python (`random.randint(0, 0xFFFF)`), PHP (`mt_rand(0, 0xFFFF)`),
|
||||
//! Go (`math/rand.Intn(0x10000)`), Rust (`rand::thread_rng` truncated
|
||||
//! to 16 bits). Every vuln payload triggers the harness's
|
||||
//! instrumented key-generation path with a seed that produces an
|
||||
//! attacker-derivable key bounded inside the 16-bit search space.
|
||||
//! The harness shim writes a
|
||||
//! [`crate::dynamic::probe::ProbeKind::WeakKey { key_int }`] probe
|
||||
//! with the produced integer view of the key bytes; the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::WeakKeyEntropy`]
|
||||
//! predicate fires when `key_int < 2^max_bits` (`max_bits = 16` by
|
||||
//! default). The paired benign control routes the same harness
|
||||
//! through a CSPRNG (`SecureRandom`, `secrets.token_bytes`,
|
||||
//! `random_bytes(32)`, `crypto/rand.Read`, `rand::rngs::OsRng`) so
|
||||
//! the produced `key_int` trivially exceeds the budget and the
|
||||
//! predicate stays clear.
|
||||
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod rust;
|
||||
49
src/dynamic/corpus/crypto/php.rs
Normal file
49
src/dynamic/corpus/crypto/php.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! PHP `Cap::CRYPTO` payloads — `mt_rand` weak-key generation.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const WEAK_BITS: u32 = 16;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_WEAK",
|
||||
label: "crypto-php-weak-random",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "crypto-php-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_STRONG",
|
||||
label: "crypto-php-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
59
src/dynamic/corpus/crypto/python.rs
Normal file
59
src/dynamic/corpus/crypto/python.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//! Python `Cap::CRYPTO` payloads — `random.randint` weak-key
|
||||
//! generation.
|
||||
//!
|
||||
//! Vuln payload: marker bytes that route the harness through
|
||||
//! `random.randint(0, 0xFFFF)`; the harness emits a
|
||||
//! [`crate::dynamic::probe::ProbeKind::WeakKey`] probe and the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::WeakKeyEntropy`]
|
||||
//! predicate fires.
|
||||
//!
|
||||
//! Benign control: marker bytes that route the harness through
|
||||
//! `secrets.token_bytes(32)`.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const WEAK_BITS: u32 = 16;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_WEAK",
|
||||
label: "crypto-python-weak-random",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "crypto-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_STRONG",
|
||||
label: "crypto-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
50
src/dynamic/corpus/crypto/rust.rs
Normal file
50
src/dynamic/corpus/crypto/rust.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! Rust `Cap::CRYPTO` payloads — `rand::thread_rng` weak-key
|
||||
//! generation truncated to 16 bits.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const WEAK_BITS: u32 = 16;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_WEAK",
|
||||
label: "crypto-rust-weak-random",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "crypto-rust-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_CRYPTO_STRONG",
|
||||
label: "crypto-rust-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::WeakKeyEntropy {
|
||||
max_bits: WEAK_BITS,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/crypto/rust/benign.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
49
src/dynamic/corpus/data_exfil/go.rs
Normal file
49
src/dynamic/corpus/data_exfil/go.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! go `Cap::DATA_EXFIL` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"attacker.test",
|
||||
label: "data-exfil-go-external",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "data-exfil-go-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"127.0.0.1",
|
||||
label: "data-exfil-go-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/go/benign.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
49
src/dynamic/corpus/data_exfil/java.rs
Normal file
49
src/dynamic/corpus/data_exfil/java.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! java `Cap::DATA_EXFIL` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"attacker.test",
|
||||
label: "data-exfil-java-external",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "data-exfil-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"127.0.0.1",
|
||||
label: "data-exfil-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
49
src/dynamic/corpus/data_exfil/js.rs
Normal file
49
src/dynamic/corpus/data_exfil/js.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! js `Cap::DATA_EXFIL` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"attacker.test",
|
||||
label: "data-exfil-js-external",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/js/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "data-exfil-js-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"127.0.0.1",
|
||||
label: "data-exfil-js-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/js/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
22
src/dynamic/corpus/data_exfil/mod.rs
Normal file
22
src/dynamic/corpus/data_exfil/mod.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! Data-exfiltration (`Cap::DATA_EXFIL`) per-language payload
|
||||
//! slices.
|
||||
//!
|
||||
//! Phase 11 (Track J.9) carves an outbound-network oracle across
|
||||
//! all seven backend-capable languages. Each harness stands up a
|
||||
//! mock HTTP client that records the destination host of every
|
||||
//! outbound request via a
|
||||
//! [`crate::dynamic::probe::ProbeKind::OutboundNetwork { host }`]
|
||||
//! probe. The
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::OutboundHostNotIn`]
|
||||
//! predicate fires when the captured `host` falls outside the
|
||||
//! configured loopback allowlist (`&["127.0.0.1", "localhost"]`).
|
||||
//! The vuln payload supplies `attacker.test`; the paired benign
|
||||
//! control supplies `127.0.0.1` so the predicate stays clear.
|
||||
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod js;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
49
src/dynamic/corpus/data_exfil/php.rs
Normal file
49
src/dynamic/corpus/data_exfil/php.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! php `Cap::DATA_EXFIL` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"attacker.test",
|
||||
label: "data-exfil-php-external",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "data-exfil-php-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"127.0.0.1",
|
||||
label: "data-exfil-php-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
49
src/dynamic/corpus/data_exfil/python.rs
Normal file
49
src/dynamic/corpus/data_exfil/python.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! python `Cap::DATA_EXFIL` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"attacker.test",
|
||||
label: "data-exfil-python-external",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "data-exfil-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"127.0.0.1",
|
||||
label: "data-exfil-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
49
src/dynamic/corpus/data_exfil/ruby.rs
Normal file
49
src/dynamic/corpus/data_exfil/ruby.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! ruby `Cap::DATA_EXFIL` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"attacker.test",
|
||||
label: "data-exfil-ruby-external",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "data-exfil-ruby-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"127.0.0.1",
|
||||
label: "data-exfil-ruby-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/ruby/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
49
src/dynamic/corpus/data_exfil/rust.rs
Normal file
49
src/dynamic/corpus/data_exfil/rust.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! rust `Cap::DATA_EXFIL` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"attacker.test",
|
||||
label: "data-exfil-rust-external",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "data-exfil-rust-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"127.0.0.1",
|
||||
label: "data-exfil-rust-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::OutboundHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/data_exfil/rust/benign.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
62
src/dynamic/corpus/deserialize/java.rs
Normal file
62
src/dynamic/corpus/deserialize/java.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//! Java `Cap::DESERIALIZE` payloads.
|
||||
//!
|
||||
//! Vuln payload: a base64-encoded `java.io.ObjectInputStream` byte stream
|
||||
//! that materialises a gadget class outside the harness's allowlist.
|
||||
//! The harness's `RestrictedObjectInputStream.resolveClass` intercepts
|
||||
//! the lookup and emits a `ProbeKind::Deserialize { gadget_chain_invoked
|
||||
//! = true }` probe before aborting the chain.
|
||||
//!
|
||||
//! Benign control: a base64-encoded `ObjectInputStream` byte stream of a
|
||||
//! single allow-listed `java.lang.Integer`. The class lives inside the
|
||||
//! resolveClass allowlist so no Deserialize probe is emitted.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// Marker class name embedded in the serialized stream — the
|
||||
// harness allowlist contains `java.lang.Integer` and `java.lang.String`
|
||||
// only. The byte form is a small literal so const-eval can keep it.
|
||||
bytes: b"NYX_GADGET_CLASS:org.nyx.deserialize.Gadget",
|
||||
label: "java-deserialize-gadget",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 7,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/deserialize/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "java-deserialize-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
// Allow-listed payload — the marker carries `java.lang.Integer`,
|
||||
// which the harness resolveClass accepts without writing a probe.
|
||||
bytes: b"NYX_GADGET_CLASS:java.lang.Integer",
|
||||
label: "java-deserialize-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 7,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/deserialize/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
17
src/dynamic/corpus/deserialize/mod.rs
Normal file
17
src/dynamic/corpus/deserialize/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//! Deserialization (`Cap::DESERIALIZE`) per-language payload slices.
|
||||
//!
|
||||
//! Phase 03 (Track J.1) lands the first cap end-to-end: Java
|
||||
//! (`ObjectInputStream.readObject` / `XMLDecoder`), Python (`pickle.loads`
|
||||
//! / `yaml.unsafe_load`), PHP (`unserialize`), and Ruby (`Marshal.load`
|
||||
//! / `YAML.load`). Every vuln payload is paired with a benign control
|
||||
//! whose oracle should *not* fire — the per-language harness shims
|
||||
//! emit a [`crate::dynamic::probe::ProbeKind::Deserialize`] record with
|
||||
//! `gadget_chain_invoked: true` when a non-allowlisted gadget class is
|
||||
//! materialised by the instrumented deserialiser; benign well-formed
|
||||
//! serialized data does not reach the allowlist boundary and so leaves
|
||||
//! no Deserialize probe.
|
||||
|
||||
pub mod java;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
60
src/dynamic/corpus/deserialize/php.rs
Normal file
60
src/dynamic/corpus/deserialize/php.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! PHP `Cap::DESERIALIZE` payloads.
|
||||
//!
|
||||
//! Vuln payload: marker string handed to `unserialize($input)` where the
|
||||
//! harness wraps the call with `['allowed_classes' => false]` and an
|
||||
//! observer on `__wakeup`. When `unserialize` materialises a
|
||||
//! `__PHP_Incomplete_Class` from a non-allowlisted class name, the
|
||||
//! observer emits a `ProbeKind::Deserialize { gadget_chain_invoked:
|
||||
//! true }` probe.
|
||||
//!
|
||||
//! Benign control: serialised primitive (an `int`) that
|
||||
//! `unserialize` materialises without engaging the allowlist boundary.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_GADGET_CLASS:PHP_Object_Injection_RCE",
|
||||
label: "php-unserialize-gadget",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 7,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/deserialize/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "php-unserialize-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
// Allow-listed marker — the harness allowlist accepts
|
||||
// `__primitive_int` as a no-op type representing a serialised
|
||||
// integer literal.
|
||||
bytes: b"NYX_GADGET_CLASS:__primitive_int",
|
||||
label: "php-unserialize-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 7,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/deserialize/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
56
src/dynamic/corpus/deserialize/python.rs
Normal file
56
src/dynamic/corpus/deserialize/python.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! Python `Cap::DESERIALIZE` payloads.
|
||||
//!
|
||||
//! Vuln payload: marker string consumed by the harness shim which calls
|
||||
//! `pickle.Unpickler(...).load()` with `find_class` overridden to record
|
||||
//! a `ProbeKind::Deserialize { gadget_chain_invoked: true }` whenever a
|
||||
//! non-allowlisted class is requested. The harness allowlists
|
||||
//! `builtins.list` / `builtins.dict` / `builtins.int`; the marker class
|
||||
//! `nyx.gadget.RCE` is outside that set.
|
||||
//!
|
||||
//! Benign control: payload requests only allow-listed builtins.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_GADGET_CLASS:nyx.gadget.RCE",
|
||||
label: "python-pickle-gadget",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 7,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/deserialize/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "python-pickle-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_GADGET_CLASS:builtins.list",
|
||||
label: "python-pickle-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 7,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/deserialize/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
57
src/dynamic/corpus/deserialize/ruby.rs
Normal file
57
src/dynamic/corpus/deserialize/ruby.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//! Ruby `Cap::DESERIALIZE` payloads.
|
||||
//!
|
||||
//! Vuln payload: marker string consumed by the harness shim which calls
|
||||
//! `Marshal.load(input)` with `Marshal.const_defined?`-style
|
||||
//! instrumentation that records a `ProbeKind::Deserialize {
|
||||
//! gadget_chain_invoked: true }` probe whenever a non-allowlisted
|
||||
//! constant is materialised. The harness allowlist contains `Integer`
|
||||
//! / `String` / `Array`.
|
||||
//!
|
||||
//! Benign control: marker requests only the allow-listed `Integer`
|
||||
//! constant.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_GADGET_CLASS:Nyx::Gadget::RCE",
|
||||
label: "ruby-marshal-gadget",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 7,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/deserialize/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ruby-marshal-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_GADGET_CLASS:Integer",
|
||||
label: "ruby-marshal-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::DeserializeGadgetInvoked {
|
||||
require_invoked: true,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 7,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/deserialize/ruby/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
56
src/dynamic/corpus/fmt_string/c.rs
Normal file
56
src/dynamic/corpus/fmt_string/c.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! Format-string (`Cap::FMT_STRING`) payloads exercised by C fixtures
|
||||
//! (`tests/dynamic_fixtures/c/free_fn/`).
|
||||
//!
|
||||
//! The vuln payload confirms via a sink-site Crash probe rather than an
|
||||
//! output marker. The bytes themselves are not load-bearing — the
|
||||
//! detection contract is "process aborts inside the entry call AFTER the
|
||||
//! crash-guard installs". Fixtures choose how to crash on the payload;
|
||||
//! the canonical example is a `printf`-family sink that interprets `%n`
|
||||
//! against a controlled destination pointer on a guard-compiled binary.
|
||||
//!
|
||||
//! The benign control must reach the same entry without crashing; its
|
||||
//! bytes carry `NYX_BENIGN` so fixture code can short-circuit before
|
||||
//! the fault path.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::SignalSet;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"%n%n%n%n%n%n%n%n%n%n",
|
||||
label: "fmt-string-percent-n-crash",
|
||||
oracle: Oracle::SinkCrash {
|
||||
signals: SignalSet::all(),
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 5,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/dynamic_fixtures/c/free_fn/sink_fault.c",
|
||||
"tests/dynamic_fixtures/c/free_fn/setup_fault.c",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "fmt-string-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_fmt_NYX_BENIGN",
|
||||
label: "fmt-string-benign",
|
||||
oracle: Oracle::SinkCrash {
|
||||
signals: SignalSet::all(),
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 5,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/c/free_fn/sink_fault.c"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
3
src/dynamic/corpus/fmt_string/mod.rs
Normal file
3
src/dynamic/corpus/fmt_string/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Format-string (`Cap::FMT_STRING`) per-language payload slices.
|
||||
|
||||
pub mod c;
|
||||
56
src/dynamic/corpus/header_injection/go.rs
Normal file
56
src/dynamic/corpus/header_injection/go.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! Go `Cap::HEADER_INJECTION` payloads —
|
||||
//! `http.ResponseWriter.Header().Set` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into the host's `w.Header().Set("Set-
|
||||
//! Cookie", value)` call without CRLF stripping.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with
|
||||
//! `net/url.QueryEscape`. Captured value carries `%0D%0A` so the
|
||||
//! predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-go-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-go-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-go-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/go/benign.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
122
src/dynamic/corpus/header_injection/java.rs
Normal file
122
src/dynamic/corpus/header_injection/java.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
//! Java `Cap::HEADER_INJECTION` payloads —
|
||||
//! `HttpServletResponse.setHeader` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Concatenated into the host's
|
||||
//! `response.setHeader("Set-Cookie", value)` call without CRLF
|
||||
//! stripping, the wire response carries the attacker's second
|
||||
//! header. The harness's instrumented `setHeader` records a
|
||||
//! `ProbeKind::HeaderEmit { name: "Set-Cookie", value: <raw bytes> }`
|
||||
//! probe with the unescaped CRLF intact.
|
||||
//!
|
||||
//! Benign control: same logical session-id, but the harness's
|
||||
//! benign code path runs the value through `URLEncoder.encode(...,
|
||||
//! "UTF-8")` so the carried bytes become
|
||||
//! `nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn`. The
|
||||
//! captured value has no literal `\r\n`; the
|
||||
//! [`ProbePredicate::HeaderInjected`] predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-java-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
// Phase 08 tier-(b): raw-socket wire-frame smuggling payload.
|
||||
// Same CRLF-bearing bytes as the servlet payload above, but pinned
|
||||
// to the `java_raw` fixture (a `java.net.ServerSocket` driven by
|
||||
// `createServer` + `runOnce` that writes raw bytes via
|
||||
// `OutputStream.write(byte[])`). The wire frame captured off the
|
||||
// response socket carries two distinct `Set-Cookie:` lines, so
|
||||
// `HeaderSmuggledInWire { primary: "Set-Cookie", smuggled:
|
||||
// "Set-Cookie" }` fires — proving the smuggled header survived
|
||||
// to the actual wire instead of being CRLF-stripped en route by
|
||||
// Tomcat / Jetty / Undertow.
|
||||
//
|
||||
// Distinct payload (not just an extra predicate on the servlet
|
||||
// row) because every modern Java servlet container response
|
||||
// serializer strips CRLF at the wire-write boundary, so the
|
||||
// wire-frame predicate would never fire against the canonical
|
||||
// servlet fixture.
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-java-raw-wire-smuggle",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/java_raw/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-java-raw-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-java-raw-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/java_raw/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
114
src/dynamic/corpus/header_injection/js.rs
Normal file
114
src/dynamic/corpus/header_injection/js.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! JavaScript `Cap::HEADER_INJECTION` payloads —
|
||||
//! `http.ServerResponse#setHeader` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into the host's
|
||||
//! `res.setHeader('Set-Cookie', value)` call without CRLF stripping.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with
|
||||
//! `encodeURIComponent`. Captured value carries `%0D%0A` so the
|
||||
//! predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-js-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/js/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-js-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-js-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/js/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
// Phase 08 tier-(b): raw-socket wire-frame smuggling payload.
|
||||
// Same CRLF-bearing bytes as the Node payload above, but pinned to
|
||||
// the `js_raw` fixture (a `net.createServer` callback writing raw
|
||||
// bytes via `socket.write`). The wire frame captured off the
|
||||
// response socket carries two distinct `Set-Cookie:` lines, so
|
||||
// `HeaderSmuggledInWire { primary: "Set-Cookie", smuggled:
|
||||
// "Set-Cookie" }` fires — proving the smuggled header survived to
|
||||
// the actual wire instead of being CRLF-stripped en route.
|
||||
//
|
||||
// Distinct payload (not just an extra predicate on the Node row)
|
||||
// because Node's `http.ServerResponse#setHeader` validator strips
|
||||
// CRLF at the wire-write boundary, so the wire-frame predicate
|
||||
// would never fire against the canonical Node fixture. See
|
||||
// `.pitboss/play/deferred.md` (Phase 08 wire-frame option A) for
|
||||
// the framework-level CRLF-strip empirical from session-0018.
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-js-raw-wire-smuggle",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/js_raw/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-js-raw-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-js-raw-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/js_raw/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
31
src/dynamic/corpus/header_injection/mod.rs
Normal file
31
src/dynamic/corpus/header_injection/mod.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! HTTP response-header CRLF injection (`Cap::HEADER_INJECTION`)
|
||||
//! per-language payload slices.
|
||||
//!
|
||||
//! Phase 08 (Track J.6) carves header injection across the seven HTTP
|
||||
//! framework ecosystems Nyx supports: Java (`HttpServletResponse.
|
||||
//! setHeader`), Python (`flask.Response.headers.__setitem__`), PHP
|
||||
//! (`header()`), Ruby (`Rack::Response#set_header`), JavaScript
|
||||
//! (`http.ServerResponse#setHeader`), Go (`http.ResponseWriter.
|
||||
//! Header().Set`), Rust (`axum`-style `HeaderMap::insert`). Every
|
||||
//! vuln payload appends a `\r\n` followed by an injected header line
|
||||
//! (`Set-Cookie: nyx-injected=pwn`) — once the host code splices the
|
||||
//! attacker bytes into the response writer's value argument the wire
|
||||
//! actually carries two headers instead of one. The paired benign
|
||||
//! control passes the same logical value through the per-language URL
|
||||
//! encoder so the captured value carries `%0d%0a` (not the raw
|
||||
//! bytes), the encoded text is preserved verbatim inside a single
|
||||
//! header value, and the differential rule stays clear.
|
||||
//!
|
||||
//! The oracle's
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::HeaderInjected`] reads
|
||||
//! the per-payload `ProbeKind::HeaderEmit { name, value }` records
|
||||
//! and fires when the value contains a literal CRLF byte pair —
|
||||
//! vuln passes, benign clears, fulfilling the §4.1 differential rule.
|
||||
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod js;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
117
src/dynamic/corpus/header_injection/php.rs
Normal file
117
src/dynamic/corpus/header_injection/php.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//! PHP `Cap::HEADER_INJECTION` payloads — `header()` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Concatenated into the host's `header("Set-
|
||||
//! Cookie: " . $value)` call without CRLF stripping, the wire response
|
||||
//! carries the attacker's second header. The harness's instrumented
|
||||
//! `header()` records a `ProbeKind::HeaderEmit` probe with the
|
||||
//! unescaped CRLF intact.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with PHP's
|
||||
//! `urlencode`. Captured value carries `%0D%0A` so the predicate
|
||||
//! stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-php-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-php-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-php-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
// Phase 08 tier-(b): raw-socket wire-frame smuggling payload.
|
||||
// Same CRLF-bearing bytes as the `header()` payload above, but
|
||||
// pinned to the `php_raw` fixture (a `stream_socket_server` driven
|
||||
// by `create_server` + `run_once` that writes raw bytes via
|
||||
// `fwrite($conn, $raw)`). The wire frame captured off the
|
||||
// response socket carries two distinct `Set-Cookie:` lines, so
|
||||
// `HeaderSmuggledInWire { primary: "Set-Cookie", smuggled:
|
||||
// "Set-Cookie" }` fires — proving the smuggled header survived to
|
||||
// the actual wire instead of being CRLF-stripped en route.
|
||||
//
|
||||
// Distinct payload (not just an extra predicate on the `header()`
|
||||
// row) because PHP's built-in `header()` rejects raw CRLF since
|
||||
// 5.1.2 and modern Slim / Laravel / Symfony response serializers
|
||||
// strip CRLF at the wire-write boundary, so the wire-frame
|
||||
// predicate would never fire against the canonical `header()`
|
||||
// fixture.
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-php-raw-wire-smuggle",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/php_raw/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-php-raw-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-php-raw-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/php_raw/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
120
src/dynamic/corpus/header_injection/python.rs
Normal file
120
src/dynamic/corpus/header_injection/python.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! Python `Cap::HEADER_INJECTION` payloads —
|
||||
//! `flask.Response.headers.__setitem__` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a session cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into the host's
|
||||
//! `response.headers["Set-Cookie"] = value` assignment without CRLF
|
||||
//! stripping, the WSGI layer carries the attacker's second header on
|
||||
//! the wire. The harness's instrumented response writer records a
|
||||
//! `ProbeKind::HeaderEmit { name: "Set-Cookie", value: <raw bytes> }`
|
||||
//! probe with the unescaped CRLF intact.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with
|
||||
//! `urllib.parse.quote`. The carried bytes become
|
||||
//! `nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn` — no literal
|
||||
//! CRLF — and the [`ProbePredicate::HeaderInjected`] predicate stays
|
||||
//! clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-python-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
// Phase 08 tier-(b): raw-socket wire-frame smuggling payload.
|
||||
// Same CRLF-bearing bytes as the Flask payload above, but pinned
|
||||
// to the `python_raw` fixture (a `BaseHTTPRequestHandler` writing
|
||||
// raw bytes via `self.wfile.write`). The wire frame captured off
|
||||
// the response socket carries two distinct `Set-Cookie:` lines, so
|
||||
// `HeaderSmuggledInWire { primary: "Set-Cookie", smuggled:
|
||||
// "Set-Cookie" }` fires — proving the smuggled header survived to
|
||||
// the actual wire instead of being CRLF-stripped en route.
|
||||
//
|
||||
// Distinct payload (not just an extra predicate on the Flask row)
|
||||
// because Flask's werkzeug response serializer strips CRLF at the
|
||||
// wire-write boundary, so the wire-frame predicate would never
|
||||
// fire against the canonical Flask fixture. See
|
||||
// `.pitboss/play/deferred.md` (Phase 08 wire-frame option A) for
|
||||
// the framework-level CRLF-strip empirical from session-0018.
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-python-raw-wire-smuggle",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/python_raw/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-python-raw-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-python-raw-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/python_raw/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
114
src/dynamic/corpus/header_injection/ruby.rs
Normal file
114
src/dynamic/corpus/header_injection/ruby.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! Ruby `Cap::HEADER_INJECTION` payloads —
|
||||
//! `Rack::Response#set_header` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into the host's
|
||||
//! `response.set_header("Set-Cookie", value)` call without CRLF
|
||||
//! stripping, the wire response carries the attacker's second header.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with
|
||||
//! `URI.encode_www_form_component`. Captured value carries `%0D%0A`
|
||||
//! so the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-ruby-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-ruby-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-ruby-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/ruby/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
// Phase 08 tier-(b): raw-socket wire-frame smuggling payload.
|
||||
// Same CRLF-bearing bytes as the Rack payload above, but pinned to
|
||||
// the `ruby_raw` fixture (a `TCPServer` driven by `create_server`
|
||||
// + `run_once` that writes raw bytes via `TCPSocket#write`). The
|
||||
// wire frame captured off the response socket carries two
|
||||
// distinct `Set-Cookie:` lines, so `HeaderSmuggledInWire { primary:
|
||||
// "Set-Cookie", smuggled: "Set-Cookie" }` fires — proving the
|
||||
// smuggled header survived to the actual wire instead of being
|
||||
// CRLF-stripped en route.
|
||||
//
|
||||
// Distinct payload (not just an extra predicate on the Rack row)
|
||||
// because Rack / Sinatra / Rails response serializers strip CRLF
|
||||
// at the wire-write boundary, so the wire-frame predicate would
|
||||
// never fire against the canonical Rack fixture.
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-ruby-raw-wire-smuggle",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/ruby_raw/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-ruby-raw-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-ruby-raw-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/ruby_raw/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
116
src/dynamic/corpus/header_injection/rust.rs
Normal file
116
src/dynamic/corpus/header_injection/rust.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! Rust `Cap::HEADER_INJECTION` payloads — `axum`-style
|
||||
//! `HeaderMap::insert` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into a hand-rolled `HeaderMap` insert
|
||||
//! that bypasses the `HeaderValue::from_str` validity check (e.g.
|
||||
//! `HeaderValue::from_bytes(...).unwrap()` over a tainted slice).
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with the
|
||||
//! `percent-encoding` crate. Captured value carries `%0D%0A` so the
|
||||
//! predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-rust-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-rust-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-rust-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust/benign.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
// Phase 08 tier-(b): raw-socket wire-frame smuggling payload.
|
||||
// Same CRLF-bearing bytes as the axum payload above, but pinned to
|
||||
// the `rust_raw` fixture (a `std::net::TcpListener` driven by
|
||||
// `create_server` + `run_once` that writes raw bytes via
|
||||
// `TcpStream::write_all`). The wire frame captured off the
|
||||
// response socket carries two distinct `Set-Cookie:` lines, so
|
||||
// `HeaderSmuggledInWire { primary: "Set-Cookie", smuggled:
|
||||
// "Set-Cookie" }` fires — proving the smuggled header survived to
|
||||
// the actual wire instead of being CRLF-stripped en route.
|
||||
//
|
||||
// Distinct payload (not just an extra predicate on the axum row)
|
||||
// because every framework's response serializer strips CRLF at
|
||||
// the wire-write boundary, so the wire-frame predicate would
|
||||
// never fire against the canonical axum fixture. See
|
||||
// `.pitboss/play/deferred.md` (Phase 08 wire-frame option A) for
|
||||
// the framework-level CRLF-strip empirical from session-0018.
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-rust-raw-wire-smuggle",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust_raw/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-rust-raw-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-rust-raw-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
||||
primary: "Set-Cookie",
|
||||
smuggled: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust_raw/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
54
src/dynamic/corpus/json_parse/go.rs
Normal file
54
src/dynamic/corpus/json_parse/go.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! Go `Cap::JSON_PARSE` payloads.
|
||||
//!
|
||||
//! The depth pair shares a single fixture; the payload tag
|
||||
//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch. Go has
|
||||
//! no prototype-pollution surface so the canary half of the slice is
|
||||
//! intentionally omitted.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const MAX_DEPTH: u32 = 64;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_DEEP",
|
||||
label: "json-parse-go-depth-bomb",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-go-depth-shallow",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_SHALLOW",
|
||||
label: "json-parse-go-depth-shallow",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
59
src/dynamic/corpus/json_parse/java.rs
Normal file
59
src/dynamic/corpus/json_parse/java.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//! Java `Cap::JSON_PARSE` payloads.
|
||||
//!
|
||||
//! The depth pair shares a single fixture; the payload tag
|
||||
//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch. Java has
|
||||
//! no prototype-pollution surface so the canary half of the slice is
|
||||
//! intentionally omitted, matching the PHP / Go / Rust shape.
|
||||
//!
|
||||
//! Java has no stdlib JSON parser, so the harness ships a hand-rolled
|
||||
//! iterative JSON walker as a sibling class (`NyxJsonProbe.java`); the
|
||||
//! fixture calls `NyxJsonProbe.parse(text)` in place of any Jackson /
|
||||
//! Gson dependency so the build path never reaches for an external jar.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const MAX_DEPTH: u32 = 64;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_DEEP",
|
||||
label: "json-parse-java-depth-bomb",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-java-depth-shallow",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_SHALLOW",
|
||||
label: "json-parse-java-depth-shallow",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
93
src/dynamic/corpus/json_parse/javascript.rs
Normal file
93
src/dynamic/corpus/json_parse/javascript.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! JavaScript `Cap::JSON_PARSE` payloads.
|
||||
//!
|
||||
//! Covers two oracle shapes: the prototype-canary pair reuses the
|
||||
//! Phase 10 PROTOTYPE_POLLUTION canary
|
||||
//! ([`crate::dynamic::oracle::ProbePredicate::PrototypeCanaryTouched`])
|
||||
//! against a `JSON.parse`-then-deep-merge fixture, and the depth-bomb
|
||||
//! pair drives `JSON.parse` past the 64-level depth budget for the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::JsonParseExcessiveDepth`]
|
||||
//! oracle. The depth pair shares a single fixture; the payload tag
|
||||
//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::{Canary, ProbePredicate};
|
||||
|
||||
const CANARY: &str = Canary::PLACEHOLDER;
|
||||
const MAX_DEPTH: u32 = 64;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "json-parse-js-proto-key",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse/javascript/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-js-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "json-parse-js-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse/javascript/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_DEEP",
|
||||
label: "json-parse-js-depth-bomb",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/javascript/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-js-depth-shallow",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_SHALLOW",
|
||||
label: "json-parse-js-depth-shallow",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/javascript/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
25
src/dynamic/corpus/json_parse/mod.rs
Normal file
25
src/dynamic/corpus/json_parse/mod.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! JSON-parse pollution (`Cap::JSON_PARSE`) per-language payload
|
||||
//! slices.
|
||||
//!
|
||||
//! Phase 11 (Track J.9) reuses the prototype-canary oracle from
|
||||
//! Phase 10 across the three languages whose JSON parsers have a
|
||||
//! published pollution surface: JavaScript (`JSON.parse` then deep
|
||||
//! assign), Python (`json.loads` then `dict.update` /
|
||||
//! `setattr`-driven attribute pollution), Ruby (`JSON.parse` then
|
||||
//! recursive merge). Every vuln payload binds a JSON literal whose
|
||||
//! top-level key is `__proto__`; the per-language harness's
|
||||
//! instrumented canary trap (`Object.prototype.__nyx_canary` in JS,
|
||||
//! a `dict`/class-scoped sentinel in Python, an `Object.prepend`
|
||||
//! flag in Ruby) records a
|
||||
//! [`crate::dynamic::probe::ProbeKind::PrototypePollution`] probe
|
||||
//! once the malicious key reaches the shared chain. The paired
|
||||
//! benign control sends a JSON literal whose top-level key is the
|
||||
//! regular property `data`, leaving the chain untouched.
|
||||
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod javascript;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
54
src/dynamic/corpus/json_parse/php.rs
Normal file
54
src/dynamic/corpus/json_parse/php.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! PHP `Cap::JSON_PARSE` payloads.
|
||||
//!
|
||||
//! The depth pair shares a single fixture; the payload tag
|
||||
//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch. PHP has
|
||||
//! no prototype-pollution surface so the canary half of the slice is
|
||||
//! intentionally omitted.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const MAX_DEPTH: u32 = 64;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_DEEP",
|
||||
label: "json-parse-php-depth-bomb",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-php-depth-shallow",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_SHALLOW",
|
||||
label: "json-parse-php-depth-shallow",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
88
src/dynamic/corpus/json_parse/python.rs
Normal file
88
src/dynamic/corpus/json_parse/python.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//! Python `Cap::JSON_PARSE` payloads.
|
||||
//!
|
||||
//! The canary cases cover pollution-style parses. The depth cases drive
|
||||
//! `json.loads` past the depth oracle while sharing one fixture for the
|
||||
//! vulnerable and benign attempts.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::{Canary, ProbePredicate};
|
||||
|
||||
const CANARY: &str = Canary::PLACEHOLDER;
|
||||
const MAX_DEPTH: u32 = 64;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "json-parse-python-proto-key",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "json-parse-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_DEEP",
|
||||
label: "json-parse-python-depth-bomb",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-python-depth-shallow",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_SHALLOW",
|
||||
label: "json-parse-python-depth-shallow",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
92
src/dynamic/corpus/json_parse/ruby.rs
Normal file
92
src/dynamic/corpus/json_parse/ruby.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! Ruby `Cap::JSON_PARSE` payloads.
|
||||
//!
|
||||
//! Covers two oracle shapes: the prototype-canary pair reuses the
|
||||
//! Phase 10 PROTOTYPE_POLLUTION canary against a `JSON.parse` then
|
||||
//! recursive `Hash#deep_merge!` fixture, and the depth-bomb pair
|
||||
//! drives `JSON.parse` past the 64-level depth budget for the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::JsonParseExcessiveDepth`]
|
||||
//! oracle. The depth pair shares a single fixture; the payload tag
|
||||
//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::{Canary, ProbePredicate};
|
||||
|
||||
const CANARY: &str = Canary::PLACEHOLDER;
|
||||
const MAX_DEPTH: u32 = 64;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "json-parse-ruby-proto-key",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-ruby-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "json-parse-ruby-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse/ruby/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_DEEP",
|
||||
label: "json-parse-ruby-depth-bomb",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-ruby-depth-shallow",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_SHALLOW",
|
||||
label: "json-parse-ruby-depth-shallow",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
54
src/dynamic/corpus/json_parse/rust.rs
Normal file
54
src/dynamic/corpus/json_parse/rust.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! Rust `Cap::JSON_PARSE` payloads.
|
||||
//!
|
||||
//! The depth pair shares a single fixture; the payload tag
|
||||
//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch. Rust has
|
||||
//! no prototype-pollution surface so the canary half of the slice is
|
||||
//! intentionally omitted.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const MAX_DEPTH: u32 = 64;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_DEEP",
|
||||
label: "json-parse-rust-depth-bomb",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "json-parse-rust-depth-shallow",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"NYX_JSON_SHALLOW",
|
||||
label: "json-parse-rust-depth-shallow",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||
max_depth: MAX_DEPTH,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
53
src/dynamic/corpus/ldap/java.rs
Normal file
53
src/dynamic/corpus/ldap/java.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! Java `Cap::LDAP_INJECTION` payloads — `LdapTemplate.search` /
|
||||
//! `DirContext.search` filter injection.
|
||||
//!
|
||||
//! Vuln payload: a filter fragment whose `*)(uid=*` tail breaks out of
|
||||
//! the host template's `(uid=…)` clause and rewraps the search as
|
||||
//! `(|(uid=…)(uid=*))`, matching every user the directory carries.
|
||||
//! The harness's instrumented LDAP client (talking to
|
||||
//! [`crate::dynamic::stubs::ldap_server`]) records
|
||||
//! `ProbeKind::Ldap { entries_returned: 3 }`.
|
||||
//!
|
||||
//! Benign control: the same intended username quoted through
|
||||
//! `EscapeDN` so the LDAP filter stays pinned to a single entry; the
|
||||
//! shim records `entries_returned: 1` and the oracle does not fire.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"alice*)(uid=*",
|
||||
label: "ldap-java-filter-wildcard",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 10,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ldap-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "ldap-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 10,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
30
src/dynamic/corpus/ldap/mod.rs
Normal file
30
src/dynamic/corpus/ldap/mod.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//! LDAP filter injection (`Cap::LDAP_INJECTION`) per-language payload
|
||||
//! slices.
|
||||
//!
|
||||
//! Phase 06 (Track J.4) carves LDAP filter injection across the three
|
||||
//! most-common directory clients: Java (`LdapTemplate.search` /
|
||||
//! `DirContext.search`), Python (`ldap.search_s`), and PHP
|
||||
//! (`ldap_search`). Every vuln payload appends the canonical
|
||||
//! `*)(uid=*` quote-escape break — once the host code substitutes the
|
||||
//! attacker bytes into its filter template the synthesized LDAP
|
||||
//! filter matches every entry the directory carries (the
|
||||
//! [`crate::dynamic::stubs::ldap_server`] stub returns its three
|
||||
//! provisioned users). The paired benign control quotes the same
|
||||
//! bytes through `EscapeDN` / `ldap.dn.escape_filter_chars` /
|
||||
//! `ldap_escape`, leaving the filter pinned to the originally
|
||||
//! intended single user.
|
||||
//!
|
||||
//! The oracle's
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::QueryResultCountGreaterThan`]
|
||||
//! checks the per-payload `ProbeKind::Ldap.entries_returned` against
|
||||
//! `n = 1` — vuln passes (3 entries), benign clears (1 entry),
|
||||
//! fulfilling the §4.1 differential rule.
|
||||
//!
|
||||
//! C# is intentionally omitted: the [`crate::symbol::Lang`] enum has
|
||||
//! no `CSharp` variant, so the corpus has nowhere to register it.
|
||||
//! Tracked in `.pitboss/play/deferred.md` alongside the Phase 05
|
||||
//! Lang::CSharp gap.
|
||||
|
||||
pub mod java;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
51
src/dynamic/corpus/ldap/php.rs
Normal file
51
src/dynamic/corpus/ldap/php.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
//! PHP `Cap::LDAP_INJECTION` payloads — `ldap_search` filter injection.
|
||||
//!
|
||||
//! Vuln payload: a filter fragment whose `*)(uid=*` tail breaks out of
|
||||
//! the host template's `(uid=…)` clause; the synthesized filter
|
||||
//! becomes `(|(uid=…)(uid=*))` and matches every directory entry.
|
||||
//! The harness's instrumented `ldap_search` records
|
||||
//! `ProbeKind::Ldap { entries_returned: 3 }`.
|
||||
//!
|
||||
//! Benign control: the same intended username quoted via
|
||||
//! `ldap_escape($value, "", LDAP_ESCAPE_FILTER)` — `entries_returned:
|
||||
//! 1`, oracle clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"alice*)(uid=*",
|
||||
label: "ldap-php-filter-wildcard",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 10,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ldap-php-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "ldap-php-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 10,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
52
src/dynamic/corpus/ldap/python.rs
Normal file
52
src/dynamic/corpus/ldap/python.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! Python `Cap::LDAP_INJECTION` payloads — `ldap.search_s` filter
|
||||
//! injection.
|
||||
//!
|
||||
//! Vuln payload: a filter fragment whose `*)(uid=*` tail breaks out of
|
||||
//! the host template's `(uid=…)` clause; the synthesized filter
|
||||
//! becomes `(|(uid=…)(uid=*))` and matches every directory entry.
|
||||
//! The harness's instrumented `ldap.search_s` records
|
||||
//! `ProbeKind::Ldap { entries_returned: 3 }`.
|
||||
//!
|
||||
//! Benign control: the same intended username quoted via
|
||||
//! `ldap.dn.escape_filter_chars`, leaving the filter pinned to a
|
||||
//! single entry — `entries_returned: 1`, oracle clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"alice*)(uid=*",
|
||||
label: "ldap-python-filter-wildcard",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 10,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ldap-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "ldap-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 10,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
89
src/dynamic/corpus/open_redirect/go.rs
Normal file
89
src/dynamic/corpus/open_redirect/go.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! Go `Cap::OPEN_REDIRECT` payloads — `gin.Context.Redirect` /
|
||||
//! `http.Redirect` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL spliced into
|
||||
//! `c.Redirect(http.StatusFound, value)` (or
|
||||
//! `http.Redirect(w, r, value, ...)`) without host validation; the
|
||||
//! harness records a `ProbeKind::Redirect` probe whose `location`
|
||||
//! points off-origin.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
//!
|
||||
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
|
||||
//! [`crate::dynamic::oob::OobListener`] the harness follows the
|
||||
//! captured `Location:` URL via a real `net/http.Get` against the
|
||||
//! loopback nonce URL so the listener records the per-finding
|
||||
//! callback. Ordered first so the runner exercises the OOB observation
|
||||
//! path before the absolute-URL vuln below triggers and short-circuits
|
||||
//! iteration; runs without a listener skip cleanly (runner
|
||||
//! `oob_nonce_slot` branch).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// substitutes a per-finding loopback URL (see runner.rs:405-413).
|
||||
bytes: b"",
|
||||
label: "open-redirect-go-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/vuln.go"],
|
||||
oob_nonce_slot: true,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: Some(
|
||||
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
|
||||
callback when the harness follows the captured Location URL with net/http.Get; \
|
||||
no benign URL can hit the nonce path.",
|
||||
),
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-go-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-go-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-go-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/benign.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
94
src/dynamic/corpus/open_redirect/java.rs
Normal file
94
src/dynamic/corpus/open_redirect/java.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! Java `Cap::OPEN_REDIRECT` payloads —
|
||||
//! `HttpServletResponse.sendRedirect` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: a fully-qualified attacker URL
|
||||
//! (`https://attacker.test/`). Spliced into the host's
|
||||
//! `response.sendRedirect(value)` call without host validation, the
|
||||
//! servlet response's `Location:` header points off-origin. The
|
||||
//! harness's instrumented `sendRedirect` shim records a
|
||||
//! `ProbeKind::Redirect { location: <raw>, request_host:
|
||||
//! "example.com" }` probe; the predicate
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`] sees
|
||||
//! the off-allowlist host and fires.
|
||||
//!
|
||||
//! Benign control: same logical entry point, but the harness's benign
|
||||
//! code path redirects to the relative path `/dashboard` (no host
|
||||
//! component). The captured `location` has no off-origin authority
|
||||
//! and the predicate stays clear.
|
||||
//!
|
||||
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
|
||||
//! [`crate::dynamic::oob::OobListener`] the harness follows the
|
||||
//! captured `Location:` URL via a real `HttpURLConnection.getInputStream()`
|
||||
//! against the loopback nonce URL so the listener records the per-finding
|
||||
//! callback. Ordered first so the runner exercises the OOB observation
|
||||
//! path before the absolute-URL vuln below triggers and short-circuits
|
||||
//! iteration; runs without a listener skip cleanly (runner
|
||||
//! `oob_nonce_slot` branch).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// substitutes a per-finding loopback URL (see runner.rs:405-413).
|
||||
bytes: b"",
|
||||
label: "open-redirect-java-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/java/Vuln.java"],
|
||||
oob_nonce_slot: true,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: Some(
|
||||
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
|
||||
callback when the harness follows the captured Location URL with \
|
||||
HttpURLConnection.getInputStream; no benign URL can hit the nonce path.",
|
||||
),
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-java-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
87
src/dynamic/corpus/open_redirect/js.rs
Normal file
87
src/dynamic/corpus/open_redirect/js.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! JavaScript `Cap::OPEN_REDIRECT` payloads —
|
||||
//! Express `res.redirect` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL spliced into
|
||||
//! `res.redirect(value)` without host validation; the harness
|
||||
//! records a `ProbeKind::Redirect` probe whose `location` points
|
||||
//! off-origin.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
//!
|
||||
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
|
||||
//! [`crate::dynamic::oob::OobListener`] the harness follows the
|
||||
//! captured `Location:` URL via a real `http.get` against the loopback
|
||||
//! nonce URL so the listener records the per-finding callback. Ordered
|
||||
//! first so the runner exercises the OOB observation path before the
|
||||
//! absolute-URL vuln below triggers and short-circuits iteration; runs
|
||||
//! without a listener skip cleanly (runner `oob_nonce_slot` branch).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// substitutes a per-finding loopback URL (see runner.rs:405-413).
|
||||
bytes: b"",
|
||||
label: "open-redirect-js-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/js/vuln.js"],
|
||||
oob_nonce_slot: true,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: Some(
|
||||
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
|
||||
callback when the harness follows the captured Location URL with http.get; \
|
||||
no benign URL can hit the nonce path.",
|
||||
),
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-js-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/js/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-js-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-js-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/js/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
26
src/dynamic/corpus/open_redirect/mod.rs
Normal file
26
src/dynamic/corpus/open_redirect/mod.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//! Open-redirect (`Cap::OPEN_REDIRECT`) per-language payload slices.
|
||||
//!
|
||||
//! Phase 09 (Track J.7) carves open redirects across the seven HTTP
|
||||
//! framework ecosystems Nyx supports: Java
|
||||
//! (`HttpServletResponse.sendRedirect`), Python (`flask.redirect`),
|
||||
//! PHP (Symfony `Response::redirect` / Slim `Response::withHeader`),
|
||||
//! Ruby (`Rack::Response#redirect`), JavaScript (Express
|
||||
//! `res.redirect`), Go (`gin.Context.Redirect`), Rust (`axum::response::
|
||||
//! Redirect::to`). Every vuln payload binds an absolute attacker URL
|
||||
//! (`https://attacker.test/`) into the response writer's redirect
|
||||
//! entry point; the paired benign control redirects to a same-origin
|
||||
//! path (`/dashboard`). The harness's instrumented redirect shim
|
||||
//! records a [`crate::dynamic::probe::ProbeKind::Redirect { location,
|
||||
//! request_host }`] probe with the unmodified location and the
|
||||
//! request's origin host, and the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
|
||||
//! predicate fires when the captured `location` resolves off-origin
|
||||
//! relative to `allowlist ∪ {request_host}`.
|
||||
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod js;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
92
src/dynamic/corpus/open_redirect/php.rs
Normal file
92
src/dynamic/corpus/open_redirect/php.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! PHP `Cap::OPEN_REDIRECT` payloads — `Response::redirect` /
|
||||
//! Symfony `RedirectResponse(...)` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL passed to
|
||||
//! `header("Location: $value")` or
|
||||
//! `new \Symfony\Component\HttpFoundation\RedirectResponse($value)`
|
||||
//! without host validation. The harness records a
|
||||
//! `ProbeKind::Redirect { location, request_host }` probe and the
|
||||
//! predicate fires on the off-allowlist host.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
//!
|
||||
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
|
||||
//! [`crate::dynamic::oob::OobListener`] the harness follows the
|
||||
//! captured `Location:` URL via a real
|
||||
//! `file_get_contents($location, stream_context_create(...))` against
|
||||
//! the loopback nonce URL so the listener records the per-finding
|
||||
//! callback. Ordered first so the runner exercises the OOB observation
|
||||
//! path before the absolute-URL vuln below triggers and short-circuits
|
||||
//! iteration; runs without a listener skip cleanly (runner
|
||||
//! `oob_nonce_slot` branch).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// substitutes a per-finding loopback URL (see runner.rs:405-413).
|
||||
bytes: b"",
|
||||
label: "open-redirect-php-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/php/vuln.php"],
|
||||
oob_nonce_slot: true,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: Some(
|
||||
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
|
||||
callback when the harness follows the captured Location URL with \
|
||||
file_get_contents under a stream context timeout; no benign URL can hit \
|
||||
the nonce path.",
|
||||
),
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-php-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-php-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-php-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
89
src/dynamic/corpus/open_redirect/python.rs
Normal file
89
src/dynamic/corpus/open_redirect/python.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! Python `Cap::OPEN_REDIRECT` payloads — `flask.redirect`
|
||||
//! off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an attacker-controlled absolute URL spliced into
|
||||
//! `flask.redirect(value)` without host validation; the captured
|
||||
//! `Location:` header points off-origin and the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
|
||||
//! predicate fires.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the relative path
|
||||
//! `/dashboard`, so the captured location has no authority component
|
||||
//! and the predicate stays clear.
|
||||
//!
|
||||
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
|
||||
//! [`crate::dynamic::oob::OobListener`] the harness follows the
|
||||
//! captured `Location:` URL via a real `urllib.request.urlopen`
|
||||
//! against the loopback nonce URL so the listener records the per-finding
|
||||
//! callback. Ordered first so the runner exercises the OOB observation
|
||||
//! path before the absolute-URL vuln below triggers and short-circuits
|
||||
//! iteration; runs without a listener skip cleanly (runner
|
||||
//! `oob_nonce_slot` branch).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// substitutes a per-finding loopback URL (see runner.rs:405-413).
|
||||
bytes: b"",
|
||||
label: "open-redirect-python-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/python/vuln.py"],
|
||||
oob_nonce_slot: true,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: Some(
|
||||
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
|
||||
callback when the harness follows the captured Location URL with \
|
||||
urllib.request.urlopen; no benign URL can hit the nonce path.",
|
||||
),
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-python-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
88
src/dynamic/corpus/open_redirect/ruby.rs
Normal file
88
src/dynamic/corpus/open_redirect/ruby.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//! Ruby `Cap::OPEN_REDIRECT` payloads —
|
||||
//! `Rack::Response#redirect` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL spliced into
|
||||
//! `response.redirect(value)` without host validation; the harness
|
||||
//! records a `ProbeKind::Redirect` probe whose `location` points
|
||||
//! off-origin.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
//!
|
||||
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
|
||||
//! [`crate::dynamic::oob::OobListener`] the harness follows the
|
||||
//! captured `Location:` URL via a real `Net::HTTP.get_response` against
|
||||
//! the loopback nonce URL so the listener records the per-finding
|
||||
//! callback. Ordered first so the runner exercises the OOB observation
|
||||
//! path before the absolute-URL vuln below triggers and short-circuits
|
||||
//! iteration; runs without a listener skip cleanly (runner
|
||||
//! `oob_nonce_slot` branch).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// substitutes a per-finding loopback URL (see runner.rs:405-413).
|
||||
bytes: b"",
|
||||
label: "open-redirect-ruby-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/ruby/vuln.rb"],
|
||||
oob_nonce_slot: true,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: Some(
|
||||
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
|
||||
callback when the harness follows the captured Location URL with \
|
||||
Net::HTTP.get_response; no benign URL can hit the nonce path.",
|
||||
),
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-ruby-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-ruby-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-ruby-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/ruby/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
88
src/dynamic/corpus/open_redirect/rust.rs
Normal file
88
src/dynamic/corpus/open_redirect/rust.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//! Rust `Cap::OPEN_REDIRECT` payloads — `axum::response::Redirect::to`
|
||||
//! off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL spliced into
|
||||
//! `Redirect::to(value)` without host validation; the harness
|
||||
//! records a `ProbeKind::Redirect` probe whose `location` points
|
||||
//! off-origin.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
//!
|
||||
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
|
||||
//! [`crate::dynamic::oob::OobListener`] the harness follows the
|
||||
//! captured `Location:` URL via a zero-dep `std::net::TcpStream`
|
||||
//! `GET / HTTP/1.0` against the loopback nonce URL so the listener
|
||||
//! records the per-finding callback. Ordered first so the runner
|
||||
//! exercises the OOB observation path before the absolute-URL vuln
|
||||
//! below triggers and short-circuits iteration; runs without a
|
||||
//! listener skip cleanly (runner `oob_nonce_slot` branch).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// substitutes a per-finding loopback URL (see runner.rs:405-413).
|
||||
bytes: b"",
|
||||
label: "open-redirect-rust-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/vuln.rs"],
|
||||
oob_nonce_slot: true,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: Some(
|
||||
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
|
||||
callback when the harness follows the captured Location URL with a zero-dep \
|
||||
TcpStream-based GET; no benign URL can hit the nonce path.",
|
||||
),
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-rust-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-rust-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-rust-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: ALLOWLIST,
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/benign.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
71
src/dynamic/corpus/path_trav/java.rs
Normal file
71
src/dynamic/corpus/path_trav/java.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! Java `Cap::FILE_IO` path-traversal payloads (entry-driven servlet harness).
|
||||
//!
|
||||
//! The vulnerable payload escapes the fixture's `testfileDir`
|
||||
//! (`<workdir>/testfiles/`) one level up to a canary file the harness plants at
|
||||
//! the workdir root. The oracle marker is the canary file's CONTENT
|
||||
//! ([`CANARY_MARKER`]), which is deliberately NOT a substring of the path
|
||||
//! payload: an OWASP fixture that merely echoes the requested filename back to
|
||||
//! the response (e.g. `getWriter().write("...'" + fileName + "'...")`) or logs
|
||||
//! it (`System.out.println(... fileName ...)`) cannot reproduce the marker —
|
||||
//! only an unsanitised `new FileInputStream(testfileDir + param)` that actually
|
||||
//! opens and reads the canary does. This is the FILE_IO analogue of the
|
||||
//! collision-resistant CODE_EXEC computed marker (`NYX_PWN_791`).
|
||||
//!
|
||||
//! The host sandbox (`path_traversal.sb`) denies `/etc/passwd` &c. but allows
|
||||
//! reads inside the workdir, so the legacy `/etc/passwd` → `root:` rust payload
|
||||
//! cannot confirm under isolation; the planted canary lives in the readable
|
||||
//! workdir instead.
|
||||
//!
|
||||
//! The benign control names a file that does not exist under `testfileDir`, so
|
||||
//! the same content-marker oracle cannot fire on it.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
/// Canary filename planted at the harness workdir root (the parent of
|
||||
/// `testfiles/`). The Java emitter stages `<workdir>/nyx_pt_canary` with
|
||||
/// [`CANARY_MARKER`] as its content plus an empty `testfiles/` directory so the
|
||||
/// `../nyx_pt_canary` traversal resolves.
|
||||
pub const CANARY_FILENAME: &str = "nyx_pt_canary";
|
||||
|
||||
/// Canary file content for the collision-resistant FILE_IO marker. It uses
|
||||
/// alphanumeric characters plus underscore, so a faithful HTML/URL escaper
|
||||
/// leaves it intact when the fixture writes the read bytes to the response.
|
||||
/// NOT a substring of any payload path.
|
||||
pub const CANARY_MARKER: &str = "NYX_PATHTRAVERSAL_R34D_a7f3c1d8";
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
// `testfileDir + "../nyx_pt_canary"` == `<workdir>/testfiles/../nyx_pt_canary`
|
||||
// == `<workdir>/nyx_pt_canary` (the planted canary).
|
||||
bytes: b"../nyx_pt_canary",
|
||||
label: "path-traversal-canary-java",
|
||||
oracle: Oracle::OutputContains(CANARY_MARKER),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 17,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/java/path_traversal/PathTraversalServlet.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "path-traversal-benign-java",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
// No traversal and no such file under `testfileDir`, so the canary is
|
||||
// never read and the content marker cannot appear.
|
||||
bytes: b"nyx_pt_benign_absent_NYX_BENIGN",
|
||||
label: "path-traversal-benign-java",
|
||||
oracle: Oracle::OutputContains(CANARY_MARKER),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 17,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/java/path_traversal/PathTraversalServlet.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
4
src/dynamic/corpus/path_trav/mod.rs
Normal file
4
src/dynamic/corpus/path_trav/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
//! Path-traversal (`Cap::FILE_IO`) per-language payload slices.
|
||||
|
||||
pub mod java;
|
||||
pub mod rust;
|
||||
43
src/dynamic/corpus/path_trav/rust.rs
Normal file
43
src/dynamic/corpus/path_trav/rust.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! Path-traversal payloads exercised by Rust fixtures
|
||||
//! (`tests/benchmark/corpus/rust/path_traversal/`).
|
||||
//!
|
||||
//! Vuln payload reads `/etc/passwd`; benign payload names a file that does
|
||||
//! not exist so the same oracle marker cannot fire.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"../../../../etc/passwd",
|
||||
label: "path-traversal-passwd",
|
||||
oracle: Oracle::OutputContains("root:"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/benchmark/corpus/rust/path_traversal/path_file_open.rs",
|
||||
"tests/benchmark/corpus/rust/path_traversal/path_read.rs",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "path-traversal-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_file_that_does_not_exist_NYX_BENIGN",
|
||||
label: "path-traversal-benign",
|
||||
oracle: Oracle::OutputContains("root:"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/path_traversal/path_file_open.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
64
src/dynamic/corpus/prototype_pollution/javascript.rs
Normal file
64
src/dynamic/corpus/prototype_pollution/javascript.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//! JavaScript `Cap::PROTOTYPE_POLLUTION` payloads.
|
||||
//!
|
||||
//! Vuln payload: a JSON literal whose top-level key is `__proto__`.
|
||||
//! When the host's deep-merge / `Object.assign` / JSON-parse-then-
|
||||
//! deep-assign sink walks the key into the merge target, the
|
||||
//! `__proto__` reference reaches `Object.prototype` and writes
|
||||
//! `__nyx_canary`. The harness's instrumented setter trap records a
|
||||
//! `ProbeKind::PrototypePollution { property: "__nyx_canary", … }`
|
||||
//! probe; the predicate
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::PrototypeCanaryTouched`]
|
||||
//! fires on the captured canary write.
|
||||
//!
|
||||
//! Benign control: a JSON literal whose top-level key is the regular
|
||||
//! property `data`. Naive deep-merge walks `target.data` rather than
|
||||
//! `target.__proto__`, so the prototype chain stays untouched and the
|
||||
//! predicate stays clear. The matching fixture
|
||||
//! (`tests/dynamic_fixtures/prototype_pollution/javascript/benign.js`)
|
||||
//! additionally demonstrates the canonical mitigation pattern of
|
||||
//! using `Object.create(null)` as the merge target — static analysis
|
||||
//! of the fixture surfaces the prototype-less target while the
|
||||
//! runtime differential is driven by the absence of a `__proto__` key
|
||||
//! in the payload bytes.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::{Canary, ProbePredicate};
|
||||
|
||||
const CANARY: &str = Canary::PLACEHOLDER;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "prototype-pollution-js-proto-key",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 14,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/prototype_pollution/javascript/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "prototype-pollution-js-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "prototype-pollution-js-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 14,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/prototype_pollution/javascript/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
20
src/dynamic/corpus/prototype_pollution/mod.rs
Normal file
20
src/dynamic/corpus/prototype_pollution/mod.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Prototype-pollution (`Cap::PROTOTYPE_POLLUTION`) per-language
|
||||
//! payload slices.
|
||||
//!
|
||||
//! Phase 10 (Track J.8) carves the JavaScript / TypeScript prototype-
|
||||
//! pollution gadget against three sink families: `lodash.merge`,
|
||||
//! `Object.assign` with tainted RHS, and `JSON.parse`-then-deep-assign.
|
||||
//! Every vuln payload binds a JSON literal whose top-level key is
|
||||
//! `__proto__`; the harness's instrumented deep-merge walks the key
|
||||
//! into `Object.prototype` and a `Proxy`-style setter trap on
|
||||
//! `Object.prototype.__nyx_canary` records a
|
||||
//! [`crate::dynamic::probe::ProbeKind::PrototypePollution`] probe. The
|
||||
//! paired benign control sends a JSON literal whose top-level key is
|
||||
//! the regular property `data`, leaving the prototype chain
|
||||
//! untouched. The
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::PrototypeCanaryTouched`]
|
||||
//! predicate fires only on probes whose `property` equals the canary
|
||||
//! name (`__nyx_canary`).
|
||||
|
||||
pub mod javascript;
|
||||
pub mod typescript;
|
||||
50
src/dynamic/corpus/prototype_pollution/typescript.rs
Normal file
50
src/dynamic/corpus/prototype_pollution/typescript.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! TypeScript `Cap::PROTOTYPE_POLLUTION` payloads.
|
||||
//!
|
||||
//! Mirrors [`super::javascript`] — the runtime is Node.js in both
|
||||
//! cases, so the payload shape and oracle predicate are identical.
|
||||
//! The per-language slice exists so the lang-aware corpus resolver
|
||||
//! pairs TS vuln payloads against TS benign controls without crossing
|
||||
//! the JS slice (and so the fixture paths point at the TS-specific
|
||||
//! fixtures the static-analysis side consumes).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::{Canary, ProbePredicate};
|
||||
|
||||
const CANARY: &str = Canary::PLACEHOLDER;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "prototype-pollution-ts-proto-key",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 14,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/prototype_pollution/typescript/vuln.ts"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "prototype-pollution-ts-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#,
|
||||
label: "prototype-pollution-ts-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 14,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/prototype_pollution/typescript/benign.ts"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
1167
src/dynamic/corpus/registry.rs
Normal file
1167
src/dynamic/corpus/registry.rs
Normal file
File diff suppressed because it is too large
Load diff
7
src/dynamic/corpus/sqli/mod.rs
Normal file
7
src/dynamic/corpus/sqli/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//! SQLi (`Cap::SQL_QUERY`) per-language payload slices.
|
||||
//!
|
||||
//! Each submodule exposes a `pub const PAYLOADS: &[CuratedPayload]` slice
|
||||
//! registered against `(Cap::SQL_QUERY, Lang::<lang>)` in
|
||||
//! [`super::registry::CORPUS`].
|
||||
|
||||
pub mod rust;
|
||||
57
src/dynamic/corpus/sqli/rust.rs
Normal file
57
src/dynamic/corpus/sqli/rust.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//! SQLi payloads exercised by Rust fixtures (`tests/benchmark/corpus/rust/sqli/`).
|
||||
//!
|
||||
//! Payload bytes are SQL-syntax, not Rust-specific; the `Lang::Rust` slot
|
||||
//! reflects the fixture that currently drives them. Track J phases 03–11
|
||||
//! add per-language slices as new fixtures land.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"' OR '1'='1",
|
||||
label: "sqli-tautology",
|
||||
oracle: Oracle::OutputContains("NYX_SQL_CONFIRMED"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "sqli-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"' UNION SELECT 'NYX_SQL_CONFIRMED'--",
|
||||
label: "sqli-union-nyx",
|
||||
oracle: Oracle::OutputContains("NYX_SQL_CONFIRMED"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "sqli-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
// Benign control: ordinary value that should never produce the SQL marker.
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_sqli_NYX_BENIGN",
|
||||
label: "sqli-benign",
|
||||
oracle: Oracle::OutputContains("NYX_SQL_CONFIRMED"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 4,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
3
src/dynamic/corpus/ssrf/mod.rs
Normal file
3
src/dynamic/corpus/ssrf/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! SSRF (`Cap::SSRF`) per-language payload slices.
|
||||
|
||||
pub mod rust;
|
||||
73
src/dynamic/corpus/ssrf/rust.rs
Normal file
73
src/dynamic/corpus/ssrf/rust.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! SSRF payloads exercised by Rust fixtures
|
||||
//! (`tests/benchmark/corpus/rust/ssrf/`).
|
||||
//!
|
||||
//! Two variants:
|
||||
//! 1. `file://` scheme — static payload, `OutputContains` oracle. Works in
|
||||
//! the process backend without OOB infrastructure.
|
||||
//! 2. OOB nonce slot — URL generated at runtime from the OOB listener.
|
||||
//! Confirms SSRF by recording the callback nonce.
|
||||
//!
|
||||
//! Oracle notes:
|
||||
//! `OutputContains("daemon:")` matches both Linux (`daemon:x:1:1:`) and
|
||||
//! macOS (`daemon:*:1:1:`) `/etc/passwd` formats and must NOT collide with
|
||||
//! FILE_IO's `"root:"` marker (see marker_uniqueness test).
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"file:///etc/passwd",
|
||||
label: "ssrf-file-scheme",
|
||||
oracle: Oracle::OutputContains("daemon:"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ssrf-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
// `bytes` is unused when `oob_nonce_slot = true`; the runner
|
||||
// materialises the URL from the OOB listener at call time.
|
||||
bytes: b"",
|
||||
label: "ssrf-oob-nonce",
|
||||
oracle: Oracle::OobCallback { host: "127.0.0.1" },
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 2,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
|
||||
oob_nonce_slot: true,
|
||||
probe_predicates: &[],
|
||||
// OOB-nonce payloads are self-confirming via the listener; no benign
|
||||
// counterpart is meaningful (a benign URL can never hit the nonce
|
||||
// listener), so this entry sits at `NoControl`.
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: Some(
|
||||
"OOB-nonce payload self-confirms via the per-finding listener callback; \
|
||||
no benign URL can hit the nonce path, so no paired control is meaningful.",
|
||||
),
|
||||
},
|
||||
// Benign control for the file-scheme SSRF variant. Fetched the same
|
||||
// way as the vuln payload but cannot resolve to a body containing the
|
||||
// `daemon:` marker.
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_ssrf_NYX_BENIGN",
|
||||
label: "ssrf-benign",
|
||||
oracle: Oracle::OutputContains("daemon:"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 4,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
46
src/dynamic/corpus/ssti/java_thymeleaf.rs
Normal file
46
src/dynamic/corpus/ssti/java_thymeleaf.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! Java Thymeleaf `Cap::SSTI` payloads.
|
||||
//!
|
||||
//! Vuln payload: `[[${7*7}]]` — Thymeleaf evaluates the SpEL-style
|
||||
//! expression inside the inlined-output marker and renders `49`.
|
||||
//! Benign control sends the literal `7*7` text; without the `[[${...}]]`
|
||||
//! markers Thymeleaf passes the payload through unchanged.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"[[${7*7}]]",
|
||||
label: "ssti-thymeleaf-eval",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/java_thymeleaf/vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ssti-thymeleaf-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"7*7",
|
||||
label: "ssti-thymeleaf-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/java_thymeleaf/benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
52
src/dynamic/corpus/ssti/js_handlebars.rs
Normal file
52
src/dynamic/corpus/ssti/js_handlebars.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! JavaScript Handlebars `Cap::SSTI` payloads.
|
||||
//!
|
||||
//! Handlebars does not evaluate arbitrary arithmetic in `{{ ... }}`
|
||||
//! expressions out of the box, so the vuln payload reaches the engine
|
||||
//! through the built-in `lookup` helper combined with a constructor
|
||||
//! gadget chain: `{{#with (lookup this 'constructor')}}{{lookup
|
||||
//! this 'constructor'}}{{/with}}` is the canonical pattern, but the
|
||||
//! evaluation marker we need ("rendered constant only via eval")
|
||||
//! reduces to a much simpler `{{multiply 7 7}}` against the in-harness
|
||||
//! `multiply` helper. The harness registers that helper before
|
||||
//! compiling so the rendered body is `49`; benign control sends `7*7`
|
||||
//! plain text which Handlebars echoes verbatim.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"{{multiply 7 7}}",
|
||||
label: "ssti-handlebars-eval",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/js_handlebars/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ssti-handlebars-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"7*7",
|
||||
label: "ssti-handlebars-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/js_handlebars/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
19
src/dynamic/corpus/ssti/mod.rs
Normal file
19
src/dynamic/corpus/ssti/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! Server-Side Template Injection (`Cap::SSTI`) per-engine payload slices.
|
||||
//!
|
||||
//! Phase 04 (Track J.2) carves SSTI across the five most-common template
|
||||
//! engines: Jinja2 (Python), ERB (Ruby), Twig (PHP), Thymeleaf (Java), and
|
||||
//! Handlebars (JavaScript). Every vuln payload sends a template
|
||||
//! expression that resolves to a known constant *only* when the engine
|
||||
//! actually evaluates the expression (e.g. `{{7*7}}` → `49` in Jinja2,
|
||||
//! `<%= 7*7 %>` → `49` in ERB). The paired benign control sends the
|
||||
//! literal arithmetic text without engine markers so the per-engine
|
||||
//! harness echoes the payload verbatim rather than evaluating it; the
|
||||
//! oracle's [`crate::dynamic::oracle::ProbePredicate::TemplateEvalEqual`]
|
||||
//! check fires on the vuln render (`49`) and does not fire on the
|
||||
//! benign render (`7*7`), satisfying the §4.1 differential rule.
|
||||
|
||||
pub mod java_thymeleaf;
|
||||
pub mod js_handlebars;
|
||||
pub mod php_twig;
|
||||
pub mod python_jinja2;
|
||||
pub mod ruby_erb;
|
||||
46
src/dynamic/corpus/ssti/php_twig.rs
Normal file
46
src/dynamic/corpus/ssti/php_twig.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! PHP Twig `Cap::SSTI` payloads.
|
||||
//!
|
||||
//! Vuln payload: `{{7*7}}` — Twig evaluates the expression and the
|
||||
//! rendered template body is `49`. Benign control sends the literal
|
||||
//! `7*7` text; Twig has no `{{ ... }}` markers around it and echoes
|
||||
//! the payload verbatim.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"{{7*7}}",
|
||||
label: "ssti-twig-eval",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/php_twig/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ssti-twig-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"7*7",
|
||||
label: "ssti-twig-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/php_twig/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
53
src/dynamic/corpus/ssti/python_jinja2.rs
Normal file
53
src/dynamic/corpus/ssti/python_jinja2.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! Python Jinja2 `Cap::SSTI` payloads.
|
||||
//!
|
||||
//! Vuln payload: `{{7*7}}` — Jinja2 evaluates the expression and the
|
||||
//! rendered template body is `49`. The harness's
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::TemplateEvalEqual`] check
|
||||
//! compares the captured `{"render": "49"}` JSON body against
|
||||
//! `expected = 49` and the oracle fires.
|
||||
//!
|
||||
//! Benign control: literal `7*7` — Jinja2 has no `{{ ... }}` markers to
|
||||
//! evaluate so the engine echoes the payload verbatim. The rendered
|
||||
//! body is `7*7`, the oracle's integer parse fails, and the oracle
|
||||
//! does not fire. Together with the vuln payload this satisfies the
|
||||
//! §4.1 differential confirmation rule.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"{{7*7}}",
|
||||
label: "ssti-jinja2-eval",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/python_jinja2/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ssti-jinja2-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"7*7",
|
||||
label: "ssti-jinja2-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/python_jinja2/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
46
src/dynamic/corpus/ssti/ruby_erb.rs
Normal file
46
src/dynamic/corpus/ssti/ruby_erb.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! Ruby ERB `Cap::SSTI` payloads.
|
||||
//!
|
||||
//! Vuln payload: `<%= 7*7 %>` — ERB evaluates the embedded Ruby
|
||||
//! expression and the rendered template body is `49`. Benign control
|
||||
//! ships the literal `7*7` text which ERB has no `<%= ... %>` marker
|
||||
//! around and so passes through verbatim.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"<%= 7*7 %>",
|
||||
label: "ssti-erb-eval",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/ruby_erb/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "ssti-erb-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"7*7",
|
||||
label: "ssti-erb-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 8,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/ssti/ruby_erb/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
41
src/dynamic/corpus/unauthorized_id/go.rs
Normal file
41
src/dynamic/corpus/unauthorized_id/go.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! go `Cap::UNAUTHORIZED_ID` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"bob",
|
||||
label: "idor-go-cross-tenant",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "idor-go-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "idor-go-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/go/benign.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
41
src/dynamic/corpus/unauthorized_id/java.rs
Normal file
41
src/dynamic/corpus/unauthorized_id/java.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! java `Cap::UNAUTHORIZED_ID` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"bob",
|
||||
label: "idor-java-cross-tenant",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "idor-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "idor-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
41
src/dynamic/corpus/unauthorized_id/js.rs
Normal file
41
src/dynamic/corpus/unauthorized_id/js.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! js `Cap::UNAUTHORIZED_ID` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"bob",
|
||||
label: "idor-js-cross-tenant",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/js/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "idor-js-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "idor-js-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/js/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
23
src/dynamic/corpus/unauthorized_id/mod.rs
Normal file
23
src/dynamic/corpus/unauthorized_id/mod.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//! IDOR / unauthorized-id-access (`Cap::UNAUTHORIZED_ID`)
|
||||
//! per-language payload slices.
|
||||
//!
|
||||
//! Phase 11 (Track J.9) carves an IDOR oracle across all seven
|
||||
//! backend-capable languages. Each harness stands up a mock data
|
||||
//! store keyed by `owner_id` and a hard-coded `caller_id`
|
||||
//! (`"alice"`). The vuln payload supplies an `owner_id` that
|
||||
//! belongs to another user (`"bob"`); the harness's instrumented
|
||||
//! lookup returns the record without an authorization check and
|
||||
//! writes a [`crate::dynamic::probe::ProbeKind::IdorAccess { caller_id,
|
||||
//! owner_id }`] probe. The
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::IdorBoundaryCrossed`]
|
||||
//! predicate fires whenever `caller_id != owner_id`. The paired
|
||||
//! benign control asks for the caller's own record (`"alice"`), so
|
||||
//! the probe records matching ids and the predicate stays clear.
|
||||
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod js;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
41
src/dynamic/corpus/unauthorized_id/php.rs
Normal file
41
src/dynamic/corpus/unauthorized_id/php.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! php `Cap::UNAUTHORIZED_ID` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"bob",
|
||||
label: "idor-php-cross-tenant",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "idor-php-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "idor-php-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
41
src/dynamic/corpus/unauthorized_id/python.rs
Normal file
41
src/dynamic/corpus/unauthorized_id/python.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! Python `Cap::UNAUTHORIZED_ID` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"bob",
|
||||
label: "idor-python-cross-tenant",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "idor-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "idor-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
41
src/dynamic/corpus/unauthorized_id/ruby.rs
Normal file
41
src/dynamic/corpus/unauthorized_id/ruby.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! ruby `Cap::UNAUTHORIZED_ID` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"bob",
|
||||
label: "idor-ruby-cross-tenant",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "idor-ruby-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "idor-ruby-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/ruby/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
41
src/dynamic/corpus/unauthorized_id/rust.rs
Normal file
41
src/dynamic/corpus/unauthorized_id/rust.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! rust `Cap::UNAUTHORIZED_ID` payloads.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"bob",
|
||||
label: "idor-rust-cross-tenant",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "idor-rust-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "idor-rust-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::IdorBoundaryCrossed],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/rust/benign.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
53
src/dynamic/corpus/xpath/java.rs
Normal file
53
src/dynamic/corpus/xpath/java.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! Java `Cap::XPATH_INJECTION` payloads — `javax.xml.xpath.XPath.evaluate`
|
||||
//! expression injection.
|
||||
//!
|
||||
//! Vuln payload: an XPath fragment whose `' or '1'='1` tail breaks
|
||||
//! out of the host template's `[@name='…']` predicate and rewraps
|
||||
//! the selector as `//user[@name='' or '1'='1']`, matching every
|
||||
//! node the staged document carries. The harness's instrumented
|
||||
//! `XPath.evaluate` records
|
||||
//! `ProbeKind::Xpath { nodes_returned: 3 }`.
|
||||
//!
|
||||
//! Benign control: the same intended username quoted via the
|
||||
//! harness's XPath-escape helper, leaving the expression pinned to a
|
||||
//! single node — `nodes_returned: 1`, oracle clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"alice' or '1'='1",
|
||||
label: "xpath-java-expression-wildcard",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 11,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/xpath_injection/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "xpath-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"alice",
|
||||
label: "xpath-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::QueryResultCountGreaterThan { n: 1 }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 11,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/xpath_injection/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue