From c051f5864737b4ae448595d3a236b65b468e0724 Mon Sep 17 00:00:00 2001 From: pitboss Date: Sat, 16 May 2026 11:53:15 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0021 (20260516T052512Z-20f8) --- src/dynamic/lang/c.rs | 95 +++- src/dynamic/lang/cpp.rs | 73 ++- src/dynamic/sandbox/process_macos.rs | 68 ++- .../stubs_e2e/c/http/vuln/main.c.fragment | 14 + .../stubs_e2e/c/sql/vuln/main.c.fragment | 16 + .../stubs_e2e/cpp/http/vuln/main.cpp.fragment | 9 + .../stubs_e2e/cpp/sql/vuln/main.cpp.fragment | 13 + tests/stubs_e2e_per_lang.rs | 499 ++++++++++++++++++ 8 files changed, 778 insertions(+), 9 deletions(-) create mode 100644 tests/dynamic_fixtures/stubs_e2e/c/http/vuln/main.c.fragment create mode 100644 tests/dynamic_fixtures/stubs_e2e/c/sql/vuln/main.c.fragment create mode 100644 tests/dynamic_fixtures/stubs_e2e/cpp/http/vuln/main.cpp.fragment create mode 100644 tests/dynamic_fixtures/stubs_e2e/cpp/sql/vuln/main.cpp.fragment diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index da1f0864..4570acbb 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -108,7 +108,11 @@ fn read_entry_source(entry_file: &str) -> String { /// Track C.1). Variadic over `const char *` args; hand-rolled JSON keeps /// the only dep on libc / stdio. pub fn probe_shim() -> &'static str { - r#" + // The body holds literal `"# key: value\n"` log-line formats for the + // Phase 10 stub recorders, so the surrounding raw string uses + // `r##"..."##` to keep `"#` substrings from terminating it early + // (same trick the Rust / Java / Go / Ruby siblings use). + r##" /* ── __nyx_probe shim (Phase 06 — Track C.1, Phase 08 — Track C.4 + C.5) ── */ #include #include @@ -290,7 +294,67 @@ static void __nyx_install_crash_guard(const char *sink_callee) { sigaction(sigs[i], &sa, NULL); } } -"# + +/* Phase 10 (Track D.3) stub recorder helpers. When the verifier spawns a + * SqlStub it publishes the queries-log path through NYX_SQL_LOG; a sink + * call site that wants the host-side stub to see its query appends one + * record-per-call. Detail kv pairs use parallel arrays so the helper is + * variadic in arity without depending on stdarg-with-typed args. The + * helper is a no-op when the env var is unset so the same source still + * runs under harness modes that did not spawn a stub. */ +static void __nyx_stub_sql_record(const char *query, + const char **detail_keys, + const char **detail_vals, + int detail_count) { + const char *p = getenv("NYX_SQL_LOG"); + if (!p || *p == '\0') return; + FILE *f = fopen(p, "a"); + if (!f) return; + for (int i = 0; i < detail_count; ++i) { + if (detail_keys && detail_vals && detail_keys[i] && detail_vals[i]) { + fprintf(f, "# %s: %s\n", detail_keys[i], detail_vals[i]); + } + } + if (query) { + size_t qlen = strlen(query); + fputs(query, f); + if (qlen == 0 || query[qlen - 1] != '\n') { + fputc('\n', f); + } + } + fclose(f); +} + +/* Phase 10 (Track D.3) HTTP recording helper. When the verifier spawns an + * HttpStub it publishes the side-channel log path through NYX_HTTP_LOG; a + * sink call site whose outbound request never reaches the on-the-wire + * listener (DNS-mocked, network-isolated sandbox, pre-flight check) can + * call this helper to surface the attempted call. Format matches the SQL + * helper so the host-side merger parses both streams identically. */ +static void __nyx_stub_http_record(const char *method, + const char *url, + const char *body, + const char **detail_keys, + const char **detail_vals, + int detail_count) { + const char *p = getenv("NYX_HTTP_LOG"); + if (!p || *p == '\0') return; + FILE *f = fopen(p, "a"); + if (!f) return; + if (method) fprintf(f, "# method: %s\n", method); + if (url) fprintf(f, "# url: %s\n", url); + if (body) fprintf(f, "# body: %s\n", body); + for (int i = 0; i < detail_count; ++i) { + if (detail_keys && detail_vals && detail_keys[i] && detail_vals[i]) { + fprintf(f, "# %s: %s\n", detail_keys[i], detail_vals[i]); + } + } + if (method && url) { + fprintf(f, "%s %s\n", method, url); + } + fclose(f); +} +"## } impl LangEmitter for CEmitter { @@ -730,6 +794,33 @@ mod tests { ); } + #[test] + fn probe_shim_publishes_stub_sql_and_http_recorders() { + // Phase 10 (Track D.3): the C probe shim ships the manual-record + // stub helpers so a C harness can surface attempted DB / outbound + // calls to the host-side SqlStub / HttpStub through their + // NYX_SQL_LOG / NYX_HTTP_LOG side channels. Helpers must be + // declared before `__nyx_install_crash_guard` so a sink-rewrite + // pass can reference them from anywhere in the entry source. + let shim = probe_shim(); + assert!( + shim.contains("static void __nyx_stub_sql_record("), + "C probe shim must define __nyx_stub_sql_record", + ); + assert!( + shim.contains("static void __nyx_stub_http_record("), + "C probe shim must define __nyx_stub_http_record", + ); + assert!( + shim.contains("getenv(\"NYX_SQL_LOG\")"), + "SQL recorder must read NYX_SQL_LOG so the SqlStub side channel picks it up", + ); + assert!( + shim.contains("getenv(\"NYX_HTTP_LOG\")"), + "HTTP recorder must read NYX_HTTP_LOG so the HttpStub side channel picks it up", + ); + } + #[test] fn emit_install_crash_guard_targets_renamed_main_entry() { // Real-world Track B CLI vuln: spec.entry_name == "main" → the entry diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index ea780408..8e9cc8f6 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -88,7 +88,11 @@ fn read_entry_source(entry_file: &str) -> String { /// (Phase 06 — Track C.1). Uses `` + variadic templates; the /// JSON-emit format matches [`crate::dynamic::probe::SinkProbe`]. pub fn probe_shim() -> &'static str { - r#" + // The body holds literal `"# key: value\n"` log-line formats for the + // Phase 10 stub recorders, so the surrounding raw string uses + // `r##"..."##` to keep `"#` substrings from terminating it early + // (same trick the Rust / Java / Go / Ruby siblings use). + r##" /* ── __nyx_probe shim (Phase 06 — Track C.1, Phase 08 — Track C.4 + C.5) ── */ #include #include @@ -263,7 +267,47 @@ inline void __nyx_install_crash_guard(const char *sink_callee) { sigaction(sig, &sa, nullptr); } } -"# + +/* Phase 10 (Track D.3) stub recorder helpers. See the C-side commentary + * for the contract — these are the same helpers expressed in C++ idiom + * (std::ofstream + std::initializer_list of {key, value} pairs). Both + * are no-ops when the relevant NYX_*_LOG env var is unset. */ +inline void __nyx_stub_sql_record( + const std::string &query, + std::initializer_list> detail = {}) { + const char *p = std::getenv("NYX_SQL_LOG"); + if (!p || *p == '\0') return; + std::ofstream f(p, std::ios::app); + if (!f.is_open()) return; + for (const auto &kv : detail) { + f << "# " << kv.first << ": " << kv.second << "\n"; + } + f << query; + if (query.empty() || query.back() != '\n') { + f << "\n"; + } +} + +inline void __nyx_stub_http_record( + const std::string &method, + const std::string &url, + const std::string &body = std::string(), + std::initializer_list> detail = {}) { + const char *p = std::getenv("NYX_HTTP_LOG"); + if (!p || *p == '\0') return; + std::ofstream f(p, std::ios::app); + if (!f.is_open()) return; + f << "# method: " << method << "\n"; + f << "# url: " << url << "\n"; + if (!body.empty()) { + f << "# body: " << body << "\n"; + } + for (const auto &kv : detail) { + f << "# " << kv.first << ": " << kv.second << "\n"; + } + f << method << " " << url << "\n"; +} +"## } impl LangEmitter for CppEmitter { @@ -649,6 +693,31 @@ mod tests { ); } + #[test] + fn probe_shim_publishes_stub_sql_and_http_recorders() { + // Phase 10 (Track D.3): the C++ probe shim ships the manual-record + // stub helpers so a C++ harness can surface attempted DB / outbound + // calls to the host-side SqlStub / HttpStub through their + // NYX_SQL_LOG / NYX_HTTP_LOG side channels. + let shim = probe_shim(); + assert!( + shim.contains("inline void __nyx_stub_sql_record("), + "C++ probe shim must define __nyx_stub_sql_record", + ); + assert!( + shim.contains("inline void __nyx_stub_http_record("), + "C++ probe shim must define __nyx_stub_http_record", + ); + assert!( + shim.contains("std::getenv(\"NYX_SQL_LOG\")"), + "SQL recorder must read NYX_SQL_LOG so the SqlStub side channel picks it up", + ); + assert!( + shim.contains("std::getenv(\"NYX_HTTP_LOG\")"), + "HTTP recorder must read NYX_HTTP_LOG so the HttpStub side channel picks it up", + ); + } + #[test] fn emit_cmake_in_extra_files() { let spec = make_spec(PayloadSlot::Param(0)); diff --git a/src/dynamic/sandbox/process_macos.rs b/src/dynamic/sandbox/process_macos.rs index c5621402..4a708bfd 100644 --- a/src/dynamic/sandbox/process_macos.rs +++ b/src/dynamic/sandbox/process_macos.rs @@ -128,19 +128,35 @@ const PROFILE_SOURCES: &[(&str, &str)] = &[ ]; /// Cap → profile-name dispatch. The most restrictive matching profile -/// wins: `FILE_IO` outranks `SSRF` outranks `CODE_EXEC` outranks -/// `DESERIALIZE`. A cap bit with no matching profile falls back to the -/// `base` profile. +/// wins: filesystem caps outrank network caps outrank CODE_EXEC outranks +/// DESERIALIZE. Filesystem-shaped caps (`FILE_IO`, `SQL_QUERY` — DBs are +/// files in WORKDIR) map to `path_traversal`; outbound-network-shaped caps +/// (`SSRF`, `HEADER_INJECTION`, `OPEN_REDIRECT`, `UNVALIDATED_REDIRECT`, +/// `LDAP_INJECTION`, `XPATH_INJECTION`) map to `ssrf` since they share the +/// "outbound allowed; host secrets denied" shape. Caps with no shared +/// shape (CRYPTO, AUTH, RACE, MEMORY_SAFETY, XSS, XXE) fall back to `base` +/// — XXE in particular would want a network-deny profile for entity +/// resolution, which the bundled `.sb` set does not yet ship. pub fn profile_for_caps(caps: u32) -> &'static str { // Mirror the bit positions declared in `src/labels/mod.rs`. const FILE_IO: u32 = 1 << 5; + const SQL_QUERY: u32 = 1 << 7; const DESERIALIZE: u32 = 1 << 8; const SSRF: u32 = 1 << 9; const CODE_EXEC: u32 = 1 << 10; + const LDAP_INJECTION: u32 = 1 << 14; + const XPATH_INJECTION: u32 = 1 << 15; + const HEADER_INJECTION: u32 = 1 << 16; + const OPEN_REDIRECT: u32 = 1 << 17; + const UNVALIDATED_REDIRECT: u32 = 1 << 18; - if caps & FILE_IO != 0 { + const FS_SHAPED: u32 = FILE_IO | SQL_QUERY; + const NET_SHAPED: u32 = + SSRF | LDAP_INJECTION | XPATH_INJECTION | HEADER_INJECTION | OPEN_REDIRECT | UNVALIDATED_REDIRECT; + + if caps & FS_SHAPED != 0 { "path_traversal" - } else if caps & SSRF != 0 { + } else if caps & NET_SHAPED != 0 { "ssrf" } else if caps & CODE_EXEC != 0 { "cmdi" @@ -323,6 +339,48 @@ mod tests { assert_eq!(profile_for_caps(0), "base"); } + #[test] + fn profile_for_caps_routes_filesystem_shaped_caps_to_path_traversal() { + // SQL_QUERY shares the `file-write into WORKDIR / file-read of + // host secrets denied` shape with FILE_IO (SQLite DBs live as + // files in the workdir), so it routes to the same profile. + const SQL_QUERY: u32 = 1 << 7; + const CODE_EXEC: u32 = 1 << 10; + assert_eq!(profile_for_caps(SQL_QUERY), "path_traversal"); + // Filesystem shape outranks the lesser-restrictive cmdi profile. + assert_eq!(profile_for_caps(SQL_QUERY | CODE_EXEC), "path_traversal"); + } + + #[test] + fn profile_for_caps_routes_outbound_network_caps_to_ssrf() { + // Outbound HTTP request sinks (HEADER_INJECTION / OPEN_REDIRECT / + // UNVALIDATED_REDIRECT) and other network-traffic injection caps + // (LDAP_INJECTION / XPATH_INJECTION) all share the SSRF shape: + // outbound allowed, host-secret reads denied. + const LDAP_INJECTION: u32 = 1 << 14; + const XPATH_INJECTION: u32 = 1 << 15; + const HEADER_INJECTION: u32 = 1 << 16; + const OPEN_REDIRECT: u32 = 1 << 17; + const UNVALIDATED_REDIRECT: u32 = 1 << 18; + assert_eq!(profile_for_caps(LDAP_INJECTION), "ssrf"); + assert_eq!(profile_for_caps(XPATH_INJECTION), "ssrf"); + assert_eq!(profile_for_caps(HEADER_INJECTION), "ssrf"); + assert_eq!(profile_for_caps(OPEN_REDIRECT), "ssrf"); + assert_eq!(profile_for_caps(UNVALIDATED_REDIRECT), "ssrf"); + } + + #[test] + fn profile_for_caps_falls_back_to_base_for_unmapped_caps() { + // CRYPTO / AUTH / RACE / MEMORY_SAFETY / XSS / XXE do not yet + // have a cap-specific .sb profile. XXE in particular would want + // a network-deny profile (entity resolution), but the bundled .sb + // set does not ship one — track in deferred.md. + const CRYPTO: u32 = 1 << 11; + const XXE: u32 = 1 << 19; + assert_eq!(profile_for_caps(CRYPTO), "base"); + assert_eq!(profile_for_caps(XXE), "base"); + } + #[test] fn profile_path_materialises_baked_source() { let path = profile_path("base").expect("base profile"); diff --git a/tests/dynamic_fixtures/stubs_e2e/c/http/vuln/main.c.fragment b/tests/dynamic_fixtures/stubs_e2e/c/http/vuln/main.c.fragment new file mode 100644 index 00000000..347ab843 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/c/http/vuln/main.c.fragment @@ -0,0 +1,14 @@ +/* Phase 10 (Track D.3) — C HTTP recorder body-only fragment. + * + * Wrapped at test time by `wrap_c_fragment(body, shim)`. The + * fixture surfaces an SSRF attempt at the IMDS metadata endpoint + * through the shim recorder, so the host-side HttpStub captures + * the attempted outbound call without the harness opening a real + * socket. Mirrors the per-lang HTTP recording siblings. + */ +const char *method = "GET"; +const char *url = "http://169.254.169.254/latest/meta-data/"; +const char *body = NULL; +const char *detail_keys[] = { "driver" }; +const char *detail_vals[] = { "manual" }; +__nyx_stub_http_record(method, url, body, detail_keys, detail_vals, 1); diff --git a/tests/dynamic_fixtures/stubs_e2e/c/sql/vuln/main.c.fragment b/tests/dynamic_fixtures/stubs_e2e/c/sql/vuln/main.c.fragment new file mode 100644 index 00000000..6ef00dae --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/c/sql/vuln/main.c.fragment @@ -0,0 +1,16 @@ +/* Phase 10 (Track D.3) — C SQL recorder body-only fragment. + * + * Wrapped at test time by `wrap_c_fragment(body, shim)` in + * `tests/stubs_e2e_per_lang.rs`: the wrapper prepends the C probe + * shim (which carries `__nyx_stub_sql_record`) and a `main()` shell + * so `cc .c -o && ./` builds the program in place. + * + * The fixture surfaces the attempted tautology query through the + * shim recorder so the host-side SqlStub captures it as + * `driver = "manual"` — no libsqlite3-dev / sqlite3.h dependency on + * the dynamic CI matrix. + */ +const char *query = "SELECT 1 WHERE 'a' = 'a' OR 1=1 --"; +const char *detail_keys[] = { "driver" }; +const char *detail_vals[] = { "manual" }; +__nyx_stub_sql_record(query, detail_keys, detail_vals, 1); diff --git a/tests/dynamic_fixtures/stubs_e2e/cpp/http/vuln/main.cpp.fragment b/tests/dynamic_fixtures/stubs_e2e/cpp/http/vuln/main.cpp.fragment new file mode 100644 index 00000000..e485fc6f --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/cpp/http/vuln/main.cpp.fragment @@ -0,0 +1,9 @@ +// Phase 10 (Track D.3) — C++ HTTP recorder body-only fragment. +// +// Wrapped at test time by `wrap_cpp_fragment(body, shim)`. Records +// an SSRF attempt at the IMDS metadata endpoint through the shim +// recorder; the host-side HttpStub captures the attempted outbound +// call without the harness opening a real socket. +std::string method = "GET"; +std::string url = "http://169.254.169.254/latest/meta-data/"; +__nyx_stub_http_record(method, url, std::string(), { {"driver", "manual"} }); diff --git a/tests/dynamic_fixtures/stubs_e2e/cpp/sql/vuln/main.cpp.fragment b/tests/dynamic_fixtures/stubs_e2e/cpp/sql/vuln/main.cpp.fragment new file mode 100644 index 00000000..6a0145f8 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/cpp/sql/vuln/main.cpp.fragment @@ -0,0 +1,13 @@ +// Phase 10 (Track D.3) — C++ SQL recorder body-only fragment. +// +// Wrapped at test time by `wrap_cpp_fragment(body, shim)` in +// `tests/stubs_e2e_per_lang.rs`: the wrapper prepends the C++ +// probe shim (which carries `__nyx_stub_sql_record`) and a +// `int main()` shell so `c++ .cpp -o && ./` +// builds the program in place. +// +// Records the attempted tautology query through the shim recorder +// so the host-side SqlStub captures it as `driver = "manual"` — +// no libsqlite3 / sqlite3pp dependency on the dynamic CI matrix. +std::string query = "SELECT 1 WHERE 'a' = 'a' OR 1=1 --"; +__nyx_stub_sql_record(query, { {"driver", "manual"} }); diff --git a/tests/stubs_e2e_per_lang.rs b/tests/stubs_e2e_per_lang.rs index 052b5e83..7bfc1db6 100644 --- a/tests/stubs_e2e_per_lang.rs +++ b/tests/stubs_e2e_per_lang.rs @@ -20,6 +20,8 @@ #![cfg(feature = "dynamic")] +use nyx_scanner::dynamic::lang::c::probe_shim as c_probe_shim; +use nyx_scanner::dynamic::lang::cpp::probe_shim as cpp_probe_shim; use nyx_scanner::dynamic::lang::go::probe_shim as go_probe_shim; use nyx_scanner::dynamic::lang::java::probe_shim as java_probe_shim; use nyx_scanner::dynamic::lang::javascript::probe_shim as node_probe_shim; @@ -80,6 +82,34 @@ fn cargo_available() -> bool { .unwrap_or(false) } +fn cc_available() -> bool { + // Honours the same NYX_CC_BIN override used by the Phase 29 + // CommandAvailableEnvOverride prereq variant in the C fixture suite. + let bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned()); + Command::new(bin) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn cxx_available() -> bool { + let bin = std::env::var("NYX_CXX_BIN").unwrap_or_else(|_| "c++".to_owned()); + Command::new(bin) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn cc_bin() -> String { + std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned()) +} + +fn cxx_bin() -> String { + std::env::var("NYX_CXX_BIN").unwrap_or_else(|_| "c++".to_owned()) +} + fn java_available() -> bool { // The Java shim helpers use `java MainSource.java` single-file // source-mode (JEP 330, JDK 11+) so only the `java` runtime is @@ -163,6 +193,38 @@ fn wrap_rust_fragment(body: &str, shim: &str) -> String { /// `CARGO_TARGET_DIR` when nextest runs the Rust stub tests in /// parallel (every test still benefits from the cached `libc` build, /// only the final `nyx-stub-driver-` link is per-test). +/// Wrap a body-only C fragment in a complete translation unit: prepend +/// the C probe shim (which carries `__nyx_stub_sql_record` / +/// `__nyx_stub_http_record`) at file scope, then wrap the fragment as +/// the body of `int main(void)`. The shim's own `#include` directives +/// pull in stdio / string / signal headers, so the fragment can use +/// `NULL`, string literals, and the recorder helpers without any +/// additional preamble. +fn wrap_c_fragment(body: &str, shim: &str) -> String { + format!( + "{shim}\n\ + int main(void) {{\n\ + {body}\n\ + return 0;\n\ + }}\n" + ) +} + +/// Wrap a body-only C++ fragment in a complete translation unit: prepend +/// the C++ probe shim and wrap the fragment as the body of `int main()`. +/// The shim's own `#include` block covers `` / `` / +/// `` so initializer-list `{key, value}` literals + `std::string` +/// in the fragment compile cleanly. +fn wrap_cpp_fragment(body: &str, shim: &str) -> String { + format!( + "{shim}\n\ + int main() {{\n\ + {body}\n\ + return 0;\n\ + }}\n" + ) +} + fn rust_stub_cargo_toml(slug: &str) -> String { format!( "[package]\n\ @@ -1668,3 +1730,440 @@ fn rust_sql_shim_recorder_is_noop_without_log_env() { events.len() ); } + +// ── C ──────────────────────────────────────────────────────────────────────── + +/// Build + run a wrapped C source: writes the source to +/// `/.c`, drives `cc` to compile to `/`, +/// runs the binary with the supplied env block. Returns the binary's +/// own `Output` so tests assert on exit code + stdout/stderr. Build +/// failures surface as a panic with the compiler's stderr. +fn build_and_run_c( + workdir: &std::path::Path, + slug: &str, + source: &str, + extra_env: &[(&str, &str)], + suppress_env: &[&str], +) -> std::process::Output { + let src_path = workdir.join(format!("{slug}.c")); + let bin_path = workdir.join(slug); + std::fs::write(&src_path, source).expect("write C source"); + + let build = Command::new(cc_bin()) + .arg(&src_path) + .arg("-o") + .arg(&bin_path) + .output() + .expect("invoke cc"); + assert!( + build.status.success(), + "cc must build the wrapped C source; stderr = {}", + String::from_utf8_lossy(&build.stderr) + ); + + let mut cmd = Command::new(&bin_path); + for (k, v) in extra_env { + cmd.env(k, v); + } + for k in suppress_env { + cmd.env_remove(*k); + } + cmd.output().expect("run C driver") +} + +fn build_and_run_cpp( + workdir: &std::path::Path, + slug: &str, + source: &str, + extra_env: &[(&str, &str)], + suppress_env: &[&str], +) -> std::process::Output { + let src_path = workdir.join(format!("{slug}.cpp")); + let bin_path = workdir.join(slug); + std::fs::write(&src_path, source).expect("write C++ source"); + + let build = Command::new(cxx_bin()) + .arg(&src_path) + .arg("-o") + .arg(&bin_path) + .output() + .expect("invoke c++"); + assert!( + build.status.success(), + "c++ must build the wrapped C++ source; stderr = {}", + String::from_utf8_lossy(&build.stderr) + ); + + let mut cmd = Command::new(&bin_path); + for (k, v) in extra_env { + cmd.env(k, v); + } + for k in suppress_env { + cmd.env_remove(*k); + } + cmd.output().expect("run C++ driver") +} + +#[test] +fn c_sql_stub_captures_tautology_query_via_shim_recorder() { + // Phase 10 (Track D.3) SQL recording: C leg of the side-channel + // `__nyx_stub_sql_record` helper. Mirrors the Rust SQL test — + // the C fragment never opens a live SQLite handle (no sqlite3.h + // dependency on the dynamic CI matrix) so it surfaces the + // attempted tautology query through the shim recorder as + // `driver = "manual"`. + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("SqlStub must publish a recording endpoint"); + + let fragment = std::fs::read_to_string(fixture_path("c/sql/vuln/main.c.fragment")) + .expect("read c sql fragment"); + let source = wrap_c_fragment(&fragment, c_probe_shim()); + + let output = build_and_run_c( + workdir.path(), + "driver_c_sql", + &source, + &[ + ("NYX_SQL_ENDPOINT", endpoint.as_str()), + (recording.0, recording.1.as_str()), + ], + &[], + ); + assert!( + output.status.success(), + "driver must exit 0; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + !events.is_empty(), + "SqlStub must capture at least one event after the C shim recorder fires" + ); + let tautology = events + .iter() + .find(|e| e.summary.contains("OR 1=1")) + .expect("recorded query must contain the tautology marker"); + assert_eq!( + tautology.detail.get("driver").map(String::as_str), + Some("manual"), + "parallel-array detail passed to __nyx_stub_sql_record must surface as event detail" + ); +} + +#[test] +fn c_sql_shim_recorder_is_noop_without_log_env() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let fragment = std::fs::read_to_string(fixture_path("c/sql/vuln/main.c.fragment")) + .expect("read c sql fragment"); + let source = wrap_c_fragment(&fragment, c_probe_shim()); + + let output = build_and_run_c( + workdir.path(), + "driver_c_sql_no_log", + &source, + &[("NYX_SQL_ENDPOINT", endpoint.as_str())], + &["NYX_SQL_LOG"], + ); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_SQL_LOG; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + events.is_empty(), + "no events expected when the recording env var is unset, got {} entries", + events.len() + ); +} + +#[test] +fn c_http_stub_captures_attempted_outbound_via_shim_recorder() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("HttpStub must publish a recording endpoint"); + + let fragment = std::fs::read_to_string(fixture_path("c/http/vuln/main.c.fragment")) + .expect("read c http fragment"); + let source = wrap_c_fragment(&fragment, c_probe_shim()); + + let output = build_and_run_c( + workdir.path(), + "driver_c_http", + &source, + &[ + ("NYX_HTTP_ENDPOINT", endpoint.as_str()), + (recording.0, recording.1.as_str()), + ], + &[], + ); + assert!( + output.status.success(), + "driver must exit 0; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + !events.is_empty(), + "HttpStub must capture at least one event after the C shim recorder fires" + ); + let imds = events + .iter() + .find(|e| e.summary.contains("169.254.169.254")) + .expect("recorded URL must contain the IMDS metadata host"); + assert_eq!( + imds.detail.get("method").map(String::as_str), + Some("GET"), + "method line must surface in the recorded event detail" + ); +} + +#[test] +fn c_http_shim_recorder_is_noop_without_log_env() { + if !cc_available() { + eprintln!("SKIP: cc not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); + + let endpoint = stub.endpoint(); + let fragment = std::fs::read_to_string(fixture_path("c/http/vuln/main.c.fragment")) + .expect("read c http fragment"); + let source = wrap_c_fragment(&fragment, c_probe_shim()); + + let output = build_and_run_c( + workdir.path(), + "driver_c_http_no_log", + &source, + &[("NYX_HTTP_ENDPOINT", endpoint.as_str())], + &["NYX_HTTP_LOG"], + ); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_HTTP_LOG; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + events.is_empty(), + "no events expected when the recording env var is unset, got {} entries", + events.len() + ); +} + +// ── C++ ────────────────────────────────────────────────────────────────────── + +#[test] +fn cpp_sql_stub_captures_tautology_query_via_shim_recorder() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("SqlStub must publish a recording endpoint"); + + let fragment = std::fs::read_to_string(fixture_path("cpp/sql/vuln/main.cpp.fragment")) + .expect("read cpp sql fragment"); + let source = wrap_cpp_fragment(&fragment, cpp_probe_shim()); + + let output = build_and_run_cpp( + workdir.path(), + "driver_cpp_sql", + &source, + &[ + ("NYX_SQL_ENDPOINT", endpoint.as_str()), + (recording.0, recording.1.as_str()), + ], + &[], + ); + assert!( + output.status.success(), + "driver must exit 0; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + !events.is_empty(), + "SqlStub must capture at least one event after the C++ shim recorder fires" + ); + let tautology = events + .iter() + .find(|e| e.summary.contains("OR 1=1")) + .expect("recorded query must contain the tautology marker"); + assert_eq!( + tautology.detail.get("driver").map(String::as_str), + Some("manual"), + "initializer-list detail passed to __nyx_stub_sql_record must surface as event detail" + ); +} + +#[test] +fn cpp_sql_shim_recorder_is_noop_without_log_env() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let fragment = std::fs::read_to_string(fixture_path("cpp/sql/vuln/main.cpp.fragment")) + .expect("read cpp sql fragment"); + let source = wrap_cpp_fragment(&fragment, cpp_probe_shim()); + + let output = build_and_run_cpp( + workdir.path(), + "driver_cpp_sql_no_log", + &source, + &[("NYX_SQL_ENDPOINT", endpoint.as_str())], + &["NYX_SQL_LOG"], + ); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_SQL_LOG; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + events.is_empty(), + "no events expected when the recording env var is unset, got {} entries", + events.len() + ); +} + +#[test] +fn cpp_http_stub_captures_attempted_outbound_via_shim_recorder() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("HttpStub must publish a recording endpoint"); + + let fragment = std::fs::read_to_string(fixture_path("cpp/http/vuln/main.cpp.fragment")) + .expect("read cpp http fragment"); + let source = wrap_cpp_fragment(&fragment, cpp_probe_shim()); + + let output = build_and_run_cpp( + workdir.path(), + "driver_cpp_http", + &source, + &[ + ("NYX_HTTP_ENDPOINT", endpoint.as_str()), + (recording.0, recording.1.as_str()), + ], + &[], + ); + assert!( + output.status.success(), + "driver must exit 0; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + !events.is_empty(), + "HttpStub must capture at least one event after the C++ shim recorder fires" + ); + let imds = events + .iter() + .find(|e| e.summary.contains("169.254.169.254")) + .expect("recorded URL must contain the IMDS metadata host"); + assert_eq!( + imds.detail.get("method").map(String::as_str), + Some("GET"), + "method line must surface in the recorded event detail" + ); +} + +#[test] +fn cpp_http_shim_recorder_is_noop_without_log_env() { + if !cxx_available() { + eprintln!("SKIP: c++ not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); + + let endpoint = stub.endpoint(); + let fragment = std::fs::read_to_string(fixture_path("cpp/http/vuln/main.cpp.fragment")) + .expect("read cpp http fragment"); + let source = wrap_cpp_fragment(&fragment, cpp_probe_shim()); + + let output = build_and_run_cpp( + workdir.path(), + "driver_cpp_http_no_log", + &source, + &[("NYX_HTTP_ENDPOINT", endpoint.as_str())], + &["NYX_HTTP_LOG"], + ); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_HTTP_LOG; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + events.is_empty(), + "no events expected when the recording env var is unset, got {} entries", + events.len() + ); +}