docs(16): create phase plan — 6 plans, 2 waves (dep audit, security checks, image hygiene)
SEC-01/02, DEP-01/02, IMG-01/02/03, CI-03. Wave 1: image-hygiene runtime guard (TDD), audit+outdated wrappers (TDD), eslint-plugin-security fold, gitleaks config+baseline+.dockerignore. Wave 2: ci.yml security job + gate wiring, publish.yml hygiene assertions + boot-smoke. esbuild GHSA-gv7w-rqvm-qjhr waivered in 16-02 before the gate goes live.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
---
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Restructure the Build/push step so build, assertions, and push are separable</name>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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)"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>Build and push are separate steps, creating an insertion point for the hygiene assertions.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Insert static image-hygiene assertion + boot-smoke between build and push</name>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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)
|
||||
</acceptance_criteria>
|
||||
<done>The publish job builds, then asserts hygiene + boot-smoke, then pushes — a hygiene failure blocks publish.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- 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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-06-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user