feat(dynamic): remap command injection sink cap to CODE_EXEC, update corpus markers to NYX_PWN_791_CMDI, and enhance spec derivation strategies for wider coverage and consistency

This commit is contained in:
elipeter 2026-06-01 15:58:11 -05:00
parent 738f1fedbc
commit 7027dbca0a
30 changed files with 524 additions and 130 deletions

View file

@ -146,6 +146,63 @@ pub(crate) fn base_command(bin: &str) -> Command {
cmd
}
/// 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.

View file

@ -9,7 +9,10 @@
//! compiled require-cache across findings. Falls back to the legacy path when
//! `bundle` is not runnable.
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
use super::{
BuildPool, PoolCompileResult, base_command, binary_runnable, combine_output, pool_cache_dir,
ruby_hermetic_env,
};
use std::path::Path;
use std::time::Instant;
@ -29,6 +32,10 @@ impl RubyPool {
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);
}
@ -56,31 +63,16 @@ impl BuildPool for RubyPool {
}
}
let config = self
.bundle(workdir)
.args(["config", "set", "--local", "path", "vendor/bundle"])
.output();
match config {
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!("ruby-pool: bundle config: {e}"),
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", "--jobs", "4", "--retry", "2"])
.args(["install", "--local", "--jobs", "4", "--retry", "0"])
.output();
match install {
Ok(o) if o.status.success() => PoolCompileResult {
@ -90,7 +82,12 @@ impl BuildPool for RubyPool {
},
Ok(o) => PoolCompileResult {
success: false,
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
// 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 {

View file

@ -21,7 +21,7 @@ use crate::dynamic::build_pool::php::PhpPool;
use crate::dynamic::build_pool::python::PythonPool;
use crate::dynamic::build_pool::ruby::RubyPool;
use crate::dynamic::build_pool::rust::RustPool;
use crate::dynamic::build_pool::{BuildPool, is_pool_enabled};
use crate::dynamic::build_pool::{BuildPool, combine_output, is_pool_enabled, ruby_hermetic_env};
use crate::dynamic::sandbox::ProcessHardeningProfile;
use crate::dynamic::spec::HarnessSpec;
use crate::symbol::Lang;
@ -516,44 +516,49 @@ fn try_bundle_install(workdir: &Path) -> Result<(), String> {
return Ok(());
}
let config = Command::new(&bundle)
.args(["config", "set", "--local", "path", "vendor/bundle"])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.map_err(|e| format!("bundle config: {e}"))?;
if !config.status.success() {
return Err(String::from_utf8_lossy(&config.stderr).into_owned());
}
let output = Command::new(&bundle)
.args(["install", "--jobs", "4", "--retry", "2"])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
// No `bundle config set …` step: it is 2.x-only syntax that silently
// no-ops on Bundler 1.x, which then installs to the root-owned system gem
// dir and shells out to `sudo`. `ruby_build_command` pins a writable
// install target via env (GEM_HOME / BUNDLE_PATH) on every Bundler
// version, and `--local` keeps the build offline so an absent gem fails
// fast with a host-limitation error rather than reaching the network.
let output = ruby_build_command(&bundle, workdir)
.args(["install", "--local", "--jobs", "4", "--retry", "0"])
.output()
.map_err(|e| format!("bundle install: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
// Bundler's resolution error ("Could not find gem …") goes to stdout;
// combine both streams so the host-limitation classifier sees it.
return Err(combine_output(&output.stdout, &output.stderr));
}
Ok(())
}
fn bundle_check(bundle: &str, workdir: &Path) -> Result<bool, String> {
let output = Command::new(bundle)
let output = ruby_build_command(bundle, workdir)
.arg("check")
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.map_err(|e| format!("bundle check: {e}"))?;
Ok(output.status.success())
}
/// Build a Bundler/RubyGems `Command` with a scrubbed environment plus the
/// hermetic gem env from [`ruby_hermetic_env`] (writable `GEM_HOME` /
/// `BUNDLE_PATH`). This is the legacy direct-spawn sibling of
/// [`crate::dynamic::build_pool::ruby::RubyPool::bundle`]; both guarantee the
/// Ruby harness build never invokes `sudo` and never touches the network.
fn ruby_build_command(bundle: &str, workdir: &Path) -> Command {
let mut cmd = Command::new(bundle);
cmd.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default());
for (k, v) in ruby_hermetic_env(workdir) {
cmd.env(k, v);
}
cmd
}
fn restore_cached_ruby_bundle(cache_path: &Path, workdir: &Path) {
let cached_vendor = cache_path.join("vendor").join("bundle");
if cached_vendor.exists() && !workdir.join("vendor").join("bundle").exists() {

View file

@ -103,7 +103,8 @@ pub use crate::dynamic::oracle::Oracle;
/// | 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 |
pub const CORPUS_VERSION: u32 = 15;
/// | 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. |
pub const CORPUS_VERSION: u32 = 16;
/// Where a payload originated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-c",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -27,7 +27,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-c",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-cpp",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -30,7 +30,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-cpp",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-go",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -27,7 +27,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-go",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-java",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -25,7 +25,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-java",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-javascript",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -25,7 +25,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-javascript",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-php",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -25,7 +25,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-php",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -8,9 +8,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-python",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -30,7 +30,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-python",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-ruby",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -26,7 +26,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-ruby",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -9,9 +9,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
@ -31,7 +31,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 4,

View file

@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
label: "cmdi-echo-marker-typescript",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
@ -25,7 +25,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"benign_safe_cmdi_NYX_BENIGN",
label: "cmdi-benign-typescript",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,

View file

@ -58,6 +58,10 @@ pub enum CShape {
/// libFuzzer-style: `int LLVMFuzzerTestOneInput(const uint8_t *data,
/// size_t size)`. Harness invokes with `payload` bytes + length.
LibfuzzerEntry,
/// `int main(void)` / `int main()`. A no-argument program entry: the
/// harness invokes it with no arguments (calling it with `(argc, argv)`
/// is a "too many arguments to function call" compile error).
MainVoid,
/// Free function with `(const char *, size_t)` or `(const char *)`
/// signature. Harness invokes directly.
FreeFn,
@ -80,6 +84,12 @@ impl CShape {
if has_libfuzzer {
return Self::LibfuzzerEntry;
}
// A `main(void)` / `main()` entry takes no argv; invoking it with
// `(argc, argv)` is a compile error. Route it to MainVoid so the
// harness calls it with no arguments.
if entry == "main" && main_takes_no_args(source) {
return Self::MainVoid;
}
if entry == "main" || has_main_argv {
return Self::MainArgv;
}
@ -91,6 +101,13 @@ impl CShape {
}
}
/// True when `source` declares a no-argument `main` (`int main(void)` or
/// `int main()`), tolerating arbitrary internal whitespace.
fn main_takes_no_args(source: &str) -> bool {
let compact: String = source.split_whitespace().collect();
compact.contains("main(void)") || compact.contains("main()")
}
/// Public wrapper: detect the shape for a finalised `HarnessSpec`, reading
/// the entry file from disk.
pub fn detect_shape(spec: &HarnessSpec) -> CShape {
@ -859,6 +876,11 @@ fn invoke_for_shape(spec: &HarnessSpec, shape: CShape) -> String {
entry_fn = entry_fn,
)
}
CShape::MainVoid => {
// `int main(void)` / `int main()` — renamed to `__nyx_entry_main`
// by the include guards; invoke with no arguments.
format!(" (void)payload;\n {entry_fn}();\n")
}
CShape::MainArgv => {
// Heap-allocate `new_argv` so a future `PayloadSlot::Argv(n)` with
// `n >= 6` cannot overrun a fixed stack array. Slots: 1

View file

@ -585,9 +585,6 @@ fn derive_from_flow_steps(
if evidence.flow_steps.is_empty() {
return None;
}
let entry = outermost_entry(&evidence.flow_steps)?;
let lang = lang_from_path(&entry.file)?;
let expected_cap = Cap::from_bits_truncate(evidence.sink_caps);
if expected_cap.is_empty() {
return None;
@ -601,10 +598,38 @@ fn derive_from_flow_steps(
.map(|s| (s.file.clone(), s.line))
.unwrap_or_else(|| (diag.path.clone(), diag.line as u32));
// Entry resolution, in descending fidelity:
// 1. the outermost `Source` step that carries a function annotation
// (the original behaviour — the outermost callable receiving input),
// 2. the first flow step carrying *any* function annotation — covers the
// generic `taint-unsanitised-flow` shape whose flow begins at a `Call`
// / assignment step rather than a `Source` step, so it has no
// `Source`-kind step yet still names the enclosing function,
// 3. the enclosing function resolved from the sink's AST span, and
// 4. the `<unknown>` placeholder, which the per-language emitters route
// to a synthetic direct-sink harness.
// The sink location plus a non-empty cap is enough to drive verification,
// so a missing `Source` step no longer aborts derivation.
let entry = outermost_entry(&evidence.flow_steps)
.or_else(|| first_annotated_entry(&evidence.flow_steps));
let (entry_file, entry_name) = match entry {
Some(e) => (e.file, e.function),
None => {
let name = lang_from_path(&sink_file)
.and_then(|l| {
resolve_enclosing_function_via_ast(&sink_file, sink_line as usize, l)
})
.unwrap_or_else(|| "<unknown>".to_owned());
(sink_file.clone(), name)
}
};
let lang = lang_from_path(&entry_file).or_else(|| lang_from_path(&sink_file))?;
Some(finalize_spec(
diag,
entry.file,
entry.function,
entry_file,
entry_name,
lang,
expected_cap,
sink_file,
@ -614,6 +639,26 @@ fn derive_from_flow_steps(
))
}
/// Return an [`EntryRef`] for the first flow step that carries a non-empty
/// `function` annotation, regardless of its [`FlowStepKind`].
///
/// Unlike [`outermost_entry`] (which requires a `Source`-kind step), this
/// recovers an entry from flows that begin at a `Call` / assignment step —
/// the common shape for the generic `taint-unsanitised-flow` rule, whose
/// steps are annotated with the enclosing function but include no explicit
/// `Source` step.
fn first_annotated_entry(steps: &[crate::evidence::FlowStep]) -> Option<EntryRef> {
steps.iter().find_map(|s| {
s.function
.as_ref()
.filter(|f| !f.is_empty())
.map(|f| EntryRef {
file: s.file.clone(),
function: f.clone(),
})
})
}
// ── Strategy 2: from rule namespace + sink evidence ──────────────────────────
/// Build a spec from a rule-namespace finding (e.g. `py.cmdi.os_system`,
@ -1490,6 +1535,15 @@ fn function_node_name(node: tree_sitter::Node, bytes: &[u8]) -> Option<String> {
{
return Some(text.to_owned());
}
// C / C++ expose the function name inside the `declarator` subtree
// (`function_definition` -> `function_declarator` -> `identifier`), not a
// `name` field, so the direct-child scan below misses it. Descend the
// declarator chain first.
if let Some(decl) = node.child_by_field_name("declarator")
&& let Some(name) = declarator_name(decl, bytes)
{
return Some(name);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
@ -1506,6 +1560,40 @@ fn function_node_name(node: tree_sitter::Node, bytes: &[u8]) -> Option<String> {
None
}
/// Follow a C / C++ declarator chain (`pointer_declarator`,
/// `function_declarator`, `parenthesized_declarator`, `array_declarator`, …)
/// down to the leaf identifier — the declared function name.
fn declarator_name(node: tree_sitter::Node, bytes: &[u8]) -> Option<String> {
let mut cur = node;
loop {
let kind = cur.kind();
if kind == "identifier" || kind == "field_identifier" || kind == "type_identifier" {
return cur
.utf8_text(bytes)
.ok()
.filter(|t| !t.is_empty())
.map(|t| t.to_owned());
}
match cur.child_by_field_name("declarator") {
Some(next) => cur = next,
None => break,
}
}
// Leaf was not reached via the `declarator` field (e.g. an inner
// `parenthesized_declarator`); scan immediate children for the identifier.
let mut cursor = cur.walk();
for child in cur.children(&mut cursor) {
let kind = child.kind();
if (kind == "identifier" || kind == "field_identifier")
&& let Ok(text) = child.utf8_text(bytes)
&& !text.is_empty()
{
return Some(text.to_owned());
}
}
None
}
/// Lookup a `FuncSummary` by `(lang, name)` and filter to one whose
/// `file_path` matches `diag_path`. Returns `None` on no match.
fn find_summary_by_path<'a>(
@ -1594,6 +1682,32 @@ fn cap_for_rule_category(category: &str) -> Option<Cap> {
}
}
/// Remap a static *sink* capability onto the capability the dynamic corpus
/// keys its payload set + sound oracle under.
///
/// The static taint engine tags a shell command-injection sink with
/// [`Cap::SHELL_ESCAPE`] — the "data reaches a shell context" property — but
/// the dynamic corpus keys the command-injection oracle and every cmdi payload
/// under [`Cap::CODE_EXEC`] (see [`crate::dynamic::corpus::registry`]). Left
/// unmapped, every command-injection finding derives a spec whose cap has no
/// oracle and routes to `Unsupported(SoundOracleUnavailable)` instead of being
/// executed — historically the single largest "unsupported" class.
///
/// `SHELL_ESCAPE` on a *sink* is always command injection, so swapping it for
/// `CODE_EXEC` is sound now that the cmdi oracle is collision-resistant
/// (corpus v16: the marker is produced only by executing the injected command,
/// not by a sink that safely echoes the quoted payload — so a benign
/// `os.system("echo " + shlex.quote(x))` control no longer false-confirms).
/// Other set bits are preserved so a multi-cap sink keeps its other
/// (already-driveable) capabilities.
fn drivable_expected_cap(cap: Cap) -> Cap {
if cap.contains(Cap::SHELL_ESCAPE) {
(cap - Cap::SHELL_ESCAPE) | Cap::CODE_EXEC
} else {
cap
}
}
#[allow(clippy::too_many_arguments)]
fn finalize_spec(
diag: &Diag,
@ -1606,6 +1720,10 @@ fn finalize_spec(
derivation: SpecDerivationStrategy,
summaries: Option<&GlobalSummaries>,
) -> HarnessSpec {
// Drive the finding against the cap the corpus actually keys an oracle
// under (command injection: SHELL_ESCAPE -> CODE_EXEC) instead of routing
// to `Unsupported(SoundOracleUnavailable)`.
let expected_cap = drivable_expected_cap(expected_cap);
let toolchain_id = default_toolchain_id(lang).to_owned();
let stubs_required = StubKind::for_cap(expected_cap);
let mut spec = HarnessSpec {
@ -2360,7 +2478,8 @@ mod tests {
let spec = HarnessSpec::from_finding(&diag).unwrap();
assert_eq!(spec.derivation, SpecDerivationStrategy::FromRuleNamespace);
assert_eq!(spec.lang, Lang::Python);
assert_eq!(spec.expected_cap, Cap::SHELL_ESCAPE);
// cmdi sink cap `SHELL_ESCAPE` remaps to the driveable `CODE_EXEC`.
assert_eq!(spec.expected_cap, Cap::CODE_EXEC);
assert_eq!(spec.entry_file, "app/handler.py");
assert_eq!(spec.sink_line, 12);
}
@ -2374,6 +2493,24 @@ mod tests {
assert_eq!(spec.expected_cap, Cap::DESERIALIZE);
}
#[test]
fn drivable_expected_cap_remaps_shell_escape_to_code_exec() {
// Command injection: the oracle + payloads live under `CODE_EXEC`,
// while the static engine tags cmdi sinks `SHELL_ESCAPE` (which has no
// sound oracle of its own). The remap routes cmdi findings to the real
// (collision-resistant, corpus v16) oracle instead of
// `SoundOracleUnavailable`.
assert_eq!(drivable_expected_cap(Cap::SHELL_ESCAPE), Cap::CODE_EXEC);
// Multi-cap sinks keep their other (already-driveable) bits.
assert_eq!(
drivable_expected_cap(Cap::SHELL_ESCAPE | Cap::FILE_IO),
Cap::CODE_EXEC | Cap::FILE_IO
);
// Caps without SHELL_ESCAPE pass through untouched.
assert_eq!(drivable_expected_cap(Cap::SQL_QUERY), Cap::SQL_QUERY);
assert_eq!(drivable_expected_cap(Cap::CODE_EXEC), Cap::CODE_EXEC);
}
#[test]
fn rule_namespace_strategy_pins_rs_auth_mapping() {
// Regression: `rs.auth.*` must map to `Lang::Rust` + `Cap::UNAUTHORIZED_ID`.
@ -2668,6 +2805,16 @@ mod tests {
// name comes from the flow_steps annotation, the summary is found
// by `(lang, name)` lookup filtered by file_path, and the spec
// picks `tainted_sink_params[0]` as the payload slot.
//
// The evidence intentionally carries `sink_caps = 0`: this is the
// scenario `func_summary_auto` exists for — recovering the cap (and
// the tainted-param slot) from the summary when the finding's own
// flow evidence lacks them. With a zero evidence cap, `FromFlowSteps`
// bails (it requires a non-empty cap), so `FromFuncSummaryWalk` is the
// strategy that supplies the cap and wins. (When the evidence *does*
// carry a cap, `FromFlowSteps` derives the same enclosing-function
// entry directly and outranks the summary walk per the precedence
// ladder — that path is covered by the flow-steps tests.)
use crate::labels::Cap;
use crate::symbol::FuncKey;
let mut gs = GlobalSummaries::new();
@ -2684,7 +2831,7 @@ mod tests {
let ev = Evidence {
flow_steps: vec![sink_only_step_with_function("app/handler.py", "do_request")],
sink_caps: Cap::SHELL_ESCAPE.bits(),
sink_caps: 0,
..Default::default()
};
let diag = crate::commands::scan::Diag {

View file

@ -59,7 +59,7 @@ pub const NYX_VERSION: &str = env!("CARGO_PKG_VERSION");
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
/// jointly guard drift.
pub const CORPUS_VERSION: &str = "15";
pub const CORPUS_VERSION: &str = "16";
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the

View file

@ -462,20 +462,29 @@ fn format_spec_scoring_detail(
detail
}
/// True when the finding has *some* derivable signal (rule namespace, sink
/// caps, or evidence) so a spec-derivation failure should be surfaced as
/// True when the finding has *some* derivable signal (rule namespace or a
/// drivable taint flow) so a spec-derivation failure should be surfaced as
/// `Inconclusive` rather than `Unsupported`.
///
/// A finding with neither a non-zero sink capability nor any flow steps has no
/// dynamic model at all: there is no cap to select a payload corpus / oracle
/// and no flow to drive. This is the shape of the structural CFG / state
/// rules (`cfg-unguarded-sink`, `cfg-resource-leak`, `state-unauthed-access`,
/// `state-resource-leak`, `cfg-error-fallthrough`), which carry a sink span
/// but zero cap bits. A bare sink span is therefore *not* treated as
/// "derivation should have worked" — such findings route to `Unsupported`
/// (a non-engine outcome), not engine-`Inconclusive(SpecDerivationFailed)`.
fn should_be_inconclusive(diag: &Diag) -> bool {
let has_rule_ns = diag.id.split('.').count() >= 2
&& !diag.id.starts_with("taint-")
&& !diag.id.starts_with("cfg-")
&& !diag.id.starts_with("state-");
let has_evidence = diag
let has_drivable_evidence = diag
.evidence
.as_ref()
.map(|e| e.sink_caps != 0 || !e.flow_steps.is_empty() || e.sink.is_some())
.map(|e| e.sink_caps != 0 || !e.flow_steps.is_empty())
.unwrap_or(false);
has_rule_ns || has_evidence
has_rule_ns || has_drivable_evidence
}
fn derivation_failure_hint(diag: &Diag) -> String {
@ -501,6 +510,96 @@ fn derivation_failure_hint(diag: &Diag) -> String {
parts.join("; ")
}
/// True when a build / runtime-load failure's stderr indicates a genuinely
/// absent host dependency or toolchain rather than a defect in the harness
/// the engine emitted.
///
/// These are host limitations — the dynamic verifier cannot run *this* finding
/// on *this* host because a framework gem / Python module / npm package is not
/// installed and could not be resolved offline, a top-level `require` /
/// `import` / `use` failed at load time (`NYX_IMPORT_ERROR:`), or the language
/// interpreter / compiler itself is missing. The verdict routes to
/// `Unsupported(LangUnsupported)` so the operator sees "not verifiable on this
/// host", not engine-`Inconclusive(BuildFailed)`.
///
/// The needles are deliberately specific to dependency-resolution / load
/// failures: a malformed emitted harness produces compiler / syntax errors
/// (`error[E…]`, `SyntaxError`, …) that match none of these and stay
/// `Inconclusive(BuildFailed)`, preserving visibility of real engine defects.
fn build_failure_is_host_limitation(stderr: &str) -> bool {
const NEEDLES: &[&str] = &[
// Top-level require/import/use failure emitted by the per-language
// harness preambles (js_shared / ruby / php / python) with exit 77.
"NYX_IMPORT_ERROR:",
// Python: missing module / offline pip resolution miss.
"No module named",
"ModuleNotFoundError",
"No matching distribution found",
"Could not find a version that satisfies",
// Ruby / Bundler: gem not installed / not resolvable offline.
"Could not find gem",
"Bundler::GemNotFound",
"in any of the sources",
"Could not find ", // bundler: "Could not find X-1.2.3 in locally installed gems"
// Node: missing package.
"Cannot find module",
"ERR_MODULE_NOT_FOUND",
// Generic missing-toolchain signatures.
"command not found",
"executable file not found",
"No such file or directory (os error 2)",
];
if NEEDLES.iter().any(|n| stderr.contains(n)) {
return true;
}
// Java / Kotlin: an `import` of a framework package that is not on the
// host classpath produces `error: package <pkg> does not exist`. Offline
// and without the dependency JAR (e.g. Spring on a bare host), that is a
// host limitation, not an emitter defect.
if stderr.contains("does not exist") && stderr.contains("package ") {
return true;
}
false
}
/// True when a C / C++ harness build failure proves the emitter could not
/// bind a standalone driver to the *resolved entry symbol*, as opposed to a
/// fixable defect in otherwise-supported emitted code.
///
/// The compiler-native languages embed the fixture via `#include "entry.c"`
/// and provide their own `main`. When the taint sink's enclosing function is
/// an ordinary (non-`main`) symbol inside a file that *also* defines `main`,
/// the harness `main` collides with the fixture's (`redefinition of 'main'`),
/// or the resolved symbol's arity / signature matches no driveable shape
/// (`too many/few arguments to function call`, `conflicting types for`).
/// These are structural properties of the source — the entry simply is not a
/// driveable top-level shape for the signature-blind C emitter — so the
/// verdict is `Unsupported(EntryKindUnsupported)`, not
/// engine-`Inconclusive(BuildFailed)`.
///
/// Scoped to C / C++ so the interpreted / managed languages (whose build
/// failures are dependency / toolchain issues handled by
/// [`build_failure_is_host_limitation`]) are unaffected, and a genuine
/// emitter regression in a *supported* shape still surfaces as
/// `Inconclusive(BuildFailed)` (its diagnostics carry none of these
/// entry-binding signatures).
fn build_failure_is_undrivable_entry(lang: crate::symbol::Lang, stderr: &str) -> bool {
use crate::symbol::Lang;
if !matches!(lang, Lang::C | Lang::Cpp) {
return false;
}
const NEEDLES: &[&str] = &[
"redefinition of 'main'",
"redefinition of \u{2018}main\u{2019}", // gcc curly-quote variant
"too many arguments to function call",
"too few arguments to function call",
"too many arguments to function", // gcc phrasing
"too few arguments to function", // gcc phrasing
"conflicting types for",
];
NEEDLES.iter().any(|n| stderr.contains(n))
}
/// Try to dynamically confirm a static finding.
///
/// Never fails: every error path collapses into a [`VerifyStatus`] so the
@ -1321,20 +1420,73 @@ fn build_verdict(
Err(RunError::BuildFailed {
stderr,
attempts: build_att,
}) => VerifyResult {
finding_id: finding_id.to_owned(),
status: VerifyStatus::Inconclusive,
triggered_payload: None,
reason: None,
inconclusive_reason: Some(InconclusiveReason::BuildFailed),
detail: Some(format!("build failed after {build_att} attempts: {stderr}")),
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
hardening_outcome: None,
},
}) => {
// A build / runtime-load failure caused by a genuinely-absent host
// dependency or toolchain (an offline dependency-resolution miss,
// a missing module / gem / package, a top-level import failure, a
// missing interpreter) is a host limitation, not a defect in the
// harness the engine emitted. Such failures route to
// `Unsupported(LangUnsupported)` — a non-engine outcome — rather
// than `Inconclusive(BuildFailed)`, so the Inconclusive bucket
// stays reserved for failures the engine could plausibly fix.
// Real harness build defects (compiler errors, malformed emitted
// source) carry none of these signatures and stay `Inconclusive`.
if build_failure_is_host_limitation(&stderr) {
VerifyResult {
finding_id: finding_id.to_owned(),
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some(UnsupportedReason::LangUnsupported),
inconclusive_reason: None,
detail: None,
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
hardening_outcome: None,
}
} else if build_failure_is_undrivable_entry(spec.lang, &stderr) {
// The toolchain is present and the emitted harness is
// well-formed, but it cannot bind a standalone driver to the
// resolved entry: the compiler-native langs (C / C++) reject
// the harness because the fixture defines its own `main` that
// collides with the harness `main`, or the entry's arity /
// signature matches no driveable shape. That is an
// unsupported *entry shape* for this source, not a fixable
// engine defect — route to `Unsupported(EntryKindUnsupported)`
// rather than engine-`Inconclusive(BuildFailed)`.
VerifyResult {
finding_id: finding_id.to_owned(),
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some(UnsupportedReason::EntryKindUnsupported),
inconclusive_reason: None,
detail: None,
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
hardening_outcome: None,
}
} else {
VerifyResult {
finding_id: finding_id.to_owned(),
status: VerifyStatus::Inconclusive,
triggered_payload: None,
reason: None,
inconclusive_reason: Some(InconclusiveReason::BuildFailed),
detail: Some(format!("build failed after {build_att} attempts: {stderr}")),
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
hardening_outcome: None,
}
}
}
Err(RunError::Sandbox(e)) => VerifyResult {
finding_id: finding_id.to_owned(),
status: VerifyStatus::Inconclusive,

View file

@ -6,7 +6,7 @@ import "strings"
type ShellRunner struct{}
func (ShellRunner) Run(command string) string {
return strings.ReplaceAll(command, "NYX_PWN_CMDI", "")
return strings.ReplaceAll(command, "NYX_PWN", "")
}
type UserRepository struct {

View file

@ -2,7 +2,7 @@
public class Benign {
public static class ShellRunner {
public String run(String command) {
return command.replace("NYX_PWN_CMDI", "");
return command.replace("NYX_PWN", "");
}
}

View file

@ -1,7 +1,7 @@
# Benign control for recursively constructed Ruby dependencies.
class ShellRunner
def run(command)
command.gsub('NYX_PWN_CMDI', '')
command.gsub('NYX_PWN', '')
end
end

View file

@ -1,5 +1,5 @@
// Command injection — adversarial collision fixture.
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
// Prints NYX_PWN_791_CMDI unconditionally without reaching a command sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: RunPing(host string) Cap: CODE_EXEC
@ -10,6 +10,6 @@ import "fmt"
func RunPing(host string) {
// Coincidental oracle match — not a shell sink.
fmt.Println("NYX_PWN_CMDI")
fmt.Println("NYX_PWN_791_CMDI")
_ = len(host)
}

View file

@ -1,5 +1,5 @@
// Command injection adversarial collision fixture.
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
// Prints NYX_PWN_791_CMDI unconditionally without reaching a command sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: Entry.runPing(String) Cap: CODE_EXEC
@ -7,7 +7,7 @@
public class Entry {
public static void runPing(String host) {
// Coincidental oracle match not a shell sink.
System.out.println("NYX_PWN_CMDI");
System.out.println("NYX_PWN_791_CMDI");
int x = host.length();
}
}

View file

@ -1,12 +1,12 @@
// Command injection — adversarial collision fixture.
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
// Prints NYX_PWN_791_CMDI unconditionally without reaching a command sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: runPing(host) Cap: CODE_EXEC
function runPing(host) {
// Coincidental oracle match — not a shell sink.
process.stdout.write('NYX_PWN_CMDI\n');
process.stdout.write('NYX_PWN_791_CMDI\n');
void host.length;
}

View file

@ -1,12 +1,12 @@
<?php
// Command injection — adversarial collision fixture.
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
// Prints NYX_PWN_791_CMDI unconditionally without reaching a command sink
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: runPing($host) Cap: CODE_EXEC
function runPing($host) {
// Coincidental oracle match — not a shell sink.
echo "NYX_PWN_CMDI\n";
echo "NYX_PWN_791_CMDI\n";
$x = strlen($host);
}

View file

@ -1,6 +1,6 @@
"""Command injection — adversarial collision fixture.
Prints NYX_PWN_CMDI unconditionally without going through a shell sink.
Prints NYX_PWN_791_CMDI unconditionally without going through a shell sink.
Expected verdict: Inconclusive(OracleCollisionSuspected).
"""
@ -8,5 +8,5 @@ Expected verdict: Inconclusive(OracleCollisionSuspected).
def process_input(user_input):
"""Prints oracle marker without using a command execution sink."""
# Coincidental oracle match — not a shell sink.
print("NYX_PWN_CMDI")
print("NYX_PWN_791_CMDI")
return user_input.upper()

View file

@ -1,13 +1,13 @@
/// Command injection — adversarial collision fixture.
///
/// Prints "NYX_PWN_CMDI" unconditionally without going through a shell sink.
/// Prints "NYX_PWN_791_CMDI" unconditionally without going through a shell sink.
///
/// Expected verdict: Inconclusive(OracleCollisionSuspected)
/// oracle_fired = true (OutputContains("NYX_PWN_CMDI"))
/// oracle_fired = true (OutputContains("NYX_PWN_791_CMDI"))
/// sink_hit = false (__NYX_SINK_HIT__ never printed)
/// Cap: CODE_EXEC Entry: `run(payload: &str)`
pub fn run(payload: &str) {
// Coincidental oracle match — not a command execution sink.
println!("NYX_PWN_CMDI");
println!("NYX_PWN_791_CMDI");
let _ = payload.len();
}

View file

@ -1175,9 +1175,11 @@ mod e2e_phase_20 {
}
/// Python kafka vuln must Confirm: the synthetic Kafka loopback
/// delivers `; echo NYX_PWN_CMDI` to the handler's `os.system`
/// which prints `NYX_PWN_CMDI` to stdout and the differential
/// oracle reads it.
/// delivers `; echo NYX_PWN_$((113*7))_CMDI` to the handler's
/// `os.system`, which *executes* the injected `echo` and prints the
/// computed marker `NYX_PWN_791_CMDI` to stdout (corpus v16 — a benign
/// `shlex.quote` handler echoes the literal payload and never yields the
/// marker), and the differential oracle reads it.
#[test]
fn kafka_python_vuln_confirms_via_run_spec() {
let Some(outcome) = run(Lang::Python, "kafka_python", "vuln.py", "handler", "orders")

View file

@ -176,7 +176,15 @@ fn assert_callgraph_rewrites_entry(
"callgraph walk must classify the entry as HttpRoute; got {:?}",
spec.entry_kind
);
assert_eq!(spec.expected_cap, cap);
// Command injection's static sink cap `SHELL_ESCAPE` is remapped at spec
// derivation to the driveable `CODE_EXEC` (the cap the dynamic corpus keys
// its cmdi oracle under); every other cap passes through unchanged.
let expected_drivable = if cap == Cap::SHELL_ESCAPE {
Cap::CODE_EXEC
} else {
cap
};
assert_eq!(spec.expected_cap, expected_drivable);
let _ = analysis; // accepted but not asserted on here.
}

View file

@ -110,7 +110,9 @@ mod spec_strategies {
let spec = HarnessSpec::from_finding(&diag).expect("flow_steps strategy must succeed");
assert_eq!(spec.derivation, SpecDerivationStrategy::FromFlowSteps);
assert_eq!(spec.entry_name, "handle_request");
assert_eq!(spec.expected_cap, Cap::SHELL_ESCAPE);
// cmdi sink cap `SHELL_ESCAPE` remaps to the driveable `CODE_EXEC` (the
// cap the dynamic corpus keys its command-injection oracle under).
assert_eq!(spec.expected_cap, Cap::CODE_EXEC);
}
// ── Strategy 2: FromRuleNamespace ────────────────────────────────────────
@ -129,7 +131,8 @@ mod spec_strategies {
let spec = HarnessSpec::from_finding(&diag).expect("rule-namespace strategy must succeed");
assert_eq!(spec.derivation, SpecDerivationStrategy::FromRuleNamespace);
assert_eq!(spec.expected_cap, Cap::SHELL_ESCAPE);
// cmdi sink cap `SHELL_ESCAPE` remaps to the driveable `CODE_EXEC`.
assert_eq!(spec.expected_cap, Cap::CODE_EXEC);
assert_eq!(spec.toolchain_id, "python-3");
}