From 1b1bdeff88ae5039d1412c2abdf6743c233e2d88 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 10:03:45 +0200 Subject: [PATCH 1/7] ci(security): run the weekly Dependabot fix routine in GitHub Actions The claude.ai cloud routines cannot call the Dependabot alerts API (the cloud GitHub proxy substitutes its own app token on api.github.com by design). Same schedule (Thursdays 11:00 UTC), same agent instructions, now running where the SECURITY_GH_PAT secret reaches the job untouched. Co-Authored-By: Claude Fable 5 --- .github/security-fixes-prompt.md | 148 +++++++++++++++++++++++++++ .github/workflows/security-fixes.yml | 57 +++++++++++ 2 files changed, 205 insertions(+) create mode 100644 .github/security-fixes-prompt.md create mode 100644 .github/workflows/security-fixes.yml diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md new file mode 100644 index 0000000000..12e676b54a --- /dev/null +++ b/.github/security-fixes-prompt.md @@ -0,0 +1,148 @@ +Triage and fix open Dependabot vulnerability alerts in this repository, open a PR, then monitor CI and fix anything that breaks until CI is green or you hit the retry cap. Work through the phases below in order. + +**0. Preflight.** Confirm you have what you need: + +**Context & token rule:** this routine runs inside GitHub Actions on a checkout of the repository's default branch. `$GH_PAT` is provided by the workflow from the `SECURITY_GH_PAT` secret (a user fine-grained PAT) — use it for **every** `curl`/REST call to `api.github.com`. Git is already authenticated with the same PAT via the checkout step, so plain `git push` works and its pushes trigger CI. Never use the Actions-provided `$GITHUB_TOKEN` for REST calls or the label POST: events it creates do not trigger other workflows, so the Slack notification would never fire. + +- `$GH_PAT` is set and non-empty (`[ -n "$GH_PAT" ]`) with `security_events:read` (Dependabot alerts), `contents:write`, `pull_requests:write`, `issues:write`, `actions:read` + `actions:write`, and `statuses:read` on this repo (`actions:write` is needed to re-run flaky jobs; `issues:write` is required for labeling PRs and for creating the `:lock: security` label if missing). Note: fine-grained PATs cannot carry the `Checks` permission (it is GitHub App-only), so CI monitoring below uses the Actions and Commit statuses APIs — never call the Checks API (`/check-runs`), it would 403. Probe with `curl -sS -o /tmp/preflight-body.json -w "%{http_code}" -H "Authorization: Bearer $GH_PAT" "https://api.github.com/repos/$(git remote get-url origin | sed -E 's|.*github\.com[:/]([^.]+)(\.git)?$|\1|')/dependabot/alerts?per_page=1"` — expect `200`. On any non-200, `cat /tmp/preflight-body.json` — the GitHub error body says why (missing fine-grained permission vs org restriction/pending approval vs expired token). +- Package manager is detected (presence of `package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml`). + +If `$GH_PAT` is missing or the probe fails, stop and print exactly: `Preflight failed: GH_PAT must be set (from the SECURITY_GH_PAT repository/organization secret) with the required scopes (including issues:write for labeling) on . Probe returned , GitHub said: . Aborting — not falling back to npm audit (it ignores dismissals and the 7-day gate).` Do not proceed. + +**1. Fetch alerts.** Derive `$REPO` as `owner/name` from `git remote get-url origin`. Then: +``` +curl -sS \ + -H "Authorization: Bearer $GH_PAT" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$REPO/dependabot/alerts?state=open&per_page=100" +``` +Paginate by following the `rel="next"` URL in the `Link` header. For each alert capture: `number`, `security_advisory.severity`, `dependency.package.ecosystem`, `dependency.package.name`, `security_vulnerability.vulnerable_version_range`, `security_vulnerability.first_patched_version.identifier`, `created_at`, `security_advisory.summary`. + +**2. Triage each alert as FIX or IGNORE.** Mark as IGNORE only when one of the following is concretely true — record which one applies: +- The package is dev/test/tooling only and the exploit requires untrusted input at runtime. +- The vulnerable code path is unreachable from our code (verify by grepping the repo for the affected API). +- The advisory is disputed or withdrawn upstream. +- No upstream patch exists yet (`first_patched_version` is null). +- It duplicates another open alert on the same root cause (reference which). +- The vulnerable package is only pulled in by `_example/` and is not shipped to production. `_example/` intentionally pins older versions to demonstrate backward compatibility, so bumping them defeats the purpose. To qualify: the alert's `dependency.manifest_path` must be under `_example/`, AND no manifest outside `_example/` pulls in the same package (verify with `grep -r "" --include=package.json .` or a workspace-wide `npm ls ` / `yarn why `). If the package appears in any non-`_example` manifest, this reason does not apply — treat as FIX. + +Everything else is FIX. + +**3. Skip alerts opened less than 7 days ago.** These are deferred to the next run — list them in the PR description but don't touch them. + +**4. For each remaining FIX, prefer a parent bump.** Find the dependency chain with `npm ls --all` (or `yarn why` / `pnpm why`). If direct, bump to `>= first_patched_version`. If transitive, bump the nearest ancestor in `package.json` to the lowest version whose resolved tree pulls in the patched sub-dep. Verify with a fresh install + `npm ls `. + +If no reasonable parent bump closes the alert — no ancestor pulls in the patched sub-dep, or the required bump is a breaking major touching APIs we use — add a `resolutions` (Yarn) / `overrides` (npm, pnpm) entry pinning the vulnerable package to `>= first_patched_version`. Do this without asking. + +**Narrow the blast radius.** Prefer in this order: +1. **Workspace-level placement.** If only one workspace's dependency graph contains the vulnerable chain AND your package manager honors workspace-level resolutions/overrides, place the entry in that workspace's `package.json`, not the root. Verify it took effect after install with `npm ls ` from the root. +2. **Scoped root entry keyed by parent.** If workspace placement isn't honored (common for npm `overrides` and pnpm `pnpm.overrides`), use parent-scoped syntax at root so the pin only applies within the specific dependency chain: `"some-parent > vulnerable-pkg": "X"` (pnpm), `"some-parent/vulnerable-pkg": "X"` (Yarn), or `{"some-parent": {"vulnerable-pkg": "X"}}` (npm). +3. **Unconditional root entry.** Last resort only, when multiple unrelated dependency chains share the vulnerability. + +Record each resolution in the PR under "Resolutions added" with: the parent chain tried, why the bump wasn't viable, which `package.json` it was placed in, and which form (workspace / scoped / unconditional) was used. + +Always update lockfiles by running the install, never by hand. In a monorepo, apply each change in the correct workspace. + +**5. Audit existing resolutions across all `package.json` files.** After applying the bumps above, sweep every `package.json` in the repo (root, workspaces, and `_example/`) and check each entry in `resolutions`, `overrides`, and `pnpm.overrides`: +- **Stale** — the pinned package no longer appears in the resolved dependency tree. Verify with `npm ls --all` (or `yarn why` / `pnpm why`). Remove the entry. +- **Redundant** — removing the entry leaves the natural resolution at a version that still satisfies the original pin (parent packages have since been upgraded upstream to pull in the patched sub-dep on their own). Verify by removing the entry, running a fresh install, and confirming `npm ls ` still reports `>= pinned version`. If yes, commit the removal; if no, restore the entry. + +Process one entry at a time, re-running the install between each to avoid compounding changes. Record every removal in a "Resolutions removed" section of the PR with: the file it was in, the pinned package + version, and why removal is safe (stale or redundant). + +**6. Pre-push checks (cheap, local only).** Run only what's fast and doesn't need the full test dependency graph: +``` +npx prettier --check . +npm run lint +``` +Fix any prettier/lint issues the bumps introduced before pushing. **Do not run `npm test` locally** — CI is the source of truth for tests. + +**7. Open the PR.** Create the branch with this exact shell command — do **not** use any built-in branch-creation tool that auto-generates names: +``` +BRANCH="security/$(date -u +%Y-%m-%d)" +git checkout -b "$BRANCH" +``` +Before pushing, verify the branch name matches `^security/\d{4}-\d{2}-\d{2}$`: +``` +git rev-parse --abbrev-ref HEAD | grep -Eq '^security/[0-9]{4}-[0-9]{2}-[0-9]{2}$' || { echo "Branch name does not match required pattern; aborting"; exit 1; } +``` +If the check fails, stop. Do not rename after the fact by auto-generating a name elsewhere — investigate why the branch got a different name and fix it at the source. + +Then commit as `chore(security): patch Dependabot alerts`, push with `git push -u origin "$BRANCH"`, and open a PR against the default branch via `POST /repos/$REPO/pulls`. Capture `$PR_NUMBER` and the head SHA from the response. + +**Label the PR `:lock: security`.** This triggers the Slack notification workflow that pings `@first_level_support` — do not skip. Call: +``` +curl -sS -o /tmp/label-resp.json -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $GH_PAT" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/labels" \ + -d '{"labels":[":lock: security"]}' +``` +If the status is `422` (label doesn't exist in the repo yet), create it once and retry: +``` +curl -sS -X POST \ + -H "Authorization: Bearer $GH_PAT" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$REPO/labels" \ + -d '{"name":":lock: security","color":"B60205","description":"Security fix — notifies first-level support"}' +``` +Then rerun the label POST. If labeling still fails, stop and print the response body — do not silently continue, because a missing label means the Slack notification won't fire and the PR won't be picked up for review. + +The PR description must include the sections listed below. Mark the **Validation** line as `⏳ Awaiting CI` — phase 8 updates it. + +**Alert references in the body must be full Markdown links, never bare `#`.** GitHub auto-links `#` to issues/PRs in the same repo, which sends readers to the wrong page (`#214` would resolve to PR/issue 214, not Dependabot alert 214). Format every alert reference as `[#](https://github.com/$REPO/security/dependabot/)` — in the Fixed / Ignored / Deferred / Resolutions tables AND in any inline mentions ("duplicate of #X", "also closes #Y", etc.). + +**Tables that require human action must start with a checkbox column** so first-level support can tick off each alert as they handle it. Use cell content `- [ ]` (GitHub renders it as an interactive checkbox in PR bodies, even inside table cells). Apply this to: +- **Fixed** table: add a leading `Done` column (ticked once the reviewer has verified the fix landed) +- **Ignored** table: add a leading `Dismissed` column (ticked once the reviewer has dismissed the alert in the repo's Security tab) + +Deferred / Resolutions added / Resolutions removed are informational — no checkbox column. + +The very first line of the PR description must be the following blockquote so first-level support knows how to review it: + +``` +> 👋 First-level support: see [Handling automated security PRs](https://forest.slite.com/app/docs/rmjdwFgmV2RzUp) for how to triage and merge this PR. +``` + +- **Summary**: N fixed, M ignored, K deferred, R resolutions added, S resolutions removed. Append `| label: :lock: security applied` once the label POST returns 200. +- **Fixed** table: alert number, package, ecosystem, from → to, severity, what was bumped (direct dep, or "bumped `` X → Y"). +- **Ignored**: each alert with its specific reason (one of the six allowed reasons). +- **Deferred**: alert numbers skipped by the age gate. +- **Resolutions added** (if any): alert number, package + pinned range, parent chain tried, why the bump wasn't viable, which `package.json` received it, and which form (workspace / scoped / unconditional). +- **Resolutions removed** (if any): file, package + version that was pinned, reason (stale or redundant). +- **Risks**: per bump, from the upstream CHANGELOG — breaking changes touching APIs we use, peer-dep bumps affecting neighbors, tests likely to need updating. If no behavior change beyond the patched vuln, say so. +- **Manual testing**: only if automated CI doesn't cover the affected paths — give concrete reproduction steps. Otherwise write "Covered by CI." +- **Validation**: `⏳ Awaiting CI` for now. + +**8. Monitor CI and fix failures.** Every 60 seconds, fetch the workflow runs and the combined commit status for the PR head SHA (Actions + Commit statuses APIs only — the Checks API is not accessible to fine-grained PATs): +``` +curl -sS -H "Authorization: Bearer $GH_PAT" \ + "https://api.github.com/repos/$REPO/actions/runs?head_sha=$SHA" +curl -sS -H "Authorization: Bearer $GH_PAT" \ + "https://api.github.com/repos/$REPO/commits/$SHA/status" +``` +Wait until every workflow run has a non-null `conclusion` and the combined status `state` is no longer `pending`. Cap one polling cycle at 45 minutes. (Exclude this security-fixes workflow's own run from the wait condition.) + +Outcomes: +- **All green** → edit the PR description: replace `⏳ Awaiting CI` with `✅ CI green`. Stop. +- **Any failure** → for each failing workflow run, fetch the logs (`GET /repos/$REPO/actions/runs//logs`) and the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`). Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, and resume polling. +- **Still pending after 45 minutes** → comment on the PR: "CI still pending after 45 minutes; stopping automated monitoring. Please review." Stop. + +**Retry cap: 3 fix-and-push cycles.** After the third failing cycle, stop. Comment on the PR with: the failure signatures seen each cycle, what fixes were attempted, and which alerts in the diff are most likely responsible. Update Validation to `❌ CI failing — needs human review`. Do not close the PR. + +**Failures the routine must not try to fix — flag and stop instead:** +- Infrastructure failures (runner startup errors, missing CI secrets, GitHub Actions outage). Detect via runner-level error messages or zero-step runs. +- Flaky tests unrelated to the bumped packages (the failing test file doesn't import anything that changed, and the failure is timing/network-shaped). Re-run the failed check **once** via `POST /repos/$REPO/actions/runs//rerun-failed-jobs`; if it flakes again, comment and stop. +- Any fix that would require editing source code beyond minor test adjustments tied to a specific bump. In that case, revert just the offending bump (and any resolution added for it), regenerate the lockfile, re-push, and move the alert to "Could not auto-fix" in the PR description with the observed failure. + +**Constraints:** +- Only modify `package.json`, lockfiles, and test files that genuinely need to change. Don't touch source code to make a bump work — revert the bump instead. +- Never silence failures with `--no-verify`, `eslint-disable`, `.only`, `.skip`, coverage threshold changes, or by marking a workflow required-status as optional. +- Don't close, dismiss, or comment on Dependabot alerts from the API — merging the PR closes them. +- Don't force-push. Only add commits to the security branch. +- Branch name must match `^security/\d{4}-\d{2}-\d{2}$` — no exceptions, no default-named branches (`claude/*`, `dependabot/*`, etc.). Create the branch via `git checkout -b` in a shell, not via any built-in branch-creation helper that auto-names. +- The PR must carry the `:lock: security` label before phase 7 exits. A missing label means `@first_level_support` never gets pinged and the PR sits unreviewed — treat a labeling failure as hard-stop, not a warning. +- The PR creation and label calls must go through `$GH_PAT` (a user PAT), never a GitHub App installation token or the in-Actions `GITHUB_TOKEN` — GitHub suppresses workflow-triggered events (like `labeled`) from those tokens, so the Slack notification workflow would never fire. `$GH_PAT` satisfies this, same as the `REPO_TOKEN` the central security workflow uses. +- If every alert ends up IGNORED or DEFERRED (no fixes, no new resolutions) AND no stale or redundant resolutions were removed, skip the PR entirely and print the triage summary as the run output. If stale resolutions were removed, ship the PR anyway — resolution hygiene is worth shipping on its own. diff --git a/.github/workflows/security-fixes.yml b/.github/workflows/security-fixes.yml new file mode 100644 index 0000000000..00e59a5792 --- /dev/null +++ b/.github/workflows/security-fixes.yml @@ -0,0 +1,57 @@ +# Weekly automated Dependabot vulnerability fixes. +# +# Replaces the claude.ai cloud routine (the cloud GitHub proxy substitutes its own +# app token on api.github.com, which cannot read Dependabot alerts — see PR body). +# The agent instructions live in .github/security-fixes-prompt.md. +# +# Required secrets (org-level, shared by the 5 ForestAdmin repos running this): +# SECURITY_GH_PAT fine-grained PAT: Dependabot alerts RO, Contents RW, +# Pull requests RW, Issues RW, Actions RW, Commit statuses RO +# ANTHROPIC_API_KEY Claude API key +name: Security fixes + +on: + schedule: + - cron: "0 11 * * 4" # Thursdays 11:00 UTC — same slot as the former routine + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: security-fixes + cancel-in-progress: false + +jobs: + security-fixes: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.SECURITY_GH_PAT }} + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Enable corepack (yarn) + run: corepack enable + + - name: Configure git identity + run: | + git config user.name "forest-security-routine" + git config user.email "security-routines@forestadmin.com" + + - name: Run the security-fix agent + uses: anthropics/claude-code-action@v1 + env: + GH_PAT: ${{ secrets.SECURITY_GH_PAT }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.SECURITY_GH_PAT }} + prompt: "Read the file .github/security-fixes-prompt.md in this repository and execute its instructions exactly, from Preflight through the end." + claude_args: | + --model claude-opus-4-7 + --max-turns 250 + --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch" From 9f035014d1d945fd37b487f3f1a35f2a22687bdb Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 10:13:52 +0200 Subject: [PATCH 2/7] fix(ci): address Macroscope review findings on the security-fixes workflow - raise the job timeout to 240 min to cover the full CI-polling retry budget - resume the existing security/ branch and PR on same-day reruns - refresh the head SHA after each corrective push during CI monitoring - treat an empty combined-status (total_count: 0) as no status gate - provide GH_PAT at job level instead of step level Co-Authored-By: Claude Fable 5 --- .github/security-fixes-prompt.md | 17 +++++++++++++---- .github/workflows/security-fixes.yml | 6 +++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index 12e676b54a..0a3fa48366 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -60,8 +60,17 @@ Fix any prettier/lint issues the bumps introduced before pushing. **Do not run ` **7. Open the PR.** Create the branch with this exact shell command — do **not** use any built-in branch-creation tool that auto-generates names: ``` BRANCH="security/$(date -u +%Y-%m-%d)" -git checkout -b "$BRANCH" -``` +if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + # Same-day rerun: resume the existing branch instead of colliding with it. + git stash -u + git fetch origin "$BRANCH" + git checkout -B "$BRANCH" "origin/$BRANCH" + git stash pop +else + git checkout -b "$BRANCH" +fi +``` +If `git stash pop` reports conflicts, resolve them in favor of **this run's** changes (the stashed versions), then continue. When resuming, check for an already-open PR whose head is `$BRANCH` (`GET /repos/$REPO/pulls?head=:$BRANCH&state=open`): if one exists, skip PR creation, reuse its `$PR_NUMBER`, and update its description instead of creating a new one. Before pushing, verify the branch name matches `^security/\d{4}-\d{2}-\d{2}$`: ``` git rev-parse --abbrev-ref HEAD | grep -Eq '^security/[0-9]{4}-[0-9]{2}-[0-9]{2}$' || { echo "Branch name does not match required pattern; aborting"; exit 1; } @@ -123,11 +132,11 @@ curl -sS -H "Authorization: Bearer $GH_PAT" \ curl -sS -H "Authorization: Bearer $GH_PAT" \ "https://api.github.com/repos/$REPO/commits/$SHA/status" ``` -Wait until every workflow run has a non-null `conclusion` and the combined status `state` is no longer `pending`. Cap one polling cycle at 45 minutes. (Exclude this security-fixes workflow's own run from the wait condition.) +Wait until every workflow run has a non-null `conclusion` and — **only if the combined status reports at least one status context (`total_count > 0`)** — its `state` is no longer `pending`. An empty `statuses` array (`total_count: 0`) always reports `state: pending` on GitHub's side and must be treated as *no status gate*, not as pending CI. Cap one polling cycle at 45 minutes. (Exclude this security-fixes workflow's own run from the wait condition.) Outcomes: - **All green** → edit the PR description: replace `⏳ Awaiting CI` with `✅ CI green`. Stop. -- **Any failure** → for each failing workflow run, fetch the logs (`GET /repos/$REPO/actions/runs//logs`) and the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`). Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, and resume polling. +- **Any failure** → for each failing workflow run, fetch the logs (`GET /repos/$REPO/actions/runs//logs`) and the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`). Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, refresh the head SHA (`SHA=$(git rev-parse HEAD)`) so the polling calls inspect the new commit, and resume polling. - **Still pending after 45 minutes** → comment on the PR: "CI still pending after 45 minutes; stopping automated monitoring. Please review." Stop. **Retry cap: 3 fix-and-push cycles.** After the third failing cycle, stop. Comment on the PR with: the failure signatures seen each cycle, what fixes were attempted, and which alerts in the diff are most likely responsible. Update Validation to `❌ CI failing — needs human review`. Do not close the PR. diff --git a/.github/workflows/security-fixes.yml b/.github/workflows/security-fixes.yml index 00e59a5792..4aae7d4345 100644 --- a/.github/workflows/security-fixes.yml +++ b/.github/workflows/security-fixes.yml @@ -25,7 +25,9 @@ concurrency: jobs: security-fixes: runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 240 + env: + GH_PAT: ${{ secrets.SECURITY_GH_PAT }} steps: - uses: actions/checkout@v4 with: @@ -45,8 +47,6 @@ jobs: - name: Run the security-fix agent uses: anthropics/claude-code-action@v1 - env: - GH_PAT: ${{ secrets.SECURITY_GH_PAT }} with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.SECURITY_GH_PAT }} From 09f91576b064425e10e6cee789b3bbf1ef045cb1 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 10:26:58 +0200 Subject: [PATCH 3/7] fix(ci): address second round of Macroscope findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - override the action's default guardrails (--append-system-prompt) so the unattended agent actually pushes the security branch and opens the PR - same-day rerun now supersedes the dated branch with --force-with-lease (recomputed from scratch), instead of fragile stash/resume logic that could carry stale history from closed PRs - phase 8: startup guard against vacuous green (zero workflow runs), all-green now also requires combined status success when contexts exist, failing status contexts are reported explicitly - agent-ruby: Gemfile.lock is gitignored (library repo) — preflight and fix strategy reworked around gemspec/Gemfile constraints, local-only lock Co-Authored-By: Claude Fable 5 --- .github/security-fixes-prompt.md | 22 +++++++--------------- .github/workflows/security-fixes.yml | 1 + 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index 0a3fa48366..c22555cdba 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -60,17 +60,9 @@ Fix any prettier/lint issues the bumps introduced before pushing. **Do not run ` **7. Open the PR.** Create the branch with this exact shell command — do **not** use any built-in branch-creation tool that auto-generates names: ``` BRANCH="security/$(date -u +%Y-%m-%d)" -if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then - # Same-day rerun: resume the existing branch instead of colliding with it. - git stash -u - git fetch origin "$BRANCH" - git checkout -B "$BRANCH" "origin/$BRANCH" - git stash pop -else - git checkout -b "$BRANCH" -fi -``` -If `git stash pop` reports conflicts, resolve them in favor of **this run's** changes (the stashed versions), then continue. When resuming, check for an already-open PR whose head is `$BRANCH` (`GET /repos/$REPO/pulls?head=:$BRANCH&state=open`): if one exists, skip PR creation, reuse its `$PR_NUMBER`, and update its description instead of creating a new one. +git checkout -b "$BRANCH" +``` +**Same-day rerun:** if the remote branch already exists (`git ls-remote --exit-code --heads origin "$BRANCH"`), this run supersedes it — its content was recomputed from scratch off the default branch. Push with `git push --force-with-lease -u origin "$BRANCH"` instead of a plain push (the single sanctioned force-push; see Constraints). Then look for an already-**open** PR whose head is `$BRANCH` (`GET /repos/$REPO/pulls?head=:$BRANCH&state=open`): if one exists, skip PR creation, reuse its `$PR_NUMBER`, and update its description; if the only PRs on that branch are closed, create a new PR normally — the force-push has already discarded their stale history. Before pushing, verify the branch name matches `^security/\d{4}-\d{2}-\d{2}$`: ``` git rev-parse --abbrev-ref HEAD | grep -Eq '^security/[0-9]{4}-[0-9]{2}-[0-9]{2}$' || { echo "Branch name does not match required pattern; aborting"; exit 1; } @@ -132,11 +124,11 @@ curl -sS -H "Authorization: Bearer $GH_PAT" \ curl -sS -H "Authorization: Bearer $GH_PAT" \ "https://api.github.com/repos/$REPO/commits/$SHA/status" ``` -Wait until every workflow run has a non-null `conclusion` and — **only if the combined status reports at least one status context (`total_count > 0`)** — its `state` is no longer `pending`. An empty `statuses` array (`total_count: 0`) always reports `state: pending` on GitHub's side and must be treated as *no status gate*, not as pending CI. Cap one polling cycle at 45 minutes. (Exclude this security-fixes workflow's own run from the wait condition.) +Wait until every workflow run has a non-null `conclusion` and — **only if the combined status reports at least one status context (`total_count > 0`)** — its `state` is no longer `pending`. An empty `statuses` array (`total_count: 0`) always reports `state: pending` on GitHub's side and must be treated as *no status gate*, not as pending CI. **Startup guard:** do not evaluate completion until at least one workflow run other than this security-fixes workflow exists for `$SHA` — with zero runs the condition would be vacuously true. If no such run has appeared after 10 minutes, comment on the PR ("No CI run started for this commit after 10 minutes — please check branch filters.") and stop. Cap one polling cycle at 45 minutes. (Exclude this security-fixes workflow's own run from the wait condition.) Outcomes: -- **All green** → edit the PR description: replace `⏳ Awaiting CI` with `✅ CI green`. Stop. -- **Any failure** → for each failing workflow run, fetch the logs (`GET /repos/$REPO/actions/runs//logs`) and the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`). Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, refresh the head SHA (`SHA=$(git rev-parse HEAD)`) so the polling calls inspect the new commit, and resume polling. +- **All green** — every workflow run concluded `success` and, when `total_count > 0`, the combined status `state` is `success` → edit the PR description: replace `⏳ Awaiting CI` with `✅ CI green`. Stop. +- **Any failure** — a workflow run concludes non-`success`, or a status context reports `failure`/`error` → for each failing workflow run, fetch the logs (`GET /repos/$REPO/actions/runs//logs`) and the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`); a failing **status context** has no Actions logs — record its `context` name and `target_url` in the PR instead. Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, refresh the head SHA (`SHA=$(git rev-parse HEAD)`) so the polling calls inspect the new commit, and resume polling. - **Still pending after 45 minutes** → comment on the PR: "CI still pending after 45 minutes; stopping automated monitoring. Please review." Stop. **Retry cap: 3 fix-and-push cycles.** After the third failing cycle, stop. Comment on the PR with: the failure signatures seen each cycle, what fixes were attempted, and which alerts in the diff are most likely responsible. Update Validation to `❌ CI failing — needs human review`. Do not close the PR. @@ -150,7 +142,7 @@ Outcomes: - Only modify `package.json`, lockfiles, and test files that genuinely need to change. Don't touch source code to make a bump work — revert the bump instead. - Never silence failures with `--no-verify`, `eslint-disable`, `.only`, `.skip`, coverage threshold changes, or by marking a workflow required-status as optional. - Don't close, dismiss, or comment on Dependabot alerts from the API — merging the PR closes them. -- Don't force-push. Only add commits to the security branch. +- Don't force-push during CI fix cycles — only add commits to the security branch. Single exception: the same-day-rerun supersede in phase 7 uses `--force-with-lease` once, before the PR is (re)used or created. - Branch name must match `^security/\d{4}-\d{2}-\d{2}$` — no exceptions, no default-named branches (`claude/*`, `dependabot/*`, etc.). Create the branch via `git checkout -b` in a shell, not via any built-in branch-creation helper that auto-names. - The PR must carry the `:lock: security` label before phase 7 exits. A missing label means `@first_level_support` never gets pinged and the PR sits unreviewed — treat a labeling failure as hard-stop, not a warning. - The PR creation and label calls must go through `$GH_PAT` (a user PAT), never a GitHub App installation token or the in-Actions `GITHUB_TOKEN` — GitHub suppresses workflow-triggered events (like `labeled`) from those tokens, so the Slack notification workflow would never fire. `$GH_PAT` satisfies this, same as the `REPO_TOKEN` the central security workflow uses. diff --git a/.github/workflows/security-fixes.yml b/.github/workflows/security-fixes.yml index 4aae7d4345..e754678c61 100644 --- a/.github/workflows/security-fixes.yml +++ b/.github/workflows/security-fixes.yml @@ -55,3 +55,4 @@ jobs: --model claude-opus-4-7 --max-turns 250 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch" + --append-system-prompt "This is an unattended scheduled maintenance workflow. You are explicitly authorized to create and switch to the security/ branch, commit, push it to origin, and create, update and label the pull request yourself via the GitHub REST API with the token in the GH_PAT environment variable, exactly as the instructions file specifies. Never defer pushing or PR creation to a human, never stop to ask for confirmation, and do not use any built-in branch or PR helper that would override these instructions." From af927b95ddd40750bcdedaff2c65da7b57d5bc07 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 10:46:39 +0200 Subject: [PATCH 4/7] fix(ci): address third round of Macroscope findings - pin anthropics/claude-code-action to an immutable commit SHA (supply chain: the job holds ANTHROPIC_API_KEY and a repo-write PAT) - fetch the remote security branch before --force-with-lease so the lease has an expected value (fresh checkouts only fetch the default branch) - set SHA via git rev-parse HEAD on every path, including PR reuse - agent-ruby: treat all packages/*/*.gemspec as direct-dependency owners (monorepo of published gems); Gemfile pins flagged as CI-only fixes; generate the local Gemfile.lock in the workflow before the agent runs Co-Authored-By: Claude Fable 5 --- .github/security-fixes-prompt.md | 4 ++-- .github/workflows/security-fixes.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index c22555cdba..ce0d8fc4ae 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -62,14 +62,14 @@ Fix any prettier/lint issues the bumps introduced before pushing. **Do not run ` BRANCH="security/$(date -u +%Y-%m-%d)" git checkout -b "$BRANCH" ``` -**Same-day rerun:** if the remote branch already exists (`git ls-remote --exit-code --heads origin "$BRANCH"`), this run supersedes it — its content was recomputed from scratch off the default branch. Push with `git push --force-with-lease -u origin "$BRANCH"` instead of a plain push (the single sanctioned force-push; see Constraints). Then look for an already-**open** PR whose head is `$BRANCH` (`GET /repos/$REPO/pulls?head=:$BRANCH&state=open`): if one exists, skip PR creation, reuse its `$PR_NUMBER`, and update its description; if the only PRs on that branch are closed, create a new PR normally — the force-push has already discarded their stale history. +**Same-day rerun:** if the remote branch already exists (`git ls-remote --exit-code --heads origin "$BRANCH"`), this run supersedes it — its content was recomputed from scratch off the default branch. The checkout only fetched the default branch, so first give the lease something to compare against: `git fetch origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"`. Then push with `git push --force-with-lease -u origin "$BRANCH"` instead of a plain push (the single sanctioned force-push; see Constraints). Then look for an already-**open** PR whose head is `$BRANCH` (`GET /repos/$REPO/pulls?head=:$BRANCH&state=open`): if one exists, skip PR creation, reuse its `number` as `$PR_NUMBER`, and update its description; if the only PRs on that branch are closed, create a new PR normally — the force-push has already discarded their stale history. Before pushing, verify the branch name matches `^security/\d{4}-\d{2}-\d{2}$`: ``` git rev-parse --abbrev-ref HEAD | grep -Eq '^security/[0-9]{4}-[0-9]{2}-[0-9]{2}$' || { echo "Branch name does not match required pattern; aborting"; exit 1; } ``` If the check fails, stop. Do not rename after the fact by auto-generating a name elsewhere — investigate why the branch got a different name and fix it at the source. -Then commit as `chore(security): patch Dependabot alerts`, push with `git push -u origin "$BRANCH"`, and open a PR against the default branch via `POST /repos/$REPO/pulls`. Capture `$PR_NUMBER` and the head SHA from the response. +Then commit as `chore(security): patch Dependabot alerts`, push with `git push -u origin "$BRANCH"`, and open a PR against the default branch via `POST /repos/$REPO/pulls`. Capture `$PR_NUMBER` (from the creation response, or from the reused open PR on the same-day-rerun path) and set the monitoring commit explicitly on **every** path — create or reuse: `SHA=$(git rev-parse HEAD)`. After any later push, refresh it the same way. **Label the PR `:lock: security`.** This triggers the Slack notification workflow that pings `@first_level_support` — do not skip. Call: ``` diff --git a/.github/workflows/security-fixes.yml b/.github/workflows/security-fixes.yml index e754678c61..373e67db33 100644 --- a/.github/workflows/security-fixes.yml +++ b/.github/workflows/security-fixes.yml @@ -46,7 +46,7 @@ jobs: git config user.email "security-routines@forestadmin.com" - name: Run the security-fix agent - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.SECURITY_GH_PAT }} From b0427965af7275128bc83a3a48bbae807653f3a4 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 11:01:51 +0200 Subject: [PATCH 5/7] fix(ci): address fourth round of Macroscope findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - treat skipped/neutral workflow conclusions as non-failures; only failure/timed_out/cancelled/action_required trigger remediation - the 'unreachable code path' IGNORE reason now requires call-chain or configuration evidence — a negative repo grep alone is insufficient Co-Authored-By: Claude Fable 5 --- .github/security-fixes-prompt.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index ce0d8fc4ae..a329d67b24 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -21,7 +21,7 @@ Paginate by following the `rel="next"` URL in the `Link` header. For each alert **2. Triage each alert as FIX or IGNORE.** Mark as IGNORE only when one of the following is concretely true — record which one applies: - The package is dev/test/tooling only and the exploit requires untrusted input at runtime. -- The vulnerable code path is unreachable from our code (verify by grepping the repo for the affected API). +- The vulnerable code path is unreachable from our code. A negative repo grep alone is NOT sufficient evidence — a dependency can invoke the affected API internally without our code ever naming it. Require at least one of: the advisory targets an optional component/feature we demonstrably don't install or enable; the dependency chain shows the vulnerable call is only reachable through an entry point we never invoke (trace it); or the exploit requires a configuration we don't use. State the evidence in the PR; when in doubt, treat as FIX. - The advisory is disputed or withdrawn upstream. - No upstream patch exists yet (`first_patched_version` is null). - It duplicates another open alert on the same root cause (reference which). @@ -127,8 +127,8 @@ curl -sS -H "Authorization: Bearer $GH_PAT" \ Wait until every workflow run has a non-null `conclusion` and — **only if the combined status reports at least one status context (`total_count > 0`)** — its `state` is no longer `pending`. An empty `statuses` array (`total_count: 0`) always reports `state: pending` on GitHub's side and must be treated as *no status gate*, not as pending CI. **Startup guard:** do not evaluate completion until at least one workflow run other than this security-fixes workflow exists for `$SHA` — with zero runs the condition would be vacuously true. If no such run has appeared after 10 minutes, comment on the PR ("No CI run started for this commit after 10 minutes — please check branch filters.") and stop. Cap one polling cycle at 45 minutes. (Exclude this security-fixes workflow's own run from the wait condition.) Outcomes: -- **All green** — every workflow run concluded `success` and, when `total_count > 0`, the combined status `state` is `success` → edit the PR description: replace `⏳ Awaiting CI` with `✅ CI green`. Stop. -- **Any failure** — a workflow run concludes non-`success`, or a status context reports `failure`/`error` → for each failing workflow run, fetch the logs (`GET /repos/$REPO/actions/runs//logs`) and the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`); a failing **status context** has no Actions logs — record its `context` name and `target_url` in the PR instead. Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, refresh the head SHA (`SHA=$(git rev-parse HEAD)`) so the polling calls inspect the new commit, and resume polling. +- **All green** — every workflow run has concluded with `success`, `skipped`, or `neutral` (skipped/neutral are not failures), and, when `total_count > 0`, the combined status `state` is `success` → edit the PR description: replace `⏳ Awaiting CI` with `✅ CI green`. Stop. +- **Any failure** — a workflow run concludes `failure`, `timed_out`, `cancelled`, or `action_required`, or a status context reports `failure`/`error` → for each failing workflow run, fetch the logs (`GET /repos/$REPO/actions/runs//logs`) and the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`); a failing **status context** has no Actions logs — record its `context` name and `target_url` in the PR instead. Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, refresh the head SHA (`SHA=$(git rev-parse HEAD)`) so the polling calls inspect the new commit, and resume polling. - **Still pending after 45 minutes** → comment on the PR: "CI still pending after 45 minutes; stopping automated monitoring. Please review." Stop. **Retry cap: 3 fix-and-push cycles.** After the third failing cycle, stop. Comment on the PR with: the failure signatures seen each cycle, what fixes were attempted, and which alerts in the diff are most likely responsible. Update Validation to `❌ CI failing — needs human review`. Do not close the PR. From 3162e2462c401a6a8b49c38622596b5978d688ff Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 11:15:10 +0200 Subject: [PATCH 6/7] fix(ci): address fifth round of Macroscope findings - the _example/ ignore rule now requires a workspace-aware resolved-tree check (npm ls --all / yarn why); manifest grep is not sufficient - direct bumps and resolutions/overrides pins now target the smallest patched version within the parent-compatible major instead of an unbounded >= range that could resolve to a breaking major Co-Authored-By: Claude Fable 5 --- .github/security-fixes-prompt.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index a329d67b24..fbdbdfe3a7 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -25,15 +25,15 @@ Paginate by following the `rel="next"` URL in the `Link` header. For each alert - The advisory is disputed or withdrawn upstream. - No upstream patch exists yet (`first_patched_version` is null). - It duplicates another open alert on the same root cause (reference which). -- The vulnerable package is only pulled in by `_example/` and is not shipped to production. `_example/` intentionally pins older versions to demonstrate backward compatibility, so bumping them defeats the purpose. To qualify: the alert's `dependency.manifest_path` must be under `_example/`, AND no manifest outside `_example/` pulls in the same package (verify with `grep -r "" --include=package.json .` or a workspace-wide `npm ls ` / `yarn why `). If the package appears in any non-`_example` manifest, this reason does not apply — treat as FIX. +- The vulnerable package is only pulled in by `_example/` and is not shipped to production. `_example/` intentionally pins older versions to demonstrate backward compatibility, so bumping them defeats the purpose. To qualify: the alert's `dependency.manifest_path` must be under `_example/`, AND no workspace outside `_example/` resolves the same package **even transitively** — verify with a workspace-aware resolved-tree query from the repo root (`npm ls --all` / `yarn why ` / `pnpm why `); a manifest grep only sees direct dependencies and is NOT sufficient evidence. If any non-`_example` workspace resolves the package, this reason does not apply — treat as FIX. Everything else is FIX. **3. Skip alerts opened less than 7 days ago.** These are deferred to the next run — list them in the PR description but don't touch them. -**4. For each remaining FIX, prefer a parent bump.** Find the dependency chain with `npm ls --all` (or `yarn why` / `pnpm why`). If direct, bump to `>= first_patched_version`. If transitive, bump the nearest ancestor in `package.json` to the lowest version whose resolved tree pulls in the patched sub-dep. Verify with a fresh install + `npm ls `. +**4. For each remaining FIX, prefer a parent bump.** Find the dependency chain with `npm ls --all` (or `yarn why` / `pnpm why`). If direct, bump to the smallest patched version that stays within the currently used major (if the patch only exists in a later major, treat it as the breaking-major case below). If transitive, bump the nearest ancestor in `package.json` to the lowest version whose resolved tree pulls in the patched sub-dep. Verify with a fresh install + `npm ls `. -If no reasonable parent bump closes the alert — no ancestor pulls in the patched sub-dep, or the required bump is a breaking major touching APIs we use — add a `resolutions` (Yarn) / `overrides` (npm, pnpm) entry pinning the vulnerable package to `>= first_patched_version`. Do this without asking. +If no reasonable parent bump closes the alert — no ancestor pulls in the patched sub-dep, or the required bump is a breaking major touching APIs we use — add a `resolutions` (Yarn) / `overrides` (npm, pnpm) entry pinning the vulnerable package to the smallest patched version compatible with the major its parents expect — an exact version or a `^` range within that major, never an unbounded `>=`, which could silently resolve to a later breaking major. Do this without asking. **Narrow the blast radius.** Prefer in this order: 1. **Workspace-level placement.** If only one workspace's dependency graph contains the vulnerable chain AND your package manager honors workspace-level resolutions/overrides, place the entry in that workspace's `package.json`, not the root. Verify it took effect after install with `npm ls ` from the root. From 8f856ad8f0f90632afab2590adf788f02621e811 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 12:52:35 +0200 Subject: [PATCH 7/7] fix(ci): harden the security-fixes routine after a 3-lens adversarial review Reviewed by three independent adversarial passes (git/GitHub semantics, clean-runner toolchain reality, internal consistency) on top of the Macroscope findings. Notable fixes: Runner reality: - drop 'corepack enable': with packageManager yarn@1.22.19 pinned and Node 22.12's bundled corepack, every yarn call dies on the npm registry key rotation (Cannot find matching keyid); the preinstalled yarn 1.22 handles these repos fine - raise claude-code-action Bash timeouts (BASH_DEFAULT/MAX_TIMEOUT_MS): monorepo installs exceed the default 10-min hard cap - agent-ruby: all dependency resolution/verification now happens in the owning package's own Gemfile (packages/*/), not the root dev-only bundle, which proves nothing about shipped gems GitHub semantics: - document that SECURITY_GH_PAT needs Workflows RW (pushing uses: bumps) - Actions log download spelled out as the 302-then-unauthenticated-fetch dance; a naive authed -L follow is rejected by blob storage - ensure-label-first flow: POST /issues/labels silently auto-creates missing labels with a random color, the 422 branch was dead code - Yarn 1 'redundant resolution' audit now deletes the lockfile blocks before re-resolving (plain install conservatively keeps the pin) - phase 8 explicitly flags app-based check runs as unmonitored Consistency: - no-changes gate moved to the top of phase 7 (was buried in Constraints) - phase 5 keeps removals in the working tree; single commit in phase 7 - CI conclusion taxonomy is now exhaustive (skipped/neutral green; everything else including stale/startup_failure is a failure) - retry cap defined as 'at most 3 fix commits'; flaky rerun doesn't count - split abort messages for missing token vs failed probe - 'Could not auto-fix' added to the PR description spec - Yarn-1-only resolutions mechanics (root-level, scoped keys); removed npm/pnpm dead branches and the ruby 'conservative lockfile update' impossible fix category; polling batched into one Bash loop per call Co-Authored-By: Claude Fable 5 --- .github/security-fixes-prompt.md | 117 ++++++++++++++++----------- .github/workflows/security-fixes.yml | 10 ++- 2 files changed, 75 insertions(+), 52 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index fbdbdfe3a7..6909823f94 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -4,20 +4,20 @@ Triage and fix open Dependabot vulnerability alerts in this repository, open a P **Context & token rule:** this routine runs inside GitHub Actions on a checkout of the repository's default branch. `$GH_PAT` is provided by the workflow from the `SECURITY_GH_PAT` secret (a user fine-grained PAT) — use it for **every** `curl`/REST call to `api.github.com`. Git is already authenticated with the same PAT via the checkout step, so plain `git push` works and its pushes trigger CI. Never use the Actions-provided `$GITHUB_TOKEN` for REST calls or the label POST: events it creates do not trigger other workflows, so the Slack notification would never fire. -- `$GH_PAT` is set and non-empty (`[ -n "$GH_PAT" ]`) with `security_events:read` (Dependabot alerts), `contents:write`, `pull_requests:write`, `issues:write`, `actions:read` + `actions:write`, and `statuses:read` on this repo (`actions:write` is needed to re-run flaky jobs; `issues:write` is required for labeling PRs and for creating the `:lock: security` label if missing). Note: fine-grained PATs cannot carry the `Checks` permission (it is GitHub App-only), so CI monitoring below uses the Actions and Commit statuses APIs — never call the Checks API (`/check-runs`), it would 403. Probe with `curl -sS -o /tmp/preflight-body.json -w "%{http_code}" -H "Authorization: Bearer $GH_PAT" "https://api.github.com/repos/$(git remote get-url origin | sed -E 's|.*github\.com[:/]([^.]+)(\.git)?$|\1|')/dependabot/alerts?per_page=1"` — expect `200`. On any non-200, `cat /tmp/preflight-body.json` — the GitHub error body says why (missing fine-grained permission vs org restriction/pending approval vs expired token). -- Package manager is detected (presence of `package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml`). +- `$GH_PAT` is set and non-empty (`[ -n "$GH_PAT" ]`) with `security_events:read` (Dependabot alerts), `contents:write`, `workflows:write` (needed to push `uses:` bumps for `github-actions`-ecosystem alerts), `pull_requests:write`, `issues:write`, `actions:read` + `actions:write` (re-running flaky jobs), and `statuses:read` on this repo. Note: fine-grained PATs cannot carry the `Checks` permission (it is GitHub App-only), so CI monitoring below uses the Actions and Commit statuses APIs — never call the Checks API (`/check-runs`), it would 403. Probe with `curl -sS -o /tmp/preflight-body.json -w "%{http_code}" -H "Authorization: Bearer $GH_PAT" "https://api.github.com/repos/$(git remote get-url origin | sed -E 's|.*github\.com[:/]([^.]+)(\.git)?$|\1|')/dependabot/alerts?per_page=1"` — expect `200`. On any non-200, `cat /tmp/preflight-body.json` — the GitHub error body says why (missing fine-grained permission vs org restriction/pending approval vs expired token). +- Package manager is detected — this repo uses **Yarn 1** (`yarn.lock` at the root). Prefer `yarn` commands throughout (`yarn install`, `yarn why `). -If `$GH_PAT` is missing or the probe fails, stop and print exactly: `Preflight failed: GH_PAT must be set (from the SECURITY_GH_PAT repository/organization secret) with the required scopes (including issues:write for labeling) on . Probe returned , GitHub said: . Aborting — not falling back to npm audit (it ignores dismissals and the 7-day gate).` Do not proceed. +If `$GH_PAT` is missing, stop and print exactly: `Preflight failed: GH_PAT must be set (from the SECURITY_GH_PAT repository/organization secret). Probe not attempted. Aborting — not falling back to npm audit (it ignores dismissals and the 7-day gate).` If the probe fails, stop and print exactly: `Preflight failed: GH_PAT (SECURITY_GH_PAT secret) lacks the required scopes on . Probe returned , GitHub said: . Aborting — not falling back to npm audit (it ignores dismissals and the 7-day gate).` Do not proceed. **1. Fetch alerts.** Derive `$REPO` as `owner/name` from `git remote get-url origin`. Then: ``` -curl -sS \ +curl -sS -D /tmp/alerts-headers.txt \ -H "Authorization: Bearer $GH_PAT" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "https://api.github.com/repos/$REPO/dependabot/alerts?state=open&per_page=100" ``` -Paginate by following the `rel="next"` URL in the `Link` header. For each alert capture: `number`, `security_advisory.severity`, `dependency.package.ecosystem`, `dependency.package.name`, `security_vulnerability.vulnerable_version_range`, `security_vulnerability.first_patched_version.identifier`, `created_at`, `security_advisory.summary`. +Paginate by following the `rel="next"` URL from the `Link` response header captured in `/tmp/alerts-headers.txt` — the JSON body does not contain pagination links. For each alert capture: `number`, `security_advisory.severity`, `dependency.package.ecosystem`, `dependency.package.name`, `dependency.manifest_path`, `security_vulnerability.vulnerable_version_range`, `security_vulnerability.first_patched_version.identifier`, `created_at`, `security_advisory.summary`. **2. Triage each alert as FIX or IGNORE.** Mark as IGNORE only when one of the following is concretely true — record which one applies: - The package is dev/test/tooling only and the exploit requires untrusted input at runtime. @@ -25,62 +25,68 @@ Paginate by following the `rel="next"` URL in the `Link` header. For each alert - The advisory is disputed or withdrawn upstream. - No upstream patch exists yet (`first_patched_version` is null). - It duplicates another open alert on the same root cause (reference which). -- The vulnerable package is only pulled in by `_example/` and is not shipped to production. `_example/` intentionally pins older versions to demonstrate backward compatibility, so bumping them defeats the purpose. To qualify: the alert's `dependency.manifest_path` must be under `_example/`, AND no workspace outside `_example/` resolves the same package **even transitively** — verify with a workspace-aware resolved-tree query from the repo root (`npm ls --all` / `yarn why ` / `pnpm why `); a manifest grep only sees direct dependencies and is NOT sufficient evidence. If any non-`_example` workspace resolves the package, this reason does not apply — treat as FIX. +- The vulnerable package is only pulled in by `_example/` and is not shipped to production. `_example/` intentionally pins older versions to demonstrate backward compatibility, so bumping them defeats the purpose. To qualify: the alert's `dependency.manifest_path` must be under `_example/`, AND no workspace outside `_example/` resolves the same package **even transitively** — verify with a workspace-aware resolved-tree query from the repo root (`yarn why ` — this repo uses Yarn 1); a manifest grep only sees direct dependencies and is NOT sufficient evidence. If any non-`_example` workspace resolves the package, this reason does not apply — treat as FIX. Everything else is FIX. **3. Skip alerts opened less than 7 days ago.** These are deferred to the next run — list them in the PR description but don't touch them. -**4. For each remaining FIX, prefer a parent bump.** Find the dependency chain with `npm ls --all` (or `yarn why` / `pnpm why`). If direct, bump to the smallest patched version that stays within the currently used major (if the patch only exists in a later major, treat it as the breaking-major case below). If transitive, bump the nearest ancestor in `package.json` to the lowest version whose resolved tree pulls in the patched sub-dep. Verify with a fresh install + `npm ls `. +**4. For each remaining FIX, prefer a parent bump.** -If no reasonable parent bump closes the alert — no ancestor pulls in the patched sub-dep, or the required bump is a breaking major touching APIs we use — add a `resolutions` (Yarn) / `overrides` (npm, pnpm) entry pinning the vulnerable package to the smallest patched version compatible with the major its parents expect — an exact version or a `^` range within that major, never an unbounded `>=`, which could silently resolve to a later breaking major. Do this without asking. +For **npm-ecosystem** alerts: find the dependency chain with `yarn why ` (this repo uses Yarn 1). If direct, bump to the smallest patched version that stays within the currently used major (if the patch only exists in a later major, treat it as the breaking-major case below). If transitive, bump the nearest ancestor in `package.json` to the lowest version whose resolved tree pulls in the patched sub-dep. Verify with a fresh `yarn install` + `yarn why `. -**Narrow the blast radius.** Prefer in this order: -1. **Workspace-level placement.** If only one workspace's dependency graph contains the vulnerable chain AND your package manager honors workspace-level resolutions/overrides, place the entry in that workspace's `package.json`, not the root. Verify it took effect after install with `npm ls ` from the root. -2. **Scoped root entry keyed by parent.** If workspace placement isn't honored (common for npm `overrides` and pnpm `pnpm.overrides`), use parent-scoped syntax at root so the pin only applies within the specific dependency chain: `"some-parent > vulnerable-pkg": "X"` (pnpm), `"some-parent/vulnerable-pkg": "X"` (Yarn), or `{"some-parent": {"vulnerable-pkg": "X"}}` (npm). -3. **Unconditional root entry.** Last resort only, when multiple unrelated dependency chains share the vulnerability. +If no reasonable parent bump closes the alert — no ancestor pulls in the patched sub-dep, or the required bump is a breaking major touching APIs we use — add a `resolutions` entry pinning the vulnerable package to the smallest patched version compatible with the major its parents expect — an exact version or a `^` range within that major, never an unbounded `>=`, which could silently resolve to a later breaking major. Do this without asking. -Record each resolution in the PR under "Resolutions added" with: the parent chain tried, why the bump wasn't viable, which `package.json` it was placed in, and which form (workspace / scoped / unconditional) was used. +**Yarn 1 resolutions mechanics — narrow the blast radius.** Yarn 1 only honors the `resolutions` field in the **root** `package.json` of the workspace tree (workspace-level `resolutions` are ignored). Prefer, in this order: +1. **Scoped root entry keyed by parent**, so the pin only applies within the specific dependency chain: `"some-parent/vulnerable-pkg": "X"`. +2. **Unconditional root entry** (`"vulnerable-pkg": "X"`) only when multiple unrelated dependency chains share the vulnerability. -Always update lockfiles by running the install, never by hand. In a monorepo, apply each change in the correct workspace. +Record each resolution in the PR under "Resolutions added" with: the parent chain tried, why the bump wasn't viable, and which form (scoped / unconditional) was used. Always update `yarn.lock` by running `yarn install`, never by hand. In a monorepo, apply each `package.json` change in the correct workspace. -**5. Audit existing resolutions across all `package.json` files.** After applying the bumps above, sweep every `package.json` in the repo (root, workspaces, and `_example/`) and check each entry in `resolutions`, `overrides`, and `pnpm.overrides`: -- **Stale** — the pinned package no longer appears in the resolved dependency tree. Verify with `npm ls --all` (or `yarn why` / `pnpm why`). Remove the entry. -- **Redundant** — removing the entry leaves the natural resolution at a version that still satisfies the original pin (parent packages have since been upgraded upstream to pull in the patched sub-dep on their own). Verify by removing the entry, running a fresh install, and confirming `npm ls ` still reports `>= pinned version`. If yes, commit the removal; if no, restore the entry. +For **github-actions-ecosystem** alerts: bump the `uses:` version reference in the affected `.github/workflows/*.yml` file (the alert's `manifest_path` names it). `SECURITY_GH_PAT` carries the `workflows:write` permission this push requires. If the push is nevertheless rejected citing workflow permissions, unstage the workflow-file changes, move those alerts to "Could not auto-fix (token lacks Workflows permission)", and ship the rest. -Process one entry at a time, re-running the install between each to avoid compounding changes. Record every removal in a "Resolutions removed" section of the PR with: the file it was in, the pinned package + version, and why removal is safe (stale or redundant). +**5. Audit existing resolutions in the root `package.json`.** After applying the bumps above, check each entry under `resolutions` (if you encounter stray `overrides`/`pnpm.overrides` blocks, treat them the same way — they are dead weight under Yarn 1): +- **Stale** — the pinned package no longer appears in the resolved dependency tree. Verify with `yarn why `. Remove the entry. +- **Redundant** — removing the entry leaves the natural resolution at a version that still satisfies the original pin (parent packages have since been upgraded upstream to pull in the patched sub-dep on their own). ⚠ A plain `yarn install` after removing the entry is NOT a valid test: Yarn 1 conservatively keeps the existing `yarn.lock` entry, so every pin would look redundant. Instead: remove the entry, **delete the vulnerable package's blocks from `yarn.lock`**, run `yarn install` to force re-resolution, then check `yarn why `. If the re-resolved version still satisfies the original pin, keep the removal; if not, restore the entry (and restore the lockfile state via `git checkout -- yarn.lock` + `yarn install`). + +Process one entry at a time, re-running the install between each to avoid compounding changes. Keep removals in the working tree — everything is committed once, in phase 7. On this large monorepo, if disk gets tight across repeated installs (`df -h`), run `yarn cache clean` between iterations. Record every removal in a "Resolutions removed" section of the PR with: the pinned package + version, and why removal is safe (stale or redundant). **6. Pre-push checks (cheap, local only).** Run only what's fast and doesn't need the full test dependency graph: ``` -npx prettier --check . -npm run lint +yarn install +yarn prettier --check . +yarn lint ``` -Fix any prettier/lint issues the bumps introduced before pushing. **Do not run `npm test` locally** — CI is the source of truth for tests. +Fix any prettier/lint issues the bumps introduced before pushing. If a check fails on files this run did not touch, leave those pre-existing issues alone. **Do not run the test suite locally** — CI is the source of truth for tests. + +**7. Open the PR.** + +**Gate first:** if there is nothing to ship — every alert ended up IGNORED or DEFERRED, no resolutions were added, and no stale/redundant resolutions were removed — print the triage summary as the run output and **stop here**: no branch, no commit, no PR. If stale resolutions were removed but nothing else changed, ship the PR anyway — resolution hygiene is worth shipping on its own. -**7. Open the PR.** Create the branch with this exact shell command — do **not** use any built-in branch-creation tool that auto-generates names: +Create the branch with this exact shell command — do **not** use any built-in branch-creation tool that auto-generates names: ``` BRANCH="security/$(date -u +%Y-%m-%d)" git checkout -b "$BRANCH" ``` -**Same-day rerun:** if the remote branch already exists (`git ls-remote --exit-code --heads origin "$BRANCH"`), this run supersedes it — its content was recomputed from scratch off the default branch. The checkout only fetched the default branch, so first give the lease something to compare against: `git fetch origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"`. Then push with `git push --force-with-lease -u origin "$BRANCH"` instead of a plain push (the single sanctioned force-push; see Constraints). Then look for an already-**open** PR whose head is `$BRANCH` (`GET /repos/$REPO/pulls?head=:$BRANCH&state=open`): if one exists, skip PR creation, reuse its `number` as `$PR_NUMBER`, and update its description; if the only PRs on that branch are closed, create a new PR normally — the force-push has already discarded their stale history. +**Same-day rerun note:** if the remote branch already exists (`git ls-remote --exit-code --heads origin "$BRANCH"`), this run supersedes it — its content is recomputed from scratch off the default branch. Just note it for now: the push step below changes accordingly, and it only ever happens **after** this run's commit exists. Before pushing, verify the branch name matches `^security/\d{4}-\d{2}-\d{2}$`: ``` git rev-parse --abbrev-ref HEAD | grep -Eq '^security/[0-9]{4}-[0-9]{2}-[0-9]{2}$' || { echo "Branch name does not match required pattern; aborting"; exit 1; } ``` If the check fails, stop. Do not rename after the fact by auto-generating a name elsewhere — investigate why the branch got a different name and fix it at the source. -Then commit as `chore(security): patch Dependabot alerts`, push with `git push -u origin "$BRANCH"`, and open a PR against the default branch via `POST /repos/$REPO/pulls`. Capture `$PR_NUMBER` (from the creation response, or from the reused open PR on the same-day-rerun path) and set the monitoring commit explicitly on **every** path — create or reuse: `SHA=$(git rev-parse HEAD)`. After any later push, refresh it the same way. +Then, strictly in this order: +1. **Commit** as `chore(security): patch Dependabot alerts` (a single commit containing all of this run's changes, including the phase-5 removals). +2. **Push — only after the commit exists** (never push the bare default-branch state over an existing security branch). If the remote branch does not exist: `git push -u origin "$BRANCH"`. If it does (same-day rerun): first `git fetch origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"` so the lease has an expected value — the checkout only fetched the default branch — then `git push --force-with-lease -u origin "$BRANCH"` (the single sanctioned force-push; see Constraints). +3. **Open or reuse the PR:** look for an already-**open** PR whose head is `$BRANCH` (`GET /repos/$REPO/pulls?head=:$BRANCH&state=open`). If one exists, reuse its `number` as `$PR_NUMBER` and update its description; otherwise create one against the default branch via `POST /repos/$REPO/pulls` and capture `$PR_NUMBER` from the response (closed PRs on that branch are irrelevant — the force-push already discarded their stale history). +4. Set the monitoring commit explicitly on **every** path — create or reuse: `SHA=$(git rev-parse HEAD)`. After any later push, refresh it the same way. -**Label the PR `:lock: security`.** This triggers the Slack notification workflow that pings `@first_level_support` — do not skip. Call: +**Label the PR `:lock: security`.** This triggers the Slack notification workflow that pings `@first_level_support` — do not skip. The add-labels endpoint silently auto-creates missing labels with a random color and no description, so ensure the label exists **first**: ``` -curl -sS -o /tmp/label-resp.json -w "%{http_code}" -X POST \ - -H "Authorization: Bearer $GH_PAT" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/labels" \ - -d '{"labels":[":lock: security"]}' +curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $GH_PAT" \ + "https://api.github.com/repos/$REPO/labels/%3Alock%3A%20security" ``` -If the status is `422` (label doesn't exist in the repo yet), create it once and retry: +If that returns `404`, create it: ``` curl -sS -X POST \ -H "Authorization: Bearer $GH_PAT" \ @@ -89,17 +95,26 @@ curl -sS -X POST \ "https://api.github.com/repos/$REPO/labels" \ -d '{"name":":lock: security","color":"B60205","description":"Security fix — notifies first-level support"}' ``` -Then rerun the label POST. If labeling still fails, stop and print the response body — do not silently continue, because a missing label means the Slack notification won't fire and the PR won't be picked up for review. +Then attach it to the PR: +``` +curl -sS -o /tmp/label-resp.json -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $GH_PAT" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/labels" \ + -d '{"labels":[":lock: security"]}' +``` +If labeling fails, stop and print the response body — do not silently continue, because a missing label means the Slack notification won't fire and the PR won't be picked up for review. The PR description must include the sections listed below. Mark the **Validation** line as `⏳ Awaiting CI` — phase 8 updates it. -**Alert references in the body must be full Markdown links, never bare `#`.** GitHub auto-links `#` to issues/PRs in the same repo, which sends readers to the wrong page (`#214` would resolve to PR/issue 214, not Dependabot alert 214). Format every alert reference as `[#](https://github.com/$REPO/security/dependabot/)` — in the Fixed / Ignored / Deferred / Resolutions tables AND in any inline mentions ("duplicate of #X", "also closes #Y", etc.). +**Alert references in the body must be full Markdown links, never bare `#`.** GitHub auto-links `#` to issues/PRs in the same repo, which sends readers to the wrong page (`#214` would resolve to PR/issue 214, not Dependabot alert 214). Format every alert reference as `[#](https://github.com/$REPO/security/dependabot/)` — in every table AND in any inline mentions ("duplicate of #X", "also closes #Y", etc.). **Tables that require human action must start with a checkbox column** so first-level support can tick off each alert as they handle it. Use cell content `- [ ]` (GitHub renders it as an interactive checkbox in PR bodies, even inside table cells). Apply this to: - **Fixed** table: add a leading `Done` column (ticked once the reviewer has verified the fix landed) - **Ignored** table: add a leading `Dismissed` column (ticked once the reviewer has dismissed the alert in the repo's Security tab) -Deferred / Resolutions added / Resolutions removed are informational — no checkbox column. +Deferred / Resolutions added / Resolutions removed / Could not auto-fix are informational — no checkbox column. The very first line of the PR description must be the following blockquote so first-level support knows how to review it: @@ -107,43 +122,49 @@ The very first line of the PR description must be the following blockquote so fi > 👋 First-level support: see [Handling automated security PRs](https://forest.slite.com/app/docs/rmjdwFgmV2RzUp) for how to triage and merge this PR. ``` -- **Summary**: N fixed, M ignored, K deferred, R resolutions added, S resolutions removed. Append `| label: :lock: security applied` once the label POST returns 200. +- **Summary**: N fixed, M ignored, K deferred, R resolutions added, S resolutions removed, F could-not-auto-fix. Append `| label: :lock: security applied` once the label POST returns 200. Keep these counts (and the `patch ` commit message, when practical) consistent whenever an alert later moves between sections. - **Fixed** table: alert number, package, ecosystem, from → to, severity, what was bumped (direct dep, or "bumped `` X → Y"). - **Ignored**: each alert with its specific reason (one of the six allowed reasons). - **Deferred**: alert numbers skipped by the age gate. -- **Resolutions added** (if any): alert number, package + pinned range, parent chain tried, why the bump wasn't viable, which `package.json` received it, and which form (workspace / scoped / unconditional). -- **Resolutions removed** (if any): file, package + version that was pinned, reason (stale or redundant). +- **Resolutions added** (if any): alert number, package + pinned range, parent chain tried, why the bump wasn't viable, and which form (scoped / unconditional) was used. +- **Resolutions removed** (if any): package + version that was pinned, reason (stale or redundant). +- **Could not auto-fix** (if any): alert number, what was attempted, the observed failure. - **Risks**: per bump, from the upstream CHANGELOG — breaking changes touching APIs we use, peer-dep bumps affecting neighbors, tests likely to need updating. If no behavior change beyond the patched vuln, say so. - **Manual testing**: only if automated CI doesn't cover the affected paths — give concrete reproduction steps. Otherwise write "Covered by CI." - **Validation**: `⏳ Awaiting CI` for now. -**8. Monitor CI and fix failures.** Every 60 seconds, fetch the workflow runs and the combined commit status for the PR head SHA (Actions + Commit statuses APIs only — the Checks API is not accessible to fine-grained PATs): +**8. Monitor CI and fix failures.** Poll **inside a single Bash loop per tool call** (about 10 minutes of `sleep 60` iterations per call — do NOT spend one tool call per poll, that would exhaust the turn budget). Each iteration fetches the workflow runs and the combined commit status for the PR head SHA (Actions + Commit statuses APIs only — the Checks API is not accessible to fine-grained PATs; check runs posted by GitHub Apps such as coverage or preview bots are therefore a blind spot of this monitoring): ``` curl -sS -H "Authorization: Bearer $GH_PAT" \ "https://api.github.com/repos/$REPO/actions/runs?head_sha=$SHA" curl -sS -H "Authorization: Bearer $GH_PAT" \ "https://api.github.com/repos/$REPO/commits/$SHA/status" ``` -Wait until every workflow run has a non-null `conclusion` and — **only if the combined status reports at least one status context (`total_count > 0`)** — its `state` is no longer `pending`. An empty `statuses` array (`total_count: 0`) always reports `state: pending` on GitHub's side and must be treated as *no status gate*, not as pending CI. **Startup guard:** do not evaluate completion until at least one workflow run other than this security-fixes workflow exists for `$SHA` — with zero runs the condition would be vacuously true. If no such run has appeared after 10 minutes, comment on the PR ("No CI run started for this commit after 10 minutes — please check branch filters.") and stop. Cap one polling cycle at 45 minutes. (Exclude this security-fixes workflow's own run from the wait condition.) +Wait until every workflow run has a non-null `conclusion` and — **only if the combined status reports at least one status context (`total_count > 0`)** — its `state` is no longer `pending`. An empty `statuses` array (`total_count: 0`) always reports `state: pending` on GitHub's side and must be treated as *no status gate*, not as pending CI. **Startup guard:** do not evaluate completion until at least one workflow run other than this security-fixes workflow exists for `$SHA` — with zero runs the condition would be vacuously true. If no such run has appeared after 10 minutes, comment on the PR ("No CI run started for this commit after 10 minutes — please check branch filters.") and stop. Cap one polling cycle at 45 minutes. -Outcomes: -- **All green** — every workflow run has concluded with `success`, `skipped`, or `neutral` (skipped/neutral are not failures), and, when `total_count > 0`, the combined status `state` is `success` → edit the PR description: replace `⏳ Awaiting CI` with `✅ CI green`. Stop. -- **Any failure** — a workflow run concludes `failure`, `timed_out`, `cancelled`, or `action_required`, or a status context reports `failure`/`error` → for each failing workflow run, fetch the logs (`GET /repos/$REPO/actions/runs//logs`) and the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`); a failing **status context** has no Actions logs — record its `context` name and `target_url` in the PR instead. Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, refresh the head SHA (`SHA=$(git rev-parse HEAD)`) so the polling calls inspect the new commit, and resume polling. +Outcomes (every concluded run falls in exactly one bucket — there is no third state): +- **All green** — every workflow run concluded `success`, `skipped`, or `neutral`, and, when `total_count > 0`, the combined status `state` is `success` → edit the PR description: replace `⏳ Awaiting CI` with `✅ CI green (Actions + commit statuses; app-based checks not monitored)`. Stop. +- **Any failure** — a workflow run concludes with **anything else** (`failure`, `timed_out`, `cancelled`, `action_required`, `stale`, `startup_failure`, …), or a status context reports `failure`/`error` → diagnose. Fetching Actions logs is a two-step dance — the endpoint answers `302` with an empty body and a signed storage URL that must then be requested **without** the Authorization header: +``` +LOGS_URL=$(curl -sS -o /dev/null -w '%{redirect_url}' -H "Authorization: Bearer $GH_PAT" \ + "https://api.github.com/repos/$REPO/actions/runs//logs") +curl -sS -o /tmp/logs.zip "$LOGS_URL" && unzip -o /tmp/logs.zip -d /tmp/logs/ +``` + Also fetch the per-job breakdown (`GET /repos/$REPO/actions/runs//jobs`); a failing **status context** has no Actions logs — record its `context` name and `target_url` in the PR instead. Identify the root cause. Apply a fix, commit as `fix(security): address CI failure — `, push to the same branch, refresh the head SHA (`SHA=$(git rev-parse HEAD)`) so the polling calls inspect the new commit, and resume polling. - **Still pending after 45 minutes** → comment on the PR: "CI still pending after 45 minutes; stopping automated monitoring. Please review." Stop. -**Retry cap: 3 fix-and-push cycles.** After the third failing cycle, stop. Comment on the PR with: the failure signatures seen each cycle, what fixes were attempted, and which alerts in the diff are most likely responsible. Update Validation to `❌ CI failing — needs human review`. Do not close the PR. +**Retry cap: push at most 3 fix commits.** If CI fails again after the third fix commit, stop. (The single flaky-job rerun described below pushes nothing and does not count.) When stopping at the cap, comment on the PR with: the failure signatures seen each cycle, what fixes were attempted, and which alerts in the diff are most likely responsible. Update Validation to `❌ CI failing — needs human review`. Do not close the PR. **Failures the routine must not try to fix — flag and stop instead:** - Infrastructure failures (runner startup errors, missing CI secrets, GitHub Actions outage). Detect via runner-level error messages or zero-step runs. - Flaky tests unrelated to the bumped packages (the failing test file doesn't import anything that changed, and the failure is timing/network-shaped). Re-run the failed check **once** via `POST /repos/$REPO/actions/runs//rerun-failed-jobs`; if it flakes again, comment and stop. -- Any fix that would require editing source code beyond minor test adjustments tied to a specific bump. In that case, revert just the offending bump (and any resolution added for it), regenerate the lockfile, re-push, and move the alert to "Could not auto-fix" in the PR description with the observed failure. +- Any fix that would require editing source code beyond minor test adjustments tied to a specific bump. In that case, revert just the offending bump (and any resolution added for it), regenerate the lockfile, re-push, and move the alert to "Could not auto-fix" in the PR description with the observed failure (updating the Summary counts). **Constraints:** -- Only modify `package.json`, lockfiles, and test files that genuinely need to change. Don't touch source code to make a bump work — revert the bump instead. +- Only modify `package.json` files, `yarn.lock`, `uses:` references in `.github/workflows/*.yml` (github-actions-ecosystem alerts only), and test files that genuinely need to change. Don't touch source code to make a bump work — revert the bump instead. - Never silence failures with `--no-verify`, `eslint-disable`, `.only`, `.skip`, coverage threshold changes, or by marking a workflow required-status as optional. - Don't close, dismiss, or comment on Dependabot alerts from the API — merging the PR closes them. - Don't force-push during CI fix cycles — only add commits to the security branch. Single exception: the same-day-rerun supersede in phase 7 uses `--force-with-lease` once, before the PR is (re)used or created. - Branch name must match `^security/\d{4}-\d{2}-\d{2}$` — no exceptions, no default-named branches (`claude/*`, `dependabot/*`, etc.). Create the branch via `git checkout -b` in a shell, not via any built-in branch-creation helper that auto-names. - The PR must carry the `:lock: security` label before phase 7 exits. A missing label means `@first_level_support` never gets pinged and the PR sits unreviewed — treat a labeling failure as hard-stop, not a warning. -- The PR creation and label calls must go through `$GH_PAT` (a user PAT), never a GitHub App installation token or the in-Actions `GITHUB_TOKEN` — GitHub suppresses workflow-triggered events (like `labeled`) from those tokens, so the Slack notification workflow would never fire. `$GH_PAT` satisfies this, same as the `REPO_TOKEN` the central security workflow uses. -- If every alert ends up IGNORED or DEFERRED (no fixes, no new resolutions) AND no stale or redundant resolutions were removed, skip the PR entirely and print the triage summary as the run output. If stale resolutions were removed, ship the PR anyway — resolution hygiene is worth shipping on its own. +- The PR creation and label calls must go through `$GH_PAT` (a user PAT), never a GitHub App installation token or the in-Actions `$GITHUB_TOKEN` — GitHub suppresses workflow-triggered events (like `labeled`) from those tokens, so the Slack notification workflow would never fire. diff --git a/.github/workflows/security-fixes.yml b/.github/workflows/security-fixes.yml index 373e67db33..8a80493d8d 100644 --- a/.github/workflows/security-fixes.yml +++ b/.github/workflows/security-fixes.yml @@ -6,7 +6,8 @@ # # Required secrets (org-level, shared by the 5 ForestAdmin repos running this): # SECURITY_GH_PAT fine-grained PAT: Dependabot alerts RO, Contents RW, -# Pull requests RW, Issues RW, Actions RW, Commit statuses RO +# Workflows RW, Pull requests RW, Issues RW, Actions RW, +# Commit statuses RO # ANTHROPIC_API_KEY Claude API key name: Security fixes @@ -28,6 +29,10 @@ jobs: timeout-minutes: 240 env: GH_PAT: ${{ secrets.SECURITY_GH_PAT }} + # claude-code-action's Bash tool defaults to a 10-min hard cap; monorepo + # installs and lint runs can exceed it. + BASH_DEFAULT_TIMEOUT_MS: "900000" + BASH_MAX_TIMEOUT_MS: "3600000" steps: - uses: actions/checkout@v4 with: @@ -37,9 +42,6 @@ jobs: with: node-version-file: .nvmrc - - name: Enable corepack (yarn) - run: corepack enable - - name: Configure git identity run: | git config user.name "forest-security-routine"