diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index 4527dd52..7c0cd3d0 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -1,52 +1,42 @@ -//! JavaScript / TypeScript harness emitter. +//! JavaScript harness emitter. //! -//! Generates a Node.js script that: -//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. -//! 2. Requires the entry module from the workdir (`entry.js`). -//! 3. Calls the entry function with the payload routed to the correct slot. -//! 4. Catches all exceptions to prevent harness crashes from masking results. +//! After Phase 13 (Track B JS + TS vertical) the per-shape dispatch lives in +//! [`crate::dynamic::lang::js_shared`]. This module is the typed surface for +//! `Lang::JavaScript`: registers the [`JavaScriptEmitter`] in the dispatch +//! table, advertises the supported [`EntryKind`] set, and forwards +//! `emit` / `materialize_runtime` calls to the shared module. //! -//! Sink-reachability probe: the fixture itself emits `__NYX_SINK_HIT__` before -//! the actual sink call (same pattern as Rust fixtures). The harness is a pure -//! runner with no line-level tracing. -//! -//! Payload slot support: -//! - `PayloadSlot::Param(n)` — n-th positional argument. -//! - `PayloadSlot::EnvVar(name)` — set env var before calling. -//! - `PayloadSlot::Stdin` — pipe payload to process.stdin. -//! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`. -//! -//! Build: no compilation step. Command is `node harness.js`. -//! Build container: `nyx-build-node:{toolchain_id}` (deferred; §19.1). +//! Payload slot support (handled by `js_shared::emit`): +//! - [`PayloadSlot::Param`] — n-th positional argument. +//! - [`PayloadSlot::EnvVar`] — set env var before calling. +//! - [`PayloadSlot::Stdin`] — pipe payload to `process.stdin`. +//! - [`PayloadSlot::QueryParam`] — HTTP-shaped query param (Express / Koa / Next). +//! - [`PayloadSlot::HttpBody`] — HTTP body (Express / Koa / Next). +//! - [`PayloadSlot::Argv`] — coerced to positional `Param(0)` by build_call. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::lang::{js_shared, HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec}; use crate::evidence::UnsupportedReason; -use crate::utils::project::DetectedFramework; -/// Zero-sized [`LangEmitter`] handle for JavaScript / TypeScript (one -/// emitter, both langs share the same Node.js dispatch). Method bodies -/// delegate to the existing free functions in this module. +pub use js_shared::{detect_shape, materialize_node, probe_shim, JsShape}; + +/// Zero-sized [`LangEmitter`] handle for JavaScript. pub struct JavaScriptEmitter; -/// Entry kinds the JS / TS emitter currently understands. Extended in -/// Phase 13 (Track B JS + TS vertical) to include `HttpRoute` (Express / -/// Koa / Next), `CliSubcommand`, etc. -const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; - impl LangEmitter for JavaScriptEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { emit(spec) } fn entry_kinds_supported(&self) -> &'static [EntryKind] { - SUPPORTED + js_shared::SUPPORTED } fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( - "javascript / typescript emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Express / Koa / Next shapes in phase 13" + "javascript emitter supports {supported:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 13 shape dispatch in `js_shared`", + supported = js_shared::SUPPORTED, ) } @@ -55,344 +45,25 @@ impl LangEmitter for JavaScriptEmitter { } } -/// Phase 09 — Track D.2: emit a `package.json` covering every captured -/// dep plus the framework deps inferred from the manifest detector. -/// -/// Versions default to `"*"` so npm resolves to a recent compatible -/// release. Re-used by the TypeScript emitter. -pub fn materialize_node(env: &Environment) -> RuntimeArtifacts { - let mut artifacts = RuntimeArtifacts::new(); - let mut deps: Vec<(String, &'static str)> = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - - for d in &env.direct_deps { - if is_node_builtin(d) { - continue; - } - if seen.insert(d.clone()) { - deps.push((d.clone(), "*")); - } - } - for fw in &env.frameworks { - if let Some(name) = node_framework_pkg_name(*fw) { - if seen.insert(name.to_owned()) { - deps.push((name.to_owned(), "*")); - } - } - } - deps.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut body = String::with_capacity(128); - body.push_str("{\n"); - body.push_str(" \"name\": \"nyx-harness\",\n"); - body.push_str(" \"version\": \"0.0.0\",\n"); - body.push_str(" \"private\": true,\n"); - body.push_str(" \"dependencies\": {\n"); - for (i, (name, ver)) in deps.iter().enumerate() { - body.push_str(" \""); - body.push_str(name); - body.push_str("\": \""); - body.push_str(ver); - body.push('"'); - if i + 1 != deps.len() { - body.push(','); - } - body.push('\n'); - } - body.push_str(" }\n"); - body.push_str("}\n"); - artifacts.push("package.json", body); - artifacts -} - -fn is_node_builtin(name: &str) -> bool { - matches!( - name, - "fs" - | "path" - | "http" - | "https" - | "url" - | "crypto" - | "stream" - | "util" - | "child_process" - | "os" - | "events" - | "buffer" - | "querystring" - | "zlib" - | "assert" - | "process" - | "net" - | "tls" - | "dns" - | "readline" - | "tty" - ) -} - -fn node_framework_pkg_name(fw: DetectedFramework) -> Option<&'static str> { - match fw { - DetectedFramework::Express => Some("express"), - DetectedFramework::Koa => Some("koa"), - DetectedFramework::Fastify => Some("fastify"), - _ => None, - } -} - -/// 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, 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_scrub_env() { - const out = {}; - const env = process.env || {}; - for (const k of Object.keys(env)) { - const ku = String(k).toUpperCase(); - if (_NYX_DENY_SUBSTRINGS.some((n) => ku.indexOf(n) !== -1)) { - out[k] = _NYX_REDACTED; - } else { - out[k] = env[k]; - } - } - return out; -} - -function __nyx_witness(sinkCallee, args) { - let payload = process.env.NYX_PAYLOAD || ''; - let buf = Buffer.from(String(payload), 'utf8'); - if (buf.length > _NYX_PAYLOAD_LIMIT) buf = buf.slice(0, _NYX_PAYLOAD_LIMIT); - const argsRepr = args.map(function (a) { - if (a && typeof a === 'object' && (a instanceof Buffer || a instanceof Uint8Array)) { - return ''; - } - return String(a); - }); - let cwd = ''; - try { cwd = process.cwd(); } catch (e) {} - return { - env_snapshot: __nyx_scrub_env(), - cwd: cwd, - payload_bytes: Array.from(buf), - callee: String(sinkCallee), - args_repr: argsRepr, - }; -} - -function __nyx_emit(rec) { - const _fs = require('fs'); - const _p = process.env.NYX_PROBE_PATH; - if (!_p) return; - try { - _fs.appendFileSync(_p, JSON.stringify(rec) + '\n'); - } catch (e) { - // best-effort: probe channel write failure is non-fatal. - } -} - -function __nyx_probe(sinkCallee, ...args) { - 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) }; - }); - __nyx_emit({ - sink_callee: String(sinkCallee), - args: _ser, - captured_at_ns: Number(process.hrtime.bigint()), - payload_id: String(process.env.NYX_PAYLOAD_ID || ''), - kind: { kind: 'Normal' }, - witness: __nyx_witness(sinkCallee, args), - }); -} - -// Phase 08: V8 cannot catch native SIGSEGV in pure JS, but it can intercept -// `uncaughtException` / `unhandledRejection` plus the synchronously -// deliverable signals (SIGABRT via process.kill). __nyx_install_crash_guard -// registers both: the uncaught path maps Error-shaped failures to a SIGABRT -// crash probe; explicit process.on('SIG*') registers the others where the -// runtime exposes them. Re-raise via process.exit(134) so the outcome's -// exit_code still reflects an abort-style death. -function __nyx_install_crash_guard(sinkCallee) { - const _emit_crash = function (signalName) { - __nyx_emit({ - sink_callee: String(sinkCallee), - args: [], - captured_at_ns: Number(process.hrtime.bigint()), - payload_id: String(process.env.NYX_PAYLOAD_ID || ''), - kind: { kind: 'Crash', signal: signalName }, - witness: __nyx_witness(sinkCallee, []), - }); - }; - process.on('uncaughtException', function (_err) { - _emit_crash('SIGABRT'); - process.exit(134); - }); - process.on('unhandledRejection', function (_reason) { - _emit_crash('SIGABRT'); - process.exit(134); - }); - for (const nm of ['SIGSEGV','SIGABRT','SIGBUS','SIGFPE','SIGILL']) { - try { - process.on(nm, function () { - _emit_crash(nm); - process.exit(128 + (nm === 'SIGABRT' ? 6 : 11)); - }); - } catch (e) { /* runtime refused signal handler */ } - } -} -"# -} - -/// Emit a Node.js harness for `spec`. +/// Emit a JS 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); - let entry_filename = entry_module_filename(&spec.entry_file); - - Ok(HarnessSource { - source, - filename: "harness.js".to_owned(), - command: vec!["node".to_owned(), "harness.js".to_owned()], - extra_files: vec![], - entry_subpath: Some(entry_filename), - }) -} - -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 = (() => {{ - if (process.env.NYX_PAYLOAD && process.env.NYX_PAYLOAD.length > 0) {{ - return process.env.NYX_PAYLOAD; - }} - if (process.env.NYX_PAYLOAD_B64 && process.env.NYX_PAYLOAD_B64.length > 0) {{ - return Buffer.from(process.env.NYX_PAYLOAD_B64, 'base64').toString('utf8'); - }} - return ''; -}})(); - -// ── Entry module import ──────────────────────────────────────────────────────── -let _entry; -try {{ - _entry = require('./{entry_module}'); -}} catch (e) {{ - process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n'); - process.exit(77); -}} - -const payload = _nyx_payload; - -// ── Pre-call setup ───────────────────────────────────────────────────────────── -{pre_call} -// ── Call entry point ────────────────────────────────────────────────────────── -try {{ - const _result = {call_expr}; - if (_result !== undefined && _result !== null) {{ - if (_result && typeof _result.then === 'function') {{ - _result - .then(r => {{ if (r != null) process.stdout.write(String(r) + '\n'); }}) - .catch(e => {{ process.stderr.write('NYX_EXCEPTION: ' + e.message + '\n'); }}); - }} else {{ - process.stdout.write(String(_result) + '\n'); - }} - }} -}} catch (e) {{ - process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); -}} -"#, - entry_module = entry_module, - pre_call = pre_call, - call_expr = call_expr, - probe = probe, - ) -} - -/// Build `(pre_call_setup, call_expression)` for the chosen payload slot. -fn build_call(spec: &HarnessSpec, _module: &str, func: &str) -> (String, String) { - match &spec.payload_slot { - PayloadSlot::Param(idx) => { - let pre = String::new(); - let call = if *idx == 0 { - format!("_entry.{func}(payload)") - } else { - let pads = (0..*idx).map(|_| "''").collect::>().join(", "); - format!("_entry.{func}({pads}, payload)") - }; - (pre, call) - } - PayloadSlot::EnvVar(name) => { - let pre = format!("process.env[{name:?}] = payload;\n"); - let call = format!("_entry.{func}()"); - (pre, call) - } - PayloadSlot::Stdin => { - // Synchronous stdin replacement via Buffer. - let pre = format!( - "const {{ Readable }} = require('stream');\n\ - process.stdin = Readable.from([Buffer.from(payload, 'utf8')]);\n" - ); - let call = format!("_entry.{func}()"); - (pre, call) - } - _ => { - let pre = String::new(); - let call = format!("_entry.{func}(payload)"); - (pre, call) - } - } + js_shared::emit(spec, false) } /// Derive the JS module name from an entry file path. /// -/// `"src/handlers/login.js"` → `"login"` (basename without extension). +/// Always returns `"entry"` because the JS harness stages the entry file at +/// `workdir/entry.js` so `require('./entry')` is the only path that resolves +/// regardless of the source file's original name. pub fn entry_module_name(_entry_file: &str) -> String { - // The harness always `require('./entry')` because `entry_module_filename` - // unconditionally copies the source to `entry.js` in the workdir. Keeping - // these two helpers in sync prevents a "Cannot find module" import error - // when the fixture's on-disk filename is anything other than `entry.js`. "entry".to_owned() } -/// Derive the filename for `entry_subpath` from an entry file path. +/// Derive the entry filename from an entry file path. /// -/// Always returns `"entry.js"` — fixture files are copied here regardless of -/// their original name so the harness can always `require('./entry')`. +/// Always `"entry.js"` for the JS surface; TypeScript uses `"entry.ts"` (see +/// [`crate::dynamic::lang::typescript`]) and ESM-default shapes use +/// `"entry.mjs"` (handled inside `js_shared`). pub fn entry_module_filename(_entry_file: &str) -> String { "entry.js".to_owned() } @@ -464,40 +135,37 @@ mod tests { } #[test] - fn emit_http_body_is_unsupported() { - let spec = make_spec(PayloadSlot::HttpBody); - let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); + fn emit_http_body_now_supported_for_express_shape() { + let mut spec = make_spec(PayloadSlot::HttpBody); + spec.entry_kind = EntryKind::HttpRoute; + let h = emit(&spec).unwrap(); + assert_eq!(h.filename, "harness.js"); } #[test] - fn emit_entry_subpath_is_entry_js() { + fn emit_entry_subpath_default_is_entry_js() { let spec = make_spec(PayloadSlot::Param(0)); let harness = emit(&spec).unwrap(); assert_eq!(harness.entry_subpath, Some("entry.js".to_owned())); } #[test] - fn entry_kinds_supported_is_non_empty() { - assert!(!JavaScriptEmitter.entry_kinds_supported().is_empty()); - assert!(JavaScriptEmitter - .entry_kinds_supported() - .contains(&EntryKind::Function)); + fn entry_kinds_supported_includes_http_and_cli_after_phase_13() { + let kinds = JavaScriptEmitter.entry_kinds_supported(); + assert!(kinds.contains(&EntryKind::Function)); + assert!(kinds.contains(&EntryKind::HttpRoute)); + assert!(kinds.contains(&EntryKind::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { let hint = JavaScriptEmitter.entry_kind_hint(EntryKind::HttpRoute); assert!(hint.contains("HttpRoute")); - assert!(hint.contains("phase 13")); + assert!(hint.contains("Phase 13")); } #[test] fn entry_module_name_is_always_entry_to_match_copy_destination() { - // `copy_entry_file` (via `entry_module_filename`) stages every fixture - // at `workdir/entry.js`, so `require('./entry')` is the only path the - // harness can use without missing-module errors at runtime, regardless - // of the source file's original name. assert_eq!(entry_module_name("src/handlers/login.js"), "entry"); assert_eq!(entry_module_name("app.ts"), "entry"); assert_eq!(entry_module_name("handler.mjs"), "entry"); diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs new file mode 100644 index 00000000..4b398588 --- /dev/null +++ b/src/dynamic/lang/js_shared.rs @@ -0,0 +1,992 @@ +//! Shared helpers for the JavaScript + TypeScript harness emitters (Phase 13). +//! +//! Both [`crate::dynamic::lang::javascript::JavaScriptEmitter`] and +//! [`crate::dynamic::lang::typescript::TypeScriptEmitter`] delegate their +//! `emit` to [`emit`] in this module — the runtime is Node.js in both cases, +//! so the harness layout is identical after type erasure. The only divergence +//! is the entry filename: `entry.js` vs `entry.ts` so each emitter advertises +//! a typed surface even when the underlying dispatch is shared. +//! +//! Phase 13 introduces a per-file shape detector ([`JsShape`]) that inspects +//! the entry source for framework markers and picks one of seven harness +//! templates: +//! +//! - [`JsShape::Express`]: route handler `(req, res) => ...`. +//! - [`JsShape::Koa`]: middleware `async (ctx) => ...`. +//! - [`JsShape::NextRoute`]: Next.js API route default export. +//! - [`JsShape::AsyncFunction`]: bare `async function f(payload)`. +//! - [`JsShape::CommonJsExport`]: CommonJS `module.exports = { fn }` — legacy default. +//! - [`JsShape::EsModuleDefault`]: ESM `export default function f(payload)`. +//! - [`JsShape::BrowserEvent`]: DOM event handler simulated under `jsdom`. +//! +//! Shape detection is best-effort: when the entry source is unreadable or no +//! marker fires the dispatcher falls back to [`JsShape::CommonJsExport`], +//! which preserves the pre-Phase-13 behaviour. + +use crate::dynamic::environment::{Environment, RuntimeArtifacts}; +use crate::dynamic::lang::HarnessSource; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::evidence::UnsupportedReason; +use crate::utils::project::DetectedFramework; +use std::path::PathBuf; + +/// Concrete per-file shape resolved by reading the entry source. One +/// harness template per variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsShape { + /// Express handler exported by name. Harness builds a mock req/res + /// and dispatches synchronously. + Express, + /// Koa middleware exported by name. Harness builds a mock ctx and + /// awaits the middleware. + Koa, + /// Next.js API route — default-export handler `(req, res)`. Harness + /// builds a mock req/res; status / json / send / end captured. + NextRoute, + /// Bare `async function f(payload)`. Harness awaits the result. + AsyncFunction, + /// `module.exports = { fn }` — pre-Phase-13 default. Harness calls + /// the named export synchronously. + CommonJsExport, + /// `export default function f(payload)` — `.mjs` / `type:module` + /// entry. Harness uses dynamic `import()` and unwraps `.default`. + EsModuleDefault, + /// DOM event handler executed inside a `jsdom` window. Harness sets + /// up `globalThis.window` / `document` and dispatches an event. + BrowserEvent, +} + +impl JsShape { + /// Detect the shape from `(spec, source)`. Framework / runtime + /// markers in the source win over `spec.entry_kind`. + pub fn detect(spec: &HarnessSpec, source: &str) -> Self { + let kind = spec.entry_kind; + let entry = spec.entry_name.as_str(); + + // ── Framework / runtime markers ───────────────────────────── + let has_express = source_has_marker( + source, + &["require('express')", "require(\"express\")", "from 'express'", "from \"express\""], + ); + let has_koa = source_has_marker( + source, + &["require('koa')", "require(\"koa\")", "from 'koa'", "from \"koa\""], + ); + let has_next = source_has_marker( + source, + &["from 'next'", "from \"next\"", "NextApiRequest", "NextApiResponse", "// nyx-shape: next"], + ); + let has_jsdom = source_has_marker( + source, + &[ + "require('jsdom')", + "require(\"jsdom\")", + "from 'jsdom'", + "from \"jsdom\"", + "document.getElementById", + "addEventListener", + "// nyx-shape: browser-event", + ], + ); + let has_esm_default = source_has_marker( + source, + // `module.exports = function` is intentionally NOT a marker: + // single-function CJS exports must NOT be staged at `entry.mjs`, + // where Node would refuse to parse the file's `require()` / + // `module.exports` as ESM. Legit ESM signals only. + &["export default ", "// nyx-shape: esm-default"], + ); + + if has_express { + return Self::Express; + } + if has_koa { + return Self::Koa; + } + if has_next { + return Self::NextRoute; + } + if has_jsdom { + return Self::BrowserEvent; + } + + if kind == EntryKind::HttpRoute { + return Self::Express; + } + + // ESM default export marker comes after framework checks so the + // route shapes win when both apply. + if has_esm_default && !source.contains("module.exports = {") { + return Self::EsModuleDefault; + } + + if function_is_async(source, entry) { + return Self::AsyncFunction; + } + + Self::CommonJsExport + } +} + +fn source_has_marker(source: &str, markers: &[&str]) -> bool { + markers.iter().any(|m| source.contains(m)) +} + +fn function_is_async(source: &str, name: &str) -> bool { + source.contains(&format!("async function {name}(")) + || source.contains(&format!("async {name}(")) + || source.contains(&format!("const {name} = async")) +} + +// ── Probe shim (Phase 06 + Phase 08) ───────────────────────────────────────── + +/// Source of the `__nyx_probe` shim for the Node.js harness. Identical +/// for JS and TS — Node executes both after type erasure. +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_scrub_env() { + const out = {}; + const env = process.env || {}; + for (const k of Object.keys(env)) { + const ku = String(k).toUpperCase(); + if (_NYX_DENY_SUBSTRINGS.some((n) => ku.indexOf(n) !== -1)) { + out[k] = _NYX_REDACTED; + } else { + out[k] = env[k]; + } + } + return out; +} + +function __nyx_witness(sinkCallee, args) { + let payload = process.env.NYX_PAYLOAD || ''; + let buf = Buffer.from(String(payload), 'utf8'); + if (buf.length > _NYX_PAYLOAD_LIMIT) buf = buf.slice(0, _NYX_PAYLOAD_LIMIT); + const argsRepr = args.map(function (a) { + if (a && typeof a === 'object' && (a instanceof Buffer || a instanceof Uint8Array)) { + return ''; + } + return String(a); + }); + let cwd = ''; + try { cwd = process.cwd(); } catch (e) {} + return { + env_snapshot: __nyx_scrub_env(), + cwd: cwd, + payload_bytes: Array.from(buf), + callee: String(sinkCallee), + args_repr: argsRepr, + }; +} + +function __nyx_emit(rec) { + const _fs = require('fs'); + const _p = process.env.NYX_PROBE_PATH; + if (!_p) return; + try { + _fs.appendFileSync(_p, JSON.stringify(rec) + '\n'); + } catch (e) { + // best-effort: probe channel write failure is non-fatal. + } +} + +function __nyx_probe(sinkCallee, ...args) { + 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) }; + }); + __nyx_emit({ + sink_callee: String(sinkCallee), + args: _ser, + captured_at_ns: Number(process.hrtime.bigint()), + payload_id: String(process.env.NYX_PAYLOAD_ID || ''), + kind: { kind: 'Normal' }, + witness: __nyx_witness(sinkCallee, args), + }); +} + +function __nyx_install_crash_guard(sinkCallee) { + const _emit_crash = function (signalName) { + __nyx_emit({ + sink_callee: String(sinkCallee), + args: [], + captured_at_ns: Number(process.hrtime.bigint()), + payload_id: String(process.env.NYX_PAYLOAD_ID || ''), + kind: { kind: 'Crash', signal: signalName }, + witness: __nyx_witness(sinkCallee, []), + }); + }; + process.on('uncaughtException', function (_err) { + _emit_crash('SIGABRT'); + process.exit(134); + }); + process.on('unhandledRejection', function (_reason) { + _emit_crash('SIGABRT'); + process.exit(134); + }); + for (const nm of ['SIGSEGV','SIGABRT','SIGBUS','SIGFPE','SIGILL']) { + try { + process.on(nm, function () { + _emit_crash(nm); + process.exit(128 + (nm === 'SIGABRT' ? 6 : 11)); + }); + } catch (e) { /* runtime refused signal handler */ } + } +} +"# +} + +// ── Runtime / package.json synthesis (Phase 09) ───────────────────────────── + +/// Phase 09 — Track D.2: emit a `package.json` covering every captured +/// dep plus the framework deps inferred from the manifest detector. +pub fn materialize_node(env: &Environment) -> RuntimeArtifacts { + let mut artifacts = RuntimeArtifacts::new(); + let mut deps: Vec<(String, &'static str)> = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for d in &env.direct_deps { + if is_node_builtin(d) { + continue; + } + if seen.insert(d.clone()) { + deps.push((d.clone(), "*")); + } + } + for fw in &env.frameworks { + if let Some(name) = node_framework_pkg_name(*fw) { + if seen.insert(name.to_owned()) { + deps.push((name.to_owned(), "*")); + } + } + } + deps.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut body = String::with_capacity(128); + body.push_str("{\n"); + body.push_str(" \"name\": \"nyx-harness\",\n"); + body.push_str(" \"version\": \"0.0.0\",\n"); + body.push_str(" \"private\": true,\n"); + body.push_str(" \"dependencies\": {\n"); + for (i, (name, ver)) in deps.iter().enumerate() { + body.push_str(" \""); + body.push_str(name); + body.push_str("\": \""); + body.push_str(ver); + body.push('"'); + if i + 1 != deps.len() { + body.push(','); + } + body.push('\n'); + } + body.push_str(" }\n"); + body.push_str("}\n"); + artifacts.push("package.json", body); + artifacts +} + +fn is_node_builtin(name: &str) -> bool { + matches!( + name, + "fs" | "path" | "http" | "https" | "url" | "crypto" | "stream" + | "util" | "child_process" | "os" | "events" | "buffer" + | "querystring" | "zlib" | "assert" | "process" | "net" + | "tls" | "dns" | "readline" | "tty" + ) +} + +fn node_framework_pkg_name(fw: DetectedFramework) -> Option<&'static str> { + match fw { + DetectedFramework::Express => Some("express"), + DetectedFramework::Koa => Some("koa"), + DetectedFramework::Fastify => Some("fastify"), + _ => None, + } +} + +// ── Per-shape `extra_files` (Phase 13 — Track B JS / TS vertical) ─────────── + +/// `package.json` + `package-lock.json` for shapes that bring in a real +/// framework dep. The harness builder folds these into the workdir via +/// the existing `extra_files` mechanism and `prepare_node` then runs +/// `npm install` against them. +fn extra_files_for_shape(shape: JsShape) -> Vec<(String, String)> { + match shape { + JsShape::Express => vec![ + ("package.json".to_owned(), package_json_for("express", "^4.19.2")), + ("package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-express")), + ], + JsShape::Koa => vec![ + ("package.json".to_owned(), package_json_for("koa", "^2.15.3")), + ("package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-koa")), + ], + JsShape::NextRoute => vec![ + ("package.json".to_owned(), package_json_for("next", "^14.2.5")), + ("package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-next")), + ], + JsShape::BrowserEvent => vec![ + ("package.json".to_owned(), package_json_for("jsdom", "^24.1.1")), + ("package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-jsdom")), + ], + // Plain async / CJS / ESM use stdlib only. + _ => vec![], + } +} + +fn package_json_for(dep: &str, version: &str) -> String { + format!( + "{{\n \"name\": \"nyx-harness-{dep}\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"dependencies\": {{\n \"{dep}\": \"{version}\"\n }}\n}}\n", + ) +} + +fn package_lock_skeleton(name: &str) -> String { + // Bare lockfile structure. npm rewrites this on first install; checking + // it in keeps the per-shape fixture directory self-describing. + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"0.0.0\",\n \"lockfileVersion\": 3,\n \"requires\": true,\n \"packages\": {{\n \"\": {{\n \"name\": \"{name}\",\n \"version\": \"0.0.0\"\n }}\n }}\n}}\n", + ) +} + +// ── Public entry: emit() ───────────────────────────────────────────────────── + +/// Emit a Node.js harness for `spec`. `is_typescript` controls only the +/// entry filename (`entry.ts` vs `entry.js`) — the harness itself is JS +/// either way, and the runner relies on Node's CommonJS extension being +/// permissive enough to load both. +pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result { + match &spec.payload_slot { + PayloadSlot::Param(_) + | PayloadSlot::EnvVar(_) + | PayloadSlot::Stdin + | PayloadSlot::QueryParam(_) + | PayloadSlot::HttpBody + | PayloadSlot::Argv(_) => {} + } + + let entry_source = read_entry_source(&spec.entry_file); + let shape = JsShape::detect(spec, &entry_source); + let entry_subpath = entry_subpath_for_shape(shape, is_typescript); + let body = generate_for_shape(spec, shape, &entry_subpath); + + Ok(HarnessSource { + source: body, + filename: "harness.js".to_owned(), + command: vec!["node".to_owned(), "harness.js".to_owned()], + extra_files: extra_files_for_shape(shape), + entry_subpath: Some(entry_subpath), + }) +} + +/// Public wrapper to detect the shape for a finalised [`HarnessSpec`]. +pub fn detect_shape(spec: &HarnessSpec) -> JsShape { + let entry_source = read_entry_source(&spec.entry_file); + JsShape::detect(spec, &entry_source) +} + +fn read_entry_source(entry_file: &str) -> String { + let candidates = [ + PathBuf::from(entry_file), + PathBuf::from(".").join(entry_file), + ]; + for path in &candidates { + if let Ok(s) = std::fs::read_to_string(path) { + return s; + } + } + String::new() +} + +/// File name the harness's `require` / `import()` will reach for. +/// +/// Both JS and TS fixtures stage their entry source at `workdir/entry.js` +/// so Node's CommonJS `require('./entry')` resolves without registering a +/// loader extension hook. TS fixtures therefore use ES-compatible syntax +/// (no type annotations) — the `.ts` extension on the source-side fixture +/// file is purely cosmetic for the per-language test bucket. ESM-default +/// shapes get `entry.mjs` because dynamic `import()` is extension-sensitive +/// and Node only enters strict-ESM mode for `.mjs`. +fn entry_subpath_for_shape(shape: JsShape, _is_typescript: bool) -> String { + match shape { + JsShape::EsModuleDefault => "entry.mjs".to_owned(), + _ => "entry.js".to_owned(), + } +} + +fn generate_for_shape(spec: &HarnessSpec, shape: JsShape, entry_subpath: &str) -> String { + let preamble = harness_preamble(spec, entry_subpath, shape); + let body = match shape { + JsShape::CommonJsExport => emit_commonjs(spec), + JsShape::AsyncFunction => emit_async(spec), + JsShape::EsModuleDefault => emit_esm_default(spec), + JsShape::Express => emit_express(spec), + JsShape::Koa => emit_koa(spec), + JsShape::NextRoute => emit_next(spec), + JsShape::BrowserEvent => emit_browser_event(spec), + }; + format!("{preamble}\n{body}\n") +} + +/// Shared preamble: shim, payload loader, entry import. ESM default +/// shape opts out of the eager require and pulls the module in via +/// dynamic `import()` from its own body. +fn harness_preamble(spec: &HarnessSpec, entry_subpath: &str, shape: JsShape) -> String { + let probe = probe_shim(); + let entry_require_path = entry_require_path(entry_subpath); + let import_block = match shape { + JsShape::EsModuleDefault => String::new(), + _ => format!( + r#"let _entry; +try {{ + _entry = require('./{entry_require_path}'); +}} catch (e) {{ + process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n'); + process.exit(77); +}} +"# + ), + }; + + let sink_file = &spec.sink_file; + let sink_line = spec.sink_line; + + format!( + r#"'use strict'; +// Nyx dynamic harness — auto-generated, do not edit. +{probe} + +const _NYX_SINK_FILE = {sink_file:?}; +const _NYX_SINK_LINE = {sink_line}; + +// ── Payload loading ──────────────────────────────────────────────────────────── +const _nyx_payload = (() => {{ + if (process.env.NYX_PAYLOAD && process.env.NYX_PAYLOAD.length > 0) {{ + return process.env.NYX_PAYLOAD; + }} + if (process.env.NYX_PAYLOAD_B64 && process.env.NYX_PAYLOAD_B64.length > 0) {{ + return Buffer.from(process.env.NYX_PAYLOAD_B64, 'base64').toString('utf8'); + }} + return ''; +}})(); +const payload = _nyx_payload; + +{import_block} +"# + ) +} + +/// Strip the file extension so `require('./entry')` resolves regardless +/// of whether the on-disk file is `.js` or `.ts` (Node's CJS loader +/// honours either when the extension is omitted). The ESM-default +/// shape uses the full `entry.mjs` path because dynamic `import()` is +/// extension-sensitive. +fn entry_require_path(entry_subpath: &str) -> String { + if let Some(stripped) = entry_subpath.strip_suffix(".js") { + return stripped.to_owned(); + } + if let Some(stripped) = entry_subpath.strip_suffix(".ts") { + return stripped.to_owned(); + } + entry_subpath.to_owned() +} + +// ── Per-shape bodies ───────────────────────────────────────────────────────── + +fn emit_commonjs(spec: &HarnessSpec) -> String { + let (pre_call, call_expr) = build_call(spec, &spec.entry_name); + format!( + r#"// Shape: CommonJS export — module.exports = {{ fn }}. +{pre_call} +try {{ + const _result = {call_expr}; + if (_result && typeof _result.then === 'function') {{ + _result + .then((r) => {{ if (r != null) process.stdout.write(String(r) + '\n'); }}) + .catch((e) => process.stderr.write('NYX_EXCEPTION: ' + e.message + '\n')); + }} else if (_result != null) {{ + process.stdout.write(String(_result) + '\n'); + }} +}} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); +}} +"# + ) +} + +fn emit_async(spec: &HarnessSpec) -> String { + let (pre_call, call_expr) = build_call(spec, &spec.entry_name); + format!( + r#"// Shape: async function — await the coroutine. +{pre_call} +(async () => {{ + try {{ + const _result = await {call_expr}; + if (_result != null) process.stdout.write(String(_result) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"# + ) +} + +fn emit_esm_default(spec: &HarnessSpec) -> String { + let entry_fn = &spec.entry_name; + let (pre_call, call_args) = build_call_args(spec); + format!( + r#"// Shape: ES module default export — dynamic import(). +{pre_call} +(async () => {{ + let _mod; + try {{ + _mod = await import('./entry.mjs'); + }} catch (e) {{ + process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n'); + process.exit(77); + }} + const _fn = _mod.default || _mod[{entry_fn:?}]; + if (typeof _fn !== 'function') {{ + process.stderr.write('NYX_ENTRY_NOT_CALLABLE\n'); + process.exit(78); + }} + try {{ + const _result = await _fn({call_args}); + if (_result != null) process.stdout.write(String(_result) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"# + ) +} + +fn emit_express(spec: &HarnessSpec) -> String { + let entry_fn = &spec.entry_name; + let (method, payload_key, body_kind) = resolve_http_payload(&spec.payload_slot); + format!( + r#"// Shape: Express handler — mock req/res and dispatch synchronously. +const _handler = _entry[{entry_fn:?}] || _entry.default || _entry; +if (typeof _handler !== 'function') {{ + process.stderr.write('NYX_EXPRESS_HANDLER_NOT_FOUND\n'); + process.exit(78); +}} +const _kind = {body_kind:?}; +const _payload_key = {payload_key:?}; +const _req = {{ + method: {method:?}, + query: {{}}, + body: {{}}, + params: {{}}, + headers: {{}}, + url: '/', +}}; +if (_kind === 'query') {{ + _req.query[_payload_key] = payload; + _req.url = '/?' + encodeURIComponent(_payload_key) + '=' + encodeURIComponent(payload); +}} else if (_kind === 'body') {{ + _req.body = payload; +}} else if (_kind === 'env') {{ + process.env[_payload_key] = payload; +}} else if (_kind === 'param') {{ + _req.params[_payload_key] = payload; +}} +let _captured = ''; +const _res = {{ + statusCode: 200, + headers: {{}}, + status: function (c) {{ this.statusCode = c; return this; }}, + set: function (k, v) {{ this.headers[k] = v; return this; }}, + setHeader: function (k, v) {{ this.headers[k] = v; }}, + send: function (b) {{ _captured += String(b == null ? '' : b); return this; }}, + end: function (b) {{ if (b != null) _captured += String(b); return this; }}, + json: function (o) {{ _captured += JSON.stringify(o); return this; }}, + write: function (b) {{ _captured += String(b == null ? '' : b); return this; }}, +}}; +(async () => {{ + try {{ + const _result = _handler(_req, _res, function () {{}}); + if (_result && typeof _result.then === 'function') await _result; + process.stdout.write(_captured + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"# + ) +} + +fn emit_koa(spec: &HarnessSpec) -> String { + let entry_fn = &spec.entry_name; + let (method, payload_key, body_kind) = resolve_http_payload(&spec.payload_slot); + format!( + r#"// Shape: Koa middleware — mock ctx and await dispatch. +const _mw = _entry[{entry_fn:?}] || _entry.default || _entry; +if (typeof _mw !== 'function') {{ + process.stderr.write('NYX_KOA_HANDLER_NOT_FOUND\n'); + process.exit(78); +}} +const _kind = {body_kind:?}; +const _payload_key = {payload_key:?}; +const _ctx = {{ + method: {method:?}, + query: {{}}, + request: {{ body: {{}}, query: {{}}, header: {{}} }}, + params: {{}}, + headers: {{}}, + body: '', + status: 200, + set: function (k, v) {{ this.headers[k] = v; }}, +}}; +if (_kind === 'query') {{ + _ctx.query[_payload_key] = payload; + _ctx.request.query[_payload_key] = payload; +}} else if (_kind === 'body') {{ + _ctx.request.body = payload; +}} else if (_kind === 'env') {{ + process.env[_payload_key] = payload; +}} else if (_kind === 'param') {{ + _ctx.params[_payload_key] = payload; +}} +(async () => {{ + try {{ + await _mw(_ctx, async function () {{}}); + process.stdout.write(String(_ctx.body == null ? '' : _ctx.body) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"# + ) +} + +fn emit_next(spec: &HarnessSpec) -> String { + let entry_fn = &spec.entry_name; + let (method, payload_key, body_kind) = resolve_http_payload(&spec.payload_slot); + format!( + r#"// Shape: Next.js API route — default export (req, res). +const _handler = _entry.default || _entry[{entry_fn:?}] || _entry; +if (typeof _handler !== 'function') {{ + process.stderr.write('NYX_NEXT_HANDLER_NOT_FOUND\n'); + process.exit(78); +}} +const _kind = {body_kind:?}; +const _payload_key = {payload_key:?}; +const _req = {{ + method: {method:?}, + query: {{}}, + body: {{}}, + headers: {{}}, + url: '/', +}}; +if (_kind === 'query') {{ + _req.query[_payload_key] = payload; +}} else if (_kind === 'body') {{ + _req.body = payload; +}} else if (_kind === 'env') {{ + process.env[_payload_key] = payload; +}} +let _captured = ''; +const _res = {{ + statusCode: 200, + headers: {{}}, + status: function (c) {{ this.statusCode = c; return this; }}, + setHeader: function (k, v) {{ this.headers[k] = v; }}, + send: function (b) {{ _captured += String(b == null ? '' : b); return this; }}, + end: function (b) {{ if (b != null) _captured += String(b); return this; }}, + json: function (o) {{ _captured += JSON.stringify(o); return this; }}, + write: function (b) {{ _captured += String(b == null ? '' : b); return this; }}, +}}; +(async () => {{ + try {{ + const _result = _handler(_req, _res); + if (_result && typeof _result.then === 'function') await _result; + process.stdout.write(_captured + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"# + ) +} + +fn emit_browser_event(spec: &HarnessSpec) -> String { + let entry_fn = &spec.entry_name; + let (pre_call, call_args) = build_call_args(spec); + format!( + r#"// Shape: browser-side event handler — simulate under jsdom. +let _JSDOM; +try {{ + _JSDOM = require('jsdom').JSDOM; +}} catch (e) {{ + process.stderr.write('NYX_JSDOM_MISSING: ' + e.message + '\n'); + process.exit(79); +}} +const _dom = new _JSDOM('
', {{ + runScripts: 'outside-only', + pretendToBeVisual: true, + url: 'http://nyx.test/', +}}); +globalThis.window = _dom.window; +globalThis.document = _dom.window.document; +globalThis.HTMLElement = _dom.window.HTMLElement; +globalThis.Event = _dom.window.Event; + +{pre_call} +(async () => {{ + try {{ + const _fn = _entry[{entry_fn:?}] || _entry.default || _entry; + if (typeof _fn !== 'function') {{ + process.stderr.write('NYX_BROWSER_HANDLER_NOT_FOUND\n'); + process.exit(78); + }} + await _fn({call_args}); + // Mirror the resulting DOM to stdout so the oracle sees the + // payload only when it was actually injected into innerHTML. + // Intentionally do NOT print the handler's return value — a + // `textContent` (benign) sink returns the raw payload string and + // would otherwise smuggle the XSS marker past the DOM escape. + const _out = _dom.window.document.body.innerHTML; + process.stdout.write(_out + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"# + ) +} + +// ── Slot resolution helpers ────────────────────────────────────────────────── + +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!("_entry.{func}(payload)") + } else { + let pads = (0..*idx).map(|_| "''").collect::>().join(", "); + format!("_entry.{func}({pads}, payload)") + }; + (pre, call) + } + PayloadSlot::EnvVar(name) => { + let pre = format!("process.env[{name:?}] = payload;\n"); + let call = format!("_entry.{func}()"); + (pre, call) + } + PayloadSlot::Stdin => { + let pre = "const { Readable } = require('stream');\nprocess.stdin = Readable.from([Buffer.from(payload, 'utf8')]);\n".to_owned(); + let call = format!("_entry.{func}()"); + (pre, call) + } + _ => { + let pre = String::new(); + let call = format!("_entry.{func}(payload)"); + (pre, call) + } + } +} + +fn build_call_args(spec: &HarnessSpec) -> (String, String) { + match &spec.payload_slot { + PayloadSlot::Param(idx) => { + let pre = String::new(); + let args = if *idx == 0 { + "payload".to_owned() + } else { + let pads = (0..*idx).map(|_| "''").collect::>().join(", "); + format!("{pads}, payload") + }; + (pre, args) + } + PayloadSlot::EnvVar(name) => { + let pre = format!("process.env[{name:?}] = payload;\n"); + (pre, String::new()) + } + PayloadSlot::Stdin => { + let pre = "const { Readable } = require('stream');\nprocess.stdin = Readable.from([Buffer.from(payload, 'utf8')]);\n".to_owned(); + (pre, String::new()) + } + _ => (String::new(), "payload".to_owned()), + } +} + +/// Resolve `(http_method, payload_key, body_kind)` for the HTTP-shaped +/// emitters. `body_kind` is one of `"query"`, `"body"`, `"env"`, or +/// `"param"`. +fn resolve_http_payload(slot: &PayloadSlot) -> (&'static str, String, &'static str) { + match slot { + PayloadSlot::QueryParam(name) => ("GET", name.clone(), "query"), + PayloadSlot::HttpBody => ("POST", String::new(), "body"), + PayloadSlot::EnvVar(name) => ("GET", name.clone(), "env"), + PayloadSlot::Param(_) => ("GET", "host".to_owned(), "param"), + _ => ("GET", "q".to_owned(), "query"), + } +} + +/// Supported entry kinds for both JS + TS after Phase 13. +pub const SUPPORTED: &[EntryKind] = &[ + EntryKind::Function, + EntryKind::HttpRoute, + EntryKind::CliSubcommand, + EntryKind::LibraryApi, +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::labels::Cap; + use crate::symbol::Lang; + + fn make_spec(kind: EntryKind, name: &str, slot: PayloadSlot) -> HarnessSpec { + HarnessSpec { + finding_id: "jsshared0000001".into(), + entry_file: "src/app.js".into(), + entry_name: name.into(), + entry_kind: kind, + lang: Lang::JavaScript, + toolchain_id: "node-20".into(), + payload_slot: slot, + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: "src/app.js".into(), + sink_line: 12, + spec_hash: "jsshared00000001".into(), + derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + } + } + + #[test] + fn detect_express_via_require() { + let src = "const express = require('express');\nfunction ping(req, res) {}"; + let spec = make_spec(EntryKind::Function, "ping", PayloadSlot::QueryParam("host".into())); + assert_eq!(JsShape::detect(&spec, src), JsShape::Express); + } + + #[test] + fn detect_koa_via_require() { + let src = "const Koa = require('koa');\nasync function ping(ctx) {}"; + let spec = make_spec(EntryKind::Function, "ping", PayloadSlot::QueryParam("host".into())); + assert_eq!(JsShape::detect(&spec, src), JsShape::Koa); + } + + #[test] + fn detect_next_via_marker() { + let src = "// nyx-shape: next\nmodule.exports = async function handler(req, res) {};"; + let spec = make_spec(EntryKind::HttpRoute, "handler", PayloadSlot::QueryParam("host".into())); + assert_eq!(JsShape::detect(&spec, src), JsShape::NextRoute); + } + + #[test] + fn detect_browser_via_jsdom_marker() { + let src = "// nyx-shape: browser-event\nfunction onClick(p) { document.getElementById('out').innerHTML = p; }"; + let spec = make_spec(EntryKind::Function, "onClick", PayloadSlot::Param(0)); + assert_eq!(JsShape::detect(&spec, src), JsShape::BrowserEvent); + } + + #[test] + fn detect_async_function() { + let src = "async function runPing(host) { return host; }\nmodule.exports = { runPing };"; + let spec = make_spec(EntryKind::Function, "runPing", PayloadSlot::Param(0)); + assert_eq!(JsShape::detect(&spec, src), JsShape::AsyncFunction); + } + + #[test] + fn detect_esm_default_export() { + let src = "// nyx-shape: esm-default\nexport default function runPing(host) { return host; }"; + let spec = make_spec(EntryKind::Function, "runPing", PayloadSlot::Param(0)); + assert_eq!(JsShape::detect(&spec, src), JsShape::EsModuleDefault); + } + + #[test] + fn detect_commonjs_fallback() { + let src = "function login(x) {}\nmodule.exports = { login };"; + let spec = make_spec(EntryKind::Function, "login", PayloadSlot::Param(0)); + assert_eq!(JsShape::detect(&spec, src), JsShape::CommonJsExport); + } + + #[test] + fn emit_express_uses_mock_req_res() { + let spec = make_spec(EntryKind::HttpRoute, "ping", PayloadSlot::QueryParam("host".into())); + let src = generate_for_shape(&spec, JsShape::Express, "entry.js"); + assert!(src.contains("Express handler")); + assert!(src.contains("_req.query[_payload_key] = payload")); + } + + #[test] + fn emit_koa_awaits_middleware() { + let spec = make_spec(EntryKind::HttpRoute, "ping", PayloadSlot::QueryParam("host".into())); + let src = generate_for_shape(&spec, JsShape::Koa, "entry.js"); + assert!(src.contains("await _mw(_ctx")); + } + + #[test] + fn emit_esm_default_uses_dynamic_import() { + let spec = make_spec(EntryKind::Function, "runPing", PayloadSlot::Param(0)); + let src = generate_for_shape(&spec, JsShape::EsModuleDefault, "entry.mjs"); + assert!(src.contains("await import('./entry.mjs')")); + } + + #[test] + fn emit_browser_event_installs_jsdom() { + let spec = make_spec(EntryKind::Function, "onClick", PayloadSlot::Param(0)); + let src = generate_for_shape(&spec, JsShape::BrowserEvent, "entry.js"); + assert!(src.contains("new _JSDOM")); + assert!(src.contains("globalThis.document")); + } + + #[test] + fn extra_files_for_express_has_package_json() { + let extras = extra_files_for_shape(JsShape::Express); + assert!(extras.iter().any(|(p, c)| p == "package.json" && c.contains("express"))); + assert!(extras.iter().any(|(p, _)| p == "package-lock.json")); + } + + #[test] + fn extra_files_for_commonjs_is_empty() { + let extras = extra_files_for_shape(JsShape::CommonJsExport); + assert!(extras.is_empty()); + } + + #[test] + fn entry_require_path_strips_extension() { + assert_eq!(entry_require_path("entry.js"), "entry"); + assert_eq!(entry_require_path("entry.ts"), "entry"); + assert_eq!(entry_require_path("entry.mjs"), "entry.mjs"); + } + + #[test] + fn emit_returns_node_command() { + let spec = make_spec(EntryKind::Function, "login", PayloadSlot::Param(0)); + let h = emit(&spec, false).unwrap(); + assert_eq!(h.filename, "harness.js"); + assert_eq!(h.command, vec!["node", "harness.js"]); + } + + #[test] + fn typescript_and_javascript_share_entry_js_subpath() { + let spec = make_spec(EntryKind::Function, "login", PayloadSlot::Param(0)); + let h_js = emit(&spec, false).unwrap(); + let h_ts = emit(&spec, true).unwrap(); + assert_eq!(h_js.entry_subpath, h_ts.entry_subpath); + assert_eq!(h_js.entry_subpath.as_deref(), Some("entry.js")); + } +} diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index 84bf291b..0e9b42e3 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -17,6 +17,7 @@ pub mod cpp; pub mod go; pub mod java; pub mod javascript; +pub mod js_shared; pub mod php; pub mod python; pub mod ruby; diff --git a/src/dynamic/lang/typescript.rs b/src/dynamic/lang/typescript.rs index 15150f63..70ef7889 100644 --- a/src/dynamic/lang/typescript.rs +++ b/src/dynamic/lang/typescript.rs @@ -1,78 +1,101 @@ //! TypeScript harness emitter. //! -//! Today TypeScript shares the JS emitter — `tsc` is not invoked; the runner -//! treats `.ts` / `.tsx` / `.mts` / `.cts` files as Node-compatible because -//! every shape we currently emit (free functions, `module.exports`-style -//! handlers) is identical at the runtime level after type erasure. This -//! module exists so the [`crate::dynamic::lang::LangEmitter`] dispatch table -//! has a discoverable per-language handle and so callers can call -//! `entry_kinds_supported(Lang::TypeScript)` symmetrically with the other -//! languages — the actual `emit` body delegates to -//! [`crate::dynamic::lang::javascript::emit`]. +//! Shares the per-shape dispatch in [`crate::dynamic::lang::js_shared`] with +//! the JavaScript emitter — the runtime is Node.js in both cases. The only +//! divergence is the entry filename: TypeScript fixtures are staged at +//! `workdir/entry.ts` so the staged source preserves its extension for +//! human-readable repro bundles. Node's CommonJS loader honours an +//! extension-less `require('./entry')`, so the harness can load either +//! `entry.js` or `entry.ts` without a separate typed-loader step. //! -//! Phase 13 (Track B JS + TS vertical) introduces TS-specific shapes -//! (Next.js route handlers, `tsx` browser modules under jsdom). When those -//! land, the supported list / hint shift here without affecting the JS -//! emitter. +//! Phase 13 (Track B JS + TS vertical) introduced TS-specific shapes +//! (Next.js route handlers, `tsx` browser modules under jsdom). The shape +//! detector in `js_shared` fires identically against TS or JS source — TS +//! fixtures use ES-compatible syntax with optional type annotations the +//! runtime ignores. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{javascript, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{js_shared, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec}; use crate::evidence::UnsupportedReason; /// Zero-sized [`LangEmitter`] handle for TypeScript. pub struct TypeScriptEmitter; -/// Entry kinds the TypeScript emitter currently understands. Same as JS until -/// Phase 13 introduces TS-specific shapes (Next.js route handlers, `tsx` -/// 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() + js_shared::probe_shim() } impl LangEmitter for TypeScriptEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { - javascript::emit(spec) + js_shared::emit(spec, true) } fn entry_kinds_supported(&self) -> &'static [EntryKind] { - SUPPORTED + js_shared::SUPPORTED } fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( - "typescript emitter supports {SUPPORTED:?} (delegates to the JavaScript emitter); this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Next.js / jsdom shapes in phase 13" + "typescript emitter supports {supported:?} (shared dispatch with javascript via `js_shared`); this finding's enclosing context is `EntryKind::{attempted}` — see Phase 13 shape dispatch", + supported = js_shared::SUPPORTED, ) } fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { - javascript::materialize_node(env) + js_shared::materialize_node(env) } } #[cfg(test)] mod tests { use super::*; + use crate::dynamic::spec::{HarnessSpec, PayloadSlot, SpecDerivationStrategy}; + use crate::labels::Cap; + use crate::symbol::Lang; + + fn make_spec(kind: EntryKind) -> HarnessSpec { + HarnessSpec { + finding_id: "ts000000000001".into(), + entry_file: "src/app.ts".into(), + entry_name: "login".into(), + entry_kind: kind, + lang: Lang::TypeScript, + toolchain_id: "node-20".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: "src/app.ts".into(), + sink_line: 12, + spec_hash: "ts000000000001ab".into(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + } + } #[test] - fn entry_kinds_supported_is_non_empty() { + fn entry_kinds_supported_is_non_empty_and_includes_http_route() { assert!(!TypeScriptEmitter.entry_kinds_supported().is_empty()); assert!(TypeScriptEmitter .entry_kinds_supported() - .contains(&EntryKind::Function)); + .contains(&EntryKind::HttpRoute)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { let hint = TypeScriptEmitter.entry_kind_hint(EntryKind::HttpRoute); assert!(hint.contains("HttpRoute")); - assert!(hint.contains("phase 13")); + assert!(hint.contains("Phase 13")); + } + + #[test] + fn typescript_emit_stages_entry_at_entry_js_for_node_resolution() { + let h = TypeScriptEmitter.emit(&make_spec(EntryKind::Function)).unwrap(); + // TS fixtures use ES-compatible syntax; the workdir layout matches + // JavaScript so Node's CJS `require('./entry')` resolves without an + // extension-loader hook. See js_shared::entry_subpath_for_shape. + assert_eq!(h.entry_subpath.as_deref(), Some("entry.js")); + assert_eq!(h.filename, "harness.js"); } } diff --git a/tests/common/fixture_harness.rs b/tests/common/fixture_harness.rs index f02c81a2..8ae1f5b2 100644 --- a/tests/common/fixture_harness.rs +++ b/tests/common/fixture_harness.rs @@ -192,22 +192,10 @@ fn stage_fixture(src: &Path, tmp: &TempDir, copy: CopyStrategy) -> PathBuf { } } -/// Phase 12 — per-shape acceptance helper. +/// Phase 12 — Python-specific per-shape acceptance helper. /// -/// Stages `fixture_root//` into a tempdir, builds a -/// [`HarnessSpec`] with the caller's `entry_kind` / `payload_slot`, -/// then executes it through [`nyx_scanner::dynamic::runner::run_spec`] -/// directly. Returns a [`VerifyResult`]-shaped summary so callers can -/// reuse the same `assert_confirmed` / `assert_not_confirmed` helpers -/// the older golden-based suite uses. -/// -/// Bypasses [`verify_finding`] because the public verifier derives the -/// payload slot from the synthetic Diag's flow steps and always lands -/// on [`nyx_scanner::dynamic::spec::PayloadSlot::Param`], which the -/// HTTP / pytest / CLI shapes cannot honour. Going through the runner -/// directly lets the test pin the slot the spec under test actually -/// expects (e.g. [`nyx_scanner::dynamic::spec::PayloadSlot::QueryParam`] -/// for HTTP routes). +/// Thin wrapper over [`run_shape_fixture_lang`] pinning the lang dir +/// to `tests/dynamic_fixtures/python/` and [`Lang::Python`]. #[allow(clippy::too_many_arguments)] pub fn run_shape_fixture( shape_dir: &str, @@ -217,16 +205,54 @@ pub fn run_shape_fixture( sink_line: u32, entry_kind: EntryKind, payload_slot: nyx_scanner::dynamic::spec::PayloadSlot, +) -> VerifyResult { + run_shape_fixture_lang( + nyx_scanner::symbol::Lang::Python, + "python", + shape_dir, + file, + func, + cap, + sink_line, + entry_kind, + payload_slot, + ) +} + +/// Phase 13 — lang-aware per-shape acceptance helper. +/// +/// Stages `tests/dynamic_fixtures///` into a +/// tempdir, builds a [`HarnessSpec`] with the caller's `entry_kind` / +/// `payload_slot` / [`Lang`], then executes it through +/// [`nyx_scanner::dynamic::runner::run_spec`] directly. Returns a +/// [`VerifyResult`]-shaped summary so callers can reuse the same +/// `assert_confirmed` / `assert_not_confirmed` helpers across Python / +/// JS / TS / etc. shape suites. +/// +/// Bypasses [`verify_finding`] for the same reason as [`run_shape_fixture`]: +/// the public verifier always lands on +/// [`nyx_scanner::dynamic::spec::PayloadSlot::Param`]. +#[allow(clippy::too_many_arguments)] +pub fn run_shape_fixture_lang( + lang: nyx_scanner::symbol::Lang, + lang_dir: &str, + shape_dir: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + entry_kind: EntryKind, + payload_slot: nyx_scanner::dynamic::spec::PayloadSlot, ) -> VerifyResult { use nyx_scanner::dynamic::runner::{run_spec, RunError}; use nyx_scanner::dynamic::sandbox::SandboxOptions; use nyx_scanner::dynamic::spec::{HarnessSpec, SpecDerivationStrategy}; - use nyx_scanner::symbol::Lang; let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/dynamic_fixtures/python") + .join("tests/dynamic_fixtures") + .join(lang_dir) .join(shape_dir); let fixture_src = fixture_root.join(file); @@ -245,8 +271,10 @@ pub fn run_shape_fixture( let entry_file = dst.to_string_lossy().into_owned(); // Per-fixture stable hash so workdir layout / cache key stays - // distinct between shapes and between vuln / benign fixtures. + // distinct between langs / shapes / vuln-vs-benign fixtures. let mut digest = blake3::Hasher::new(); + digest.update(lang_dir.as_bytes()); + digest.update(b"|"); digest.update(shape_dir.as_bytes()); digest.update(b"|"); digest.update(file.as_bytes()); @@ -255,13 +283,25 @@ pub fn run_shape_fixture( u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap()) }); + let toolchain_id = match lang { + nyx_scanner::symbol::Lang::Python => "python-3", + nyx_scanner::symbol::Lang::JavaScript | nyx_scanner::symbol::Lang::TypeScript => "node-20", + nyx_scanner::symbol::Lang::Rust => "rust-stable", + nyx_scanner::symbol::Lang::Go => "go-1.21", + nyx_scanner::symbol::Lang::Java => "java-17", + nyx_scanner::symbol::Lang::Php => "php-8", + nyx_scanner::symbol::Lang::Ruby => "ruby-3", + nyx_scanner::symbol::Lang::C => "gcc", + nyx_scanner::symbol::Lang::Cpp => "g++", + }; + let spec = HarnessSpec { finding_id: spec_hash.clone(), entry_file: entry_file.clone(), entry_name: func.to_owned(), entry_kind, - lang: Lang::Python, - toolchain_id: "python-3".into(), + lang, + toolchain_id: toolchain_id.into(), payload_slot, expected_cap: cap, constraint_hints: vec![], @@ -332,15 +372,10 @@ pub fn run_shape_fixture( } } -/// Phase 12 — golden harness snapshot. +/// Phase 12 — Python-specific harness snapshot wrapper. /// -/// Stages `/` into a tempdir, builds a [`HarnessSpec`] for -/// the supplied entry kind / payload slot, emits the per-shape harness -/// via [`nyx_scanner::dynamic::lang::emit`], and either writes the -/// resulting source to `/.golden_harness.py` (under -/// `NYX_UPDATE_GOLDENS=1`) or diffs against the existing snapshot. The -/// emitter is deterministic, so the snapshot doubles as documentation -/// of the per-shape harness shape. +/// Pins lang to [`Lang::Python`] and the lang dir to `python` so legacy +/// Python tests can keep their original two-axis signature. #[allow(clippy::too_many_arguments)] pub fn run_harness_snapshot( shape_dir: &str, @@ -351,17 +386,52 @@ pub fn run_harness_snapshot( entry_kind: EntryKind, payload_slot: nyx_scanner::dynamic::spec::PayloadSlot, ) { - use nyx_scanner::dynamic::lang; + run_harness_snapshot_lang( + nyx_scanner::symbol::Lang::Python, + "python", + "py", + shape_dir, + file, + func, + cap, + sink_line, + entry_kind, + payload_slot, + ) +} + +/// Phase 13 — lang-aware golden harness snapshot. +/// +/// Stages `tests/dynamic_fixtures///` into a +/// tempdir, builds a [`HarnessSpec`] for the supplied lang / entry kind +/// / payload slot, emits the per-shape harness via +/// [`nyx_scanner::dynamic::lang::emit`], and either writes the resulting +/// source to `/.golden_harness.` (under +/// `NYX_UPDATE_GOLDENS=1`) or diffs against the existing snapshot. +#[allow(clippy::too_many_arguments)] +pub fn run_harness_snapshot_lang( + lang: nyx_scanner::symbol::Lang, + lang_dir: &str, + snapshot_ext: &str, + shape_dir: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + entry_kind: EntryKind, + payload_slot: nyx_scanner::dynamic::spec::PayloadSlot, +) { + use nyx_scanner::dynamic::lang as lang_emit; use nyx_scanner::dynamic::spec::{HarnessSpec, SpecDerivationStrategy}; - use nyx_scanner::symbol::Lang; let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/dynamic_fixtures/python") + .join("tests/dynamic_fixtures") + .join(lang_dir) .join(shape_dir); let fixture_src = fixture_root.join(file); - let snapshot_path = fixture_root.join(format!("{file}.golden_harness.py")); + let snapshot_path = fixture_root.join(format!("{file}.golden_harness.{snapshot_ext}")); // Stage into tempdir so the spec.entry_file path matches what the // verifier sees at runtime. @@ -370,13 +440,19 @@ pub fn run_harness_snapshot( std::fs::copy(&fixture_src, &dst).expect("copy fixture into tempdir"); let entry_file = dst.to_string_lossy().into_owned(); + let toolchain_id = match lang { + nyx_scanner::symbol::Lang::Python => "python-3", + nyx_scanner::symbol::Lang::JavaScript | nyx_scanner::symbol::Lang::TypeScript => "node-20", + _ => "unknown", + }; + let spec = HarnessSpec { finding_id: "0000000000000001".into(), entry_file: entry_file.clone(), entry_name: func.to_owned(), entry_kind, - lang: Lang::Python, - toolchain_id: "python-3".into(), + lang, + toolchain_id: toolchain_id.into(), payload_slot, expected_cap: cap, constraint_hints: vec![], @@ -389,7 +465,7 @@ pub fn run_harness_snapshot( stubs_required: vec![], }; - let harness = lang::emit(&spec).expect("python emitter must produce a harness"); + let harness = lang_emit::emit(&spec).expect("emitter must produce a harness"); // Strip the tempdir prefix so the snapshot is stable across runs. let tmp_prefix = tmp.path().to_string_lossy().into_owned(); diff --git a/tests/dynamic_fixtures/javascript/async_function/benign.js b/tests/dynamic_fixtures/javascript/async_function/benign.js new file mode 100644 index 00000000..bb228a0c --- /dev/null +++ b/tests/dynamic_fixtures/javascript/async_function/benign.js @@ -0,0 +1,24 @@ +// Phase 13 — bare async function, benign control. +// +// execFile (no shell) via util.promisify(execFile). Payload never reaches a +// shell; stderr silenced so payload bytes do not leak via the inner process' +// error message. + +'use strict'; +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const execFileP = promisify(execFile); + +async function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const { stdout } = await execFileP('true', [host], { + timeout: 5000, + }); + return stdout; + } catch (_e) { + return 'err'; + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/javascript/async_function/vuln.js b/tests/dynamic_fixtures/javascript/async_function/vuln.js new file mode 100644 index 00000000..89422692 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/async_function/vuln.js @@ -0,0 +1,25 @@ +// Phase 13 — bare async function, vulnerable. +// +// Stdlib-only. Async function awaits `child_process.exec` via util.promisify +// so the harness's `await _entry.runPing(payload)` resolves before the +// process exits. + +'use strict'; +const { exec } = require('child_process'); +const { promisify } = require('util'); +const execP = promisify(exec); + +async function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const { stdout } = await execP('echo hello ' + host, { timeout: 5000 }); + process.stdout.write(stdout); + return stdout; + } catch (e) { + const out = (e.stdout || '') + (e.stderr || ''); + process.stdout.write(out); + return out; + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/javascript/browser_event/benign.js b/tests/dynamic_fixtures/javascript/browser_event/benign.js new file mode 100644 index 00000000..c3800d17 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/browser_event/benign.js @@ -0,0 +1,19 @@ +// Phase 13 — browser-side event handler, benign control. +// +// Uses `textContent` so the payload's `` payload appears in the serialised DOM the harness mirrors to +// stdout. + +'use strict'; +// nyx-shape: browser-event + +function clickHandler(payload) { + process.stdout.write('__NYX_SINK_HIT__\n'); + const el = document.getElementById('out'); + if (el) { + el.innerHTML = String(payload); + } + return el ? el.innerHTML : ''; +} + +module.exports = { clickHandler }; diff --git a/tests/dynamic_fixtures/javascript/commonjs_export/benign.js b/tests/dynamic_fixtures/javascript/commonjs_export/benign.js new file mode 100644 index 00000000..e45478a1 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/commonjs_export/benign.js @@ -0,0 +1,20 @@ +// Phase 13 — CommonJS export, benign control. + +'use strict'; +const { execFileSync } = require('child_process'); + +function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return 'ok'; + } catch (_e) { + return 'err'; + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/javascript/commonjs_export/vuln.js b/tests/dynamic_fixtures/javascript/commonjs_export/vuln.js new file mode 100644 index 00000000..6ffa5dcc --- /dev/null +++ b/tests/dynamic_fixtures/javascript/commonjs_export/vuln.js @@ -0,0 +1,21 @@ +// Phase 13 — CommonJS export, vulnerable. +// +// Synchronous `execSync` with shell:true via string concat. Stdlib only. + +'use strict'; +const { execSync } = require('child_process'); + +function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + process.stdout.write(out); + return out; + } catch (e) { + const out = (e.stdout || '') + (e.stderr || ''); + process.stdout.write(out); + return out; + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/javascript/esm_default/benign.js b/tests/dynamic_fixtures/javascript/esm_default/benign.js new file mode 100644 index 00000000..408e9f25 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/esm_default/benign.js @@ -0,0 +1,18 @@ +// Phase 13 — ES module default export, benign control. +// +// nyx-shape: esm-default +import { execFileSync } from 'child_process'; + +export default function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return 'ok'; + } catch (_e) { + return 'err'; + } +} diff --git a/tests/dynamic_fixtures/javascript/esm_default/vuln.js b/tests/dynamic_fixtures/javascript/esm_default/vuln.js new file mode 100644 index 00000000..5d550be6 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/esm_default/vuln.js @@ -0,0 +1,22 @@ +// Phase 13 — ES module default export, vulnerable. +// +// `export default` body is the entry the harness imports dynamically. The +// harness builder stages this file at `workdir/entry.mjs` (per +// js_shared::entry_subpath_for_shape) so Node parses it under ESM semantics +// regardless of the on-disk `.js` extension under the fixture tree. + +// nyx-shape: esm-default +import { execSync } from 'child_process'; + +export default function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + process.stdout.write(out); + return out; + } catch (e) { + const out = (e.stdout || '') + (e.stderr || ''); + process.stdout.write(out); + return out; + } +} diff --git a/tests/dynamic_fixtures/javascript/express/benign.js b/tests/dynamic_fixtures/javascript/express/benign.js new file mode 100644 index 00000000..0f1e2974 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/express/benign.js @@ -0,0 +1,28 @@ +// Phase 13 — Express route handler, benign control. +// +// Uses execFile (no shell) so the payload bytes are never interpreted as +// shell metacharacters. The oracle marker cannot appear in stdout because +// the inner child reads `true` and its stdio is ignored. + +'use strict'; +const express = require('express'); +const { execFileSync } = require('child_process'); + +function ping(req, res) { + const host = (req.query && req.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + res.send('ok'); + } catch (_e) { + res.send('err'); + } +} + +void express; + +module.exports = { ping }; diff --git a/tests/dynamic_fixtures/javascript/express/package-lock.json b/tests/dynamic_fixtures/javascript/express/package-lock.json new file mode 100644 index 00000000..5f590858 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/express/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "nyx-harness-express", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nyx-harness-express", + "version": "0.0.0" + } + } +} diff --git a/tests/dynamic_fixtures/javascript/express/package.json b/tests/dynamic_fixtures/javascript/express/package.json new file mode 100644 index 00000000..cdf74110 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/express/package.json @@ -0,0 +1,8 @@ +{ + "name": "nyx-harness-express", + "version": "0.0.0", + "private": true, + "dependencies": { + "express": "^4.19.2" + } +} diff --git a/tests/dynamic_fixtures/javascript/express/vuln.js b/tests/dynamic_fixtures/javascript/express/vuln.js new file mode 100644 index 00000000..797ace9b --- /dev/null +++ b/tests/dynamic_fixtures/javascript/express/vuln.js @@ -0,0 +1,26 @@ +// Phase 13 — Express route handler, vulnerable. +// +// Vulnerable handler concatenates `req.query.host` into a shell command. +// Harness builds a mock req/res via js_shared::emit_express and dispatches +// synchronously; we never bind a real listener. + +'use strict'; +const express = require('express'); +const { execSync } = require('child_process'); + +function ping(req, res) { + const host = (req.query && req.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + res.send(out); + } catch (e) { + res.send((e.stdout || '') + (e.stderr || '')); + } +} + +// Touch the dep so the materialised package.json's `express` pin survives +// shake-down by `npm install --no-save`; harness never starts the server. +void express; + +module.exports = { ping }; diff --git a/tests/dynamic_fixtures/javascript/koa/benign.js b/tests/dynamic_fixtures/javascript/koa/benign.js new file mode 100644 index 00000000..8e98db36 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/koa/benign.js @@ -0,0 +1,26 @@ +// Phase 13 — Koa middleware, benign control. +// +// execFile (no shell), stderr silenced, child writes nothing to stdout. + +'use strict'; +const Koa = require('koa'); +const { execFileSync } = require('child_process'); + +async function ping(ctx) { + const host = (ctx.query && ctx.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + ctx.body = 'ok'; + } catch (_e) { + ctx.body = 'err'; + } +} + +void Koa; + +module.exports = { ping }; diff --git a/tests/dynamic_fixtures/javascript/koa/package-lock.json b/tests/dynamic_fixtures/javascript/koa/package-lock.json new file mode 100644 index 00000000..7e07bab2 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/koa/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "nyx-harness-koa", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nyx-harness-koa", + "version": "0.0.0" + } + } +} diff --git a/tests/dynamic_fixtures/javascript/koa/package.json b/tests/dynamic_fixtures/javascript/koa/package.json new file mode 100644 index 00000000..9b26fd1b --- /dev/null +++ b/tests/dynamic_fixtures/javascript/koa/package.json @@ -0,0 +1,8 @@ +{ + "name": "nyx-harness-koa", + "version": "0.0.0", + "private": true, + "dependencies": { + "koa": "^2.15.3" + } +} diff --git a/tests/dynamic_fixtures/javascript/koa/vuln.js b/tests/dynamic_fixtures/javascript/koa/vuln.js new file mode 100644 index 00000000..d52fbffa --- /dev/null +++ b/tests/dynamic_fixtures/javascript/koa/vuln.js @@ -0,0 +1,23 @@ +// Phase 13 — Koa middleware, vulnerable. +// +// Vulnerable middleware reads `ctx.query.host` and concatenates it into a +// shell command. Harness builds a mock ctx via js_shared::emit_koa. + +'use strict'; +const Koa = require('koa'); +const { execSync } = require('child_process'); + +async function ping(ctx) { + const host = (ctx.query && ctx.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + ctx.body = out; + } catch (e) { + ctx.body = (e.stdout || '') + (e.stderr || ''); + } +} + +void Koa; + +module.exports = { ping }; diff --git a/tests/dynamic_fixtures/javascript/next_route/benign.js b/tests/dynamic_fixtures/javascript/next_route/benign.js new file mode 100644 index 00000000..3917aec2 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/next_route/benign.js @@ -0,0 +1,25 @@ +// Phase 13 — Next.js API route handler, benign control. +// +// execFile (no shell) so payload bytes never reach a shell. +// +// nyx-shape: next + +'use strict'; +try { require.resolve('next'); } catch (_e) {} + +const { execFileSync } = require('child_process'); + +module.exports = async function handler(req, res) { + const host = (req.query && req.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + res.status(200).send('ok'); + } catch (_e) { + res.status(200).send('err'); + } +}; diff --git a/tests/dynamic_fixtures/javascript/next_route/package-lock.json b/tests/dynamic_fixtures/javascript/next_route/package-lock.json new file mode 100644 index 00000000..72d3446a --- /dev/null +++ b/tests/dynamic_fixtures/javascript/next_route/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "nyx-harness-next", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nyx-harness-next", + "version": "0.0.0" + } + } +} diff --git a/tests/dynamic_fixtures/javascript/next_route/package.json b/tests/dynamic_fixtures/javascript/next_route/package.json new file mode 100644 index 00000000..bd94d464 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/next_route/package.json @@ -0,0 +1,8 @@ +{ + "name": "nyx-harness-next", + "version": "0.0.0", + "private": true, + "dependencies": { + "next": "^14.2.5" + } +} diff --git a/tests/dynamic_fixtures/javascript/next_route/vuln.js b/tests/dynamic_fixtures/javascript/next_route/vuln.js new file mode 100644 index 00000000..e9f4a083 --- /dev/null +++ b/tests/dynamic_fixtures/javascript/next_route/vuln.js @@ -0,0 +1,26 @@ +// Phase 13 — Next.js API route handler, vulnerable. +// +// Reads `req.query.host` and concatenates it into a shell command. The +// `next` package is required for the materialised package.json pin to +// survive `npm install --no-save`, but the harness builds its own mock +// req/res via js_shared::emit_next; we never go through the Next router. +// +// nyx-shape: next + +'use strict'; +// Touching `next` would also load React; the import is intentionally lazy +// and guarded so test runs without a network-fed install still parse. +try { require.resolve('next'); } catch (_e) {} + +const { execSync } = require('child_process'); + +module.exports = async function handler(req, res) { + const host = (req.query && req.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + res.status(200).send(out); + } catch (e) { + res.status(200).send((e.stdout || '') + (e.stderr || '')); + } +}; diff --git a/tests/dynamic_fixtures/typescript/async_function/benign.ts b/tests/dynamic_fixtures/typescript/async_function/benign.ts new file mode 100644 index 00000000..bb228a0c --- /dev/null +++ b/tests/dynamic_fixtures/typescript/async_function/benign.ts @@ -0,0 +1,24 @@ +// Phase 13 — bare async function, benign control. +// +// execFile (no shell) via util.promisify(execFile). Payload never reaches a +// shell; stderr silenced so payload bytes do not leak via the inner process' +// error message. + +'use strict'; +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const execFileP = promisify(execFile); + +async function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const { stdout } = await execFileP('true', [host], { + timeout: 5000, + }); + return stdout; + } catch (_e) { + return 'err'; + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/typescript/async_function/vuln.ts b/tests/dynamic_fixtures/typescript/async_function/vuln.ts new file mode 100644 index 00000000..89422692 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/async_function/vuln.ts @@ -0,0 +1,25 @@ +// Phase 13 — bare async function, vulnerable. +// +// Stdlib-only. Async function awaits `child_process.exec` via util.promisify +// so the harness's `await _entry.runPing(payload)` resolves before the +// process exits. + +'use strict'; +const { exec } = require('child_process'); +const { promisify } = require('util'); +const execP = promisify(exec); + +async function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const { stdout } = await execP('echo hello ' + host, { timeout: 5000 }); + process.stdout.write(stdout); + return stdout; + } catch (e) { + const out = (e.stdout || '') + (e.stderr || ''); + process.stdout.write(out); + return out; + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/typescript/browser_event/benign.ts b/tests/dynamic_fixtures/typescript/browser_event/benign.ts new file mode 100644 index 00000000..c3800d17 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/browser_event/benign.ts @@ -0,0 +1,19 @@ +// Phase 13 — browser-side event handler, benign control. +// +// Uses `textContent` so the payload's `` payload appears in the serialised DOM the harness mirrors to +// stdout. + +'use strict'; +// nyx-shape: browser-event + +function clickHandler(payload) { + process.stdout.write('__NYX_SINK_HIT__\n'); + const el = document.getElementById('out'); + if (el) { + el.innerHTML = String(payload); + } + return el ? el.innerHTML : ''; +} + +module.exports = { clickHandler }; diff --git a/tests/dynamic_fixtures/typescript/commonjs_export/benign.ts b/tests/dynamic_fixtures/typescript/commonjs_export/benign.ts new file mode 100644 index 00000000..e45478a1 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/commonjs_export/benign.ts @@ -0,0 +1,20 @@ +// Phase 13 — CommonJS export, benign control. + +'use strict'; +const { execFileSync } = require('child_process'); + +function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return 'ok'; + } catch (_e) { + return 'err'; + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/typescript/commonjs_export/vuln.ts b/tests/dynamic_fixtures/typescript/commonjs_export/vuln.ts new file mode 100644 index 00000000..6ffa5dcc --- /dev/null +++ b/tests/dynamic_fixtures/typescript/commonjs_export/vuln.ts @@ -0,0 +1,21 @@ +// Phase 13 — CommonJS export, vulnerable. +// +// Synchronous `execSync` with shell:true via string concat. Stdlib only. + +'use strict'; +const { execSync } = require('child_process'); + +function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + process.stdout.write(out); + return out; + } catch (e) { + const out = (e.stdout || '') + (e.stderr || ''); + process.stdout.write(out); + return out; + } +} + +module.exports = { runPing }; diff --git a/tests/dynamic_fixtures/typescript/esm_default/benign.ts b/tests/dynamic_fixtures/typescript/esm_default/benign.ts new file mode 100644 index 00000000..408e9f25 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/esm_default/benign.ts @@ -0,0 +1,18 @@ +// Phase 13 — ES module default export, benign control. +// +// nyx-shape: esm-default +import { execFileSync } from 'child_process'; + +export default function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return 'ok'; + } catch (_e) { + return 'err'; + } +} diff --git a/tests/dynamic_fixtures/typescript/esm_default/vuln.ts b/tests/dynamic_fixtures/typescript/esm_default/vuln.ts new file mode 100644 index 00000000..5d550be6 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/esm_default/vuln.ts @@ -0,0 +1,22 @@ +// Phase 13 — ES module default export, vulnerable. +// +// `export default` body is the entry the harness imports dynamically. The +// harness builder stages this file at `workdir/entry.mjs` (per +// js_shared::entry_subpath_for_shape) so Node parses it under ESM semantics +// regardless of the on-disk `.js` extension under the fixture tree. + +// nyx-shape: esm-default +import { execSync } from 'child_process'; + +export default function runPing(host) { + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + process.stdout.write(out); + return out; + } catch (e) { + const out = (e.stdout || '') + (e.stderr || ''); + process.stdout.write(out); + return out; + } +} diff --git a/tests/dynamic_fixtures/typescript/express/benign.ts b/tests/dynamic_fixtures/typescript/express/benign.ts new file mode 100644 index 00000000..0f1e2974 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/express/benign.ts @@ -0,0 +1,28 @@ +// Phase 13 — Express route handler, benign control. +// +// Uses execFile (no shell) so the payload bytes are never interpreted as +// shell metacharacters. The oracle marker cannot appear in stdout because +// the inner child reads `true` and its stdio is ignored. + +'use strict'; +const express = require('express'); +const { execFileSync } = require('child_process'); + +function ping(req, res) { + const host = (req.query && req.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + res.send('ok'); + } catch (_e) { + res.send('err'); + } +} + +void express; + +module.exports = { ping }; diff --git a/tests/dynamic_fixtures/typescript/express/package-lock.json b/tests/dynamic_fixtures/typescript/express/package-lock.json new file mode 100644 index 00000000..5f590858 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/express/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "nyx-harness-express", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nyx-harness-express", + "version": "0.0.0" + } + } +} diff --git a/tests/dynamic_fixtures/typescript/express/package.json b/tests/dynamic_fixtures/typescript/express/package.json new file mode 100644 index 00000000..cdf74110 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/express/package.json @@ -0,0 +1,8 @@ +{ + "name": "nyx-harness-express", + "version": "0.0.0", + "private": true, + "dependencies": { + "express": "^4.19.2" + } +} diff --git a/tests/dynamic_fixtures/typescript/express/vuln.ts b/tests/dynamic_fixtures/typescript/express/vuln.ts new file mode 100644 index 00000000..797ace9b --- /dev/null +++ b/tests/dynamic_fixtures/typescript/express/vuln.ts @@ -0,0 +1,26 @@ +// Phase 13 — Express route handler, vulnerable. +// +// Vulnerable handler concatenates `req.query.host` into a shell command. +// Harness builds a mock req/res via js_shared::emit_express and dispatches +// synchronously; we never bind a real listener. + +'use strict'; +const express = require('express'); +const { execSync } = require('child_process'); + +function ping(req, res) { + const host = (req.query && req.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + res.send(out); + } catch (e) { + res.send((e.stdout || '') + (e.stderr || '')); + } +} + +// Touch the dep so the materialised package.json's `express` pin survives +// shake-down by `npm install --no-save`; harness never starts the server. +void express; + +module.exports = { ping }; diff --git a/tests/dynamic_fixtures/typescript/koa/benign.ts b/tests/dynamic_fixtures/typescript/koa/benign.ts new file mode 100644 index 00000000..8e98db36 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/koa/benign.ts @@ -0,0 +1,26 @@ +// Phase 13 — Koa middleware, benign control. +// +// execFile (no shell), stderr silenced, child writes nothing to stdout. + +'use strict'; +const Koa = require('koa'); +const { execFileSync } = require('child_process'); + +async function ping(ctx) { + const host = (ctx.query && ctx.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + ctx.body = 'ok'; + } catch (_e) { + ctx.body = 'err'; + } +} + +void Koa; + +module.exports = { ping }; diff --git a/tests/dynamic_fixtures/typescript/koa/package-lock.json b/tests/dynamic_fixtures/typescript/koa/package-lock.json new file mode 100644 index 00000000..7e07bab2 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/koa/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "nyx-harness-koa", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nyx-harness-koa", + "version": "0.0.0" + } + } +} diff --git a/tests/dynamic_fixtures/typescript/koa/package.json b/tests/dynamic_fixtures/typescript/koa/package.json new file mode 100644 index 00000000..9b26fd1b --- /dev/null +++ b/tests/dynamic_fixtures/typescript/koa/package.json @@ -0,0 +1,8 @@ +{ + "name": "nyx-harness-koa", + "version": "0.0.0", + "private": true, + "dependencies": { + "koa": "^2.15.3" + } +} diff --git a/tests/dynamic_fixtures/typescript/koa/vuln.ts b/tests/dynamic_fixtures/typescript/koa/vuln.ts new file mode 100644 index 00000000..d52fbffa --- /dev/null +++ b/tests/dynamic_fixtures/typescript/koa/vuln.ts @@ -0,0 +1,23 @@ +// Phase 13 — Koa middleware, vulnerable. +// +// Vulnerable middleware reads `ctx.query.host` and concatenates it into a +// shell command. Harness builds a mock ctx via js_shared::emit_koa. + +'use strict'; +const Koa = require('koa'); +const { execSync } = require('child_process'); + +async function ping(ctx) { + const host = (ctx.query && ctx.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + ctx.body = out; + } catch (e) { + ctx.body = (e.stdout || '') + (e.stderr || ''); + } +} + +void Koa; + +module.exports = { ping }; diff --git a/tests/dynamic_fixtures/typescript/next_route/benign.ts b/tests/dynamic_fixtures/typescript/next_route/benign.ts new file mode 100644 index 00000000..3917aec2 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/next_route/benign.ts @@ -0,0 +1,25 @@ +// Phase 13 — Next.js API route handler, benign control. +// +// execFile (no shell) so payload bytes never reach a shell. +// +// nyx-shape: next + +'use strict'; +try { require.resolve('next'); } catch (_e) {} + +const { execFileSync } = require('child_process'); + +module.exports = async function handler(req, res) { + const host = (req.query && req.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + execFileSync('true', [host], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + res.status(200).send('ok'); + } catch (_e) { + res.status(200).send('err'); + } +}; diff --git a/tests/dynamic_fixtures/typescript/next_route/package-lock.json b/tests/dynamic_fixtures/typescript/next_route/package-lock.json new file mode 100644 index 00000000..72d3446a --- /dev/null +++ b/tests/dynamic_fixtures/typescript/next_route/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "nyx-harness-next", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nyx-harness-next", + "version": "0.0.0" + } + } +} diff --git a/tests/dynamic_fixtures/typescript/next_route/package.json b/tests/dynamic_fixtures/typescript/next_route/package.json new file mode 100644 index 00000000..bd94d464 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/next_route/package.json @@ -0,0 +1,8 @@ +{ + "name": "nyx-harness-next", + "version": "0.0.0", + "private": true, + "dependencies": { + "next": "^14.2.5" + } +} diff --git a/tests/dynamic_fixtures/typescript/next_route/vuln.ts b/tests/dynamic_fixtures/typescript/next_route/vuln.ts new file mode 100644 index 00000000..e9f4a083 --- /dev/null +++ b/tests/dynamic_fixtures/typescript/next_route/vuln.ts @@ -0,0 +1,26 @@ +// Phase 13 — Next.js API route handler, vulnerable. +// +// Reads `req.query.host` and concatenates it into a shell command. The +// `next` package is required for the materialised package.json pin to +// survive `npm install --no-save`, but the harness builds its own mock +// req/res via js_shared::emit_next; we never go through the Next router. +// +// nyx-shape: next + +'use strict'; +// Touching `next` would also load React; the import is intentionally lazy +// and guarded so test runs without a network-fed install still parse. +try { require.resolve('next'); } catch (_e) {} + +const { execSync } = require('child_process'); + +module.exports = async function handler(req, res) { + const host = (req.query && req.query.host) || ''; + process.stdout.write('__NYX_SINK_HIT__\n'); + try { + const out = execSync('echo hello ' + host, { encoding: 'utf8', timeout: 5000 }); + res.status(200).send(out); + } catch (e) { + res.status(200).send((e.stdout || '') + (e.stderr || '')); + } +}; diff --git a/tests/javascript_fixtures.rs b/tests/javascript_fixtures.rs new file mode 100644 index 00000000..2d884fb9 --- /dev/null +++ b/tests/javascript_fixtures.rs @@ -0,0 +1,278 @@ +//! JavaScript per-shape acceptance tests (Phase 13 — Track B JS / TS vertical). +//! +//! For each [`nyx_scanner::dynamic::lang::js_shared::JsShape`] this suite +//! asserts: +//! +//! 1. The vuln fixture confirms (cmdi / xss oracle fires on the process +//! backend, sink probe lights up). +//! 2. The benign fixture does NOT confirm. +//! +//! Framework-bound shapes (Express / Koa / Next.js / browser-event under +//! jsdom) skip with an `eprintln!` when the package is unimportable in the +//! host's `node` interpreter — `prepare_node`'s `npm install --no-save` +//! would otherwise hang on a clean offline CI environment. In a developer +//! workstation with the framework installed globally / via the lockfile, +//! the test attempts the full pipeline. + +mod common; + +#[cfg(feature = "dynamic")] +mod javascript_fixture_tests { + use crate::common::fixture_harness::run_shape_fixture_lang; + use nyx_scanner::dynamic::spec::PayloadSlot; + use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + + fn node_available() -> bool { + std::process::Command::new("node") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn node_module_available(name: &'static str) -> bool { + std::process::Command::new("node") + .arg("-e") + .arg(format!("require.resolve('{name}')")) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn assert_confirmed(shape: &str, result: &VerifyResult) { + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "{shape}/vuln: expected Confirmed, got {:?} ({:?})", + result.status, + result.detail, + ); + } + + fn assert_not_confirmed(shape: &str, result: &VerifyResult) { + assert!( + matches!( + result.status, + VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive + ), + "{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})", + result.status, + result.detail, + ); + assert_ne!( + result.status, + VerifyStatus::Confirmed, + "{shape}/benign: must not confirm", + ); + } + + fn run( + shape: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + kind: EntryKind, + slot: PayloadSlot, + ) -> VerifyResult { + run_shape_fixture_lang( + Lang::JavaScript, + "javascript", + shape, + file, + func, + cap, + sink_line, + kind, + slot, + ) + } + + // ── commonjs_export ───────────────────────────────────────────────────── + + #[test] + fn commonjs_export_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "commonjs_export", "vuln.js", "runPing", Cap::CODE_EXEC, 11, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("commonjs_export", &r); + } + + #[test] + fn commonjs_export_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "commonjs_export", "benign.js", "runPing", Cap::CODE_EXEC, 11, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("commonjs_export", &r); + } + + // ── async_function ────────────────────────────────────────────────────── + + #[test] + fn async_function_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "async_function", "vuln.js", "runPing", Cap::CODE_EXEC, 15, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("async_function", &r); + } + + #[test] + fn async_function_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "async_function", "benign.js", "runPing", Cap::CODE_EXEC, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("async_function", &r); + } + + // ── esm_default ───────────────────────────────────────────────────────── + + #[test] + fn esm_default_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "esm_default", "vuln.js", "runPing", Cap::CODE_EXEC, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("esm_default", &r); + } + + #[test] + fn esm_default_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "esm_default", "benign.js", "runPing", Cap::CODE_EXEC, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("esm_default", &r); + } + + // ── express (framework-bound) ─────────────────────────────────────────── + + #[test] + fn express_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("express") { + eprintln!("SKIP: express not importable"); + return; + } + let r = run( + "express", "vuln.js", "ping", Cap::CODE_EXEC, 15, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_confirmed("express", &r); + } + + #[test] + fn express_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("express") { + eprintln!("SKIP: express not importable"); + return; + } + let r = run( + "express", "benign.js", "ping", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_not_confirmed("express", &r); + } + + // ── koa (framework-bound) ─────────────────────────────────────────────── + + #[test] + fn koa_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("koa") { + eprintln!("SKIP: koa not importable"); + return; + } + let r = run( + "koa", "vuln.js", "ping", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_confirmed("koa", &r); + } + + #[test] + fn koa_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("koa") { + eprintln!("SKIP: koa not importable"); + return; + } + let r = run( + "koa", "benign.js", "ping", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_not_confirmed("koa", &r); + } + + // ── next_route (framework-bound) ──────────────────────────────────────── + + #[test] + fn next_route_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("next") { + eprintln!("SKIP: next not importable"); + return; + } + let r = run( + "next_route", "vuln.js", "handler", Cap::CODE_EXEC, 17, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_confirmed("next_route", &r); + } + + #[test] + fn next_route_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("next") { + eprintln!("SKIP: next not importable"); + return; + } + let r = run( + "next_route", "benign.js", "handler", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_not_confirmed("next_route", &r); + } + + // ── browser_event (jsdom) ─────────────────────────────────────────────── + + #[test] + fn browser_event_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("jsdom") { + eprintln!("SKIP: jsdom not importable"); + return; + } + let r = run( + "browser_event", "vuln.js", "clickHandler", Cap::HTML_ESCAPE, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("browser_event", &r); + } + + #[test] + fn browser_event_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("jsdom") { + eprintln!("SKIP: jsdom not importable"); + return; + } + let r = run( + "browser_event", "benign.js", "clickHandler", Cap::HTML_ESCAPE, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("browser_event", &r); + } +} diff --git a/tests/typescript_fixtures.rs b/tests/typescript_fixtures.rs new file mode 100644 index 00000000..a6a34ba8 --- /dev/null +++ b/tests/typescript_fixtures.rs @@ -0,0 +1,270 @@ +//! TypeScript per-shape acceptance tests (Phase 13 — Track B JS / TS vertical). +//! +//! Mirrors `tests/javascript_fixtures.rs` against +//! `tests/dynamic_fixtures/typescript//`. TS fixtures use +//! ES-compatible syntax so the harness builder can stage them at +//! `workdir/entry.js` and run them through Node's CommonJS / ESM loader +//! without a separate `tsc` step. + +mod common; + +#[cfg(feature = "dynamic")] +mod typescript_fixture_tests { + use crate::common::fixture_harness::run_shape_fixture_lang; + use nyx_scanner::dynamic::spec::PayloadSlot; + use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + + fn node_available() -> bool { + std::process::Command::new("node") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn node_module_available(name: &'static str) -> bool { + std::process::Command::new("node") + .arg("-e") + .arg(format!("require.resolve('{name}')")) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn assert_confirmed(shape: &str, result: &VerifyResult) { + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "{shape}/vuln: expected Confirmed, got {:?} ({:?})", + result.status, + result.detail, + ); + } + + fn assert_not_confirmed(shape: &str, result: &VerifyResult) { + assert!( + matches!( + result.status, + VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive + ), + "{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})", + result.status, + result.detail, + ); + assert_ne!( + result.status, + VerifyStatus::Confirmed, + "{shape}/benign: must not confirm", + ); + } + + fn run( + shape: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + kind: EntryKind, + slot: PayloadSlot, + ) -> VerifyResult { + run_shape_fixture_lang( + Lang::TypeScript, + "typescript", + shape, + file, + func, + cap, + sink_line, + kind, + slot, + ) + } + + // ── commonjs_export ───────────────────────────────────────────────────── + + #[test] + fn commonjs_export_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "commonjs_export", "vuln.ts", "runPing", Cap::CODE_EXEC, 11, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("commonjs_export", &r); + } + + #[test] + fn commonjs_export_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "commonjs_export", "benign.ts", "runPing", Cap::CODE_EXEC, 11, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("commonjs_export", &r); + } + + // ── async_function ────────────────────────────────────────────────────── + + #[test] + fn async_function_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "async_function", "vuln.ts", "runPing", Cap::CODE_EXEC, 15, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("async_function", &r); + } + + #[test] + fn async_function_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "async_function", "benign.ts", "runPing", Cap::CODE_EXEC, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("async_function", &r); + } + + // ── esm_default ───────────────────────────────────────────────────────── + + #[test] + fn esm_default_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "esm_default", "vuln.ts", "runPing", Cap::CODE_EXEC, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("esm_default", &r); + } + + #[test] + fn esm_default_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + let r = run( + "esm_default", "benign.ts", "runPing", Cap::CODE_EXEC, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("esm_default", &r); + } + + // ── express ───────────────────────────────────────────────────────────── + + #[test] + fn express_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("express") { + eprintln!("SKIP: express not importable"); + return; + } + let r = run( + "express", "vuln.ts", "ping", Cap::CODE_EXEC, 15, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_confirmed("express", &r); + } + + #[test] + fn express_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("express") { + eprintln!("SKIP: express not importable"); + return; + } + let r = run( + "express", "benign.ts", "ping", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_not_confirmed("express", &r); + } + + // ── koa ───────────────────────────────────────────────────────────────── + + #[test] + fn koa_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("koa") { + eprintln!("SKIP: koa not importable"); + return; + } + let r = run( + "koa", "vuln.ts", "ping", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_confirmed("koa", &r); + } + + #[test] + fn koa_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("koa") { + eprintln!("SKIP: koa not importable"); + return; + } + let r = run( + "koa", "benign.ts", "ping", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_not_confirmed("koa", &r); + } + + // ── next_route ────────────────────────────────────────────────────────── + + #[test] + fn next_route_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("next") { + eprintln!("SKIP: next not importable"); + return; + } + let r = run( + "next_route", "vuln.ts", "handler", Cap::CODE_EXEC, 17, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_confirmed("next_route", &r); + } + + #[test] + fn next_route_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("next") { + eprintln!("SKIP: next not importable"); + return; + } + let r = run( + "next_route", "benign.ts", "handler", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("host".into()), + ); + assert_not_confirmed("next_route", &r); + } + + // ── browser_event (jsdom) ─────────────────────────────────────────────── + + #[test] + fn browser_event_vuln_is_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("jsdom") { + eprintln!("SKIP: jsdom not importable"); + return; + } + let r = run( + "browser_event", "vuln.ts", "clickHandler", Cap::HTML_ESCAPE, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("browser_event", &r); + } + + #[test] + fn browser_event_benign_not_confirmed() { + if !node_available() { eprintln!("SKIP: node not available"); return; } + if !node_module_available("jsdom") { + eprintln!("SKIP: jsdom not importable"); + return; + } + let r = run( + "browser_event", "benign.ts", "clickHandler", Cap::HTML_ESCAPE, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("browser_event", &r); + } +}