Skip to content

Instantly share code, notes, and snippets.

@illoyd
Last active June 30, 2026 01:39
Show Gist options
  • Select an option

  • Save illoyd/2131d93f5933897a469738feaf75a3e9 to your computer and use it in GitHub Desktop.

Select an option

Save illoyd/2131d93f5933897a469738feaf75a3e9 to your computer and use it in GitHub Desktop.
Manage GitHub Code Scanning CVEs from the CLI and mise
#!/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