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"