Last active
June 30, 2026 01:39
-
-
Save illoyd/2131d93f5933897a469738feaf75a3e9 to your computer and use it in GitHub Desktop.
Manage GitHub Code Scanning CVEs from the CLI and mise
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| #MISE description="Dismiss/reopen all open code-scanning alerts for a CVE, with a comment" | |
| #MISE dir="{{cwd}}" | |
| # | |
| # Bulk-triage GitHub code-scanning alerts by rule/CVE id. | |
| # | |
| # Dismiss (or reopen) EVERY open alert matching a CVE in one shot, recording a | |
| # reason + comment. Dismissing keeps the alert tracked — it leaves the active | |
| # list but is retained under `is:dismissed`, and when a later scan no longer | |
| # finds the CVE (e.g. upstream ships the fix and we bump the package) GitHub | |
| # auto-closes it as "fixed". This is preferable to a scan-time `.trivyignore` | |
| # for upstream / won't-fix CVEs, which would hide the finding entirely and never | |
| # signal resolution. | |
| # | |
| # Matching is by `rule.id`, which for Trivy/Dependabot is the CVE (e.g. | |
| # CVE-2026-42506) — so a single run also covers a CVE that fans out across | |
| # several sibling packages. | |
| # | |
| # Installed as a global mise file task: drop this executable at | |
| # ~/.config/mise/tasks/cve-triage and run from any repo with `mise run cve-triage`. | |
| # | |
| # Usage: | |
| # mise run cve-triage <CVE-ID> <action> [comment] | |
| # | |
| # action: wontfix | falsepositive | usedintest | reopen | |
| # comment: required when dismissing; recorded on each alert (audited). | |
| # Quote it — it is a single argument. | |
| # | |
| # Examples: | |
| # mise run cve-triage CVE-2026-42506 wontfix \ | |
| # "Upstream: thruster must bump golang.org/x/net; x/net/html not linked." | |
| # mise run cve-triage CVE-2026-42506 reopen | |
| # | |
| # Env: | |
| # DRY_RUN=1 print the alerts that would change, without modifying anything. | |
| # | |
| # Requires: gh (authenticated, with security-events/repo write scope) + jq. | |
| set -euo pipefail | |
| die() { echo "error: $*" >&2; exit 1; } | |
| cve="${1:-}" | |
| action_raw="${2:-}" | |
| comment="${3:-}" | |
| [ -n "$cve" ] || die "missing CVE/rule id (arg 1), e.g. CVE-2026-42506" | |
| [ -n "$action_raw" ] || die "missing action (arg 2): wontfix | falsepositive | usedintest | reopen" | |
| [[ "$cve" =~ ^[A-Za-z0-9._-]+$ ]] || die "invalid rule/CVE id: '$cve'" | |
| command -v gh >/dev/null || die "gh CLI not found" | |
| command -v jq >/dev/null || die "jq not found" | |
| # Normalise the action (lower-case, strip punctuation/spaces) -> GitHub state. | |
| norm="$(printf '%s' "$action_raw" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z')" | |
| case "$norm" in | |
| wontfix|wont) state=dismissed; reason="won't fix"; list_state=open ;; | |
| falsepositive|fp) state=dismissed; reason="false positive"; list_state=open ;; | |
| usedintest|test|tests) state=dismissed; reason="used in test"; list_state=open ;; | |
| reopen|open) state=open; reason=""; list_state=dismissed ;; | |
| *) die "unknown action '$action_raw' (use: wontfix | falsepositive | usedintest | reopen)" ;; | |
| esac | |
| if [ "$state" = dismissed ] && [ -z "$comment" ]; then | |
| die "a comment is required when dismissing (arg 3) — quote it" | |
| fi | |
| repo="$(gh repo view --json nameWithOwner -q .nameWithOwner)" || die "not in a GitHub repo (or gh not authenticated)" | |
| echo "repo: $repo" | |
| echo "rule: $cve" | |
| echo "action: $action_raw -> state=$state${reason:+, reason=\"$reason\"}" | |
| [ -n "$comment" ] && echo "comment: $comment" | |
| echo | |
| # Collect matching alert numbers (filter by rule id client-side). | |
| raw="$(gh api --paginate -X GET "repos/$repo/code-scanning/alerts" \ | |
| -f state="$list_state" -f per_page=100 \ | |
| --jq ".[] | select(.rule.id == \"$cve\") | .number")" \ | |
| || die "failed to query code-scanning alerts (token scope? security-events:write)" | |
| # shellcheck disable=SC2206 # alert numbers are integers — word-splitting is safe | |
| numbers=( $raw ) | |
| count=${#numbers[@]} | |
| if [ "$count" -eq 0 ]; then | |
| echo "no $list_state alert(s) found for $cve — nothing to do." | |
| exit 0 | |
| fi | |
| echo "matched $count $list_state alert(s): ${numbers[*]}" | |
| if [ "${DRY_RUN:-}" = "1" ]; then | |
| echo "DRY_RUN=1 — no changes made." | |
| exit 0 | |
| fi | |
| for n in "${numbers[@]}"; do | |
| if [ "$state" = dismissed ]; then | |
| gh api -X PATCH "repos/$repo/code-scanning/alerts/$n" \ | |
| -f state=dismissed -f dismissed_reason="$reason" -f dismissed_comment="$comment" \ | |
| --jq '" #\(.number) -> \(.state) (\(.dismissed_reason))"' | |
| else | |
| gh api -X PATCH "repos/$repo/code-scanning/alerts/$n" \ | |
| -f state=open \ | |
| --jq '" #\(.number) -> \(.state)"' | |
| fi | |
| done | |
| echo "done: $count alert(s) updated." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment