diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..b068e87 --- /dev/null +++ b/.github/workflows/security.yml @@ -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 + + - 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 < "${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})." + + - 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 '
govulncheck output' + echo + echo '```' + echo "${body_output}" + echo '```' + echo + echo '
' + 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