[pitboss] phase 06: Track C.1 — SinkProbe channel + structured oracle observation

This commit is contained in:
pitboss 2026-05-14 05:35:28 -05:00
parent cdbc7f2d21
commit cce07d6c96
18 changed files with 1234 additions and 57 deletions

View file

@ -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: &[],
},
];

View file

@ -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 <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
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<HarnessSource, UnsupportedReason> {
Err(UnsupportedReason::LangUnsupported)

View file

@ -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 `<fstream>` + 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 <chrono>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <string>
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 <typename... Args>
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::nanoseconds>(
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<HarnessSource, UnsupportedReason> {
Err(UnsupportedReason::LangUnsupported)

View file

@ -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<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {

View file

@ -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<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {

View file

@ -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<HarnessSource, UnsupportedReason> {
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,
)
}

View file

@ -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<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {

View file

@ -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<HarnessSource, UnsupportedReason> {
// 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));

View file

@ -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<HarnessSource, UnsupportedReason> {
Err(UnsupportedReason::LangUnsupported)

View file

@ -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<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {

View file

@ -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<HarnessSource, UnsupportedReason> {
javascript::emit(spec)

View file

@ -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;

245
src/dynamic/oracle.rs Normal file
View file

@ -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<ProbePredicate>` 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<ProbeArg>) -> 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));
}
}

274
src/dynamic/probe.rs Normal file
View file

@ -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
//! `<workdir>/__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<u8>),
/// 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<i64> {
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<ProbeArg>,
/// 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 `<workdir>/__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<Self> {
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<Self> {
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<SinkProbe> {
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::<SinkProbe>(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());
}
}

View file

@ -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<RunOutcome,
let harness_source = harness.source.clone();
let entry_source = harness.entry_source.clone();
// Provision a per-run [`ProbeChannel`] under the harness workdir when
// the caller didn't pre-supply one (the public verifier path leaves
// `probe_channel = None` so the runner owns lifetime). Failure to
// create the file is non-fatal: the legacy `Oracle::OutputContains`
// oracle still works without a channel.
let mut effective_opts = opts.clone();
if effective_opts.probe_channel.is_none() {
if let Ok(ch) = ProbeChannel::for_workdir(&harness.workdir) {
effective_opts.probe_channel = Some(Arc::new(ch));
}
}
let probe_channel: Option<Arc<ProbeChannel>> = 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<RunOutcome,
for (i, payload) in vuln_payloads.iter().enumerate() {
// Materialise payload bytes (OOB nonce-slot payloads generate a URL).
let (oob_nonce, effective_bytes) = if payload.oob_nonce_slot {
if let Some(ref listener) = opts.oob_listener {
if let Some(ref listener) = effective_opts.oob_listener {
let nonce = generate_nonce();
let url = if uses_docker_backend(opts) {
let url = if uses_docker_backend(&effective_opts) {
listener.nonce_url_for_host("host-gateway", &nonce)
} else {
listener.nonce_url(&nonce)
@ -229,10 +245,16 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
(None, payload.bytes.to_vec())
};
let mut outcome = sandbox::run(&harness, &effective_bytes, opts)?;
// Clear the probe channel before each payload so the oracle's
// drained records belong unambiguously to this run.
if let Some(ch) = &probe_channel {
let _ = ch.clear();
}
let mut outcome = sandbox::run(&harness, &effective_bytes, &effective_opts)?;
// For OOB payloads, check the nonce listener and update the outcome flag.
if let (Some(nonce), Some(listener)) = (&oob_nonce, &opts.oob_listener) {
if let (Some(nonce), Some(listener)) = (&oob_nonce, &effective_opts.oob_listener) {
// Poll until the nonce arrives or the budget expires. The sandbox run
// already waited for process exit so the callback should arrive quickly;
// 200 ms covers OS TCP delivery jitter without burning wall-clock at scale.
@ -241,7 +263,12 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
}
}
let fired = oracle_fired(&payload.oracle, &outcome);
let probes: Vec<SinkProbe> = 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<RunOutcome,
let benign_bytes = materialise_bytes(benign, None)
.map(|b| b.into_owned())
.unwrap_or_default();
let benign_outcome = sandbox::run(&harness, &benign_bytes, opts)?;
let benign_fired = oracle_fired(&benign.oracle, &benign_outcome);
if let Some(ch) = &probe_channel {
let _ = ch.clear();
}
let benign_outcome = sandbox::run(&harness, &benign_bytes, &effective_opts)?;
let benign_probes: Vec<SinkProbe> = 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();

View file

@ -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<Arc<OobListener>>,
/// 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<Arc<ProbeChannel>>,
}
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)]

View file

@ -59,6 +59,7 @@ mod escape_tests {
env_passthrough: vec![],
output_limit: 65536,
oob_listener: None,
probe_channel: None,
}
}

200
tests/oracle_sink_probe.rs Normal file
View file

@ -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));
}