//! PHP harness emitter. //! //! Generates a PHP script that: //! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. //! 2. Includes the entry file (`entry.php`) from the workdir. //! 3. Calls the entry function with the payload routed to the correct slot. //! 4. Catches all Throwables to prevent harness crashes from masking results. //! //! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__` before //! the actual sink call (same pattern as Rust / JS fixtures). //! //! Payload slot support: //! - `PayloadSlot::Param(n)` — n-th positional argument. //! - `PayloadSlot::EnvVar(name)` — set `$_ENV`/`putenv()` before calling. //! - `PayloadSlot::Stdin` — wrap `STDIN` with the payload. //! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`. //! //! Build: no compilation step. Command is `php harness.php`. //! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1). use crate::dynamic::lang::{HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; /// Zero-sized [`LangEmitter`] handle for PHP. Method bodies delegate to the /// existing free functions in this module. pub struct PhpEmitter; /// Entry kinds the PHP emitter currently understands. Extended in Phase 15 /// (Track B PHP vertical) to include `HttpRoute` (Slim / Laravel / Symfony /// closures) and `CliSubcommand` (`$argv`). const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; impl LangEmitter for PhpEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { emit(spec) } fn entry_kinds_supported(&self) -> &'static [EntryKind] { SUPPORTED } fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( "php emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Slim / Laravel / Symfony route + CLI shapes in phase 15" ) } } /// 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, Phase 08 — Track C.4 + C.5) ────── const __NYX_DENY_SUBSTRINGS = [ 'TOKEN','SECRET','PASSWORD','PASSWD','API_KEY','APIKEY','PRIVATE_KEY', 'CREDENTIAL','SESSION','COOKIE','AUTH','BEARER','AWS_ACCESS','AWS_SESSION', 'GH_TOKEN','GITHUB_TOKEN','NPM_TOKEN','PYPI_TOKEN','DOCKER_PASS', ]; const __NYX_PAYLOAD_LIMIT = 16 * 1024; const __NYX_REDACTED = ''; function __nyx_is_denied_key(string $k): bool { $ku = strtoupper($k); foreach (__NYX_DENY_SUBSTRINGS as $n) { if (strpos($ku, $n) !== false) return true; } return false; } function __nyx_witness(string $sinkCallee, array $args): array { $env = []; foreach ($_ENV as $k => $v) { $env[(string)$k] = __nyx_is_denied_key((string)$k) ? __NYX_REDACTED : (string)$v; } // Sort for deterministic output. ksort($env); $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); $pb = substr($payload, 0, __NYX_PAYLOAD_LIMIT); $bytes = []; for ($i = 0; $i < strlen($pb); $i++) $bytes[] = ord($pb[$i]); $repr = []; foreach ($args as $a) $repr[] = is_string($a) ? $a : (string) $a; return [ 'env_snapshot' => $env, 'cwd' => @getcwd() ?: '', 'payload_bytes' => $bytes, 'callee' => $sinkCallee, 'args_repr' => $repr, ]; } function __nyx_emit(array $rec): void { $p = getenv('NYX_PROBE_PATH'); if ($p === false || $p === '') return; $line = json_encode($rec) . "\n"; @file_put_contents($p, $line, FILE_APPEND); } function __nyx_probe(string $sinkCallee, ...$args): void { $ser = []; foreach ($args as $a) { if (is_int($a)) { $ser[] = ['kind' => 'Int', 'value' => $a]; } else { $ser[] = ['kind' => 'String', 'value' => (string) $a]; } } __nyx_emit([ 'sink_callee' => $sinkCallee, 'args' => $ser, 'captured_at_ns' => (int) (microtime(true) * 1e9), 'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''), 'kind' => ['kind' => 'Normal'], 'witness' => __nyx_witness($sinkCallee, $args), ]); } // Phase 08: PHP cannot catch SIGSEGV from userland, but pcntl_signal and // register_shutdown_function intercept SIGABRT-class fatal errors. function __nyx_install_crash_guard(string $sinkCallee): void { $emit_crash = function (string $signalName) use ($sinkCallee) { __nyx_emit([ 'sink_callee' => $sinkCallee, 'args' => [], 'captured_at_ns' => (int) (microtime(true) * 1e9), 'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''), 'kind' => ['kind' => 'Crash', 'signal' => $signalName], 'witness' => __nyx_witness($sinkCallee, []), ]); }; set_error_handler(function ($errno, $errstr) use ($emit_crash) { if ($errno & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR)) { $emit_crash('SIGABRT'); } return false; }); register_shutdown_function(function () use ($emit_crash) { $err = error_get_last(); if ($err && ($err['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR))) { $emit_crash('SIGABRT'); } }); if (function_exists('pcntl_signal') && function_exists('pcntl_async_signals')) { pcntl_async_signals(true); foreach ([SIGABRT, SIGBUS ?? null, SIGFPE ?? null, SIGILL ?? null] as $sig) { if ($sig === null) continue; pcntl_signal($sig, function ($s) use ($emit_crash) { $name = 'SIGABRT'; if (defined('SIGABRT') && $s === SIGABRT) $name = 'SIGABRT'; if (defined('SIGBUS') && $s === SIGBUS) $name = 'SIGBUS'; if (defined('SIGFPE') && $s === SIGFPE) $name = 'SIGFPE'; if (defined('SIGILL') && $s === SIGILL) $name = 'SIGILL'; $emit_crash($name); pcntl_signal($s, SIG_DFL); posix_kill(posix_getpid(), $s); }); } } } "# } /// Emit a PHP harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {} _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } let source = generate_source(spec); Ok(HarnessSource { source, filename: "harness.php".to_owned(), command: vec!["php".to_owned(), "harness.php".to_owned()], extra_files: vec![], entry_subpath: Some("entry.php".to_owned()), }) } fn generate_source(spec: &HarnessSpec) -> String { let entry_fn = &spec.entry_name; let (pre_call, call_expr) = build_call(spec, entry_fn); format!( r#"getMessage() . "\n"); exit(77); }} // ── Pre-call setup ───────────────────────────────────────────────────────────── {pre_call} // ── Call entry point ────────────────────────────────────────────────────────── try {{ $result = {call_expr}; if ($result !== null) {{ echo $result . "\n"; }} }} catch (Throwable $e) {{ fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); }} "#, pre_call = pre_call, call_expr = call_expr, ) } /// Build `(pre_call_setup, call_expression)` for the chosen payload slot. fn build_call(spec: &HarnessSpec, func: &str) -> (String, String) { match &spec.payload_slot { PayloadSlot::Param(idx) => { let pre = String::new(); let call = if *idx == 0 { format!("{func}($payload)") } else { let pads = (0..*idx).map(|_| "''").collect::>().join(", "); format!("{func}({pads}, $payload)") }; (pre, call) } PayloadSlot::EnvVar(name) => { let pre = format!("putenv({name:?} . '=' . $payload);\n$_ENV[{name:?}] = $payload;\n"); let call = format!("{func}()"); (pre, call) } PayloadSlot::Stdin => { // Replace STDIN with an in-memory stream containing the payload. let pre = "if (defined('STDIN')) {\n $stream = fopen('php://memory', 'r+');\n fwrite($stream, $payload);\n rewind($stream);\n // Note: STDIN reassignment is not portable; fixture reads via fgets(STDIN).\n}\n".to_owned(); let call = format!("{func}()"); (pre, call) } _ => { let pre = String::new(); let call = format!("{func}($payload)"); (pre, call) } } } #[cfg(test)] mod tests { use super::*; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec { HarnessSpec { finding_id: "php0000000000001".into(), entry_file: "src/login.php".into(), entry_name: "login".into(), entry_kind: EntryKind::Function, lang: Lang::Php, toolchain_id: "php-8".into(), payload_slot, expected_cap: Cap::SQL_QUERY, constraint_hints: vec![], sink_file: "src/login.php".into(), sink_line: 10, spec_hash: "php0000000000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, } } #[test] fn emit_produces_source() { let spec = make_spec(PayloadSlot::Param(0)); let harness = emit(&spec).unwrap(); assert!(harness.source.starts_with("