From 7027dbca0a63e0f6c0d8b01974d4b4b4ad859093 Mon Sep 17 00:00:00 2001 From: elipeter Date: Mon, 1 Jun 2026 15:58:11 -0500 Subject: [PATCH] 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 --- src/dynamic/build_pool/mod.rs | 57 ++++++ src/dynamic/build_pool/ruby.rs | 47 ++--- src/dynamic/build_sandbox.rs | 55 ++--- src/dynamic/corpus.rs | 3 +- src/dynamic/corpus/cmdi/c.rs | 6 +- src/dynamic/corpus/cmdi/cpp.rs | 6 +- src/dynamic/corpus/cmdi/go.rs | 6 +- src/dynamic/corpus/cmdi/java.rs | 6 +- src/dynamic/corpus/cmdi/javascript.rs | 6 +- src/dynamic/corpus/cmdi/php.rs | 6 +- src/dynamic/corpus/cmdi/python.rs | 6 +- src/dynamic/corpus/cmdi/ruby.rs | 6 +- src/dynamic/corpus/cmdi/rust.rs | 6 +- src/dynamic/corpus/cmdi/typescript.rs | 6 +- src/dynamic/lang/c.rs | 22 ++ src/dynamic/spec.rs | 161 ++++++++++++++- src/dynamic/telemetry.rs | 2 +- src/dynamic/verify.rs | 190 ++++++++++++++++-- .../class_method/go_recursive_deps/benign.go | 2 +- .../java_recursive_deps/Benign.java | 2 +- .../ruby_recursive_deps/benign.rb | 2 +- tests/dynamic_fixtures/go/cmdi_adversarial.go | 4 +- .../java/cmdi_adversarial.java | 4 +- tests/dynamic_fixtures/js/cmdi_adversarial.js | 4 +- .../dynamic_fixtures/php/cmdi_adversarial.php | 4 +- .../python/cmdi_adversarial.py | 4 +- .../dynamic_fixtures/rust/cmdi_adversarial.rs | 6 +- tests/message_handler_corpus.rs | 8 +- tests/spec_callgraph_resolution.rs | 10 +- tests/spec_derivation_strategies.rs | 7 +- 30 files changed, 524 insertions(+), 130 deletions(-) diff --git a/src/dynamic/build_pool/mod.rs b/src/dynamic/build_pool/mod.rs index 94524942..d533be6a 100644 --- a/src/dynamic/build_pool/mod.rs +++ b/src/dynamic/build_pool/mod.rs @@ -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 `/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. diff --git a/src/dynamic/build_pool/ruby.rs b/src/dynamic/build_pool/ruby.rs index a559872d..b8326777 100644 --- a/src/dynamic/build_pool/ruby.rs +++ b/src/dynamic/build_pool/ruby.rs @@ -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 { diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 0bb27686..8897683d 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -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 { - 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() { diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs index 476b6163..e0a694f6 100644 --- a/src/dynamic/corpus.rs +++ b/src/dynamic/corpus.rs @@ -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)] diff --git a/src/dynamic/corpus/cmdi/c.rs b/src/dynamic/corpus/cmdi/c.rs index 0abf7f37..86128313 100644 --- a/src/dynamic/corpus/cmdi/c.rs +++ b/src/dynamic/corpus/cmdi/c.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/cpp.rs b/src/dynamic/corpus/cmdi/cpp.rs index 0dca6aeb..ecd185b9 100644 --- a/src/dynamic/corpus/cmdi/cpp.rs +++ b/src/dynamic/corpus/cmdi/cpp.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/go.rs b/src/dynamic/corpus/cmdi/go.rs index cfb0fad0..f77d5637 100644 --- a/src/dynamic/corpus/cmdi/go.rs +++ b/src/dynamic/corpus/cmdi/go.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/java.rs b/src/dynamic/corpus/cmdi/java.rs index 62d44630..b17f48d0 100644 --- a/src/dynamic/corpus/cmdi/java.rs +++ b/src/dynamic/corpus/cmdi/java.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/javascript.rs b/src/dynamic/corpus/cmdi/javascript.rs index 6539f46f..838b96ed 100644 --- a/src/dynamic/corpus/cmdi/javascript.rs +++ b/src/dynamic/corpus/cmdi/javascript.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/php.rs b/src/dynamic/corpus/cmdi/php.rs index 8b2a560e..8cd8aa00 100644 --- a/src/dynamic/corpus/cmdi/php.rs +++ b/src/dynamic/corpus/cmdi/php.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/python.rs b/src/dynamic/corpus/cmdi/python.rs index 29bb2145..55afaba7 100644 --- a/src/dynamic/corpus/cmdi/python.rs +++ b/src/dynamic/corpus/cmdi/python.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/ruby.rs b/src/dynamic/corpus/cmdi/ruby.rs index 71eaa155..0dec2f6c 100644 --- a/src/dynamic/corpus/cmdi/ruby.rs +++ b/src/dynamic/corpus/cmdi/ruby.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/rust.rs b/src/dynamic/corpus/cmdi/rust.rs index b37129db..5b7f52e8 100644 --- a/src/dynamic/corpus/cmdi/rust.rs +++ b/src/dynamic/corpus/cmdi/rust.rs @@ -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, diff --git a/src/dynamic/corpus/cmdi/typescript.rs b/src/dynamic/corpus/cmdi/typescript.rs index 7591b4e6..18c12dcb 100644 --- a/src/dynamic/corpus/cmdi/typescript.rs +++ b/src/dynamic/corpus/cmdi/typescript.rs @@ -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, diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index 06e019a9..db9aba0b 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -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 diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs index 81afa52a..da803dd5 100644 --- a/src/dynamic/spec.rs +++ b/src/dynamic/spec.rs @@ -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 `` 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(|| "".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 { + 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 { { 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 { 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 { + 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 { } } +/// 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 { diff --git a/src/dynamic/telemetry.rs b/src/dynamic/telemetry.rs index 0e9ba086..5341d974 100644 --- a/src/dynamic/telemetry.rs +++ b/src/dynamic/telemetry.rs @@ -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 diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index d88aabd7..3268bf0f 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -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 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, diff --git a/tests/dynamic_fixtures/class_method/go_recursive_deps/benign.go b/tests/dynamic_fixtures/class_method/go_recursive_deps/benign.go index 610fbf73..14f68ab1 100644 --- a/tests/dynamic_fixtures/class_method/go_recursive_deps/benign.go +++ b/tests/dynamic_fixtures/class_method/go_recursive_deps/benign.go @@ -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 { diff --git a/tests/dynamic_fixtures/class_method/java_recursive_deps/Benign.java b/tests/dynamic_fixtures/class_method/java_recursive_deps/Benign.java index bcc301db..4c5c2020 100644 --- a/tests/dynamic_fixtures/class_method/java_recursive_deps/Benign.java +++ b/tests/dynamic_fixtures/class_method/java_recursive_deps/Benign.java @@ -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", ""); } } diff --git a/tests/dynamic_fixtures/class_method/ruby_recursive_deps/benign.rb b/tests/dynamic_fixtures/class_method/ruby_recursive_deps/benign.rb index 3d9c8e47..a089ad84 100644 --- a/tests/dynamic_fixtures/class_method/ruby_recursive_deps/benign.rb +++ b/tests/dynamic_fixtures/class_method/ruby_recursive_deps/benign.rb @@ -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 diff --git a/tests/dynamic_fixtures/go/cmdi_adversarial.go b/tests/dynamic_fixtures/go/cmdi_adversarial.go index 612e3c50..66ac0997 100644 --- a/tests/dynamic_fixtures/go/cmdi_adversarial.go +++ b/tests/dynamic_fixtures/go/cmdi_adversarial.go @@ -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) } diff --git a/tests/dynamic_fixtures/java/cmdi_adversarial.java b/tests/dynamic_fixtures/java/cmdi_adversarial.java index a5dae40e..5c32ac2e 100644 --- a/tests/dynamic_fixtures/java/cmdi_adversarial.java +++ b/tests/dynamic_fixtures/java/cmdi_adversarial.java @@ -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(); } } diff --git a/tests/dynamic_fixtures/js/cmdi_adversarial.js b/tests/dynamic_fixtures/js/cmdi_adversarial.js index 616a75d9..c4568c97 100644 --- a/tests/dynamic_fixtures/js/cmdi_adversarial.js +++ b/tests/dynamic_fixtures/js/cmdi_adversarial.js @@ -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; } diff --git a/tests/dynamic_fixtures/php/cmdi_adversarial.php b/tests/dynamic_fixtures/php/cmdi_adversarial.php index 0ba6853b..6b023a32 100644 --- a/tests/dynamic_fixtures/php/cmdi_adversarial.php +++ b/tests/dynamic_fixtures/php/cmdi_adversarial.php @@ -1,12 +1,12 @@