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