diff --git a/.github/actions/locked-tools/README.md b/.github/actions/locked-tools/README.md index 630a8a0..45db771 100644 --- a/.github/actions/locked-tools/README.md +++ b/.github/actions/locked-tools/README.md @@ -12,7 +12,8 @@ Install checksum-verified release tools before configuring release credentials: The action owns the version/URL/checksum reader. It resolves its canonical `tool-locks.txt` from the action directory, not from a caller checkout, verifies artifacts before extraction, and checks each expected executable leaf before -publishing PATH. Requesting `gon` also installs Go first. +publishing PATH. Requesting `gon` or `govulncheck` also installs Go first, then +builds the locked source commit with readonly, checksum-authenticated modules. ## Inputs and outputs @@ -26,7 +27,7 @@ Outputs: | Output | Contract | | --- | --- | -| `go`, `node`, `syft`, `goreleaser`, `goreleaser-pro`, `gon`, `minisign` | Installed tool version, or source commit for gon; empty when not requested. Go includes its `go` prefix. | +| `go`, `node`, `syft`, `goreleaser`, `goreleaser-pro`, `gon`, `govulncheck`, `minisign` | Installed tool version, or source commit for gon/govulncheck; empty when not requested. Go includes its `go` prefix. | | `versions` | JSON per-tool map of the installed versions/source commits. | | `lock_sha256` | SHA256 of the exact reviewed lock used throughout preparation, installation and output publication. | | `lock_path` | Absolute path of that same existing lock file. Action-local by default; the resolved reviewed override when explicitly supplied. Native Windows form on Windows. | @@ -55,7 +56,7 @@ or any other checkout copy: ``` The same environment applies to `verify-locked-go.sh version`. Request `go` -(or `gon`, which adds it) to obtain a nonempty expected Go version. +(or `gon`/`govulncheck`, which add it) to obtain a nonempty expected Go version. All three expectation variables are required. The gate validates their shape, requires an absolute readable `LOCK_PATH`, and hashes its bytes to assert the diff --git a/.github/actions/locked-tools/action.yml b/.github/actions/locked-tools/action.yml index c1ca867..8247e75 100644 --- a/.github/actions/locked-tools/action.yml +++ b/.github/actions/locked-tools/action.yml @@ -2,7 +2,7 @@ name: Locked release tools description: Install checksum-verified release tools before configuring release credentials. inputs: tools: - description: Comma-separated tools (go,node,syft,goreleaser,goreleaser-pro,gon,minisign). Gon automatically adds Go. + description: Comma-separated tools (go,node,syft,goreleaser,goreleaser-pro,gon,govulncheck,minisign). Gon and govulncheck automatically add Go. required: true locks: description: Path to the reviewed tool lock file; empty selects the action-local canonical lock. @@ -12,7 +12,7 @@ inputs: default: ${{ runner.temp }}/locked-tools outputs: go: - description: Installed Go version, when requested or required by gon. + description: Installed Go version, when requested or required by a source-built tool. value: ${{ steps.result.outputs.go }} node: description: Installed Node version, when requested. @@ -29,6 +29,9 @@ outputs: gon: description: Built gon source commit, when requested. value: ${{ steps.result.outputs.gon }} + govulncheck: + description: Built govulncheck source commit, when requested. + value: ${{ steps.result.outputs.govulncheck }} minisign: description: Installed Minisign version, when requested. value: ${{ steps.result.outputs.minisign }} @@ -63,6 +66,15 @@ runs: path: ${{ steps.prepare.outputs.gon_path }} persist-credentials: false + - name: Checkout locked govulncheck source + if: steps.prepare.outputs.govulncheck_ref != '' + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 + with: + repository: golang/vuln + ref: ${{ steps.prepare.outputs.govulncheck_ref }} + path: ${{ steps.prepare.outputs.govulncheck_path }} + persist-credentials: false + - name: Install verified artifacts shell: bash env: @@ -91,6 +103,23 @@ runs: GOSUMDB: sum.golang.org run: bash "$GITHUB_ACTION_PATH/locked-tools.sh" gon + - name: Build locked govulncheck source + if: steps.prepare.outputs.govulncheck_ref != '' + shell: bash + env: + LOCKED_TOOLS: ${{ steps.prepare.outputs.tools }} + TOOL_LOCK_FILE: ${{ steps.prepare.outputs.lock_file }} + LOCK_PATH: ${{ steps.prepare.outputs.lock_path }} + EXPECTED_GO_VERSION: ${{ steps.prepare.outputs.go_version }} + LOCKED_TOOLS_DEST: ${{ steps.prepare.outputs.dest }} + EXPECTED_LOCK_SHA256: ${{ steps.prepare.outputs.lock_sha256 }} + LOCKED_GOVULNCHECK_PATH: ${{ steps.prepare.outputs.govulncheck_path }} + GOTOOLCHAIN: local + GOFLAGS: -mod=readonly + GOPROXY: https://proxy.golang.org + GOSUMDB: sum.golang.org + run: bash "$GITHUB_ACTION_PATH/locked-tools.sh" govulncheck + - name: Report installed tool versions and lock hash id: result shell: bash diff --git a/.github/actions/locked-tools/govulncheck-source.sh b/.github/actions/locked-tools/govulncheck-source.sh new file mode 100755 index 0000000..4b4f85f --- /dev/null +++ b/.github/actions/locked-tools/govulncheck-source.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build govulncheck from an immutable source commit with the locked Go toolchain. +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +lock="${TOOL_LOCK_FILE:-$script_dir/tool-locks.txt}" +commit="$(awk '$1 == "govulncheck" && $3 == "linux/amd64" && $4 == "source" && NF == 4 { print $2; count++ } END { if (count != 1) exit 1 }' "$lock")" +if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then + echo '::error title=locked-tools::Expected a single immutable govulncheck source commit lock' >&2 + exit 1 +fi +case "${1:-}" in + ref) + printf 'ref=%s\n' "$commit" >> "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" + ;; + build) + if [ "$#" -ne 3 ]; then + echo 'Usage: govulncheck-source.sh build ' >&2 + exit 1 + fi + source_dir="$2" + dest="$3" + if [ "$(git -C "$source_dir" rev-parse HEAD)" != "$commit" ]; then + echo '::error title=locked-tools::govulncheck checkout does not match locked source commit' >&2 + exit 1 + fi + # Require the verified Go binary and checksum-authenticated module downloads. + if [ "${GOFLAGS:-}" != -mod=readonly ] || [ "${GOPROXY:-}" != https://proxy.golang.org ] || [ "${GOSUMDB:-}" != sum.golang.org ]; then + echo '::error title=locked-tools::govulncheck requires readonly modules, proxy.golang.org, and sum.golang.org' >&2 + exit 1 + fi + bash "$script_dir/verify-locked-go.sh" version + bash "$script_dir/verify-locked-go.sh" module "$source_dir/go.mod" + mkdir -p "$dest" + dest="$(cd "$dest" && pwd)" + (cd "$source_dir" && GOENV=off GOWORK=off GOPRIVATE='' GONOPROXY='' GONOSUMDB='' GOINSECURE='' \ + go build -trimpath -o "$dest/govulncheck" ./cmd/govulncheck) + if [ ! -x "$dest/govulncheck" ]; then + echo '::error title=locked-tools::govulncheck source build did not produce an executable' >&2 + exit 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$dest/govulncheck" + else + shasum -a 256 "$dest/govulncheck" + fi + printf '%s\n' "$dest" >> "${GITHUB_PATH:?GITHUB_PATH must be set}" + ;; + *) echo 'Usage: govulncheck-source.sh ref | build ' >&2; exit 1 ;; +esac diff --git a/.github/actions/locked-tools/locked-tools.sh b/.github/actions/locked-tools/locked-tools.sh index 1162721..3bf2ee2 100755 --- a/.github/actions/locked-tools/locked-tools.sh +++ b/.github/actions/locked-tools/locked-tools.sh @@ -4,7 +4,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fail() { echo "::error title=locked-tools::$*" >&2; exit 1; } phase="${1:-}" -case "$phase" in prepare|install|gon|outputs) ;; *) fail 'Unknown locked-tools phase' ;; esac +case "$phase" in prepare|install|gon|govulncheck|outputs) ;; *) fail 'Unknown locked-tools phase' ;; esac request="${LOCKED_TOOLS:-}" if [[ -z "$request" || "$request" == *$'\n'* || "$request" == *$'\r'* ]]; then fail 'tools must be a nonempty comma-separated list' @@ -20,15 +20,15 @@ for tool in "${requested[@]}"; do tool="${tool#"${tool%%[![:space:]]*}"}" tool="${tool%"${tool##*[![:space:]]}"}" case "$tool" in - go|node|syft|goreleaser|goreleaser-pro|minisign|gon) ;; + go|node|syft|goreleaser|goreleaser-pro|minisign|gon|govulncheck) ;; *) fail "Unsupported tool: $tool" ;; esac [[ "$seen" != *",$tool,"* ]] || fail "Duplicate tool: $tool" seen+="$tool," tools+=("$tool") - if [ "$tool" = go ] || [ "$tool" = gon ]; then needs_go=true; fi + if [ "$tool" = go ] || [ "$tool" = gon ] || [ "$tool" = govulncheck ]; then needs_go=true; fi done -# Go must precede every source build, even when gon is the only requested tool. +# Go must precede every source build, even when a source-built tool is the only request. if [ "$needs_go" = true ]; then ordered=(go) for tool in "${tools[@]}"; do @@ -74,13 +74,18 @@ lock_row() { awk -v tool="$tool" -v platform="$platform" '$1 == tool && $3 == platform { print; count++ } END { if (count != 1) exit 1 }' "$lock" || fail "Expected exactly one $tool lock for $platform" } gon_ref= +govulncheck_ref= for tool in "${tools[@]}"; do row="$(lock_row "$tool")" read -r _ version _ checksum url extra <<< "$row" [[ "$version" =~ ^[a-zA-Z0-9][a-zA-Z0-9.+_-]*$ ]] || fail "Invalid locked version for $tool" - if [ "$tool" = gon ]; then - [[ "$version" =~ ^[0-9a-f]{40}$ && "$checksum" = source && -z "$url" && -z "$extra" ]] || fail 'Invalid gon source lock' - gon_ref="$version" + if [ "$tool" = gon ] || [ "$tool" = govulncheck ]; then + [[ "$version" =~ ^[0-9a-f]{40}$ && "$checksum" = source && -z "$url" && -z "$extra" ]] || fail "Invalid $tool source lock" + if [ "$tool" = gon ]; then + gon_ref="$version" + else + govulncheck_ref="$version" + fi else [[ "$checksum" =~ ^[0-9a-f]{64}$ && -n "$url" && -z "$extra" ]] || fail "Invalid artifact lock for $tool" fi @@ -119,15 +124,21 @@ case "$phase" in source_dir="$(mktemp -d "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE must be set}/.locked-tools-gon.XXXXXX")" gon_path="$(basename "$source_dir")" fi + govulncheck_path= + if [ -n "$govulncheck_ref" ]; then + source_dir="$(mktemp -d "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE must be set}/.locked-tools-govulncheck.XXXXXX")" + govulncheck_path="$(basename "$source_dir")" + fi { printf 'tools=%s\nlock_file=%s\ndest=%s\nlock_sha256=%s\n' "$normalized" "$lock" "$dest" "$lock_sha256" printf 'lock_path=%s\ngo_version=%s\n' "$lock_path" "$expected_go_version" printf 'gon_ref=%s\ngon_path=%s\n' "$gon_ref" "$gon_path" + printf 'govulncheck_ref=%s\ngovulncheck_path=%s\n' "$govulncheck_ref" "$govulncheck_path" } >> "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" ;; install) for tool in "${tools[@]}"; do - [ "$tool" != gon ] || continue + if [ "$tool" = gon ] || [ "$tool" = govulncheck ]; then continue; fi target="$dest/$tool" if [ "$tool" = go ]; then target="$dest"; fi if [ "$os" = windows ]; then @@ -157,6 +168,15 @@ case "$phase" in # The action owns this unique checkout. Do not leave it in caller source trees. rm -rf -- "$source_dir" ;; + govulncheck) + [ -n "$govulncheck_ref" ] || fail 'govulncheck was not requested' + govulncheck_path="${LOCKED_GOVULNCHECK_PATH:-}" + [[ "$govulncheck_path" =~ ^\.locked-tools-govulncheck\.[a-zA-Z0-9]+$ ]] || fail 'Invalid temporary govulncheck checkout path' + source_dir="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE must be set}/$govulncheck_path" + bash "$script_dir/govulncheck-source.sh" build "$source_dir" "$dest/govulncheck" + # The action owns this unique checkout. Do not leave it in caller source trees. + rm -rf -- "$source_dir" + ;; outputs) versions='{' separator= diff --git a/.github/actions/locked-tools/tool-locks.txt b/.github/actions/locked-tools/tool-locks.txt index 0e364da..5cd0fbc 100644 --- a/.github/actions/locked-tools/tool-locks.txt +++ b/.github/actions/locked-tools/tool-locks.txt @@ -7,6 +7,8 @@ goreleaser v2.13.3 darwin/arm64 5516c37779efb3935d5b213cda3b0b9025ae94ddbcb51df6 goreleaser-pro v2.13.3 windows/amd64 8f65fca431ae6aa083805c9287d4b98b7937d54649712f1185b0590ae02001de https://github.com/goreleaser/goreleaser-pro/releases/download/v2.13.3/goreleaser-pro_Windows_x86_64.zip # gon-release v0.2.10 gon f4c3f2be10a2ee8d50067d2c8a8828a14c83977c darwin/* source +# govulncheck-release v1.7.0 +govulncheck 617f44b718537dccdea1915395650e0529e3b72e linux/amd64 source syft v1.42.3 linux/amd64 0d6be741479eddd2c8644a288990c04f3df0d609bbc1599a005532a9dff63509 https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_linux_amd64.tar.gz syft v1.42.3 darwin/arm64 d71ee7db2be0fe2e96f679fd9d69ef04274cc86c8604707797080a21070b3f32 https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_darwin_arm64.tar.gz syft v1.42.3 windows/amd64 e1b9f4945aa64c2b34970bec617623d7f803d0661b48a50b966768b363322e2d https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_windows_amd64.zip diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8c9ceb7..d17eb18 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -289,7 +289,7 @@ jobs: if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true uses: ./_workflows/.github/actions/locked-tools with: - tools: go + tools: ${{ inputs.go_vulnerability_scan == true && 'go,govulncheck' || 'go' }} - name: Checkout caller repo if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true @@ -382,13 +382,8 @@ jobs: shell: bash run: | set -euo pipefail - go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 - # locked-tools does not put GOBIN or GOPATH/bin on PATH. - gopath="$(go env GOPATH)" - bin="$(go env GOBIN)" - bin="${bin:-${gopath%%:*}/bin}" for goos in linux darwin windows; do - GOOS="$goos" "$bin/govulncheck" ./... + GOOS="$goos" govulncheck ./... done goreleaser-binaries: diff --git a/docs/release-workflow.md b/docs/release-workflow.md index ed15f5a..f2279c2 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -47,7 +47,7 @@ on `determine-workflows-ref`. No job dependencies or permissions change. Runs opted-in source integrity checks against the exact tagged caller source before any build job: - `go_source_hygiene` runs `go generate ./...` and `go mod tidy`, then rejects a changed tree. -- `go_vulnerability_scan` runs `govulncheck` for Linux, macOS, and Windows. +- `go_vulnerability_scan` runs govulncheck for Linux, macOS, and Windows. The scanner is built from its locked source commit with the verified Go toolchain before caller checkout and release credentials. `verify_module_version` checks a generated binary after GoReleaser and before provenance or artifact upload. It requires the embedded Go module version to equal the release tag. @@ -260,7 +260,7 @@ the guard but no locked-tools installation. | Tool-using job | `locked-tools` input | | --- | --- | -| release-preflight | `go` | +| release-preflight | `go`, plus `govulncheck` when `go_vulnerability_scan` is enabled | | goreleaser-binaries | `go,gon,syft,goreleaser` | | goreleaser-windows | `go,syft,goreleaser-pro` | | goreleaser-docker | `go,goreleaser` | @@ -274,7 +274,8 @@ optional checks does not download a Go toolchain. Tool-using jobs set `GOTOOLCHAIN=local` at job scope. Caller builds do not override `GOFLAGS`, `GOPROXY`, or `GOSUMDB` at workflow/job scope, preserving Go's default vendor/readonly mode selection and caller proxy configuration. The trusted gon -build retains its separate step-local module and checksum requirements. +and govulncheck source builds retain their separate step-local module and +checksum requirements. Cosign and bundled-buildx/QEMU setup run after trust/tool bootstrap and before their signing or AWS credentials. @@ -284,8 +285,8 @@ their signing or AWS credentials. URLs and committed SHA256 values. The action-local Bash and PowerShell installers fetch the selected URL, verify its hash **before extraction**, and check the expected executable leaf before publishing PATH. They do not execute downloaded -binaries during extraction. The action's Go assertion and native gon build run -after verified installation. Release credentials are configured only after +binaries during extraction. The action's Go assertion and native gon/govulncheck +builds run after verified installation. Release credentials are configured only after tool installation and checks. Helper code checkouts use `job.workflow_sha`. Artifact downloads use five retries with a two-second delay; curl retries all @@ -321,10 +322,11 @@ composite action accepts a comma-separated `tools` list, an optional reviewed `locks` file (default: the action's canonical lock), and `dest` (default: `${{ runner.temp }}/locked-tools`). Invoke it before configuring release credentials. Supported tools are `go`, `node`, `syft`, `goreleaser`, -`goreleaser-pro`, `gon`, and `minisign`; missing platform locks and unknown or -duplicate tool names fail closed. Requesting `gon` automatically adds Go, -installs/verifies it first, then builds the SHA-checked-out gon source with the -existing readonly-module/proxy/checksum-database constraints. +`goreleaser-pro`, `gon`, `govulncheck`, and `minisign`; missing platform locks +and unknown or duplicate tool names fail closed. Requesting `gon` or +`govulncheck` automatically adds Go, installs/verifies it first, then builds the +SHA-checked-out source with the existing readonly-module/proxy/checksum-database +constraints. Govulncheck source builds currently have a Linux amd64 lock. Node is supported on Linux amd64, macOS arm64, and Windows amd64. The Windows installer verifies the ZIP hash before extraction, checks the nested `node-v26.12.1-win-x64/node.exe` leaf, and publishes that package directory to PATH. @@ -333,8 +335,9 @@ An empty `locks` input selects the action-local canonical lock inside prepare. `dest` keeps its `runner.temp` metadata default: that context is available in the caller on Linux, macOS, and Windows. -Outputs include each installed tool's version (`gon` reports its source commit), -a `versions` JSON object, `lock_sha256`, and absolute `lock_path` for provenance. +Outputs include each installed tool's version (`gon` and `govulncheck` report +their source commits), a `versions` JSON object, `lock_sha256`, and absolute +`lock_path` for provenance. The path identifies the action-local canonical lock by default, or the actual reviewed override used. The action verifies that lock bytes have not changed between preparation, installation, and output publication; internal Go gates @@ -353,6 +356,7 @@ wrappers under `scripts/` are not supported. | goreleaser v2.13.3 | darwin/arm64 | `5516c37779efb3935d5b213cda3b0b9025ae94ddbcb51df6919acbcdef4194b0` | [Universal Darwin archive](https://github.com/goreleaser/goreleaser/releases/download/v2.13.3/goreleaser_Darwin_all.tar.gz) | | goreleaser-pro v2.13.3 | windows/amd64 | `8f65fca431ae6aa083805c9287d4b98b7937d54649712f1185b0590ae02001de` | [Windows archive](https://github.com/goreleaser/goreleaser-pro/releases/download/v2.13.3/goreleaser-pro_Windows_x86_64.zip) | | gon v0.2.10 | darwin/* | `f4c3f2be10a2ee8d50067d2c8a8828a14c83977c` (Git commit, not SHA256) | [Pinned source](https://github.com/ConductorOne/gon/commit/f4c3f2be10a2ee8d50067d2c8a8828a14c83977c) | +| govulncheck v1.7.0 | linux/amd64 | `617f44b718537dccdea1915395650e0529e3b72e` (Git commit, not SHA256) | [Pinned source](https://github.com/golang/vuln/commit/617f44b718537dccdea1915395650e0529e3b72e) | | syft v1.42.3 | linux/amd64 | `0d6be741479eddd2c8644a288990c04f3df0d609bbc1599a005532a9dff63509` | [Linux archive](https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_linux_amd64.tar.gz) | | syft v1.42.3 | darwin/arm64 | `d71ee7db2be0fe2e96f679fd9d69ef04274cc86c8604707797080a21070b3f32` | [Darwin archive](https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_darwin_arm64.tar.gz) | | syft v1.42.3 | windows/amd64 | `e1b9f4945aa64c2b34970bec617623d7f803d0661b48a50b966768b363322e2d` | [Windows archive](https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_windows_amd64.zip) | @@ -365,10 +369,12 @@ wrappers under `scripts/` are not supported. | node v26.12.1 | windows/amd64 | `b3efd5c70cba3308ed46e7e699a9b8aac075bbc6b027fcb39c84c31acc25e2b4` | [Windows package](https://nodejs.org/dist/v26.12.1/node-v26.12.1-win-x64.zip) | | minisign 0.12 | darwin/arm64 | `89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63` | [macOS ZIP](https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip) | -Gon builds natively from a pinned source commit with the verified Go toolchain. -The action checks out the exact source object, checks its Go requirement, and -requires `GOFLAGS=-mod=readonly`, `GOPROXY=https://proxy.golang.org` and -`GOSUMDB=sum.golang.org`. It records the resulting binary's SHA256. +Gon and govulncheck build natively from pinned source commits with the verified +Go toolchain. The action checks out the exact source object, checks its Go +requirement, and requires `GOFLAGS=-mod=readonly`, +`GOPROXY=https://proxy.golang.org` and `GOSUMDB=sum.golang.org`. It records each +resulting binary's SHA256. Govulncheck builds also disable Go environment files +and workspace discovery and clear private/insecure module overrides. Cosign v2.5.2 is installed by `sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac`. diff --git a/renovate.json b/renovate.json index 75198ba..79cc963 100644 --- a/renovate.json +++ b/renovate.json @@ -55,6 +55,13 @@ "depNameTemplate": "ConductorOne/gon", "datasourceTemplate": "github-tags" }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/actions/locked-tools/tool-locks\\.txt$/"], + "matchStrings": ["(?:^|\\n)# govulncheck-release (?v[^ \\n]+)\\ngovulncheck (?[a-f0-9]{40}) linux/amd64 source"], + "depNameTemplate": "golang/vuln", + "datasourceTemplate": "github-tags" + }, { "customType": "regex", "managerFilePatterns": ["/^\\.github/actions/locked-tools/tool-locks\\.txt$/"], @@ -104,7 +111,7 @@ "matchManagers": ["custom.regex"], "matchDatasources": ["github-releases", "github-tags", "golang-version"], "draftPR": true, - "prBodyNotes": ["This is a version proposal, NOT a completed lock refresh. For binary tools, update every affected platform URL and committed SHA-256 in .github/actions/locked-tools/tool-locks.txt from reviewed upstream release assets. For source-built gon, review the release tag and locked commit and rerun the source build. Refresh the download table and run installer negative controls and the release canary before marking ready. Never merge a version-only lock change."] + "prBodyNotes": ["This is a version proposal, NOT a completed lock refresh. For binary tools, update every affected platform URL and committed SHA-256 in .github/actions/locked-tools/tool-locks.txt from reviewed upstream release assets. For source-built gon/govulncheck, review the release tag and locked commit and rerun the source build. Refresh the download table and run installer negative controls and the release canary before marking ready. Never merge a version-only lock change."] } ] } diff --git a/scripts/test-check-image-locks.py b/scripts/test-check-image-locks.py index 2bfc16b..2e34392 100755 --- a/scripts/test-check-image-locks.py +++ b/scripts/test-check-image-locks.py @@ -130,7 +130,7 @@ def test_helper_input_and_driver_assignment_are_discovered(self): def test_binary_tool_proposals_preserve_go_prefix(self): for manager in self.config["customManagers"]: dep = manager.get("depNameTemplate") - if not dep or dep == "ConductorOne/gon": + if not dep or dep in {"ConductorOne/gon", "golang/vuln"}: continue tool = {"anchore/syft": "syft", "go": "go"}.get(dep, dep.split("/")[-1]) version = "go9.8.7" if tool == "go" else "v9.8.7" @@ -152,12 +152,13 @@ def test_tool_lock_managers_target_the_action_lock(self): "goreleaser/goreleaser": "github-releases", "goreleaser/goreleaser-pro": "github-releases", "ConductorOne/gon": "github-tags", + "golang/vuln": "github-tags", "anchore/syft": "github-releases", "go": "golang-version", "nodejs/node": "github-releases", "jedisct1/minisign": "github-releases", } - self.assertEqual(len(managers), 7) + self.assertEqual(len(managers), len(expected_sources)) self.assertEqual({manager["depNameTemplate"] for manager in managers}, set(expected_sources)) self.assertFalse(self.config["automerge"]) for manager in managers: diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index f25dc7d..200cda4 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -35,7 +35,7 @@ def setUp(self) -> None: self.scripts.mkdir() self.action_dir = self.root / ".github/actions/locked-tools" self.action_dir.mkdir(parents=True) - for name in ("install-tool.sh", "verify-locked-go.sh", "gon-source.sh"): + for name in ("install-tool.sh", "verify-locked-go.sh", "gon-source.sh", "govulncheck-source.sh"): shutil.copyfile(ACTION / name, self.action_dir / name) shutil.copyfile(ACTION / "locked-tools.sh", self.action_dir / "locked-tools.sh") shutil.copyfile(ACTION / "install-tool.ps1", self.action_dir / "install-tool.ps1") @@ -394,6 +394,37 @@ def test_gon_source_gates_and_build(self) -> None: self.run_script("gon-source.sh", "build", self.repo, self.root / "new-gon", ok=False) self.assertFalse(self.calls.exists(), "mismatched gon commit must fail before invoking Go") + def test_govulncheck_source_gates_and_build(self) -> None: + commit = self.init_repo() + text = self.lock.read_text() + old_commit = next(line.split()[1] for line in text.splitlines() if line.startswith("govulncheck ")) + self.lock.write_text(text.replace(old_commit, commit)) + self.go_contract() + self.run_script("govulncheck-source.sh", "ref") + self.assertEqual(self.output_file.read_text(), f"ref={commit}\n") + self.env.update(GOFLAGS="-mod=readonly", GOPROXY="https://proxy.golang.org", GOSUMDB="sum.golang.org") + self.executable("go", 'echo "go $*" >> "$CALLS"\ncase "$1" in version) echo "go version go1.27.3 linux/amd64";; list) awk \'$1 == "go" { print $2 }\' go.mod;; build) [ "$PWD" = "'+str(self.root)+'/repo" ]; [ "$1 $2 $3" = "build -trimpath -o" ]; [ "$5" = ./cmd/govulncheck ]; [ "$GOENV" = off ] && [ "$GOWORK" = off ] && [ -z "$GOPRIVATE$GONOPROXY$GONOSUMDB$GOINSECURE" ]; printf fixture > "$4"; chmod +x "$4";; *) exit 99;; esac\n') + module = self.repo / "go.mod" + module.write_text("module fixture\ngo 1.13\n") + (self.repo / "cmd/govulncheck").mkdir(parents=True) + dest = self.root / "govulncheck-bin" + self.run_script("govulncheck-source.sh", "build", self.repo, dest) + self.assertTrue((dest / "govulncheck").is_file()) + self.assertTrue(os.access(dest / "govulncheck", os.X_OK)) + self.assertEqual(self.path_file.read_text(), f"{dest}\n") + self.calls.unlink() + module.write_text("module fixture\ngo 1.28\n") + self.run_script("govulncheck-source.sh", "build", self.repo, self.root / "new-govulncheck", ok=False) + self.assertNotIn("build", self.calls.read_text()) + self.env["GOSUMDB"] = "off" + self.calls.unlink() + self.run_script("govulncheck-source.sh", "build", self.repo, self.root / "new-govulncheck", ok=False) + self.assertFalse(self.calls.exists()) + self.env["GOSUMDB"] = "sum.golang.org" + self.git("commit", "--allow-empty", "-qm", "changed source") + self.run_script("govulncheck-source.sh", "build", self.repo, self.root / "new-govulncheck", ok=False) + self.assertFalse(self.calls.exists(), "mismatched govulncheck commit must fail before invoking Go") + def test_bundled_buildx_fails_without_downloading(self) -> None: self.executable("docker", 'echo "$*" >> "$CALLS"\nif [ "${FAIL_BUILDX:-}" = 1 ] && [ "$*" = "buildx version" ]; then exit 1; fi\n') self.run_script("setup-release-buildx.sh") @@ -610,6 +641,29 @@ def test_action_gon_automatically_adds_go_and_rejects_bad_requests(self) -> None self.run_script("locked-tools.sh", "prepare", ok=False) self.assertFalse(self.calls.exists(), "preparation must not download or run tools") + def test_action_govulncheck_automatically_adds_go_and_prepares_source(self) -> None: + self.executable("uname", 'case "$1" in -s) echo "$TEST_OS";; -m) echo "$TEST_ARCH";; esac\n') + self.env.update(TEST_ARCH="x86_64", LOCKED_TOOLS="govulncheck", LOCKS_INPUT="", ACTION_DIR=str(self.action_dir)) + self.run_script("locked-tools.sh", "prepare") + plan = self.phase_outputs() + locked_commit = next(line.split()[1] for line in self.lock.read_text().splitlines() + if line.startswith("govulncheck ")) + self.assertEqual(plan["tools"], "go,govulncheck") + self.assertEqual(plan["govulncheck_ref"], locked_commit) + self.assertTrue((self.workspace / plan["govulncheck_path"]).is_dir()) + self.assertEqual(plan["gon_ref"], "") + self.assertFalse(self.calls.exists(), "preparation must not download or run tools") + self.env.update(LOCKED_TOOLS=plan["tools"], TOOL_LOCK_FILE=plan["lock_file"], + LOCK_PATH=plan["lock_path"], EXPECTED_GO_VERSION=plan["go_version"], + EXPECTED_LOCK_SHA256=plan["lock_sha256"]) + self.output_file.unlink() + self.run_script("locked-tools.sh", "outputs") + outputs = self.phase_outputs() + self.assertEqual(outputs["govulncheck"], plan["govulncheck_ref"]) + self.assertEqual(json.loads(outputs["versions"]), { + "go": plan["go_version"], "govulncheck": plan["govulncheck_ref"], + }) + def test_composite_metadata_and_single_canonical_lock(self) -> None: action = json.loads(subprocess.check_output(["yq", ".", str(ACTION / "action.yml")], text=True)) self.assertEqual(action["runs"]["using"], "composite") @@ -625,14 +679,19 @@ def test_composite_metadata_and_single_canonical_lock(self) -> None: self.assertIn("$GITHUB_ACTION_PATH/locked-tools.sh", step["run"]) else: self.assertRegex(step["uses"], r"@[0-9a-f]{40}$") - self.assertEqual(step["with"]["ref"], "${{ steps.prepare.outputs.gon_ref }}") + repository = step["with"]["repository"] + expected_refs = { + "ConductorOne/gon": "${{ steps.prepare.outputs.gon_ref }}", + "golang/vuln": "${{ steps.prepare.outputs.govulncheck_ref }}", + } + self.assertEqual(step["with"]["ref"], expected_refs[repository]) self.assertFalse(step["with"]["persist-credentials"]) - for tool in ("go", "node", "syft", "goreleaser", "goreleaser-pro", "gon", "minisign", "versions", "lock_sha256", "lock_path"): + for tool in ("go", "node", "syft", "goreleaser", "goreleaser-pro", "gon", "govulncheck", "minisign", "versions", "lock_sha256", "lock_path"): self.assertIn(tool, action["outputs"]) self.assertEqual(action["outputs"]["lock_path"]["value"], "${{ steps.result.outputs.lock_path }}") for step in action["runs"]["steps"]: if step.get("name") in ("Install verified artifacts", "Build locked gon source", - "Report installed tool versions and lock hash"): + "Build locked govulncheck source", "Report installed tool versions and lock hash"): self.assertEqual(step["env"]["LOCK_PATH"], "${{ steps.prepare.outputs.lock_path }}") self.assertEqual(step["env"]["EXPECTED_GO_VERSION"], "${{ steps.prepare.outputs.go_version }}") @@ -656,7 +715,7 @@ def test_workflow_security_wiring(self) -> None: expected_tools = { "validate-inputs": "", "determine-workflows-ref": "", - "release-preflight": "go", + "release-preflight": "${{ inputs.go_vulnerability_scan == true && 'go,govulncheck' || 'go' }}", "goreleaser-binaries": "go,gon,syft,goreleaser", "goreleaser-windows": "go,syft,goreleaser-pro", "goreleaser-docker": "go,goreleaser", @@ -666,6 +725,12 @@ def test_workflow_security_wiring(self) -> None: "notify-release-failure": "", } self.assertEqual(set(jobs), set(expected_tools)) + scan = next(step for step in jobs["release-preflight"]["steps"] + if step.get("name") == "Check for known vulnerabilities") + self.assertEqual(scan["if"], "inputs.go_vulnerability_scan == true") + self.assertIn('GOOS="$goos" govulncheck ./...', scan["run"]) + self.assertNotIn("go install", scan["run"]) + self.assertNotIn("go env", scan["run"]) checkout_count = guard_count = tool_count = gate_count = 0 for job_name, job in jobs.items(): with self.subTest(job=job_name): @@ -763,7 +828,7 @@ def test_workflow_security_wiring(self) -> None: ids = [step["id"] for step in steps if "id" in step] self.assertEqual(len(ids), len(set(ids))) self.assertEqual((checkout_count, guard_count, tool_count, gate_count), (9, 9, 6, 6)) - for wrapper in ("install-tool.sh", "install-tool.ps1", "gon-source.sh", "verify-locked-go.sh"): + for wrapper in ("install-tool.sh", "install-tool.ps1", "gon-source.sh", "govulncheck-source.sh", "verify-locked-go.sh"): self.assertFalse((ROOT / "scripts" / wrapper).exists())