security: annotate secure memory unsafe sites
ober
df2f9c5b0ee16860b1b1fed50a687e331923547e
--- a/docs/ffi-audit.md +++ b/docs/ffi-audit.md @@ -41,9 +41,9 @@ Latest summary from `tools/ffi-audit-report.ss`: (c-file-count 5) (no-mangle-exports 196) (unsafe-sites 438) - (unsafe-sites-with-nearby-safety-comment 82) - (unsafe-sites-without-nearby-safety-comment 356) - (unsafe-comment-review-sites 356) + (unsafe-sites-with-nearby-safety-comment 97) + (unsafe-sites-without-nearby-safety-comment 341) + (unsafe-comment-review-sites 341) (export-review-sites 196)) (vendor (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated))) @@ -79,8 +79,11 @@ invariant comments. The `crypto.rs` digest, HMAC, random, constant-time comparison, AEAD, scrypt, PBKDF2, and Argon2id FFI buffer sites also gained nearby `SAFETY:` invariant comments; the same pass added fail-closed nonzero-null input checks for HMAC and AEAD optional buffers and checked AEAD -seal output length addition for overflow. The generated inventory now reports -82 annotated native unsafe sites and 356 remaining unsafe review sites. +seal output length addition for overflow. `secure_mem.rs` now documents the +`sysconf`, `mmap`, guard-page `mprotect`, pointer-offset, `mlock`/`munlock`, +`madvise`, `munmap`, wipe, and random-fill invariants around the secure memory +region lifecycle. The generated inventory now reports 97 annotated native +unsafe sites and 341 remaining unsafe review sites. Remaining work before closing K3-P1-01: --- a/docs/kimi3-security-recommmendations.md +++ b/docs/kimi3-security-recommmendations.md @@ -625,9 +625,11 @@ not started." invariant comments for the worker launch/seccomp unsafe sites. `crypto.rs` also gained invariant comments for digest/HMAC/random/compare/AEAD/KDF buffer sites plus fail-closed nonzero-null input checks and AEAD seal length - overflow checks. The generated report now shows 82 annotated native unsafe - sites and 356 remaining unsafe review sites. Remaining work: continue unsafe - invariant comments across the rest of `jerboa-native-rs`. + overflow checks. `secure_mem.rs` now documents the mmap/mlock/guard-page, + wipe, free, and random-fill unsafe invariants. The generated report now shows + 97 annotated native unsafe sites and 341 remaining unsafe review sites. + Remaining work: continue unsafe invariant comments across the rest of + `jerboa-native-rs`. ### K3-P1-02 — TOCTOU-safe filesystem capability checks **Serves:** G2. **Effort:** 1 week. @@ -1115,7 +1117,7 @@ Track these in `docs/status.md` per release: | Confined worker exists | facade, audit lifecycle, output caps, deadline, process-group kill, memory rlimit pre-exec path, explicit sandbox-axis refusal, egress proxy env wiring, and Linux syscall/ptrace seccomp pre-exec setup landed; Landlock path/net and Seatbelt/Capsicum parity pending | yes, tested (P0-02) | | Unclassified raw `read`/FASL/`load` sites | scanner-driven manifest classification gate closed 2026-07-27; trusted build, compiler, REPL/dev, and test surfaces are centrally justified by exact source line | 0 | | FFI bindings audited | scanner inventory and provisional verdicts in [ffi-audit.md](ffi-audit.md); native exports gated and justified | 100% with final verdicts | -| Un-annotated Rust `unsafe` blocks | 356 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 | +| Un-annotated Rust `unsafe` blocks | 341 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 | | Fuzz corpora / crash regressions | 0 / 0 | per-parser corpora + every crash a test | | Exploit-shaped regression tests | ~0 | ≥ 1 per historical finding | | Sandboxed-by-default app entry | no | yes (P0-06) | --- a/docs/reviews/2026-07-27-native-export-review.sexp +++ b/docs/reviews/2026-07-27-native-export-review.sexp @@ -112,7 +112,7 @@ (export (symbol "jerboa_seccomp_available") (file "jerboa-native-rs/src/seccomp.rs") (line 285) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 32)))) (export (symbol "jerboa_seccomp_lock") (file "jerboa-native-rs/src/seccomp.rs") (line 179) (scheme-callers 2 (caller (file "lib/std/os/seccomp.ss") (line 34)) (caller (file "lib/std/os/seccomp.ss") (line 36)))) (export (symbol "jerboa_seccomp_lock_strict") (file "jerboa-native-rs/src/seccomp.rs") (line 196) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 36)))) - (export (symbol "jerboa_secure_alloc") (file "jerboa-native-rs/src/secure_mem.rs") (line 28) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 35)))) + (export (symbol "jerboa_secure_alloc") (file "jerboa-native-rs/src/secure_mem.rs") (line 30) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 35)))) (export (symbol "jerboa_secure_dir_close") (file "jerboa-native-rs/src/secure_fs.rs") (line 463) (scheme-callers 2 (caller (file "lib/std/os/secure-output.ss") (line 33)) (caller (file "lib/std/os/secure-output.ss") (line 53)))) (export (symbol "jerboa_secure_dir_list") (file "jerboa-native-rs/src/secure_fs.rs") (line 633) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 62)))) (export (symbol "jerboa_secure_dir_mkdirs") (file "jerboa-native-rs/src/secure_fs.rs") (line 472) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 56)))) @@ -120,14 +120,14 @@ (export (symbol "jerboa_secure_dir_open_strict") (file "jerboa-native-rs/src/secure_fs.rs") (line 432) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 51)))) (export (symbol "jerboa_secure_entry_info") (file "jerboa-native-rs/src/secure_fs.rs") (line 502) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 59)))) (export (symbol "jerboa_secure_fd_close") (file "jerboa-native-rs/src/secure_fs.rs") (line 916) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 77)))) - (export (symbol "jerboa_secure_free") (file "jerboa-native-rs/src/secure_mem.rs") (line 113) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 38)))) + (export (symbol "jerboa_secure_free") (file "jerboa-native-rs/src/secure_mem.rs") (line 132) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 38)))) (export (symbol "jerboa_secure_output_abort") (file "jerboa-native-rs/src/secure_fs.rs") (line 907) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 75)))) (export (symbol "jerboa_secure_output_begin") (file "jerboa-native-rs/src/secure_fs.rs") (line 760) (scheme-callers 2 (caller (file "lib/std/os/secure-output.ss") (line 68)) (caller (file "lib/std/os/secure-output.ss") (line 71)))) (export (symbol "jerboa_secure_output_begin_checked") (file "jerboa-native-rs/src/secure_fs.rs") (line 816) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 71)))) (export (symbol "jerboa_secure_output_commit") (file "jerboa-native-rs/src/secure_fs.rs") (line 867) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 73)))) - (export (symbol "jerboa_secure_random_fill") (file "jerboa-native-rs/src/secure_mem.rs") (line 169) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 44)))) + (export (symbol "jerboa_secure_random_fill") (file "jerboa-native-rs/src/secure_mem.rs") (line 198) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 44)))) (export (symbol "jerboa_secure_read_file") (file "jerboa-native-rs/src/secure_fs.rs") (line 673) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 65)))) - (export (symbol "jerboa_secure_wipe") (file "jerboa-native-rs/src/secure_mem.rs") (line 156) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 41)))) + (export (symbol "jerboa_secure_wipe") (file "jerboa-native-rs/src/secure_mem.rs") (line 183) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 41)))) (export (symbol "jerboa_sha1") (file "jerboa-native-rs/src/crypto.rs") (line 71) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 60)))) (export (symbol "jerboa_sha256") (file "jerboa-native-rs/src/crypto.rs") (line 87) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 69)))) (export (symbol "jerboa_sha384") (file "jerboa-native-rs/src/crypto.rs") (line 184) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 78)))) --- a/docs/status.md +++ b/docs/status.md @@ -25,7 +25,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`, | Area | Current state | Remaining work | |---|---|---| | Kimi security handoff | [kimi3-security-recommmendations.md](kimi3-security-recommmendations.md) is the backlog. Dated evidence and review manifests live under [reviews/](reviews/). | Keep new security evidence in dated review records and summarize the current release state here. | -| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs` and the crypto FFI buffer path in `crypto.rs` now have nearby `SAFETY:` comments, reducing the generated unannotated unsafe-site count to 356. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. | +| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, and secure-memory region lifecycle in `secure_mem.rs` now have nearby `SAFETY:` comments, reducing the generated unannotated unsafe-site count to 341. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. | | Native Rust exports | The native export review now has 190 exported functions: 183 tracked Scheme references and 7 retained standalone C/binary helpers. The previous 35 no-Scheme-reference removal candidates no longer have C ABI export markers. | Re-run `make native-export-review-check` whenever adding or removing native exports. | | Confined worker | `(std security worker)` provides the facade, audit lifecycle, output caps, deadlines, process-group kill, memory rlimit pre-exec setup, Linux syscall/ptrace seccomp pre-exec setup for requested axes, explicit sandbox-axis refusal, and egress proxy env wiring. | Install native Landlock path/net rules in the worker pre-exec path and keep Linux/macOS/FreeBSD parity tests current. | | Safe surface | Direct scripts default to the safe prelude; raw access requires `--unsafe-prelude` or `(jerboa prelude unsafe)`. | Continue moving risky APIs behind explicit unsafe imports as new modules land. | --- a/jerboa-native-rs/src/secure_mem.rs +++ b/jerboa-native-rs/src/secure_mem.rs @@ -2,6 +2,8 @@ use crate::panic::{ffi_wrap, ffi_wrap_ptr}; use std::ptr; fn system_page_size() -> Option<usize> { + // SAFETY: sysconf(_SC_PAGESIZE) takes no pointers and returns a scalar page + // size or an error indicator. let value = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; if value <= 0 { None @@ -46,6 +48,8 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { Some(n) if n <= isize::MAX as usize => n, _ => return ptr::null_mut(), }; + // SAFETY: mmap is asked for a fresh anonymous private mapping with no + // caller-supplied address. `total` is checked not to exceed isize::MAX. let base = unsafe { libc::mmap( ptr::null_mut(), @@ -60,10 +64,17 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { return ptr::null_mut(); } - let cleanup = || unsafe { - libc::munmap(base, total); + let cleanup = || { + // SAFETY: base/total describe the live mapping returned by mmap; + // cleanup is called only before ownership is returned to Scheme. + unsafe { + libc::munmap(base, total); + } }; + // SAFETY: base is the start of a `total`-byte mmap region. The second + // guard starts at page_size + rounded, which remains inside the mapping + // because total == page_size + rounded + page_size. let protect_ok = unsafe { libc::mprotect(base, page_size, libc::PROT_NONE) == 0 && libc::mprotect( @@ -78,8 +89,12 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { } // Put the usable range flush against the right guard page. + // SAFETY: rounded >= size and page_size + rounded is within the mmap + // region; this computes the start of the user-visible writable range. let data = unsafe { (base as *mut u8).add(page_size + (rounded - size)) }; + // SAFETY: data points to the writable range of length `size` inside the + // mapping and is not exposed to callers until after mlock succeeds. if unsafe { libc::mlock(data as *const _, size) } != 0 { cleanup(); return ptr::null_mut(); @@ -87,7 +102,9 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { // Exclude from core dumps (MADV_DONTDUMP is Linux-specific) #[cfg(target_os = "linux")] + // SAFETY: data/size is the live user-visible subrange of the mapping. if unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTDUMP) } != 0 { + // SAFETY: data/size was successfully mlock'd above and is still live. unsafe { libc::munlock(data as *const _, size); } @@ -97,7 +114,9 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 { // Don't inherit in child processes (MADV_DONTFORK is Linux-specific) #[cfg(target_os = "linux")] + // SAFETY: data/size is the live user-visible subrange of the mapping. if unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTFORK) } != 0 { + // SAFETY: data/size was successfully mlock'd above and is still live. unsafe { libc::munlock(data as *const _, size); } @@ -127,13 +146,19 @@ pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 { None => return -1, }; + // SAFETY: caller must pass a pointer returned by jerboa_secure_alloc + // with the same `size`, so the user-visible range is writable. unsafe { secure_zero(ptr, size); } + // SAFETY: ptr/size is the user-visible range previously mlock'd by + // jerboa_secure_alloc. let unlock_rc = unsafe { libc::munlock(ptr as *const _, size) }; // Unmap entire region including guard pages + // SAFETY: ptr came from jerboa_secure_alloc(size), so subtracting the + // same front guard and padding offset recovers the mmap base address. let base = unsafe { ptr.sub(page_size + (rounded - size)) }; let total = match page_size .checked_add(rounded) @@ -142,6 +167,8 @@ pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 { Some(n) => n, None => return -1, }; + // SAFETY: base/total reconstruct the full mapping allocated by + // jerboa_secure_alloc for this size. let unmap_rc = unsafe { libc::munmap(base as *mut _, total) }; if unlock_rc == 0 && unmap_rc == 0 { @@ -158,6 +185,8 @@ pub extern "C" fn jerboa_secure_wipe(ptr: *mut u8, size: usize) -> i32 { if ptr.is_null() { return -1; } + // SAFETY: caller guarantees ptr points to a writable buffer of `size` + // bytes; secure_zero bounds every write by 0..size. unsafe { secure_zero(ptr, size); } @@ -175,6 +204,8 @@ pub extern "C" fn jerboa_secure_random_fill(ptr: *mut u8, size: usize) -> i32 { return 0; } let rng = ring::rand::SystemRandom::new(); + // SAFETY: ptr is non-null and `size > 0`; the caller provides a + // writable buffer for ring to fill. let buf = unsafe { std::slice::from_raw_parts_mut(ptr, size) }; match ring::rand::SecureRandom::fill(&rng, buf) { Ok(()) => 0,