Schedule icon
Webhook icon
Get icon
WorkingDirectory icon
Clone icon
Commands icon
Docker icon
Script icon
Log icon
If icon
Loop icon
Search icon
Create icon
Comment icon
Request icon
Set icon

Open a GitHub issue only for vulnerabilities that appeared since the last scan

Scan a pinned commit with osv-scanner, diff findings against a KV baseline, open issues only for new vulnerabilities, close them when they clear.

Categories
CoreInfrastructure

A dependency gate that reports counts is permanently red on any real repository, so teams mute it. The scan in this flow reports one thing instead: what appeared since the last time the same commit was scanned.

That number is not zero even when nothing in the repository changed. A lockfile is fixed once it is committed. The advisory database is not. OSV publishes new advisories against versions that shipped months ago, so the finding worth paging someone about is the one that did not exist yesterday. A count threshold cannot express that difference. An image scan that only runs on push never sees it at all.

So this flow checks out one pinned commit, scans it with osv-scanner, then diffs the result against a baseline in Kestra's KV store. New vulnerabilities get one GitHub issue each. Vulnerabilities that stopped being reported get their issue closed. Everything else is silent.

This blueprint was created by zkasuran.

The state model

Four decisions carry the flow. Each one exists because the obvious version breaks.

Vulnerabilities are keyed on the alias group, never on a raw id. OSV's own documentation says that two advisories which share an alias (or alias each other) are the same vulnerability. GHSA-c3h9-896r-86jm and GO-2021-0053 are one flaw with two names. Keying on vulnerabilities[].id would open two issues for it and make the resolved set flap. The flow reads groups[].ids together with groups[].aliases, stores the whole alias set, then matches a reported group against a baseline entry on any shared id. That last part matters: OSV adds aliases to existing advisories, so exact set equality would make a tracked vulnerability look new the day it gains a CVE number.

One advisory affecting three packages is one issue. Groups arrive per package, so the flow merges any two that share an id and lists every affected package in the issue body.

The baseline advances last. It records only what the run handled. advance_baseline is the final task, so a failed scan or a failed issue sync leaves the previous baseline in place. The cap is part of this: only the selected vulnerabilities enter the baseline, so the remainder is reported again next run instead of being silently written off as known. The log always prints the full count beside the handled count.

The KV baseline is the deduplication, the issue search is the backstop. GitHub's search API is rate limited and eventually consistent, so an issue created seconds ago may not be found. Before opening anything the flow still searches for the id in an existing title, open or closed, because a run that created issues then failed before advancing the baseline would otherwise duplicate them.

How it works

  1. read_baseline (io.kestra.plugin.core.kv.Get) loads the tracked set for this repository at this commit, with errorOnMissing: false so the first run starts from nothing. The key carries no TTL, because kv.Get throws on an expired key rather than reporting it absent.
  2. clone_repository (io.kestra.plugin.git.Clone) does a detached checkout of scan_commit. Pinning the commit is what makes two runs comparable.
  3. scan_dependencies runs ghcr.io/google/osv-scanner:v2.6.0 on the Docker task runner. Three traps are handled here. The binary sits at /osv-scanner with no PATH entry, so the command uses the full path. Exit code 1 means vulnerabilities were found, which is the normal case, so the code is captured instead of aborting the run. Exit code 128 means no supported manifest was found and no report file is written at all, which is a misconfiguration rather than a clean repository, so the task fails loudly.
  4. diff_against_baseline does the grouping, merging, diffing and capping in the Python standard library, then emits counts through Kestra's outputs protocol.
  5. report_diff prints the reported, tracked, new, deferred and resolved counts in one line.
  6. handle_new (io.kestra.plugin.core.flow.If) branches on whether anything is new, then on dry_run. The live path loops the selected vulnerabilities, searches for an existing issue with io.kestra.plugin.github.issues.Search, then opens one with io.kestra.plugin.github.issues.Create when there is none.
  7. handle_resolved closes the other side. It comments the reason with io.kestra.plugin.github.issues.Comment, then closes over REST with io.kestra.plugin.core.http.Request and method: PATCH, because plugin-github ships only Create, Search and Comment for issues. There is no close task to call.
  8. advance_baseline (io.kestra.plugin.core.kv.Set) records the new tracked set.

Prerequisites

  • A worker with Docker access. Both containers are public: ghcr.io/google/osv-scanner:v2.6.0 and python:3.12-slim.
  • Outbound HTTPS to api.osv.dev. No API key is needed.
  • For the live path, a GitHub token with Issues: write on issue_repository (classic PAT scope repo). The scan itself needs no token for a public repository.

Secrets

  • GITHUB_TOKEN: opens the issues, comments on them and closes them. Only read when dry_run is false.
  • OSV_SCAN_WEBHOOK_KEY: the key segment guarding the webhook URL. Only needed if you use the push trigger.

Quick start

  1. Run it with the defaults. It scans OWASP/NodeGoat at a pinned commit, reports every finding as new, then logs the issues it would open without touching GitHub.
  2. Run it a second time with no changes. NodeGoat reports 188 vulnerability groups, so run one handled 10 (max_new_issues) and run two reports 10 already tracked plus 178 new. Each run drains 10 more until the backlog is gone, after which an unchanged commit reports 0 new and only freshly published advisories show up. That contrast is the whole point of the flow.
  3. Point scan_repository and scan_commit at your own code, set issue_repository, then leave dry_run on until the counts look sensible.
  4. Add GITHUB_TOKEN, set dry_run to false, then let the schedule run it every morning.

Expected outputs

  • total_reported: alias-grouped vulnerabilities osv-scanner reported for the commit.
  • new_count and selected_count: how many were new, plus how many the cap let through.
  • resolved_count: tracked vulnerabilities that stopped being reported.
  • One KV entry per repository and commit, named osv_baseline_<owner>_<repo>_<short sha>, holding the alias set of every vulnerability the flow currently tracks.

How to extend

  • Raise max_new_issues to drain a backlog faster. Leave it at 10 to let successive runs work through it.
  • Filter on severity in diff_against_baseline so only scored findings above a threshold open an issue.
  • Replace the issue tasks with a Jira or Linear ticket. Add a Slack notice for the new set.
  • Scan several services from one monorepo by calling the flow per scan_path with its own baseline key.
  • Pass --allow-no-lockfiles to the scanner when a repository legitimately has no manifest, instead of letting exit code 128 fail the run.
  • Track a moving branch by resolving its head SHA in a first task and feeding that into scan_commit. The diff then mixes code changes with advisory changes, which is why the shipped default pins the commit.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.