security: expand taint propagation wrappers

Jaime Fournier <jaimef@linbsd.org>

c51d2a6c241f8ccd307c8646803ebbfa4952d674

diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index edbd219..d5a513f 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -637,17 +637,18 @@ Taint is opt-in; native sinks don't check it. In the safe prelude, the
   `open-input-file`, `open-output-file`, `call-with-input-file`,
   `call-with-output-file`, `delete-file`, and `system` to taint-checking safe
   wrappers. `tests/test-safe-prelude.ss` locks rejection of tainted values
-  through the script-loader default prelude.
+  through the script-loader default prelude. Explicit taint-propagating wrappers
+  now cover trim/split/join, UTF-8 string/bytevector conversion, bytevector
+  copy, and bytevector element reads.
 - **Remaining:** (b) Taint sources by default: HTTP request fields, env vars,
   file contents read in safe mode, network frames — wrapped at the boundary.
-  (c) Propagation coverage: extend the tainted-string operation set to the full
-  common string/bytevector API so taint doesn't silently launder away.
-  (d) Performance: measure; if overhead matters, document
+  (c) Performance: measure; if overhead matters, document
   `*taint-enforce* #f` as an explicit, warned de-opt — never silent.
 - **Accept:** partially satisfied: tainted values reaching standard sink names
   in safe-prelude code raise `&taint-violation`; untainted flows are covered by
   existing safe-prelude tests; `safety-guide.md` has a taint section. Full
-  completion still requires source-default and broader propagation work.
+  completion still requires source-default work at protocol/file/env
+  boundaries.
 
 ### K3-P1-04 — Decide `define-syntax` in the sandbox allowlist
 **Serves:** G1. **Effort:** 2–3 days.
diff --git a/docs/safety-guide.md b/docs/safety-guide.md
index aca462c..4a398ed 100644
--- a/docs/safety-guide.md
+++ b/docs/safety-guide.md
@@ -278,8 +278,10 @@ binds the default file and shell/delete sink names to taint-checking wrappers:
 
 Use `taint-http`, `taint-env`, `taint-file`, `taint-net`, and `taint-deser` at
 input boundaries. Use `untaint` only at a reviewed sanitization boundary. Code
-that imports raw Chez or unsafe modules bypasses these wrappers; keep
-`(std security import-audit)` in the build gate for applications.
+that transforms tainted strings or bytevectors should use the `tainted-string-*`
+and `tainted-bytevector-*` wrappers so taint remains attached until the value is
+sanitized. Code that imports raw Chez or unsafe modules bypasses these wrappers;
+keep `(std security import-audit)` in the build gate for applications.
 
 ### What the Contracts Check
 
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 5d7315f..5d54ae8 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -358,8 +358,11 @@ boundaries without importing a separate module.
 
 `tainted-string-append`, `tainted-string-ref`, `tainted-substring`,
 `tainted-string-length`, `tainted-string-upcase`, `tainted-string-downcase`,
-`tainted-string->number`, and `tainted-format` -- operations on tainted strings
-propagate the taint to results where the result carries untrusted data.
+`tainted-string-trim`, `tainted-string-split`, `tainted-string-join`,
+`tainted-string->number`, `tainted-string->utf8`, `tainted-utf8->string`,
+`tainted-bytevector-copy`, `tainted-bytevector-u8-ref`, and
+`tainted-format` -- operations on tainted strings and bytevectors propagate the
+taint to results where the result carries untrusted data.
 
 ---
 
@@ -877,9 +880,11 @@ These are known gaps documented as current limitations, not implementation promi
 - **Seccomp architecture coverage is limited.** The BPF bytecode generator supports x86_64 and aarch64 syscall numbers. Use `sandbox-report` in audit logs or release evidence to record which confinement specs were requested, available, or installed for the current platform.
 - **Landlock requires Linux 5.13+.** No equivalent on macOS, BSDs, or older Linux kernels. `landlock-available?` returns `#f` on unsupported systems.
 - **Taint enforcement depends on the safe surface.** `(jerboa prelude safe)`
-  binds default file/shell/delete sink names to taint-checking wrappers, but raw
-  Chez operations and unsafe imports do not check taint. Boundary sources still
-  need explicit `taint-*` marking until protocol modules wrap inputs by default.
+  binds default file/shell/delete sink names to taint-checking wrappers and
+  exports taint-propagating wrappers for common string/bytevector transforms,
+  but raw Chez operations and unsafe imports do not check taint. Boundary
+  sources still need explicit `taint-*` marking until protocol modules wrap
+  inputs by default.
 - **Distributed actor authentication is opt-in at the serialization layer.** `(std actor transport)` authenticates TCP traffic, and `(std actor distributed)` exposes HMAC'd envelopes with timestamp and monotonic sequence replay checks. Compatibility callers that use only `serialize-message` / `deserialize-message` still get parsing limits but no peer authentication.
 - **Filesystem capabilities are not process sandboxes.** They validate paths at
   the capability API boundary. Code with raw Chez file primitives or
diff --git a/lib/jerboa/prelude/safe.ss b/lib/jerboa/prelude/safe.ss
index de584f4..aa925c9 100644
--- a/lib/jerboa/prelude/safe.ss
+++ b/lib/jerboa/prelude/safe.ss
@@ -119,7 +119,10 @@
     check-untainted!
     tainted-string-append tainted-string-ref tainted-substring
     tainted-string-length tainted-string-upcase tainted-string-downcase ; jerboa-security: suppress string-number-radix-prefix -- export list only; tainted-string->number preserves taint and is not used for protocol lengths
-    tainted-string->number tainted-format
+    tainted-string-trim tainted-string-split tainted-string-join
+    tainted-string->number tainted-string->utf8 tainted-utf8->string
+    tainted-bytevector-copy tainted-bytevector-u8-ref tainted-bytevector-length
+    tainted-format
     make-taint-violation taint-violation?
     taint-violation-class taint-violation-sink
 
@@ -321,7 +324,15 @@
   (def tainted-string-length  taint:tainted-string-length)
   (def tainted-string-upcase  taint:tainted-string-upcase)
   (def tainted-string-downcase taint:tainted-string-downcase)
+  (def tainted-string-trim    taint:tainted-string-trim)
+  (def tainted-string-split   taint:tainted-string-split)
+  (def tainted-string-join    taint:tainted-string-join)
   (def tainted-string->number taint:tainted-string->number)
+  (def tainted-string->utf8   taint:tainted-string->utf8)
+  (def tainted-utf8->string   taint:tainted-utf8->string)
+  (def tainted-bytevector-copy taint:tainted-bytevector-copy)
+  (def tainted-bytevector-u8-ref taint:tainted-bytevector-u8-ref)
+  (def tainted-bytevector-length taint:tainted-bytevector-length)
   (def tainted-format         taint:tainted-format)
   (def make-taint-violation   taint:make-taint-violation)
   (def taint-violation?       taint:taint-violation?)
diff --git a/lib/std/security/taint.ss b/lib/std/security/taint.ss
index c37d92f..0e747a9 100644
--- a/lib/std/security/taint.ss
+++ b/lib/std/security/taint.ss
@@ -34,7 +34,15 @@
     tainted-string-length
     tainted-string-upcase
     tainted-string-downcase
+    tainted-string-trim
+    tainted-string-split
+    tainted-string-join
     tainted-string->number
+    tainted-string->utf8
+    tainted-utf8->string
+    tainted-bytevector-copy
+    tainted-bytevector-u8-ref
+    tainted-bytevector-length
     tainted-format
 
     ;; Safe wrappers (auto-check taint at dangerous sinks)
@@ -51,7 +59,8 @@
     taint-violation-sink)
 
   (import (chezscheme)
-          (only (jerboa core) def defstruct))
+          (only (jerboa core) def defstruct)
+          (only (std misc string) string-split string-join string-trim))
 
   ;; ========== Tainted Value ==========
 
@@ -175,6 +184,60 @@
              [result (apply format #f fmt vals)])
         (if cls (taint cls result) result))))
 
+  (def (first-taint-class values)
+    (let loop ([xs values])
+      (cond
+        [(null? xs) #f]
+        [(tainted? (car xs)) (taint-class (car xs))]
+        [else (loop (cdr xs))])))
+
+  (def (maybe-taint cls value)
+    (if cls (taint cls value) value))
+
+  (def (taint-each cls values)
+    (if cls
+      (map (lambda (v) (taint cls v)) values)
+      values))
+
+  (def (tainted-string-trim s)
+    (tainted-string-transform string-trim s))
+
+  (def tainted-string-split
+    (case-lambda
+      [(s)
+       (let ([cls (first-taint-class (list s))])
+         (taint-each cls (string-split (untaint s))))]
+      [(s sep)
+       (let ([cls (first-taint-class (list s sep))])
+         (taint-each cls (string-split (untaint s) (untaint sep))))]))
+
+  (def (tainted-string-join parts . maybe-sep)
+    (let* ([raw-parts (untaint parts)]
+           [sep (if (null? maybe-sep) #f (car maybe-sep))]
+           [cls (or (and (tainted? parts) (taint-class parts))
+                    (first-taint-class raw-parts)
+                    (first-taint-class maybe-sep))]
+           [values (map untaint raw-parts)]
+           [result (if (null? maybe-sep)
+                     (string-join values)
+                     (string-join values (untaint sep)))])
+      (maybe-taint cls result)))
+
+  (def (tainted-string->utf8 s)
+    (maybe-taint (first-taint-class (list s)) (string->utf8 (untaint s))))
+
+  (def (tainted-utf8->string bv)
+    (maybe-taint (first-taint-class (list bv)) (utf8->string (untaint bv))))
+
+  (def (tainted-bytevector-copy bv)
+    (maybe-taint (first-taint-class (list bv)) (bytevector-copy (untaint bv))))
+
+  (def (tainted-bytevector-u8-ref bv i)
+    (maybe-taint (first-taint-class (list bv)) (bytevector-u8-ref (untaint bv) i)))
+
+  (def (tainted-bytevector-length bv)
+    (bytevector-length (untaint bv)))
+
   ;; ========== Safe Wrappers (auto-enforce taint at dangerous sinks) ==========
   ;;
   ;; These wrappers automatically reject tainted arguments at dangerous
diff --git a/tests/test-safe-prelude.ss b/tests/test-safe-prelude.ss
index 54dbdd6..763e7fc 100644
--- a/tests/test-safe-prelude.ss
+++ b/tests/test-safe-prelude.ss
@@ -155,6 +155,15 @@
          (equal? (cadr result) "(#t #t #t #t)")))
   #t)
 
+(test "safe prelude exports expanded taint propagation wrappers"
+  (let ([result
+         (run-script-loader
+          "(import (jerboa prelude))\n(define parts (tainted-string-split (taint-http \"a,b\") #\\,))\n(define joined (tainted-string-join parts \"/\"))\n(define bv (tainted-string->utf8 joined))\n(write (list (tainted? joined) (tainted? bv) (tainted? (tainted-bytevector-copy bv)) (taint-value (tainted-bytevector-u8-ref bv 0))))\n"
+          #f)])
+    (and (loader-ok? result)
+         (equal? (cadr result) "(#t #t #t 97)")))
+  #t)
+
 (test "script loader unsafe flag preserves raw prelude and warns"
   (let ([result
          (run-script-loader
diff --git a/tests/test-security-taint.ss b/tests/test-security-taint.ss
index 11be14e..6e0cba3 100644
--- a/tests/test-security-taint.ss
+++ b/tests/test-security-taint.ss
@@ -55,6 +55,18 @@
 (test-t "tainted format string keeps taint" (tainted? (tainted-format (taint-http "v=~a") "1")))
 (test   "tainted format string value"       (taint-value (tainted-format (taint-http "v=~a") "1")) "v=1")
 (test-f "upcase clean stays clean"    (tainted? (tainted-string-upcase "abc")))
+(test-t "string-trim keeps taint"     (tainted? (tainted-string-trim (taint-http "  x  "))))
+(test   "string-trim value"           (taint-value (tainted-string-trim (taint-http "  x  "))) "x")
+(test-t "string-split taints pieces"  (tainted? (car (tainted-string-split (taint-net "a,b") #\,))))
+(test   "string-split piece value"    (taint-value (cadr (tainted-string-split (taint-net "a,b") #\,))) "b")
+(test-t "string-join keeps taint"     (tainted? (tainted-string-join (list "a" (taint-env "b")) ",")))
+(test   "string-join value"           (taint-value (tainted-string-join (list "a" (taint-env "b")) ",")) "a,b")
+(test-t "string->utf8 keeps taint"    (tainted? (tainted-string->utf8 (taint-http "abc"))))
+(test-t "utf8->string keeps taint"    (tainted? (tainted-utf8->string (tainted-string->utf8 (taint-http "abc")))))
+(test-t "bytevector-copy keeps taint" (tainted? (tainted-bytevector-copy (tainted-string->utf8 (taint-http "abc")))))
+(test-t "bytevector-u8-ref keeps taint" (tainted? (tainted-bytevector-u8-ref (tainted-string->utf8 (taint-http "abc")) 0)))
+(test   "bytevector-u8-ref value"     (taint-value (tainted-bytevector-u8-ref (tainted-string->utf8 (taint-http "abc")) 0)) 97)
+(test   "bytevector-length clean"     (tainted-bytevector-length (tainted-string->utf8 (taint-http "abc"))) 3)
 
 ;; (c) THE regression: a tainted string run through string-upcase then passed
 ;; to a taint-checking sink is still detected as tainted.