docs(16): re-review clean + code review fix report
This commit is contained in:
@@ -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_
|
||||||
@@ -2,246 +2,117 @@
|
|||||||
phase: 16-ci-dependency-audit-and-security-checks
|
phase: 16-ci-dependency-audit-and-security-checks
|
||||||
reviewed: 2026-06-13T00:00:00Z
|
reviewed: 2026-06-13T00:00:00Z
|
||||||
depth: standard
|
depth: standard
|
||||||
files_reviewed: 17
|
files_reviewed: 5
|
||||||
files_reviewed_list:
|
files_reviewed_list:
|
||||||
- .dockerignore
|
|
||||||
- .gitea/workflows/ci.yml
|
- .gitea/workflows/ci.yml
|
||||||
- .gitea/workflows/publish.yml
|
- .gitea/workflows/publish.yml
|
||||||
- .gitleaks.toml
|
|
||||||
- apps/api/Dockerfile
|
|
||||||
- apps/api/src/index.ts
|
|
||||||
- apps/api/src/lib/bootGuards.ts
|
|
||||||
- apps/api/tests/broker/expand.test.ts
|
|
||||||
- apps/api/tests/lib/bootGuards.test.ts
|
|
||||||
- eslint.config.js
|
|
||||||
- package.json
|
|
||||||
- scripts/__tests__/check-audit.test.mjs
|
|
||||||
- scripts/audit-allowlist.json
|
|
||||||
- scripts/check-audit.mjs
|
- scripts/check-audit.mjs
|
||||||
- scripts/check-outdated.mjs
|
- scripts/check-outdated.mjs
|
||||||
- scripts/gitleaks-baseline.json
|
- scripts/__tests__/check-audit.test.mjs
|
||||||
- scripts/outdated-pins.json
|
|
||||||
findings:
|
findings:
|
||||||
critical: 1
|
critical: 0
|
||||||
warning: 5
|
warning: 0
|
||||||
info: 4
|
info: 1
|
||||||
total: 10
|
total: 1
|
||||||
status: issues_found
|
status: clean
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 16: Code Review Report
|
# Phase 16: Code Review Report
|
||||||
|
|
||||||
**Reviewed:** 2026-06-13
|
**Reviewed:** 2026-06-13
|
||||||
**Depth:** standard
|
**Depth:** standard
|
||||||
**Files Reviewed:** 17
|
**Files Reviewed:** 5
|
||||||
**Status:** issues_found
|
**Status:** clean
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
CI / dependency-audit / security-hardening phase. The boot-guard logic
|
Iteration-2 re-review of the fixer's changes to the 5 in-scope files. Every prior
|
||||||
(`bootGuards.ts`) is correct and correctly ordered as the first statement inside
|
finding that targeted these files (CR-01, WR-01, WR-02, WR-03, WR-04, WR-05,
|
||||||
`isMainModule()` in `index.ts`. The audit wrapper (`check-audit.mjs`) parses the
|
IN-01) is genuinely resolved, and the fixes introduced no Critical/Warning
|
||||||
real `pnpm audit --json` shape correctly, keys the allowlist on
|
regression. The previously-accepted skipped INFO items (IN-02, IN-03, IN-04) are
|
||||||
`github_advisory_id` (verified against live audit output: the High advisory
|
not re-litigated.
|
||||||
`GHSA-gv7w-rqvm-qjhr` is present and is waived, so the gate currently passes),
|
|
||||||
and fails closed (exit 2) on parse errors. The publish boot-smoke `set +e` +
|
|
||||||
`pipefail` interaction was verified to propagate the container exit code through
|
|
||||||
the `| head -20` pipe correctly.
|
|
||||||
|
|
||||||
Findings concentrate in three areas: (1) the audit allowlist's `expires` field
|
Verification performed:
|
||||||
is **never enforced** — an expired security waiver silently keeps suppressing
|
- `node --test scripts/__tests__/check-audit.test.mjs` → 8 pass / 0 fail
|
||||||
High/Critical advisories forever (BLOCKER); (2) Gitea Actions context expressions
|
(includes the 3 new expired-waiver / `isWaived` truth-table assertions).
|
||||||
(notably `github.base_ref`) are interpolated directly into shell script bodies,
|
- Both workflow files parse under `yaml.safe_load`.
|
||||||
the canonical script-injection anti-pattern that `publish.yml` itself avoids for
|
- `node -c` syntax-clean on both `.mjs` scripts.
|
||||||
secrets but `ci.yml` does not for context values; (3) several robustness gaps in
|
- Guard marker string asserted by the boot-smoke
|
||||||
the boot-smoke and the outdated cross-check tier.
|
(`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
|
I was not given a `<structural_findings>` block, so there is no fallow substrate
|
||||||
section.
|
section.
|
||||||
|
|
||||||
## Critical Issues
|
### Prior-finding verification
|
||||||
|
|
||||||
### CR-01: Audit-waiver `expires` field is decorative — expired waivers never re-block
|
- **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.
|
||||||
|
|
||||||
**File:** `scripts/check-audit.mjs:32-38, 48-61` (and `scripts/audit-allowlist.json:5`)
|
### Regression check on the fixes
|
||||||
**Issue:** `selectBlocking` and `partitionAdvisories` waive an advisory using only
|
|
||||||
`!allowlist[adv.github_advisory_id]`. The `expires` date stored in the allowlist
|
|
||||||
(`"expires": "2026-09-01"`) is never read. After the expiry date the High/Critical
|
|
||||||
advisory `GHSA-gv7w-rqvm-qjhr` (esbuild) will continue to be suppressed indefinitely,
|
|
||||||
silently defeating the entire point of a time-boxed security waiver. The gate's
|
|
||||||
security guarantee degrades to "any GHSA ever added to the allowlist is permanently
|
|
||||||
ignored." The unit tests (`check-audit.test.mjs`) reinforce the gap — none of them
|
|
||||||
exercise an expired waiver.
|
|
||||||
**Fix:** Treat an expired waiver as absent. Evaluate expiry inside the predicate:
|
|
||||||
```js
|
|
||||||
function isWaived(adv, allowlist) {
|
|
||||||
const w = allowlist[adv.github_advisory_id];
|
|
||||||
if (!w) return false;
|
|
||||||
// No expiry or future expiry → waived; past expiry → NOT waived (re-blocks).
|
|
||||||
if (w.expires && Date.parse(w.expires) <= Date.now()) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectBlocking(advisories, allowlist) {
|
- `ci.yml` env-binding: with `set -u`, an empty `PR_BASE_REF` + empty `base.sha`
|
||||||
return Object.values(advisories).filter(
|
makes `git rev-parse "origin/"` fail and the step fails closed — acceptable.
|
||||||
(adv) => BLOCKING_SEVERITIES.has(adv.severity) && !isWaived(adv, allowlist),
|
- `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.
|
||||||
Apply the same `isWaived` check in `partitionAdvisories`, and add a unit test for
|
- `check-audit.mjs` `isWaived`: logic-traced for missing / past / equal / future /
|
||||||
an expired-waiver fixture asserting it is blocking.
|
absent-expires cases — all correct. One latent gap noted as IN-01 below.
|
||||||
|
- `check-outdated.mjs` relabel: classification logic unchanged; only strings moved.
|
||||||
## Warnings
|
|
||||||
|
|
||||||
### WR-01: `github.base_ref` interpolated into a shell command (script-injection vector)
|
|
||||||
|
|
||||||
**File:** `.gitea/workflows/ci.yml:369` (also `base.sha`/`head.sha` at lines 363-366)
|
|
||||||
**Issue:** `BASE_SHA=$(git merge-base "$(git rev-parse origin/${{ github.base_ref }})" HEAD)`
|
|
||||||
splices the attacker-influenceable PR target branch name directly into the rendered
|
|
||||||
shell body. This is the exact script-injection anti-pattern `publish.yml:64-75`
|
|
||||||
deliberately avoids for `REGISTRY_PAT` ("Bind the secret through env: so it is never
|
|
||||||
substituted into the rendered script body"). `base.sha`/`head.sha` are Git-validated
|
|
||||||
SHAs (low risk), but `base_ref` is a branch name and Gitea permits a broad charset.
|
|
||||||
A target branch name containing shell metacharacters would execute in the runner.
|
|
||||||
**Fix:** Bind context values through `env:` and reference them as already-quoted
|
|
||||||
shell variables, never inline `${{ ... }}` in `run:`:
|
|
||||||
```yaml
|
|
||||||
- name: Probe PR base/head SHA
|
|
||||||
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
|
|
||||||
BASE_SHA="$PR_BASE_SHA"
|
|
||||||
HEAD_SHA="$PR_HEAD_SHA"
|
|
||||||
if [ -z "$BASE_SHA" ]; then
|
|
||||||
BASE_SHA=$(git merge-base "$(git rev-parse "origin/$PR_BASE_REF")" HEAD)
|
|
||||||
fi
|
|
||||||
{ echo "BASE_SHA=$BASE_SHA"; echo "HEAD_SHA=$HEAD_SHA"; } >> "$GITHUB_ENV"
|
|
||||||
```
|
|
||||||
|
|
||||||
### WR-02: Boot-smoke false-PASS if a regressed image emits ≥20 lines before binding
|
|
||||||
|
|
||||||
**File:** `.gitea/workflows/publish.yml:125-140`
|
|
||||||
**Issue:** `timeout 15 docker run ... 2>&1 | head -20` followed by `EXIT=$?`. The exit
|
|
||||||
semantics handle 0 (FAIL), 124 (FAIL) and "other non-zero" (PASS). But if the guard
|
|
||||||
regresses and the image boots **and is chatty** (web-push VAPID-unset warning,
|
|
||||||
worker startup logs, "FamilySync API running…"), it can emit 20 lines fast; `head`
|
|
||||||
then closes the pipe and SIGPIPEs `docker`, making the pipeline exit `141`
|
|
||||||
(128+SIGPIPE). `141` is neither 0 nor 124, so the broken-guard image is reported as
|
|
||||||
PASS. The smoke test's stated contract is "a container that BOOTS under the forbidden
|
|
||||||
env must FAIL" — this hole lets a chatty boot slip through.
|
|
||||||
**Fix:** Capture docker's exit code directly rather than the pipeline's, and treat
|
|
||||||
"started" as anything that did not exit before the timeout. For example, drop the
|
|
||||||
`| head` from the exit-bearing command and tee logs separately, or invert the test
|
|
||||||
to a positive assertion:
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
# 124 (timeout) or 0 (clean start) both mean the guard did NOT refuse boot.
|
|
||||||
if [ "$EXIT" -eq 0 ] || [ "$EXIT" -eq 124 ]; then
|
|
||||||
echo "FAIL: image did not refuse dev-bypass in production (exit $EXIT)"; exit 1
|
|
||||||
fi
|
|
||||||
# Belt-and-suspenders: require the FATAL marker in the output.
|
|
||||||
echo "$OUT" | grep -q "DEV_AUTH_BYPASS=true is set in a production environment" \
|
|
||||||
|| { echo "FAIL: refused boot but not via the expected guard (exit $EXIT)"; exit 1; }
|
|
||||||
```
|
|
||||||
|
|
||||||
### WR-03: `HEAD_SHA` has no fallback while `BASE_SHA` does — asymmetric defense
|
|
||||||
|
|
||||||
**File:** `.gitea/workflows/ci.yml:365-373`
|
|
||||||
**Issue:** The step adds a `merge-base` fallback when `base.sha` is empty (Gitea version
|
|
||||||
parity), but applies no equivalent guard when `head.sha` is empty. If `head.sha` is
|
|
||||||
empty on a given Gitea version, the gitleaks range `--no-merges ${BASE_SHA}..` is
|
|
||||||
emitted (line 390). `A..` happens to default to `A..HEAD` in git, so the scan still
|
|
||||||
runs — but only by luck of git's range parsing, not by design, and the failure is
|
|
||||||
silent (no log of which range was scanned). Given this job is the secret-scan gate,
|
|
||||||
a silently-wrong range is a real risk.
|
|
||||||
**Fix:** Mirror the base fallback: `if [ -z "$HEAD_SHA" ]; then HEAD_SHA=$(git rev-parse HEAD); fi`,
|
|
||||||
and echo the final `${BASE_SHA}..${HEAD_SHA}` range before invoking gitleaks.
|
|
||||||
|
|
||||||
### WR-04: AUDIT-ADVISORY tier in the outdated report is effectively dead code
|
|
||||||
|
|
||||||
**File:** `scripts/check-outdated.mjs:62-76, 105, 116-117`
|
|
||||||
**Issue:** `vulnerableModules` is built from `pnpm audit` `module_name` values (which
|
|
||||||
are mostly **transitive** packages — verified live: the only advisories are on
|
|
||||||
`esbuild`, a transitive dep). `outdatedData` keys come from `pnpm outdated`, which
|
|
||||||
lists only **direct/top-level** dependencies (verified live: `hono`, `@types/react`,
|
|
||||||
`eslint`, …). The two sets almost never intersect, so the `hasAdvisory` branch — the
|
|
||||||
report's highest-priority tier — will essentially never fire. The report claims to
|
|
||||||
surface "packages with active advisories on the pinned version" but cannot, because
|
|
||||||
the advisory subject (esbuild) never appears in the outdated list. This is advisory-
|
|
||||||
only (never gates), hence WARNING not BLOCKER, but the tier is misleading.
|
|
||||||
**Fix:** Either cross-check against the full installed dependency tree (e.g. walk
|
|
||||||
`pnpm list -r --json` and match transitive advisory `module_name`s), or relabel/remove
|
|
||||||
the tier so the report does not imply a check it does not perform.
|
|
||||||
|
|
||||||
### WR-05: Static `.dockerignore` assertions use unquoted-regex `grep` (false-positive prone)
|
|
||||||
|
|
||||||
**File:** `.gitea/workflows/publish.yml:100-105`
|
|
||||||
**Issue:** `grep -q "$pattern"` treats each pattern as a regex, so `.env` matches
|
|
||||||
`denv`, `.git` matches `xgit`, `.planning` matches `Xplanning`, etc. More importantly
|
|
||||||
the assertion only checks that the *substring* appears **anywhere** in `.dockerignore`,
|
|
||||||
not that it is an effective ignore rule. A commented-out line (`# .env was here`) would
|
|
||||||
satisfy the check while ignoring nothing — the hygiene gate would pass on a regressed
|
|
||||||
ignore file. The gate's purpose is to prevent secrets/tests leaking into the image;
|
|
||||||
a string-presence check is weaker than that promise.
|
|
||||||
**Fix:** Use fixed-string, anchored matching and ignore comment lines:
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Info
|
## Info
|
||||||
|
|
||||||
### IN-01: `check-audit.mjs` `isMain` uses raw string compare vs `index.ts`'s `realpathSync`
|
### IN-01: Unparseable `expires` in the audit allowlist waives indefinitely
|
||||||
|
|
||||||
**File:** `scripts/check-audit.mjs:64-65`
|
**File:** `scripts/check-audit.mjs:41`
|
||||||
**Issue:** `process.argv[1] === __filename` is a plain string comparison.
|
**Issue:** `isWaived` guards expiry with `if (w.expires && Date.parse(w.expires) <= Date.now())`.
|
||||||
`index.ts:101-109` deliberately uses `realpathSync(process.argv[1])` for symlink/relative
|
If `expires` is a non-empty but unparseable string (e.g. `"soon"`, `"2026-13-40"`),
|
||||||
robustness and documents why (WR-05 in that file). Invoking the script via a symlink or
|
`Date.parse` returns `NaN`, `NaN <= Date.now()` is `false`, and the entry waives the
|
||||||
a non-canonical path (`./scripts/check-audit.mjs`) would silently skip the main body and
|
advisory indefinitely — the same failure mode CR-01 fixed, reachable via a typo in
|
||||||
exit 0 — a security gate that no-ops without error. CI invokes `node scripts/check-audit.mjs`
|
committed allowlist data. Low severity: the allowlist is reviewed, committed,
|
||||||
from repo root, which works today, so this is latent.
|
non-attacker data, and CR-01's primary case (a real past date) is handled. Flagged
|
||||||
**Fix:** Mirror the `index.ts` pattern: compare `realpathSync(process.argv[1])` to
|
for completeness only; not a regression introduced by the fix.
|
||||||
`__filename`, or use `import.meta.url === pathToFileURL(process.argv[1]).href` with realpath.
|
**Fix:** Treat an unparseable `expires` as expired (fail-closed):
|
||||||
|
```js
|
||||||
### IN-02: `pnpm audit --json` is run twice per CI security job
|
if (w.expires) {
|
||||||
|
const t = Date.parse(w.expires);
|
||||||
**File:** `scripts/check-audit.mjs:84` and `scripts/check-outdated.mjs:65`
|
if (Number.isNaN(t) || t <= Date.now()) return false;
|
||||||
**Issue:** Both scripts independently spawn `pnpm audit --json`. The security job runs
|
}
|
||||||
them back-to-back, doubling the audit work. Out of v1 performance scope and harmless,
|
return true;
|
||||||
but a shared cache or a single audit pass piped to both would be cleaner.
|
```
|
||||||
**Fix:** Optional — have `check-outdated.mjs` accept the audit JSON via stdin/arg, or
|
|
||||||
merge the two into one script with two report sections.
|
|
||||||
|
|
||||||
### IN-03: `outdated-pins.json` reasons are not cross-checked against the audit allowlist
|
|
||||||
|
|
||||||
**File:** `scripts/outdated-pins.json` / `scripts/audit-allowlist.json`
|
|
||||||
**Issue:** Two independent suppression lists (pin reasons keyed by package name; audit
|
|
||||||
waivers keyed by GHSA). Nothing keeps them consistent, and neither references the other.
|
|
||||||
A pinned package (e.g. `eslint`) that later acquires a High advisory would be waived in
|
|
||||||
one place and pinned in another with no linkage. Documentation-level coupling only.
|
|
||||||
**Fix:** Optional — add a note in each file referencing the other, or a lint step that
|
|
||||||
flags a pinned package carrying an unwaived blocking advisory.
|
|
||||||
|
|
||||||
### IN-04: `expand.test.ts` is in scope but unrelated to this CI/security phase
|
|
||||||
|
|
||||||
**File:** `apps/api/tests/broker/expand.test.ts`
|
|
||||||
**Issue:** This is a substantive, well-constructed test (DST wall-clock, EXDATE, DURATION,
|
|
||||||
COUNT, Temporal round-trip). No defects found. It appears in the review set only because
|
|
||||||
it was touched/moved; it is orthogonal to the CI/dependency/security changes. Noted for
|
|
||||||
completeness — no action required.
|
|
||||||
**Fix:** None.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user