10 Commits
Author SHA1 Message Date
Lucas Berger c72e013a7b docs(phase-16): complete phase execution
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Failing after 57s
CI / api (pull_request) Successful in 58s
CI / harness (pull_request) Successful in 3m59s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Failing after 1s
2026-06-13 09:00:12 -04:00
Lucas Berger e0ec4a40a5 docs(16): re-review clean + code review fix report 2026-06-13 08:52:12 -04:00
Lucas Berger bb1e97556d fix(16): fail closed on unparseable audit-waiver expiry date 2026-06-13 08:51:53 -04:00
Lucas Berger 3e609b2550 fix(16): WR-04 relabel/clarify outdated advisory tier (no false promise)
The AUDIT-ADVISORY tier cross-checks pnpm outdated (direct deps) against
advisory module_names (mostly transitive), so it almost never fires.
Relabel to OUTDATED-WITH-ADVISORY and document the limitation in the
header, header text, and inline comment; point to check-audit.mjs as the
authoritative advisory gate. No risky full-tree rewrite.
2026-06-13 08:43:54 -04:00
Lucas Berger 3daa351d70 fix(16): WR-02/WR-05 robust boot-smoke and active .dockerignore checks
WR-02: capture docker run exit directly (not the piped head exit) so a
chatty booting image can't SIGPIPE to 141 and false-PASS; require the
FATAL guard marker in output as a positive assertion.
WR-05: strip comment lines and use anchored fixed-string (grep -qF)
matching so a commented-out rule can't satisfy the hygiene check and
patterns aren't treated as regexes.
2026-06-13 08:43:16 -04:00
Lucas Berger 26a6b2e53f fix(16): WR-01/WR-03 bind PR context via env; symmetric HEAD_SHA fallback
Bind github.base_ref/base.sha/head.sha through env: and reference quoted
shell vars (no inline ${{ }} in run:) to close the script-injection vector.
Add a HEAD_SHA rev-parse fallback mirroring BASE_SHA and echo the final
${BASE_SHA}..${HEAD_SHA} range before gitleaks.
2026-06-13 08:42:40 -04:00
Lucas Berger 4bb205fe0f fix(16): CR-01 enforce audit-waiver expiry; IN-01 realpath isMain
Add isWaived() predicate: a waiver with a past 'expires' date is treated
as absent so the High/Critical advisory re-blocks. Applied in both
selectBlocking and partitionAdvisories. Add expired-waiver unit tests.
isMain now compares fully-resolved real paths (mirrors index.ts).
2026-06-13 08:42:08 -04:00
Lucas Berger 5dd84a2861 docs(16): add code review report 2026-06-13 08:35:28 -04:00
Lucas Berger 9a108a3618 docs(16-06): complete image hygiene CI assertions plan 2026-06-13 08:28:37 -04:00
Lucas Berger 27046dbf92 ci(16-06): add static image-hygiene assertion + boot-smoke before push
- Static assertions step: verifies .dockerignore exists, covers all forbidden
  patterns (.env, node_modules, apps/api/scripts, .git, .planning, apps/api/tests,
  apps/pwa/e2e), and --target production is still pinned in publish.yml (T-16-19)
- Boot-smoke step: runs freshly-built sha_tag image with NODE_ENV=production +
  DEV_AUTH_BYPASS=true; fails if image starts (EXIT==0) or times out (EXIT==124),
  passes on any other non-zero exit — proves D-08 guard fires in shipped image (T-16-18)
- Both steps ordered after build and before push; a hygiene failure blocks publish (T-16-20)
2026-06-13 08:27:13 -04:00
11 changed files with 737 additions and 37 deletions
+21 -5
View File
@@ -358,17 +358,33 @@ jobs:
# github.event.pull_request.base.sha may be empty on some Gitea versions.
# If so, fall back to git merge-base to compute the real branch-point SHA.
- name: Probe PR base/head SHA
# WR-01: bind context values through env: so they are never substituted
# into the rendered shell body (script-injection vector — github.base_ref
# is an attacker-influenceable branch name). Reference them as already-
# quoted shell variables only.
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
echo "Event base.sha: ${{ github.event.pull_request.base.sha }}"
echo "Event head.sha: ${{ github.event.pull_request.head.sha }}"
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
echo "Event base.sha: $PR_BASE_SHA"
echo "Event head.sha: $PR_HEAD_SHA"
BASE_SHA="$PR_BASE_SHA"
HEAD_SHA="$PR_HEAD_SHA"
if [ -z "$BASE_SHA" ]; then
echo "base.sha empty — computing merge-base fallback"
BASE_SHA=$(git merge-base "$(git rev-parse origin/${{ github.base_ref }})" HEAD)
BASE_SHA=$(git merge-base "$(git rev-parse "origin/$PR_BASE_REF")" HEAD)
echo "Computed BASE_SHA via merge-base: $BASE_SHA"
fi
# WR-03: mirror the base fallback for head so the scan range is never
# silently left half-empty (A.. only happens to default to A..HEAD).
if [ -z "$HEAD_SHA" ]; then
echo "head.sha empty — falling back to git rev-parse HEAD"
HEAD_SHA=$(git rev-parse HEAD)
echo "Computed HEAD_SHA via rev-parse: $HEAD_SHA"
fi
echo "Secret-scan range: ${BASE_SHA}..${HEAD_SHA}"
echo "BASE_SHA=$BASE_SHA" >> "$GITHUB_ENV"
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
+65
View File
@@ -85,6 +85,71 @@ jobs:
-t ${{ steps.tags.outputs.sha_tag }} \
.
# ── D-10 image hygiene assertions — run AFTER build, BEFORE push ───────────
# A failure here stops the job before any push, so a regressed image can
# never be published (T-16-18 / T-16-19 / T-16-20).
- name: Image hygiene — static assertions
run: |
set -euo pipefail
# Assert .dockerignore exists
if [ ! -f ".dockerignore" ]; then
echo "FAIL: .dockerignore does not exist"
exit 1
fi
# Assert every forbidden pattern is an ACTIVE ignore rule (WR-05).
# Strip comment lines first, then fixed-string match so a commented-out
# "# .env was here" can't satisfy the check and "$pattern" is never
# treated as a regex (e.g. ".env" matching "denv").
for pattern in ".env" "node_modules" "apps/api/scripts" ".git" \
".planning" "apps/api/tests" "apps/pwa/e2e"; do
if ! grep -v '^[[:space:]]*#' .dockerignore | grep -qF "$pattern"; then
echo "FAIL: .dockerignore missing active rule: $pattern"
exit 1
fi
done
# Assert this workflow still pins --target production (D-10 / T-16-19)
if ! grep -q "\-\-target production" .gitea/workflows/publish.yml; then
echo "FAIL: publish.yml does not build --target production"
exit 1
fi
echo "Static image hygiene assertions PASSED."
# Boot-smoke: run the freshly-built production image with the forbidden
# NODE_ENV=production + DEV_AUTH_BYPASS=true combo and assert it refuses to
# boot — proving the D-08 guard (assertNotDevBypassInProduction) fires in
# the ACTUAL shipped image (T-16-18 / T-16-21).
# EXIT==0 → image started → guard NOT working → FAIL
# EXIT==124 → timeout (15s) → guard not firing → FAIL
# Any other non-zero exit → image refused boot → PASS
- name: Image hygiene — boot-smoke (must refuse dev-bypass in production)
run: |
set -euo pipefail
IMAGE="${{ steps.tags.outputs.sha_tag }}"
# WR-02: capture docker's exit code DIRECTLY, not a pipeline exit. Piping
# through `head -20` would let a chatty-but-booting regressed image emit
# 20 lines, SIGPIPE docker (exit 141), and false-PASS. Capture all output
# to a variable, then print a bounded slice for the log.
set +e
OUT=$(timeout 15 docker run --rm \
--env NODE_ENV=production \
--env DEV_AUTH_BYPASS=true \
"$IMAGE" 2>&1)
EXIT=$?
set -e
echo "$OUT" | head -20
# 0 (clean start) and 124 (timeout) both mean the guard did NOT refuse boot.
if [ "$EXIT" -eq 0 ] || [ "$EXIT" -eq 124 ]; then
echo "FAIL: Production image did not refuse DEV_AUTH_BYPASS=true (exit $EXIT)"
exit 1
fi
# Belt-and-suspenders: require the FATAL guard marker, so a refusal for
# some UNRELATED reason cannot masquerade as the guard working.
if ! echo "$OUT" | grep -q "DEV_AUTH_BYPASS=true is set in a production environment"; then
echo "FAIL: image refused boot (exit $EXIT) but NOT via the expected D-08 guard"
exit 1
fi
echo "PASS: Production image refused to start with DEV_AUTH_BYPASS=true (exit $EXIT)"
# Push the IMMUTABLE :<milestone>-<sha> tag FIRST. set -euo pipefail stops on
# the first failed push, so :latest is only moved after the immutable,
# rollback-traceable tag has landed — a failed second push can never leave
+4 -4
View File
@@ -34,7 +34,7 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
- [x] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12)
- [x] **Phase 14: Desktop E2E Coverage** - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile (completed 2026-06-12)
- [x] **Phase 15: Doc-Only CI Skip + Markdown Lint** - Aggregate-gate the slow api/harness CI jobs so doc-only PRs to main merge without running them (no branch-protection deadlock), and add markdownlint to `fast-checks` so docs get a fast format+lint gate (promoted from backlog 999.17) (completed 2026-06-12)
- [ ] **Phase 16: CI Dependency Audit, Security Checks & Image Hygiene** - 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 (absorbs backlog 999.17); independent of the admin chain
- [x] **Phase 16: CI Dependency Audit, Security Checks & Image Hygiene** - 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 (absorbs backlog 999.17); independent of the admin chain (completed 2026-06-13)
## Phase Details
@@ -336,7 +336,7 @@ Plans:
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 16-05-PLAN.md — Add the security job to ci.yml (gitleaks always; audit/outdated code-gated) + gate wiring (CI-03)
- [ ] 16-06-PLAN.md — publish.yml static image-hygiene assertion + boot-smoke before push (IMG-03)
- [x] 16-06-PLAN.md — publish.yml static image-hygiene assertion + boot-smoke before push (IMG-03)
**UI hint**: no
@@ -359,7 +359,7 @@ Plans:
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 5/6 | In Progress| |
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
## Backlog
@@ -367,7 +367,7 @@ Plans:
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
**Requirements:** TBD
**Plans:** 5/6 plans executed
**Plans:** 6/6 plans complete
Plans:
+13 -12
View File
@@ -2,16 +2,16 @@
gsd_state_version: 1.0
milestone: v1.1
milestone_name: Operability & Polish
status: executing
status: verifying
stopped_at: Completed 16-05-PLAN.md
last_updated: "2026-06-13T12:24:49.646Z"
last_activity: 2026-06-13 -- Phase 16 execution started
last_updated: "2026-06-13T12:59:54.942Z"
last_activity: 2026-06-13
progress:
total_phases: 19
completed_phases: 6
completed_phases: 7
total_plans: 23
completed_plans: 22
percent: 32
completed_plans: 23
percent: 37
---
# Project State
@@ -25,10 +25,10 @@ See: .planning/PROJECT.md (updated 2026-06-10)
## Current Position
Phase: 16 (ci-dependency-audit-and-security-checks) — EXECUTING
Plan: 6 of 6
Status: Ready to execute
Last activity: 2026-06-13 -- Phase 16 execution started
Phase: 999.1
Plan: Not started
Status: Phase complete — ready for verification
Last activity: 2026-06-13
### Deferred Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
@@ -47,7 +47,7 @@ Resume: after the operator completes the change, re-run `/gsd-execute-phase 15`
**Velocity:**
- Total plans completed: 33
- Total plans completed: 39
- Average duration: -
- Total execution time: 0 hours
@@ -62,6 +62,7 @@ Resume: after the operator completes the change, re-run `/gsd-execute-phase 15`
| 14 | 1 | - | - |
| 15 | 3 | - | - |
| 09 | 2 | - | - |
| 16 | 6 | - | - |
**Recent Trend:**
@@ -241,7 +242,7 @@ Recent decisions affecting current work:
## Session Continuity
Last session: 2026-06-13T12:24:49.635Z
Last session: 2026-06-13T12:28:17.736Z
Stopped at: Completed 16-05-PLAN.md
Resume file: None
@@ -0,0 +1,105 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: "06"
subsystem: ci
tags: [ci, docker, image-hygiene, boot-smoke, security, publish]
dependency_graph:
requires: ["16-01", "16-04"]
provides: ["IMG-03"]
affects: [".gitea/workflows/publish.yml"]
tech_stack:
added: []
patterns:
- "Static assertion step: grep-based structural checks in shell before docker push"
- "Boot-smoke: docker run with forbidden env combo + timeout + exit-code semantics"
key_files:
created: []
modified:
- .gitea/workflows/publish.yml
decisions:
- "D-10 (16-06): Static assertions grep for 7 forbidden .dockerignore patterns + --target production pin; grep is substring-safe (apps/api/scripts matches apps/api/scripts/seed-credential.mjs)"
- "D-10 (16-06): Boot-smoke treats EXIT==0 and EXIT==124 as failures; any other non-zero is a PASS — covers the D-08 guard's process.exit(1) path"
- "D-10 (16-06): Both hygiene steps ordered strictly after build and before push; set -euo pipefail ensures any failure stops the job before push runs"
metrics:
duration: "1 minute"
completed: "2026-06-13"
tasks_completed: 2
files_modified: 1
requirements: [IMG-03]
---
# Phase 16 Plan 06: Image Hygiene CI Assertions Summary
**One-liner:** Publish-time CI assertions that block docker push when .dockerignore is incomplete, --target production is dropped, or the production image tolerates DEV_AUTH_BYPASS=true (D-10 / IMG-03).
## What Was Built
Two CI assertion steps added to `.gitea/workflows/publish.yml`, inserted strictly between the `Build production image` step and the `Push image` step:
**Step 1 — "Image hygiene — static assertions"** (`set -euo pipefail`):
- Fails if `.dockerignore` is absent
- Loops over 7 forbidden patterns (`.env`, `node_modules`, `apps/api/scripts`, `.git`, `.planning`, `apps/api/tests`, `apps/pwa/e2e`) and fails if any is missing from `.dockerignore`
- Fails if `--target production` is no longer grep-able in `publish.yml` itself
- Catches config drift that would ship secrets or dev files (T-16-19)
**Step 2 — "Image hygiene — boot-smoke (must refuse dev-bypass in production)"** (`set -euo pipefail`):
- Runs the freshly-built `sha_tag` image with `NODE_ENV=production DEV_AUTH_BYPASS=true`
- `timeout 15` caps the run (T-16-21: prevents the job hanging if the guard does not fire)
- EXIT==0 → image started → guard NOT working → FAIL
- EXIT==124 → timeout → guard not firing → FAIL
- Any other non-zero exit → image refused boot → PASS
- Proves `assertNotDevBypassInProduction()` (16-01 / D-08) fires in the actual shipped image (T-16-18)
The step order in the publish job is now:
1. Checkout
2. Compute image tags
3. Docker login
4. Build production image
5. Image hygiene — static assertions ← new
6. Image hygiene — boot-smoke ← new
7. Push image
8. Docker logout (if: always())
## Task Commits
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Split publish build and push into separate steps | bc00f3e | .gitea/workflows/publish.yml |
| 2 | Add static image-hygiene assertion + boot-smoke before push | 27046db | .gitea/workflows/publish.yml |
## Deviations from Plan
None — plan executed exactly as written.
## Verification
- YAML parses cleanly (python3 yaml.safe_load confirmed)
- Step ordering verified: assertions at indices 4-5, push at index 6 (between Build at 3 and Push at 6)
- All 7 forbidden .dockerignore patterns confirmed present in .dockerignore
- `--target production` grep confirmed to match within publish.yml
- Dockerfile `ENV NODE_ENV=production` confirmed in production stage (line 45) — precondition for the smoke to be meaningful
- `bootGuards.ts` `process.exit(1)` confirmed fires when `NODE_ENV=production AND DEV_AUTH_BYPASS=true` — that is what the smoke exercises
## Threat Coverage
| Threat ID | Description | Mitigated By |
|-----------|-------------|--------------|
| T-16-18 | Regressed image that tolerates DEV_AUTH_BYPASS=true published | Boot-smoke step: fails push if image starts under forbidden combo |
| T-16-19 | Future change removes .dockerignore or drops --target production | Static assertions step: fails push on any missing pattern or pin |
| T-16-20 | Assertions run after push (bad image already published) | Ordering: both steps are before Push image step |
| T-16-21 | Boot-smoke hangs if guard does not fire | `timeout 15`; EXIT==124 treated as failure |
## Known Stubs
None.
## Self-Check
Files modified:
- `.gitea/workflows/publish.yml` — modified (confirmed by git log)
Commits:
- `bc00f3e` — ci(16-06): split publish build and push into separate steps
- `27046db` — ci(16-06): add static image-hygiene assertion + boot-smoke before push
## Self-Check: PASSED
@@ -0,0 +1,160 @@
---
phase: 16-ci-dependency-audit-and-security-checks
fixed_at: 2026-06-13T00:00:00Z
review_path: .planning/phases/16-ci-dependency-audit-and-security-checks/16-REVIEW.md
iteration: 1
findings_in_scope: 9
fixed: 6
skipped: 3
status: partial
---
# Phase 16: Code Review Fix Report
**Fixed at:** 2026-06-13
**Source review:** .planning/phases/16-ci-dependency-audit-and-security-checks/16-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 9 (CR-01; WR-01..WR-05; IN-01..IN-04 — IN-04 carries no fix)
- Fixed: 6 (CR-01, WR-01, WR-02, WR-03, WR-04, WR-05, plus IN-01)
- Skipped: 3 (IN-02, IN-03, IN-04 — all optional/no-action)
IN-01 was applied inside the CR-01 commit (same file, `scripts/check-audit.mjs`),
so it does not get a standalone commit line below but is counted as fixed.
## Validation
All validation run inside the isolated review-fix worktree:
- `pnpm lint` — PASS (apps/api + apps/pwa, `--max-warnings 0`). Note: the project
lint scope does not include `scripts/*.mjs`, so the edited `.mjs` scripts are
syntax-checked via `node -c` instead (all pass).
- `pnpm typecheck` — PASS (apps/api + apps/pwa `tsc --noEmit`, PWA e2e tsconfig).
- `node --test scripts/__tests__/check-audit.test.mjs` — 8 pass / 0 fail
(includes 3 new expired-waiver / isWaived assertions for CR-01).
- `python3 -c 'yaml.safe_load(...)'` for `.gitea/workflows/ci.yml` and
`.gitea/workflows/publish.yml` — both parse OK after edits.
- Working tree clean after all commits (no stray/uncommitted changes).
The API DB-integration tests (`tests/routes/lists.test.ts`,
`tests/lib/listAccess.test.ts`, etc.) fail with `ER_ACCESS_DENIED` in this
environment because no dev MariaDB is reachable. That is pre-existing/environmental
and unrelated to these fixes; no new test failures were introduced.
## Fixed Issues
### CR-01: Audit-waiver `expires` field is decorative — expired waivers never re-block
**Files modified:** `scripts/check-audit.mjs`, `scripts/__tests__/check-audit.test.mjs`
**Commit:** 4bb205f
**Applied fix:** Added an exported `isWaived(adv, allowlist)` predicate. An allowlist
entry with no `expires`, or a future `expires`, waives the advisory; an entry whose
`expires` is in the past (`<= Date.now()`) is treated as absent so the High/Critical
advisory re-blocks. Applied `isWaived` in BOTH `selectBlocking` and
`partitionAdvisories` (replacing the bare `!allowlist[...]` checks). Added three
unit tests: expired waiver re-blocks via `selectBlocking`, expired waiver re-blocks
via `partitionAdvisories`, and a direct `isWaived` truth-table test (future expiry →
waived, past expiry → not waived, no-expiry → waived, missing → not waived). All 8
tests pass.
### WR-01: `github.base_ref` interpolated into a shell command (script-injection vector)
**Files modified:** `.gitea/workflows/ci.yml`
**Commit:** 26a6b2e
**Applied fix:** Bound `github.event.pull_request.base.sha`, `head.sha`, and
`github.base_ref` through an `env:` block (`PR_BASE_SHA`, `PR_HEAD_SHA`,
`PR_BASE_REF`) on the "Probe PR base/head SHA" step. The `run:` body now references
only the already-quoted shell variables — no `${{ ... }}` context interpolation
inside the script. The merge-base fallback uses `git rev-parse "origin/$PR_BASE_REF"`.
### WR-03: `HEAD_SHA` has no fallback while `BASE_SHA` does — asymmetric defense
**Files modified:** `.gitea/workflows/ci.yml`
**Commit:** 26a6b2e (committed together with WR-01 — same step in the same file)
**Applied fix:** Added a symmetric head fallback (`if [ -z "$HEAD_SHA" ]; then
HEAD_SHA=$(git rev-parse HEAD); fi`) and an `echo "Secret-scan range:
${BASE_SHA}..${HEAD_SHA}"` line before the gitleaks invocation so the scanned range
is logged rather than relying on git's `A..``A..HEAD` default.
### WR-02: Boot-smoke false-PASS if a regressed image emits ≥20 lines before binding
**Files modified:** `.gitea/workflows/publish.yml`
**Commit:** 3daa351
**Applied fix:** Captured `docker run` output into `OUT=$(...)` and read `EXIT=$?`
from the docker command directly (no `| head -20` in the exit-bearing command), so a
chatty-but-booting image can no longer SIGPIPE docker to exit 141 and false-PASS.
`head -20` is now applied only to the printed `echo "$OUT"`. Treat both `0` and `124`
as FAIL ("did not refuse boot"). Added a positive belt-and-suspenders assertion:
the output must contain `DEV_AUTH_BYPASS=true is set in a production environment`
(the exact D-08 guard marker from `bootGuards.ts`), so a refusal for an unrelated
reason cannot masquerade as the guard working.
> Logic note: this change alters the PASS/FAIL decision logic of a security smoke
> test. The shell logic was reviewed against the guard marker string, but the actual
> container behavior under the forbidden env is not exercisable in this environment
> (no docker daemon / built image). Recommend a human confirm the smoke step on a
> real publish run.
### WR-05: Static `.dockerignore` assertions use unquoted-regex `grep` (false-positive prone)
**Files modified:** `.gitea/workflows/publish.yml`
**Commit:** 3daa351 (committed together with WR-02 — same file)
**Applied fix:** Switched the per-pattern assertion to comment-stripped, fixed-string
matching: `grep -v '^[[:space:]]*#' .dockerignore | grep -qF "$pattern"`. Patterns
are no longer treated as regexes (`.env` can't match `denv`) and a commented-out
rule (`# .env was here`) no longer satisfies the check. Failure message updated to
"missing active rule".
### WR-04: AUDIT-ADVISORY tier in the outdated report is effectively dead code
**Files modified:** `scripts/check-outdated.mjs`
**Commit:** 3e609b2
**Applied fix:** Took the SAFE relabel option (no risky full-tree rewrite). Renamed
the tier from `AUDIT-ADVISORY` to `OUTDATED-WITH-ADVISORY` and documented, in the
file header, the inline classification comment, and the printed header text, that it
only matches outdated *direct* deps against advisory `module_name`s (most advisories
are on transitive deps, so it rarely fires) and that the authoritative advisory gate
is `check-audit.mjs`. The report still always exits 0 (advisory-only). The
audit-parse-failure warning message was updated to the new tier name.
## Skipped Issues
### IN-02: `pnpm audit --json` is run twice per CI security job
**File:** `scripts/check-audit.mjs:84`, `scripts/check-outdated.mjs:65`
**Reason:** Skipped — explicitly optional and out of v1 performance scope per REVIEW.md
("Out of v1 performance scope and harmless"). The suggested fix (pipe one audit pass
to both scripts via stdin/arg, or merge the two scripts) is a structural change to
script interfaces and CI invocation with no correctness benefit; applying it here
would be speculative scope creep.
**Original issue:** Both scripts independently spawn `pnpm audit --json`, doubling
the audit work in the security job.
### IN-03: `outdated-pins.json` reasons are not cross-checked against the audit allowlist
**File:** `scripts/outdated-pins.json` / `scripts/audit-allowlist.json`
**Reason:** Skipped — explicitly optional ("Documentation-level coupling only").
Both JSON files are flat maps that the consuming scripts iterate directly:
`check-outdated.mjs` reads `pins[pkgName]` as a pin reason, and `check-audit.mjs`
reads `allowlist[github_advisory_id]`. Injecting a meta `__note`/cross-reference key
risks the consumers misreading it as real data (a `__note` pin would be treated as a
pin reason if a package were ever named `__note`). The safer choice is to leave the
data files as pure data rather than add inert-but-fragile meta keys. The suggested
lint-step variant is net-new tooling, out of scope for a review fix.
**Original issue:** Two independent suppression lists with no linkage between a
pinned package and an audit waiver for the same package.
### IN-04: `expand.test.ts` is in scope but unrelated to this CI/security phase
**File:** `apps/api/tests/broker/expand.test.ts`
**Reason:** Skipped — no action required. REVIEW.md states "**Fix:** None." The
reviewer found no defects; the file appears in the review set only because it was
touched/moved and is orthogonal to this phase.
**Original issue:** Well-constructed test noted for completeness; no defect.
---
_Fixed: 2026-06-13_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,121 @@
---
phase: 16-ci-dependency-audit-and-security-checks
reviewed: 2026-06-13T00:00:00Z
depth: standard
files_reviewed: 5
files_reviewed_list:
- .gitea/workflows/ci.yml
- .gitea/workflows/publish.yml
- scripts/check-audit.mjs
- scripts/check-outdated.mjs
- scripts/__tests__/check-audit.test.mjs
findings:
critical: 0
warning: 0
info: 1
total: 1
status: clean
---
# Phase 16: Code Review Report
**Reviewed:** 2026-06-13
**Depth:** standard
**Files Reviewed:** 5
**Status:** clean
## Summary
Iteration-2 re-review of the fixer's changes to the 5 in-scope files. Every prior
finding that targeted these files (CR-01, WR-01, WR-02, WR-03, WR-04, WR-05,
IN-01) is genuinely resolved, and the fixes introduced no Critical/Warning
regression. The previously-accepted skipped INFO items (IN-02, IN-03, IN-04) are
not re-litigated.
Verification performed:
- `node --test scripts/__tests__/check-audit.test.mjs` → 8 pass / 0 fail
(includes the 3 new expired-waiver / `isWaived` truth-table assertions).
- Both workflow files parse under `yaml.safe_load`.
- `node -c` syntax-clean on both `.mjs` scripts.
- Guard marker string asserted by the boot-smoke
(`DEV_AUTH_BYPASS=true is set in a production environment`) confirmed to match
the literal in `apps/api/src/lib/bootGuards.ts:29`.
I was not given a `<structural_findings>` block, so there is no fallow substrate
section.
### Prior-finding verification
- **CR-01 (resolved).** `isWaived(adv, allowlist)` (`scripts/check-audit.mjs:37-43`)
returns false for a missing entry and for a past/equal `expires`
(`Date.parse(w.expires) <= Date.now()`), true for future/no `expires`. Wired into
BOTH `selectBlocking` (line 55) and `partitionAdvisories` (line 72). Tests
exercise expired-waiver re-block via both functions plus a direct `isWaived`
truth-table. The time-boxed waiver is now actually time-boxed.
- **IN-01 (resolved).** `isMainModule()` (`scripts/check-audit.mjs:86-93`) now
compares `realpathSync(process.argv[1])` to the resolved `__filename`, mirroring
`index.ts`. A symlinked/non-canonical entrypoint no longer silently skips the gate.
- **WR-01 (resolved).** `github.event.pull_request.base.sha`, `head.sha`, and
`github.base_ref` are bound through `env:` (`ci.yml:365-368`) and referenced only
as already-quoted shell variables (`$PR_BASE_SHA`, `$PR_HEAD_SHA`, `$PR_BASE_REF`).
No `${{ ... }}` context value is interpolated into the rendered `run:` body. The
merge-base fallback uses `git rev-parse "origin/$PR_BASE_REF"` (quoted). The
script-injection vector is closed.
- **WR-03 (resolved).** Symmetric head fallback added (`ci.yml:382-386`):
`if [ -z "$HEAD_SHA" ]; then HEAD_SHA=$(git rev-parse HEAD); fi`, plus an explicit
`echo "Secret-scan range: ${BASE_SHA}..${HEAD_SHA}"` (line 387). The gitleaks step
consumes both via `$GITHUB_ENV` under `set -u`, so a missing range fails loudly
rather than scanning a silently-wrong range.
- **WR-02 (resolved).** Boot-smoke now captures `OUT=$(timeout 15 docker run ...)`
and `EXIT=$?` directly (`publish.yml:132-137`), with `head -20` applied only to the
display `echo` (line 139). A chatty-but-booting regressed image can no longer
SIGPIPE docker to exit 141 and false-PASS. 0/124 → FAIL, and a positive
belt-and-suspenders grep requires the exact D-08 guard marker (line 147).
- **WR-04 (resolved, relabel option).** The tier is relabeled `OUTDATED-WITH-ADVISORY`
with an honest sub-line and header doc (`check-outdated.mjs:4-11, 144-145`)
stating it only cross-checks outdated *direct* deps against advisory
`module_name`s, rarely fires, and that `check-audit.mjs` is the authoritative gate.
The misleading implied check is gone.
- **WR-05 (resolved).** Per-pattern `.dockerignore` assertion now strips comment
lines and fixed-string matches (`publish.yml:103-109`):
`grep -v '^[[:space:]]*#' .dockerignore | grep -qF "$pattern"`. `.env` no longer
regex-matches `denv`, and a commented-out rule no longer satisfies the check.
### Regression check on the fixes
- `ci.yml` env-binding: with `set -u`, an empty `PR_BASE_REF` + empty `base.sha`
makes `git rev-parse "origin/"` fail and the step fails closed — acceptable.
- `publish.yml` boot-smoke: the only residual edge is that under `pipefail` a very
large `OUT` could SIGPIPE the display `echo "$OUT" | head -20` and fail the step.
That direction is fail-safe (blocks a good image, never false-PASSes a bad one)
and strictly more conservative than the original bug — not a defect.
- `check-audit.mjs` `isWaived`: logic-traced for missing / past / equal / future /
absent-expires cases — all correct. One latent gap noted as IN-01 below.
- `check-outdated.mjs` relabel: classification logic unchanged; only strings moved.
## Info
### IN-01: Unparseable `expires` in the audit allowlist waives indefinitely
**File:** `scripts/check-audit.mjs:41`
**Issue:** `isWaived` guards expiry with `if (w.expires && Date.parse(w.expires) <= Date.now())`.
If `expires` is a non-empty but unparseable string (e.g. `"soon"`, `"2026-13-40"`),
`Date.parse` returns `NaN`, `NaN <= Date.now()` is `false`, and the entry waives the
advisory indefinitely — the same failure mode CR-01 fixed, reachable via a typo in
committed allowlist data. Low severity: the allowlist is reviewed, committed,
non-attacker data, and CR-01's primary case (a real past date) is handled. Flagged
for completeness only; not a regression introduced by the fix.
**Fix:** Treat an unparseable `expires` as expired (fail-closed):
```js
if (w.expires) {
const t = Date.parse(w.expires);
if (Number.isNaN(t) || t <= Date.now()) return false;
}
return true;
```
---
_Reviewed: 2026-06-13_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
@@ -0,0 +1,134 @@
---
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)_
+56 -1
View File
@@ -10,7 +10,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { selectBlocking, partitionAdvisories } from '../check-audit.mjs';
import { selectBlocking, partitionAdvisories, isWaived } from '../check-audit.mjs';
// Fixture: a High advisory not in the allowlist
const highUnwaived = {
@@ -59,6 +59,33 @@ const allowlist = {
const emptyAllowlist = {};
// Fixture: allowlist whose esbuild waiver has already expired (CR-01).
const expiredAllowlist = {
'GHSA-gv7w-rqvm-qjhr': {
reason: 'esbuild dev transitive — not in production runtime',
reviewer: 'luc',
expires: '2000-01-01',
},
};
// Fixture: allowlist with no expiry field (waives indefinitely).
const noExpiryAllowlist = {
'GHSA-gv7w-rqvm-qjhr': {
reason: 'esbuild dev transitive — not in production runtime',
reviewer: 'luc',
},
};
// Fixture: allowlist whose expiry date is malformed (typo). Must fail CLOSED —
// a bad date can never grant an indefinite waiver.
const malformedExpiryAllowlist = {
'GHSA-gv7w-rqvm-qjhr': {
reason: 'esbuild dev transitive — not in production runtime',
reviewer: 'luc',
expires: '2026-13-99',
},
};
test('unwaived High advisory is blocking', () => {
const blocking = selectBlocking(highUnwaived, emptyAllowlist);
assert.equal(blocking.length, 1);
@@ -86,3 +113,31 @@ test('partitionAdvisories splits blocking and advisory correctly', () => {
assert.equal(blocking.length, 1);
assert.equal(advisory.length, 2);
});
test('expired waiver is treated as absent — High advisory re-blocks (selectBlocking)', () => {
const blocking = selectBlocking(highWaived, expiredAllowlist);
assert.equal(blocking.length, 1);
assert.equal(blocking[0].github_advisory_id, 'GHSA-gv7w-rqvm-qjhr');
});
test('expired waiver is treated as absent — High advisory re-blocks (partitionAdvisories)', () => {
const { blocking, advisory } = partitionAdvisories(highWaived, expiredAllowlist);
assert.equal(blocking.length, 1);
assert.equal(advisory.length, 0);
assert.equal(blocking[0].github_advisory_id, 'GHSA-gv7w-rqvm-qjhr');
});
test('isWaived: future expiry waives, past expiry does not, missing entry does not', () => {
const adv = { severity: 'high', github_advisory_id: 'GHSA-gv7w-rqvm-qjhr' };
assert.equal(isWaived(adv, allowlist), true); // future expiry (2026-09-01)
assert.equal(isWaived(adv, expiredAllowlist), false); // past expiry
assert.equal(isWaived(adv, noExpiryAllowlist), true); // no expiry → indefinite waive
assert.equal(isWaived(adv, emptyAllowlist), false); // not listed
assert.equal(isWaived(adv, malformedExpiryAllowlist), false); // unparseable expiry → fail closed
});
test('malformed expiry fails closed — High advisory re-blocks', () => {
const blocking = selectBlocking(highWaived, malformedExpiryAllowlist);
assert.equal(blocking.length, 1);
assert.equal(blocking[0].github_advisory_id, 'GHSA-gv7w-rqvm-qjhr');
});
+44 -10
View File
@@ -15,34 +15,58 @@
*/
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve, dirname } from 'node:path';
const BLOCKING_SEVERITIES = new Set(['high', 'critical']);
/**
* Decides whether an advisory is currently waived by the allowlist.
*
* A waiver entry suppresses the advisory ONLY while it is in force: an entry
* with no `expires` field, or with an `expires` date strictly in the future,
* waives the advisory. An entry whose `expires` date is in the past (≤ now) is
* treated as absent — the advisory re-blocks. This makes the time-boxed waiver
* actually time-boxed (CR-01).
*
* @param {{severity: string, github_advisory_id: string}} adv
* @param {Record<string, {reason: string, reviewer: string, expires?: string}>} allowlist
* @returns {boolean}
*/
export function isWaived(adv, allowlist) {
const w = allowlist[adv.github_advisory_id];
if (!w) return false;
// No expiry → waived; future expiry → waived; past/equal expiry → NOT waived.
// An unparseable `expires` (typo) fails CLOSED: treated as expired so a malformed
// date can never grant an indefinite waiver (same failure class as CR-01).
if (w.expires) {
const ts = Date.parse(w.expires);
if (Number.isNaN(ts) || ts <= Date.now()) return false;
}
return true;
}
/**
* From an advisories map (keyed by numeric id), returns the subset that
* are High or Critical AND whose github_advisory_id is NOT present in allowlist.
* are High or Critical AND whose github_advisory_id is NOT currently waived.
*
* @param {Record<string, {severity: string, github_advisory_id: string, module_name: string, title: string}>} advisories
* @param {Record<string, {reason: string, reviewer: string, expires: string}>} allowlist
* @param {Record<string, {reason: string, reviewer: string, expires?: string}>} allowlist
* @returns {Array<{severity: string, github_advisory_id: string, module_name: string, title: string}>}
*/
export function selectBlocking(advisories, allowlist) {
return Object.values(advisories).filter(
(adv) =>
BLOCKING_SEVERITIES.has(adv.severity) &&
!allowlist[adv.github_advisory_id],
(adv) => BLOCKING_SEVERITIES.has(adv.severity) && !isWaived(adv, allowlist),
);
}
/**
* Partitions all advisories into blocking (unwaived High/Critical) and
* advisory-only (moderate/low, or waived High/Critical).
* advisory-only (moderate/low, or currently-waived High/Critical).
*
* @param {Record<string, {severity: string, github_advisory_id: string, module_name: string, title: string}>} advisories
* @param {Record<string, {reason: string, reviewer: string, expires: string}>} allowlist
* @param {Record<string, {reason: string, reviewer: string, expires?: string}>} allowlist
* @returns {{ blocking: Array, advisory: Array }}
*/
export function partitionAdvisories(advisories, allowlist) {
@@ -50,7 +74,7 @@ export function partitionAdvisories(advisories, allowlist) {
const advisory = [];
for (const adv of Object.values(advisories)) {
if (BLOCKING_SEVERITIES.has(adv.severity) && !allowlist[adv.github_advisory_id]) {
if (BLOCKING_SEVERITIES.has(adv.severity) && !isWaived(adv, allowlist)) {
blocking.push(adv);
} else {
advisory.push(adv);
@@ -61,8 +85,18 @@ export function partitionAdvisories(advisories, allowlist) {
}
// Main body — only runs when invoked directly (not when imported as a module).
// IN-01: compare fully-resolved real paths (mirrors index.ts isMainModule) so a
// symlinked or non-canonical entrypoint does not silently skip the gate.
const __filename = fileURLToPath(import.meta.url);
const isMain = process.argv[1] === __filename;
function isMainModule() {
if (!process.argv[1]) return false;
try {
return __filename === realpathSync(process.argv[1]);
} catch {
return false;
}
}
const isMain = isMainModule();
if (isMain) {
const __dirname = dirname(__filename);
+14 -5
View File
@@ -2,7 +2,13 @@
* check-outdated.mjs — pnpm outdated advisory-only tiered report (D-06 / OQ-01).
*
* Classifies all outdated packages into four tiers in priority order:
* 1. AUDIT-ADVISORY — the package's current version carries a known advisory
* 1. OUTDATED-WITH-ADVISORY — an outdated DIRECT dep whose name also appears as
* an advisory subject. NOTE (WR-04): `pnpm outdated` lists only direct/top-
* level deps, while most advisories are on TRANSITIVE deps (e.g. esbuild), so
* the two sets rarely intersect and this tier usually reports "(none)". It is
* a best-effort flag for the case where a *direct* dependency you control is
* both outdated and carries an advisory — NOT a full advisory cross-check of
* the dependency tree. The authoritative advisory gate is check-audit.mjs.
* 2. MAJOR-BEHIND-INTENTIONAL — latest major > current major, pin reason exists in outdated-pins.json
* 3. MAJOR-BEHIND-UNPINNED — latest major > current major, no pin reason (potential liability)
* 4. ROUTINE-DRIFT — same major, minor/patch behind (low priority)
@@ -72,7 +78,7 @@ try {
}
} catch {
// Audit parse failure is non-fatal for the outdated report
console.warn('[check-outdated] Warning: could not parse pnpm audit output; AUDIT-ADVISORY cross-check skipped');
console.warn('[check-outdated] Warning: could not parse pnpm audit output; OUTDATED-WITH-ADVISORY cross-check skipped');
}
// ── Run pnpm outdated ────────────────────────────────────────────────────────
@@ -112,7 +118,9 @@ for (const [pkgName, info] of Object.entries(outdatedData)) {
dependentPackages: info.dependentPackages,
};
// Priority 1: the package has an active advisory on the pinned version
// Priority 1: this outdated DIRECT dep also appears as an advisory subject.
// Rarely fires — most advisories are on transitive deps (see WR-04 note in the
// file header); the authoritative advisory gate is check-audit.mjs.
if (hasAdvisory) {
tiers.auditAdvisory.push(entry);
// Priority 2: major behind + intentional pin
@@ -132,8 +140,9 @@ console.log('');
console.log('=== DEPENDENCY HEALTH REPORT ===');
console.log('');
// Tier 1: AUDIT-ADVISORY
console.log('[AUDIT-ADVISORY] Packages with active advisories on the pinned version:');
// Tier 1: OUTDATED-WITH-ADVISORY (direct deps only — see WR-04 note in header)
console.log('[OUTDATED-WITH-ADVISORY] Outdated direct deps that also appear as an advisory subject');
console.log(' (best-effort; most advisories are on transitive deps — authoritative gate is check-audit.mjs):');
if (tiers.auditAdvisory.length === 0) {
console.log(' (none)');
} else {