diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml index 382cc86..13de399 100644 --- a/deploy/docker-compose.test.yml +++ b/deploy/docker-compose.test.yml @@ -305,8 +305,28 @@ services: CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CLOCK_SKEW_TOLERANCE: 60s CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_PER_DEVICE_RATE_LIMIT_24H: 3 - # Dynamic issuer/target config encryption (M34/M35) - CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!! + # Dynamic issuer/target config encryption (M34/M35). + # + # MUST be ≥ 32 bytes. The H-1 closure (commit 6cb4414, "feat(security): + # encryption-key validation") added internal/config/config.go's + # minEncryptionKeyLength = 32 byte floor; values shorter than that are + # rejected at server boot with `Failed to load configuration: + # CERTCTL_CONFIG_ENCRYPTION_KEY too short`. The previous test value + # `test-encryption-key-32chars!!` was 29 bytes (the name claimed 32 but + # the author miscounted — 4+1+10+1+3+1+2+5+2 = 29). Pre-H-1 the + # validator accepted any non-empty string, so the gap was silent. Once + # the test stack actually boots the certctl-server (which the + # ci-pipeline-cleanup Phase 5 matrix collapse forced for the first + # time), the server now hard-fails at startup and the deploy-vendor-e2e + # job's `dependency failed to start: container certctl-test-server + # is unhealthy` error fires. + # + # The replacement below is 49 bytes — 17 bytes of safety margin over + # the floor so a future tightening (32 → 33+) does not break this + # fixture. It is clearly test-only / deterministic; do NOT copy this + # to production. Operators set CERTCTL_CONFIG_ENCRYPTION_KEY from + # `openssl rand -base64 32` per the README. + CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-deterministic-32-byte-fixture # Network scanning CERTCTL_NETWORK_SCAN_ENABLED: "true" diff --git a/scripts/ci-guards/H-1-encryption-key-min-length.sh b/scripts/ci-guards/H-1-encryption-key-min-length.sh new file mode 100755 index 0000000..b163747 --- /dev/null +++ b/scripts/ci-guards/H-1-encryption-key-min-length.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# scripts/ci-guards/H-1-encryption-key-min-length.sh +# +# H-1 master commit 6cb4414 ("feat(security): bodyLimit on noAuth + +# security headers + encryption-key validation (H-1 master)") added +# internal/config/config.go's minEncryptionKeyLength = 32 byte floor +# and 5 unit-test cases at internal/config/config_test.go for it. +# +# The closure was incomplete: it didn't enforce the floor against +# the literal values certctl's own deploy/docker-compose*.yml files +# pass via `CERTCTL_CONFIG_ENCRYPTION_KEY: `. Pre-Phase-5 +# (ci-pipeline-cleanup matrix collapse) the test stack didn't fully +# exercise the validator at boot, so the gap was silent. Once the +# collapsed deploy-vendor-e2e job started actually booting the +# certctl-test-server, deploy/docker-compose.test.yml's 29-byte +# `test-encryption-key-32chars!!` (the name claimed 32 but the +# author miscounted: 4+1+10+1+3+1+2+5+2 = 29) failed the validator +# at startup and the whole job started failing with +# `dependency failed to start: container certctl-test-server is +# unhealthy` — without an obvious cause until the diagnostic-dump +# step in commit 69266c8 made it visible. +# +# This guard closes the recurrence path: every literal value +# CERTCTL_CONFIG_ENCRYPTION_KEY in any deploy/docker-compose*.yml +# is checked against the 32-byte floor at PR time. `${VAR:-...}` +# expansions where `...` is not a literal are skipped (they're +# operator-supplied; runtime validation handles them). Default +# values inside `${VAR:-default}` ARE checked — if the operator +# never sets the env var, the default takes effect. +# +# Per the contract documented in scripts/ci-guards/README.md: +# bare callable, no args, no env, exit 0 on clean. + +set -e + +GUARD_NAME="H-1-encryption-key-min-length" +MIN_BYTES=32 +ENV_VAR="CERTCTL_CONFIG_ENCRYPTION_KEY" + +# Find every literal value in any deploy/docker-compose*.yml. +# Matches lines of the form (with optional leading whitespace): +# +# CERTCTL_CONFIG_ENCRYPTION_KEY: +# CERTCTL_CONFIG_ENCRYPTION_KEY: ${VAR:-} +# CERTCTL_CONFIG_ENCRYPTION_KEY: ${VAR} # operator-supplied; skip +# +# The validator runs against whatever the runtime sees — if the env +# expansion ${VAR:-default} resolves to `default`, that's what the +# server validates against. So `default` IS a literal we should check. +# A bare ${VAR} with no default is a runtime contract — skip. + +failed=0 +while IFS= read -r line; do + file=$(echo "$line" | cut -d: -f1) + lineno=$(echo "$line" | cut -d: -f2) + raw=$(echo "$line" | cut -d: -f3-) + + # Strip leading whitespace and the env-var name + colon. + value=$(echo "$raw" | sed -E "s/^\s*${ENV_VAR}\s*:\s*//") + # Strip any trailing inline comment + surrounding whitespace. + value=$(echo "$value" | sed -E 's/\s+#.*$//' | sed -E 's/\s+$//') + + # Three cases: + # 1. Pure literal: `value` + # 2. Env expansion with default: `${SOMEVAR:-default}` + # 3. Env expansion without default: `${SOMEVAR}` — skip + case "$value" in + '${'*':-'*'}') + # Extract the default between :- and the closing }. + default=$(echo "$value" | sed -E 's/.*:-(.*)\}.*/\1/') + check="$default" + kind="default of ${value}" + ;; + '${'*'}') + # Bare env reference, no default — operator-supplied at runtime. + continue + ;; + *) + check="$value" + kind="literal" + ;; + esac + + byte_len=${#check} + if [ "$byte_len" -lt "$MIN_BYTES" ]; then + echo "::error file=${file},line=${lineno}::${ENV_VAR} ${kind} is ${byte_len} bytes (minimum ${MIN_BYTES}). H-1 closure (config.go:1974) rejects values <32 at server boot. Generate a replacement with: openssl rand -base64 32" + failed=1 + fi +done < <(grep -nE "^\s*${ENV_VAR}\s*:" deploy/docker-compose*.yml 2>/dev/null || true) + +if [ "$failed" -ne 0 ]; then + echo "" + echo "${GUARD_NAME}: FAILED — at least one ${ENV_VAR} literal violates the 32-byte floor." + exit 1 +fi + +echo "${GUARD_NAME}: clean." diff --git a/scripts/ci-guards/README.md b/scripts/ci-guards/README.md index 0b173eb..9411747 100644 --- a/scripts/ci-guards/README.md +++ b/scripts/ci-guards/README.md @@ -53,7 +53,7 @@ Current helpers: 4. CI auto-picks up new scripts via the `for g in scripts/ci-guards/*.sh` loop in the `Regression guards` step — no ci.yml change required. -## The 20 guards in this directory +## The 21 guards in this directory | ID | Finding | Catches | |---|---|---| @@ -77,6 +77,7 @@ Current helpers: | `bundle-8-L-015-target-blank-rel-noopener` | L-015 (CWE-1022) reverse-tabnabbing | `target="_blank"` without `rel="noopener noreferrer"` | | `bundle-8-L-019-dangerously-set-inner-html` | L-019 (CWE-79) XSS | `dangerouslySetInnerHTML` outside `safeHtml.ts` | | `bundle-8-M-009-bare-usemutation` | M-009 + M-029 mutation contract | Bare `useMutation()` outside `useTrackedMutation` wrapper | +| `H-1-encryption-key-min-length` | H-1 closure follow-up (post-Phase-5 surfacing) | `CERTCTL_CONFIG_ENCRYPTION_KEY` literal in any `deploy/docker-compose*.yml` shorter than the 32-byte floor enforced by `internal/config/config.go::Validate()` | ## Guards explicitly NOT here