12 KiB
Phase 16: CI Dependency Audit, Security Checks & Image Hygiene - Context
Gathered: 2026-06-12 Status: Ready for planning
## Phase BoundaryExtend the existing Gitea CI with three families of gates — implemented as additions to the current .gitea/workflows/ci.yml (PR workflow: changes → fast-checks / api / harness → gate) and .gitea/workflows/publish.yml (push-to-main publish, builds --target production). Not a new pipeline, not a new runtime dependency, not a new external service.
- Dependency audit —
pnpm auditagainst the lockfile (fail on High+Critical) + apnpm outdatedadvisory report. - Security checks baseline — secret scanning (gitleaks) + a static security lint (eslint-plugin-security folded into the existing ESLint gate).
- Dev↔prod image hygiene (absorbs backlog 999.17) — provably confine
DEV_AUTH_BYPASSand any dev affordance/secret/seed data to local dev; the published production image must never carry them and must refuse to run with dev-bypass enabled.
Explicitly out of scope: automated dependency upgrade bots (Renovate/Dependabot), Trivy/image CVE scanning, removing dev-bypass (still needed for local + Phase 7/8 harness).
## Implementation DecisionsSecurity-Check Baseline
- D-01: Baseline = secret scanning + static security lint. Trivy/image CVE scanning is dropped — not even backlogged; revisit only if a future need arises.
- D-02: Secret scanning via gitleaks (tool choice researcher may confirm vs trufflehog). Scope = per-PR diff (blocking) + a one-time full-history/full-tree baseline scan to catch anything already committed. The app handles real family credentials (encryption key, OIDC secret, Fastmail app passwords), so secret scanning is core.
- D-03: eslint-plugin-security folded into the existing Phase 13 ESLint gate, as blocking errors (not warnings). Accepted consequence: the plugin is heuristic/noisy (e.g.
detect-object-injection); the executor must triage existing code — add targetedeslint-disablewith justification or rule-tune — to get the gate green. This is expected work, not a blocker.
Dependency Audit & Outdated
- D-04:
pnpm auditfails the build on High + Critical; moderate/low are advisory only. - D-05: Unfixable/transitive advisories are waived via a committed allowlist file in the repo — advisory IDs (CVE/GHSA) each with a reason + reviewer, reviewed through PR. A wrapper filters
pnpm auditoutput against it (or pnpm's nativeauditConfig.ignore*if the researcher finds it cleaner — but keep it auditable and self-documenting, not silent). - D-06: Outdated reporting runs and never gates (respects the deliberate exact-version pins in CLAUDE.md). Exact balance is an open research question — see OQ-01.
Dev/Prod Image Hygiene (999.17)
- D-07: Bake
ENV NODE_ENV=productioninto the production Dockerfile stage. Today theproductionstage sets noNODE_ENVandCMDrunsnode dist/index.jswith no env baked in, sodevBypass.ts's "hard guard" (NODE_ENV==='production'checked first) is not actually engaged in the shipped image — it's only safe because the second check (DEV_AUTH_BYPASS !== 'true') passes through when unset. BakingNODE_ENV=productionengages the hard guard. - D-08: Add a boot-time refuse-to-boot guard: on startup, if
NODE_ENV==='production'ANDDEV_AUTH_BYPASS==='true', throw and exit non-zero instead of silently no-op'ing. Unit-tested. (This is defense-in-depth on top of D-07 — turns a silent misconfig into a loud failure.) - D-09: Create a full
.dockerignore(none exists today — the whole repo root is currently sent to the Docker daemon as build context). Scope = secrets + dev + bulk:.env/.env.*(incl.apps/api/.env.spike),apps/api/scripts/seed-credential.mjs,.git,node_modules,dist,test/tests,e2e,.planning,*.sql/dumps, Playwright artifacts (researcher enumerates the exact list against the current tree). - D-10: CI assertion = static + boot-smoke. Static: assert
.dockerignoreexists & covers the forbidden patterns, andpublish.ymlstill pins--target production. Boot-smoke: start the built production image withNODE_ENV=production+DEV_AUTH_BYPASS=trueand assert it refuses to boot (exits non-zero, proving D-08 in the actual image). Full filesystem forensics deemed unnecessary — theproductionstage already copies onlyapps/api/dist+apps/pwa/dist.
Gating & Noise Posture
- D-11: Blocking vs advisory split — block: gitleaks (secret found), eslint-plugin-security,
pnpm auditHigh+Critical, image-hygiene boot-smoke + static checks. Advisory (never gates):pnpm outdated. - D-12: Doc-only PR behavior: gitleaks runs on every PR including doc-only (a secret can land in a doc/config).
pnpm audit+pnpm outdatedare gated behind the code/lockfilechangesfilter likeapi/harness(mirrors Phase 15's doc-only-skip model). - D-13: Advisory results surface in the job log only — no PR comment / Gitea API wiring. Gitea doesn't render GitHub-style annotations (see
ci.ymlPitfall 5 / D-06: reportergithubis overridden). Blocking checks surface via failed status + thegateaggregate. - D-14: Any new blocking job must be wired into the
gateaggregator (if: always(), individualneeds.X.resultchecks per the Gitea 1.26.2 wildcard bug #31007) and, if it becomes a required context, into branch protection onmain.
Claude's Discretion / Researcher Decides
- Job decomposition (D-15): how the new PR-time checks (secret scan, audit, outdated) are laid out in
ci.yml— a dedicated parallelsecurityjob vs folding intofast-checks— is left to the researcher/planner against runner constraints (noactions/cache— times out; ~30s install per job). Recommendation surfaced in discussion: a newsecurityjob parallel tofast-checkskeeps the critical path fast and isolates advisory churn; eslint-plugin-security folds into the existing lint step regardless. - Exact secret-scan tool (gitleaks vs trufflehog) and exact
.dockerignoreline list — researcher confirms.
Folded Todos
None folded. (See Deferred — the one matched todo was already delivered in Phase 8.)
<open_questions>
Open Research Questions
- OQ-01 (outdated-vs-pins balance): Design a pragmatic
pnpm outdatedreporting policy that respects the intentional exact-version pins in CLAUDE.md but still surfaces when a pin is a liability — e.g. the pinned version is multiple major versions behind latest, or the pinned version itself carries a known advisory. The user's words: "Version pins are fine but if there's an issue with them or if they are too far behind there should be a balance here." Output should be a concrete, advisory-only mechanism (what's reported, how a "dangerously behind" pin is flagged distinctly from routine drift). Never gates the build (D-06). </open_questions>
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Existing CI to extend
.gitea/workflows/ci.yml— PR workflow being extended. Jobs:changes(dorny/paths-filter),fast-checks(lint/format/md-lint/typecheck/pwa-unit),api(DB-backed, MariaDB service container),harness(Playwright iphone+pixel+desktop),gate(if: always()aggregator). Documents runner pitfalls: noactions/cache, nomysqlCLI,localhost→::1vs IPv4, Gitea annotation non-rendering, wildcard-needs bug #31007..gitea/workflows/publish.yml— push-to-main publish. Buildsdocker build --target production -f apps/api/Dockerfile .; tags:latest+:<MILESTONE>-<shortsha>;REGISTRY_PATsecret (Gitea forbidsGITEA_prefix). This is where the image-hygiene boot-smoke + static assertion attach.
Image hygiene (999.17)
apps/api/Dockerfile— multi-stage:base/builder/dev/pwa-builder/production.productioncopies onlyapps/api/dist+apps/pwa/dist; noENV NODE_ENV(the gap D-07 fixes).devtarget shares the file.apps/api/src/auth/devBypass.ts— the dev-bypass middleware +DEV_USER(id=1). Hard guard checksNODE_ENV==='production'first; boot-time refuse-to-boot (D-08) extends this.- (no
.dockerignoreexists yet — D-09 creates it)
Constraints / precedent
CLAUDE.md— pins exact dependency versions intentionally (drives D-06 / OQ-01); Technology Stack + Version Compatibility tables..planning/phases/15-doc-only-ci-skip-and-md-lint/15-CONTEXT.md— gate-aggregation + doc-only-skip noise-control precedent (D-12, D-14 mirror it)..planning/phases/08-gitea-ci/08-CONTEXT.md— original CI design decisions + runner-constraint probes (D-PROBE-*).
Memory (operator-confirmed gotchas)
- Gitea CI runner gotchas:
ubuntu-latestlabel, noactions/cache, nomysqlCLI,GITEA_secret prefix forbidden (useREGISTRY_PAT),actreaps backgrounded procs at step boundary. </canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
gatejob pattern (ci.yml) — new blocking jobs plug into its per-needs.X.resultaggregation; copy the success/skipped tolerance logic.changes/paths-filter (ci.yml) — reuseneeds.changes.outputs.codeto gate audit/outdated on code/lockfile changes (D-12).- Existing ESLint gate (Phase 13, run in
fast-checkspnpm lint) — eslint-plugin-security plugs into the same config/step (D-03). - Phase 8 inline-Node-via-mysql2 pattern — precedent for runner steps without extra CLIs (no
mysql, no extra binaries assumed available).
Established Patterns
- Image build only at publish (
publish.yml, push-to-main). The boot-smoke (D-10) builds/runs the image where it's already built — at publish — rather than adding a full image build to every PR. - Throwaway CI creds scoped to ephemeral service containers — never reuse for any new secret-handling step.
- Job-log-only result surfacing — Gitea annotation non-rendering already forced
--reporter=list,htmlovergithub; advisory output follows the same constraint (D-13).
Integration Points
- New
security(or folded) checks →ci.ymljobs +gateaggregator + possibly branch-protection required contexts. - Boot-time guard →
apps/apistartup path (alongside/withindevBypass.tsusage inindex.ts) + a unit test. .dockerignore→ repo root;ENV NODE_ENV=production→productionstage ofapps/api/Dockerfile.- Boot-smoke + static assertion →
publish.yml(post-build, pre/around push). </code_context>
- gitleaks preferred for secret scanning (single binary, easy on a self-hosted runner); per-PR diff + one-time full-history baseline.
- eslint-plugin-security must be blocking even though it's noisy — the user explicitly chose
erroroverwarn. - The
NODE_ENVgap in the production image was the concrete "aha" of this discussion — fixing it (D-07) is the highest-leverage, lowest-cost hardening. - Keep the whole phase additive to existing CI — no rewrite of
ci.yml/publish.ymlstructure.
- Renovate / Dependabot automated dependency upgrades — out of scope; detection/enforcement only this phase. Self-hosted Renovate on Gitea is its own setup + interplay with the pin strategy. Candidate for a future phase/backlog.
- Trivy / image CVE scanning — dropped, not backlogged per the user; reconsider only if a concrete need arises (base-image
node:22-alpineCVE exposure). - PR-comment surfacing of advisory results (Gitea API) — deferred in favor of job-log-only (D-13); revisit if visibility proves insufficient.
Reviewed Todos (not folded)
2026-06-10-gitea-ci-regression-and-docker-publish.md("Gitea CI — full regression on PR to main + build/publish Docker image") — matched on keywords but already delivered in Phase 8 (CI-01/CI-02). Not in Phase 16 scope; this is a stale pending-todo that should be archived.
Phase: 16-ci-dependency-audit-and-security-checks Context gathered: 2026-06-12