From 6ce8b9ff1846d55002ee9345fbd9c89df0e27d83 Mon Sep 17 00:00:00 2001 From: Josh Siviour Date: Sun, 2 Aug 2026 08:15:16 +1000 Subject: [PATCH] fix: fail preflight when a gate tool is killed instead of reporting it clean (#633) A tool whose child dies by signal can still exit 0 through go tool, so govulncheck printed 'ok' after being OOM-killed and preflight passed without a vulnerability scan having run. Route lint, govulncheck and nilaway through scripts/run-gate.sh, which fails on a non-zero exit or on signal-death output. --- Makefile | 6 +- internal/preflightgate/rungate_test.go | 91 ++++++++++++++++++++++++++ scripts/run-gate.sh | 35 ++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 internal/preflightgate/rungate_test.go create mode 100755 scripts/run-gate.sh diff --git a/Makefile b/Makefile index 7f9726be9..0b190d923 100644 --- a/Makefile +++ b/Makefile @@ -259,7 +259,7 @@ quickinstall: install-system install-go install-aws lint: @echo "Running golangci-lint..." - $(_Q)golangci-lint run ./... + $(_Q)scripts/run-gate.sh golangci-lint golangci-lint run ./... @echo " golangci-lint ok" fix: @@ -267,13 +267,13 @@ fix: govulncheck: @echo "Running govulncheck..." - $(_Q)go tool govulncheck ./... + $(_Q)scripts/run-gate.sh govulncheck go tool govulncheck ./... @echo " govulncheck ok" # NilAway — advisory nil-panic analysis. Not in preflight due to false positives nilaway: @echo "Running nilaway..." - $(_Q)go tool nilaway -include-pkgs=github.com/mulgadc/spinifex -exclude-test-files ./... + $(_Q)scripts/run-gate.sh nilaway go tool nilaway -include-pkgs=github.com/mulgadc/spinifex -exclude-test-files ./... @echo " nilaway ok" # Build release tarballs — use distro-ARCH for single arch, distro for both diff --git a/internal/preflightgate/rungate_test.go b/internal/preflightgate/rungate_test.go new file mode 100644 index 000000000..4bc1500b4 --- /dev/null +++ b/internal/preflightgate/rungate_test.go @@ -0,0 +1,91 @@ +// Package preflightgate tests the preflight gate wrapper. It exists as a Go +// test so `go test ./...` exercises it; the repo's shell suites are not yet +// wired into CI. +package preflightgate + +import ( + "errors" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// scriptPath resolves scripts/run-gate.sh relative to this test file, so the +// test does not depend on the working directory. +func scriptPath(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "..", "..", "scripts", "run-gate.sh") +} + +func runGate(t *testing.T, args ...string) (string, int) { + t.Helper() + cmd := exec.Command(scriptPath(t), args...) + out, err := cmd.CombinedOutput() + if err == nil { + return string(out), 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return string(out), exitErr.ExitCode() + } + t.Fatalf("running run-gate.sh: %v", err) + return "", -1 +} + +func TestRunGate_CleanRunPasses(t *testing.T) { + out, code := runGate(t, "govulncheck", "echo", "No vulnerabilities found.") + if code != 0 { + t.Fatalf("expected exit 0 for a clean run, got %d\n%s", code, out) + } + if !strings.Contains(out, "No vulnerabilities found.") { + t.Errorf("tool output should be forwarded, got:\n%s", out) + } +} + +func TestRunGate_NonZeroExitFails(t *testing.T) { + out, code := runGate(t, "lint", "sh", "-c", "echo 3 issues.; exit 1") + if code == 0 { + t.Fatalf("expected failure for a non-zero tool exit, got 0\n%s", out) + } + if !strings.Contains(out, "lint FAILED") { + t.Errorf("failure should name the gate, got:\n%s", out) + } +} + +// The defect this guards: a signal-killed scanner exiting 0 was reported clean, +// so preflight passed without a vulnerability scan having run. +func TestRunGate_SignalKilledWithZeroExitFails(t *testing.T) { + for _, sig := range []string{"killed", "terminated", "aborted"} { + t.Run(sig, func(t *testing.T) { + out, code := runGate(t, "govulncheck", "sh", "-c", + "echo 'go tool govulncheck: signal: "+sig+"'; exit 0") + if code == 0 { + t.Fatalf("expected failure when the tool died by signal, got 0\n%s", out) + } + if !strings.Contains(out, "did NOT complete") { + t.Errorf("failure should say the gate did not complete, got:\n%s", out) + } + }) + } +} + +// "signal" appearing in ordinary findings must not fail an otherwise clean run. +func TestRunGate_SignalWordInOutputDoesNotFail(t *testing.T) { + out, code := runGate(t, "lint", "echo", "checked signal handling in 12 files") + if code != 0 { + t.Fatalf("expected exit 0, got %d\n%s", code, out) + } +} + +func TestRunGate_RequiresLabelAndCommand(t *testing.T) { + out, code := runGate(t, "govulncheck") + if code != 2 { + t.Fatalf("expected usage exit 2, got %d\n%s", code, out) + } +} diff --git a/scripts/run-gate.sh b/scripts/run-gate.sh new file mode 100755 index 000000000..0d880c4ce --- /dev/null +++ b/scripts/run-gate.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Runs a preflight gate and fails unless it actually completed. +# A tool whose child is killed by a signal can still exit 0 through `go tool`, +# which would let a security or lint gate pass without having run. +set -uo pipefail + +if [ "$#" -lt 2 ]; then + echo "usage: run-gate.sh