mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 13: Track B — JavaScript + TypeScript harness emitter shapes
This commit is contained in:
parent
96eb37500c
commit
34a5879459
51 changed files with 2556 additions and 440 deletions
|
|
@ -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<HarnessSource, UnsupportedReason> {
|
||||
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<String> = 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 = '<redacted-by-nyx-policy>';
|
||||
|
||||
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 '<bytes:' + a.length + '>';
|
||||
}
|
||||
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<HarnessSource, UnsupportedReason> {
|
||||
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::<Vec<_>>().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");
|
||||
|
|
|
|||
992
src/dynamic/lang/js_shared.rs
Normal file
992
src/dynamic/lang/js_shared.rs
Normal file
|
|
@ -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 = '<redacted-by-nyx-policy>';
|
||||
|
||||
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 '<bytes:' + a.length + '>';
|
||||
}
|
||||
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<String> = 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<HarnessSource, UnsupportedReason> {
|
||||
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('<!doctype html><html><body><div id="out"></div></body></html>', {{
|
||||
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::<Vec<_>>().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::<Vec<_>>().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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<HarnessSource, UnsupportedReason> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<shape>/<file>` 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/<lang_dir>/<shape>/<file>` 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 `<shape>/<file>` 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 `<shape>/<file>.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/<lang_dir>/<shape>/<file>` 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 `<shape>/<file>.golden_harness.<ext>` (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();
|
||||
|
|
|
|||
24
tests/dynamic_fixtures/javascript/async_function/benign.js
Normal file
24
tests/dynamic_fixtures/javascript/async_function/benign.js
Normal file
|
|
@ -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 };
|
||||
25
tests/dynamic_fixtures/javascript/async_function/vuln.js
Normal file
25
tests/dynamic_fixtures/javascript/async_function/vuln.js
Normal file
|
|
@ -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 };
|
||||
19
tests/dynamic_fixtures/javascript/browser_event/benign.js
Normal file
19
tests/dynamic_fixtures/javascript/browser_event/benign.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Phase 13 — browser-side event handler, benign control.
|
||||
//
|
||||
// Uses `textContent` so the payload's `<script>` tag is HTML-escaped before
|
||||
// serialisation; the XSS oracle marker cannot appear in stdout because
|
||||
// `<` becomes `<`.
|
||||
|
||||
'use strict';
|
||||
// nyx-shape: browser-event
|
||||
|
||||
function clickHandler(payload) {
|
||||
process.stdout.write('__NYX_SINK_HIT__\n');
|
||||
const el = document.getElementById('out');
|
||||
if (el) {
|
||||
el.textContent = String(payload);
|
||||
}
|
||||
return el ? el.textContent : '';
|
||||
}
|
||||
|
||||
module.exports = { clickHandler };
|
||||
12
tests/dynamic_fixtures/javascript/browser_event/package-lock.json
generated
Normal file
12
tests/dynamic_fixtures/javascript/browser_event/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "nyx-harness-jsdom",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nyx-harness-jsdom",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nyx-harness-jsdom",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"jsdom": "^24.1.1"
|
||||
}
|
||||
}
|
||||
21
tests/dynamic_fixtures/javascript/browser_event/vuln.js
Normal file
21
tests/dynamic_fixtures/javascript/browser_event/vuln.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Phase 13 — browser-side event handler, vulnerable.
|
||||
//
|
||||
// Harness spins up jsdom (js_shared::emit_browser_event), assigns
|
||||
// `globalThis.document`, then calls `clickHandler(payload)`. The handler
|
||||
// writes payload into innerHTML — the XSS oracle's `<script>NYX_XSS_CONFIRMED
|
||||
// </script>` 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 };
|
||||
20
tests/dynamic_fixtures/javascript/commonjs_export/benign.js
Normal file
20
tests/dynamic_fixtures/javascript/commonjs_export/benign.js
Normal file
|
|
@ -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 };
|
||||
21
tests/dynamic_fixtures/javascript/commonjs_export/vuln.js
Normal file
21
tests/dynamic_fixtures/javascript/commonjs_export/vuln.js
Normal file
|
|
@ -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 };
|
||||
18
tests/dynamic_fixtures/javascript/esm_default/benign.js
Normal file
18
tests/dynamic_fixtures/javascript/esm_default/benign.js
Normal file
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
22
tests/dynamic_fixtures/javascript/esm_default/vuln.js
Normal file
22
tests/dynamic_fixtures/javascript/esm_default/vuln.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
28
tests/dynamic_fixtures/javascript/express/benign.js
Normal file
28
tests/dynamic_fixtures/javascript/express/benign.js
Normal file
|
|
@ -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 };
|
||||
12
tests/dynamic_fixtures/javascript/express/package-lock.json
generated
Normal file
12
tests/dynamic_fixtures/javascript/express/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
tests/dynamic_fixtures/javascript/express/package.json
Normal file
8
tests/dynamic_fixtures/javascript/express/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nyx-harness-express",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"express": "^4.19.2"
|
||||
}
|
||||
}
|
||||
26
tests/dynamic_fixtures/javascript/express/vuln.js
Normal file
26
tests/dynamic_fixtures/javascript/express/vuln.js
Normal file
|
|
@ -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 };
|
||||
26
tests/dynamic_fixtures/javascript/koa/benign.js
Normal file
26
tests/dynamic_fixtures/javascript/koa/benign.js
Normal file
|
|
@ -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 };
|
||||
12
tests/dynamic_fixtures/javascript/koa/package-lock.json
generated
Normal file
12
tests/dynamic_fixtures/javascript/koa/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
tests/dynamic_fixtures/javascript/koa/package.json
Normal file
8
tests/dynamic_fixtures/javascript/koa/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nyx-harness-koa",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"koa": "^2.15.3"
|
||||
}
|
||||
}
|
||||
23
tests/dynamic_fixtures/javascript/koa/vuln.js
Normal file
23
tests/dynamic_fixtures/javascript/koa/vuln.js
Normal file
|
|
@ -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 };
|
||||
25
tests/dynamic_fixtures/javascript/next_route/benign.js
Normal file
25
tests/dynamic_fixtures/javascript/next_route/benign.js
Normal file
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
12
tests/dynamic_fixtures/javascript/next_route/package-lock.json
generated
Normal file
12
tests/dynamic_fixtures/javascript/next_route/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nyx-harness-next",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"next": "^14.2.5"
|
||||
}
|
||||
}
|
||||
26
tests/dynamic_fixtures/javascript/next_route/vuln.js
Normal file
26
tests/dynamic_fixtures/javascript/next_route/vuln.js
Normal file
|
|
@ -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 || ''));
|
||||
}
|
||||
};
|
||||
24
tests/dynamic_fixtures/typescript/async_function/benign.ts
Normal file
24
tests/dynamic_fixtures/typescript/async_function/benign.ts
Normal file
|
|
@ -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 };
|
||||
25
tests/dynamic_fixtures/typescript/async_function/vuln.ts
Normal file
25
tests/dynamic_fixtures/typescript/async_function/vuln.ts
Normal file
|
|
@ -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 };
|
||||
19
tests/dynamic_fixtures/typescript/browser_event/benign.ts
Normal file
19
tests/dynamic_fixtures/typescript/browser_event/benign.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Phase 13 — browser-side event handler, benign control.
|
||||
//
|
||||
// Uses `textContent` so the payload's `<script>` tag is HTML-escaped before
|
||||
// serialisation; the XSS oracle marker cannot appear in stdout because
|
||||
// `<` becomes `<`.
|
||||
|
||||
'use strict';
|
||||
// nyx-shape: browser-event
|
||||
|
||||
function clickHandler(payload) {
|
||||
process.stdout.write('__NYX_SINK_HIT__\n');
|
||||
const el = document.getElementById('out');
|
||||
if (el) {
|
||||
el.textContent = String(payload);
|
||||
}
|
||||
return el ? el.textContent : '';
|
||||
}
|
||||
|
||||
module.exports = { clickHandler };
|
||||
12
tests/dynamic_fixtures/typescript/browser_event/package-lock.json
generated
Normal file
12
tests/dynamic_fixtures/typescript/browser_event/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "nyx-harness-jsdom",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nyx-harness-jsdom",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nyx-harness-jsdom",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"jsdom": "^24.1.1"
|
||||
}
|
||||
}
|
||||
21
tests/dynamic_fixtures/typescript/browser_event/vuln.ts
Normal file
21
tests/dynamic_fixtures/typescript/browser_event/vuln.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Phase 13 — browser-side event handler, vulnerable.
|
||||
//
|
||||
// Harness spins up jsdom (js_shared::emit_browser_event), assigns
|
||||
// `globalThis.document`, then calls `clickHandler(payload)`. The handler
|
||||
// writes payload into innerHTML — the XSS oracle's `<script>NYX_XSS_CONFIRMED
|
||||
// </script>` 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 };
|
||||
20
tests/dynamic_fixtures/typescript/commonjs_export/benign.ts
Normal file
20
tests/dynamic_fixtures/typescript/commonjs_export/benign.ts
Normal file
|
|
@ -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 };
|
||||
21
tests/dynamic_fixtures/typescript/commonjs_export/vuln.ts
Normal file
21
tests/dynamic_fixtures/typescript/commonjs_export/vuln.ts
Normal file
|
|
@ -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 };
|
||||
18
tests/dynamic_fixtures/typescript/esm_default/benign.ts
Normal file
18
tests/dynamic_fixtures/typescript/esm_default/benign.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
22
tests/dynamic_fixtures/typescript/esm_default/vuln.ts
Normal file
22
tests/dynamic_fixtures/typescript/esm_default/vuln.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
28
tests/dynamic_fixtures/typescript/express/benign.ts
Normal file
28
tests/dynamic_fixtures/typescript/express/benign.ts
Normal file
|
|
@ -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 };
|
||||
12
tests/dynamic_fixtures/typescript/express/package-lock.json
generated
Normal file
12
tests/dynamic_fixtures/typescript/express/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
tests/dynamic_fixtures/typescript/express/package.json
Normal file
8
tests/dynamic_fixtures/typescript/express/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nyx-harness-express",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"express": "^4.19.2"
|
||||
}
|
||||
}
|
||||
26
tests/dynamic_fixtures/typescript/express/vuln.ts
Normal file
26
tests/dynamic_fixtures/typescript/express/vuln.ts
Normal file
|
|
@ -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 };
|
||||
26
tests/dynamic_fixtures/typescript/koa/benign.ts
Normal file
26
tests/dynamic_fixtures/typescript/koa/benign.ts
Normal file
|
|
@ -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 };
|
||||
12
tests/dynamic_fixtures/typescript/koa/package-lock.json
generated
Normal file
12
tests/dynamic_fixtures/typescript/koa/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
tests/dynamic_fixtures/typescript/koa/package.json
Normal file
8
tests/dynamic_fixtures/typescript/koa/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nyx-harness-koa",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"koa": "^2.15.3"
|
||||
}
|
||||
}
|
||||
23
tests/dynamic_fixtures/typescript/koa/vuln.ts
Normal file
23
tests/dynamic_fixtures/typescript/koa/vuln.ts
Normal file
|
|
@ -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 };
|
||||
25
tests/dynamic_fixtures/typescript/next_route/benign.ts
Normal file
25
tests/dynamic_fixtures/typescript/next_route/benign.ts
Normal file
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
12
tests/dynamic_fixtures/typescript/next_route/package-lock.json
generated
Normal file
12
tests/dynamic_fixtures/typescript/next_route/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nyx-harness-next",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"next": "^14.2.5"
|
||||
}
|
||||
}
|
||||
26
tests/dynamic_fixtures/typescript/next_route/vuln.ts
Normal file
26
tests/dynamic_fixtures/typescript/next_route/vuln.ts
Normal file
|
|
@ -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 || ''));
|
||||
}
|
||||
};
|
||||
278
tests/javascript_fixtures.rs
Normal file
278
tests/javascript_fixtures.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
270
tests/typescript_fixtures.rs
Normal file
270
tests/typescript_fixtures.rs
Normal file
|
|
@ -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/<shape>/`. 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue