-
Notifications
You must be signed in to change notification settings - Fork 89
Run govulncheck on a schedule #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
11108b3
Run govulncheck on a schedule
jeremy d7af5c7
Address review: exit codes, stale issues, races, module coverage
jeremy 1c230eb
Address review: scan released build configs, expire accepted IDs, nam…
jeremy 39f947f
Address review: arm64 target, jq title as data, interval-aware fix check
jeremy 51d61a1
Address review: a failed target must not bury findings from one that …
jeremy bc7280e
Address review: make the version comparator actually correct
jeremy 5f7f109
Address review: close the tracking issue when the scan comes back clean
jeremy 38524a7
Address review: per-ref concurrency, ref type in the key, bounded body
jeremy 2cf9de3
Scan the default branch always, and delete the per-ref machinery
jeremy 36d8eeb
Find the tracking issue by its label, and let only the canonical run …
jeremy a36c1c8
Scan each module for the platforms that module actually ships on
jeremy 7b1636f
Let checkout pin the triggering SHA instead of naming a branch
jeremy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,382 @@ | ||
| name: Security | ||
|
|
||
| # Toolchain and dependency advisories are published against code that has not | ||
| # changed, so this runs on a clock rather than on a diff. Gating pull requests | ||
| # on it would turn every newly published CVE into a red build on unrelated work. | ||
| on: | ||
| schedule: | ||
| - cron: '31 6 * * *' | ||
| workflow_dispatch: | ||
|
|
||
| permissions: {} | ||
|
|
||
| # The reporting step reads then writes the one tracking issue, which is not | ||
| # atomic, so runs must not overlap. Every run scans the same tree, so a queued | ||
| # run that gets superseded was redundant anyway. | ||
| concurrency: | ||
| group: ${{ github.workflow }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| govulncheck: | ||
| name: Govulncheck | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| issues: write | ||
| env: | ||
| # One line per module: the module directory, then the GOOS/GOARCH targets | ||
| # that module actually ships on. ./... does not cross module boundaries, | ||
| # so every shipped module is listed; the targets differ per module because | ||
| # what each one ships on differs. | ||
| SCAN_MATRIX: | | ||
| . linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 | ||
| installer linux/amd64 linux/arm64 | ||
| # Findings we have looked at and are choosing to carry, so the run stays | ||
| # quiet until something NEW appears. Everything else fails the job. | ||
| # | ||
| # GO-2026-4887 Moby AuthZ plugin bypass on oversized request bodies | ||
| # GO-2026-4883 Moby off-by-one in plugin privilege validation | ||
| # | ||
| # Both are in github.com/docker/docker, which reports "Fixed in: N/A" -- | ||
| # the fix exists only in github.com/moby/moby/v2 >= 2.0.0-beta.8, a | ||
| # module-path migration onto a beta. | ||
| # | ||
| # govulncheck reports these as reachable across most of the docker client | ||
| # surface, so treat that as "we link the module", not as a specific | ||
| # exploitable path: both bugs are in the *daemon's* plugin authorization, | ||
| # and we are a client of the daemon, not a host of it. | ||
| # | ||
| # An ID stays accepted only while it has no fix. Re-check when bumping | ||
| # docker, and drop the ID once a fixed version is reachable for us -- | ||
| # otherwise this list keeps the job green exactly when it could go green | ||
| # honestly. Revisit when moby/moby/v2 is stable. | ||
| ACCEPTED: "GO-2026-4887 GO-2026-4883" | ||
| steps: | ||
| # No ref: on purpose. With none given, checkout pins to the SHA that | ||
| # triggered the run, so the tree and the workflow definition are the same | ||
| # commit -- a push that adds a module and its SCAN_MATRIX entry together | ||
| # can never be scanned with the previous matrix. Naming the branch instead | ||
| # would follow the moving tip and reintroduce exactly that gap. | ||
| # | ||
| # Scheduled runs only ever trigger on the default branch, which is what we | ||
| # ship. A workflow_dispatch from elsewhere scans its own ref, and the | ||
| # reporting step declines to touch the tracking issue for those. | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - name: Record the scanned commit | ||
| id: commit | ||
| run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 | ||
| with: | ||
| go-version-file: go.mod | ||
|
jeremy marked this conversation as resolved.
|
||
|
|
||
| - name: Install govulncheck | ||
| run: go install golang.org/x/vuln/cmd/govulncheck@latest | ||
|
|
||
| - name: Run govulncheck | ||
| id: scan | ||
| run: | | ||
| set +e | ||
| : > "${RUNNER_TEMP}/report.txt" | ||
| rm -f "${RUNNER_TEMP}"/gv-*.json | ||
| n=0 | ||
| scan_error=0 | ||
| toolchain=$(go env GOVERSION) | ||
|
|
||
| # Releases are built CGO_ENABLED=0, and govulncheck's source mode only sees the | ||
| # files the current build configuration selects -- so each module is scanned for | ||
| # the platforms that module actually ships on. They are not the same list: a | ||
| # container-only artifact never runs on darwin, and reporting a darwin-only path | ||
| # against it would be a finding nobody can act on. | ||
| export CGO_ENABLED=0 | ||
| while read -r module targets; do | ||
| [ -z "${module}" ] && continue | ||
|
|
||
| # One setup-go installs one toolchain, from the root go.mod. If a nested | ||
| # module asks for a different one, this would silently scan it against the | ||
| # wrong standard library -- so say so rather than report a wrong answer. | ||
| want=$(awk '/^go /{print "go" $2; exit}' "${module}/go.mod") | ||
| if [ "${want}" != "${toolchain}" ]; then | ||
| scan_error=1 | ||
| echo "===== ${module}" >> "${RUNNER_TEMP}/report.txt" | ||
| echo "module declares ${want} but the scan is running ${toolchain}; align the go directives or give this module its own scan job." >> "${RUNNER_TEMP}/report.txt" | ||
| continue | ||
| fi | ||
|
|
||
| for target in ${targets}; do | ||
| n=$((n + 1)) | ||
| GOOS="${target%%/*}" | ||
| GOARCH="${target##*/}" | ||
| export GOOS GOARCH | ||
| echo "===== ${target} ${module}" >> "${RUNNER_TEMP}/report.txt" | ||
| ( cd "${module}" && govulncheck -format json ./... ) > "${RUNNER_TEMP}/gv-${n}.json" 2> "${RUNNER_TEMP}/gv-${n}.err" | ||
| rc=$? | ||
| # govulncheck: 0 = clean, 3 = vulnerabilities found, anything else = it failed to run. | ||
| if [ "${rc}" -ne 0 ] && [ "${rc}" -ne 3 ]; then | ||
| scan_error=1 | ||
| echo "govulncheck exited ${rc} (scanner error, not a finding):" >> "${RUNNER_TEMP}/report.txt" | ||
| cat "${RUNNER_TEMP}/gv-${n}.err" >> "${RUNNER_TEMP}/report.txt" | ||
| rm -f "${RUNNER_TEMP}/gv-${n}.json" | ||
| continue | ||
| fi | ||
| ( cd "${module}" && govulncheck ./... ) >> "${RUNNER_TEMP}/report.txt" 2>&1 | ||
| done | ||
| done <<EOF | ||
| ${SCAN_MATRIX} | ||
| EOF | ||
| set -e | ||
|
|
||
| cat "${RUNNER_TEMP}/report.txt" | ||
|
|
||
| # A failed target must not bury a real finding from a target that succeeded, so | ||
| # always filter whatever JSON was produced before deciding what to report. | ||
|
|
||
| # Fail on findings govulncheck considers reachable (a symbol-level trace) that | ||
| # are not in ACCEPTED -- the same set its "Your code is affected by N | ||
| # vulnerabilities" line counts -- and on accepted IDs that have since gained a | ||
| # fix for the module path we actually depend on. | ||
| python3 - "$ACCEPTED" > "${RUNNER_TEMP}/new.txt" <<'PY' | ||
| import glob, json, os, sys | ||
|
|
||
| accepted = {x for x in sys.argv[1].split() if x} | ||
| reachable = {} # osv id -> {module path: set of versions} it was reached through | ||
| advisories = {} | ||
|
|
||
| for path in glob.glob(os.environ["RUNNER_TEMP"] + "/gv-*.json"): | ||
| raw = open(path).read() | ||
| dec, i = json.JSONDecoder(), 0 | ||
| while i < len(raw): | ||
| while i < len(raw) and raw[i].isspace(): i += 1 | ||
| if i >= len(raw): break | ||
| obj, i = dec.raw_decode(raw, i) | ||
| if "osv" in obj: | ||
| advisories[obj["osv"]["id"]] = obj["osv"] | ||
| f = obj.get("finding") | ||
| if f: | ||
| frame = (f.get("trace") or [{}])[0] | ||
| if frame.get("function"): | ||
| mods = reachable.setdefault(f["osv"], {}) | ||
| mods.setdefault(frame.get("module"), set()).add(frame.get("version") or "v0.0.0") | ||
|
|
||
| def parse(version): | ||
| # Order Go module versions by SemVer rules. Two prefixes to shed: module | ||
| # versions carry "v" (v28.5.2), and stdlib findings carry "go" (go1.26.5) | ||
| # while the advisory's own ranges use a bare 1.26.5 -- comparing those | ||
| # unnormalized would match the wrong interval entirely. Build metadata | ||
| # (+incompatible) does not participate in precedence. | ||
| version = version.split("+", 1)[0] | ||
| if version.startswith("go"): | ||
| version = version[2:] | ||
| elif version.startswith("v"): | ||
| version = version[1:] | ||
| main, _, pre = version.partition("-") | ||
|
|
||
| release = [] | ||
| for chunk in main.split("."): | ||
| release.append(int(chunk) if chunk.isdigit() else 0) | ||
| while len(release) < 3: | ||
| release.append(0) | ||
|
|
||
| # SemVer 11.4: dot-separated prerelease identifiers compare field by field; | ||
| # numeric ones compare numerically and rank below alphanumeric ones. String | ||
| # comparison would put beta.10 before beta.2. | ||
| identifiers = [] | ||
| for chunk in pre.split(".") if pre else []: | ||
| if chunk.isdigit(): | ||
| identifiers.append((0, int(chunk), "")) | ||
| else: | ||
| identifiers.append((1, 0, chunk)) | ||
|
|
||
| # A version with no prerelease outranks the same release with one. | ||
| return (release, 0 if pre else 1, identifiers) | ||
|
|
||
| def fix_for(osv_id, module_versions): | ||
| # Only a fix on a module path we actually import counts: these advisories | ||
| # routinely list a renamed module (moby/moby/v2) carrying the fix while the | ||
| # path we depend on (docker/docker) never gets one. | ||
| # | ||
| # And only a fix for the interval OUR version falls in counts. An advisory | ||
| # can be fixed, then reintroduced and left unfixed; treating the historical | ||
| # fixed event as an upgrade would fail the job forever with advice that does | ||
| # not apply. | ||
| out = [] | ||
| for affected in (advisories.get(osv_id) or {}).get("affected", []): | ||
| name = affected.get("package", {}).get("name") | ||
| if name not in module_versions: | ||
| continue | ||
| for ours in module_versions[name]: | ||
| try: | ||
| target = parse(ours) | ||
| except Exception: | ||
| continue | ||
| for rng in affected.get("ranges", []): | ||
| introduced, fixed = None, None | ||
| for event in rng.get("events", []): | ||
| if event.get("introduced") is not None: | ||
| # A new interval starts; the previous one did not contain us. | ||
| introduced, fixed = event["introduced"], None | ||
| elif event.get("fixed") is not None: | ||
| fixed = event["fixed"] | ||
| if introduced is not None and parse(introduced) <= target < parse(fixed): | ||
| out.append(f"{name}@{fixed}") | ||
| introduced = None | ||
| # A trailing introduced with no fixed is an open, unfixed interval. | ||
| return out | ||
|
|
||
| problems = [] | ||
| for osv_id in sorted(reachable): | ||
| if osv_id not in accepted: | ||
| problems.append(osv_id) | ||
| continue | ||
| fixes = fix_for(osv_id, reachable[osv_id]) | ||
| if fixes: | ||
| problems.append(f"{osv_id} (accepted, but now fixed in {', '.join(sorted(set(fixes)))} -- drop it from ACCEPTED and upgrade)") | ||
|
|
||
| if problems: | ||
| print("\n".join(problems)) | ||
| PY | ||
|
|
||
| findings=0 | ||
| if [ -s "${RUNNER_TEMP}/new.txt" ]; then | ||
| findings=1 | ||
| echo "Actionable findings:" | ||
| cat "${RUNNER_TEMP}/new.txt" | ||
| fi | ||
|
|
||
| if [ "${scan_error}" -eq 1 ] && [ "${findings}" -eq 1 ]; then | ||
| echo "outcome=vulnerable-and-error" >> "$GITHUB_OUTPUT" | ||
| echo "govulncheck also failed to run for at least one target/module; see above." >&2 | ||
| exit 1 | ||
| elif [ "${scan_error}" -eq 1 ]; then | ||
| echo "outcome=error" >> "$GITHUB_OUTPUT" | ||
| echo "govulncheck failed to run for at least one target/module; see above." >&2 | ||
| exit 1 | ||
| elif [ "${findings}" -eq 1 ]; then | ||
| echo "outcome=vulnerable" >> "$GITHUB_OUTPUT" | ||
| exit 1 | ||
| fi | ||
| echo "outcome=clean" >> "$GITHUB_OUTPUT" | ||
| echo "No actionable findings (accepted: ${ACCEPTED:-none})." | ||
|
jeremy marked this conversation as resolved.
|
||
|
|
||
| - name: Summarize | ||
| if: always() | ||
| run: | | ||
| { | ||
| echo '## govulncheck' | ||
| echo | ||
| echo '```' | ||
| cat "${RUNNER_TEMP}/report.txt" 2>/dev/null || echo '(no output -- the job failed before scanning)' | ||
| echo '```' | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
|
|
||
| # Runs on success too, not just failure: a failing run leaves an issue open, | ||
| # and only a later clean run can close it. Skipped only on cancellation. | ||
| - name: Report | ||
| if: ${{ !cancelled() }} | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| GH_REPO: ${{ github.repository }} | ||
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | ||
| OUTCOME: ${{ steps.scan.outputs.outcome }} | ||
| SCANNED_SHA: ${{ steps.commit.outputs.sha }} | ||
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | ||
| GITHUB_REF: ${{ github.ref }} | ||
| run: | | ||
| # The scan always runs against the default branch, so there is exactly one thing | ||
| # to track: one issue, found by its own label. Matching on the title instead | ||
| # would break the moment the default branch is renamed or someone edits the | ||
| # title, and would silently open a second issue. | ||
| existing=$(gh issue list --label govulncheck --state open --limit 1 --json number --jq '.[0].number // empty') | ||
|
|
||
| # Only the canonical run owns that issue. workflow_dispatch pins the *source | ||
| # tree* to the default branch but not the workflow definition, which GitHub | ||
| # loads from the dispatched ref -- so a branch carrying an experimental ACCEPTED | ||
| # list or reporting change would otherwise write to, or close, the real issue. | ||
| # Dispatching from a branch stays useful for trying this workflow out; it just | ||
| # reports to the run summary instead. | ||
| if [ "${GITHUB_REF}" != "refs/heads/${DEFAULT_BRANCH}" ]; then | ||
| echo "Running from ${GITHUB_REF}, not the default branch -- results are in the run summary; the tracking issue is left alone." | ||
| exit 0 | ||
| fi | ||
|
|
||
| # A clean run has to close the loop too. Otherwise the issue from a run that | ||
| # failed -- or from a vulnerability since fixed -- stays open forever, and the | ||
| # tracking state is stale in the other direction. | ||
| if [ "${OUTCOME}" = "clean" ]; then | ||
| if [ -n "${existing}" ]; then | ||
| gh issue comment "${existing}" --body "The scheduled \`govulncheck\` run is clean again at \`${SCANNED_SHA}\`. | ||
|
|
||
| Run: ${RUN_URL} | ||
|
|
||
| Closing; a later failure files a fresh issue." | ||
| gh issue close "${existing}" --reason completed | ||
| echo "Closed issue #${existing} -- scan is clean." | ||
| else | ||
| echo "Scan is clean and no tracking issue is open; nothing to do." | ||
| fi | ||
| exit 0 | ||
| fi | ||
|
|
||
| # GitHub caps an issue body or comment at 65536 characters. A dozen scans of | ||
| # verbose output can pass that, and gh would fail the report entirely -- turning | ||
| # a large finding into no notification, the exact failure this exists to | ||
| # prevent. Keep the head and tail and say what was dropped. | ||
| if [ ! -s "${RUNNER_TEMP}/report.txt" ]; then | ||
| body_output="(govulncheck produced no output -- the job failed before or during the scan; see the run log.)" | ||
| elif [ "$(wc -c < "${RUNNER_TEMP}/report.txt")" -gt 45000 ]; then | ||
| body_output="$(head -c 22000 "${RUNNER_TEMP}/report.txt") | ||
|
|
||
| [... truncated to fit GitHub's 65536-character limit -- full output in the run log ...] | ||
|
|
||
| $(tail -c 22000 "${RUNNER_TEMP}/report.txt")" | ||
| else | ||
| body_output=$(cat "${RUNNER_TEMP}/report.txt") | ||
| fi | ||
|
|
||
| if [ "${OUTCOME}" = "vulnerable-and-error" ]; then | ||
| headline="The scheduled \`govulncheck\` run found vulnerabilities that are not in this workflow's accepted list, **and** failed to scan at least one target or module. Both need attention: the findings below are real, and the coverage gap means there may be more." | ||
| elif [ "${OUTCOME}" = "error" ]; then | ||
| headline="The scheduled \`govulncheck\` run **could not complete**. This is a scanner or environment failure, not a vulnerability report." | ||
| elif [ "${OUTCOME}" = "vulnerable" ]; then | ||
| headline="The scheduled \`govulncheck\` run found vulnerabilities that are not in this workflow's accepted list." | ||
| else | ||
| headline="The scheduled \`govulncheck\` job failed before it could scan. This is an environment or setup failure, not a vulnerability report." | ||
| fi | ||
|
|
||
| { | ||
| echo "${headline}" | ||
| echo | ||
| echo "Commit: \`${SCANNED_SHA}\`" | ||
| echo "Run: ${RUN_URL}" | ||
| if [ -s "${RUNNER_TEMP}/new.txt" ]; then | ||
| echo | ||
| echo '### Not in the accepted list' | ||
| echo '```' | ||
| cat "${RUNNER_TEMP}/new.txt" | ||
| echo '```' | ||
| fi | ||
| echo | ||
| echo '<details><summary>govulncheck output</summary>' | ||
| echo | ||
| echo '```' | ||
| echo "${body_output}" | ||
|
jeremy marked this conversation as resolved.
|
||
| echo '```' | ||
| echo | ||
| echo '</details>' | ||
| echo | ||
| echo "Standard library findings are usually cleared by bumping the \`go\` directive in \`go.mod\` (and any \`FROM golang:\` base image) to the version named under \"Fixed in\"." | ||
| echo | ||
| echo "An entry marked \"accepted, but now fixed\" means an ID in this workflow's ACCEPTED list has gained a fix for a module path we import: upgrade and drop the ID, rather than re-accepting it." | ||
| } > "${RUNNER_TEMP}/body.md" | ||
|
|
||
| if [ -n "${existing}" ]; then | ||
| gh issue comment "${existing}" --body-file "${RUNNER_TEMP}/body.md" | ||
| echo "Updated issue #${existing}." | ||
| else | ||
| gh label create govulncheck --description "Reported by the scheduled govulncheck run" --color d93f0b --force | ||
| gh issue create --title "govulncheck: findings on the default branch" --label govulncheck --body-file "${RUNNER_TEMP}/body.md" | ||
| fi | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.