From 4e14fafd00550a950871db3c4b80aa0cfec59c3c Mon Sep 17 00:00:00 2001 From: Michal Ferber Date: Mon, 7 Sep 2026 05:35:46 -0400 Subject: [PATCH] =?UTF-8?q?Add=20the=20=C2=A72=20eval=20gate,=20on=20green?= =?UTF-8?q?,=20and=20prove=20it=20can=20fail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DS §2 says built output MUST be free of runtime code generation and that `scripts/no-eval.sh` MUST run in CI against it. This repo shipped neither the script nor the step, so the rule had no instrument here. Estate context: 11 Node repos ship a `build` script and 7 already run this scan. This is the last of the 4 that did not (tgwab-standards#173). MEASURED, NOT INHERITED. The two repos already gated under #173 are LAN Astro sites and nothing about their result carries here — inheriting a scan result across repos is the failure that issue was filed against. Built from origin/main (c7f4561) and scanned: npm ci (prepare runs the build) -> lint (0) -> test (0) dist: 1 file, markdownwizard-tools.iife.js, 46,037 bytes ./scripts/no-eval.sh dist eval-free: OK exit 0 AND PROVED ABLE TO FAIL. Planting one file in dist: ::error::runtime code-generation found in dist (CSP has no 'unsafe-eval') dist/__ctl__/x.js:1:const f = new Function("a","return a"); eval("1"); exit 1 Removing it returns the run to green. So the gate goes on green because nothing eval-shaped reaches the bundle, not because the scan is lenient. SCOPE, STATED SO A GREEN RUN IS NOT READ AS MORE THAN IT IS — and this one is specific to a published package rather than a site. The `files` list ships BOTH `src` and `dist`, and the default export is `"." : "./src/index.js"`, so a consumer writing `import … from 'markdownwizard-tools'` gets the SOURCE. Only the `./iife` subpath resolves to what this scan covers. Nothing is hiding there: src/ scans clean today, all 8 files, exit 0. But whether §2's target should widen for a package whose default export is source is a STANDARDS question, not one this repo should answer by quietly editing its own step. Raised in tgwab-standards#173. Widening to `dist src` is a one-word change and is green today if that is the ruling. No placement subtlety: `prepare` is the build, so `npm ci` produces dist and the scan has the bundle to read wherever it sits after that. It is placed after `test` to keep the §15 gates contiguous. Refs MichalAFerber/tgwab-standards#173 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016beCydw4C9VrgL9eHzGUG2 --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ scripts/no-eval.sh | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100755 scripts/no-eval.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c435b71..0dc1e54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,28 @@ jobs: fi npm test + # §2 gate: built output MUST be free of runtime code generation. dist/ + # already exists by here — `prepare` is the build, so `npm ci` above + # produces it — and the scan must see the esbuild bundle, not the source. + # + # Measured clean when this was added: 1 dist file + # (markdownwizard-tools.iife.js, 46,037 bytes), `eval-free: OK`. It goes + # on green because nothing eval-shaped reaches the bundle, NOT because + # the scan is lenient — planting one `new Function(` in dist makes it + # exit 1. + # + # SCOPE, STATED SO A GREEN RUN IS NOT READ AS MORE THAN IT IS. This + # package's `files` list ships BOTH `src` and `dist`, and its default + # export is `"." : "./src/index.js"` — so a consumer doing + # `import … from 'markdownwizard-tools'` gets the SOURCE, and only the + # `./iife` subpath gets what this scan covers. src/ scans clean today + # (all 8 files, exit 0), so nothing is hiding there; whether §2's target + # should widen for a package whose default export is source is a + # standards question, raised in tgwab-standards#173 rather than decided + # here. Widening this to `dist src` is a one-word change and is green. + - name: No runtime code generation in built output (§2) + run: ./scripts/no-eval.sh dist + # Full tree, deliberately not --omit=dev (DS §15): the dev half is the # build toolchain, and what it writes into dist/ is what ships. Excluding it # blinds the gate to the half whose compromise reaches users. Narrowing this diff --git a/scripts/no-eval.sh b/scripts/no-eval.sh new file mode 100755 index 0000000..de07e4e --- /dev/null +++ b/scripts/no-eval.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# TGWAB eval gate—DEV-STANDARDS §2. +# +# Place at: scripts/no-eval.sh · CI: ./scripts/no-eval.sh dist +# +# The §12 CSP never carries 'unsafe-eval', which blocks eval(), new Function(), +# bare Function("…"), and string-form setTimeout/setInterval/setImmediate. Anything +# this finds is code that will throw at runtime for a real user. +# +# Runs against BUILT output, not source: esbuild preserves the `eval` identifier +# through minification, so the check survives bundling. +# +# Escape hatch (§2): a vendor file that genuinely cannot be made eval-free goes in +# .eval-allowlist as a path prefix with a one-line reason, AND as a README deviation. +# The CSP MUST NOT be loosened instead. +set -euo pipefail + +TARGET="${1:-dist}" +ALLOW=".eval-allowlist" + +PATTERN='(^|[^A-Za-z0-9_$])eval[[:space:]]*\(|new[[:space:]]+Function[[:space:]]*\(|(^|[^A-Za-z0-9_$.])Function[[:space:]]*\([[:space:]]*["'"'"'`]|set(Timeout|Interval|Immediate)[[:space:]]*\([[:space:]]*["'"'"'`]' + +hits="$(grep -nEr "$PATTERN" \ + --include='*.js' --include='*.mjs' --include='*.cjs' --include='*.html' \ + "$TARGET" || true)" + +if [ -s "$ALLOW" ]; then + hits="$(printf '%s\n' "$hits" \ + | grep -vFf <(grep -v '^[[:space:]]*#' "$ALLOW" | grep -v '^[[:space:]]*$') || true)" +fi + +if [ -n "$hits" ]; then + printf '::error::runtime code-generation found in %s (CSP has no %s)\n' "$TARGET" "'unsafe-eval'" + printf '%s\n' "$hits" + exit 1 +fi + +printf 'eval-free: OK (%s)\n' "$TARGET"