From b0bb18bc5a3326ac9f3db2faac1132549f7a9a50 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 10:03:47 +0200 Subject: [PATCH 1/8] 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 | 135 +++++++++++++++++++++++++++ .github/workflows/security-fixes.yml | 54 +++++++++++ 2 files changed, 189 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 000000000..c0698470c --- /dev/null +++ b/.github/security-fixes-prompt.md @@ -0,0 +1,135 @@ +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. This is a **Ruby / Bundler** repository. + +**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). +- Bundler is usable: `Gemfile` and `Gemfile.lock` are present and `bundle --version` works. + +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 bundle 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`. Alerts may cover several ecosystems (`rubygems`, but also `npm` via the tooling `package.json`, or `github-actions`) — handle each with its own package manager; the phases below describe the Bundler path, apply the equivalent for others. + +**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 gem is dev/test/tooling only (`group :development, :test` in the Gemfile) 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). + +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 the narrowest bump.** Locate the gem: direct dependency (listed in the `Gemfile` or in `agent_ruby.gemspec`) or transitive (only in `Gemfile.lock`). +- **Transitive:** run `bundle update --conservative` — it bumps only that gem (and required sub-deps) in the lockfile without touching siblings. Verify with `bundle list | grep ` that the resolved version is `>= first_patched_version`. +- **Direct:** relax/bump the version constraint in the `Gemfile` or the gemspec to allow `>= first_patched_version` (stay within the current major unless the patch only exists in a new major), then `bundle update --conservative`. +- **Blocked bump:** if a parent constraint prevents reaching the patched version, bump the nearest ancestor the same way. If the only path is a breaking major touching APIs we use, add an explicit entry to the `Gemfile` pinning the gem to `">= first_patched_version"` with a trailing comment `# security pin — see Dependabot alert `, then `bundle install`. Record it in the PR under "Security pins added" with the parent chain tried and why the bump wasn't viable. + +Always regenerate `Gemfile.lock` by running bundler, never by hand. + +**5. Audit existing security pins.** Sweep the `Gemfile` for entries carrying a `# security pin` comment (or clearly pin-only entries not required by the code): +- **Stale** — the gem no longer appears in the dependency tree without the pin. Remove the entry, `bundle install`, verify. +- **Redundant** — removing the entry still resolves the gem at a version satisfying the original pin (parents caught up upstream). Verify by removing it, running `bundle install`, and checking `bundle list | grep `. If satisfied, commit the removal; if not, restore it. + +Process one entry at a time, re-running `bundle install` between each. Record every removal in a "Security pins removed" section of the PR with the reason (stale or redundant). + +**6. Pre-push checks (cheap, local only).** Run `bundle exec rubocop` (the repo has a `.rubocop.yml`) and fix any offenses the changes introduced. **Do not run the test suite 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. Format every alert reference as `[#](https://github.com/$REPO/security/dependabot/)` — in every table AND in any inline mentions. + +**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 / Security pins added / Security pins 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 security pins added, S security pins removed. Append `| label: :lock: security applied` once the label POST returns 200. +- **Fixed** table: alert number, gem, ecosystem, from → to, severity, what was bumped (direct dep, or "bumped `` X → Y", or "conservative lockfile update"). +- **Ignored**: each alert with its specific reason (one of the five allowed reasons). +- **Deferred**: alert numbers skipped by the age gate. +- **Security pins added / removed** (if any): gem, pinned range, reason. +- **Risks**: per bump, from the upstream CHANGELOG — breaking changes touching APIs we use, 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 gems (the failing test file doesn't touch 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 pin 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 the `Gemfile`, `*.gemspec`, `Gemfile.lock` (and `package.json`/lockfile for npm-ecosystem alerts), plus 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 `rubocop:disable`, skipped specs, 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. Create the branch via `git checkout -b` in a shell. +- The PR must carry the `:lock: security` label before phase 7 exits — 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 the in-Actions `$GITHUB_TOKEN` — GitHub suppresses workflow-triggered events (like `labeled`) from that token, so the Slack notification workflow would never fire. +- If every alert ends up IGNORED or DEFERRED (no fixes, no new pins) AND no stale or redundant pins were removed, skip the PR entirely and print the triage summary as the run output. If stale pins were removed, ship the PR anyway — pin 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 000000000..e1d81a7b9 --- /dev/null +++ b/.github/workflows/security-fixes.yml @@ -0,0 +1,54 @@ +# 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: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + + - 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 78a9af6074644cf6522168bc2ec84cd94b90b7c5 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 10:13:54 +0200 Subject: [PATCH 2/8] 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 c0698470c..9f8aa779e 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -48,8 +48,17 @@ Process one entry at a time, re-running `bundle install` between each. Record ev **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; } @@ -110,11 +119,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. @@ -125,7 +134,7 @@ Outcomes: - 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 pin 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 the `Gemfile`, `*.gemspec`, `Gemfile.lock` (and `package.json`/lockfile for npm-ecosystem alerts), plus test files that genuinely need to change. Don't touch source code to make a bump work — revert the bump instead. +- Only modify the `Gemfile`, `*.gemspec`, `Gemfile.lock` — plus, when an alert belongs to another ecosystem: `package.json`/lockfile (npm alerts) or the `uses:` version references in `.github/workflows/*.yml` (github-actions alerts) — 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 `rubocop:disable`, skipped specs, 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. diff --git a/.github/workflows/security-fixes.yml b/.github/workflows/security-fixes.yml index e1d81a7b9..4b8165a0c 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: @@ -42,8 +44,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 8db950b107e1210bff505b3fa341dd204e443adf Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 10:27:00 +0200 Subject: [PATCH 3/8] 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 | 34 +++++++++++----------------- .github/workflows/security-fixes.yml | 1 + 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index 9f8aa779e..976a8b917 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -5,7 +5,7 @@ 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). -- Bundler is usable: `Gemfile` and `Gemfile.lock` are present and `bundle --version` works. +- Bundler is usable: a `Gemfile` is present and `bundle --version` works. This is a **library repo**: `Gemfile.lock` is gitignored and not committed — run `bundle lock` once to materialize a local lockfile for dependency inspection, and NEVER commit `Gemfile.lock`. 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 bundle audit (it ignores dismissals and the 7-day gate).` Do not proceed. @@ -30,12 +30,12 @@ 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 the narrowest bump.** Locate the gem: direct dependency (listed in the `Gemfile` or in `agent_ruby.gemspec`) or transitive (only in `Gemfile.lock`). -- **Transitive:** run `bundle update --conservative` — it bumps only that gem (and required sub-deps) in the lockfile without touching siblings. Verify with `bundle list | grep ` that the resolved version is `>= first_patched_version`. -- **Direct:** relax/bump the version constraint in the `Gemfile` or the gemspec to allow `>= first_patched_version` (stay within the current major unless the patch only exists in a new major), then `bundle update --conservative`. -- **Blocked bump:** if a parent constraint prevents reaching the patched version, bump the nearest ancestor the same way. If the only path is a breaking major touching APIs we use, add an explicit entry to the `Gemfile` pinning the gem to `">= first_patched_version"` with a trailing comment `# security pin — see Dependabot alert `, then `bundle install`. Record it in the PR under "Security pins added" with the parent chain tried and why the bump wasn't viable. +**4. For each remaining FIX, prefer the narrowest bump.** Locate the gem: direct dependency (listed in the `Gemfile` or in `agent_ruby.gemspec`) or transitive (only in the locally generated lockfile). Because no lockfile is committed, a lockfile-only update cannot ship a fix — every fix must land in the `Gemfile` or the gemspec: +- **Direct:** relax/bump the version constraint in the `Gemfile` or the gemspec to require `>= first_patched_version` (stay within the current major unless the patch only exists in a new major), then regenerate the local lock (`bundle lock`) and verify with `bundle list | grep `. +- **Transitive:** bump the constraint of the nearest direct ancestor so resolution pulls the patched version; if no ancestor bump works, add an explicit entry to the `Gemfile` pinning the gem to `">= first_patched_version"` with a trailing comment `# security pin — see Dependabot alert `. Verify via `bundle lock` + `bundle list`. +- **Blocked bump:** if the only viable path is a breaking major touching APIs we use, use the explicit Gemfile pin as above. Record it in the PR under "Security pins added" with the parent chain tried and why the bump wasn't viable. -Always regenerate `Gemfile.lock` by running bundler, never by hand. +Use bundler-generated resolution only for verification — `Gemfile.lock` is gitignored here and must never be committed. **5. Audit existing security pins.** Sweep the `Gemfile` for entries carrying a `# security pin` comment (or clearly pin-only entries not required by the code): - **Stale** — the gem no longer appears in the dependency tree without the pin. Remove the entry, `bundle install`, verify. @@ -48,17 +48,9 @@ Process one entry at a time, re-running `bundle install` between each. Record ev **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 +git checkout -b "$BRANCH" ``` -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. +**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; } @@ -119,11 +111,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. @@ -134,10 +126,10 @@ Outcomes: - 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 pin 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 the `Gemfile`, `*.gemspec`, `Gemfile.lock` — plus, when an alert belongs to another ecosystem: `package.json`/lockfile (npm alerts) or the `uses:` version references in `.github/workflows/*.yml` (github-actions alerts) — and test files that genuinely need to change. Don't touch source code to make a bump work — revert the bump instead. +- Only modify the `Gemfile` and `*.gemspec` (never commit `Gemfile.lock` — it is gitignored) — plus, when an alert belongs to another ecosystem: `package.json`/lockfile (npm alerts) or the `uses:` version references in `.github/workflows/*.yml` (github-actions alerts) — 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 `rubocop:disable`, skipped specs, 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. Create the branch via `git checkout -b` in a shell. - The PR must carry the `:lock: security` label before phase 7 exits — 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 the in-Actions `$GITHUB_TOKEN` — GitHub suppresses workflow-triggered events (like `labeled`) from that token, so the Slack notification workflow would never fire. diff --git a/.github/workflows/security-fixes.yml b/.github/workflows/security-fixes.yml index 4b8165a0c..7b17a1c5b 100644 --- a/.github/workflows/security-fixes.yml +++ b/.github/workflows/security-fixes.yml @@ -52,3 +52,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 d3423fc41f10034ed896830ad55b10b3eba1841b Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 10:46:41 +0200 Subject: [PATCH 4/8] 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 | 10 +++++----- .github/workflows/security-fixes.yml | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index 976a8b917..0ab377097 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -30,9 +30,9 @@ 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 the narrowest bump.** Locate the gem: direct dependency (listed in the `Gemfile` or in `agent_ruby.gemspec`) or transitive (only in the locally generated lockfile). Because no lockfile is committed, a lockfile-only update cannot ship a fix — every fix must land in the `Gemfile` or the gemspec: -- **Direct:** relax/bump the version constraint in the `Gemfile` or the gemspec to require `>= first_patched_version` (stay within the current major unless the patch only exists in a new major), then regenerate the local lock (`bundle lock`) and verify with `bundle list | grep `. -- **Transitive:** bump the constraint of the nearest direct ancestor so resolution pulls the patched version; if no ancestor bump works, add an explicit entry to the `Gemfile` pinning the gem to `">= first_patched_version"` with a trailing comment `# security pin — see Dependabot alert `. Verify via `bundle lock` + `bundle list`. +**4. For each remaining FIX, prefer the narrowest bump.** This is a monorepo of gems: dependencies are declared in the root `Gemfile`, the root `agent_ruby.gemspec`, **and the per-package gemspecs under `packages/*/*.gemspec`** (list them with `git ls-files '*.gemspec'`). A gem is a **direct** dependency if any of those files declares it — the alert's `dependency.manifest_path` tells you which one owns it; otherwise it is transitive (only in the locally generated lockfile). Since only gemspecs (not the Gemfile, not a lockfile) ship to consumers, a fix for a published package MUST land in the owning package's gemspec. Because no lockfile is committed, a lockfile-only update cannot ship a fix — every fix must land in the `Gemfile` or the gemspec: +- **Direct:** relax/bump the version constraint **in the file that owns the dependency** (the per-package gemspec, the root gemspec, or the Gemfile) to require `>= first_patched_version` (stay within the current major unless the patch only exists in a new major), then regenerate the local lock (`bundle lock`) and verify with `bundle list | grep `. +- **Transitive:** bump the constraint of the nearest direct ancestor (in its owning gemspec) so resolution pulls the patched version; if no ancestor bump works, add an explicit entry to the `Gemfile` pinning the gem to `">= first_patched_version"` with a trailing comment `# security pin — see Dependabot alert ` — but remember a Gemfile pin protects only this repo's CI, NOT consumers of the published gems; say so explicitly in the PR when that is the only option. Verify via `bundle lock` + `bundle list`. - **Blocked bump:** if the only viable path is a breaking major touching APIs we use, use the explicit Gemfile pin as above. Record it in the PR under "Security pins added" with the parent chain tried and why the bump wasn't viable. Use bundler-generated resolution only for verification — `Gemfile.lock` is gitignored here and must never be committed. @@ -50,14 +50,14 @@ Process one entry at a time, re-running `bundle install` between each. Record ev 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 7b17a1c5b..3b8b70a67 100644 --- a/.github/workflows/security-fixes.yml +++ b/.github/workflows/security-fixes.yml @@ -37,13 +37,16 @@ jobs: with: ruby-version: "3.4" + - name: Generate a local Gemfile.lock (gitignored) for dependency inspection + run: bundle lock + - 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 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.SECURITY_GH_PAT }} From a409e8eea2bc86d7150ef04595e6fcb44435d392 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 11:01:53 +0200 Subject: [PATCH 5/8] 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 0ab377097..79a1d96bb 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 gem is dev/test/tooling only (`group :development, :test` in the Gemfile) 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). @@ -114,8 +114,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 d6436f5f1bf0c0cd819ea2a606f9337dbada8e3d Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 11:15:12 +0200 Subject: [PATCH 6/8] 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index 79a1d96bb..6f3a7f4f0 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -32,7 +32,7 @@ Everything else is FIX. **4. For each remaining FIX, prefer the narrowest bump.** This is a monorepo of gems: dependencies are declared in the root `Gemfile`, the root `agent_ruby.gemspec`, **and the per-package gemspecs under `packages/*/*.gemspec`** (list them with `git ls-files '*.gemspec'`). A gem is a **direct** dependency if any of those files declares it — the alert's `dependency.manifest_path` tells you which one owns it; otherwise it is transitive (only in the locally generated lockfile). Since only gemspecs (not the Gemfile, not a lockfile) ship to consumers, a fix for a published package MUST land in the owning package's gemspec. Because no lockfile is committed, a lockfile-only update cannot ship a fix — every fix must land in the `Gemfile` or the gemspec: - **Direct:** relax/bump the version constraint **in the file that owns the dependency** (the per-package gemspec, the root gemspec, or the Gemfile) to require `>= first_patched_version` (stay within the current major unless the patch only exists in a new major), then regenerate the local lock (`bundle lock`) and verify with `bundle list | grep `. -- **Transitive:** bump the constraint of the nearest direct ancestor (in its owning gemspec) so resolution pulls the patched version; if no ancestor bump works, add an explicit entry to the `Gemfile` pinning the gem to `">= first_patched_version"` with a trailing comment `# security pin — see Dependabot alert ` — but remember a Gemfile pin protects only this repo's CI, NOT consumers of the published gems; say so explicitly in the PR when that is the only option. Verify via `bundle lock` + `bundle list`. +- **Transitive:** bump the constraint of the nearest direct ancestor (in its owning gemspec) so resolution pulls the patched version; if no ancestor bump works, add an explicit entry to the `Gemfile` pinning the gem to the smallest patched version compatible with what its parents expect (a pessimistic constraint like `"~> "` — never an unbounded `">= …"`, which could jump to a later breaking major) with a trailing comment `# security pin — see Dependabot alert ` — but remember a Gemfile pin protects only this repo's CI, NOT consumers of the published gems; say so explicitly in the PR when that is the only option. Verify via `bundle lock` + `bundle list`. - **Blocked bump:** if the only viable path is a breaking major touching APIs we use, use the explicit Gemfile pin as above. Record it in the PR under "Security pins added" with the parent chain tried and why the bump wasn't viable. Use bundler-generated resolution only for verification — `Gemfile.lock` is gitignored here and must never be committed. From 93dd9791e5a33f177fc6cc581e6edd148cd0b70a Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 11:17:24 +0200 Subject: [PATCH 7/8] fix(ci): address sixth round of Macroscope findings (ruby prompt) - direct bumps now require an explicit upper bound on the current major (a bare >= lets Bundler resolve a later breaking major) - the dev-only triage rule matches this Gemfile's actual group name, :development, :tests (plural) Co-Authored-By: Claude Fable 5 --- .github/security-fixes-prompt.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index 6f3a7f4f0..421977c89 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -20,7 +20,7 @@ curl -sS \ 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`. Alerts may cover several ecosystems (`rubygems`, but also `npm` via the tooling `package.json`, or `github-actions`) — handle each with its own package manager; the phases below describe the Bundler path, apply the equivalent for others. **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 gem is dev/test/tooling only (`group :development, :test` in the Gemfile) and the exploit requires untrusted input at runtime. +- The gem is dev/test/tooling only (in this repo's Gemfile that group is spelled `group :development, :tests` — note the plural) and the exploit requires untrusted input at runtime. - 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). @@ -31,7 +31,7 @@ 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 the narrowest bump.** This is a monorepo of gems: dependencies are declared in the root `Gemfile`, the root `agent_ruby.gemspec`, **and the per-package gemspecs under `packages/*/*.gemspec`** (list them with `git ls-files '*.gemspec'`). A gem is a **direct** dependency if any of those files declares it — the alert's `dependency.manifest_path` tells you which one owns it; otherwise it is transitive (only in the locally generated lockfile). Since only gemspecs (not the Gemfile, not a lockfile) ship to consumers, a fix for a published package MUST land in the owning package's gemspec. Because no lockfile is committed, a lockfile-only update cannot ship a fix — every fix must land in the `Gemfile` or the gemspec: -- **Direct:** relax/bump the version constraint **in the file that owns the dependency** (the per-package gemspec, the root gemspec, or the Gemfile) to require `>= first_patched_version` (stay within the current major unless the patch only exists in a new major), then regenerate the local lock (`bundle lock`) and verify with `bundle list | grep `. +- **Direct:** relax/bump the version constraint **in the file that owns the dependency** (the per-package gemspec, the root gemspec, or the Gemfile) to require the patched version with an explicit upper bound on the current major — e.g. `">= ", "< "` — a bare `>=` would let Bundler resolve a later breaking major. If the patch only exists in a later major, treat it as the blocked-bump case below. Then regenerate the local lock (`bundle lock`) and verify with `bundle list | grep `. - **Transitive:** bump the constraint of the nearest direct ancestor (in its owning gemspec) so resolution pulls the patched version; if no ancestor bump works, add an explicit entry to the `Gemfile` pinning the gem to the smallest patched version compatible with what its parents expect (a pessimistic constraint like `"~> "` — never an unbounded `">= …"`, which could jump to a later breaking major) with a trailing comment `# security pin — see Dependabot alert ` — but remember a Gemfile pin protects only this repo's CI, NOT consumers of the published gems; say so explicitly in the PR when that is the only option. Verify via `bundle lock` + `bundle list`. - **Blocked bump:** if the only viable path is a breaking major touching APIs we use, use the explicit Gemfile pin as above. Record it in the PR under "Security pins added" with the parent chain tried and why the bump wasn't viable. From 128b73ce6b3ffa86c0a21d5406f1d677efad2763 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 30 Jul 2026 12:52:37 +0200 Subject: [PATCH 8/8] 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 | 107 +++++++++++++++++---------- .github/workflows/security-fixes.yml | 11 ++- 2 files changed, 74 insertions(+), 44 deletions(-) diff --git a/.github/security-fixes-prompt.md b/.github/security-fixes-prompt.md index 421977c89..d72adbf51 100644 --- a/.github/security-fixes-prompt.md +++ b/.github/security-fixes-prompt.md @@ -1,26 +1,26 @@ -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. This is a **Ruby / Bundler** repository. +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. This is a **Ruby / Bundler monorepo of published gems**: the root `Gemfile`/`agent_ruby.gemspec` carry only dev tooling (rubocop, rspec), and each published package lives under `packages//` with its **own `Gemfile`** (declaring `gemspec` + `path:` deps) and its own gemspec. No `Gemfile.lock` is ever committed (gitignored). Only gemspecs ship to consumers. **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). -- Bundler is usable: a `Gemfile` is present and `bundle --version` works. This is a **library repo**: `Gemfile.lock` is gitignored and not committed — run `bundle lock` once to materialize a local lockfile for dependency inspection, and NEVER commit `Gemfile.lock`. +- `$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). +- Bundler is usable: `bundle --version` works. The workflow already ran `bundle install` at the **root**, which installs the dev tooling (needed for `bundle exec rubocop`). ⚠ The root bundle covers only dev tooling — the published packages' dependency trees resolve through their **own** Gemfiles under `packages//`; work there for anything dependency-related (see phase 4). NEVER commit any `Gemfile.lock`. -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 bundle 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 bundle 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 bundle 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`. Alerts may cover several ecosystems (`rubygems`, but also `npm` via the tooling `package.json`, or `github-actions`) — handle each with its own package manager; the phases below describe the Bundler path, apply the equivalent for others. +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`. Alerts may cover several ecosystems (`rubygems`, but also `npm` via the tooling `package.json`/`yarn.lock`, or `github-actions`) — handle each with its own toolchain; the phases below describe the Bundler path, with the other ecosystems addressed at the end of phase 4. **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 gem is dev/test/tooling only (in this repo's Gemfile that group is spelled `group :development, :tests` — note the plural) and the exploit requires untrusted input at runtime. +- The gem is dev/test/tooling only (in this repo's root Gemfile that group is spelled `group :development, :tests` — note the plural) and the exploit requires untrusted input at runtime. - 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). @@ -30,45 +30,55 @@ 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 the narrowest bump.** This is a monorepo of gems: dependencies are declared in the root `Gemfile`, the root `agent_ruby.gemspec`, **and the per-package gemspecs under `packages/*/*.gemspec`** (list them with `git ls-files '*.gemspec'`). A gem is a **direct** dependency if any of those files declares it — the alert's `dependency.manifest_path` tells you which one owns it; otherwise it is transitive (only in the locally generated lockfile). Since only gemspecs (not the Gemfile, not a lockfile) ship to consumers, a fix for a published package MUST land in the owning package's gemspec. Because no lockfile is committed, a lockfile-only update cannot ship a fix — every fix must land in the `Gemfile` or the gemspec: -- **Direct:** relax/bump the version constraint **in the file that owns the dependency** (the per-package gemspec, the root gemspec, or the Gemfile) to require the patched version with an explicit upper bound on the current major — e.g. `">= ", "< "` — a bare `>=` would let Bundler resolve a later breaking major. If the patch only exists in a later major, treat it as the blocked-bump case below. Then regenerate the local lock (`bundle lock`) and verify with `bundle list | grep `. -- **Transitive:** bump the constraint of the nearest direct ancestor (in its owning gemspec) so resolution pulls the patched version; if no ancestor bump works, add an explicit entry to the `Gemfile` pinning the gem to the smallest patched version compatible with what its parents expect (a pessimistic constraint like `"~> "` — never an unbounded `">= …"`, which could jump to a later breaking major) with a trailing comment `# security pin — see Dependabot alert ` — but remember a Gemfile pin protects only this repo's CI, NOT consumers of the published gems; say so explicitly in the PR when that is the only option. Verify via `bundle lock` + `bundle list`. +**4. For each remaining FIX, prefer the narrowest bump.** + +**Work in the package the alert belongs to.** The alert's `dependency.manifest_path` names the owning `Gemfile`/gemspec — usually `packages//…`. All resolution and verification for that alert happens **inside that package**: `cd packages/`, delete its local `Gemfile.lock` if present, run `bundle lock`, and read the resolved version in the freshly generated lockfile (never commit it). The root bundle knows nothing about the published packages' dependencies — do not use it to verify anything but the dev-tooling group. + +A gem is a **direct** dependency if the owning package's gemspec (or its Gemfile, or the root Gemfile/gemspec for root alerts) declares it; otherwise it is transitive. Since only gemspecs ship to consumers, a fix whose vulnerable gem is a **direct dependency of a published package MUST land in that package's gemspec**: +- **Direct:** relax/bump the version constraint in the owning gemspec (or Gemfile) to require the patched version with an explicit upper bound on the current major — e.g. `">= ", "< "` — a bare `>=` would let Bundler resolve a later breaking major. If the patch only exists in a later major, treat it as the blocked-bump case below. Then re-resolve in that package (delete its local lockfile, `bundle lock`) and read the resolved version. +- **Transitive:** bump the constraint of the nearest direct ancestor **in its owning gemspec** so resolution pulls the patched version. If no ancestor bump works, add an explicit pinned entry to the owning package's `Gemfile` (a pessimistic constraint like `"~> "` — never an unbounded `">= …"`) with a trailing comment `# security pin — see Dependabot alert `. ⚠ Gemfile pins protect only this repo's CI, NOT consumers of the published gems (gemspecs cannot pin transitives) — whenever a Gemfile pin is the only option, say so explicitly in the PR. Verify by re-resolving in that package. - **Blocked bump:** if the only viable path is a breaking major touching APIs we use, use the explicit Gemfile pin as above. Record it in the PR under "Security pins added" with the parent chain tried and why the bump wasn't viable. -Use bundler-generated resolution only for verification — `Gemfile.lock` is gitignored here and must never be committed. +Use bundler-generated resolution only for verification — no `Gemfile.lock` is ever committed in this repo. + +For **npm-ecosystem** alerts (the tooling `package.json`/`yarn.lock` at the root): fix with yarn (`yarn why `, bump or root-`resolutions` pin bounded to the parent-compatible major, `yarn install` to update the committed `yarn.lock`). For **github-actions-ecosystem** alerts: bump the `uses:` reference in the affected `.github/workflows/*.yml`; `SECURITY_GH_PAT` carries the `workflows:write` permission this push requires — if the push is nevertheless rejected citing workflow permissions, unstage those changes, move the alerts to "Could not auto-fix (token lacks Workflows permission)", and ship the rest. -**5. Audit existing security pins.** Sweep the `Gemfile` for entries carrying a `# security pin` comment (or clearly pin-only entries not required by the code): -- **Stale** — the gem no longer appears in the dependency tree without the pin. Remove the entry, `bundle install`, verify. -- **Redundant** — removing the entry still resolves the gem at a version satisfying the original pin (parents caught up upstream). Verify by removing it, running `bundle install`, and checking `bundle list | grep `. If satisfied, commit the removal; if not, restore it. +**5. Audit existing security pins.** Sweep **every** `Gemfile` (root and `packages/*/Gemfile`) for entries carrying a `# security pin` comment (or clearly pin-only entries not required by the code): +- **Stale** — the gem no longer appears in that package's dependency tree at all. Remove the entry, re-resolve in that package (delete its local lockfile, `bundle lock`), confirm. +- **Redundant** — removing the entry still resolves the gem at a version satisfying the original pin (parents caught up upstream). Verify by removing it and re-resolving **from scratch in that package** (delete its local `Gemfile.lock`, then `bundle lock` — a plain `bundle install` conservatively keeps the previously locked version and would always look satisfied). Read the resolved version in the regenerated lockfile: if it still satisfies the original pin, keep the removal; if not, restore the entry. -Process one entry at a time, re-running `bundle install` between each. Record every removal in a "Security pins removed" section of the PR with the reason (stale or redundant). +Process one entry at a time, re-resolving between each. Keep removals in the working tree — everything is committed once, in phase 7. Record every removal in a "Security pins removed" section of the PR with the reason (stale or redundant). -**6. Pre-push checks (cheap, local only).** Run `bundle exec rubocop` (the repo has a `.rubocop.yml`) and fix any offenses the changes introduced. **Do not run the test suite locally** — CI is the source of truth for tests. +**6. Pre-push checks (cheap, local only).** From the repo root, re-run `bundle install` (to sync the dev tooling with any root Gemfile edits), then `bundle exec rubocop` (the repo has a `.rubocop.yml`) and fix any offenses the changes introduced. Leave pre-existing offenses in untouched files alone. **Do not run the test suite 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: +**7. Open the PR.** + +**Gate first:** if there is nothing to ship — every alert ended up IGNORED or DEFERRED, no fixes or pins were added, and no stale/redundant pins were removed — print the triage summary as the run output and **stop here**: no branch, no commit, no PR. If stale pins were removed but nothing else changed, ship the PR anyway — pin hygiene is worth shipping on its own. + +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; double-check no `Gemfile.lock` is staged). +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" \ @@ -77,7 +87,16 @@ 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. @@ -87,7 +106,7 @@ The PR description must include the sections listed below. Mark the **Validation - **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 / Security pins added / Security pins removed are informational — no checkbox column. +Deferred / Security pins added / Security pins 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: @@ -95,42 +114,48 @@ 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 security pins added, S security pins removed. Append `| label: :lock: security applied` once the label POST returns 200. -- **Fixed** table: alert number, gem, ecosystem, from → to, severity, what was bumped (direct dep, or "bumped `` X → Y", or "conservative lockfile update"). +- **Summary**: N fixed, M ignored, K deferred, R security pins added, S security pins 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, gem, ecosystem, from → to, severity, what was bumped (direct dep in which gemspec, or "bumped `` X → Y"). - **Ignored**: each alert with its specific reason (one of the five allowed reasons). - **Deferred**: alert numbers skipped by the age gate. -- **Security pins added / removed** (if any): gem, pinned range, reason. +- **Security pins added / removed** (if any): gem, pinned range, which Gemfile, reason — and for added pins, the explicit "CI-only, does not protect consumers" caveat where applicable. +- **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, 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 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 gems (the failing test file doesn't touch 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 pin 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 pin added for it), re-resolve locally in the affected package to verify the revert, re-push the Gemfile/gemspec change, and move the alert to "Could not auto-fix" in the PR description with the observed failure (updating the Summary counts). **Constraints:** -- Only modify the `Gemfile` and `*.gemspec` (never commit `Gemfile.lock` — it is gitignored) — plus, when an alert belongs to another ecosystem: `package.json`/lockfile (npm alerts) or the `uses:` version references in `.github/workflows/*.yml` (github-actions alerts) — and test files that genuinely need to change. Don't touch source code to make a bump work — revert the bump instead. +- Only modify `Gemfile` files and `*.gemspec` files (never commit any `Gemfile.lock` — they are gitignored) — plus, for other ecosystems: the root `package.json`/`yarn.lock` (npm alerts) or `uses:` references in `.github/workflows/*.yml` (github-actions alerts) — 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 `rubocop:disable`, skipped specs, 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. Create the branch via `git checkout -b` in a shell. - The PR must carry the `:lock: security` label before phase 7 exits — 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 the in-Actions `$GITHUB_TOKEN` — GitHub suppresses workflow-triggered events (like `labeled`) from that token, so the Slack notification workflow would never fire. -- If every alert ends up IGNORED or DEFERRED (no fixes, no new pins) AND no stale or redundant pins were removed, skip the PR entirely and print the triage summary as the run output. If stale pins were removed, ship the PR anyway — pin hygiene is worth shipping on its own. diff --git a/.github/workflows/security-fixes.yml b/.github/workflows/security-fixes.yml index 3b8b70a67..be3b6f53d 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,8 +42,8 @@ jobs: with: ruby-version: "3.4" - - name: Generate a local Gemfile.lock (gitignored) for dependency inspection - run: bundle lock + - name: Install gems (also generates the gitignored local Gemfile.lock) + run: bundle install - name: Configure git identity run: |