--- phase: 16-ci-dependency-audit-and-security-checks plan: 06 type: execute wave: 2 depends_on: ["16-01", "16-04"] files_modified: - .gitea/workflows/publish.yml autonomous: true requirements: [IMG-03] must_haves: truths: - "On push-to-main publish, a static assertion fails the publish unless .dockerignore exists, covers the forbidden patterns, and publish.yml still pins --target production" - "A boot-smoke runs the freshly-built production image with NODE_ENV=production + DEV_AUTH_BYPASS=true and fails the publish unless the image refuses to boot (non-zero exit, neither 0 nor a 124 timeout)" - "Both assertions run AFTER docker build but BEFORE docker push, so a hygiene failure can never publish the image" artifacts: - path: ".gitea/workflows/publish.yml" provides: "static image-hygiene assertion + boot-smoke steps, ordered before docker push" contains: "boot-smoke" key_links: - from: ".gitea/workflows/publish.yml boot-smoke" to: "apps/api/src/lib/bootGuards.ts (via the built image)" via: "docker run prod image with forbidden env, assert non-zero exit" pattern: "DEV_AUTH_BYPASS=true" --- Add the publish-time image-hygiene CI assertions (D-10 / IMG-03) to `.gitea/workflows/publish.yml`: a static assertion (`.dockerignore` exists + covers forbidden patterns + `--target production` still pinned) and a boot-smoke that runs the freshly-built production image with the forbidden `NODE_ENV=production DEV_AUTH_BYPASS=true` combo and asserts it refuses to boot — proving the D-08 guard fires in the ACTUAL shipped image. Purpose: The runtime guard (16-01) and the `.dockerignore` (16-04) are only as good as their enforcement at the boundary where the image is actually published. These assertions are the CI-level proof. Critically, they must run after `docker build` (so the image exists and the smoke can run it) but BEFORE `docker push` (so a hygiene regression cannot publish a broken image). The image build only happens at publish (push-to-main), so this attaches to publish.yml, not to every PR. Output: The modified `publish.yml`. Depends on 16-01 (the boot guard + ENV NODE_ENV=production must be in the image for the smoke to pass) and 16-04 (the .dockerignore the static assertion greps for). Does not touch ci.yml. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/16-ci-dependency-audit-and-security-checks/16-CONTEXT.md @.planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md @.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md @.gitea/workflows/publish.yml Task 1: Restructure the Build/push step so build, assertions, and push are separable - .gitea/workflows/publish.yml (the "Build and push" step lines 79-92 — currently builds then pushes in one run block; the tags step lines 43-55 outputs sha_tag/latest; the Docker logout step lines 95-97) - .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (publish.yml section — assertions placed BEFORE the docker push lines; set -euo pipefail; if: always() cleanup pattern) - .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (D-10 section — ordering rule: static assertions then boot-smoke BEFORE push; the existing structure runs steps sequentially) In publish.yml, split the current "Build and push" step (lines 79-92) so the `docker build --target production ...` invocation is its own step ("Build production image") that builds + tags the image but does NOT push, and the two `docker push` lines move into a separate later "Push image" step (`set -euo pipefail`; push the immutable sha_tag FIRST, then latest — preserve the existing WR-04 ordering comment). Keep the `--target production`, `-f apps/api/Dockerfile`, both `-t` tags, and the root `.` context identical. Leave the Compute-tags step, Docker login, and Docker logout steps unchanged. This creates the seam where Task 2's assertions insert between build and push. Commit: `ci(16-06): split publish build and push into separate steps`. python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/publish.yml')); steps=[s.get('name','') for s in d['jobs']['publish']['steps']]; assert any('Build' in n for n in steps) and any('Push' in n for n in steps), steps; print('build/push split OK:',steps)" - publish.yml has a distinct build step (docker build, no push) and a distinct push step (docker push sha_tag then latest) - --target production, the Dockerfile path, both tags, and the `.` context are unchanged - The immutable-tag-first push ordering (WR-04) is preserved in the push step Build and push are separate steps, creating an insertion point for the hygiene assertions. Task 2: Insert static image-hygiene assertion + boot-smoke between build and push - .gitea/workflows/publish.yml (the build step and push step from Task 1; the tags step outputs steps.tags.outputs.sha_tag) - .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (publish.yml section — exact static-assertion grep loop over patterns; the boot-smoke `timeout 15 docker run --rm --env NODE_ENV=production --env DEV_AUTH_BYPASS=true "$IMAGE"` block with the EXIT==0 fail, EXIT==124 timeout fail, otherwise PASS logic) - .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (D-10 section — full static + boot-smoke step bodies; Pitfall 5: guard fires before DB/OIDC/VAPID so no env beyond the forbidden combo is needed) - .dockerignore (16-04 — the forbidden patterns the static assertion greps for must match) - apps/api/src/lib/bootGuards.ts (16-01 — the guard the boot-smoke proves fires in the image) Insert two new steps in publish.yml AFTER the "Build production image" step and BEFORE the "Push image" step. Step A "Image hygiene — static assertions" (`set -euo pipefail`): fail if .dockerignore is absent; loop over the forbidden patterns (.env, node_modules, apps/api/scripts, .git, .planning, apps/api/tests, apps/pwa/e2e) and fail if any is missing from .dockerignore; fail if `--target production` is no longer grep-able in .gitea/workflows/publish.yml; echo a PASS line. Step B "Image hygiene — boot-smoke (must refuse dev-bypass in production)" (`set -euo pipefail`): set IMAGE to ${{ steps.tags.outputs.sha_tag }}; `set +e`; `timeout 15 docker run --rm --env NODE_ENV=production --env DEV_AUTH_BYPASS=true "$IMAGE" 2>&1 | head -20`; capture EXIT; `set -e`; fail with a clear message if EXIT==0 (image started — guard not working) or EXIT==124 (timeout — guard not firing); otherwise echo PASS (image refused to start). Because both steps precede the push step and `set -euo pipefail` / non-zero exits stop the job, a failure blocks the push. Commit: `ci(16-06): add static image-hygiene assertion + boot-smoke before push`. python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/publish.yml')); names=[s.get('name','') for s in d['jobs']['publish']['steps']]; bi=next(i for i,n in enumerate(names) if 'Build' in n); si=next(i for i,n in enumerate(names) if 'Push' in n); seg=names[bi+1:si]; assert any('static' in n.lower() for n in seg) and any('boot-smoke' in n.lower() for n in seg), names; print('assertions between build and push OK')" - A static-assertions step and a boot-smoke step both appear strictly between the build step and the push step - The static assertion greps for all forbidden .dockerignore patterns AND the `--target production` pin - The boot-smoke runs the sha_tag image with NODE_ENV=production + DEV_AUTH_BYPASS=true and fails on EXIT 0 or 124, passes otherwise - publish.yml parses as valid YAML; the push step still runs last (after the assertions) The publish job builds, then asserts hygiene + boot-smoke, then pushes — a hygiene failure blocks publish. ## Trust Boundaries | Boundary | Description | |----------|-------------| | built image → container registry | The push is the point of no return; once published, the image is pullable/deployable | | Dockerfile/config drift → shipped image | A future change could re-introduce dev-bypass tolerance or strip the .dockerignore; the assertions catch that at publish | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-16-18 | Elevation of Privilege | A regressed production image that tolerates DEV_AUTH_BYPASS=true is published | mitigate | D-10: boot-smoke runs the built image with the forbidden combo and fails the publish unless it exits non-zero (Task 2), proving the D-08 guard fires in the shipped artifact before push | | T-16-19 | Information Disclosure | A future change removes/weakens .dockerignore or drops --target production, shipping secrets/dev files | mitigate | D-10 static assertion fails the publish if .dockerignore is missing/incomplete or --target production is unpinned (Task 2) | | T-16-20 | Tampering | Assertions run after push, allowing a bad image to publish before the check fails | mitigate | Ordering enforced: build → assertions → push (Tasks 1-2); push is a separate later step, so any assertion failure stops the job before push | | T-16-21 | Denial of Service | Boot-smoke hangs if the guard does not fire, wedging the publish job | mitigate | `timeout 15` caps the smoke; EXIT==124 is treated as a guard-not-firing failure (Task 2) | - publish.yml parses as valid YAML - Step order: Build production image → static assertions → boot-smoke → Push image - Static assertion patterns match the .dockerignore authored in 16-04 - Boot-smoke uses sha_tag, the forbidden env combo, timeout 15, and the EXIT 0/124 fail logic - Live proof (an actual publish run showing the smoke PASS) is the phase-verification check after merge - Static + boot-smoke assertions inserted between build and push - A hygiene/boot regression blocks the push - Boot-smoke proves the D-08 guard fires in the real production image - ci.yml untouched; publish topology otherwise unchanged Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-06-SUMMARY.md` when done.