security: gate unsafe deserialization sites
ober
d370617647f95070e5287a5097f9c8522be04de5
--- a/Makefile +++ b/Makefile @@ -1736,10 +1736,16 @@ source-balance: restrict-closure-check: @$(SCHEME) --libdirs $(LIBDIRS) --script tools/check-restrict-closure.ss +unsafe-deserialize-check: + @git ls-files '*.ss' '*.sls' \ + | grep -E '^(lib|src|tools|mcp|lsp)/' \ + | grep -v '^vendor/' \ + | xargs $(SCHEME) --libdirs $(LIBDIRS) --script tools/check-unsafe-deserialize.ss + import-policy-check: build @$(SCHEME) --libdirs $(LIBDIRS) --script tools/check-import-policy.ss examples -audit: source-balance restrict-closure-check import-policy-check security-audit audit-native data-check security-production unification-check +audit: source-balance restrict-closure-check unsafe-deserialize-check import-policy-check security-audit audit-native data-check security-production unification-check test-gaps: $(SCHEME) --libdirs $(LIBDIRS) --script tests/test-gaps.ss --- a/docs/kimi3-security-recommmendations.md +++ b/docs/kimi3-security-recommmendations.md @@ -412,31 +412,32 @@ The allowlist is Jerboa's crown jewel. It now has a CI-enforced manifest in internals, tracked as P3-06. ### K3-P0-04 — Triage and close raw `read` / native FASL paths on untrusted input -**Serves:** G1. **Effort:** 3–5 days. **Status:** first scanner-driven -batch landed 2026-07-27: gRPC request/response parsing, general `(std io)`, -and legacy lockfile readers use `jerboa-read`; package manifest/policy/lock -readers keep plain Scheme datum syntax but are size-capped, structurally -validated, and locally justified; PGO profile loading is size-capped and tagged -as trusted generated input; translator source reads are explicitly trusted -compiler input after string transforms. Remaining work: complete the broader -`load`/REPL/dev-surface classification and add a dedicated audit gate. +**Serves:** G1. **Effort:** 3–5 days. **Status:** scanner-driven +classification gate landed 2026-07-27. gRPC request/response parsing, general +`(std io)`, and legacy lockfile readers use `jerboa-read`; package +manifest/policy/lock readers keep plain Scheme datum syntax but are +size-capped, structurally validated, and locally justified; PGO profile loading +is size-capped and tagged as trusted generated input; translator source reads +are explicitly trusted compiler input after string transforms. `make audit` +now runs `tools/check-unsafe-deserialize.ss` over `lib/`, `src/`, `tools/`, +`mcp/`, and `lsp/`; existing trusted/dev surfaces are recorded in +`docs/unsafe-deserialize-allowlist.sexp` by exact source line and reason, and +new or stale raw sites fail audit. `security-reference.md`'s checklist already names exemplars: `jerbuild.ss:308`, `lib/std/actor/distributed.ss:303`, `lib/std/fasl.ss:27`, `lib/std/net/grpc.ss:162`. There are more. -- **Do:** (a) Enumerate every call site of bare `read`, `fasl-read`, and - Chez `load` in `lib/`, `src/`, `tools/`, `mcp/`, `lsp/` (the MCP scanner - already flags these — run it repo-wide, `changed_only: #f`). (b) Classify - each site: *trusted build-time input* (comment + lint suppression with - justification) or *possibly adversarial* (convert to bounded - `jerboa-read` / `safe-fasl` envelope / reject). (c) For `(std fasl)`, - add a loud docstring/contract: trusted provenance only, never network/ - workspace/IPC bytes. (d) Add an `unsafe-deserialize` lint rule so new - raw-`read` sites fail `make audit`. -- **Accept:** scanner reports zero unclassified raw-read/FASL sites; each - remaining site has an inline provenance justification; `jerbuild.ss`, - `distributed.ss`, `grpc.ss` either bounded or documented-trusted. +- **Done:** (a) The dedicated audit enumerates bare `(read ...)`, raw + `(fasl-read ...)`, and runtime `(load ...)` call forms. (b) Possibly + adversarial sites were converted in earlier batches; remaining trusted build, + compiler, REPL/dev, and test harness surfaces are manifest-classified by + exact source line. (c) `(std fasl)` remains loudly documented as trusted + provenance only. (d) `make audit` runs the gate, reports zero unclassified + sites, and fails for new raw sites or stale classifications. +- **Residual:** the current classification source of truth is the central + manifest rather than scattered inline comments, to keep language-repository + security documentation consolidated. ### K3-P0-05 — Authenticate distributed actor messages **Serves:** G2. **Effort:** 3–4 days. **Status:** initial implementation --- a/docs/security-reference.md +++ b/docs/security-reference.md @@ -177,6 +177,23 @@ the same change. `jerboa-read`; Chez already rejects `#.` and does not export `read-eval` - `gensym` (leaks runtime state via monotonic counter) +### Unsafe Deserialization Gate + +`make audit` runs `tools/check-unsafe-deserialize.ss` over tracked `.ss` and +`.sls` files under `lib/`, `src/`, `tools/`, `mcp/`, and `lsp/`. The gate flags +new unclassified call forms for: + +- bare Chez `(read ...)`, which has no Jerboa size/depth/container budgets; +- raw `(fasl-read ...)`, which is trusted-provenance only; +- runtime `(load ...)`, which evaluates caller-selected code. + +Existing trusted or intentionally unsafe developer/build surfaces are recorded +in `docs/unsafe-deserialize-allowlist.sexp` by exact file, line, rule, source +text, and reason. New sites fail audit until converted to `jerboa-read` / +`safe-fasl`, given an inline `jerboa-security: suppress ... -- ...` +justification, or added to the manifest with a reviewed provenance reason. +Stale manifest entries also fail audit. + ### Usage ```scheme new file mode 100644 --- /dev/null +++ b/docs/unsafe-deserialize-allowlist.sexp @@ -0,0 +1,46 @@ +( + ("lib/jerboa/build.ss" 160 "bare-read-untrusted" " (let ([form (read port)])" "trusted build-system source forms read from local project/build inputs") + ("lib/jerboa/build.ss" 421 "bare-read-untrusted" " (let ([form (read port)])" "trusted build-system source forms read from local project/build inputs") + ("lib/jerboa/hot.ss" 108 "runtime-load-trusted" " (try (begin (load path)" "explicit hot-reload developer surface; caller selects local code path") + ("lib/jerboa/wasm/gc-backend/driver.ss" 26 "bare-read-untrusted" " (let ([form (read port)])" "trusted compiler driver input from the local build pipeline") + ("lib/std/build.ss" 78 "bare-read-untrusted" " (let ([form (read port)])" "trusted build metadata/source forms under build-tool control") + ("lib/std/build.ss" 151 "bare-read-untrusted" " (let ([form (read port)])" "trusted build metadata/source forms under build-tool control") + ("lib/std/build/reproducible.ss" 461 "bare-read-untrusted" " (let ([sexp (read port)])" "trusted reproducibility report data generated by repository tooling") + ("lib/std/build/sbom.ss" 159 "bare-read-untrusted" " (let ([sexp (read port)])" "trusted SBOM metadata generated by repository tooling") + ("lib/std/build/watch.ss" 356 "bare-read-untrusted" " (let ([form (read port)])" "trusted local source forms watched by developer tooling") + ("lib/std/capability/sandbox.ss" 201 "runtime-load-trusted" " (try (load file-path)" "explicit capability sandbox load of caller-selected local code") + ("lib/std/dev/pgo.ss" 162 "bare-read-untrusted" " (let ([header (read port)])" "trusted PGO profile generated by Jerboa profile-save and structurally checked by loader") + ("lib/std/dev/pgo.ss" 166 "bare-read-untrusted" " (let ([entry (read port)])" "trusted PGO profile generated by Jerboa profile-save and structurally checked by loader") + ("lib/std/dev/pgo.ss" 206 "bare-read-untrusted" " (let ([header (read port)])" "trusted PGO profile generated by Jerboa profile-save and structurally checked by loader") + ("lib/std/dev/pgo.ss" 208 "bare-read-untrusted" " (let ([entry (read port)])" "trusted PGO profile generated by Jerboa profile-save and structurally checked by loader") + ("lib/std/doc.ss" 141 "bare-read-untrusted" " (try (let ([got (eval (read (open-input-string expr-str)) env)]" "developer documentation examples evaluated only by opt-in doc tooling") + ("lib/std/doc.ss" 142 "bare-read-untrusted" " [expected (eval (read (open-input-string expected-str)) env)])" "developer documentation examples evaluated only by opt-in doc tooling") + ("lib/std/interpolate.ss" 52 "bare-read-untrusted" " [expr (read p)])" "compile-time interpolation expression parser for local source text") + ("lib/std/lint.ss" 566 "bare-read-untrusted" " (let ([form (read port)])" "lint tool reads local source files supplied by developer tooling") + ("lib/std/mmap-btree.ss" 335 "bare-read-untrusted" " (let ([magic (read port)])" "trusted mmap-btree metadata file, not a network or IPC format") + ("lib/std/mmap-btree.ss" 337 "bare-read-untrusted" " (let* ([_order (read port)]" "trusted mmap-btree metadata file, not a network or IPC format") + ("lib/std/mmap-btree.ss" 338 "bare-read-untrusted" " [size (read port)]" "trusted mmap-btree metadata file, not a network or IPC format") + ("lib/std/mmap-btree.ss" 339 "bare-read-untrusted" " [root (read port)])" "trusted mmap-btree metadata file, not a network or IPC format") + ("lib/std/os/platform.ss" 163 "runtime-load-trusted" " (load path)" "platform helper loads a caller-selected local xpatch in controlled build/runtime setup") + ("lib/std/os/platform.ss" 175 "runtime-load-trusted" " (load path))" "platform helper loads a caller-selected local xpatch in controlled build/runtime setup") + ("lib/std/os/supervise.ss" 365 "bare-read-untrusted" " (read (open-input-string s))" "process status parser consumes local supervise status text, not attacker protocol data") + ("lib/std/repl.ss" 652 "runtime-load-trusted" " (try (load path (lambda (x) (eval x env)))" "explicit REPL load command for user-selected local code") + ("lib/std/repl.ss" 902 "bare-read-untrusted" " [expr (read p)]" "explicit REPL inspect/disassemble command over user-entered expressions") + ("lib/std/repl.ss" 903 "bare-read-untrusted" " [n (let ([v (read p)]) (if (eof-object? v) 100 v))])" "explicit REPL inspect/disassemble command over user-entered expressions") + ("lib/std/repl.ss" 946 "runtime-load-trusted" " (try (begin (load path (lambda (x) (eval x env)))" "explicit REPL reload command for user-selected local code") + ("lib/std/repl.ss" 1009 "bare-read-untrusted" " [expr (read p)]" "explicit REPL expand/trace command over user-entered expressions") + ("lib/std/repl.ss" 1010 "bare-read-untrusted" " [n (let ([v (read p)]) (if (eof-object? v) 10 v))]" "explicit REPL expand/trace command over user-entered expressions") + ("lib/std/repl.ss" 1030 "bare-read-untrusted" " [expr (read p)]" "explicit REPL expand/trace command over user-entered expressions") + ("lib/std/repl.ss" 1031 "bare-read-untrusted" " [n (let ([v (read p)]) (if (eof-object? v) 10 v))]" "explicit REPL expand/trace command over user-entered expressions") + ("lib/std/repl/server.ss" 346 "runtime-load-trusted" " (load (car args) (lambda (x) (eval x *server-env*)))" "explicit REPL server load command for authenticated/user-selected local code") + ("lib/std/secure/compiler.ss" 626 "bare-read-untrusted" " (let ([form (read port)])" "Slang compiler consumes local source text after secure subset checks") + ("lib/std/secure/preamble.ss" 241 "bare-read-untrusted" " (filesystem (read path) (write path))" "Slang capability-plan DSL data, not a runtime reader call") + ("lib/std/secure/wasm-target.ss" 1390 "bare-read-untrusted" " (let ([form (read p)])" "Slang/WASM target compiler consumes local source text") + ("lib/std/security/capsicum.ss" 302 "bare-read-untrusted" " `((0 . (read fstat)) ;; stdin: read-only" "Capsicum rights data literal, not a runtime reader call") + ("lib/std/typed/check.ss" 289 "bare-read-untrusted" " (let ([form (read port)])" "typed checker consumes local source forms") + ("lsp/build-jlsp-cross.ss" 79 "runtime-load-trusted" "(load xpatch)" "cross-build helper loads configured local xpatch script") + ("lsp/build-jlsp-freebsd-cross.ss" 78 "runtime-load-trusted" "(load xpatch)" "cross-build helper loads configured local xpatch script") + ("mcp/test/security-test.ss" 13 "runtime-load-trusted" "(load (string-append repo-root \"/mcp/server.ss\"))" "test harness loads the repository-local MCP server under test") + ("tools/check-restrict-closure.ss" 121 "bare-read-untrusted" " [form (read port)]" "audit checker reads repository-controlled allowlist manifest from security-reference.md") + ("tools/check-restrict-closure.ss" 122 "bare-read-untrusted" " [extra (read port)])" "audit checker rejects trailing data in repository-controlled allowlist manifest") +) new file mode 100644 --- /dev/null +++ b/tools/check-unsafe-deserialize.ss @@ -0,0 +1,216 @@ +#!chezscheme +;;; check-unsafe-deserialize.ss -- audit raw datum/FASL/load trust boundaries. + +(import (scheme)) + +(define findings '()) +(define classification-path "docs/unsafe-deserialize-allowlist.sexp") +(define classified-sites '()) +(define matched-sites '()) + +(define (add-finding file line rule detail) + (set! findings (cons (list file line rule detail) findings))) +(define (valid-classification? entry) + (and (list? entry) + (= (length entry) 5) + (string? (car entry)) + (integer? (cadr entry)) + (string? (caddr entry)) + (string? (cadddr entry)) + (string? (car (cddddr entry))))) + +(define (load-classifications path) + (guard (exn [else + (add-finding path 0 "classification-read-error" + "could not read unsafe deserialization classification manifest") + '()]) + (call-with-input-file path + (lambda (port) + (let ([entries (read port)]) ; jerboa-security: suppress bare-read-untrusted -- repository-controlled audit manifest, not runtime input + (if (and (list? entries) + (let loop ([rest entries]) + (or (null? rest) + (and (valid-classification? (car rest)) + (loop (cdr rest)))))) + entries + (begin + (add-finding path 0 "classification-schema-error" + "expected entries shaped as (file line rule source reason)") + '()))))))) + +(define (classification-matches? entry file number rule line) + (and (string=? (car entry) file) + (= (cadr entry) number) + (string=? (caddr entry) rule) + (string=? (cadddr entry) line))) + +(define (classified? file number rule line) + (let loop ([rest classified-sites]) + (cond + [(null? rest) #f] + [(classification-matches? (car rest) file number rule line) + (set! matched-sites (cons (car rest) matched-sites)) + #t] + [else (loop (cdr rest))]))) + +(define (matched-classification? entry) + (let loop ([rest matched-sites]) + (and (pair? rest) + (or (equal? entry (car rest)) + (loop (cdr rest)))))) + +(define (check-stale-classifications) + (let loop ([rest classified-sites]) + (unless (null? rest) + (let ([entry (car rest)]) + (unless (matched-classification? entry) + (add-finding (car entry) (cadr entry) "stale-classification" + "manifest entry no longer matches an audited source line")) + (loop (cdr rest)))))) + +(define (string-contains? haystack needle) + (let ([hlen (string-length haystack)] + [nlen (string-length needle)]) + (let loop ([index 0]) + (cond + [(= nlen 0) #t] + [(> (+ index nlen) hlen) #f] + [(string=? (substring haystack index (+ index nlen)) needle) #t] + [else (loop (+ index 1))])))) + +(define (string-prefix? prefix s) + (let ([plen (string-length prefix)]) + (and (>= (string-length s) plen) + (string=? prefix (substring s 0 plen))))) + +(define (trim-left s) + (let loop ([index 0]) + (cond + [(= index (string-length s)) ""] + [(char-whitespace? (string-ref s index)) (loop (+ index 1))] + [else (substring s index (string-length s))]))) +(define (strip-string-literals line) + (let loop ([index 0] [in-string? #f] [escaped? #f] [out '()]) + (if (= index (string-length line)) + (list->string (reverse out)) + (let ([ch (string-ref line index)]) + (cond + [in-string? + (cond + [escaped? (loop (+ index 1) #t #f (cons #\space out))] + [(char=? ch #\\) (loop (+ index 1) #t #t (cons #\space out))] + [(char=? ch #\") (loop (+ index 1) #f #f (cons #\space out))] + [else (loop (+ index 1) #t #f (cons #\space out))])] + [(char=? ch #\") (loop (+ index 1) #t #f (cons #\space out))] + [else (loop (+ index 1) #f #f (cons ch out))]))))) + +(define (comment-line? line) + (let ([trimmed (trim-left line)]) + (or (string-prefix? ";;" trimmed) + (string-prefix? "#;" trimmed)))) + +(define (suppressed? rule line) + (let ([needle (string-append "jerboa-security: suppress " rule " -- ")]) + (string-contains? line needle))) +(define (delimiter-after-symbol? ch) + (or (char-whitespace? ch) + (char=? ch #\)) + (char=? ch #\]))) + +(define (quoted-form-prefix? line index) + (and (> index 0) + (let ([ch (string-ref line (- index 1))]) + (or (char=? ch #\') + (char=? ch #\`) + (char=? ch #\,))))) + +(define (next-nonspace-index line index) + (let loop ([i index]) + (cond + [(>= i (string-length line)) #f] + [(char-whitespace? (string-ref line i)) (loop (+ i 1))] + [else i]))) + +(define (call-form? line name) + (let ([nlen (string-length name)] + [llen (string-length line)]) + (let loop ([index 0]) + (cond + [(>= index llen) #f] + [(and (char=? (string-ref line index) #\() + (not (quoted-form-prefix? line index)) + (<= (+ index 1 nlen) llen) + (string=? (substring line (+ index 1) (+ index 1 nlen)) name) + (< (+ index 1 nlen) llen) + (delimiter-after-symbol? (string-ref line (+ index 1 nlen)))) + (let ([arg-index (next-nonspace-index line (+ index 1 nlen))]) + (and arg-index + (not (char=? (string-ref line arg-index) #\")) + (not (char=? (string-ref line arg-index) #\))) + (not (char=? (string-ref line arg-index) #\.)) + (not (and (> index 0) + (char=? (string-ref line (- index 1)) #\()))))] + [else (loop (+ index 1))])))) + +(define (bare-read-call? line) + (call-form? line "read")) + +(define (unsafe-fasl-call? line) + (call-form? line "fasl-read")) + +(define (runtime-load-call? line) + (call-form? line "load")) +(define (report-unclassified file number rule line detail) + (unless (classified? file number rule line) + (add-finding file number rule detail))) + +(define (check-line file number line) + (unless (comment-line? line) + (let ([code (strip-string-literals line)]) + (when (and (bare-read-call? code) + (not (suppressed? "bare-read-untrusted" line))) + (report-unclassified file number "bare-read-untrusted" line + "use jerboa-read/safe schema parsing or add an inline trusted-provenance suppression")) + (when (and (unsafe-fasl-call? code) + (not (suppressed? "unsafe-fasl-deserialize" line))) + (report-unclassified file number "unsafe-fasl-deserialize" line + "use safe-fasl for untrusted bytes or add an inline trusted-provenance suppression")) + (when (and (runtime-load-call? code) + (not (suppressed? "runtime-load-trusted" line))) + (report-unclassified file number "runtime-load-trusted" line + "runtime code loading needs an inline trusted-provenance suppression"))))) + +(define (scan-file file) + (guard (exn [else + (add-finding file 0 "scan-error" + "could not read file during unsafe deserialize audit")]) + (call-with-input-file file + (lambda (port) + (let loop ([line (get-line port)] [number 1]) + (unless (eof-object? line) + (check-line file number line) + (loop (get-line port) (+ number 1)))))))) + +(define files + (let ([args (cdr (command-line))]) + (if (null? args) + '() + args))) + +(set! classified-sites (load-classifications classification-path)) +(for-each scan-file files) +(check-stale-classifications) + +(unless (null? files) + (if (null? findings) + (begin + (printf "unsafe-deserialize: OK (~a file(s))\n" (length files)) + (exit 0)) + (begin + (for-each + (lambda (f) + (printf "unsafe-deserialize: ~a:~a [~a] ~a\n" + (car f) (cadr f) (caddr f) (cadddr f))) + (reverse findings)) + (printf "unsafe-deserialize: FAIL (~a finding(s))\n" (length findings)) + (exit 1))))