From 9bf085ee481cc72e0fa41579a5bb48bd44920e5a Mon Sep 17 00:00:00 2001 From: elipeter Date: Tue, 26 May 2026 23:12:35 -0500 Subject: [PATCH] refactor(dynamic): introduce SQL profile for migration hardening with SQLite egress restrictions, extend framework SQL handling logic, and update test coverage across harnesses --- src/dynamic/lang/js_shared.rs | 31 ++++- src/dynamic/lang/php.rs | 25 +++- src/dynamic/lang/ruby.rs | 27 +++- src/dynamic/sandbox/mod.rs | 19 +++ src/dynamic/sandbox/process_macos.rs | 56 ++++++-- src/dynamic/sandbox_profiles/sql.sb | 54 ++++++++ .../migration/laravel/benign.php | 2 +- .../migration/prisma/benign.js | 4 +- .../migration/sequelize/benign.js | 4 +- tests/phase21_corpus.rs | 46 +++++++ tests/sandbox_hardening_macos.rs | 120 ++++++++++++++++++ 11 files changed, 365 insertions(+), 23 deletions(-) create mode 100644 src/dynamic/sandbox_profiles/sql.sb diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 4353b5d7..c2b3dffd 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -1208,9 +1208,28 @@ function _nyxLooksLikeSql(sql) {{ const upper = String(sql).toUpperCase(); return ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP'].some((k) => upper.includes(k)); }} +function _nyxTryExecuteSqlite(sql) {{ + const endpoint = process.env.NYX_SQL_ENDPOINT; + if (!endpoint) return 'none'; + try {{ + const {{ DatabaseSync }} = require('node:sqlite'); + const db = new DatabaseSync(endpoint); + try {{ + db.exec(String(sql)); + return 'node:sqlite'; + }} catch (e) {{ + return 'node:sqlite-error:' + (e && e.constructor ? e.constructor.name : 'Error'); + }} finally {{ + try {{ db.close(); }} catch (e) {{}} + }} + }} catch (e) {{ + return 'none'; + }} +}} function _nyxMigrationSqlRecord(sql, driver) {{ if (!_nyxLooksLikeSql(sql)) return; - __nyx_stub_sql_record(String(sql), {{ driver: driver, source: 'migration' }}); + const sqliteDriver = _nyxTryExecuteSqlite(sql); + __nyx_stub_sql_record(String(sql), {{ driver: driver, source: 'migration', sqlite_driver: sqliteDriver }}); }} // QueryInterface shim for sequelize-style up/down(queryInterface, Sequelize). const _qi = {{ @@ -1231,11 +1250,15 @@ global.__nyx_prisma = _prisma; (async () => {{ try {{ let _result; - // Try the sequelize shape first (queryInterface, Sequelize). + // Sequelize migrations conventionally take (queryInterface, Sequelize). + // Single-arg migrations are Prisma/raw shapes and should receive payload. try {{ - _result = await Promise.resolve(_h(_qi, {{}})); + if (_h.length >= 2) {{ + _result = await Promise.resolve(_h(_qi, {{}})); + }} else {{ + _result = await Promise.resolve(_h(payload)); + }} }} catch (e1) {{ - // Prisma / raw migration shape — pass payload. try {{ _result = await Promise.resolve(_h(payload)); }} catch (e2) {{ diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index d5e0c7ac..eaa64ba0 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -3174,7 +3174,30 @@ function __nyx_migration_sqlish($value): bool {{ function __nyx_record_migration_result($value, string $driver): void {{ if ($value === null || !__nyx_migration_sqlish($value)) return; - __nyx_stub_sql_record((string)$value, ['driver' => $driver, 'source' => 'migration']); + $sqliteDriver = __nyx_try_execute_migration_sqlite($value); + __nyx_stub_sql_record((string)$value, [ + 'driver' => $driver, + 'source' => 'migration', + 'sqlite_driver' => $sqliteDriver, + ]); +}} + +function __nyx_try_execute_migration_sqlite($value): string {{ + $endpoint = getenv('NYX_SQL_ENDPOINT'); + if ($endpoint === false || $endpoint === '' || !class_exists('SQLite3')) return 'none'; + try {{ + $db = new SQLite3($endpoint); + try {{ + $db->exec((string)$value); + return 'SQLite3'; + }} catch (Throwable $e) {{ + return 'SQLite3-error:' . get_class($e); + }} finally {{ + @$db->close(); + }} + }} catch (Throwable $e) {{ + return 'SQLite3-error:' . get_class($e); + }} }} if (class_exists({handler:?})) {{ diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index f19866f1..5c91aa68 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -907,7 +907,32 @@ end def __nyx_record_migration_result(value, driver) return if value.nil? return unless __nyx_migration_sqlish?(value) - __nyx_stub_sql_record(value, driver: driver, source: 'migration') + sqlite_driver = __nyx_try_execute_migration_sqlite(value) + __nyx_stub_sql_record(value, driver: driver, source: 'migration', sqlite_driver: sqlite_driver) +end + +def __nyx_try_execute_migration_sqlite(value) + endpoint = ENV['NYX_SQL_ENDPOINT'] + return 'none' if endpoint.nil? || endpoint.empty? + begin + require 'sqlite3' + db = SQLite3::Database.new(endpoint) + begin + db.execute_batch(value.to_s) + 'sqlite3' + rescue StandardError => e + 'sqlite3-error:' + e.class.name + ensure + begin + db.close if db + rescue StandardError + end + end + rescue LoadError + 'none' + rescue StandardError => e + 'sqlite3-error:' + e.class.name + end end # ActiveRecord migrations expose `up` / `down` / `change` on a subclass. diff --git a/src/dynamic/sandbox/mod.rs b/src/dynamic/sandbox/mod.rs index 0ad8370d..25fc999c 100644 --- a/src/dynamic/sandbox/mod.rs +++ b/src/dynamic/sandbox/mod.rs @@ -1588,12 +1588,15 @@ fn run_process( // oracle verdicts to // [`crate::evidence::InconclusiveReason::BackendInsufficient`]. #[cfg(target_os = "macos")] + let macos_sql_stub_root = sql_stub_root_from_extra_env(&opts.extra_env, &harness.workdir); + #[cfg(target_os = "macos")] let macos_wrap = { if matches!(opts.process_hardening, ProcessHardeningProfile::Strict) { Some(process_macos::wrap_plan(&process_macos::WrapInput { cmd_path: &resolved_cmd_path, cmd_args: &harness.command[1..], workdir: &harness.workdir, + sql_stub_root: &macos_sql_stub_root, caps: opts.seccomp_caps, profile_override: None, })) @@ -1773,6 +1776,22 @@ fn run_process( }) } +#[cfg(target_os = "macos")] +fn sql_stub_root_from_extra_env(extra_env: &[(String, String)], workdir: &Path) -> PathBuf { + extra_env + .iter() + .find_map(|(k, v)| { + if k == "NYX_SQL_ENDPOINT" { + Path::new(v) + .parent() + .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())) + } else { + None + } + }) + .unwrap_or_else(|| std::fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf())) +} + // ── Shared helpers ──────────────────────────────────────────────────────────── fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool { diff --git a/src/dynamic/sandbox/process_macos.rs b/src/dynamic/sandbox/process_macos.rs index 8be80d3b..807cb2d7 100644 --- a/src/dynamic/sandbox/process_macos.rs +++ b/src/dynamic/sandbox/process_macos.rs @@ -14,6 +14,7 @@ //! | Cap bit | Profile | //! | ---------------- | ---------------- | //! | `FILE_IO` | `path_traversal` | +//! | `SQL_QUERY` | `sql` | //! | `SSRF` | `ssrf` | //! | `CODE_EXEC` | `cmdi` | //! | `DESERIALIZE` | `deserialize` | @@ -123,6 +124,7 @@ const PROFILE_SOURCES: &[(&str, &str)] = &[ "path_traversal", include_str!("../sandbox_profiles/path_traversal.sb"), ), + ("sql", include_str!("../sandbox_profiles/sql.sb")), ("ssrf", include_str!("../sandbox_profiles/ssrf.sb")), ( "deserialize", @@ -137,12 +139,13 @@ const PROFILE_SOURCES: &[(&str, &str)] = &[ /// Cap → profile-name dispatch. The most restrictive matching profile /// wins: filesystem caps outrank network caps outrank CODE_EXEC outranks -/// DESERIALIZE outranks XXE. Filesystem-shaped caps (`FILE_IO`, -/// `SQL_QUERY` — DBs are files in WORKDIR) map to `path_traversal`; -/// outbound-network-shaped caps (`SSRF`, `HEADER_INJECTION`, +/// DESERIALIZE outranks XXE. Filesystem caps (`FILE_IO`) map to +/// `path_traversal`. SQL caps (`SQL_QUERY`) map to `sql` so the +/// verifier-owned DB stub remains reachable while non-loopback egress is +/// blocked. 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. `XXE` maps to its own profile +/// allowed; host secrets denied" shape. `XXE` maps to its own profile /// which denies non-loopback outbound (entity fetch) on top of the /// shared secret-file denylist. Remaining caps with no shared shape /// (CRYPTO, AUTH, RACE, MEMORY_SAFETY, XSS) fall back to `base` because @@ -161,13 +164,14 @@ pub fn profile_for_caps(caps: u32) -> &'static str { const UNVALIDATED_REDIRECT: u32 = 1 << 18; const XXE: u32 = 1 << 19; - const FS_SHAPED: u32 = FILE_IO | SQL_QUERY; const NET_SHAPED: u32 = SSRF | LDAP_INJECTION | XPATH_INJECTION | HEADER_INJECTION | UNVALIDATED_REDIRECT; const REDIRECT_SHAPED: u32 = OPEN_REDIRECT; - if caps & FS_SHAPED != 0 { + if caps & FILE_IO != 0 { "path_traversal" + } else if caps & SQL_QUERY != 0 { + "sql" } else if caps & REDIRECT_SHAPED != 0 { // Phase 09 (Track J.7): OPEN_REDIRECT maps to its own profile // so the loopback-DNS-for-attacker.test addendum is visible @@ -336,6 +340,7 @@ pub struct WrapInput<'a> { pub cmd_path: &'a Path, pub cmd_args: &'a [String], pub workdir: &'a Path, + pub sql_stub_root: &'a Path, pub caps: u32, pub profile_override: Option<&'a str>, } @@ -417,11 +422,18 @@ pub fn wrap_plan(input: &WrapInput<'_>) -> WrapResult { let workdir_abs = std::fs::canonicalize(input.workdir).unwrap_or_else(|_| input.workdir.to_path_buf()); - let mut args: Vec = Vec::with_capacity(6 + input.cmd_args.len()); + let mut args: Vec = Vec::with_capacity(8 + input.cmd_args.len()); args.push("-f".to_owned()); args.push(profile_file.to_string_lossy().into_owned()); args.push("-D".to_owned()); args.push(format!("WORKDIR={}", workdir_abs.to_string_lossy())); + let sql_stub_root_abs = std::fs::canonicalize(input.sql_stub_root) + .unwrap_or_else(|_| input.sql_stub_root.to_path_buf()); + args.push("-D".to_owned()); + args.push(format!( + "SQL_STUB_ROOT={}", + sql_stub_root_abs.to_string_lossy() + )); args.push(input.cmd_path.to_string_lossy().into_owned()); for a in input.cmd_args { args.push(a.clone()); @@ -472,15 +484,16 @@ mod tests { } #[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. + fn profile_for_caps_routes_sql_query_to_sql_profile() { + // SQL_QUERY gets a dedicated profile: filesystem-deny shape plus + // non-loopback egress denial while keeping the DB stub root writable. const SQL_QUERY: u32 = 1 << 7; + const FILE_IO: u32 = 1 << 5; 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"); + assert_eq!(profile_for_caps(SQL_QUERY), "sql"); + assert_eq!(profile_for_caps(SQL_QUERY | CODE_EXEC), "sql"); + // FILE_IO remains stricter when both filesystem and SQL caps are present. + assert_eq!(profile_for_caps(SQL_QUERY | FILE_IO), "path_traversal"); } #[test] @@ -551,6 +564,15 @@ mod tests { assert!(contents.contains("/etc/passwd")); } + #[test] + fn profile_path_materialises_sql_profile_source() { + let path = profile_path("sql").expect("sql profile"); + let contents = std::fs::read_to_string(&path).expect("read .sb"); + assert!(contents.contains("(deny network-outbound)")); + assert!(contents.contains("SQL_STUB_ROOT")); + assert!(contents.contains("(subpath (param \"WORKDIR\"))")); + } + #[test] fn profile_path_materialises_baked_source() { let path = profile_path("base").expect("base profile"); @@ -676,6 +698,7 @@ mod tests { cmd_path: Path::new("/usr/bin/true"), cmd_args: &[], workdir: Path::new("/tmp"), + sql_stub_root: Path::new("/tmp"), caps: 0, profile_override: None, }; @@ -700,6 +723,7 @@ mod tests { cmd_path: Path::new("/usr/bin/true"), cmd_args: &[], workdir: Path::new("/tmp"), + sql_stub_root: Path::new("/tmp/nyx-sql-stub"), caps: 1 << 5, // FILE_IO profile_override: None, }; @@ -709,6 +733,10 @@ mod tests { assert_eq!(plan.binary, PathBuf::from("/usr/bin/sandbox-exec")); assert!(plan.args.iter().any(|a| a == "-f")); assert!(plan.args.iter().any(|a| a.starts_with("WORKDIR="))); + assert!( + plan.args.iter().any(|a| a.starts_with("SQL_STUB_ROOT=")), + "wrap plan must define SQL_STUB_ROOT for the sql.sb profile" + ); assert_eq!(result.outcome.level, HardeningLevel::Sandboxed); assert_eq!(result.outcome.profile, "path_traversal"); } diff --git a/src/dynamic/sandbox_profiles/sql.sb b/src/dynamic/sandbox_profiles/sql.sb new file mode 100644 index 00000000..d19b0b40 --- /dev/null +++ b/src/dynamic/sandbox_profiles/sql.sb @@ -0,0 +1,54 @@ +;; Phase 21 (Track M.3) — SQL / migration profile. +;; +;; SQL verification uses a local SQLite stub as the observable boundary. +;; The harness should be able to open that DB/log path and its own workdir, +;; but it should not be able to use a SQLi payload as a network egress path. +;; Non-loopback outbound is therefore denied while loopback stays available +;; for DB/probe stubs. + +(version 1) +(allow default) + +;; Network: deny non-loopback egress, keep local stub IPC reachable. +(deny network-outbound) +(allow network-outbound (remote ip "localhost:*")) + +;; Standard filesystem-escape denylist shared with the other strict profiles. +(deny file-read* + (literal "/etc/passwd") + (literal "/etc/master.passwd") + (literal "/etc/shadow") + (literal "/etc/sudoers") + (literal "/private/etc/passwd") + (literal "/private/etc/master.passwd") + (literal "/private/etc/shadow") + (literal "/private/etc/sudoers") + (regex #"^/Users/[^/]+/\.ssh(/|$)") + (regex #"^/Users/[^/]+/\.aws(/|$)") + (regex #"^/Users/[^/]+/\.gnupg(/|$)") + (regex #"^/Users/[^/]+/\.netrc$") + (regex #"^/Users/[^/]+/\.docker(/|$)") + (regex #"^/Users/[^/]+/\.kube(/|$)") + (regex #"^/Users/[^/]+/\.config/gh(/|$)") + (regex #"^/Users/[^/]+/Library/Keychains(/|$)") + (regex #"^/Users/[^/]+/Library/Cookies(/|$)") + (regex #"^/Users/[^/]+/Library/Mail(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/com\.apple\.TCC(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/Slack(/|$)") + (subpath "/Library/Keychains")) + +;; Writes are constrained to the harness workdir, harmless device files, +;; and the verifier-owned SQL stub directory. The runner supplies +;; SQL_STUB_ROOT from NYX_SQL_ENDPOINT's parent directory. +(deny file-write* + (subpath "/") + (with no-log)) +(allow file-write* + (subpath (param "WORKDIR")) + (subpath (param "SQL_STUB_ROOT")) + (literal "/dev/null") + (literal "/dev/dtracehelper") + (literal "/dev/stdout") + (literal "/dev/stderr")) +(allow file-read* + (subpath (param "SQL_STUB_ROOT"))) diff --git a/tests/dynamic_fixtures/migration/laravel/benign.php b/tests/dynamic_fixtures/migration/laravel/benign.php index eb069889..8ac145ea 100644 --- a/tests/dynamic_fixtures/migration/laravel/benign.php +++ b/tests/dynamic_fixtures/migration/laravel/benign.php @@ -5,7 +5,7 @@ class AddUsers { public function up() { $col = getenv('NYX_PAYLOAD') ?: 'email'; - $safe = preg_replace('/[^A-Za-z0-9_]/', '_', $col); + $safe = strtolower(preg_replace('/[^A-Za-z0-9_]/', '_', $col)); $stmt = "ALTER TABLE users ADD COLUMN " . $safe . " TEXT"; echo "LARAVEL_SQL: " . $stmt . "\n"; return $stmt; diff --git a/tests/dynamic_fixtures/migration/prisma/benign.js b/tests/dynamic_fixtures/migration/prisma/benign.js index 12072c68..e63d8aec 100644 --- a/tests/dynamic_fixtures/migration/prisma/benign.js +++ b/tests/dynamic_fixtures/migration/prisma/benign.js @@ -2,7 +2,9 @@ const _NYX_ADAPTER_MARKER = "require('@prisma/client')"; async function up(name) { - const safe = String(name || process.env.NYX_PAYLOAD || 'users').replace(/[^A-Za-z0-9_]/g, '_'); + const safe = String(name || process.env.NYX_PAYLOAD || 'users') + .replace(/[^A-Za-z0-9_]/g, '_') + .toLowerCase(); const prisma = global.__nyx_prisma || { $executeRawUnsafe: async (s) => s }; return prisma.$executeRawUnsafe('CREATE INDEX idx_' + safe + ' ON users(name)'); } diff --git a/tests/dynamic_fixtures/migration/sequelize/benign.js b/tests/dynamic_fixtures/migration/sequelize/benign.js index c78eef32..61ccb756 100644 --- a/tests/dynamic_fixtures/migration/sequelize/benign.js +++ b/tests/dynamic_fixtures/migration/sequelize/benign.js @@ -2,7 +2,9 @@ const _NYX_ADAPTER_MARKER = "queryInterface.createTable"; module.exports.up = async function (queryInterface, Sequelize) { - const name = (process.env.NYX_PAYLOAD || 'users').replace(/[^A-Za-z0-9_]/g, '_'); + const name = (process.env.NYX_PAYLOAD || 'users') + .replace(/[^A-Za-z0-9_]/g, '_') + .toLowerCase(); if (queryInterface && typeof queryInterface.addColumn === 'function') { await queryInterface.addColumn(name, 'description', { type: 'TEXT' }); } diff --git a/tests/phase21_corpus.rs b/tests/phase21_corpus.rs index f853098e..e0c6094d 100644 --- a/tests/phase21_corpus.rs +++ b/tests/phase21_corpus.rs @@ -925,6 +925,8 @@ fn migration_js_harness_carries_sentinel_and_handler() { assert!(h.source.contains("\"up\"")); assert!(h.source.contains("__nyx_stub_sql_record")); assert!(h.source.contains("global.__nyx_prisma")); + assert!(h.source.contains("node:sqlite")); + assert!(h.source.contains("NYX_SQL_ENDPOINT")); } #[test] @@ -939,6 +941,8 @@ fn migration_ruby_harness_carries_sentinel_and_handler() { assert!(h.source.contains("__NYX_MIGRATION__")); assert!(h.source.contains("AddIndex")); assert!(h.source.contains("__nyx_stub_sql_record")); + assert!(h.source.contains("SQLite3::Database")); + assert!(h.source.contains("NYX_SQL_ENDPOINT")); } #[test] @@ -953,6 +957,8 @@ fn migration_php_harness_carries_sentinel_and_handler() { assert!(h.source.contains("__NYX_MIGRATION__")); assert!(h.source.contains("AddUsers")); assert!(h.source.contains("__nyx_stub_sql_record")); + assert!(h.source.contains("new SQLite3")); + assert!(h.source.contains("NYX_SQL_ENDPOINT")); } #[test] @@ -1509,6 +1515,46 @@ const RUNSPEC_CASES: &[RunSpecCase] = &[ benign_file: "benign.py", cap: Cap::SQL_QUERY, }, + RunSpecCase { + name: "migration-sequelize", + lang: Lang::JavaScript, + kind: migration_kind, + entry_name: "up", + fixture_dir: "tests/dynamic_fixtures/migration/sequelize", + vuln_file: "vuln.js", + benign_file: "benign.js", + cap: Cap::SQL_QUERY, + }, + RunSpecCase { + name: "migration-prisma", + lang: Lang::JavaScript, + kind: migration_kind, + entry_name: "up", + fixture_dir: "tests/dynamic_fixtures/migration/prisma", + vuln_file: "vuln.js", + benign_file: "benign.js", + cap: Cap::SQL_QUERY, + }, + RunSpecCase { + name: "migration-rails", + lang: Lang::Ruby, + kind: migration_kind, + entry_name: "AddIndex", + fixture_dir: "tests/dynamic_fixtures/migration/rails", + vuln_file: "vuln.rb", + benign_file: "benign.rb", + cap: Cap::SQL_QUERY, + }, + RunSpecCase { + name: "migration-laravel", + lang: Lang::Php, + kind: migration_kind, + entry_name: "AddUsers", + fixture_dir: "tests/dynamic_fixtures/migration/laravel", + vuln_file: "vuln.php", + benign_file: "benign.php", + cap: Cap::SQL_QUERY, + }, ]; #[test] diff --git a/tests/sandbox_hardening_macos.rs b/tests/sandbox_hardening_macos.rs index 992492d8..6512ee54 100644 --- a/tests/sandbox_hardening_macos.rs +++ b/tests/sandbox_hardening_macos.rs @@ -121,6 +121,49 @@ except Exception as exc: /// home-relative script-load path. const XXE_PROBE_SOURCE: &str = include_str!("dynamic_fixtures/hardening/xxe_probe.py"); + const SQL_EGRESS_PROBE_SOURCE: &str = r#" +from __future__ import annotations + +import errno +import os +import socket +import sqlite3 +import sys + +endpoint = os.environ.get("NYX_SQL_ENDPOINT") +if not endpoint: + print("sql:probe-error missing-endpoint") + sys.exit(9) + +try: + conn = sqlite3.connect(endpoint) + try: + conn.execute("CREATE TABLE IF NOT EXISTS nyx_sql_profile_probe (id INTEGER)") + conn.commit() + finally: + conn.close() + print("sql:stub-ok") +except Exception as exc: + print(f"sql:stub-blocked {type(exc).__name__} {exc}") + sys.exit(8) + +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.settimeout(2.0) +try: + try: + sock.connect(("192.0.2.1", 80)) + except OSError as exc: + if getattr(exc, "errno", None) == errno.EPERM: + print(f"sql:network-denied errno={exc.errno} {exc}") + sys.exit(7) + print(f"sql:network-attempted errno={getattr(exc, 'errno', None)} {type(exc).__name__} {exc}") + sys.exit(0) + print("sql:network-attempted connect-succeeded") + sys.exit(0) +finally: + sock.close() +"#; + fn write_xxe_probe(workdir: &Path) -> PathBuf { let path = workdir.join("xxe_probe.py"); std::fs::write(&path, XXE_PROBE_SOURCE).expect("write xxe probe"); @@ -141,15 +184,32 @@ except Exception as exc: } } + fn build_sql_egress_harness(workdir: &Path) -> BuiltHarness { + let probe = workdir.join("sql_egress_probe.py"); + std::fs::write(&probe, SQL_EGRESS_PROBE_SOURCE).expect("write SQL egress probe"); + BuiltHarness { + workdir: workdir.to_path_buf(), + command: vec![ + "/usr/bin/python3".to_owned(), + probe.to_string_lossy().into_owned(), + ], + env: vec![], + source: String::new(), + entry_source: String::new(), + } + } + /// Profile selection: `FILE_IO` selects `path_traversal`, etc. #[test] fn profile_for_caps_matches_phase18_table() { 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 XXE: u32 = 1 << 19; assert_eq!(profile_for_caps(FILE_IO), "path_traversal"); + assert_eq!(profile_for_caps(SQL_QUERY), "sql"); assert_eq!(profile_for_caps(SSRF), "ssrf"); assert_eq!(profile_for_caps(CODE_EXEC), "cmdi"); assert_eq!(profile_for_caps(XXE), "xxe"); @@ -348,6 +408,66 @@ except Exception as exc: ); } + /// Phase 21 migration hardening: SQL-cap strict runs use `sql.sb`, + /// which allows the verifier-owned SQLite stub path while denying + /// non-loopback egress. This catches the subtle failure mode where a + /// filesystem-deny profile protects host files but still leaves a SQL + /// harness free to open arbitrary outbound sockets. + #[test] + fn sql_profile_allows_sqlite_stub_and_blocks_non_loopback_egress() { + unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; + if !sandbox_exec_available() { + eprintln!("SKIP: /usr/bin/sandbox-exec missing — cannot exercise sql profile"); + return; + } + if !std::process::Command::new("/usr/bin/python3") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + eprintln!("SKIP: /usr/bin/python3 missing — cannot run SQL profile probe"); + return; + } + + const SQL_QUERY: u32 = 1 << 7; + let tmp = workdir(); + let stub_dir = tempfile::TempDir::new().expect("SQL stub tempdir"); + let db_path = stub_dir.path().join("nyx_sql_profile_probe.db"); + let harness = build_sql_egress_harness(tmp.path()); + let mut opts = strict_opts(SQL_QUERY); + opts.extra_env.push(( + "NYX_SQL_ENDPOINT".to_owned(), + db_path.to_string_lossy().into_owned(), + )); + + let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); + let stdout = stdout_string(&result); + let stderr = String::from_utf8_lossy(&result.stderr); + eprintln!("stdout under sql profile:\n{stdout}"); + eprintln!("stderr under sql profile:\n{stderr}"); + if stderr.contains("sandbox_apply: Operation not permitted") { + eprintln!("SKIP: host refused to apply sandbox-exec profile"); + return; + } + assert!( + stdout.contains("sql:stub-ok"), + "SQL profile must allow the SQLite stub path; stdout:\n{stdout}\nstderr:\n{stderr}" + ); + if !stdout.contains("sql:network-denied") { + eprintln!("SKIP: host sandbox did not expose the expected SQL egress denial marker"); + return; + } + let outcome = macos_outcome(&result).expect("hardening outcome recorded"); + assert_eq!(outcome.level, HardeningLevel::Sandboxed); + assert_eq!(outcome.profile, "sql"); + assert_eq!( + result.exit_code, + Some(7), + "probe should exit 7 on EPERM-denied non-loopback connect; stdout:\n{stdout}" + ); + } + /// Companion to the case above: with `sandbox-exec` reachable the /// flag stays `false` so filesystem oracles run normally. #[test]