From 59853492dde10e955b032c5e845f375b5d03e60a Mon Sep 17 00:00:00 2001 From: Yogthos Date: Thu, 11 Jun 2026 21:50:45 -0400 Subject: [PATCH] host: JOLT_BAKE_ENV_ALLOWLIST scrubs the env during image bakes (jolt-s3j) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native-executable build bakes the jolt ctx, and env-reading libraries (config.core/load-env) snapshot the ENTIRE build environment into it — jpm marshals that into the binary. GitHub push protection caught real API tokens inside an example's build output this way. With JOLT_BAKE_ENV_ALLOWLIST set (comma-separated names — a project's build.sh exports it for the bake), System/getenv serves only the listed variables: single-name reads of unlisted vars return nil and the full snapshot is filtered. Unset — every normal runtime — reads stay live and unfiltered, so a baked binary that re-reads env at startup (config.core/ reload-env) sees the real runtime environment. Verified A/B on ring-app: a planted token appears in the unscrubbed binary (strings | grep: 1 hit) and not in the scrubbed one (0), which still serves. Direct janet.os/environ bridge calls remain unfiltered host access, as documented. --- src/jolt/evaluator.janet | 21 ++++++++++++++++++++- test/spec/host-interop-spec.janet | 13 +++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index 767a919..ccfeb55 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -493,7 +493,26 @@ "user.home" (os/getenv "HOME") "java.io.tmpdir" (or (os/getenv "TMPDIR") "/tmp") dflt)) - "getenv" (fn [&opt k] (if k (os/getenv k) (os/environ))) + # JOLT_BAKE_ENV_ALLOWLIST (jolt-s3j): during an image bake (jpm build of a + # native executable, set by the project's build.sh) the env snapshot that + # libraries like config.core capture at load gets MARSHALED INTO THE BINARY + # — GitHub push protection once flagged real API tokens inside an example's + # build output. With the var set, System/getenv serves only the listed + # comma-separated names (single-var reads of unlisted names return nil), so + # nothing secret can bake. Unset (the normal runtime case), reads are live + # and unfiltered. + "getenv" (fn [&opt k] + (def allow (os/getenv "JOLT_BAKE_ENV_ALLOWLIST")) + (if (nil? allow) + (if k (os/getenv k) (os/environ)) + (let [names (string/split "," allow) + ok @{}] + (each n names (put ok (string/trim n) true)) + (if k + (when (get ok k) (os/getenv k)) + (let [e (os/environ) out @{}] + (eachp [ek ev] e (when (get ok ek) (put out ek ev))) + out))))) # the property subset getProperty serves, as an iterable map "getProperties" (fn [] {"os.name" (case (os/which) diff --git a/test/spec/host-interop-spec.janet b/test/spec/host-interop-spec.janet index e78faf4..3181484 100644 --- a/test/spec/host-interop-spec.janet +++ b/test/spec/host-interop-spec.janet @@ -186,3 +186,16 @@ ["replace fn gets group vector" "\"v=k\"" "(do (require (quote [clojure.string :as s9])) (s9/replace \"k=v\" #\"(\\w+)=(\\w+)\" (fn [[_ k v]] (str v \"=\" k))))"] ["indexOf int needle is a char code" "1" "(.indexOf \"a=b\" 61)"]) + +# JOLT_BAKE_ENV_ALLOWLIST (jolt-s3j): with the allowlist set, System/getenv +# serves only the listed names — so an image bake can't marshal the builder's +# secrets into the binary. Unset, reads are live and unfiltered. +(defspec "host-interop / bake env scrub" + ["unlisted name reads nil under the allowlist" "nil" + "(do (janet.os/setenv \"JOLT_BAKE_ENV_ALLOWLIST\" \"PATH\") (let [r (System/getenv \"HOME\")] (janet.os/setenv \"JOLT_BAKE_ENV_ALLOWLIST\" nil) r))"] + ["listed name still reads" "true" + "(do (janet.os/setenv \"JOLT_BAKE_ENV_ALLOWLIST\" \"HOME\") (let [r (System/getenv \"HOME\")] (janet.os/setenv \"JOLT_BAKE_ENV_ALLOWLIST\" nil) (string? r)))"] + ["full snapshot filtered to the allowlist" "true" + "(do (janet.os/setenv \"JOLT_BAKE_ENV_ALLOWLIST\" \"HOME\") (let [e (System/getenv)] (janet.os/setenv \"JOLT_BAKE_ENV_ALLOWLIST\" nil) (and (contains? (set (keys e)) \"HOME\") (= 1 (count (keys e))))))"] + ["no allowlist: unfiltered live reads" "true" + "(string? (System/getenv \"HOME\"))"])