--- phase: 16-ci-dependency-audit-and-security-checks plan: 05 type: execute wave: 2 depends_on: ["16-02", "16-03", "16-04"] files_modified: - .gitea/workflows/ci.yml autonomous: true requirements: [CI-03, SEC-01, DEP-01, DEP-02] must_haves: truths: - "A new security job runs in parallel with fast-checks: gitleaks scans every PR (including doc-only), while pnpm audit + pnpm outdated run only when changes.outputs.code is true" - "The gitleaks PR-diff scan is blocking and uses the committed config + baseline; the base.sha availability assumption is probed before the scan relies on it, with a merge-base fallback" - "The security job is wired into the gate aggregator with an individual needs.security.result check that requires success (not success-or-skipped), since gitleaks always runs" artifacts: - path: ".gitea/workflows/ci.yml" provides: "security job (gitleaks + check-audit.mjs + check-outdated.mjs) + updated gate" contains: "security:" key_links: - from: ".gitea/workflows/ci.yml" to: "scripts/check-audit.mjs" via: "node scripts/check-audit.mjs step (code-gated)" pattern: "check-audit" - from: ".gitea/workflows/ci.yml gate" to: "security job" via: "needs.security.result == success check" pattern: "needs.security.result" --- Add a dedicated `security` job to the existing PR workflow (`.gitea/workflows/ci.yml`) — parallel to `fast-checks` — that runs gitleaks on every PR (blocking, D-12) and runs `check-audit.mjs` (blocking on unwaived High+Critical, D-04) and `check-outdated.mjs` (advisory-only, D-06) only on code/lockfile-changing PRs. Then wire `security` into the `gate` aggregator with an individual `needs.security.result` check (D-14 / D-15). This realizes the D-11 gating posture: gitleaks and pnpm audit High+Critical are blocking; pnpm outdated is advisory and never gates. Purpose: Centralizes the new PR-time security/dependency checks into one isolated, parallel job so a secret-leak or unwaived advisory is clearly attributable and does not pollute fast-checks. This is ADDITIVE — it does not restructure the existing changes/fast-checks/api/harness/gate topology. eslint-plugin-security is NOT a step here (it already runs inside the existing fast-checks `pnpm lint` via 16-03 — this plan only relies on that). Output: The modified `ci.yml`. Consumes the scripts/config from 16-02 (check-audit.mjs, check-outdated.mjs), 16-03 (eslint-plugin-security already in the lint step), and 16-04 (.gitleaks.toml, gitleaks-baseline.json). Honors all Gitea runner constraints: no actions/cache, ubuntu-latest, set -euo pipefail, REGISTRY_PAT naming (n/a here), individual needs.X.result (Gitea #31007). @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/16-ci-dependency-audit-and-security-checks/16-CONTEXT.md @.planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md @.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md @.gitea/workflows/ci.yml Task 1: Add the security job (gitleaks always; audit/outdated code-gated) with a base.sha probe - .gitea/workflows/ci.yml (existing job skeletons: fast-checks lines 33-66, api conditional pattern lines 68-72, the `changes`/paths-filter job lines 8-31 — needs.changes.outputs.code) - .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (the full `security` job skeleton; set -euo pipefail convention; no actions/cache rule; node: import convention) - .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (Secret Scanning section — gitleaks v8.30.1 install via curl|tar; `gitleaks git --log-opts="--no-merges BASE..HEAD" --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --exit-code 1`; fetch-depth:0 requirement; Pitfall 3; Assumption A2 + Open Question 1 — base.sha may be empty on Gitea, fallback `git merge-base $(git rev-parse origin/${{ github.base_ref }}) HEAD`; the security job needs:[changes], if pull_request) - scripts/check-audit.mjs and scripts/check-outdated.mjs (created in 16-02 — invoked here) - .gitleaks.toml and scripts/gitleaks-baseline.json (created in 16-04 — referenced here) In ci.yml add a new `security` job (placed after `harness`, before `gate`) with `runs-on: ubuntu-latest`, `needs: [changes]`, `if: github.event_name == 'pull_request'`. Steps in order: (1) actions/checkout@v4 with `fetch-depth: 0` (Pitfall 3 — base.sha must be local). (2) A "Probe PR base/head SHA" step (always runs) that echoes `github.event.pull_request.base.sha` and `head.sha`, computes `BASE_SHA` from the event context and, if empty, falls back to `git merge-base "$(git rev-parse origin/${{ github.base_ref }})" HEAD`, exporting BASE_SHA and HEAD_SHA to $GITHUB_ENV (Assumption A2 / OQ-1). (3) Install gitleaks: `set -euo pipefail`, pin VERSION=8.30.1, curl the linux_x64 tarball, tar -xz gitleaks, chmod +x, mv to /usr/local/bin. (4) "Secret scan (PR diff, blocking)" always-runs: `set -euo pipefail`, run `gitleaks git --log-opts="--no-merges ${BASE_SHA}..${HEAD_SHA}" --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --report-path /tmp/gitleaks-pr-report.json --exit-code 1`. (5) actions/setup-node@v4 node 22, (6) corepack enable pnpm, (7) pnpm install --frozen-lockfile, (8) `node scripts/check-audit.mjs`, (9) `node scripts/check-outdated.mjs` — steps 5-9 EACH carry `if: needs.changes.outputs.code == 'true'` (step-level, NOT job-level — D-12 so gitleaks still runs on doc-only PRs). Do NOT add actions/cache. Every multi-line run block starts with `set -euo pipefail`. Commit: `ci(16-05): add security job (gitleaks always; audit/outdated code-gated)`. command -v yq >/dev/null 2>&1 && yq '.jobs.security' .gitea/workflows/ci.yml >/dev/null || python3 -c "import yaml,sys; d=yaml.safe_load(open('.gitea/workflows/ci.yml')); j=d['jobs']['security']; assert j['needs']==['changes']; print('security job parses OK')" - ci.yml has a `security` job with `needs: [changes]`, `if: github.event_name == 'pull_request'`, and `fetch-depth: 0` checkout - A base/head SHA probe step computes BASE_SHA with a `git merge-base` fallback when the event context is empty - gitleaks install + scan steps have NO `if:` (always run, D-12); the gitleaks scan references --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --exit-code 1 - The pnpm/setup-node/check-audit/check-outdated steps each carry `if: needs.changes.outputs.code == 'true'` - No `actions/cache` in the security job; every `run: |` block starts with `set -euo pipefail` The security job runs gitleaks unconditionally and the dependency checks behind the code filter, with a base.sha probe + fallback. Task 2: Wire the security job into the gate aggregator (individual needs.security.result check) - .gitea/workflows/ci.yml (the gate job lines 345-367 — `needs: [fast-checks, changes, api, harness]`, the individual needs.X.result checks, the #31007 wildcard-bug comment) - .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (Updated gate needs list + security check; the rule that security must require SUCCESS, not "success OR skipped", because gitleaks always runs) In ci.yml update the `gate` job: add `security` to its `needs:` list (so it becomes `needs: [fast-checks, changes, api, harness, security]`). In the gate shell script, add a NEW individual check after the existing fast-checks check and BEFORE the `for result in ... api ... harness` loop: if `needs.security.result != success` then echo the result and `exit 1`. Do NOT add security to the success-OR-skipped loop (the api/harness loop) — security always runs (gitleaks is unconditional), so it must strictly require success per Gitea #31007 individual-check convention. Leave the api/harness loop unchanged. Commit: `ci(16-05): wire security job into gate aggregator`. python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/ci.yml')); g=d['jobs']['gate']; assert 'security' in g['needs'], 'security not in gate needs'; print('gate needs OK')" && grep -q "needs.security.result" .gitea/workflows/ci.yml && echo CHECK-OK - gate `needs:` includes `security` - The gate script has an individual `needs.security.result` check that exits 1 unless it equals `success` - security is NOT folded into the api/harness success-or-skipped loop - The existing fast-checks / api / harness gate logic is unchanged The gate requires the security job to succeed via an individual result check, consistent with the #31007 workaround. ## Trust Boundaries | Boundary | Description | |----------|-------------| | PR author → main branch | The PR workflow is the enforcement point before code reaches the trusted main branch | | CI runner network → external download | gitleaks binary is fetched from GitHub releases at a pinned version | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-16-15 | Information Disclosure | A secret introduced in a PR diff (including a doc/config-only PR) reaches main | mitigate | D-02/D-12: gitleaks runs unconditionally in the security job on every PR with --exit-code 1 (Task 1); gate requires security success (Task 2) | | T-16-16 | Tampering | Unwaived High/Critical dependency advisory merges to main | mitigate | D-04: check-audit.mjs runs code-gated and exits 1 on unwaived High+Critical (Task 1); gate blocks | | T-16-17 | Repudiation | gitleaks silently scans nothing (fetch-depth:1 → empty base.sha → no commits in range → exit 0) | mitigate | Pitfall 3 + A2: fetch-depth:0 + a base.sha probe with merge-base fallback (Task 1) ensures the diff range is real | | T-16-SC | Tampering | gitleaks binary download from GitHub releases could be substituted | mitigate | Version pinned to 8.30.1; download from the official gitleaks/gitleaks releases path (Task 1). Note: no checksum verification this phase — accepted residual for a pinned tag from the canonical source | - ci.yml parses as valid YAML; `jobs.security` exists with needs:[changes] - gitleaks steps have no `if:`; audit/outdated steps are code-gated - gate `needs:` includes security and the script has an individual needs.security.result==success check - No actions/cache; set -euo pipefail on every new multi-line run block - Live proof (a deliberate-secret PR failing the gate, and a doc-only PR still running gitleaks) is a phase-verification manual check, not a unit test - security job added parallel to fast-checks; gitleaks always, audit/outdated code-gated - base.sha probe + merge-base fallback present - gate requires security success via an individual result check - Fully additive — existing topology untouched; all runner constraints honored Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-05-SUMMARY.md` when done.