diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 1a618475..d6a288a5 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -346,8 +346,14 @@ function __nyx_witness(string $sinkCallee, array $args): array { for ($i = 0; $i < strlen($pb); $i++) $bytes[] = ord($pb[$i]); $repr = []; foreach ($args as $a) $repr[] = is_string($a) ? $a : (string) $a; + // Cast env to object so json_encode emits `{}` (a JSON map) when + // `$_ENV` is empty. PHP's default `variables_order` (`GPCS`) + // leaves `$_ENV` empty, and an empty PHP array json_encodes to + // `[]` (a JSON sequence) — which fails to deserialise on the host + // side as `BTreeMap` and would drop every probe + // record on hosts without `E` in `variables_order`. return [ - 'env_snapshot' => $env, + 'env_snapshot' => (object) $env, 'cwd' => @getcwd() ?: '', 'payload_bytes' => $bytes, 'callee' => $sinkCallee, @@ -532,6 +538,28 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_json_parse_harness(spec)); } + // Phase 11 (Track J.9): UNAUTHORIZED_ID harness. Requires the + // fixture, invokes the named entry with the payload as the + // requested owner_id, and emits a + // `ProbeKind::IdorAccess { caller_id, owner_id }` whenever the + // fixture materialises a non-null record. The + // `IdorBoundaryCrossed` predicate fires when `caller_id != owner_id`. + if spec.expected_cap == crate::labels::Cap::UNAUTHORIZED_ID { + return Ok(emit_unauthorized_id_harness(spec)); + } + + // Phase 11 (Track J.9): DATA_EXFIL harness. Registers a stream + // wrapper against the `http` + `https` schemes so any outbound + // `file_get_contents` / `fopen` / `stream_*` call from the fixture + // is intercepted before the wire I/O: the URL's host is parsed via + // `parse_url(PHP_URL_HOST)`, a + // [`crate::dynamic::probe::ProbeKind::OutboundNetwork`] probe is + // emitted, and the wrapper returns an empty stream so the fixture's + // caller never blocks on the network. + if spec.expected_cap == crate::labels::Cap::DATA_EXFIL { + return Ok(emit_data_exfil_harness(spec)); + } + // Phase 19 (Track M.1): ClassMethod short-circuit. if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { return Ok(emit_class_method_harness(class, method)); @@ -2322,6 +2350,248 @@ _nyx_run(); } } +/// Phase 11 (Track J.9) — UNAUTHORIZED_ID IDOR harness for PHP. +/// +/// Requires the fixture, calls `entry_name($payload)`, and emits a +/// [`crate::dynamic::probe::ProbeKind::IdorAccess`] probe iff the +/// fixture materialises a non-null record. The fixture lives at +/// `__DIR__ . '/' . $entry_basename` (the harness runner copies it +/// next to `harness.php` when `entry_subpath` is `None`). +/// +/// `caller_id` is hard-pinned to `"alice"`; the +/// [`crate::dynamic::oracle::ProbePredicate::IdorBoundaryCrossed`] +/// predicate fires when the payload (treated as `owner_id`) does not +/// match. +pub fn emit_unauthorized_id_harness(spec: &HarnessSpec) -> HarnessSource { + let shim = probe_shim(); + let entry_basename = derive_php_entry_basename(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let body = format!( + r#" '__nyx_idor_lookup', + 'args' => [ + ['kind' => 'String', 'value' => $caller], + ['kind' => 'String', 'value' => $owner], + ], + 'captured_at_ns' => (int) hrtime(true), + 'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''), + 'kind' => [ + 'kind' => 'IdorAccess', + 'caller_id' => $caller, + 'owner_id' => $owner, + ], + 'witness' => __nyx_witness('__nyx_idor_lookup', [$caller, $owner]), + ]; + @file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND); +}} + +function _nyx_idor_call_entry(string $payload, string $entry_name) {{ + if (!function_exists($entry_name)) {{ + return null; + }} + try {{ + return $entry_name($payload); + }} catch (\Throwable $_) {{ + return null; + }} +}} + +// Require the fixture at script-top so its top-level state (e.g. +// `$STORE = […]`) lands in the global scope — `require_once` inside a +// function would scope those variables to the calling function, and +// the fixture's `function run() {{ global $STORE; … }}` would then see +// an undefined symbol. +$_NYX_ENTRY_PATH = __DIR__ . DIRECTORY_SEPARATOR . "{entry_basename}"; +if (is_file($_NYX_ENTRY_PATH)) {{ + require_once $_NYX_ENTRY_PATH; +}} + +function _nyx_run(): void {{ + $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); + $record = _nyx_idor_call_entry($payload, "{entry_name}"); + if ($record !== null) {{ + _nyx_idor_probe(_NYX_CALLER_ID, $payload); + }} + echo "__NYX_SINK_HIT__\n"; +}} + +_nyx_run(); +"# + ); + HarnessSource { + source: body, + filename: "harness.php".to_owned(), + command: vec!["php".to_owned(), "harness.php".to_owned()], + extra_files: Vec::new(), + entry_subpath: None, + } +} + +/// Phase 11 (Track J.9) — DATA_EXFIL outbound-network harness for PHP. +/// +/// PHP has no monkey-patch hook for `file_get_contents` / `fopen`, but +/// the language exposes a per-scheme stream-wrapper registry the +/// harness can override. Before requiring the fixture the harness +/// unregisters the default `http` + `https` wrappers and installs +/// `NyxHttpStreamWrapper` in their place; the wrapper's `stream_open` +/// parses the URL host via `parse_url(PHP_URL_HOST)`, emits a +/// [`crate::dynamic::probe::ProbeKind::OutboundNetwork`] probe, and +/// returns an immediately-EOF stream so the fixture's caller does not +/// block on a real wire request. The +/// [`crate::dynamic::oracle::ProbePredicate::OutboundHostNotIn`] +/// predicate fires when the captured host falls outside the loopback +/// allowlist, so the `attacker.test` vuln payload materialises a probe +/// the predicate matches while the `127.0.0.1` benign control stays +/// clear. +pub fn emit_data_exfil_harness(spec: &HarnessSpec) -> HarnessSource { + let shim = probe_shim(); + let entry_basename = derive_php_entry_basename(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let body = format!( + r#" '__nyx_mock_http', + 'args' => [['kind' => 'String', 'value' => $host]], + 'captured_at_ns' => (int) hrtime(true), + 'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''), + 'kind' => ['kind' => 'OutboundNetwork', 'host' => $host], + 'witness' => __nyx_witness('__nyx_mock_http', [$host]), + ]; + @file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND); +}} + +class NyxHttpStreamWrapper {{ + public $context; + private int $pos = 0; + + public function stream_open($path, $mode, $options, &$opened_path) {{ + $host = @parse_url($path, PHP_URL_HOST); + if (is_string($host) && $host !== '') {{ + _nyx_outbound_probe($host); + }} + $this->pos = 0; + return true; + }} + + public function stream_read($count) {{ + return ''; + }} + + public function stream_write($data) {{ + return strlen((string) $data); + }} + + public function stream_eof() {{ + return true; + }} + + public function stream_close() {{}} + + public function stream_stat() {{ + return false; + }} + + public function url_stat($path, $flags) {{ + // file_get_contents / fopen on http URLs go through stream_open; + // the probe is captured there. Returning false here keeps + // is_file() / file_exists() honest without double-emitting. + return false; + }} + + public function stream_set_option($option, $arg1, $arg2) {{ + return false; + }} + + public function stream_seek($offset, $whence = SEEK_SET) {{ + return false; + }} + + public function stream_tell() {{ + return $this->pos; + }} +}} + +function _nyx_install_http_wrapper(): void {{ + foreach (['http', 'https'] as $scheme) {{ + if (in_array($scheme, stream_get_wrappers(), true)) {{ + @stream_wrapper_unregister($scheme); + }} + @stream_wrapper_register($scheme, 'NyxHttpStreamWrapper'); + }} +}} + +function _nyx_data_exfil_call_entry(string $payload, string $entry_name): bool {{ + if (!function_exists($entry_name)) {{ + return false; + }} + try {{ + $entry_name($payload); + }} catch (\Throwable $_) {{ + // Fixture-side throw after a partial outbound call still leaves + // the probe emitted; nothing else to do here. + }} + return true; +}} + +// Install the stream-wrapper override at script-top BEFORE requiring +// the fixture so any top-level `file_get_contents(http://…)` inside +// the fixture's body is also captured (the v1 fixtures only call into +// the wrapper from `run()` but a future fixture's top-level state may +// still want the egress trapped). +_nyx_install_http_wrapper(); + +// Require the fixture at script-top so its top-level state lands in +// the global scope. `require_once` inside a function scopes any +// top-level variables to that function — the v1 fixture body is pure +// `function run(…) {{ … }}` so the distinction does not bite today, +// but keeping the require at script-top matches the +// UNAUTHORIZED_ID emitter and stays correct under fixture growth. +$_NYX_ENTRY_PATH = __DIR__ . DIRECTORY_SEPARATOR . "{entry_basename}"; +if (is_file($_NYX_ENTRY_PATH)) {{ + require_once $_NYX_ENTRY_PATH; +}} + +function _nyx_run(): void {{ + $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); + _nyx_data_exfil_call_entry($payload, "{entry_name}"); + echo "__NYX_SINK_HIT__\n"; +}} + +_nyx_run(); +"# + ); + HarnessSource { + source: body, + filename: "harness.php".to_owned(), + command: vec!["php".to_owned(), "harness.php".to_owned()], + extra_files: Vec::new(), + entry_subpath: None, + } +} + /// Phase 19 (Track M.1) — class-method harness for PHP. /// /// Includes the entry file, instantiates the class via its default @@ -3627,4 +3897,234 @@ mod tests { h.source ); } + + // ── Phase 11 (Track J.9) PHP UNAUTHORIZED_ID emitter tests ─────────────── + + fn make_unauthorized_id_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::UNAUTHORIZED_ID; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_dispatches_to_unauthorized_id_harness_when_cap_is_unauthorized_id() { + let h = emit(&make_unauthorized_id_spec( + "tests/dynamic_fixtures/unauthorized_id/php/vuln.php", + "run", + )) + .unwrap(); + assert!( + h.source.contains("_nyx_idor_probe"), + "dispatcher must short-circuit Cap::UNAUTHORIZED_ID into emit_unauthorized_id_harness: {}", + h.source + ); + assert!( + h.source.contains("'kind' => 'IdorAccess'"), + "UNAUTHORIZED_ID harness must record probes with kind IdorAccess so IdorBoundaryCrossed fires: {}", + h.source + ); + } + + #[test] + fn emit_unauthorized_id_harness_pins_caller_id() { + let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec( + "tests/dynamic_fixtures/unauthorized_id/php/vuln.php", + "run", + )); + assert!( + h.source.contains("const _NYX_CALLER_ID = 'alice'"), + "PHP UNAUTHORIZED_ID harness must pin caller_id to 'alice': {}", + h.source + ); + assert!( + h.source + .contains("_nyx_idor_probe(_NYX_CALLER_ID, $payload)"), + "PHP UNAUTHORIZED_ID harness must call probe with caller_id + payload-as-owner: {}", + h.source + ); + } + + #[test] + fn emit_unauthorized_id_harness_skips_probe_when_record_is_null() { + let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec( + "tests/dynamic_fixtures/unauthorized_id/php/benign.php", + "run", + )); + assert!( + h.source.contains("if ($record !== null) {"), + "PHP UNAUTHORIZED_ID harness must gate probe emission on a non-null record so the benign fixture's null rejection clears the predicate: {}", + h.source + ); + } + + #[test] + fn emit_unauthorized_id_harness_routes_through_fixture_require() { + let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec( + "tests/dynamic_fixtures/unauthorized_id/php/vuln.php", + "run", + )); + assert!( + h.source.contains("function _nyx_idor_call_entry("), + "PHP UNAUTHORIZED_ID harness must define the entry-call helper: {}", + h.source + ); + assert!( + h.source.contains("require_once $_NYX_ENTRY_PATH"), + "PHP UNAUTHORIZED_ID harness must require_once the fixture at script-top so the fixture's top-level state lands in the global scope: {}", + h.source + ); + assert!( + h.source.contains("\"vuln.php\""), + "PHP UNAUTHORIZED_ID harness must pass the entry basename to the helper: {}", + h.source + ); + assert_eq!(h.filename, "harness.php"); + assert!(h.extra_files.is_empty()); + } + + #[test] + fn emit_unauthorized_id_harness_derives_basename_from_entry_file() { + let h = emit_unauthorized_id_harness(&make_unauthorized_id_spec( + "/abs/path/benign.php", + "run", + )); + assert!( + h.source.contains("\"benign.php\""), + "PHP UNAUTHORIZED_ID harness must use the entry-file basename, not a hard-coded literal: {}", + h.source + ); + } + + // ── Phase 11 (Track J.9) PHP DATA_EXFIL emitter tests ──────────────────── + + fn make_data_exfil_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::DATA_EXFIL; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_dispatches_to_data_exfil_harness_when_cap_is_data_exfil() { + let h = emit(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/php/vuln.php", + "run", + )) + .unwrap(); + assert!( + h.source.contains("_nyx_outbound_probe"), + "dispatcher must short-circuit Cap::DATA_EXFIL into emit_data_exfil_harness: {}", + h.source + ); + assert!( + h.source.contains("'kind' => 'OutboundNetwork'"), + "DATA_EXFIL harness must record probes with kind OutboundNetwork so OutboundHostNotIn fires: {}", + h.source + ); + } + + #[test] + fn emit_data_exfil_harness_installs_stream_wrapper() { + let h = emit_data_exfil_harness(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/php/vuln.php", + "run", + )); + assert!( + h.source.contains("class NyxHttpStreamWrapper"), + "PHP DATA_EXFIL harness must define the http stream-wrapper class: {}", + h.source + ); + assert!( + h.source + .contains("stream_wrapper_register($scheme, 'NyxHttpStreamWrapper')"), + "PHP DATA_EXFIL harness must register the wrapper for http/https: {}", + h.source + ); + assert!( + h.source.contains("foreach (['http', 'https'] as $scheme)"), + "PHP DATA_EXFIL harness must override both http and https schemes: {}", + h.source + ); + } + + #[test] + fn emit_data_exfil_harness_parses_host_via_parse_url() { + let h = emit_data_exfil_harness(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/php/vuln.php", + "run", + )); + assert!( + h.source.contains("@parse_url($path, PHP_URL_HOST)"), + "PHP DATA_EXFIL harness must extract host via parse_url: {}", + h.source + ); + assert!( + h.source.contains("_nyx_outbound_probe($host)"), + "PHP DATA_EXFIL harness must emit the outbound probe with the parsed host: {}", + h.source + ); + } + + #[test] + fn emit_data_exfil_harness_routes_through_fixture_require() { + let h = emit_data_exfil_harness(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/php/vuln.php", + "run", + )); + assert!( + h.source.contains("function _nyx_data_exfil_call_entry("), + "PHP DATA_EXFIL harness must define the entry-call helper: {}", + h.source + ); + assert!( + h.source.contains("require_once $_NYX_ENTRY_PATH"), + "PHP DATA_EXFIL harness must require_once the fixture at script-top: {}", + h.source + ); + assert!( + h.source.contains("\"vuln.php\""), + "PHP DATA_EXFIL harness must pass the entry basename to the helper: {}", + h.source + ); + assert_eq!(h.filename, "harness.php"); + assert!(h.extra_files.is_empty()); + } + + #[test] + fn emit_data_exfil_harness_installs_wrapper_before_fixture_require() { + let h = emit_data_exfil_harness(&make_data_exfil_spec( + "tests/dynamic_fixtures/data_exfil/php/vuln.php", + "run", + )); + // Both the wrapper install and the fixture require happen at + // script-top. The wrapper must come first so any top-level + // egress from the fixture body is also captured. Find the + // last (script-top) occurrence of `_nyx_install_http_wrapper()` + // to skip the matches inside the helper function definitions. + let install_idx = h + .source + .rfind("_nyx_install_http_wrapper();") + .expect("script-top install call present"); + let require_idx = h + .source + .find("require_once $_NYX_ENTRY_PATH") + .expect("script-top require_once present"); + assert!( + install_idx < require_idx, + "PHP DATA_EXFIL harness must install the stream wrapper before requiring the fixture so top-level egress is also captured", + ); + } + + #[test] + fn emit_data_exfil_harness_derives_basename_from_entry_file() { + let h = emit_data_exfil_harness(&make_data_exfil_spec("/abs/path/benign.php", "run")); + assert!( + h.source.contains("\"benign.php\""), + "PHP DATA_EXFIL harness must use the entry-file basename, not a hard-coded literal: {}", + h.source + ); + } } diff --git a/tests/data_exfil_corpus.rs b/tests/data_exfil_corpus.rs index 9acacfdc..96d8623b 100644 --- a/tests/data_exfil_corpus.rs +++ b/tests/data_exfil_corpus.rs @@ -149,8 +149,9 @@ mod e2e_data_exfil { Lang::Ruby => "ruby", Lang::JavaScript => "js", Lang::Java => "java", + Lang::Php => "php", _ => unreachable!( - "DATA_EXFIL e2e currently covers Python + Ruby + JavaScript + Java" + "DATA_EXFIL e2e currently covers Python + Ruby + JavaScript + Java + Php" ), }) .join(fixture); @@ -195,8 +196,9 @@ mod e2e_data_exfil { Lang::Ruby => "ruby", Lang::JavaScript => "node", Lang::Java => "javac", + Lang::Php => "php", _ => unreachable!( - "DATA_EXFIL e2e currently covers Python + Ruby + JavaScript + Java" + "DATA_EXFIL e2e currently covers Python + Ruby + JavaScript + Java + Php" ), }; if !command_available(required) { @@ -366,4 +368,40 @@ mod e2e_data_exfil { "Java DATA_EXFIL benign control must not confirm via run_spec; got {outcome:?}", ); } + + /// PHP pair, same shape as Python + Ruby + JavaScript + Java. The + /// vuln fixture calls `@file_get_contents("http://" . $host . "/...")`; + /// the harness installs a stream-wrapper override for the `http` + /// scheme that parses the URL host via `parse_url(PHP_URL_HOST)`, + /// emits a `ProbeKind::OutboundNetwork`, and returns an empty + /// stream. `OutboundHostNotIn` fires for the attacker payload. + /// The benign fixture's `in_array($host, ALLOWLIST)` guard + /// short-circuits before `file_get_contents` for non-loopback + /// payloads, so no probe fires. Skips when `php` is not on PATH. + #[test] + fn php_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Php, "vuln.php", "run") else { + return; + }; + assert!( + outcome.triggered_by.is_some(), + "PHP DATA_EXFIL vuln must confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn php_benign_does_not_confirm_via_run_spec() { + let Some(outcome) = run(Lang::Php, "benign.php", "run") else { + return; + }; + assert!( + outcome.triggered_by.is_none(), + "PHP DATA_EXFIL benign control must not confirm via run_spec; got {outcome:?}", + ); + } } diff --git a/tests/unauthorized_id_corpus.rs b/tests/unauthorized_id_corpus.rs index e9c9d866..91755e7b 100644 --- a/tests/unauthorized_id_corpus.rs +++ b/tests/unauthorized_id_corpus.rs @@ -140,8 +140,9 @@ mod e2e_unauthorized_id { Lang::Ruby => "ruby", Lang::JavaScript => "js", Lang::Java => "java", + Lang::Php => "php", _ => unreachable!( - "UNAUTHORIZED_ID e2e currently covers Python + Ruby + JavaScript + Java" + "UNAUTHORIZED_ID e2e currently covers Python + Ruby + JavaScript + Java + Php" ), }) .join(fixture); @@ -186,8 +187,9 @@ mod e2e_unauthorized_id { Lang::Ruby => "ruby", Lang::JavaScript => "node", Lang::Java => "javac", + Lang::Php => "php", _ => unreachable!( - "UNAUTHORIZED_ID e2e currently covers Python + Ruby + JavaScript + Java" + "UNAUTHORIZED_ID e2e currently covers Python + Ruby + JavaScript + Java + Php" ), }; if !command_available(required) { @@ -351,4 +353,38 @@ mod e2e_unauthorized_id { "Java UNAUTHORIZED_ID benign control must not confirm via run_spec; got {outcome:?}", ); } + + /// PHP pair, same shape as Python + Ruby + JavaScript + Java. The + /// vuln fixture's `$STORE[$ownerId]` materialises a record for any + /// owner_id; the harness emits `ProbeKind::IdorAccess` and + /// `IdorBoundaryCrossed` fires for `bob`. The benign fixture's + /// `if ($ownerId !== CALLER_ID) return null;` short-circuit clears + /// the predicate for the non-caller payload. Skips when `php` is + /// not on PATH. + #[test] + fn php_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Php, "vuln.php", "run") else { + return; + }; + assert!( + outcome.triggered_by.is_some(), + "PHP UNAUTHORIZED_ID vuln must confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("confirmed run must carry a DifferentialOutcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn php_benign_does_not_confirm_via_run_spec() { + let Some(outcome) = run(Lang::Php, "benign.php", "run") else { + return; + }; + assert!( + outcome.triggered_by.is_none(), + "PHP UNAUTHORIZED_ID benign control must not confirm via run_spec; got {outcome:?}", + ); + } }