From 0bbf50673b1b9fc7977ef03f51601c0e7c8246d4 Mon Sep 17 00:00:00 2001 From: jamesboyzj-design Date: Thu, 1 Oct 2026 02:35:28 +0800 Subject: [PATCH 1/3] fix(tls): generate MITM certificates accepted by strict X509 clients Set leaf Authority Key Identifier and CA keyCertSign/cRLSign usages. Add a real local HTTPS probe with Python strict verification and synthetic credential injection; do not weaken client verification. --- crates/sandlock-cli/tests/strict_tls.py | 74 +++++++++++++++++++ crates/sandlock-cli/tests/strict_tls.rs | 18 +++++ .../sandlock-core/src/transparent_proxy/ca.rs | 3 +- .../src/transparent_proxy/tls.rs | 3 + 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 crates/sandlock-cli/tests/strict_tls.py create mode 100644 crates/sandlock-cli/tests/strict_tls.rs diff --git a/crates/sandlock-cli/tests/strict_tls.py b/crates/sandlock-cli/tests/strict_tls.py new file mode 100644 index 00000000..d893a31a --- /dev/null +++ b/crates/sandlock-cli/tests/strict_tls.py @@ -0,0 +1,74 @@ +"""Linux integration probe: openssl + Python stdlib, strict verification enabled.""" +import http.server +import os +from pathlib import Path +import ssl +import subprocess +import sys +import tempfile +import threading + + +def openssl(*args): + subprocess.run(['openssl', *args], check=True, capture_output=True, timeout=15) + + +def main(binary): + with tempfile.TemporaryDirectory(prefix='sandlock-strict-tls-') as directory: + root = Path(directory) + ca, key, leaf, leafkey, csr = (root / n for n in ('ca.pem', 'ca.key', 'leaf.pem', 'leaf.key', 'leaf.csr')) + openssl('req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key), + '-out', str(ca), '-days', '1', '-subj', '/CN=Test upstream CA') + openssl('req', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(leafkey), + '-out', str(csr), '-subj', '/CN=localhost') + ext = root / 'leaf.ext' + ext.write_text('subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n' + 'extendedKeyUsage=serverAuth\nauthorityKeyIdentifier=keyid,issuer\n') + openssl('x509', '-req', '-in', str(csr), '-CA', str(ca), '-CAkey', str(key), + '-CAcreateserial', '-out', str(leaf), '-days', '1', '-extfile', str(ext)) + received = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + received.append(self.headers.get('Authorization') == 'Bearer synthetic-tls-probe') + self.send_response(200) + self.send_header('Content-Length', '2') + self.end_headers() + self.wfile.write(b'ok') + + def log_message(*args): + pass + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(leaf, leafkey) + server.socket = context.wrap_socket(server.socket, server_side=True) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + args = [binary, 'run', '--timeout', '10', '--clean-env'] + for path in ('/usr', '/bin', '/lib', '/lib64', '/etc'): + if Path(path).exists(): + args += ['-r', path] + args += ['--http-allow', 'GET localhost/check', '--http-port', str(server.server_port), + '--http-inject-ca', '/etc/ssl/certs/ca-certificates.crt', + '--credential', 'test=env:SL_TEST_CREDENTIAL', + '--http-auth', 'GET localhost/check bearer test', '--', 'python3', '-c', + 'import os,ssl,urllib.request;' + 'assert "SL_TEST_CREDENTIAL" not in os.environ;' + 'ctx=ssl.create_default_context(cafile="/etc/ssl/certs/ca-certificates.crt");' + 'ctx.verify_flags |= ssl.VERIFY_X509_STRICT;' + f'assert urllib.request.urlopen("https://localhost:{server.server_port}/check",' + 'context=ctx,timeout=5).read()==b"ok"'] + env = {'PATH': '/usr/bin:/bin', 'HOME': str(Path.home()), 'SSL_CERT_FILE': str(ca), + 'SL_TEST_CREDENTIAL': 'synthetic-tls-probe'} + result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=20) + assert result.returncode == 0, result.stderr + assert received == [True], received + print('PASS: strict TLS verified; synthetic credential received by real HTTPS server') + finally: + server.shutdown() + server.server_close() + + +if __name__ == '__main__': + main(sys.argv[1]) diff --git a/crates/sandlock-cli/tests/strict_tls.rs b/crates/sandlock-cli/tests/strict_tls.rs new file mode 100644 index 00000000..2dd4a60b --- /dev/null +++ b/crates/sandlock-cli/tests/strict_tls.rs @@ -0,0 +1,18 @@ +use std::path::Path; +use std::process::Command; + +#[test] +fn strict_python_tls_uses_generated_ca_and_leaf() { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/strict_tls.py"); + let out = Command::new("python3") + .arg(script) + .arg(env!("CARGO_BIN_EXE_sandlock")) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/crates/sandlock-core/src/transparent_proxy/ca.rs b/crates/sandlock-core/src/transparent_proxy/ca.rs index a11c07ad..888791cb 100644 --- a/crates/sandlock-core/src/transparent_proxy/ca.rs +++ b/crates/sandlock-core/src/transparent_proxy/ca.rs @@ -8,13 +8,14 @@ use rcgen::{CertificateParams, KeyPair}; /// Pre-generated dummy CA for HTTP-only mode, avoiding per-spawn keygen cost. fn dummy_ca() -> std::io::Result<(KeyPair, rcgen::Certificate)> { - use rcgen::{BasicConstraints, DnType, IsCa}; + use rcgen::{BasicConstraints, DnType, IsCa, KeyUsagePurpose}; let kp = KeyPair::generate().map_err(|e| { std::io::Error::new(std::io::ErrorKind::Other, format!("keygen failed: {e}")) })?; let mut params = CertificateParams::default(); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; // A distinct subject DN is required: leaf certs minted under this CA must // have a subject that differs from their issuer, otherwise an empty-DN leaf // looks self-signed (subject == issuer) and clients reject it. diff --git a/crates/sandlock-core/src/transparent_proxy/tls.rs b/crates/sandlock-core/src/transparent_proxy/tls.rs index 280e3e77..e06ac87f 100644 --- a/crates/sandlock-core/src/transparent_proxy/tls.rs +++ b/crates/sandlock-core/src/transparent_proxy/tls.rs @@ -47,6 +47,9 @@ impl CertSigner { // Give the leaf a subject CN distinct from the CA's subject, so the leaf // is not mistaken for self-signed (subject == issuer) by clients. params.distinguished_name.push(DnType::CommonName, sni); + // RFC 5280 4.2.1.1: non-self-signed certificates identify their issuer's + // key. OpenSSL strict verification (Python 3.13+) requires this. + params.use_authority_key_identifier_extension = true; // signed_by(public_key, issuer_cert, issuer_key): leaf public key is the // leaf KeyPair (impl PublicKeyData), signed by the CA cert + CA key. let leaf = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key).map_err(|e| { From cb29ed2f776de943e035af93e9934633603eac33 Mon Sep 17 00:00:00 2001 From: jamesboyzj-design Date: Thu, 1 Oct 2026 02:53:34 +0800 Subject: [PATCH 2/3] feat(cli): expose versioned runtime events and minimal device defaults Add protected opt-in JSONL observations and COW change records without claiming kernel outcome coverage. Add validated standard character-device grants, explicit CLI opt-out and library opt-in. Cover spoofed output, timeout, path isolation, invalid encoding and real Git operation. --- crates/sandlock-cli/src/events.rs | 228 +++++++++++++++++++ crates/sandlock-cli/src/main.rs | 77 ++++++- crates/sandlock-cli/tests/runtime_events.rs | 234 ++++++++++++++++++++ crates/sandlock-core/src/sandbox/builder.rs | 32 +++ docs/events-jsonl.md | 105 +++++++++ 5 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 crates/sandlock-cli/src/events.rs create mode 100644 crates/sandlock-cli/tests/runtime_events.rs create mode 100644 docs/events-jsonl.md diff --git a/crates/sandlock-cli/src/events.rs b/crates/sandlock-cli/src/events.rs new file mode 100644 index 00000000..803350ea --- /dev/null +++ b/crates/sandlock-cli/src/events.rs @@ -0,0 +1,228 @@ +//! Versioned supervisor observations, never a claim of complete syscall audit. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sandlock_core::policy_fn::{SyscallCategory, SyscallEvent}; +use sandlock_core::{Change, Entry}; +use serde_json::{json, Value}; + +const MAX_BYTES: u64 = 64 * 1024 * 1024; + +struct State { + file: File, + sequence: u64, + bytes: u64, + failed: bool, +} + +#[derive(Clone)] +pub(crate) struct Events(Arc>); + +impl Events { + /// Create a new regular file only, outside every writable or mounted tree. + pub(crate) fn create(path: &Path, grants: &[PathBuf]) -> io::Result<(Self, PathBuf)> { + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let path = parent.canonicalize()?.join(path.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "events path requires a file name", + ) + })?); + for grant in grants { + if path.starts_with(grant.canonicalize()?) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "events file must be outside sandbox write/mount/workdir grants", + )); + } + } + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path)?; + Ok(( + Self(Arc::new(Mutex::new(State { + file, + sequence: 0, + bytes: 0, + failed: false, + }))), + path, + )) + } + + pub(crate) fn emit(&self, kind: &str, detail: Value) -> io::Result<()> { + let mut state = self + .0 + .lock() + .map_err(|_| io::Error::other("events lock poisoned"))?; + if state.failed { + return Err(io::Error::other("events stream already failed")); + } + state.sequence += 1; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let mut line = serde_json::to_vec(&json!({ + "schema_version": 1, "sequence": state.sequence, "ts_unix_ms": ts, + "source": "sandlock-cli", "type": kind, "detail": detail, + }))?; + line.push(b'\n'); + if state.bytes + line.len() as u64 > MAX_BYTES { + state.failed = true; + return Err(io::Error::other("events stream exceeded 64 MiB")); + } + if let Err(e) = state.file.write_all(&line) { + state.failed = true; + return Err(e); + } + state.bytes += line.len() as u64; + Ok(()) + } + + pub(crate) fn syscall(&self, event: SyscallEvent) -> io::Result<()> { + if event + .path + .iter() + .chain(event.path2.iter()) + .any(|p| p.to_str().is_none()) + { + if let Ok(mut state) = self.0.lock() { + state.failed = true; + } + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "non-UTF-8 event path", + )); + } + let category = match event.category { + SyscallCategory::File => "file", + SyscallCategory::Network => "network", + SyscallCategory::Process => "process", + SyscallCategory::Memory => "memory", + }; + self.emit( + "syscall", + json!({ + "syscall": event.syscall, "category": category, "pid": event.pid, + "parent_pid": event.parent_pid, "host": event.host, "port": event.port, + "protocol": event.protocol, "fd": event.fd, "size": event.size, + "path": event.path, "path2": event.path2, "flags": event.flags, + "supervisor_denied": event.denied, + "kernel_outcome": "not_observed", "path_is_observation_only": true, + "argv_omitted": true, + }), + ) + } + + pub(crate) fn changes(&self, changes: &[Change], dry_run: bool) -> io::Result<()> { + for change in changes { + let path = change.path.to_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "non-UTF-8 change path") + })?; + self.emit("change", json!({ + "path": path, "kind": change.kind().to_string(), + "before": change.before.as_ref().map(entry), "after": change.after.as_ref().map(entry), + "dry_run": dry_run, "phase": "cow_before_branch_action", + }))?; + } + Ok(()) + } + + pub(crate) fn sync(&self) -> io::Result<()> { + let state = self + .0 + .lock() + .map_err(|_| io::Error::other("events lock poisoned"))?; + if state.failed { + return Err(io::Error::other( + "events incomplete: write failed or size limit exceeded", + )); + } + state.file.sync_all() + } +} + +fn entry(entry: &Entry) -> Value { + // No bytes, symlink target or digest: avoid copying data into audit by default. + json!({"kind": format!("{:?}", entry.kind).to_lowercase(), "mode": entry.mode, "size": entry.size}) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protected_file_is_exclusive_and_rejects_writable_parent() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("events.jsonl"); + assert!(Events::create(&path, &[root.path().to_owned()]).is_err()); + let (events, _) = Events::create(&path, &[]).unwrap(); + events.emit("start", json!({})).unwrap(); + events + .emit("finish", json!({"status":"succeeded"})) + .unwrap(); + events.sync().unwrap(); + assert!(Events::create(&path, &[]).is_err()); + let text = std::fs::read_to_string(&path).unwrap(); + let rows: Vec = text + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!(rows[0]["sequence"], 1); + assert_eq!(rows[1]["sequence"], 2); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn rejects_symlink_and_marks_write_limit_failure() { + let root = tempfile::tempdir().unwrap(); + let link = root.path().join("link"); + std::os::unix::fs::symlink(root.path().join("target"), &link).unwrap(); + assert!(Events::create(&link, &[]).is_err()); + let (events, _) = Events::create(&root.path().join("log"), &[]).unwrap(); + events.0.lock().unwrap().bytes = MAX_BYTES; + assert!(events.emit("syscall", json!({})).is_err()); + assert!(events.sync().is_err()); + } + + #[test] + fn non_utf8_observation_fails_without_panicking_or_claiming_complete() { + use std::os::unix::ffi::OsStringExt; + let root = tempfile::tempdir().unwrap(); + let (events, _) = Events::create(&root.path().join("log"), &[]).unwrap(); + let event = SyscallEvent { + syscall: "openat".into(), + category: SyscallCategory::File, + pid: 1, + parent_pid: None, + host: None, + port: None, + size: None, + argv: None, + denied: false, + path: Some(std::ffi::OsString::from_vec(vec![255]).into()), + path2: None, + flags: None, + protocol: None, + fd: None, + }; + assert!(events.syscall(event).is_err()); + assert!(events.sync().is_err()); + } +} diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index e2634458..11766c08 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::time::SystemTime; mod learn; +mod events; #[derive(Parser)] #[command(name = "sandlock", about = "Lightweight process sandbox", version)] struct Cli { @@ -133,6 +134,10 @@ struct RunArgs { #[arg(long = "status-fd", value_name = "FD")] status_fd: Option, + /// Write versioned supervisor observations to a new, private JSONL file + #[arg(long, value_name = "PATH")] + events_jsonl: Option, + /// Sandbox name (also exposed as the virtual hostname; auto-generated if omitted) #[arg(long)] name: Option, @@ -156,6 +161,10 @@ struct RunArgs { #[arg(long)] no_supervisor: bool, + /// Do not add the minimal standard character-device grants + #[arg(long)] + no_default_devices: bool, + /// Allow the named protection to degrade silently if the host kernel ABI lacks support. /// Repeatable. Accepted values: fs-refer, fs-truncate, net-tcp, fs-ioctl-dev, /// signal-scope, abstract-unix-socket-scope. @@ -703,6 +712,29 @@ async fn run_command(args: RunArgs) -> Result { builder = builder.disable(parse_protection(s).map_err(|e| anyhow!(e))?); } + if !args.no_default_devices { + builder = builder.standard_devices()?; + } + let events = if let Some(ref path) = args.events_jsonl { + let mut grants = builder.fs_writable.clone(); + grants.extend(builder.workdir.iter().cloned()); + grants.extend(builder.chroot.iter().cloned()); + grants.extend(builder.fs_mount.iter().map(|(_, host)| host.clone())); + let (sink, path) = events::Events::create(path, &grants)?; + let callback = sink.clone(); + builder = builder.fs_deny(path).policy_fn(move |event, _ctx| { + // Never relax the static policy. On logging failure held operations + // fail closed; observation-only operations cannot be recalled. + if callback.syscall(event).is_ok() { + sandlock_core::policy_fn::Verdict::Allow + } else { + sandlock_core::policy_fn::Verdict::Deny + } + }); + Some(sink) + } else { + None + }; let policy = builder.build()?; let cmd_strs: Vec<&str> = if let Some(ref shell_cmd) = args.exec_shell { vec!["/bin/sh", "-c", shell_cmd.as_str()] @@ -733,20 +765,60 @@ async fn run_command(args: RunArgs) -> Result { policy.on_error = BranchAction::Abort; } + if let Some(ref sink) = events { + sink.emit("start", serde_json::json!({ + "supervisor_pid": std::process::id(), "argv_omitted": true, + "dry_run": args.dry_run, "timeout_seconds": args.timeout, + "coverage": "policy_fn observations, not all kernel outcomes", + }))?; + } let result = if let Some(secs) = args.timeout { match tokio::time::timeout( std::time::Duration::from_secs(secs), policy.run_interactive(&cmd_strs), ).await { - Ok(r) => r?, + Ok(r) => r, Err(_) => { eprintln!("sandlock: timeout after {}s", secs); + drop(policy); + if let Some(ref sink) = events { + sink.emit("finish", serde_json::json!({ + "status": "timed_out", "exit_code": 124, "changes_available": false, + }))?; + sink.sync()?; + } return Ok(124); } } } else { - policy.run_interactive(&cmd_strs).await? + policy.run_interactive(&cmd_strs).await }; + let result = match result { + Ok(result) => result, + Err(error) => { + drop(policy); + if let Some(ref sink) = events { + sink.emit("finish", serde_json::json!({ + "status": "runtime_error", "changes_available": false, + }))?; + sink.sync()?; + } + return Err(error.into()); + } + }; + // Drain observation callbacks and finalize the existing branch lifecycle + // before emitting a terminal event. Changes still describe the pre-action + // COW snapshot, not an independently verified commit receipt. + drop(policy); + if let Some(ref sink) = events { + sink.changes(&result.changes, args.dry_run)?; + sink.emit("finish", serde_json::json!({ + "status": if result.success() { "succeeded" } else { "failed" }, + "exit_code": result.code(), "exit_status": format!("{:?}", result.exit_status), + "changes_available": true, "change_count": result.changes.len(), + }))?; + sink.sync()?; + } if args.dry_run { if result.changes.is_empty() { @@ -831,6 +903,7 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> { if args.gpu.is_some() { bad.push("--gpu"); } if args.dry_run { bad.push("--dry-run"); } if args.status_fd.is_some() { bad.push("--status-fd"); } + if args.events_jsonl.is_some() { bad.push("--events-jsonl"); } if !pb.fs_denied.is_empty() { bad.push("--fs-deny"); } if !args.fs_mount.is_empty() { bad.push("--fs-mount"); } diff --git a/crates/sandlock-cli/tests/runtime_events.rs b/crates/sandlock-cli/tests/runtime_events.rs new file mode 100644 index 00000000..92c9ae77 --- /dev/null +++ b/crates/sandlock-cli/tests/runtime_events.rs @@ -0,0 +1,234 @@ +use serde_json::Value; +use std::path::Path; +use std::process::{Command, Output}; + +fn run(extra: &[&str], command: &[&str]) -> Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_sandlock")); + cmd.args(["run", "--timeout", "10"]); + for path in ["/usr", "/bin", "/lib", "/lib64", "/etc"] { + if Path::new(path).exists() { + cmd.args(["-r", path]); + } + } + cmd.args(extra).arg("--").args(command).output().unwrap() +} + +fn rows(path: &Path) -> Vec { + std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +#[test] +fn default_devices_support_shell_without_granting_all_dev() { + let out = run( + &[], + &[ + "python3", + "-c", + r#" +import os +with open('/dev/null','wb') as f: f.write(b'hello') +for p in ('/dev/zero','/dev/random','/dev/urandom'): + with open(p,'rb') as f: assert len(f.read(1)) == 1 +for p in ('/dev/full','/dev/zero','/dev/random','/dev/urandom'): + try: fd=os.open(p,os.O_WRONLY) + except PermissionError: pass + else: + os.close(fd) + raise AssertionError('unexpected writable device: '+p) +"#, + ], + ); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(!run( + &["--no-default-devices"], + &["sh", "-c", "echo hi >/dev/null"] + ) + .status + .success()); + assert!(!run( + &["--fs-deny", "/dev/null"], + &["sh", "-c", "echo hi >/dev/null"] + ) + .status + .success()); +} + +#[test] +fn standard_device_validation_rejects_replaced_nodes_in_rootfs() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("dev")).unwrap(); + let node = root.path().join("dev/null"); + std::fs::write(&node, "not a device").unwrap(); + assert!(sandlock_core::Sandbox::builder() + .chroot(root.path()) + .standard_devices() + .is_err()); + std::fs::remove_file(&node).unwrap(); + std::os::unix::fs::symlink("/dev/null", &node).unwrap(); + assert!(sandlock_core::Sandbox::builder() + .chroot(root.path()) + .standard_devices() + .is_err()); +} + +#[test] +fn git_commit_works_without_broad_dev_or_null_workaround() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().to_str().unwrap(); + let out = run(&["-w", project, "--workdir", project, "--cwd", project], + &["sh", "-c", "git init -q && echo test > file && git add file && git -c user.name=Test -c user.email=test@example.invalid -c commit.gpgsign=false commit -qm test"]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(root.path().join(".git/HEAD").exists()); +} + +#[test] +fn jsonl_is_separate_ordered_and_does_not_copy_argv() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("audit.jsonl"); + let out = run( + &[ + "--events-jsonl", + file.to_str().unwrap(), + "--fs-deny", + "/etc/group", + ], + &[ + "sh", + "-c", + "echo '{\"type\":\"FORGED\"}'; cat /etc/group >/dev/null; exit 7", + "SYNTHETIC_ARG_DO_NOT_LOG", + ], + ); + assert_eq!( + out.status.code(), + Some(7), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let events = rows(&file); + assert_eq!(events.first().unwrap()["type"], "start"); + assert_eq!(events.last().unwrap()["type"], "finish"); + assert_eq!(events.last().unwrap()["detail"]["exit_code"], 7); + for (i, event) in events.iter().enumerate() { + assert_eq!(event["schema_version"], 1); + assert_eq!(event["sequence"], i + 1); + } + assert!(events + .iter() + .any(|e| e["detail"]["supervisor_denied"] == true)); + let raw = std::fs::read_to_string(file).unwrap(); + assert!(!raw.contains("SYNTHETIC_ARG_DO_NOT_LOG")); + assert!(!raw.contains("FORGED")); + assert!(String::from_utf8_lossy(&out.stdout).contains("FORGED")); +} + +#[test] +fn reports_real_cow_changes_without_claiming_commit() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let file = root.path().join("audit.jsonl"); + let out = run( + &[ + "--events-jsonl", + file.to_str().unwrap(), + "--workdir", + project.to_str().unwrap(), + "-w", + project.to_str().unwrap(), + "--dry-run", + ], + &[ + "sh", + "-c", + &format!("echo preview > {}/created", project.display()), + ], + ); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(!project.join("created").exists()); + assert!(rows(&file).iter().any(|e| e["type"] == "change" + && e["detail"]["path"] == "created" + && e["detail"]["kind"] == "A" + && e["detail"]["phase"] == "cow_before_branch_action")); +} + +#[test] +fn timeout_has_terminal_event_but_child_124_is_not_timeout() { + let root = tempfile::tempdir().unwrap(); + let first = root.path().join("first.jsonl"); + // Call directly so the helper's timeout argument is not duplicated. + let out = Command::new(env!("CARGO_BIN_EXE_sandlock")) + .args([ + "run", + "-r", + "/usr", + "-r", + "/lib", + "--timeout", + "1", + "--events-jsonl", + ]) + .arg(&first) + .args(["--", "sleep", "3"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(124)); + assert_eq!( + rows(&first).last().unwrap()["detail"]["status"], + "timed_out" + ); + let second = root.path().join("second.jsonl"); + let out = run( + &["--events-jsonl", second.to_str().unwrap()], + &["sh", "-c", "exit 124"], + ); + assert_eq!(out.status.code(), Some(124)); + assert_eq!(rows(&second).last().unwrap()["detail"]["status"], "failed"); +} + +#[test] +fn events_cannot_be_overwritten_or_placed_inside_project() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("audit.jsonl"); + assert!(!run( + &[ + "-w", + root.path().to_str().unwrap(), + "--events-jsonl", + file.to_str().unwrap() + ], + &["touch", "/tmp/must-not-run"] + ) + .status + .success()); + assert!(!file.exists()); + std::fs::write(&file, "keep").unwrap(); + assert!(!run(&["--events-jsonl", file.to_str().unwrap()], &["true"]) + .status + .success()); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep"); + let output = Command::new(env!("CARGO_BIN_EXE_sandlock")) + .args(["run", "--no-supervisor", "--events-jsonl"]) + .arg(root.path().join("unsupported")) + .args(["--", "true"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--events-jsonl")); +} diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 298a1cd3..88c603cf 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -421,6 +421,38 @@ impl SandboxBuilder { } } + /// Add the minimal standard character devices without granting `/dev`. + /// Null is read/write; zero and the two random sources are read-only. + /// Missing nodes are skipped. Unexpected types, symlinks or device numbers + /// fail closed. Library callers opt in; the CLI enables this by default. + pub fn standard_devices(mut self) -> Result { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + for (name, minor, writable) in [ + ("/dev/null", 3, true), + ("/dev/zero", 5, false), + ("/dev/random", 8, false), + ("/dev/urandom", 9, false), + ] { + let host = self.chroot.as_ref().map_or_else( + || PathBuf::from(name), |root| root.join(name.trim_start_matches('/'))); + let metadata = match std::fs::symlink_metadata(&host) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(SandboxError::Invalid(format!("standard device {name}: {e}"))), + }; + if !metadata.file_type().is_char_device() + || libc::major(metadata.rdev()) != 1 || libc::minor(metadata.rdev()) != minor + { + return Err(SandboxError::Invalid(format!("unexpected standard device: {name}"))); + } + let grants = if writable { &mut self.fs_writable } else { &mut self.fs_readable }; + if !grants.iter().any(|p| p == std::path::Path::new(name)) { + grants.push(PathBuf::from(name)); + } + } + Ok(self) + } + pub fn fs_deny(mut self, path: impl Into) -> Self { self.fs_denied.push(path.into()); self diff --git a/docs/events-jsonl.md b/docs/events-jsonl.md new file mode 100644 index 00000000..9a616a3e --- /dev/null +++ b/docs/events-jsonl.md @@ -0,0 +1,105 @@ +# CLI runtime observations and standard devices + +## Opt-in JSONL output + +```sh +mkdir -p "$HOME/sandlock-audit" +sandlock run -r /usr -r /lib -r /etc -w "$PWD" --workdir "$PWD" \ + --events-jsonl "$HOME/sandlock-audit/run-001.jsonl" -- python3 task.py +``` + +`--events-jsonl PATH` creates a **new** 0600 regular file, independently of child +stdout/stderr. It refuses an existing file, symlink, missing parent, or a location +inside a sandbox writable grant, workdir, chroot or mount. The supervisor also +adds a deny rule for the file. Keep its parent outside untrusted write grants. +This protects against sandbox writes, not a hostile host user or administrator. +`--no-supervisor` is incompatible with this option. + +Every line has: + +- `schema_version`: 1; +- `sequence`: strictly increasing, starting at 1; +- `ts_unix_ms`: supervisor wall-clock timestamp, not a monotonic clock; +- `source`: `sandlock-cli`; +- `type`: `start`, `syscall`, `change`, or `finish`; +- `detail`: event-specific fields. + +`start` records supervisor PID, timeout, dry-run flag and coverage. It intentionally +does not copy command argv, environment, credential values or child output. + +`syscall` projects the existing `policy_fn` event: name/category, PID/parent PID, +destination IP/port/protocol/fd, size, observed paths and open flags. It omits +argv entirely. Paths and addresses may themselves be sensitive: treat this +file as private operational data. Non-UTF-8/unrepresentable values fail logging +rather than silently converting the evidence to a successful result. + +**Coverage and verdict semantics are limited:** + +- These are intercepted supervisor observations, not a complete kernel syscall + return trace. Enabling `policy_fn` incurs existing interception, process + tracking and argv-freeze overhead, even though argv is not exported. +- `supervisor_denied=true` is the existing event's `denied` field: the supervisor + chose an errno response. It does not independently classify every errno as a + security-policy rejection. The API does not supply the final errno here. +- `supervisor_denied=false` is **not** an allow/success verdict. Landlock or a + subsequent kernel operation can still fail. `kernel_outcome=not_observed` + makes that explicit. Paths are observation-only, not TOCTOU-safe authorization + inputs. The callback never relaxes the existing policy. + +`change` comes from `RunResult.changes`. It includes relative path, A/M/D kind, +before/after kind/mode/size, dry-run flag and `phase=cow_before_branch_action`. +It deliberately excludes file content, link targets and hashes. It is a COW +change set captured before the branch action, **not a commit receipt**. For +example, dry-run and aborted runs may report changes that never land in the +workdir. Non-COW writes are outside this result's coverage. + +`finish` records succeeded/failed exit, runtime error, or timed_out. A timeout +has exit 124 and `changes_available=false`; it never invents an empty change +set. A normal child exit 124 is failed, not timed_out. Callbacks are drained +before the terminal event; writes are synced before returning from the CLI. +The existing status-fd format and timeout behavior are unchanged. + +The file is capped at 64 MiB. Write/cap failures cause held callback operations +to be denied and prevent a successful CLI completion. Observation-only effects +already performed cannot be recalled. SIGKILL, storage failure, initialization +failure, or host power loss can leave an empty/partial file with **no finish**. +Consumers must mark that stream incomplete rather than replaying the command. +This is not a tamper-evident, exactly-once or lossless kernel audit service. + +## Minimal device defaults + +`sandlock run` adds these existing character devices by default: + +| Device | Access | Linux major:minor | +|---|---|---| +| `/dev/null` | read/write | 1:3 | +| `/dev/zero` | read | 1:5 | +| `/dev/random` | read | 1:8 | +| `/dev/urandom` | read | 1:9 | + +The CLI validates each node's type and device number, and rejects symlinks or +unexpected nodes. Missing nodes are skipped; it never creates devices. For a +chroot, validation checks the node inside the rootfs. `/dev` itself, terminals, +block devices, GPU devices, `/dev/full`, and write access to random sources are +not included. Explicit denies continue to take precedence under supervision. + +Use `--no-default-devices` for the previous CLI behavior. Existing explicit +grants are not removed by this option. The Rust library builder remains opt-in: +`.standard_devices()?` provides the same validated grant set without changing +the library's default policy or its profile schema. A rendered effective +policy includes ordinary path grants, not an implicit hidden permission. + +## Validation + +```sh +cargo test --release -p sandlock-cli --test runtime_events +cargo test --release -p sandlock-cli --test strict_tls +``` + +Tests require real Linux sandbox support. The TLS integration probe also needs +Python 3 with `ssl.VERIFY_X509_STRICT`, OpenSSL CLI, and the system CA bundle at +`/etc/ssl/certs/ca-certificates.crt`. It uses a local TLS server and synthetic +credentials, keeps certificate verification enabled, and changes no global +trust store. Generated MITM leaves carry AKI; generated CAs declare signing key +usage. User-provided invalid CAs are not silently repaired. HTTPS-only credential +transport enforcement remains separate work. From c8a269d43bd0a8cb1d191b2fa929cd5cdde8c5b3 Mon Sep 17 00:00:00 2001 From: jamesboyzj-design Date: Sat, 3 Oct 2026 10:44:12 +0800 Subject: [PATCH 3/3] test(cli): use core strict TLS regression coverage --- crates/sandlock-cli/tests/strict_tls.py | 74 ------------------------ crates/sandlock-cli/tests/strict_tls.rs | 18 ------ crates/sandlock-core/src/image/docker.rs | 1 + docs/events-jsonl.md | 17 +++--- 4 files changed, 10 insertions(+), 100 deletions(-) delete mode 100644 crates/sandlock-cli/tests/strict_tls.py delete mode 100644 crates/sandlock-cli/tests/strict_tls.rs diff --git a/crates/sandlock-cli/tests/strict_tls.py b/crates/sandlock-cli/tests/strict_tls.py deleted file mode 100644 index d893a31a..00000000 --- a/crates/sandlock-cli/tests/strict_tls.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Linux integration probe: openssl + Python stdlib, strict verification enabled.""" -import http.server -import os -from pathlib import Path -import ssl -import subprocess -import sys -import tempfile -import threading - - -def openssl(*args): - subprocess.run(['openssl', *args], check=True, capture_output=True, timeout=15) - - -def main(binary): - with tempfile.TemporaryDirectory(prefix='sandlock-strict-tls-') as directory: - root = Path(directory) - ca, key, leaf, leafkey, csr = (root / n for n in ('ca.pem', 'ca.key', 'leaf.pem', 'leaf.key', 'leaf.csr')) - openssl('req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key), - '-out', str(ca), '-days', '1', '-subj', '/CN=Test upstream CA') - openssl('req', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(leafkey), - '-out', str(csr), '-subj', '/CN=localhost') - ext = root / 'leaf.ext' - ext.write_text('subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n' - 'extendedKeyUsage=serverAuth\nauthorityKeyIdentifier=keyid,issuer\n') - openssl('x509', '-req', '-in', str(csr), '-CA', str(ca), '-CAkey', str(key), - '-CAcreateserial', '-out', str(leaf), '-days', '1', '-extfile', str(ext)) - received = [] - - class Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - received.append(self.headers.get('Authorization') == 'Bearer synthetic-tls-probe') - self.send_response(200) - self.send_header('Content-Length', '2') - self.end_headers() - self.wfile.write(b'ok') - - def log_message(*args): - pass - - server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.load_cert_chain(leaf, leafkey) - server.socket = context.wrap_socket(server.socket, server_side=True) - threading.Thread(target=server.serve_forever, daemon=True).start() - try: - args = [binary, 'run', '--timeout', '10', '--clean-env'] - for path in ('/usr', '/bin', '/lib', '/lib64', '/etc'): - if Path(path).exists(): - args += ['-r', path] - args += ['--http-allow', 'GET localhost/check', '--http-port', str(server.server_port), - '--http-inject-ca', '/etc/ssl/certs/ca-certificates.crt', - '--credential', 'test=env:SL_TEST_CREDENTIAL', - '--http-auth', 'GET localhost/check bearer test', '--', 'python3', '-c', - 'import os,ssl,urllib.request;' - 'assert "SL_TEST_CREDENTIAL" not in os.environ;' - 'ctx=ssl.create_default_context(cafile="/etc/ssl/certs/ca-certificates.crt");' - 'ctx.verify_flags |= ssl.VERIFY_X509_STRICT;' - f'assert urllib.request.urlopen("https://localhost:{server.server_port}/check",' - 'context=ctx,timeout=5).read()==b"ok"'] - env = {'PATH': '/usr/bin:/bin', 'HOME': str(Path.home()), 'SSL_CERT_FILE': str(ca), - 'SL_TEST_CREDENTIAL': 'synthetic-tls-probe'} - result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=20) - assert result.returncode == 0, result.stderr - assert received == [True], received - print('PASS: strict TLS verified; synthetic credential received by real HTTPS server') - finally: - server.shutdown() - server.server_close() - - -if __name__ == '__main__': - main(sys.argv[1]) diff --git a/crates/sandlock-cli/tests/strict_tls.rs b/crates/sandlock-cli/tests/strict_tls.rs deleted file mode 100644 index 2dd4a60b..00000000 --- a/crates/sandlock-cli/tests/strict_tls.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::path::Path; -use std::process::Command; - -#[test] -fn strict_python_tls_uses_generated_ca_and_leaf() { - let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/strict_tls.py"); - let out = Command::new("python3") - .arg(script) - .arg(env!("CARGO_BIN_EXE_sandlock")) - .output() - .unwrap(); - assert!( - out.status.success(), - "{}\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); -} diff --git a/crates/sandlock-core/src/image/docker.rs b/crates/sandlock-core/src/image/docker.rs index 0a95f3ba..b6b78cd9 100644 --- a/crates/sandlock-core/src/image/docker.rs +++ b/crates/sandlock-core/src/image/docker.rs @@ -74,3 +74,4 @@ async fn save(docker: &Docker, name: &str, dest: &Path) -> Result<(), SandlockEr fn docker_error(msg: String) -> SandlockError { SandboxRuntimeError::Child(format!("docker-daemon: {msg}")).into() } + diff --git a/docs/events-jsonl.md b/docs/events-jsonl.md index 9a616a3e..1eb24d54 100644 --- a/docs/events-jsonl.md +++ b/docs/events-jsonl.md @@ -93,13 +93,14 @@ policy includes ordinary path grants, not an implicit hidden permission. ```sh cargo test --release -p sandlock-cli --test runtime_events -cargo test --release -p sandlock-cli --test strict_tls +cargo test --release -p sandlock-core --test integration test_http_strict_tls ``` -Tests require real Linux sandbox support. The TLS integration probe also needs -Python 3 with `ssl.VERIFY_X509_STRICT`, OpenSSL CLI, and the system CA bundle at -`/etc/ssl/certs/ca-certificates.crt`. It uses a local TLS server and synthetic -credentials, keeps certificate verification enabled, and changes no global -trust store. Generated MITM leaves carry AKI; generated CAs declare signing key -usage. User-provided invalid CAs are not silently repaired. HTTPS-only credential -transport enforcement remains separate work. +Tests require real Linux sandbox support. Strict TLS regression coverage lives +in `sandlock-core`: it uses a temporary injected CA bundle and inline Python +with `ssl.VERIFY_X509_STRICT`, then confirms the HTTP ACL returns its expected +403 response. It needs no upstream server, system CA bundle, or OpenSSL CLI. +Tool-free certificate structure tests check the minted leaf AKI against the CA +SKI and verify the CA `keyCertSign` usage. User-provided invalid CAs are not +silently repaired. HTTPS-only credential transport enforcement remains separate +work.