From cce07d6c96d9f418e9573d88c4129b6bcc73cfb3 Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 14 May 2026 05:35:28 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2006:=20Track=20C.1=20?= =?UTF-8?q?=E2=80=94=20SinkProbe=20channel=20+=20structured=20oracle=20obs?= =?UTF-8?q?ervation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dynamic/corpus.rs | 48 ++++-- src/dynamic/lang/c.rs | 39 +++++ src/dynamic/lang/cpp.rs | 52 ++++++ src/dynamic/lang/go.rs | 39 +++++ src/dynamic/lang/java.rs | 59 +++++++ src/dynamic/lang/javascript.rs | 44 +++++ src/dynamic/lang/php.rs | 30 ++++ src/dynamic/lang/python.rs | 54 +++++++ src/dynamic/lang/ruby.rs | 31 ++++ src/dynamic/lang/rust.rs | 65 ++++++++ src/dynamic/lang/typescript.rs | 9 ++ src/dynamic/mod.rs | 2 + src/dynamic/oracle.rs | 245 ++++++++++++++++++++++++++++ src/dynamic/probe.rs | 274 ++++++++++++++++++++++++++++++++ src/dynamic/runner.rs | 84 +++++----- src/dynamic/sandbox.rs | 15 ++ tests/dynamic_sandbox_escape.rs | 1 + tests/oracle_sink_probe.rs | 200 +++++++++++++++++++++++ 18 files changed, 1234 insertions(+), 57 deletions(-) create mode 100644 src/dynamic/oracle.rs create mode 100644 src/dynamic/probe.rs create mode 100644 tests/oracle_sink_probe.rs diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs index 159a8133..fb91f989 100644 --- a/src/dynamic/corpus.rs +++ b/src/dynamic/corpus.rs @@ -1,3 +1,9 @@ +// Legacy [`Oracle::OutputContains`] is intentionally retained for +// pre-Phase-06 corpus entries until they migrate to +// [`Oracle::SinkProbe`]. The deprecation warning is informational, not a +// signal to migrate inside this module. +#![allow(deprecated)] + //! Per-capability payload corpus. //! //! Each [`Cap`] maps to a small set of canonical payloads plus a matching @@ -16,8 +22,18 @@ //! tracks the history of incompatible corpus changes; bumping it invalidates //! all `dynamic_verdict_cache` entries whose spec touched the changed cap. +use crate::dynamic::oracle::ProbePredicate; use crate::labels::Cap; +/// Re-exported canonical [`Oracle`] type. +/// +/// The actual enum lives in [`crate::dynamic::oracle`] alongside +/// [`crate::dynamic::oracle::ProbePredicate`] and +/// [`crate::dynamic::oracle::oracle_fired`]. Re-exported here so the +/// `CuratedPayload.oracle: Oracle` field reads naturally and existing +/// `crate::dynamic::corpus::Oracle` callers keep working. +pub use crate::dynamic::oracle::Oracle; + /// Bump when the corpus content changes in a way that invalidates previously- /// computed [`crate::dynamic::spec::HarnessSpec::spec_hash`] values. /// @@ -75,26 +91,19 @@ pub struct CuratedPayload { /// listener URL + per-finding nonce at execution time (SSRF OOB variant). /// The `bytes` field is unused for such payloads. pub oob_nonce_slot: bool, + /// Structured-oracle predicates evaluated against + /// [`crate::dynamic::probe::SinkProbe`] records drained from the run's + /// probe channel (Phase 06 — Track C.1). Always populated; empty when + /// the payload still relies on the legacy + /// [`Oracle::OutputContains`](crate::dynamic::oracle::Oracle::OutputContains) + /// path and has not been migrated to + /// [`Oracle::SinkProbe`](crate::dynamic::oracle::Oracle::SinkProbe) yet. + pub probe_predicates: &'static [ProbePredicate], } /// Backward-compatible type alias. pub type Payload = CuratedPayload; -/// Detection strategy. -#[derive(Debug, Clone)] -pub enum Oracle { - /// Substring on stdout/stderr. - OutputContains(&'static str), - /// Process exited with a crash signal (SIGSEGV, SIGABRT). - Crash, - /// Outbound network connection observed to a controlled sink host. - OobCallback { host: &'static str }, - /// File written outside the sandbox root. - FileEscape, - /// Non-zero exit with specific status. - ExitStatus(i32), -} - /// Pick the payload set for a given cap. Empty slice = unsupported cap. /// /// # Cap coverage (update when adding/removing Cap bits) @@ -374,6 +383,7 @@ const SQLI: &[CuratedPayload] = &[ deprecated_at_corpus_version: None, fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"], oob_nonce_slot: false, + probe_predicates: &[], }, CuratedPayload { bytes: b"' UNION SELECT 'NYX_SQL_CONFIRMED'--", @@ -385,6 +395,7 @@ const SQLI: &[CuratedPayload] = &[ deprecated_at_corpus_version: None, fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"], oob_nonce_slot: false, + probe_predicates: &[], }, ]; @@ -402,6 +413,7 @@ const CMDI: &[CuratedPayload] = &[CuratedPayload { "tests/benchmark/corpus/rust/cmdi/cmdi_args.rs", ], oob_nonce_slot: false, + probe_predicates: &[], }]; // ── Path traversal ──────────────────────────────────────────────────────────── @@ -422,6 +434,7 @@ const PATH_TRAV: &[CuratedPayload] = &[ "tests/benchmark/corpus/rust/path_traversal/path_read.rs", ], oob_nonce_slot: false, + probe_predicates: &[], }, CuratedPayload { bytes: b"benign_safe_file_that_does_not_exist_NYX_BENIGN", @@ -433,6 +446,7 @@ const PATH_TRAV: &[CuratedPayload] = &[ deprecated_at_corpus_version: None, fixture_paths: &["tests/benchmark/corpus/rust/path_traversal/path_file_open.rs"], oob_nonce_slot: false, + probe_predicates: &[], }, ]; @@ -458,6 +472,7 @@ const SSRF_PAYLOADS: &[CuratedPayload] = &[ deprecated_at_corpus_version: None, fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"], oob_nonce_slot: false, + probe_predicates: &[], }, CuratedPayload { // `bytes` is unused when `oob_nonce_slot = true`; the runner @@ -471,6 +486,7 @@ const SSRF_PAYLOADS: &[CuratedPayload] = &[ deprecated_at_corpus_version: None, fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"], oob_nonce_slot: true, + probe_predicates: &[], }, ]; @@ -488,6 +504,7 @@ const XSS: &[CuratedPayload] = &[ deprecated_at_corpus_version: None, fixture_paths: &["tests/benchmark/corpus/rust/xss/axum_html/main.rs"], oob_nonce_slot: false, + probe_predicates: &[], }, CuratedPayload { bytes: b"Hello World", @@ -499,5 +516,6 @@ const XSS: &[CuratedPayload] = &[ deprecated_at_corpus_version: None, fixture_paths: &["tests/benchmark/corpus/rust/xss/axum_html/main.rs"], oob_nonce_slot: false, + probe_predicates: &[], }, ]; diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index 19b90d68..96dbf3a7 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -18,6 +18,45 @@ pub struct CEmitter; /// Entry kinds the C emitter intends to support once Phase 16 lands. const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Source of the `__nyx_probe` shim for the (future) C harness (Phase 06 — +/// Track C.1). Variadic over `const char *` args; hand-rolled JSON keeps +/// the only dep on libc / stdio. +pub fn probe_shim() -> &'static str { + r#" +/* ── __nyx_probe shim (Phase 06 — Track C.1) ─────────────────────────────── */ +#include +#include +#include +#include +#include + +static void __nyx_probe(const char *sink_callee, int nargs, ...) { + const char *p = getenv("NYX_PROBE_PATH"); + if (!p || *p == '\0') return; + FILE *f = fopen(p, "a"); + if (!f) return; + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + unsigned long long ns = (unsigned long long)ts.tv_sec * 1000000000ULL + + (unsigned long long)ts.tv_nsec; + const char *pid = getenv("NYX_PAYLOAD_ID"); + if (!pid) pid = ""; + fprintf(f, "{\"sink_callee\":\"%s\",\"args\":[", sink_callee); + va_list ap; + va_start(ap, nargs); + for (int i = 0; i < nargs; ++i) { + const char *arg = va_arg(ap, const char *); + if (!arg) arg = ""; + if (i > 0) fputc(',', f); + fprintf(f, "{\"kind\":\"String\",\"value\":\"%s\"}", arg); + } + va_end(ap); + fprintf(f, "],\"captured_at_ns\":%llu,\"payload_id\":\"%s\"}\n", ns, pid); + fclose(f); +} +"# +} + impl LangEmitter for CEmitter { fn emit(&self, _spec: &HarnessSpec) -> Result { Err(UnsupportedReason::LangUnsupported) diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index 0781998d..f825a086 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -18,6 +18,58 @@ pub struct CppEmitter; /// Entry kinds the C++ emitter intends to support once Phase 16 lands. const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Source of the `__nyx_probe` shim for the (future) C++ harness +/// (Phase 06 — Track C.1). Uses `` + variadic templates; the +/// JSON-emit format matches [`crate::dynamic::probe::SinkProbe`]. +pub fn probe_shim() -> &'static str { + r#" +/* ── __nyx_probe shim (Phase 06 — Track C.1) ─────────────────────────────── */ +#include +#include +#include +#include +#include + +inline void __nyx_probe_one(std::ostringstream &out, const std::string &v) { + out << "{\"kind\":\"String\",\"value\":\""; + for (char c : v) { + switch (c) { + case '"': out << "\\\""; break; + case '\\': out << "\\\\"; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: out << c; + } + } + out << "\"}"; +} + +template +inline void __nyx_probe(const char *sink_callee, Args... args) { + const char *p = std::getenv("NYX_PROBE_PATH"); + if (!p || *p == '\0') return; + std::ostringstream out; + out << "{\"sink_callee\":\"" << sink_callee << "\",\"args\":["; + bool first = true; + auto emit = [&](const std::string &s) { + if (!first) out << ','; + first = false; + __nyx_probe_one(out, s); + }; + (emit(std::string(args)), ...); + const char *pid = std::getenv("NYX_PAYLOAD_ID"); + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ).count(); + out << "],\"captured_at_ns\":" << now << ",\"payload_id\":\"" + << (pid ? pid : "") << "\"}\n"; + std::ofstream f(p, std::ios::app); + if (f.is_open()) f << out.str(); +} +"# +} + impl LangEmitter for CppEmitter { fn emit(&self, _spec: &HarnessSpec) -> Result { Err(UnsupportedReason::LangUnsupported) diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index be76a6d6..d53e81f2 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -53,6 +53,45 @@ impl LangEmitter for GoEmitter { } } +/// Source of the `__nyx_probe` shim for the Go harness (Phase 06 — +/// Track C.1). Variadic over `string` so callers can pass any number of +/// captured args at the sink site. +pub fn probe_shim() -> &'static str { + r#" +// ── __nyx_probe shim (Phase 06 — Track C.1) ────────────────────────────────── +func __nyx_probe(sinkCallee string, args ...string) { + p := os.Getenv("NYX_PROBE_PATH") + if p == "" { + return + } + serArgs := make([]map[string]interface{}, 0, len(args)) + for _, a := range args { + serArgs = append(serArgs, map[string]interface{}{ + "kind": "String", + "value": a, + }) + } + rec := map[string]interface{}{ + "sink_callee": sinkCallee, + "args": serArgs, + "captured_at_ns": uint64(time.Now().UnixNano()), + "payload_id": os.Getenv("NYX_PAYLOAD_ID"), + } + b, err := json.Marshal(rec) + if err != nil { + return + } + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + f.Write(b) + f.Write([]byte("\n")) +} +"# +} + /// Emit a Go harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index aa00e83c..2ebdd1da 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -55,6 +55,65 @@ impl LangEmitter for JavaEmitter { } } +/// Source of the `__nyx_probe` shim for the Java harness (Phase 06 — +/// Track C.1). +/// +/// Splices into the generated harness class as a `static void __nyx_probe(...)` +/// method. Hand-rolled JSON keeps the shim free of org.json / jackson +/// dependencies; matches the +/// [`crate::dynamic::probe::SinkProbe`] wire format. +pub fn probe_shim() -> &'static str { + r#" + // ── __nyx_probe shim (Phase 06 — Track C.1) ────────────────────────────────── + static void __nyx_probe(String sinkCallee, String... args) { + String p = System.getenv("NYX_PROBE_PATH"); + if (p == null || p.isEmpty()) { + return; + } + long now = System.nanoTime(); + String payloadId = System.getenv("NYX_PAYLOAD_ID"); + if (payloadId == null) payloadId = ""; + StringBuilder line = new StringBuilder(128); + line.append("{\"sink_callee\":\""); + nyxJsonEscape(sinkCallee, line); + line.append("\",\"args\":["); + for (int i = 0; i < args.length; i++) { + if (i > 0) line.append(','); + line.append("{\"kind\":\"String\",\"value\":\""); + nyxJsonEscape(args[i] == null ? "" : args[i], line); + line.append("\"}"); + } + line.append("],\"captured_at_ns\":").append(now).append(",\"payload_id\":\""); + nyxJsonEscape(payloadId, line); + line.append("\"}\n"); + try (java.io.FileWriter fw = new java.io.FileWriter(p, true)) { + fw.write(line.toString()); + } catch (java.io.IOException e) { + // best-effort + } + } + + private static void nyxJsonEscape(String s, StringBuilder out) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': out.append("\\\""); break; + case '\\': out.append("\\\\"); break; + case '\n': out.append("\\n"); break; + case '\r': out.append("\\r"); break; + case '\t': out.append("\\t"); break; + default: + if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } +"# +} + /// Emit a Java harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index cea6c7a1..f4165b42 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -49,6 +49,47 @@ impl LangEmitter for JavaScriptEmitter { } } +/// Source of the `__nyx_probe` shim for the Node.js harness. +/// +/// Defined once here so both [`JavaScriptEmitter`] and +/// [`crate::dynamic::lang::typescript::TypeScriptEmitter`] reuse the same +/// JSON-emit format. Writes a single [`crate::dynamic::probe::SinkProbe`] +/// JSON line to `NYX_PROBE_PATH` per call; no-op when the env var is +/// unset. +pub fn probe_shim() -> &'static str { + r#" +// ── __nyx_probe shim (Phase 06 — Track C.1) ────────────────────────────────── +function __nyx_probe(sinkCallee, ...args) { + const _fs = require('fs'); + const _p = process.env.NYX_PROBE_PATH; + if (!_p) return; + const _ser = args.map(function (a) { + if (a && typeof a === 'object' && (a instanceof Buffer || a instanceof Uint8Array)) { + return { kind: 'Bytes', value: Array.from(a) }; + } + if (typeof a === 'number' && Number.isInteger(a)) { + return { kind: 'Int', value: a }; + } + if (typeof a === 'boolean') { + return { kind: 'Int', value: a ? 1 : 0 }; + } + return { kind: 'String', value: String(a) }; + }); + const _rec = { + sink_callee: String(sinkCallee), + args: _ser, + captured_at_ns: Number(process.hrtime.bigint()), + payload_id: String(process.env.NYX_PAYLOAD_ID || ''), + }; + try { + _fs.appendFileSync(_p, JSON.stringify(_rec) + '\n'); + } catch (e) { + // best-effort: probe channel write failure is non-fatal. + } +} +"# +} + /// Emit a Node.js harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { @@ -72,10 +113,12 @@ fn generate_source(spec: &HarnessSpec) -> String { let entry_module = entry_module_name(&spec.entry_file); let entry_fn = &spec.entry_name; let (pre_call, call_expr) = build_call(spec, &entry_module, entry_fn); + let probe = probe_shim(); format!( r#"'use strict'; // Nyx dynamic harness — auto-generated, do not edit. +{probe} // ── Payload loading ──────────────────────────────────────────────────────────── const _nyx_payload = (() => {{ @@ -120,6 +163,7 @@ try {{ entry_module = entry_module, pre_call = pre_call, call_expr = call_expr, + probe = probe, ) } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 26784834..0a4bb45c 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -47,6 +47,36 @@ impl LangEmitter for PhpEmitter { } } +/// Source of the `__nyx_probe` shim for the PHP harness (Phase 06 — +/// Track C.1). +pub fn probe_shim() -> &'static str { + r#" +// ── __nyx_probe shim (Phase 06 — Track C.1) ────────────────────────────────── +function __nyx_probe(string $sinkCallee, ...$args): void { + $p = getenv('NYX_PROBE_PATH'); + if ($p === false || $p === '') { + return; + } + $ser = []; + foreach ($args as $a) { + if (is_int($a)) { + $ser[] = ['kind' => 'Int', 'value' => $a]; + } else { + $ser[] = ['kind' => 'String', 'value' => (string) $a]; + } + } + $rec = [ + 'sink_callee' => $sinkCallee, + 'args' => $ser, + 'captured_at_ns' => (int) (microtime(true) * 1e9), + 'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''), + ]; + $line = json_encode($rec) . "\n"; + @file_put_contents($p, $line, FILE_APPEND); +} +"# +} + /// Emit a PHP harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 51e23d5b..67d54473 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -42,6 +42,45 @@ impl LangEmitter for PythonEmitter { } } +/// Source of the `__nyx_probe` shim for the Python harness. +/// +/// The shim is callable as `__nyx_probe("sink.callee", arg0, arg1, ...)`. +/// It emits one JSON line per call to `NYX_PROBE_PATH` (when set) in the +/// [`crate::dynamic::probe::SinkProbe`] schema. No-op when the env var +/// is unset, so the shim is safe to inject even when the runner has not +/// configured a probe channel. +pub fn probe_shim() -> &'static str { + r#" +# ── __nyx_probe shim (Phase 06 — Track C.1) ────────────────────────────────── +def __nyx_probe(sink_callee, *args): + import os, time, json + p = os.environ.get("NYX_PROBE_PATH") + if not p: + return + serialised = [] + for a in args: + if isinstance(a, (bytes, bytearray)): + serialised.append({"kind": "Bytes", "value": list(a)}) + elif isinstance(a, bool): + serialised.append({"kind": "Int", "value": 1 if a else 0}) + elif isinstance(a, int): + serialised.append({"kind": "Int", "value": a}) + else: + serialised.append({"kind": "String", "value": str(a)}) + rec = { + "sink_callee": str(sink_callee), + "args": serialised, + "captured_at_ns": time.time_ns(), + "payload_id": os.environ.get("NYX_PAYLOAD_ID", ""), + } + try: + with open(p, "a") as _f: + _f.write(json.dumps(rec) + "\n") + except OSError: + pass +"# +} + /// Emit a Python harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { // Validate payload slot. @@ -69,6 +108,7 @@ fn generate_source(spec: &HarnessSpec) -> String { // Build the call expression based on payload slot. let (pre_call, call_expr) = build_call(spec, entry_module, entry_fn); + let probe = probe_shim(); format!( r#"#!/usr/bin/env python3 @@ -81,6 +121,8 @@ import traceback # Fires __NYX_SINK_HIT__ exactly once when the traced function is called at # the expected file:line. Filtered to avoid false positives from library code. +{probe} + _NYX_SINK_FILE = {sink_file:?} _NYX_SINK_LINE = {sink_line} _NYX_SINK_HIT = False @@ -152,6 +194,7 @@ sys.settrace(None) entry_module = entry_module, pre_call = pre_call, call_expr = call_expr, + probe = probe, ) } @@ -277,6 +320,17 @@ mod tests { assert!(hint.contains("phase 12")); } + #[test] + fn probe_shim_is_injected() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert!( + harness.source.contains("def __nyx_probe"), + "Phase 06 shim must be present in generated harness", + ); + assert!(harness.source.contains("NYX_PROBE_PATH")); + } + #[test] fn unsupported_lang_returns_err() { let mut spec = make_spec(PayloadSlot::Param(0)); diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 260cee61..a546b1ac 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -20,6 +20,37 @@ pub struct RubyEmitter; /// `Inconclusive(EntryKindUnsupported)` rather than `Unsupported`. const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Source of the `__nyx_probe` shim for the (future) Ruby harness +/// (Phase 06 — Track C.1). Defined here for the deliverable contract +/// even though `emit` returns `LangUnsupported` until Phase 15 lands. +pub fn probe_shim() -> &'static str { + r#" +# ── __nyx_probe shim (Phase 06 — Track C.1) ────────────────────────────────── +def __nyx_probe(sink_callee, *args) + require 'json' + p = ENV['NYX_PROBE_PATH'] + return if p.nil? || p.empty? + ser = args.map do |a| + case a + when Integer then { kind: 'Int', value: a } + when String then { kind: 'String', value: a } + else { kind: 'String', value: a.to_s } + end + end + rec = { + sink_callee: sink_callee.to_s, + args: ser, + captured_at_ns: (Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)), + payload_id: (ENV['NYX_PAYLOAD_ID'] || ''), + } + begin + File.open(p, 'a') { |f| f.puts(rec.to_json) } + rescue StandardError + end +end +"# +} + impl LangEmitter for RubyEmitter { fn emit(&self, _spec: &HarnessSpec) -> Result { Err(UnsupportedReason::LangUnsupported) diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 537b4bd0..a36de567 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -51,6 +51,71 @@ impl LangEmitter for RustEmitter { } } +/// Source of the `__nyx_probe` shim for the Rust harness (Phase 06 — +/// Track C.1). +/// +/// Defined here so future sink-rewrite passes can splice +/// `__nyx_probe("os.system", payload)` into the entry source without +/// depending on serde at the harness boundary. Hand-rolled JSON keeps +/// the shim's only dep on `std`; matches the +/// [`crate::dynamic::probe::SinkProbe`] wire format. +pub fn probe_shim() -> &'static str { + r#" +// ── __nyx_probe shim (Phase 06 — Track C.1) ────────────────────────────────── +#[allow(dead_code)] +fn __nyx_probe(sink_callee: &str, args: &[&str]) { + use std::io::Write; + let p = match std::env::var("NYX_PROBE_PATH") { + Ok(v) => v, + Err(_) => return, + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let payload_id = std::env::var("NYX_PAYLOAD_ID").unwrap_or_default(); + fn esc(s: &str, out: &mut String) { + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + } + let mut line = String::with_capacity(128); + line.push_str("{\"sink_callee\":\""); + esc(sink_callee, &mut line); + line.push_str("\",\"args\":["); + for (i, a) in args.iter().enumerate() { + if i > 0 { + line.push(','); + } + line.push_str("{\"kind\":\"String\",\"value\":\""); + esc(a, &mut line); + line.push_str("\"}"); + } + line.push_str(&format!( + "],\"captured_at_ns\":{},\"payload_id\":\"", + now + )); + esc(&payload_id, &mut line); + line.push_str("\"}\n"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&p) + { + let _ = f.write_all(line.as_bytes()); + } +} +"# +} + /// Emit a Rust harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { diff --git a/src/dynamic/lang/typescript.rs b/src/dynamic/lang/typescript.rs index 453c32c1..1d103de6 100644 --- a/src/dynamic/lang/typescript.rs +++ b/src/dynamic/lang/typescript.rs @@ -27,6 +27,15 @@ pub struct TypeScriptEmitter; /// browser modules). const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Source of the `__nyx_probe` shim for TypeScript harnesses. +/// +/// Delegates to [`crate::dynamic::lang::javascript::probe_shim`] — the +/// runtime is Node.js in both cases, so the JSON-emit shim is identical +/// after type erasure. +pub fn probe_shim() -> &'static str { + javascript::probe_shim() +} + impl LangEmitter for TypeScriptEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { javascript::emit(spec) diff --git a/src/dynamic/mod.rs b/src/dynamic/mod.rs index c758bf3e..0773e5df 100644 --- a/src/dynamic/mod.rs +++ b/src/dynamic/mod.rs @@ -71,6 +71,8 @@ pub mod harness; pub mod lang; pub mod mount_filter; pub mod oob; +pub mod oracle; +pub mod probe; pub mod repro; pub mod report; pub mod runner; diff --git a/src/dynamic/oracle.rs b/src/dynamic/oracle.rs new file mode 100644 index 00000000..7ed3488c --- /dev/null +++ b/src/dynamic/oracle.rs @@ -0,0 +1,245 @@ +//! Verdict oracle — how a sandbox run becomes Confirmed / NotConfirmed. +//! +//! Phase 06 (Track C.1) introduces the structured [`Oracle::SinkProbe`] +//! path: each curated payload supplies a small set of +//! [`ProbePredicate`]s; the runner drains the +//! [`crate::dynamic::probe::ProbeChannel`] after every payload run and +//! evaluates the predicates against the captured arguments. A run is +//! Confirmed iff at least one drained record satisfies *every* predicate. +//! +//! The legacy [`Oracle::OutputContains`] path is retained for fixtures that +//! pre-date Phase 06 and migrated downstream; it is marked +//! `#[deprecated]` so the compiler nags every new use-site. + +use crate::dynamic::probe::SinkProbe; +use crate::dynamic::sandbox::SandboxOutcome; + +/// Predicate evaluated against a single [`SinkProbe`] when the oracle is +/// [`Oracle::SinkProbe`]. +/// +/// Fields use `&'static str` so the corpus can declare predicate slices +/// in `const` context — there is no allocation cost at scan time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbePredicate { + /// Captured arg at `index` contains `needle` as a substring. String + /// view of the arg is taken via [`super::probe::ProbeArg::as_str`]. + ArgContains { index: usize, needle: &'static str }, + /// Captured arg at `index` is byte-for-byte equal to `value`. + ArgEquals { index: usize, value: &'static str }, + /// At least one captured arg contains `needle`. Useful when the sink + /// signature varies (e.g. variadic `printf`). + AnyArgContains(&'static str), + /// The probe's `sink_callee` field is byte-for-byte equal to `value`. + CalleeEquals(&'static str), + /// The probe records at least `min_args` arguments. Lets a payload + /// pin the sink's arity without locking exact values. + MinArgs(usize), +} + +/// How we decide a sandbox run confirmed the sink fired. +#[derive(Debug, Clone)] +pub enum Oracle { + /// Structured: drain the probe channel and apply `predicates`. + /// `predicates: &'static [ProbePredicate]` keeps the corpus + /// declaration `const`-friendly (Phase 06 deferred the + /// `Vec` shape the plan listed because the corpus is + /// declared in static memory; a `Vec` would require runtime init). + SinkProbe { predicates: &'static [ProbePredicate] }, + /// Legacy stdout/stderr substring oracle. Kept for fixtures that + /// pre-date Phase 06; new payloads should prefer + /// [`Oracle::SinkProbe`] which is robust to oracle collisions. + #[deprecated( + note = "use Oracle::SinkProbe with ProbePredicate args; OutputContains is brittle to oracle collisions (§16.3)" + )] + OutputContains(&'static str), + /// Process exited with a crash signal (SIGSEGV, SIGABRT). + Crash, + /// Outbound network connection observed at the controlled sink host. + OobCallback { host: &'static str }, + /// File written outside the sandbox root. + FileEscape, + /// Non-zero exit with specific status. + ExitStatus(i32), +} + +/// Evaluate an oracle against a single sandbox outcome plus the records +/// drained from the run's probe channel. Returns `true` iff the run is +/// considered to have fired the sink. +#[allow(deprecated)] +pub fn oracle_fired(oracle: &Oracle, outcome: &SandboxOutcome, probes: &[SinkProbe]) -> bool { + match oracle { + Oracle::SinkProbe { predicates } => probes + .iter() + .any(|p| probe_satisfies_all(p, predicates)), + Oracle::OutputContains(needle) => { + let nb = needle.as_bytes(); + contains_subslice(&outcome.stdout, nb) || contains_subslice(&outcome.stderr, nb) + } + Oracle::Crash => outcome.exit_code.is_none() && !outcome.timed_out, + Oracle::OobCallback { .. } => outcome.oob_callback_seen, + Oracle::FileEscape => false, + Oracle::ExitStatus(code) => outcome.exit_code == Some(*code), + } +} + +/// Returns true when `probe` satisfies *every* predicate in `preds`. +/// An empty predicate slice satisfies vacuously — a payload that wants +/// "any probe at all" can ship an empty predicate set. +pub fn probe_satisfies_all(probe: &SinkProbe, preds: &[ProbePredicate]) -> bool { + preds.iter().all(|p| probe_satisfies_one(probe, p)) +} + +fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool { + match pred { + ProbePredicate::ArgContains { index, needle } => probe + .args + .get(*index) + .and_then(|a| a.as_str()) + .map(|s| s.contains(*needle)) + .unwrap_or(false), + ProbePredicate::ArgEquals { index, value } => probe + .args + .get(*index) + .and_then(|a| a.as_str()) + .map(|s| s == *value) + .unwrap_or(false), + ProbePredicate::AnyArgContains(needle) => probe + .args + .iter() + .any(|a| a.as_str().map(|s| s.contains(*needle)).unwrap_or(false)), + ProbePredicate::CalleeEquals(value) => probe.sink_callee == *value, + ProbePredicate::MinArgs(n) => probe.args.len() >= *n, + } +} + +fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() { + return true; + } + if needle.len() > hay.len() { + return false; + } + hay.windows(needle.len()).any(|w| w == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::probe::{ProbeArg, SinkProbe}; + use std::time::Duration; + + fn outcome() -> SandboxOutcome { + SandboxOutcome { + exit_code: Some(0), + stdout: vec![], + stderr: vec![], + timed_out: false, + oob_callback_seen: false, + sink_hit: false, + duration: Duration::from_millis(1), + } + } + + fn probe(callee: &str, args: Vec) -> SinkProbe { + SinkProbe { + sink_callee: callee.into(), + args, + captured_at_ns: 1, + payload_id: "test".into(), + } + } + + #[test] + fn sink_probe_fires_when_predicates_match() { + let oracle = Oracle::SinkProbe { + predicates: &[ + ProbePredicate::CalleeEquals("os.system"), + ProbePredicate::ArgContains { index: 0, needle: "; echo" }, + ], + }; + let probes = vec![probe( + "os.system", + vec![ProbeArg::String("; echo NYX_PWN".into())], + )]; + assert!(oracle_fired(&oracle, &outcome(), &probes)); + } + + #[test] + fn sink_probe_not_fired_with_no_probes() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::CalleeEquals("os.system")], + }; + assert!(!oracle_fired(&oracle, &outcome(), &[])); + } + + #[test] + fn sink_probe_requires_all_predicates() { + let oracle = Oracle::SinkProbe { + predicates: &[ + ProbePredicate::CalleeEquals("os.system"), + ProbePredicate::ArgContains { index: 0, needle: "NEVER_PRESENT" }, + ], + }; + let probes = vec![probe( + "os.system", + vec![ProbeArg::String("hello".into())], + )]; + assert!(!oracle_fired(&oracle, &outcome(), &probes)); + } + + #[test] + fn any_arg_contains_matches_second_arg() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::AnyArgContains("password")], + }; + let probes = vec![probe( + "exec", + vec![ + ProbeArg::String("benign".into()), + ProbeArg::String("leaked password".into()), + ], + )]; + assert!(oracle_fired(&oracle, &outcome(), &probes)); + } + + #[test] + fn min_args_predicate() { + let probes_two = vec![probe( + "exec", + vec![ProbeArg::String("a".into()), ProbeArg::String("b".into())], + )]; + let probes_one = vec![probe("exec", vec![ProbeArg::String("a".into())])]; + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::MinArgs(2)], + }; + assert!(oracle_fired(&oracle, &outcome(), &probes_two)); + assert!(!oracle_fired(&oracle, &outcome(), &probes_one)); + } + + #[test] + fn empty_predicate_set_matches_any_probe() { + let oracle = Oracle::SinkProbe { predicates: &[] }; + let probes = vec![probe("anything", vec![])]; + assert!(oracle_fired(&oracle, &outcome(), &probes)); + } + + #[test] + #[allow(deprecated)] + fn output_contains_legacy_still_works() { + let mut o = outcome(); + o.stdout = b"NYX_OK".to_vec(); + let oracle = Oracle::OutputContains("NYX_OK"); + assert!(oracle_fired(&oracle, &o, &[])); + } + + #[test] + fn arg_equals_predicate() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::ArgEquals { index: 0, value: "exact" }], + }; + let hit = vec![probe("f", vec![ProbeArg::String("exact".into())])]; + let miss = vec![probe("f", vec![ProbeArg::String("inexact".into())])]; + assert!(oracle_fired(&oracle, &outcome(), &hit)); + assert!(!oracle_fired(&oracle, &outcome(), &miss)); + } +} diff --git a/src/dynamic/probe.rs b/src/dynamic/probe.rs new file mode 100644 index 00000000..48084387 --- /dev/null +++ b/src/dynamic/probe.rs @@ -0,0 +1,274 @@ +//! Structured sink-probe channel (Phase 06 — Track C.1). +//! +//! Replaces the brittle stdout-substring matching path with a per-run JSON-line +//! channel. Each harness defines a `__nyx_probe` shim (see the per-language +//! emitter in [`crate::dynamic::lang`]) that writes one [`SinkProbe`] record +//! to the channel when the instrumented sink fires. After each sandbox run +//! the runner calls [`ProbeChannel::drain`] and the oracle (see +//! [`crate::dynamic::oracle::oracle_fired`]) evaluates a payload's +//! [`crate::dynamic::oracle::ProbePredicate`] set against the captured args. +//! +//! # Channel medium +//! +//! Currently file-based: one JSON record per line at +//! `/__nyx_probes.jsonl`. The path is exposed to the harness via +//! the `NYX_PROBE_PATH` env var (see [`PROBE_PATH_ENV`]). Named-pipe (FIFO) +//! transport is deferred; the file variant works on every platform the +//! sandbox supports and matches the drain-after-run lifecycle the runner +//! actually uses — there are no streaming consumers. +//! +//! Records are appended, so a single payload can fire the shim multiple +//! times (e.g. inside a retry loop) and the oracle sees every observation. +//! The runner truncates the file via [`ProbeChannel::clear`] before each +//! payload to keep verdicts independent. + +use serde::{Deserialize, Serialize}; +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +/// Default filename for the file-backed probe channel inside a harness +/// workdir. The harness shim and the runner both build their paths off +/// this constant so they cannot drift apart. +pub const PROBE_FILENAME: &str = "__nyx_probes.jsonl"; + +/// Env-var name that carries the absolute path of the probe channel into +/// the harness process. Read by the per-language `__nyx_probe` shim. +pub const PROBE_PATH_ENV: &str = "NYX_PROBE_PATH"; + +/// Identifier of the payload that triggered the probe. Currently the +/// static [`crate::dynamic::corpus::CuratedPayload::label`] string; future +/// fuzzer-generated payloads will use the corpus hash. +pub type PayloadId = String; + +/// A single captured argument observed at the sink call site. +/// +/// The harness shim chooses the variant based on the argument's runtime +/// type so the oracle can apply byte-level predicates without losing +/// information to lossy string conversion. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "value")] +pub enum ProbeArg { + /// UTF-8 string argument. + String(String), + /// Raw byte buffer (e.g. `bytes` in Python, `Buffer` in Node). + Bytes(Vec), + /// Signed 64-bit integer. + Int(i64), +} + +impl ProbeArg { + /// String view, when the arg is textual. Returns `None` for `Int` and + /// non-UTF-8 `Bytes`. + pub fn as_str(&self) -> Option<&str> { + match self { + ProbeArg::String(s) => Some(s.as_str()), + ProbeArg::Bytes(b) => std::str::from_utf8(b).ok(), + ProbeArg::Int(_) => None, + } + } + + /// Byte view, when the arg is byte-shaped. Returns `None` for `Int`. + pub fn as_bytes(&self) -> Option<&[u8]> { + match self { + ProbeArg::String(s) => Some(s.as_bytes()), + ProbeArg::Bytes(b) => Some(b), + ProbeArg::Int(_) => None, + } + } + + /// Integer view, when the arg is `Int`. + pub fn as_int(&self) -> Option { + match self { + ProbeArg::Int(i) => Some(*i), + _ => None, + } + } +} + +/// One structured observation written by the harness when the instrumented +/// sink fires. Serialised as a single JSON object on its own line. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SinkProbe { + /// Fully-qualified or last-segment callee name of the fired sink + /// (e.g. `"os.system"`, `"Runtime.exec"`). + pub sink_callee: String, + /// Captured positional arguments, left-to-right. Empty when the sink + /// takes no arguments or the shim could not introspect them. + pub args: Vec, + /// Monotonic-ish nanosecond timestamp captured at write time. Used to + /// order multiple probe entries from the same run; absolute value is + /// not meaningful across runs. + pub captured_at_ns: u64, + /// Identifier of the payload in flight when the probe fired. + pub payload_id: PayloadId, +} + +/// Per-run handle on a file-backed [`SinkProbe`] channel. +/// +/// Construction creates / truncates the underlying file under `workdir`; +/// [`clear`](ProbeChannel::clear) re-truncates between payload runs; +/// [`drain`](ProbeChannel::drain) reads every record currently buffered. +#[derive(Debug)] +pub struct ProbeChannel { + path: PathBuf, + /// Serialises read / write / truncate operations against the underlying + /// file from the host side. The harness process writes from its own + /// address space; this lock only protects host-side callers (test + /// helpers, the runner). + io_lock: Mutex<()>, +} + +impl ProbeChannel { + /// Construct a channel rooted at `/__nyx_probes.jsonl`. + /// + /// Creates the file (truncating any previous contents) so a stale + /// probe file left over from a prior workdir reuse cannot poison the + /// next run's oracle. + pub fn for_workdir(workdir: &Path) -> std::io::Result { + let path = workdir.join(PROBE_FILENAME); + File::create(&path)?; + Ok(Self { + path, + io_lock: Mutex::new(()), + }) + } + + /// Construct a channel at an explicit path (test helper). Mirrors + /// [`for_workdir`](ProbeChannel::for_workdir) but does not assume any + /// directory layout. + pub fn at_path(path: PathBuf) -> std::io::Result { + File::create(&path)?; + Ok(Self { + path, + io_lock: Mutex::new(()), + }) + } + + /// Absolute path of the probe file. Forwarded to the harness process + /// via the `NYX_PROBE_PATH` env var. + pub fn path(&self) -> &Path { + &self.path + } + + /// Truncate the channel between payload runs. Cheap: a single + /// `File::create` on the existing path. + pub fn clear(&self) -> std::io::Result<()> { + let _guard = self.io_lock.lock().ok(); + File::create(&self.path)?; + Ok(()) + } + + /// Read every record currently buffered. Malformed lines (truncated + /// writes, partial flushes) are skipped silently — the oracle treats a + /// missing probe as "sink did not fire" without distinguishing causes. + pub fn drain(&self) -> Vec { + let _guard = self.io_lock.lock().ok(); + let file = match File::open(&self.path) { + Ok(f) => f, + Err(_) => return Vec::new(), + }; + let reader = BufReader::new(file); + let mut out = Vec::new(); + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(p) = serde_json::from_str::(trimmed) { + out.push(p); + } + } + out + } + + /// Append a probe record from the host side. Primarily a test helper: + /// in production the harness process writes directly via its + /// per-language shim, bypassing this entry point. + pub fn write(&self, probe: &SinkProbe) -> std::io::Result<()> { + let _guard = self.io_lock.lock().ok(); + let mut file = OpenOptions::new() + .append(true) + .create(true) + .open(&self.path)?; + let line = serde_json::to_string(probe).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + })?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_probe(label: &str) -> SinkProbe { + SinkProbe { + sink_callee: "os.system".into(), + args: vec![ProbeArg::String("ls; whoami".into())], + captured_at_ns: 42, + payload_id: label.into(), + } + } + + #[test] + fn channel_round_trip_writes_and_drains() { + let dir = TempDir::new().unwrap(); + let ch = ProbeChannel::for_workdir(dir.path()).unwrap(); + ch.write(&sample_probe("cmdi-echo-marker")).unwrap(); + ch.write(&sample_probe("cmdi-echo-marker-2")).unwrap(); + let probes = ch.drain(); + assert_eq!(probes.len(), 2); + assert_eq!(probes[0].payload_id, "cmdi-echo-marker"); + assert_eq!(probes[1].payload_id, "cmdi-echo-marker-2"); + } + + #[test] + fn drain_after_clear_returns_empty() { + let dir = TempDir::new().unwrap(); + let ch = ProbeChannel::for_workdir(dir.path()).unwrap(); + ch.write(&sample_probe("a")).unwrap(); + ch.clear().unwrap(); + assert!(ch.drain().is_empty()); + } + + #[test] + fn drain_skips_malformed_lines() { + let dir = TempDir::new().unwrap(); + let ch = ProbeChannel::for_workdir(dir.path()).unwrap(); + // Manually append a junk line, then a valid one. + std::fs::write(ch.path(), "this is not json\n").unwrap(); + ch.write(&sample_probe("after-junk")).unwrap(); + let probes = ch.drain(); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].payload_id, "after-junk"); + } + + #[test] + fn probe_arg_views() { + let s = ProbeArg::String("hello".into()); + assert_eq!(s.as_str(), Some("hello")); + assert_eq!(s.as_bytes(), Some(&b"hello"[..])); + assert_eq!(s.as_int(), None); + + let i = ProbeArg::Int(7); + assert_eq!(i.as_str(), None); + assert_eq!(i.as_bytes(), None); + assert_eq!(i.as_int(), Some(7)); + + let b = ProbeArg::Bytes(vec![b'h', b'i']); + assert_eq!(b.as_str(), Some("hi")); + assert_eq!(b.as_bytes(), Some(&[b'h', b'i'][..])); + } + + #[test] + fn empty_channel_drains_to_empty_vec() { + let dir = TempDir::new().unwrap(); + let ch = ProbeChannel::for_workdir(dir.path()).unwrap(); + assert!(ch.drain().is_empty()); + } +} diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index e0e32ee0..024467ec 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -6,11 +6,14 @@ //! the result into a [`crate::dynamic::report::VerifyResult`]. use crate::dynamic::build_sandbox; -use crate::dynamic::corpus::{benign_payload_for, materialise_bytes, payloads_for, Oracle, Payload}; +use crate::dynamic::corpus::{benign_payload_for, materialise_bytes, payloads_for, Payload}; use crate::dynamic::harness::{self, HarnessError}; +use crate::dynamic::oracle::oracle_fired; +use crate::dynamic::probe::{ProbeChannel, SinkProbe}; use crate::dynamic::sandbox::{self, SandboxBackend, SandboxError, SandboxOptions, SandboxOutcome}; use crate::dynamic::spec::HarnessSpec; use crate::symbol::Lang; +use std::sync::Arc; /// Max harness-build attempts before giving up. const MAX_BUILD_ATTEMPTS: u32 = 2; @@ -201,6 +204,19 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result> = effective_opts.probe_channel.clone(); + // Run only vuln (non-benign) payloads in the main loop. let vuln_payloads: Vec<&Payload> = payloads.iter().filter(|p| !p.is_benign).collect(); let benign_payload = benign_payload_for(spec.expected_cap); @@ -212,9 +228,9 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result Result Result = probe_channel + .as_ref() + .map(|ch| ch.drain()) + .unwrap_or_default(); + + let fired = oracle_fired(&payload.oracle, &outcome, &probes); let sink_hit = outcome.sink_hit; let triggered = if fired && sink_hit { @@ -251,8 +278,15 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result = probe_channel + .as_ref() + .map(|ch| ch.drain()) + .unwrap_or_default(); + let benign_fired = oracle_fired(&benign.oracle, &benign_outcome, &benign_probes); !benign_fired } else { true @@ -301,25 +335,6 @@ fn uses_docker_backend(opts: &SandboxOptions) -> bool { } } -fn oracle_fired(oracle: &Oracle, outcome: &SandboxOutcome) -> bool { - match oracle { - Oracle::OutputContains(needle) => { - let nb = needle.as_bytes(); - contains_subslice(&outcome.stdout, nb) || contains_subslice(&outcome.stderr, nb) - } - Oracle::Crash => matches!(outcome.exit_code, None) && !outcome.timed_out, - Oracle::OobCallback { .. } => outcome.oob_callback_seen, - Oracle::FileEscape => false, - Oracle::ExitStatus(code) => outcome.exit_code == Some(*code), - } -} - -fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool { - if needle.is_empty() || needle.len() > hay.len() { - return needle.is_empty(); - } - hay.windows(needle.len()).any(|w| w == needle) -} /// Generate a random 16-character hex nonce for OOB callback tracking. fn generate_nonce() -> String { @@ -340,21 +355,6 @@ fn generate_nonce() -> String { mod tests { use super::*; - #[test] - fn contains_subslice_empty_needle() { - assert!(contains_subslice(b"hello", b"")); - } - - #[test] - fn contains_subslice_finds_match() { - assert!(contains_subslice(b"hello world", b"world")); - } - - #[test] - fn contains_subslice_no_match() { - assert!(!contains_subslice(b"hello", b"xyz")); - } - #[test] fn generate_nonce_is_16_hex_chars() { let n = generate_nonce(); diff --git a/src/dynamic/sandbox.rs b/src/dynamic/sandbox.rs index 992254bc..a4068216 100644 --- a/src/dynamic/sandbox.rs +++ b/src/dynamic/sandbox.rs @@ -24,6 +24,7 @@ use crate::dynamic::harness::BuiltHarness; use crate::dynamic::oob::OobListener; +use crate::dynamic::probe::{ProbeChannel, PROBE_PATH_ENV}; use std::path::Path; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; @@ -136,6 +137,13 @@ pub struct SandboxOptions { /// networking so the harness can reach the listener on the host, and the /// runner checks [`OobListener::was_nonce_hit`] after each sandbox run. pub oob_listener: Option>, + /// Per-run structured-oracle [`ProbeChannel`] (Phase 06 — Track C.1). + /// When set, the sandbox forwards the channel's path to the harness via + /// the `NYX_PROBE_PATH` env var so the per-language `__nyx_probe` shim + /// can write [`crate::dynamic::probe::SinkProbe`] records. The runner + /// drains the channel after each sandbox run and evaluates + /// [`crate::dynamic::oracle::ProbePredicate`]s against the records. + pub probe_channel: Option>, } impl Default for SandboxOptions { @@ -147,6 +155,7 @@ impl Default for SandboxOptions { env_passthrough: vec![], output_limit: 65536, oob_listener: None, + probe_channel: None, } } } @@ -1026,6 +1035,12 @@ fn run_process( // Payload injected via NYX_PAYLOAD env var. let payload_b64 = base64_encode(payload_bytes); cmd.env("NYX_PAYLOAD_B64", &payload_b64); + // Probe channel (Phase 06). Process backend writes directly to the + // host workdir file the channel handles, so the harness shim only + // needs the absolute path. + if let Some(ch) = &opts.probe_channel { + cmd.env(PROBE_PATH_ENV, ch.path()); + } // NYX_PAYLOAD as raw bytes: Unix-only (OsStr can hold arbitrary bytes). // On other platforms we skip this env var; the harness falls back to NYX_PAYLOAD_B64. #[cfg(unix)] diff --git a/tests/dynamic_sandbox_escape.rs b/tests/dynamic_sandbox_escape.rs index 136d456e..436a4e2f 100644 --- a/tests/dynamic_sandbox_escape.rs +++ b/tests/dynamic_sandbox_escape.rs @@ -59,6 +59,7 @@ mod escape_tests { env_passthrough: vec![], output_limit: 65536, oob_listener: None, + probe_channel: None, } } diff --git a/tests/oracle_sink_probe.rs b/tests/oracle_sink_probe.rs new file mode 100644 index 00000000..fc80ac00 --- /dev/null +++ b/tests/oracle_sink_probe.rs @@ -0,0 +1,200 @@ +//! Integration test for Phase 06 — Track C.1. +//! +//! Synthetic harness emits a structured [`SinkProbe`] record to the +//! per-run [`ProbeChannel`]; the oracle's [`Oracle::SinkProbe`] path +//! drains the channel and applies [`ProbePredicate`]s. A matching +//! synthetic control harness *omits* the probe write — the same oracle +//! must then return `NotConfirmed`. +//! +//! Acceptance bullet from `plan.md` phase 06: +//! +//! > Removing the probe write from one fixture flips its verdict from +//! > `Confirmed` to `NotConfirmed` in CI. +//! +//! Mechanism: the two fixtures share the identical oracle + payload +//! configuration; the only difference is whether the synthetic harness +//! body writes a [`SinkProbe`] record to the probe channel. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::oracle::{oracle_fired, Oracle, ProbePredicate}; +use nyx_scanner::dynamic::probe::{ProbeArg, ProbeChannel, SinkProbe, PROBE_PATH_ENV}; +use std::time::Duration; +use tempfile::TempDir; + +/// Minimal [`SandboxOutcome`] suitable for oracle evaluation when the +/// runner-side execution path is not exercised. All flags are off so any +/// `true` verdict must come from the probe channel, not from +/// `output_contains` / `oob_callback_seen` etc. +fn dummy_outcome() -> nyx_scanner::dynamic::sandbox::SandboxOutcome { + nyx_scanner::dynamic::sandbox::SandboxOutcome { + exit_code: Some(0), + stdout: vec![], + stderr: vec![], + timed_out: false, + oob_callback_seen: false, + sink_hit: true, + duration: Duration::from_millis(1), + } +} + +/// Synthetic harness body. Mirrors what a real per-language `__nyx_probe` +/// shim would do: read `NYX_PROBE_PATH` from its env, append one JSON +/// record per fired sink. The runner-side test serialises the harness +/// invocation with this Rust function instead of spawning a subprocess. +fn synthetic_harness_fires_probe( + channel: &ProbeChannel, + sink_callee: &str, + captured_arg: &str, + payload_id: &str, +) { + let probe = SinkProbe { + sink_callee: sink_callee.into(), + args: vec![ProbeArg::String(captured_arg.into())], + captured_at_ns: 1, + payload_id: payload_id.into(), + }; + channel.write(&probe).expect("synthetic harness probe write"); +} + +/// "Control" harness — runs the same way but does NOT write a probe. +fn synthetic_harness_omits_probe(_channel: &ProbeChannel) { + // Intentionally empty: the oracle path must observe zero probe records + // and decide NotConfirmed. +} + +#[test] +fn sink_probe_oracle_confirms_when_harness_writes_probe() { + let dir = TempDir::new().unwrap(); + let channel = ProbeChannel::for_workdir(dir.path()).unwrap(); + + // Exercise the harness env-var path so the test also locks the + // NYX_PROBE_PATH contract the real sandbox forwards to the harness. + // SAFETY: each test has a fresh tempdir and the env var is consumed + // immediately by the synthetic harness body, then re-checked below. + // Tests in this binary run on isolated channels so the env var read + // is unambiguous. + // SAFETY: env_var is process-global; this binary contains only the + // oracle_sink_probe tests so the writes do not race other suites. + unsafe { + std::env::set_var(PROBE_PATH_ENV, channel.path()); + } + assert_eq!( + std::env::var(PROBE_PATH_ENV).unwrap().as_str(), + channel.path().to_str().unwrap(), + ); + + synthetic_harness_fires_probe( + &channel, + "os.system", + "; echo NYX_PWN_CMDI", + "cmdi-echo-marker", + ); + + let oracle = Oracle::SinkProbe { + predicates: &[ + ProbePredicate::CalleeEquals("os.system"), + ProbePredicate::ArgContains { + index: 0, + needle: "NYX_PWN_CMDI", + }, + ], + }; + let probes = channel.drain(); + assert_eq!(probes.len(), 1, "harness must have written one probe"); + + assert!( + oracle_fired(&oracle, &dummy_outcome(), &probes), + "oracle with SinkProbe predicates must confirm when probe matches", + ); +} + +#[test] +fn sink_probe_oracle_not_confirmed_when_harness_omits_probe() { + let dir = TempDir::new().unwrap(); + let channel = ProbeChannel::for_workdir(dir.path()).unwrap(); + + unsafe { + std::env::set_var(PROBE_PATH_ENV, channel.path()); + } + + // Control fixture: identical configuration but the harness skips its + // probe write. Same oracle predicate set as the Confirmed test — + // the only difference is the (absent) write. + synthetic_harness_omits_probe(&channel); + + let oracle = Oracle::SinkProbe { + predicates: &[ + ProbePredicate::CalleeEquals("os.system"), + ProbePredicate::ArgContains { + index: 0, + needle: "NYX_PWN_CMDI", + }, + ], + }; + let probes = channel.drain(); + assert!( + probes.is_empty(), + "control harness must not have written any probe", + ); + + assert!( + !oracle_fired(&oracle, &dummy_outcome(), &probes), + "oracle must NOT confirm when no probe is present", + ); +} + +#[test] +fn sink_probe_oracle_not_confirmed_when_predicate_mismatch() { + // Probe is present, but its captured arg does not satisfy the + // predicates. Verifies the oracle does not blanket-confirm on + // "any probe at all" — payload predicates have teeth. + let dir = TempDir::new().unwrap(); + let channel = ProbeChannel::for_workdir(dir.path()).unwrap(); + + synthetic_harness_fires_probe( + &channel, + "os.system", + "benign argument that does not match", + "cmdi-echo-marker", + ); + + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::ArgContains { + index: 0, + needle: "NYX_PWN_CMDI", + }], + }; + let probes = channel.drain(); + assert_eq!(probes.len(), 1); + + assert!( + !oracle_fired(&oracle, &dummy_outcome(), &probes), + "oracle must NOT confirm when probe args fail the predicate set", + ); +} + +#[test] +fn probe_channel_clear_between_runs_isolates_verdicts() { + // Mirrors the runner's clear-before-each-payload behaviour: a probe + // left over from a previous payload run must not bleed into the + // verdict for a later payload. + let dir = TempDir::new().unwrap(); + let channel = ProbeChannel::for_workdir(dir.path()).unwrap(); + + synthetic_harness_fires_probe(&channel, "os.system", "stale probe", "earlier-payload"); + assert_eq!(channel.drain().len(), 1); + + channel.clear().unwrap(); + assert!( + channel.drain().is_empty(), + "clear() must remove the leftover probe from the previous run", + ); + + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::CalleeEquals("os.system")], + }; + // Second payload omits the probe write entirely. + let probes = channel.drain(); + assert!(!oracle_fired(&oracle, &dummy_outcome(), &probes)); +}