mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0023 (20260517T044708Z-e058)
This commit is contained in:
parent
f4793b0439
commit
b638cade34
8 changed files with 458 additions and 26 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,3 +17,4 @@
|
|||
node_modules
|
||||
__pycache__/
|
||||
*.pyc
|
||||
tools/sb-trace/*.trace.raw
|
||||
|
|
|
|||
14
fuzz/dynamic_corpus/Cargo.lock
generated
14
fuzz/dynamic_corpus/Cargo.lock
generated
|
|
@ -1011,6 +1011,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"tempfile",
|
||||
"terminal_size",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
|
|
@ -1586,6 +1587,19 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terminal_size"
|
||||
version = "0.4.4"
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ use nyx_scanner::dynamic::corpus::{
|
|||
audit_marker_collisions, materialise_bytes, payloads_for, CuratedPayload, Oracle,
|
||||
PayloadProvenance, CORPUS_VERSION,
|
||||
};
|
||||
use nyx_scanner::dynamic::rand::SpecRng;
|
||||
use nyx_scanner::labels::Cap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
|
@ -138,14 +138,16 @@ fn cmd_run(args: &[String]) {
|
|||
}
|
||||
|
||||
let mut corpus: Vec<Vec<u8>> = seed_bytes.clone();
|
||||
let mut rng_state: u64 = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(12345);
|
||||
// Deterministic RNG keyed on the spec hash so two runs against the
|
||||
// same fixture produce identical candidate streams. The Phase 27
|
||||
// events.jsonl replay invariant + Phase 28 repro bundle hermeticity
|
||||
// contract both require the verifier (and any fuzzer feeding it) to
|
||||
// be reproducible from inputs alone — no host entropy mixed in.
|
||||
let mut rng = SpecRng::seeded(&spec_hash);
|
||||
|
||||
for iter in 0..iterations {
|
||||
let seed = &corpus[lcg_next(&mut rng_state) as usize % corpus.len()];
|
||||
let candidate = mutate_bytes(seed, &mut rng_state);
|
||||
let seed = &corpus[rng.gen_range(corpus.len())];
|
||||
let candidate = mutate_bytes(seed, &mut rng);
|
||||
|
||||
if seen.contains(&candidate) {
|
||||
continue;
|
||||
|
|
@ -162,7 +164,7 @@ fn cmd_run(args: &[String]) {
|
|||
|
||||
if interesting {
|
||||
discovered += 1;
|
||||
let filename = format!("candidate-{:016x}", lcg_next(&mut rng_state));
|
||||
let filename = format!("candidate-{:016x}", rng.next_u64());
|
||||
let candidate_path = out_path.join(&filename);
|
||||
std::fs::write(&candidate_path, &candidate).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to write candidate: {e}");
|
||||
|
|
@ -206,31 +208,26 @@ fn parse_cap(name: &str) -> Option<Cap> {
|
|||
}
|
||||
}
|
||||
|
||||
fn lcg_next(state: &mut u64) -> u64 {
|
||||
*state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
*state
|
||||
}
|
||||
|
||||
fn mutate_bytes(input: &[u8], rng: &mut u64) -> Vec<u8> {
|
||||
fn mutate_bytes(input: &[u8], rng: &mut SpecRng) -> Vec<u8> {
|
||||
let mut out = input.to_vec();
|
||||
if out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
match lcg_next(rng) % 5 {
|
||||
match rng.next_u64() % 5 {
|
||||
0 => {
|
||||
// Flip a random byte.
|
||||
let idx = (lcg_next(rng) as usize) % out.len();
|
||||
out[idx] ^= (lcg_next(rng) as u8) | 1;
|
||||
let idx = rng.gen_range(out.len());
|
||||
out[idx] ^= (rng.next_u64() as u8) | 1;
|
||||
}
|
||||
1 => {
|
||||
// Insert a byte.
|
||||
let idx = (lcg_next(rng) as usize) % (out.len() + 1);
|
||||
out.insert(idx, lcg_next(rng) as u8);
|
||||
let idx = rng.gen_range(out.len() + 1);
|
||||
out.insert(idx, rng.next_u64() as u8);
|
||||
}
|
||||
2 => {
|
||||
// Delete a byte.
|
||||
if out.len() > 1 {
|
||||
let idx = (lcg_next(rng) as usize) % out.len();
|
||||
let idx = rng.gen_range(out.len());
|
||||
out.remove(idx);
|
||||
}
|
||||
}
|
||||
|
|
@ -240,15 +237,15 @@ fn mutate_bytes(input: &[u8], rng: &mut u64) -> Vec<u8> {
|
|||
b"'", b"\"", b";", b"--", b" OR 1=1", b"<script>", b"../",
|
||||
b"\x00", b"{{", b"|", b"`",
|
||||
];
|
||||
let s = suffixes[(lcg_next(rng) as usize) % suffixes.len()];
|
||||
let s = suffixes[rng.gen_range(suffixes.len())];
|
||||
out.extend_from_slice(s);
|
||||
}
|
||||
_ => {
|
||||
// Replace a slice with an interesting pattern.
|
||||
let interesting: &[&[u8]] = &[b"'", b"\"", b"<", b">", b"%00", b"../", b"//"];
|
||||
if !out.is_empty() {
|
||||
let idx = (lcg_next(rng) as usize) % out.len();
|
||||
let pat = interesting[(lcg_next(rng) as usize) % interesting.len()];
|
||||
let idx = rng.gen_range(out.len());
|
||||
let pat = interesting[rng.gen_range(interesting.len())];
|
||||
let end = (idx + pat.len()).min(out.len());
|
||||
out[idx..end].copy_from_slice(&pat[..end - idx]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,22 @@ set -euo pipefail
|
|||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DYN_DIR="$ROOT/src/dynamic"
|
||||
FUZZ_DIR="$ROOT/fuzz/dynamic_corpus/src"
|
||||
|
||||
if [[ ! -d "$DYN_DIR" ]]; then
|
||||
echo "audit: src/dynamic/ missing at $DYN_DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The dynamic-corpus mutation fuzzer is also audited: it routes every
|
||||
# randomness draw through `SpecRng::seeded(&spec.spec_hash)` so two
|
||||
# runs against the same fixture produce identical candidate streams,
|
||||
# matching the determinism contract of the verifier it feeds.
|
||||
if [[ ! -d "$FUZZ_DIR" ]]; then
|
||||
# Soft warn — the fuzzer is optional during early bootstrap.
|
||||
echo "audit: fuzz/dynamic_corpus/src/ missing at $FUZZ_DIR (skipping)" >&2
|
||||
fi
|
||||
|
||||
# Banned patterns: any real call site of a non-deterministic RNG API.
|
||||
#
|
||||
# Each pattern is a Rust-token shape we expect to never appear inside
|
||||
|
|
@ -53,9 +63,13 @@ EXCLUDE_PATHS=(
|
|||
# applied via a post-filter so the audit catches new files even
|
||||
# before they are tracked.
|
||||
if git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
HITS="$(git -C "$ROOT" grep -nE "$(IFS='|'; echo "${PATTERNS[*]}")" -- 'src/dynamic/**/*.rs' 'src/dynamic/*.rs' || true)"
|
||||
HITS="$(git -C "$ROOT" grep -nE "$(IFS='|'; echo "${PATTERNS[*]}")" \
|
||||
-- 'src/dynamic/**/*.rs' 'src/dynamic/*.rs' \
|
||||
'fuzz/dynamic_corpus/src/**/*.rs' 'fuzz/dynamic_corpus/src/*.rs' \
|
||||
|| true)"
|
||||
else
|
||||
HITS="$(grep -rnE "$(IFS='|'; echo "${PATTERNS[*]}")" --include='*.rs' "$DYN_DIR" || true)"
|
||||
HITS="$(grep -rnE "$(IFS='|'; echo "${PATTERNS[*]}")" --include='*.rs' \
|
||||
"$DYN_DIR" ${FUZZ_DIR:+"$FUZZ_DIR"} || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$HITS" ]]; then
|
||||
|
|
|
|||
|
|
@ -302,6 +302,18 @@ pub fn splice_deny_default(source: &str, seed: &str) -> String {
|
|||
rewritten
|
||||
}
|
||||
|
||||
/// Drop the in-process [`PROFILE_PATHS`] cache. Intended for
|
||||
/// integration tests that flip `NYX_SB_DENY_DEFAULT` mid-process and
|
||||
/// need the next [`profile_path`] call to re-run the splice path
|
||||
/// instead of returning a previously materialised entry. Hidden from
|
||||
/// the rendered API surface; production code does not touch the cache.
|
||||
#[doc(hidden)]
|
||||
pub fn clear_profile_path_cache_for_tests() {
|
||||
if let Ok(mut cache) = profile_paths().lock() {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command wrapping ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Inputs to [`wrap_plan`] — the original harness command split into
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ mod hardening_tests {
|
|||
|
||||
use nyx_scanner::dynamic::harness::BuiltHarness;
|
||||
use nyx_scanner::dynamic::sandbox::process_macos::{
|
||||
profile_for_caps, sandbox_exec_available, HardeningLevel, SANDBOX_EXEC_BIN_ENV,
|
||||
clear_profile_path_cache_for_tests, profile_for_caps, profile_path,
|
||||
sandbox_exec_available, HardeningLevel, SANDBOX_EXEC_BIN_ENV, SB_DENY_DEFAULT_ENV,
|
||||
SB_SEED_DIR_ENV,
|
||||
};
|
||||
use nyx_scanner::dynamic::sandbox::{
|
||||
self, HardeningRecord, ProcessHardeningProfile, SandboxBackend, SandboxOptions,
|
||||
|
|
@ -686,6 +688,98 @@ except Exception as exc:
|
|||
);
|
||||
}
|
||||
|
||||
/// Phase 18 follow-up smoke test: a synthetic seed under
|
||||
/// `NYX_SB_SEED_DIR` rewrites the materialised `.sb` profile to
|
||||
/// `(deny default)` and appends the seed body verbatim. Exercises
|
||||
/// the splice path through the production [`profile_path`] call
|
||||
/// site so the env-var → seed-dir → splice → on-disk file flow is
|
||||
/// validated end-to-end, not just via the unit tests on
|
||||
/// [`splice_deny_default`].
|
||||
///
|
||||
/// Uses the `ssrf` profile because no other test in this file
|
||||
/// touches it; the cache-clear helper resets state regardless so
|
||||
/// the assertion holds even if a future test materialises ssrf
|
||||
/// before this one.
|
||||
#[test]
|
||||
fn deny_default_seed_loads_under_strict() {
|
||||
let seed_dir = tempfile::TempDir::new().expect("seed tempdir");
|
||||
// The seed body is intentionally over-permissive so the
|
||||
// /usr/bin/true probe at the end of the test can clear without
|
||||
// tripping on missing allowances. A real seed generated by
|
||||
// `tools/sb-trace.sh` would be much tighter (only the rules
|
||||
// each interpreter cold-start needs).
|
||||
let seed_body = ";; synthetic seed for end-to-end smoke test\n\
|
||||
(allow process-fork)\n\
|
||||
(allow process-exec*)\n\
|
||||
(allow file-read*)\n\
|
||||
(allow file-read-metadata)\n\
|
||||
(allow file-write-data (literal \"/dev/null\"))\n\
|
||||
(allow mach-lookup)\n\
|
||||
(allow signal (target self))\n\
|
||||
(allow sysctl-read)\n\
|
||||
(allow ipc-posix-shm-read*)\n";
|
||||
std::fs::write(seed_dir.path().join("ssrf.allow"), seed_body)
|
||||
.expect("write synthetic seed");
|
||||
|
||||
clear_profile_path_cache_for_tests();
|
||||
unsafe {
|
||||
std::env::set_var(SB_DENY_DEFAULT_ENV, "1");
|
||||
std::env::set_var(SB_SEED_DIR_ENV, seed_dir.path());
|
||||
}
|
||||
|
||||
let materialised = profile_path("ssrf").expect("profile materialises");
|
||||
let body = std::fs::read_to_string(&materialised).expect("read profile body");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(SB_DENY_DEFAULT_ENV);
|
||||
std::env::remove_var(SB_SEED_DIR_ENV);
|
||||
}
|
||||
clear_profile_path_cache_for_tests();
|
||||
|
||||
assert!(
|
||||
body.contains("(deny default)"),
|
||||
"splice should rewrite (allow default) -> (deny default); got: {body}",
|
||||
);
|
||||
assert!(
|
||||
!body.contains("(allow default)"),
|
||||
"no (allow default) directive should survive the splice; got: {body}",
|
||||
);
|
||||
assert!(
|
||||
body.contains(";; ── deny-default seed (spliced by NYX_SB_DENY_DEFAULT=1) ──"),
|
||||
"splice banner should appear once; got: {body}",
|
||||
);
|
||||
assert!(
|
||||
body.contains("(allow process-fork)"),
|
||||
"synthetic seed body should land verbatim; got: {body}",
|
||||
);
|
||||
assert!(
|
||||
body.contains("(allow mach-lookup)"),
|
||||
"later seed rule should also appear verbatim; got: {body}",
|
||||
);
|
||||
|
||||
// The spliced profile should still parse as valid sandbox-exec
|
||||
// syntax when the host has the binary on PATH; skip when it
|
||||
// is missing (stripped CI images) since this assertion is the
|
||||
// only one that needs the live binary.
|
||||
if sandbox_exec_available() {
|
||||
let probe = std::process::Command::new("/usr/bin/sandbox-exec")
|
||||
.arg("-f")
|
||||
.arg(&materialised)
|
||||
.arg("-D")
|
||||
.arg("WORKDIR=/tmp")
|
||||
.arg("/usr/bin/true")
|
||||
.output()
|
||||
.expect("invoke sandbox-exec on spliced profile");
|
||||
assert!(
|
||||
probe.status.success(),
|
||||
"spliced profile should be valid sandbox-exec syntax; \
|
||||
status={:?}, stderr={}",
|
||||
probe.status,
|
||||
String::from_utf8_lossy(&probe.stderr),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Round-trip the portable summary through JSON to lock in the
|
||||
/// repro-bundle wire shape: `VerifyResult::hardening_outcome` lands
|
||||
/// on `expected/verdict.json` so the eval-corpus tabulator and any
|
||||
|
|
|
|||
223
tools/sb-trace.sh
Executable file
223
tools/sb-trace.sh
Executable file
|
|
@ -0,0 +1,223 @@
|
|||
#!/usr/bin/env bash
|
||||
# tools/sb-trace.sh — corpus-walking seed generator for the macOS
|
||||
# sandbox-exec deny-default rollout (Phase 18 follow-up path (a)).
|
||||
#
|
||||
# What it does
|
||||
# ------------
|
||||
# For each `.sb` profile shipped under `src/dynamic/sandbox_profiles/`,
|
||||
# this script re-runs the profile in deny-default mode against the
|
||||
# per-language harness corpus under `tests/dynamic_fixtures/`,
|
||||
# captures the kernel's deny trace, and writes one
|
||||
# `tools/sb-trace/{cap}.allow` seed file with the minimum allow rules
|
||||
# the interpreter cold-start needs.
|
||||
#
|
||||
# The seed files are consumed by `src/dynamic/sandbox/process_macos.rs`
|
||||
# at runtime when `NYX_SB_DENY_DEFAULT=1` is set; the splice path
|
||||
# replaces the baked `(allow default)` with `(deny default)` and
|
||||
# appends the seed body verbatim.
|
||||
#
|
||||
# Usage
|
||||
# -----
|
||||
# tools/sb-trace.sh # walk every profile + every lang fixture
|
||||
# tools/sb-trace.sh cmdi # just the cmdi profile
|
||||
# tools/sb-trace.sh cmdi python # cmdi + python only
|
||||
#
|
||||
# Requirements
|
||||
# ------------
|
||||
# * macOS host with `/usr/bin/sandbox-exec` available
|
||||
# * `python3`, `node`, `ruby`, `php`, `java` resolvable via $PATH for
|
||||
# every language whose fixtures you want to walk
|
||||
#
|
||||
# Output
|
||||
# ------
|
||||
# tools/sb-trace/<cap>.allow — generated seed, hand-review
|
||||
# tools/sb-trace/<cap>.trace.raw — full raw deny trace, for audit
|
||||
#
|
||||
# The seed files are intended to be committed; the .trace.raw files
|
||||
# are .gitignore'd because they capture host-specific paths.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SEED_DIR="$ROOT/tools/sb-trace"
|
||||
PROFILE_DIR="$ROOT/src/dynamic/sandbox_profiles"
|
||||
FIXTURE_ROOT="$ROOT/tests/dynamic_fixtures"
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "sb-trace: must run on macOS (uname=$(uname -s))" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v /usr/bin/sandbox-exec >/dev/null 2>&1; then
|
||||
echo "sb-trace: /usr/bin/sandbox-exec missing" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$SEED_DIR"
|
||||
|
||||
# ── Profile + language coverage ──────────────────────────────────────────────
|
||||
|
||||
ALL_PROFILES=(base cmdi path_traversal ssrf deserialize xxe)
|
||||
ALL_LANGS=(python javascript ruby php java)
|
||||
|
||||
selected_profiles=()
|
||||
selected_langs=()
|
||||
|
||||
if [[ $# -ge 1 ]]; then
|
||||
selected_profiles+=("$1")
|
||||
else
|
||||
selected_profiles=("${ALL_PROFILES[@]}")
|
||||
fi
|
||||
|
||||
if [[ $# -ge 2 ]]; then
|
||||
selected_langs+=("$2")
|
||||
else
|
||||
selected_langs=("${ALL_LANGS[@]}")
|
||||
fi
|
||||
|
||||
# ── Per-language probe ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Each probe runs the language's interpreter cold-start path (import
|
||||
# the standard libraries the harness needs). The probes are
|
||||
# intentionally minimal: they exercise filesystem reads of stdlib /
|
||||
# package manager locations + a `mach-lookup` for the system
|
||||
# notification center, which is what the trace needs to enumerate.
|
||||
|
||||
probe_command_for() {
|
||||
local lang="$1"
|
||||
case "$lang" in
|
||||
python)
|
||||
echo "/usr/bin/python3" "-c" "import socket,subprocess,os,sys,json"
|
||||
;;
|
||||
javascript)
|
||||
command -v node >/dev/null 2>&1 || { echo ""; return; }
|
||||
echo "node" "-e" "require('fs');require('os');require('child_process');require('http');"
|
||||
;;
|
||||
ruby)
|
||||
command -v ruby >/dev/null 2>&1 || { echo ""; return; }
|
||||
echo "ruby" "-e" "require 'json';require 'socket';require 'net/http';require 'open3'"
|
||||
;;
|
||||
php)
|
||||
command -v php >/dev/null 2>&1 || { echo ""; return; }
|
||||
echo "php" "-r" "echo phpversion();"
|
||||
;;
|
||||
java)
|
||||
command -v java >/dev/null 2>&1 || { echo ""; return; }
|
||||
echo "java" "--version"
|
||||
;;
|
||||
*)
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Trace helper ─────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Builds a deny-default variant of the named profile, runs the probe
|
||||
# under it, captures the sandbox trace via the `(with trace)` directive,
|
||||
# and prints any deny lines for further processing.
|
||||
|
||||
trace_one() {
|
||||
local profile_name="$1"
|
||||
local lang="$2"
|
||||
local probe_cmd
|
||||
probe_cmd="$(probe_command_for "$lang")"
|
||||
if [[ -z "$probe_cmd" ]]; then
|
||||
echo "sb-trace: skipping $lang (interpreter missing)" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local source="$PROFILE_DIR/$profile_name.sb"
|
||||
if [[ ! -f "$source" ]]; then
|
||||
echo "sb-trace: profile $profile_name missing at $source" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tmp_profile
|
||||
tmp_profile="$(mktemp -t "sb-trace-$profile_name.XXXXXX.sb")"
|
||||
local trace_file
|
||||
trace_file="$(mktemp -t "sb-trace-$profile_name.XXXXXX.trace")"
|
||||
|
||||
# Rewrite (allow default) -> (deny default), append a trace directive.
|
||||
# `(trace "...")` emits one s-expression record per sandbox decision.
|
||||
sed 's/(allow default)/(deny default)/' "$source" >"$tmp_profile"
|
||||
printf '\n(trace "%s")\n' "$trace_file" >>"$tmp_profile"
|
||||
|
||||
# Run the probe under the new profile. Exit code is ignored — the
|
||||
# interpreter is expected to fail under deny-default; what we want is
|
||||
# the captured trace.
|
||||
/usr/bin/sandbox-exec -f "$tmp_profile" -D WORKDIR=/tmp -- $probe_cmd >/dev/null 2>&1 || true
|
||||
|
||||
if [[ -s "$trace_file" ]]; then
|
||||
cat "$trace_file"
|
||||
fi
|
||||
|
||||
rm -f "$tmp_profile" "$trace_file"
|
||||
}
|
||||
|
||||
# ── Trace summariser ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# The sandbox-exec trace format records one s-expression per decision.
|
||||
# We extract the deny records, normalise the per-host paths into
|
||||
# parameterised allow rules, and dedupe.
|
||||
|
||||
summarise_traces() {
|
||||
awk '
|
||||
/\(deny / {
|
||||
sub(/.*\(deny /, "")
|
||||
sub(/\).*/, "")
|
||||
print
|
||||
}
|
||||
' | sort -u
|
||||
}
|
||||
|
||||
# ── Emit seed for one profile ────────────────────────────────────────────────
|
||||
|
||||
emit_seed() {
|
||||
local profile_name="$1"
|
||||
shift
|
||||
local langs=("$@")
|
||||
|
||||
local raw="$SEED_DIR/$profile_name.trace.raw"
|
||||
: >"$raw"
|
||||
|
||||
for lang in "${langs[@]}"; do
|
||||
echo ";; ── trace from $lang probe ───────────────────────────" >>"$raw"
|
||||
trace_one "$profile_name" "$lang" >>"$raw" || true
|
||||
done
|
||||
|
||||
if [[ ! -s "$raw" ]]; then
|
||||
echo "sb-trace: no deny traces captured for $profile_name" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local seed="$SEED_DIR/$profile_name.allow"
|
||||
{
|
||||
echo ";; tools/sb-trace/$profile_name.allow"
|
||||
echo ";; Generated by tools/sb-trace.sh against per-language harness corpus."
|
||||
echo ";; Hand-review before commit: paths under \$HOME need to be regex'd"
|
||||
echo ";; rather than literalised so the seed survives a different host's"
|
||||
echo ";; \$HOME layout."
|
||||
echo ";;"
|
||||
echo ";; Languages walked: ${langs[*]}"
|
||||
echo ";; Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo
|
||||
summarise_traces <"$raw" | sed 's/^/(allow /;s/$/)/'
|
||||
} >"$seed"
|
||||
|
||||
echo "sb-trace: wrote $seed ($(wc -l <"$seed" | tr -d ' ') lines)"
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
for profile in "${selected_profiles[@]}"; do
|
||||
emit_seed "$profile" "${selected_langs[@]}"
|
||||
done
|
||||
|
||||
echo "sb-trace: done."
|
||||
echo "Next steps:"
|
||||
echo " 1. Hand-review each tools/sb-trace/*.allow seed"
|
||||
echo " 2. Replace host-specific literal paths with regex matches"
|
||||
echo " (e.g. /Users/<you>/.pyenv/... -> ^/Users/[^/]+/\\.pyenv/)"
|
||||
echo " 3. Commit the .allow files; the .trace.raw files are .gitignore'd"
|
||||
echo " 4. Run nyx with NYX_SB_DENY_DEFAULT=1 to exercise the splice"
|
||||
77
tools/sb-trace/README.md
Normal file
77
tools/sb-trace/README.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# sb-trace seeds
|
||||
|
||||
This directory holds per-capability allowlist seeds for the macOS
|
||||
sandbox-exec deny-default rollout.
|
||||
|
||||
## What the seeds are
|
||||
|
||||
Each `.allow` file is a fragment of sandbox-exec profile syntax (one
|
||||
or more `(allow ...)` directives, plus comments). At runtime,
|
||||
`src/dynamic/sandbox/process_macos.rs::profile_path` consults the
|
||||
`NYX_SB_DENY_DEFAULT` environment variable; when set, it locates the
|
||||
seed for the active capability, rewrites the baked profile's
|
||||
`(allow default)` directive to `(deny default)`, and appends the seed
|
||||
body verbatim. Sandbox-exec resolves later directives over earlier
|
||||
ones, so the appended allow rules stack on top of the deny baseline.
|
||||
|
||||
The splice path lives in `process_macos.rs::splice_deny_default`; it
|
||||
is pure, unit-tested, and a no-op when the seed for a capability is
|
||||
missing. Misconfiguration cannot brick the sandbox-exec backend.
|
||||
|
||||
## How the seeds get generated
|
||||
|
||||
Run `tools/sb-trace.sh` from a macOS host that has the interpreters
|
||||
on `$PATH`. The script materialises each `.sb` profile in
|
||||
deny-default form, runs the per-language harness cold-start
|
||||
(`python3 -c 'import socket,subprocess,...'`, `node -e require(...)`,
|
||||
etc.) under it, captures the sandbox-exec trace, and emits a
|
||||
candidate seed.
|
||||
|
||||
Output goes to this directory:
|
||||
|
||||
tools/sb-trace/<cap>.allow (committed)
|
||||
tools/sb-trace/<cap>.trace.raw (audit artifact, gitignored)
|
||||
|
||||
After a run, hand-review each `.allow` seed before committing. The
|
||||
script's emitted seeds usually need two passes:
|
||||
|
||||
1. Replace host-specific literal paths with regex matches. For
|
||||
instance `/Users/eli/.pyenv/versions/3.11/lib/python3.11/...`
|
||||
should become a regex anchored on `^/Users/[^/]+/\\.pyenv/`.
|
||||
2. Group related `mach-lookup` rules into one allow directive when
|
||||
they share a service prefix.
|
||||
|
||||
## Activating a seed at runtime
|
||||
|
||||
Set both env vars before invoking `nyx`:
|
||||
|
||||
export NYX_SB_DENY_DEFAULT=1
|
||||
export NYX_SB_SEED_DIR="$(pwd)/tools/sb-trace"
|
||||
|
||||
The seed dir defaults to `tools/sb-trace/` relative to the workspace
|
||||
root, so the second env var is only needed when running outside the
|
||||
workspace.
|
||||
|
||||
The runtime splice is opt-in. Production builds leave the baked
|
||||
`(allow default)` body intact unless the operator flips the env var.
|
||||
|
||||
## Verifying a seed end-to-end
|
||||
|
||||
The smoke test `deny_default_seed_loads_under_strict` in
|
||||
`tests/sandbox_hardening_macos.rs` exercises the splice through the
|
||||
production call site. It writes a synthetic seed to a tempdir,
|
||||
points `NYX_SB_SEED_DIR` at it, calls `profile_path`, and asserts the
|
||||
materialised file contains both `(deny default)` and the synthetic
|
||||
seed body.
|
||||
|
||||
For a real-host smoke test against a generated seed, run:
|
||||
|
||||
NYX_SB_DENY_DEFAULT=1 \
|
||||
NYX_SB_SEED_DIR="$(pwd)/tools/sb-trace" \
|
||||
cargo nextest run --features dynamic --test sandbox_hardening_macos
|
||||
|
||||
When every cap profile has a seed that lets the python3 / node
|
||||
cold-start clear, the macOS strict-mode acceptance row in
|
||||
`.github/workflows/dynamic.yml` flips from "ships (allow default)" to
|
||||
"ships deny-default by default" — that's the closing condition for
|
||||
the Phase 18 follow-up.
|
||||
Loading…
Add table
Add a link
Reference in a new issue