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:
elipeter 2026-05-26 23:12:35 -05:00
parent 6ee2bdda36
commit 9bf085ee48
11 changed files with 365 additions and 23 deletions

View file

@ -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) {{

View file

@ -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:?})) {{

View file

@ -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.

View file

@ -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 {

View file

@ -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");
}

View 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")))