Automatically triage Dependabot alerts with GitHub Actions
Dependabot tells you which of your dependencies have known vulnerabilities. It does not tell you which ones matter here. Answering that means reading each advisory, working out whether your code actually reaches the vulnerable path, checking whether anyone is exploiting it, and then either upgrading or recording why you did not. Most teams do this weekly, by hand, against a CVE severity score that is usually irrelevant to your software.
When you finish this guide you will have a scheduled GitHub Actions workflow that does that work for you, run by GitHub Copilot on the licenses you already pay for. On the schedule you set, it will:
- Fetch all of the repo's open Dependabot alerts, unfiltered.
- Score every alert with the k9 Security risk scoring rubric: known exploitation (CISA/VulnCheck KEV), exploit probability (EPSS), code-path reachability, and asset impact. Each one gets a FIX_TODAY / REVIEW / SCHEDULE / DEFER verdict with an evidence line. An alert whose vulnerable code runs in more than one place is scored in each, so a package that is critical in your public service and harmless in your build gets a verdict for both.
- Prepare the fixes that deserve fixing: a version bump on a branch, your test suite run against it, and a pull request opened only when those tests pass.
- Publish the full report to the workflow run's job summary and attach it as an artifact.
- Post a card to your Teams channel with the verdict counts and a link to the report.
It does not merge anything and does not dismiss anything. You keep the decisions, with an evidence-based recommendation attached to each one.
Setup is one workflow file, three secrets, and your repo's install and test commands.
Here is what a run produced on one of our demo repos, unattended, in 29 minutes:
Scope: 127 open Dependabot alerts, deduped to 84 unique advisories, scored across 204 execution-context bindings. Verdicts: 0 FIX_TODAY, 0 SCHEDULE, 2 REVIEW, 202 DEFER. No fix PRs, because nothing was actionable. The one thing wanting a person:
cryptographyGHSA-537c-gmf6-5ccf, a vendored-OpenSSL advisory that names no Python symbol to trace. It scored REVIEW in theruntimeandbuildcontexts, and DEFER iningest-lambdas, where the package is not installed at all.
The other 83 advisories were deferred on stated grounds: the vulnerable code is not installed, not loaded, not reachable from any entry point the project runs, or reached only by first-party build tooling. Each reason is written into the report, because a dismissal you cannot defend six months later is a dismissal you get to make twice.
If your team runs Claude Code rather than Copilot, the sibling guide Automatically triage Dependabot alerts with an AI agent builds the same triage policy as a scheduled Claude Code cloud routine that reports to Confluence and Jira instead.
A note for AI-agent readers: this document is written to be executable. Every configuration value that must be exact is given exactly. Values the reader chooses appear as <ANGLE_BRACKET_PLACEHOLDER> in the workflow, and Step 3 lists every one of them together with the values that look chooseable but are not.
Architecture
You add one workflow file. Everything below it ships in the action.
| Component | Role |
|---|---|
| Your caller workflow | The schedule and the guardrails: cron, permissions, concurrency, timeout. One job with a single step, and the only file you maintain. |
k9securityio/reachable-risk-triage-action |
The orchestration: authentication, guidance fetch, agent invocation, deliverable checks, reporting, notification. |
| GitHub Copilot CLI | The agent. It runs headlessly inside your job and does the analysis and the fix work. |
k9 Security MCP server (https://mcp.k9security.io/mcp) |
The security brain: the versioned risk-scoring rubric, the triage workflow procedures, and the score_risk / resolve_vuln_ids / lookup_vulns / lookup_kev / lookup_epss tools. |
| GitHub Dependabot | The system of record for alerts, read with a fine-grained PAT. |
| Microsoft Teams | Optional. Where the run announces itself. |
What a run does, in order:
cron fires (Tuesday morning)
└─ checkout your repo (manifests, .k9security/risk-context.yaml)
├─ install your dependencies (deps-command)
├─ mint a short-lived k9 token from your Service Client credentials
├─ fetch the current triage procedures from the k9 MCP server
├─ run the Copilot CLI with the k9 MCP server attached
│ ├─ fetch ALL open Dependabot alerts, reconcile the count
│ ├─ score every unique finding (KEV + EPSS + reachability + impact)
│ └─ for each actionable finding: branch → bump → deps → tests → PR
├─ verify the run produced its deliverables
├─ publish the report to the job summary + artifact
└─ post the outcome card to Teams
The procedure lives on the server, not in your workflow
At the start of every run, the action pulls the current triage procedures from the k9 MCP server (k9://workflow/fetch-dependency-alerts, k9://workflow/score-dependency-alerts, k9://workflow/summarize-dependency-alerts) and hands them to the agent as files. The agent also calls get_risk_scoring_rubric and get_basis_procedure itself, and is instructed never to work from a remembered copy.
So when k9 revises the rubric or the report format, your next run picks it up. No action upgrade, no workflow edit. A prompt that restates a procedure is a cache with no invalidation, which is why neither your workflow nor the action contains the procedure text.
The prompt itself ships inside the action at prompts/dependency-triage-prompt.md. You do not paste it anywhere, and you do not need to maintain it. Read it if you want to know exactly what the agent was told.
Prerequisites
- GitHub Copilot for your organization. The action authenticates the Copilot CLI with the built-in Actions token plus the
copilot-requests: writepermission, billed to your organization's Copilot plan. Confirm the organization policy "Allow use of Copilot CLI billed to the organization" is enabled under Organization Settings → Copilot → Policies. It is on by default. - A k9 Security account on the Reachable Risk plan, which is what grants
score_riskaccess. - Dependabot alerts enabled on the repository.
- Your repo's install and test commands, as one-liners. The job starts from a fresh checkout, so the action needs to be told how to install dependencies before anything can be analyzed or tested.
- Optional: a Microsoft Teams channel for run outcomes.
Step 1: Create the secrets
Three required secrets and two optional ones. Add them as repository or organization Actions secrets.
K9_CLIENT_IDandK9_CLIENT_SECRET. A k9 Service Client (M2M): in the k9 app, My Account → Service Clients (M2M) → Create, then copy the id and secret. The secret is shown once. The action mints a fresh short-lived token from these on every run, so there is no long-lived token sitting in a drawer and nothing to rotate on a schedule.GH_TRIAGE_TOKEN. A fine-grained personal access token (organization-owned is fine) with repository permissions Dependabot alerts: read, Contents: write, and Pull requests: write. It reads the alerts, pushes fix branches, and opens fix PRs. It needs no Copilot permission. A PAT is required here because the Actions-issuedGITHUB_TOKENcannot read Dependabot alerts, no matter what permissions you grant it in the workflow.TEAMS_WEBHOOK_URL(optional). See Step 2. Unset means the run reports to the job summary and artifact only.COPILOT_GITHUB_TOKEN(optional). Only for organizations that cannot hold a Copilot Business plan, since GitHub has paused new Copilot Business signups for Free and Team plan organizations. Set it to a classic token belonging to a Copilot-licensed user, and the run bills that user's plan instead of the org's. Do not use an org-owned fine-grained PAT: it cannot carry the account-level Copilot Requests permission (github/copilot-cli#223).
Step 2: Create the Teams webhook
Skip this if you do not want Teams notifications.
Microsoft retired classic "Incoming Webhook" connectors in May 2026. The supported replacement is a Workflows (Power Automate) webhook:
- In Teams, open the target channel → ⋯ → Workflows. This installs the Workflows app if you do not have it.
- Create the flow from the template "Post to a channel when a webhook request is received" and point it at the channel.
- Copy the HTTP POST URL it shows you and store it as the
TEAMS_WEBHOOK_URLsecret.
Step 3: Add the caller workflow
Save this as .github/workflows/dependency-triage.yml, substituting your own install and test commands:
name: dependency-alert-triage
on:
schedule:
- cron: "0 14 * * 2" # weekly, Tuesday 14:00 UTC
workflow_dispatch:
permissions:
contents: read # checkout of this repo (manifests, .k9security/risk-context.yaml)
copilot-requests: write # authenticates Copilot CLI via the Actions token
concurrency:
group: dependency-alert-triage
cancel-in-progress: false
jobs:
triage:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: k9securityio/reachable-risk-triage-action@v1
with:
k9-client-id: ${{ secrets.K9_CLIENT_ID }}
k9-client-secret: ${{ secrets.K9_CLIENT_SECRET }}
github-token: ${{ secrets.GH_TRIAGE_TOKEN }}
teams-webhook-url: ${{ secrets.TEAMS_WEBHOOK_URL }}
agent: copilot
model: claude-sonnet-5
deps-command: "<YOUR_INSTALL_COMMAND>"
test-command: "<YOUR_TEST_COMMAND>"
Substitute these four values:
| Value | What to put there |
|---|---|
<YOUR_INSTALL_COMMAND> |
Your repo's dependency install one-liner, for example npm ci, uv sync, or make venv-dev. Required. |
<YOUR_TEST_COMMAND> |
Your repo's test one-liner, for example npm test or make quick. Leave the input out entirely for report-only runs. |
cron: "0 14 * * 2" |
Your schedule. Weekly is a reasonable starting cadence; stagger repos if you run several. |
model: claude-sonnet-5 |
The model the agent runs. Every run behind this guide used claude-sonnet-5. Rubric-following with reachability judgment is the hard part of this job, so claude-opus-5 is worth moving to if your Copilot plan offers it. |
Everything else in that file is exact, including both permission names, the secret names as written, the concurrency group, and agent: copilot.
Three of those blocks are load-bearing:
permissionsis the entire permission set the job needs.contents: readchecks out the repo.copilot-requests: writeis what lets the Actions token authenticate the Copilot CLI against your org's plan. Fix branches and PRs are pushed withGH_TRIAGE_TOKEN, not the Actions token, so the job never needs write permission on your repository.concurrencywithcancel-in-progress: falsequeues overlapping runs instead of killing them. A manual dispatch landing on top of the cron run would otherwise double-score the same alerts, and cancelling the running one would throw away a triage that was nearly finished.timeout-minutesbounds a run that wedges. Real runs on a repo with dozens of alerts take twenty to thirty minutes.
Those three, plus on:, have to live in your workflow because a composite action cannot declare them for you.
If you lint workflows in CI, expect one false positive on the way in: actionlint (≤1.7.10) and zizmor (≤1.29) both flag copilot-requests as an unknown permission. The permission is real and newer than their permission lists. Suppress the warning rather than removing the permission, or Copilot authentication fails on the first run.
The @v1 floating major tag is the recommended default: fixes reach your workflow without per-repo edits, and breaking changes get a new major. If your organization requires hash-pinned actions, pin to a release SHA instead. See versioning and pinning.
Step 4: Name the agent, the model, and your commands
Four inputs decide what actually runs. None of them has a useful default, and two of them fail the job immediately if you leave them out.
agentnames the agent CLI. v1 supportscopilot; any other value fails fast rather than silently doing something else.modelnames the model that CLI runs. There is no default, deliberately: the caller always states what is running. Rubric-following with reachability judgment is the hard part of this job, so this is the place to spend model quality.deps-commandis your repo's install command, and it is required. It runs before the agent analyzes anything, because reachability analysis inspects the installed dependency trees (site-packages,node_modules), not just your manifests. An agent reasoning from manifests alone will tell you a transitive package is reachable when your lockfile never pulled the vulnerable path. The same command runs again on each fix branch before your tests.test-commandis your repo's test command, and it is the gate on fix PRs. A PR opens only when this passes on the bumped branch. Leave it unset and the action never opens PRs: recommended bumps are described in the report instead. An unverified fix PR is worse than none.
The remaining inputs are documented in the action's inputs table. The defaults are correct for the production k9 service, so you should not need to set any of them.
Step 5: Give the agent your risk context
Reachability and impact are properties of where a dependency runs, so configure .k9security/risk-context.yaml in the repo with one binding per execution context and the action will score each alert once per matching context. See Configure risk context, or ask an agent connected to the k9 MCP server to run the gather_risk_context prompt against the repo and write the file for you.
What the run does with what it finds
The action acts on findings, but only within a policy narrow enough to leave running unattended. Stated plainly:
- Actionable means every FIX_TODAY verdict, plus every SCHEDULE verdict whose fixed release is at least seven days old. The cooldown keeps you off releases that get yanked the same week.
- Each actionable finding gets a branch named
k9-triage/<package>with the minimal bump that covers every actionable finding on that package. One branch and one pull request per package, titledfix(deps): bump <package> to <fixed version> (<vuln ids>)and labeleddependency-triage. The body carries the finding keys, the verdict rationale verbatim fromscore_risk, the KEV / EPSS / reachability evidence, and a link back to the run. - The PR opens only if your
test-commandpasses on the bumped branch. When it fails, the failing output goes in the report and no PR is opened. - If an open Dependabot PR already bumps that package to a fixed version, the run records it in the report as ready-to-merge with its URL. It does not merge it and does not open a competing PR.
- REVIEW findings are reported and never acted on. A REVIEW means the evidence did not support any recommendation, so the report names the missing evidence and what would resolve it, and the decision stays with you.
- DEFER findings get no PR and no dismissal. The report carries ready-to-run dismissal commands with rubric-cited comments, so accepting one costs you a copy-paste.
- Nothing is merged. Nothing is dismissed. No GitHub issues are filed.
That is the same triage policy the Claude Code guide describes, which is the point: the runtime is a deployment choice, and the policy should not change when you change deployment.
Where results land
- The full report renders inline in the job summary on the run page, and is attached as the
dependency-triage-reportartifact along with the machine-readabletriage-summary.jsonand the agent's ownagent-run.log. Run pages require a GitHub login with access to the repo, and artifacts expire with your run-retention setting, 90 days by default. The durable longitudinal record is k9's scored-findings corpus, which every run feeds automatically. - Fix PRs land in the repo as described above.
- The Teams card carries the repo, the date, the run status, the rubric version, the alert count, the four verdict counts, how many fix PRs opened, how many Dependabot PRs were marked ready-to-merge, a one-sentence detail, and a button to the run page. A run that produces no readable summary posts an explicit failure card instead, so a wedged run is loud rather than silent.
Verdict counts can exceed alert counts
"Alerts scored" counts alerts. FIX_TODAY, REVIEW, SCHEDULE, and DEFER count verdicts, and there is one verdict per execution context an alert's code runs in. On any project with more than one execution context the verdicts legitimately sum higher than the alerts. The run above shows the shape: 84 advisories, 204 verdicts, because the repo declares three execution contexts and most packages are installed in more than one. That is the same package being genuinely critical in your public API and genuinely irrelevant in your build tooling, which is exactly the distinction you want the report to make.
Your first run
Dispatch it by hand before trusting the schedule: Actions → dependency-alert-triage → Run workflow. Then check four things.
- The alert count reconciles. The agent cross-checks the REST alert count against a GraphQL
totalCountand refuses to score until the two agree, so a mismatch shows up as a blocked run rather than a quietly partial triage. - The report is in the job summary, with a verdict and an evidence line for every finding.
- The Teams card arrived, if you configured the webhook.
- The fix path did what you expect. Either PRs opened, or the report says why not: no actionable findings, no
test-commandconfigured, or tests that failed on the bumped branch.
Two run shapes are normal and are not failures. A repo with zero open alerts produces a clean run with a short report. A run that cannot reach GitHub or k9 produces a blocked run that still writes both deliverables, with status blocked and the failure evidence in the report and on the Teams card.
The fix path may go unexercised for a while
A repo with a healthy backlog can go a long time without producing a FIX_TODAY or a cooldown-cleared SCHEDULE, which means branch, bump, test, and PR sit unproven. That is worth knowing rather than worrying about, and there is a cheap way to raise your confidence: the risky part is the mechanics, not the verdict that triggers them, so confirm that your deps-command and test-command both succeed from a clean checkout in an ordinary CI job. If they pass there, they will pass on a bumped branch, and a fix PR is what you get.
Do not manufacture a reachable, known-exploited vulnerability to force a FIX_TODAY. Deliberately introducing an exploitable dependency into a repo is a bad trade for a test, and it proves nothing the check above does not.
Operating it
- Review cadence. Read the Teams card, then the report if the counts warrant it. REVIEW verdicts are the ones that need you specifically: the agent could not support a recommendation, so decide whether to resolve the missing evidence or just upgrade past the question. Do not dismiss a REVIEW as tolerable risk, because that records a decision the evidence does not back.
- Token lifecycle. Note the
GH_TRIAGE_TOKENexpiry somewhere your future self will look. An expired token produces a blocked run with a 401, which is self-diagnosing but noisier than rotating a week early. - Scaling to another repo. Add the repo to the PAT's repository access, copy the workflow file, set its
deps-commandandtest-command. Organization-level secrets cover the rest. - Tuning the rubric or the report format. Not a workflow edit. The agent reads both from the k9 MCP server on every run, so improvements ship server-side.
- When something breaks, the action's troubleshooting table maps each symptom to its cause, including Copilot authentication failures, a missing report, and monthly Copilot quota exhaustion mid-run.