Compare commits
10
Commits
bc00f3e815
...
c72e013a7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c72e013a7b | ||
|
|
e0ec4a40a5 | ||
|
|
bb1e97556d | ||
|
|
3e609b2550 | ||
|
|
3daa351d70 | ||
|
|
26a6b2e53f | ||
|
|
4bb205fe0f | ||
|
|
5dd84a2861 | ||
|
|
9a108a3618 | ||
|
|
27046dbf92 |
+21
-5
@@ -358,17 +358,33 @@ jobs:
|
|||||||
# github.event.pull_request.base.sha may be empty on some Gitea versions.
|
# 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.
|
# If so, fall back to git merge-base to compute the real branch-point SHA.
|
||||||
- name: Probe PR base/head 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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "Event base.sha: ${{ github.event.pull_request.base.sha }}"
|
echo "Event base.sha: $PR_BASE_SHA"
|
||||||
echo "Event head.sha: ${{ github.event.pull_request.head.sha }}"
|
echo "Event head.sha: $PR_HEAD_SHA"
|
||||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
BASE_SHA="$PR_BASE_SHA"
|
||||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
HEAD_SHA="$PR_HEAD_SHA"
|
||||||
if [ -z "$BASE_SHA" ]; then
|
if [ -z "$BASE_SHA" ]; then
|
||||||
echo "base.sha empty — computing merge-base fallback"
|
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"
|
echo "Computed BASE_SHA via merge-base: $BASE_SHA"
|
||||||
fi
|
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 "BASE_SHA=$BASE_SHA" >> "$GITHUB_ENV"
|
||||||
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
|
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,71 @@ jobs:
|
|||||||
-t ${{ steps.tags.outputs.sha_tag }} \
|
-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
|
# Push the IMMUTABLE :<milestone>-<sha> tag FIRST. set -euo pipefail stops on
|
||||||
# the first failed push, so :latest is only moved after the immutable,
|
# the first failed push, so :latest is only moved after the immutable,
|
||||||
# rollback-traceable tag has landed — a failed second push can never leave
|
# rollback-traceable tag has landed — a failed second push can never leave
|
||||||
|
|||||||
@@ -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 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 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)
|
- [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
|
## Phase Details
|
||||||
|
|
||||||
@@ -336,7 +336,7 @@ Plans:
|
|||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
**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)
|
- [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
|
**UI hint**: no
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ Plans:
|
|||||||
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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.
|
**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
|
**Requirements:** TBD
|
||||||
**Plans:** 5/6 plans executed
|
**Plans:** 6/6 plans complete
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -2,16 +2,16 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.1
|
milestone: v1.1
|
||||||
milestone_name: Operability & Polish
|
milestone_name: Operability & Polish
|
||||||
status: executing
|
status: verifying
|
||||||
stopped_at: Completed 16-05-PLAN.md
|
stopped_at: Completed 16-05-PLAN.md
|
||||||
last_updated: "2026-06-13T12:24:49.646Z"
|
last_updated: "2026-06-13T12:59:54.942Z"
|
||||||
last_activity: 2026-06-13 -- Phase 16 execution started
|
last_activity: 2026-06-13
|
||||||
progress:
|
progress:
|
||||||
total_phases: 19
|
total_phases: 19
|
||||||
completed_phases: 6
|
completed_phases: 7
|
||||||
total_plans: 23
|
total_plans: 23
|
||||||
completed_plans: 22
|
completed_plans: 23
|
||||||
percent: 32
|
percent: 37
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -25,10 +25,10 @@ See: .planning/PROJECT.md (updated 2026-06-10)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 16 (ci-dependency-audit-and-security-checks) — EXECUTING
|
Phase: 999.1
|
||||||
Plan: 6 of 6
|
Plan: Not started
|
||||||
Status: Ready to execute
|
Status: Phase complete — ready for verification
|
||||||
Last activity: 2026-06-13 -- Phase 16 execution started
|
Last activity: 2026-06-13
|
||||||
|
|
||||||
### Deferred Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
### 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:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 33
|
- Total plans completed: 39
|
||||||
- Average duration: -
|
- Average duration: -
|
||||||
- Total execution time: 0 hours
|
- Total execution time: 0 hours
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ Resume: after the operator completes the change, re-run `/gsd-execute-phase 15`
|
|||||||
| 14 | 1 | - | - |
|
| 14 | 1 | - | - |
|
||||||
| 15 | 3 | - | - |
|
| 15 | 3 | - | - |
|
||||||
| 09 | 2 | - | - |
|
| 09 | 2 | - | - |
|
||||||
|
| 16 | 6 | - | - |
|
||||||
|
|
||||||
**Recent Trend:**
|
**Recent Trend:**
|
||||||
|
|
||||||
@@ -241,7 +242,7 @@ Recent decisions affecting current work:
|
|||||||
|
|
||||||
## Session Continuity
|
## 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
|
Stopped at: Completed 16-05-PLAN.md
|
||||||
Resume file: None
|
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)_
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
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
|
// Fixture: a High advisory not in the allowlist
|
||||||
const highUnwaived = {
|
const highUnwaived = {
|
||||||
@@ -59,6 +59,33 @@ const allowlist = {
|
|||||||
|
|
||||||
const emptyAllowlist = {};
|
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', () => {
|
test('unwaived High advisory is blocking', () => {
|
||||||
const blocking = selectBlocking(highUnwaived, emptyAllowlist);
|
const blocking = selectBlocking(highUnwaived, emptyAllowlist);
|
||||||
assert.equal(blocking.length, 1);
|
assert.equal(blocking.length, 1);
|
||||||
@@ -86,3 +113,31 @@ test('partitionAdvisories splits blocking and advisory correctly', () => {
|
|||||||
assert.equal(blocking.length, 1);
|
assert.equal(blocking.length, 1);
|
||||||
assert.equal(advisory.length, 2);
|
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
@@ -15,34 +15,58 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync, realpathSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { resolve, dirname } from 'node:path';
|
import { resolve, dirname } from 'node:path';
|
||||||
|
|
||||||
const BLOCKING_SEVERITIES = new Set(['high', 'critical']);
|
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
|
* 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, {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}>}
|
* @returns {Array<{severity: string, github_advisory_id: string, module_name: string, title: string}>}
|
||||||
*/
|
*/
|
||||||
export function selectBlocking(advisories, allowlist) {
|
export function selectBlocking(advisories, allowlist) {
|
||||||
return Object.values(advisories).filter(
|
return Object.values(advisories).filter(
|
||||||
(adv) =>
|
(adv) => BLOCKING_SEVERITIES.has(adv.severity) && !isWaived(adv, allowlist),
|
||||||
BLOCKING_SEVERITIES.has(adv.severity) &&
|
|
||||||
!allowlist[adv.github_advisory_id],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partitions all advisories into blocking (unwaived High/Critical) and
|
* 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, {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 }}
|
* @returns {{ blocking: Array, advisory: Array }}
|
||||||
*/
|
*/
|
||||||
export function partitionAdvisories(advisories, allowlist) {
|
export function partitionAdvisories(advisories, allowlist) {
|
||||||
@@ -50,7 +74,7 @@ export function partitionAdvisories(advisories, allowlist) {
|
|||||||
const advisory = [];
|
const advisory = [];
|
||||||
|
|
||||||
for (const adv of Object.values(advisories)) {
|
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);
|
blocking.push(adv);
|
||||||
} else {
|
} else {
|
||||||
advisory.push(adv);
|
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).
|
// 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 __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) {
|
if (isMain) {
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
* check-outdated.mjs — pnpm outdated advisory-only tiered report (D-06 / OQ-01).
|
* check-outdated.mjs — pnpm outdated advisory-only tiered report (D-06 / OQ-01).
|
||||||
*
|
*
|
||||||
* Classifies all outdated packages into four tiers in priority order:
|
* 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
|
* 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)
|
* 3. MAJOR-BEHIND-UNPINNED — latest major > current major, no pin reason (potential liability)
|
||||||
* 4. ROUTINE-DRIFT — same major, minor/patch behind (low priority)
|
* 4. ROUTINE-DRIFT — same major, minor/patch behind (low priority)
|
||||||
@@ -72,7 +78,7 @@ try {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Audit parse failure is non-fatal for the outdated report
|
// 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 ────────────────────────────────────────────────────────
|
// ── Run pnpm outdated ────────────────────────────────────────────────────────
|
||||||
@@ -112,7 +118,9 @@ for (const [pkgName, info] of Object.entries(outdatedData)) {
|
|||||||
dependentPackages: info.dependentPackages,
|
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) {
|
if (hasAdvisory) {
|
||||||
tiers.auditAdvisory.push(entry);
|
tiers.auditAdvisory.push(entry);
|
||||||
// Priority 2: major behind + intentional pin
|
// Priority 2: major behind + intentional pin
|
||||||
@@ -132,8 +140,9 @@ console.log('');
|
|||||||
console.log('=== DEPENDENCY HEALTH REPORT ===');
|
console.log('=== DEPENDENCY HEALTH REPORT ===');
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Tier 1: AUDIT-ADVISORY
|
// Tier 1: OUTDATED-WITH-ADVISORY (direct deps only — see WR-04 note in header)
|
||||||
console.log('[AUDIT-ADVISORY] Packages with active advisories on the pinned version:');
|
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) {
|
if (tiers.auditAdvisory.length === 0) {
|
||||||
console.log(' (none)');
|
console.log(' (none)');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user