---
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

Both items were CONFIRMED on 2026-06-13 against the live Gitea Actions logs for PR #15 (merge commit `06238a9`). The Gitea runner could not be reached during planning; it was verified after merge.

1. **Gitleaks PR diff scan + gate wiring** — ✅ **CONFIRMED** (CI run #51, `security` job 148)
   - Log evidence: `gitleaks git --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json` ran → `40 commits scanned` → `no leaks found`. `check-audit.mjs` → `Audit PASS — no unwaived High/Critical advisories` (esbuild GHSA waived); `check-outdated.mjs` advisory-only (`OUTDATED-WITH-ADVISORY` / `ROUTINE-DRIFT`), did not gate.
   - Blocking chain: gitleaks exits non-zero on a finding → `security` fails → `gate` checks `needs.security.result == success`. The gate-fails-on-a-red-job behavior is independently demonstrated by run #49, where `fast-checks: failure` produced `gate: failure`. Not separately re-tested with a planted secret (the PR carried none), but the enforcement path is proven end-to-end.

2. **Boot-smoke PASS on a freshly-built production image** — ✅ **CONFIRMED** (publish run #52, `publish` job 150, post-merge)
   - Log evidence: `docker build --target production` → `Static image hygiene assertions PASSED.` → boot-smoke ran the image with `NODE_ENV=production DEV_AUTH_BYPASS=true` → image logged `[FATAL] DEV_AUTH_BYPASS=true is set in a production environment. ... Refusing to start.` → `PASS: Production image refused to start with DEV_AUTH_BYPASS=true (exit 1)` → `docker push` (`v1.1-06238a9` + `latest`, digest `sha256:aa6f845…`). The D-08 guard fired in the actual shipped image, matched via the FATAL marker (WR-02 fix, not a false-pass), and the image published only after the gates passed.

---

## 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). The two live-CI items were CONFIRMED post-merge against the Gitea Actions logs (PR #15 run #51 `security` + publish run #52 boot-smoke) — see "Human Verification Required" above. Phase fully verified.

---

_Verified: 2026-06-13T12:56:26Z_
_Verifier: Claude (gsd-verifier)_
