Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions .github/workflows/security.yml
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
Comment thread
jeremy marked this conversation as resolved.
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
Comment thread
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})."
Comment thread
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}"
Comment thread
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