security: harden regex-native ffi outputs
ober
c4a76c07987f94276303c0c39f0e4d8c2ad0257c
--- a/docs/ffi-audit.md +++ b/docs/ffi-audit.md @@ -29,13 +29,13 @@ Latest summary from `tools/ffi-audit-report.ss`: (load-shared-object 55) (define-ftype 4) (foreign-callable 7) - (pointer-sites 298) - (width-sensitive-sites 156) + (pointer-sites 297) + (width-sensitive-sites 155) (blocking-candidates 47) (blocking-without-collect-safe 0) - (hazard-site-count 351) + (hazard-site-count 350) (blocking-review-sites 0) - (safety-review-sites 351)) + (safety-review-sites 350)) (native (rust-file-count 29) (c-file-count 5) @@ -179,8 +179,11 @@ now treats `realpath(3)` as a collect-safe blocking libc call while passing only foreign-allocated `void*` buffers across the FFI boundary, checks NULL returns before reading the output path, rejects interior NUL path strings, frees all temporary C buffers with `dynamic-wind`, and bounds optional executable hashing -to 256 MiB before allocating a digest input bytevector. `secure_fs.rs` now -documents +to 256 MiB before allocating a digest input bytevector. The Scheme +`(std regex-native)` wrapper now validates bytevector slices before copying, +uses scoped foreign `void*` slots for native u64 out-parameters, checks those +slots before reading, verifies replacement output lengths before slicing, and +documents the status/count integer-width ABI. `secure_fs.rs` now documents descriptor-relative `openat`/`mkdirat`, fd duplication/ownership transfer, stat buffers, directory stream lifecycle, caller output slots, atomic output publication, and test-owned fd conversion --- a/docs/kimi3-security-recommmendations.md +++ b/docs/kimi3-security-recommmendations.md @@ -722,8 +722,11 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit `void*` binding with foreign-allocated path/output buffers, explicit NULL-return checks before path reads, dynamic-wind cleanup for temporary C buffers, interior-NUL path rejection, and a 256 MiB cap before optional - executable hashing reads into memory. `wasm_sm.rs` now compiles under - `unsafe_op_in_unsafe_fn` for the + executable hashing reads into memory. The Scheme `(std regex-native)` wrapper + now uses scoped foreign `void*` out-slots for native u64 result buffers, + validates bytevector slices before copying, checks replacement output lengths + before slicing, and documents the status/count integer-width ABI. + `wasm_sm.rs` now compiles under `unsafe_op_in_unsafe_fn` for the SpiderMonkey feature, bounds C ABI buffers, validates host-memory offsets, converts poisoned handle-store locks into normal FFI errors, and documents SpiderMonkey native callback, realm, and WASM memory-copy invariants. The @@ -739,7 +742,7 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit shutdown invariants for blocked TLS I/O. The generated report now shows 390 annotated native unsafe sites, 0 remaining unsafe review sites, and 0 Scheme blocking calls missing `__collect_safe`; the clean-tree Scheme FFI inventory - now reports 351 remaining provisional safety-review sites. + now reports 350 remaining provisional safety-review sites. Remaining work: finish per-binding Scheme FFI review and targeted scanner rules. --- a/docs/security-reference.md +++ b/docs/security-reference.md @@ -718,7 +718,7 @@ Callers should not treat `allow-degraded?` as successful sandbox installation. ## 7. Parser Hardening -Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers.ss`). Phase 5 (FFI audit) has a reproducible inventory in [ffi-audit.md](ffi-audit.md) via `make ffi-audit-report`; native unsafe review and selected Scheme FFI slices such as Seatbelt, sandbox compatibility, and `exec-id` are remediated, while broad Scheme per-binding remediation remains open. +Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers.ss`). Phase 5 (FFI audit) has a reproducible inventory in [ffi-audit.md](ffi-audit.md) via `make ffi-audit-report`; native unsafe review and selected Scheme FFI slices such as Seatbelt, sandbox compatibility, `exec-id`, and `regex-native` are remediated, while broad Scheme per-binding remediation remains open. ### Depth limits --- 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`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, SQLite native ABI, the Scheme Seatbelt `sandbox_init` error-buffer paths, the retired `(std os sandbox)` compatibility surface, and `(std os exec-id)` realpath/hash helpers now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites and 0 Scheme blocking bindings missing `__collect_safe`. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. | +| 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`, secure-memory region lifecycle in `secure_mem.rs`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, SQLite native ABI, the Scheme Seatbelt `sandbox_init` error-buffer paths, the retired `(std os sandbox)` compatibility surface, `(std os exec-id)` realpath/hash helpers, and the Scheme `(std regex-native)` out-parameter wrapper now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites and 0 Scheme blocking bindings missing `__collect_safe`. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. | | 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, Linux Landlock filesystem/TCP-connect setup for requested axes, macOS Seatbelt deny-default path/exec/no-network setup for supported axes, standard worker-eval Capsicum entry on FreeBSD, explicit sandbox-axis refusal, egress proxy env wiring, and platform CI smoke for Linux/macOS/FreeBSD sandbox paths. | Keep Linux/macOS/FreeBSD parity tests current; finish arbitrary-command/proxy-aware Capsicum worker paths. | | Fuzzing | `tests/fuzz/corpus/` has 15 checked-in seed inputs, `tests/fuzz/regression/` has 11 crash/rejection regressions, `make fuzz-smoke` runs the deterministic regression gate first, and GitHub CI runs smoke fuzzing normally plus deep fuzzing on scheduled daily runs. | Keep adding minimized corpus and regression inputs for every parser/security bug found. | --- a/lib/std/regex-native.ss +++ b/lib/std/regex-native.ss @@ -19,33 +19,61 @@ (jerboa-native-load!)) (def (bv-sub bv start len) + (unless (and (bytevector? bv) + (integer? start) (exact? start) (>= start 0) + (integer? len) (exact? len) (>= len 0) + (<= (+ start len) (bytevector-length bv))) + (error 'bv-sub "invalid bytevector slice" start len)) (let ([out (make-bytevector len)]) - (bytevector-copy! bv start out 0 len) + (bytevector-copy! bv start out 0 len) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- bv-sub validates start/len against bytevector-length before copying out)) + (def (non-null-ptr? ptr) + (and ptr (not (= ptr 0)))) + + (def (call-with-foreign-buffer size k) + (let ([addr #f]) + (dynamic-wind + (lambda () + (set! addr (foreign-alloc size))) ; jerboa-security: suppress u8star-ffi-with-foreign-alloc -- this scoped allocator is used only for void* out slots; u8* inputs receive Scheme bytevectors + (lambda () + (and (non-null-ptr? addr) (k addr))) + (lambda () + (when (non-null-ptr? addr) + (foreign-free addr)))))) + + (def (foreign-u64-ref/checked ptr) + (unless (non-null-ptr? ptr) + (error 'foreign-u64-ref/checked "null u64 output pointer")) + (foreign-ref 'unsigned-64 ptr 0)) ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- helper rejects null pointers and reads offset 0 from an 8-byte scoped out-parameter buffer (def c-jerboa-regex-compile - (foreign-procedure "jerboa_regex_compile" (u8* size_t u8*) int)) + (foreign-procedure "jerboa_regex_compile" + (u8* size_t void*) + int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns a status int and receives size_t lengths plus one unsigned-64 out slot (def c-jerboa-regex-is-match - (foreign-procedure "jerboa_regex_is_match" (unsigned-64 u8* size_t) int)) + (foreign-procedure "jerboa_regex_is_match" (unsigned-64 u8* size_t) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns -1/0/1 status int and receives a size_t subject length (def c-jerboa-regex-find-at (foreign-procedure "jerboa_regex_find_at" - (unsigned-64 u8* size_t size_t u8* u8*) int)) + (unsigned-64 u8* size_t size_t void* void*) + int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- status int plus size_t subject/start lengths match the Rust C ABI; output offsets use unsigned-64 slots (def c-jerboa-regex-group-count - (foreign-procedure "jerboa_regex_group_count" (unsigned-64) int)) + (foreign-procedure "jerboa_regex_group_count" (unsigned-64) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns a small capture count as int and wrapper rejects values below 1 (def c-jerboa-regex-captures (foreign-procedure "jerboa_regex_captures" - (unsigned-64 u8* size_t size_t u8* size_t) int)) + (unsigned-64 u8* size_t size_t u8* size_t) + int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- status/count int plus size_t subject/start/range-slot lengths match the Rust C ABI (def c-jerboa-regex-replace-all (foreign-procedure "jerboa_regex_replace_all" - (unsigned-64 u8* size_t u8* size_t u8* size_t u8*) int)) + (unsigned-64 u8* size_t u8* size_t u8* size_t void*) + int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- status int plus size_t buffer lengths match the Rust C ABI; actual length is an unsigned-64 out slot (def c-jerboa-regex-free - (foreign-procedure "jerboa_regex_free" (unsigned-64) int)) + (foreign-procedure "jerboa_regex_free" (unsigned-64) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns a small status int for freeing an unsigned-64 handle (def c-jerboa-last-error (foreign-procedure "jerboa_last_error" (u8* size_t) size_t)) @@ -58,12 +86,13 @@ "")))) (def (regex-compile pattern) - (let ([bv (string->utf8 pattern)] - [handle-buf (make-bytevector 8)]) - (let ([rc (c-jerboa-regex-compile bv (bytevector-length bv) handle-buf)]) - (when (< rc 0) - (error 'regex-compile "invalid pattern" pattern (rust-last-error))) - (bytevector-u64-native-ref handle-buf 0)))) + (let ([bv (string->utf8 pattern)]) + (call-with-foreign-buffer 8 + (lambda (handle-ptr) + (let ([rc (c-jerboa-regex-compile bv (bytevector-length bv) handle-ptr)]) + (when (< rc 0) + (error 'regex-compile "invalid pattern" pattern (rust-last-error))) + (foreign-u64-ref/checked handle-ptr)))))) (def (regex-match? handle text) (let ([bv (string->utf8 text)]) @@ -85,15 +114,19 @@ (unless (and (integer? start) (exact? start) (>= start 0) (<= start (bytevector-length bv))) (error 'regex-find-at/utf8 "invalid start byte offset" start)) - (let ([start-buf (make-bytevector 8)] - [end-buf (make-bytevector 8)]) - (let ([rc (c-jerboa-regex-find-at handle bv (bytevector-length bv) - start start-buf end-buf)]) - (cond - [(< rc 0) (error 'regex-find-at/utf8 "find failed" (rust-last-error))] - [(= rc 0) #f] - [else (cons (bytevector-u64-native-ref start-buf 0) - (bytevector-u64-native-ref end-buf 0))])))) + (call-with-foreign-buffer 8 + (lambda (start-ptr) + (call-with-foreign-buffer 8 + (lambda (end-ptr) + (let ([rc (c-jerboa-regex-find-at handle bv (bytevector-length bv) + start start-ptr end-ptr)]) + (cond + [(< rc 0) + (error 'regex-find-at/utf8 "find failed" (rust-last-error))] + [(= rc 0) #f] + [else + (cons (foreign-u64-ref/checked start-ptr) + (foreign-u64-ref/checked end-ptr))]))))))) (def (regex-group-count handle) (let ([count (c-jerboa-regex-group-count handle)]) @@ -146,17 +179,22 @@ (def (regex-replace-all handle text replacement) (let ([text-bv (string->utf8 text)] [repl-bv (string->utf8 replacement)] - [out-max (* 4 (+ (string-length text) (string-length replacement)))] - [len-buf (make-bytevector 8)]) + [out-max (* 4 (+ (string-length text) (string-length replacement)))]) (let ([out (make-bytevector out-max)]) - (let ([rc (c-jerboa-regex-replace-all handle - text-bv (bytevector-length text-bv) - repl-bv (bytevector-length repl-bv) - out out-max len-buf)]) - (when (< rc 0) - (error 'regex-replace-all "replace failed" (rust-last-error))) - (let ([actual-len (bytevector-u64-native-ref len-buf 0)]) - (utf8->string (bv-sub out 0 actual-len))))))) + (call-with-foreign-buffer 8 + (lambda (len-ptr) + (let ([rc (c-jerboa-regex-replace-all handle + text-bv (bytevector-length text-bv) + repl-bv (bytevector-length repl-bv) + out out-max len-ptr)]) + (when (< rc 0) + (error 'regex-replace-all "replace failed" (rust-last-error))) + (let ([actual-len (foreign-u64-ref/checked len-ptr)]) + (when (> actual-len out-max) + (error 'regex-replace-all + "native replacement length exceeded allocation" + actual-len out-max)) + (utf8->string (bv-sub out 0 actual-len))))))))) (def (regex-free handle) (c-jerboa-regex-free handle)