mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(dynamic): introduce SQL profile for migration hardening with SQLite egress restrictions, extend framework SQL handling logic, and update test coverage across harnesses
This commit is contained in:
parent
6ee2bdda36
commit
9bf085ee48
11 changed files with 365 additions and 23 deletions
|
|
@ -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) {{
|
||||
|
|
|
|||
|
|
@ -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:?})) {{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<String> = Vec::with_capacity(6 + input.cmd_args.len());
|
||||
let mut args: Vec<String> = 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");
|
||||
}
|
||||
|
|
|
|||
54
src/dynamic/sandbox_profiles/sql.sb
Normal file
54
src/dynamic/sandbox_profiles/sql.sb
Normal file
|
|
@ -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")))
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue