--- phase: 16-ci-dependency-audit-and-security-checks verified: 2026-06-13T12:56:26Z status: passed score: 8/8 overrides_applied: 0 --- # Phase 16: CI Dependency Audit, Security Checks & Image Hygiene — Verification Report **Phase Goal:** Extend Gitea CI with outdated-dependency reporting + vulnerability audit + a baseline of additional security checks, and enforce the dev/prod image boundary so no dev-bypass, secret, or family data ships in published images. **Verified:** 2026-06-13T12:56:26Z **Status:** passed **Re-verification:** No — initial verification ## Goal Achievement ### Observable Truths | # | Truth | Status | Evidence | |---|-------|--------|----------| | 1 | A production image with DEV_AUTH_BYPASS=true refuses to boot (process exits non-zero) | VERIFIED | `assertNotDevBypassInProduction()` in `bootGuards.ts` calls `process.exit(1)` when `NODE_ENV==='production' && DEV_AUTH_BYPASS==='true'`; 3/3 unit tests pass | | 2 | The production Docker stage bakes NODE_ENV=production so the devBypass hard guard is engaged | VERIFIED | `ENV NODE_ENV=production` at line 45 of `apps/api/Dockerfile`, inside `FROM base AS production` stage only | | 3 | The boot guard is a unit-tested exported function, called first in isMainModule() | VERIFIED | `bootGuards.ts` exports `assertNotDevBypassInProduction`; called at `index.ts:116` as the first statement inside `if (isMainModule()) {`, before VAPID config (line 121), workers (line 141), and serve (line 149) | | 4 | The audit wrapper blocks unwaived High+Critical advisories; esbuild GHSA waived with expiry | VERIFIED | `check-audit.mjs` exports `selectBlocking`/`partitionAdvisories`/`isWaived`; no `--audit-level` flag; esbuild `GHSA-gv7w-rqvm-qjhr` waived in `audit-allowlist.json` with reviewer, reason, and future expiry `2026-09-01`; 9/9 unit tests pass (including expiry + fail-closed on malformed date) | | 5 | The outdated wrapper is advisory-only (always exits 0), tiered, and pin-aware | VERIFIED | `check-outdated.mjs` unconditionally calls `process.exit(0)` at line 189; no reachable `process.exit(1)`; four tiers (OUTDATED-WITH-ADVISORY / MAJOR-BEHIND-INTENTIONAL / MAJOR-BEHIND-UNPINNED / ROUTINE-DRIFT); reads `outdated-pins.json` with four pin reasons (eslint, @eslint/js, zod, @types/node) | | 6 | eslint-plugin-security runs as blocking errors in pnpm lint, baseline is green | VERIFIED | `eslint-plugin-security@3.0.1` in root devDependencies; folded into `eslint.config.js` section 5 before `prettierConfig`; `detect-object-injection` disabled globally with inline justification comment; `pnpm lint` exits 0 with `--max-warnings 0`; `pnpm typecheck` passes | | 7 | A gitleaks config with useDefault + fixture allowlists exists; clean baseline committed | VERIFIED | `.gitleaks.toml` has `[extend] useDefault = true` and 4 `[[allowlists]]` blocks (VAPID fixture, .env.example, .env.spike, crypto test); `scripts/gitleaks-baseline.json` is valid JSON `[]` (empty — no pre-existing findings); `.dockerignore` covers all 7 forbidden patterns and does NOT exclude `apps/api/src` | | 8 | security job (gitleaks always; audit/outdated code-gated) wired into gate as strict success | VERIFIED | `ci.yml` has `security:` job with `needs: [changes]`, `if: pull_request`; checkout has `fetch-depth: 0`; gitleaks steps have no `if:`; audit/outdated steps have `if: needs.changes.outputs.code == 'true'`; gate `needs:` includes `security`; gate script checks `needs.security.result != 'success'` as an individual non-skippable check (not in the success-or-skipped loop) | **Score:** 8/8 truths verified ### Required Artifacts | Artifact | Expected | Status | Details | |----------|----------|--------|---------| | `apps/api/src/lib/bootGuards.ts` | Exports `assertNotDevBypassInProduction()` | VERIFIED | Exists, exports function, correct logic | | `apps/api/tests/lib/bootGuards.test.ts` | 3 unit test cases | VERIFIED | 3 cases present; 3/3 pass via vitest | | `apps/api/Dockerfile` | `ENV NODE_ENV=production` in production stage | VERIFIED | Line 45, inside `FROM base AS production` only | | `apps/api/src/index.ts` | Imports and calls guard first in isMainModule | VERIFIED | Import at line 18; call at line 116, first statement in block | | `scripts/check-audit.mjs` | Blocking wrapper with pure filter exports | VERIFIED | Exports `selectBlocking`, `partitionAdvisories`, `isWaived`; no `--audit-level` | | `scripts/audit-allowlist.json` | GHSA-gv7w-rqvm-qjhr waiver with reason+reviewer+expires | VERIFIED | Valid JSON; all fields present; expiry 2026-09-01 (future) | | `scripts/check-outdated.mjs` | Advisory-only tiered report; always exits 0 | VERIFIED | `process.exit(0)` at end; no reachable exit(1) on report path | | `scripts/outdated-pins.json` | 4 pin reasons (eslint, @eslint/js, zod, @types/node) | VERIFIED | All 4 present with justification text | | `scripts/__tests__/check-audit.test.mjs` | 9 test cases (4 plan-required + 5 expiry/edge cases) | VERIFIED | 9/9 pass via `node --test` | | `eslint.config.js` | eslint-plugin-security before prettierConfig; detect-object-injection off with justification | VERIFIED | Section 5; inline comment on disabled rule | | `package.json` | eslint-plugin-security in devDependencies | VERIFIED | `3.0.1` | | `.gitleaks.toml` | useDefault + 3 allowlists (VAPID, .env.example, .env.spike) | VERIFIED | Present; 4 allowlists (plan called for 3; crypto.test.ts is a bonus) | | `scripts/gitleaks-baseline.json` | Valid JSON, confirmed clean | VERIFIED | `[]` — no findings | | `.dockerignore` | Forbidden patterns present; apps/api/src NOT excluded | VERIFIED | All 7 required patterns found; apps/api/src does not appear as an exclusion | | `.gitea/workflows/ci.yml` | security job + updated gate | VERIFIED | Job present with correct conditional structure and gate wiring | | `.gitea/workflows/publish.yml` | Static assertion + boot-smoke between build and push | VERIFIED | Step order: Build → static assertions → boot-smoke → Push | ### Key Link Verification | From | To | Via | Status | Details | |------|----|-----|--------|---------| | `apps/api/src/index.ts` | `apps/api/src/lib/bootGuards.ts` | `import { assertNotDevBypassInProduction }` + call as first statement in `isMainModule()` | WIRED | Line 18 import; line 116 call; verified order before VAPID/workers/serve | | `apps/api/Dockerfile` production stage | `apps/api/src/lib/bootGuards.ts` (via baked env) | `ENV NODE_ENV=production` engages NODE_ENV check in guard | WIRED | Line 45 in production stage only | | `scripts/check-audit.mjs` | `scripts/audit-allowlist.json` | `readFileSync` + filter by `github_advisory_id` | WIRED | `allowlistPath = resolve(__dirname, 'audit-allowlist.json')` at line 103 | | `scripts/check-outdated.mjs` | `scripts/outdated-pins.json` | `readFileSync` + pin-reason lookup | WIRED | `pinsPath = resolve(__dirname, 'outdated-pins.json')` at line 59 | | `eslint.config.js` | `eslint-plugin-security` | `import pluginSecurity` + spread `configs.recommended` | WIRED | Lines 11, 117-121 | | `.gitleaks.toml` | `apps/api/tests/fixtures/vapid.ts` | `[[allowlists]] paths` regex | WIRED | Path regex `apps/api/tests/fixtures/vapid\.ts` in first allowlist block | | `.gitea/workflows/ci.yml` security job | `scripts/check-audit.mjs` | `node scripts/check-audit.mjs` step (code-gated) | WIRED | Line 430 | | `.gitea/workflows/ci.yml` gate | security job | `needs.security.result == 'success'` individual check | WIRED | Gate needs `[fast-checks, changes, api, harness, security]`; individual check at line 452 | | `.gitea/workflows/publish.yml` boot-smoke | `apps/api/src/lib/bootGuards.ts` (via built image) | `docker run --env NODE_ENV=production --env DEV_AUTH_BYPASS=true`; assert non-zero exit + guard message | WIRED | Step 5 ("Image hygiene — boot-smoke"); greps for `DEV_AUTH_BYPASS=true is set in a production environment` | ### Behavioral Spot-Checks | Behavior | Command | Result | Status | |----------|---------|--------|--------| | boot guard exits 1 when NODE_ENV=production and DEV_AUTH_BYPASS=true | `vitest run tests/lib/bootGuards.test.ts` | 3/3 tests pass | PASS | | check-audit unit tests — blocking/waiving/expiry logic | `node --test scripts/__tests__/check-audit.test.mjs` | 9/9 pass | PASS | | pnpm lint green with eslint-plugin-security active | `pnpm lint` | exits 0 | PASS | | pnpm typecheck clean | `pnpm typecheck` | exits 0 (both apps) | PASS | | ENV NODE_ENV=production in production Dockerfile stage | `awk` + grep on Dockerfile | Found at line 45 in production stage | PASS | | .dockerignore covers all 7 forbidden patterns; does not exclude apps/api/src | grep loop | All 7 OK; apps/api/src absent | PASS | | gitleaks baseline is valid JSON | `node -e JSON.parse(...)` | `[]` — 0 findings, valid JSON | PASS | | security job parses, has correct structure | python3 yaml parse | security needs:[changes], gitleaks steps always, audit/outdated code-gated | PASS | | publish.yml step order: build → assertions → smoke → push | python3 yaml parse | Steps [3]=Build, [4]=static, [5]=smoke, [6]=Push | PASS | | check-outdated always exits 0 (no process.exit(1) on report path) | grep | Only `process.exit(0)` at line 189 | PASS | ### Probe Execution Step 7c skipped — no probe scripts declared or expected for this phase (CI workflow verification; no `probe-*.sh` files present). ### Requirements Coverage Phase 16 requirement IDs are defined in PLAN frontmatter and ROADMAP.md; they do not appear in `REQUIREMENTS.md` (which tracks only v1.1 functional requirements up to CI-01/CI-02). This is expected — REQUIREMENTS.md ends its traceability table at CI-02 and notes that CI, TEST, ADMIN, SETUP categories are tracked there. The Phase 16 operational/security requirement IDs (SEC-*, DEP-*, IMG-*, CI-03) are roadmap-internal tracking identifiers, not v1.1 product requirements. | REQ-ID | Plan | What was verified | Status | |--------|------|-------------------|--------| | IMG-01 | 16-01 | `bootGuards.ts` exported guard; `index.ts` wiring as first call in isMainModule; Dockerfile `ENV NODE_ENV=production` in production stage only | SATISFIED | | DEP-01 | 16-02 | `check-audit.mjs` blocks unwaived High+Critical; `audit-allowlist.json` with esbuild GHSA waiver pre-seeded; time-boxed expiry enforced | SATISFIED | | DEP-02 | 16-02 | `check-outdated.mjs` always exits 0; four tiers including intentional-pin; `outdated-pins.json` with 4 reasons | SATISFIED | | SEC-02 | 16-03 | `eslint-plugin-security@3.0.1` in root devDeps; folded into flat config before prettierConfig; `pnpm lint` exits 0 | SATISFIED | | SEC-01 | 16-04 | `.gitleaks.toml` with `useDefault=true` + fixture/env allowlists; `gitleaks-baseline.json` = `[]`; human checkpoint completed (baseline clean) | SATISFIED | | IMG-02 | 16-04 | `.dockerignore` covers all 7 required forbidden patterns; does NOT exclude `apps/api/src` | SATISFIED | | CI-03 | 16-05 | `security` job in ci.yml; gitleaks always-runs; audit/outdated code-gated; gate wires security via individual strict success check | SATISFIED | | IMG-03 | 16-06 | publish.yml: Build → static assertions (grep .dockerignore + `--target production`) → boot-smoke (assert non-zero exit + guard message) → Push | SATISFIED | ### Anti-Patterns Found None. Scan of all 15 phase-16-modified files found no TBD/FIXME/XXX markers, no placeholder returns, no blanket `/* eslint-disable */` headers, no hardcoded empty data structures in rendering paths. Notable good patterns observed: - `detect-object-injection` disabled globally has inline justification comment (not silent `off`) - Audit wrapper expiry check fails **closed** on unparseable date strings (malformed → not waived) - Boot-smoke matches guard output text as belt-and-suspenders (non-zero exit alone is insufficient) ### Human Verification Required Two items are structurally verified (YAML + logic) but require a live CI run to observe the end-to-end behavior. Per phase 16 RESEARCH.md and the verifier instruction notes, this is an expected/accepted constraint — the Gitea runner is not reachable locally, and a code-review + auto-fix loop was already completed. 1. **Gitleaks PR diff scan blocks a real secret** - **Test:** Open a PR that introduces a dummy secret string matching a gitleaks default rule (e.g. a fake `GITHUB_TOKEN=ghp_...` pattern in a test file not covered by allowlists) - **Expected:** The `security` job fails; the `gate` job fails; the PR is blocked from merging - **Why human:** Cannot drive the Gitea CI runner locally 2. **Boot-smoke PASS on a freshly-built production image** - **Test:** Merge a commit to `main`; observe the `publish` workflow run; verify the "Image hygiene — boot-smoke" step logs `PASS: Production image refused to start with DEV_AUTH_BYPASS=true` - **Expected:** Step passes; `Push image` runs; image is published - **Why human:** Cannot build and run the Docker image in this environment (no Docker daemon); boot-smoke requires the actual built image artifact --- ## Gaps Summary No gaps. All 8 observable truths are verified against the codebase, all 16 required artifacts exist and are substantive, all key links are wired. Behavioral spot-checks pass (9/9 unit tests, lint, typecheck, structural YAML parsing). Two human verification items remain for live CI observation, which is the expected end-state per the phase boundary (no Docker daemon, no Gitea runner locally). --- _Verified: 2026-06-13T12:56:26Z_ _Verifier: Claude (gsd-verifier)_