Skip to content
Merged
Show file tree
Hide file tree
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ Four gates a repository runs over itself from `task lint`, each failing with wha
- `go tool refs [-files 'AGENTS.md ...'] [-allow file]` fails on a repository path or a `task
<name>` that the prose an agent loads (AGENTS.md and CLAUDE.md, by default) names and the tree
does not have; `-allow` lists the ones named on purpose, `<file><TAB><reference> # why`.
- `go tool vulngate [govulncheck flags] ./...` runs govulncheck and fails on a vulnerability the
code calls and that has a fix, the standard library's included: the answer is the upgrade, and
for the standard library that is go.mod's `toolchain` line. One only imported, or with no fix,
is listed and passes. It asks vuln.go.dev for the database, so it needs the network.
- `go tool vulngate [govulncheck flags, each -flag=value] ./...` runs govulncheck and fails on a
vulnerability the code calls and that has a fix, the standard library's included: the answer is
the upgrade, and for the standard library that is go.mod's `toolchain` line. One only imported,
or with no fix, is listed and passes. It asks vuln.go.dev for the database, so it needs the
network. Each main package the patterns name is scanned in a process of its own, then what no
main imports: one call graph of everything was 11.5 GB on spin, the largest program alone 4.1.
138 changes: 126 additions & 12 deletions cmd/vulngate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// exit status is its own and no pipe in a Taskfile can eat it. The scan asks vuln.go.dev for the
// database: a lint that runs it needs the network.
//
// go tool vulngate [govulncheck flags] ./...
// go tool vulngate [govulncheck flags, each -flag=value] ./...
package main

import (
Expand All @@ -12,19 +12,44 @@ import (
"fmt"
"io"
"os"
"os/exec"
"slices"
"strings"

"golang.org/x/vuln/scan"

"github.com/spin-stack/go-tools/internal/vulngate"
)

// scanOne is the environment a vulngate runs one govulncheck under, as a process of its own.
const scanOne = "VULNGATE_SCAN"

func main() {
os.Exit(run(context.Background(), govulncheck, os.Args[1:], os.Stdout, os.Stderr)) // mutate-exempt: the process's own arguments, past its name; run is what the tests drive
if os.Getenv(scanOne) != "" { // mutate-exempt: the child's way in; govulncheck's own run, which TestEachScanIsAProcessOfItsOwn drives
os.Exit(child(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
}
os.Exit(run(context.Background(), ownProcess, goList, os.Args[1:], os.Stdout, os.Stderr)) // mutate-exempt: the process's own arguments, past its name; run is what the tests drive
}

// scanner is govulncheck asked for json: govulncheck in a build, a script in a test.
// scanner is govulncheck asked for json: a process of its own in a build, a script in a test.
type scanner func(ctx context.Context, args []string, stdout, stderr io.Writer) error

// ownProcess runs govulncheck as this binary again, so each run's memory goes when it does: run
// after run in one process, what one left was not given back before the next built its graph,
// and spin's ./... peaked at 7.9 GB where its largest program alone is 4.1 GB (2026-10-13).
func ownProcess(ctx context.Context, args []string, stdout, stderr io.Writer) error {
self, err := os.Executable()
if err != nil { // mutate-exempt: Linux answers from /proc/self/exe, which a running process has
return err
}
cmd := exec.CommandContext(ctx, self, args...)
cmd.Env = append(os.Environ(), scanOne+"=1")
cmd.Stdout, cmd.Stderr = stdout, stderr
return cmd.Run()
}

// govulncheck is one scan, as json: in json mode it exits 0 whatever it found, so an error is a
// scan that could not be made.
func govulncheck(ctx context.Context, args []string, stdout, stderr io.Writer) error {
cmd := scan.Command(ctx, append([]string{"-format", "json"}, args...)...)
cmd.Stdout, cmd.Stderr = stdout, stderr
Expand All @@ -34,22 +59,111 @@ func govulncheck(ctx context.Context, args []string, stdout, stderr io.Writer) e
return cmd.Wait()
}

// run answers the exit status: 0 clean, 1 something fails, 2 no scan to judge.
func run(ctx context.Context, scan scanner, args []string, stdout, stderr io.Writer) int {
var out bytes.Buffer
// In json mode govulncheck exits 0 whatever it found; an error here is one it could not scan.
if err := scan(ctx, args, &out, stderr); err != nil {
say(stderr, "vulngate: govulncheck:", err)
return 2
// child is the process ownProcess starts: one scan, its exit status 2 when it could not be made.
func child(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if err := govulncheck(ctx, args, stdout, stderr); err != nil {
say(stderr, "vulngate:", err)
return 2 // mutate-exempt: any status but 0 is the parent's error; which one, nothing reads
}
findings, err := vulngate.Read(&out)
return 0 // mutate-exempt: a scan that runs needs vuln.go.dev, which no test here reaches; task lint's own vulngate is that run
}

// golist is `go list` with args, its output's lines: go itself in a build, a test's answer in a
// test.
type golist func(ctx context.Context, args ...string) ([]string, error)

func goList(ctx context.Context, args ...string) ([]string, error) {
out, err := exec.CommandContext(ctx, "go", append([]string{"list"}, args...)...).Output() //nolint:gosec // go list of the patterns this tool's own caller named
if err != nil {
say(stderr, err)
return nil, fmt.Errorf("go list: %w", err)
}
return strings.Fields(string(out)), nil
}

// run answers the exit status: 0 clean, 1 something fails, 2 no scan to judge.
//
// Each program the patterns name is scanned on its own, and then whatever no program of them
// reaches: govulncheck builds one call graph of everything it is handed, and of spin's 266
// packages at once that was 11.5 GB where the largest program alone is 4.1 GB (measured
// 2026-10-13) - the union costs far more than its parts, and the OOM killer took it. What a
// program calls is in its own graph, so the findings are the same.
func run(ctx context.Context, scan scanner, list golist, args []string, stdout, stderr io.Writer) int {
runs, err := programs(ctx, list, args)
if err != nil {
say(stderr, "vulngate:", err)
return 2
}
var findings []vulngate.Finding
for _, r := range runs {
var out bytes.Buffer
// In json mode govulncheck exits 0 whatever it found; an error here is one it could not scan.
if err := scan(ctx, r, &out, stderr); err != nil {
say(stderr, "vulngate: govulncheck:", err)
return 2
}
f, err := vulngate.Read(&out)
if err != nil {
say(stderr, err)
return 2
}
findings = append(findings, f...)
}
return report(findings, stdout)
}

// programs are the govulncheck runs args make: its flags (each -flag=value) with one main package
// a run, then with the packages the patterns name that no main of them imports. With no
// patterns, the one run govulncheck makes of its default.
func programs(ctx context.Context, list golist, args []string) ([][]string, error) {
var flags, patterns, listFlags []string
for _, a := range args {
switch {
case strings.HasPrefix(a, "-tags="):
listFlags = append(listFlags, a)
flags = append(flags, a)
case strings.HasPrefix(a, "-"):
flags = append(flags, a)
default:
patterns = append(patterns, a)
}
}
if len(patterns) == 0 {
return [][]string{args}, nil
}
named, err := list(ctx, slices.Concat(listFlags, []string{"-f", "{{.ImportPath}}"}, patterns)...)
if err != nil {
return nil, err
}
mains, err := list(ctx, slices.Concat(listFlags, []string{"-f", "{{if eq .Name \"main\"}}{{.ImportPath}}{{end}}"}, patterns)...)
if err != nil {
return nil, err
}
var runs [][]string
reached := map[string]bool{}
if len(mains) > 0 {
deps, err := list(ctx, slices.Concat(listFlags, []string{"-deps", "-f", "{{.ImportPath}}"}, mains)...)
if err != nil {
return nil, err
}
for _, d := range deps {
reached[d] = true
}
}
for _, m := range mains {
runs = append(runs, slices.Concat(flags, []string{m}))
}
var rest []string
for _, p := range named {
if !reached[p] {
rest = append(rest, p)
}
}
if len(rest) > 0 {
runs = append(runs, slices.Concat(flags, rest))
}
return runs, nil
}

// report says what was found and answers the exit status: 1 when anything fails.
func report(findings []vulngate.Finding, w io.Writer) int {
failing, listed := vulngate.Judge(findings)
Expand Down
133 changes: 131 additions & 2 deletions cmd/vulngate/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import (
"context"
"errors"
"io"
"os"
"slices"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/spin-stack/go-tools/internal/vulngate"
)
Expand All @@ -17,7 +21,7 @@ import (
func TestAScanItCannotJudgeIsNotClean(t *testing.T) {
answering := func(answer string, err error) scanner {
return func(_ context.Context, args []string, stdout, _ io.Writer) error {
if len(args) != 1 || args[0] != "./..." {
if len(args) != 1 || args[0] != "m/cmd/app" {
t.Errorf("the scan was given %v", args)
}
_, _ = io.WriteString(stdout, answer)
Expand All @@ -35,9 +39,134 @@ func TestAScanItCannotJudgeIsNotClean(t *testing.T) {
} {
t.Run(name, func(t *testing.T) {
var out, errs bytes.Buffer
assert.Equal(t, tc.status, run(t.Context(), tc.scan, []string{"./..."}, &out, &errs))
assert.Equal(t, tc.status, run(t.Context(), tc.scan, oneProgram, []string{"./..."}, &out, &errs))
})
}
// A go list that fails is no scan either.
broken := func(context.Context, ...string) ([]string, error) { return nil, errors.New("no go.mod") }
var out, errs bytes.Buffer
assert.Equal(t, 2, run(t.Context(), answering(`{"config":{}}`, nil), broken, []string{"./..."}, &out, &errs))
}

// oneProgram is a module whose ./... is one main package and the library it imports.
func oneProgram(_ context.Context, args ...string) ([]string, error) {
switch {
case slices.Contains(args, "-deps"):
return []string{"m/cmd/app", "m/lib", "fmt"}, nil
case slices.ContainsFunc(args, func(a string) bool { return strings.Contains(a, `"main"`) }):
return []string{"m/cmd/app"}, nil
}
return []string{"m/cmd/app", "m/lib"}, nil
}

// lister answers go list as a module would: named is what the patterns name, mains which of
// them are main packages, deps what those import, each as go prints it.
func lister(t *testing.T, named, mains, deps []string, want []string) golist {
return func(_ context.Context, args ...string) ([]string, error) {
if tags := slices.IndexFunc(args, func(a string) bool { return strings.HasPrefix(a, "-tags=") }); want != nil && (tags < 0 || args[tags] != want[0]) {
t.Errorf("go list was asked %v, without %v", args, want)
}
switch {
case slices.Contains(args, "-deps"):
if !slices.Equal(args[len(args)-len(mains):], mains) {
t.Errorf("deps asked of %v, want of %v", args, mains)
}
return deps, nil
case slices.ContainsFunc(args, func(a string) bool { return strings.Contains(a, `"main"`) }):
return mains, nil
}
return named, nil
}
}

// Each main package is a run of its own, with the flags given, and what no main imports is one
// more; a module of libraries alone is one run of them, and no patterns is govulncheck's own.
func TestEachProgramIsScannedOnItsOwn(t *testing.T) {
for name, tc := range map[string]struct {
args []string
named, mains, deps []string
tags []string
runs [][]string
}{
"programs and what they import": {args: []string{"./..."},
named: []string{"m/cmd/a", "m/cmd/b", "m/lib"}, mains: []string{"m/cmd/a", "m/cmd/b"}, deps: []string{"m/cmd/a", "m/cmd/b", "m/lib", "fmt"},
runs: [][]string{{"m/cmd/a"}, {"m/cmd/b"}}},
"a library no program imports": {args: []string{"-show=traces", "./..."},
named: []string{"m/cmd/a", "m/lib", "m/hack/x"}, mains: []string{"m/cmd/a"}, deps: []string{"m/cmd/a", "m/lib"},
runs: [][]string{{"-show=traces", "m/cmd/a"}, {"-show=traces", "m/hack/x"}}},
"libraries alone": {args: []string{"./..."}, named: []string{"m/a", "m/b"},
runs: [][]string{{"m/a", "m/b"}}},
"build tags reach go list": {args: []string{"-tags=lane", "./..."}, tags: []string{"-tags=lane"},
named: []string{"m/cmd/a"}, mains: []string{"m/cmd/a"}, deps: []string{"m/cmd/a"},
runs: [][]string{{"-tags=lane", "m/cmd/a"}}},
"no patterns": {args: []string{"-show=traces"}, runs: [][]string{{"-show=traces"}}},
} {
t.Run(name, func(t *testing.T) {
runs, err := programs(t.Context(), lister(t, tc.named, tc.mains, tc.deps, tc.tags), tc.args)
require.NoError(t, err)
assert.Equal(t, tc.runs, runs)
})
}
// go list failing at any of its three questions is no answer.
for i := range 3 {
calls := 0
list := func(ctx context.Context, args ...string) ([]string, error) {
calls++
if calls == i+1 {
return nil, errors.New("go list: exit status 1")
}
return oneProgram(ctx, args...)
}
_, err := programs(t.Context(), list, []string{"./..."})
require.Error(t, err, "call %d", i+1)
}
}

// Every run's findings are judged together: one run's called and fixed vulnerability fails the
// whole, whichever program it is in.
func TestFindingsOfEveryRunAreJudged(t *testing.T) {
list := lister(t, []string{"m/cmd/a", "m/cmd/b"}, []string{"m/cmd/a", "m/cmd/b"}, []string{"m/cmd/a", "m/cmd/b"}, nil)
var scanned []string
scan := func(_ context.Context, args []string, stdout, _ io.Writer) error {
scanned = append(scanned, args...)
if args[0] == "m/cmd/b" {
_, _ = io.WriteString(stdout, `{"finding":{"osv":"GO-1","fixed_version":"v2","trace":[{"module":"m","package":"m/p","function":"F"}]}}`)
}
return nil
}
var out, errs bytes.Buffer
assert.Equal(t, 1, run(t.Context(), scan, list, []string{"./..."}, &out, &errs))
assert.Equal(t, []string{"m/cmd/a", "m/cmd/b"}, scanned)
}

// TestMain is the scan's child when this test binary is run again as one, as vulngate is.
func TestMain(m *testing.M) {
if os.Getenv(scanOne) != "" {
os.Exit(child(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
}
os.Exit(m.Run())
}

// A scan is this binary run again, as govulncheck: what govulncheck says reaches the caller, and
// a run it refuses is an error, not a clean answer. -version is one govulncheck refuses in json
// mode, in words only it says.
func TestEachScanIsAProcessOfItsOwn(t *testing.T) {
const says = "-show flag is not supported for json output"
var out, errs bytes.Buffer
require.Error(t, ownProcess(t.Context(), []string{"-version"}, &out, &errs))
assert.Contains(t, errs.String(), says)
errs.Reset()
assert.Equal(t, 2, child(t.Context(), []string{"-version"}, &out, &errs))
assert.Contains(t, errs.String(), says)
}

// goList is go's own: the package this test is in is a main package.
func TestGoListAnswersAsGoDoes(t *testing.T) {
mains, err := goList(t.Context(), "-f", `{{if eq .Name "main"}}{{.ImportPath}}{{end}}`, ".")
require.NoError(t, err)
assert.Equal(t, []string{"github.com/spin-stack/go-tools/cmd/vulngate"}, mains)
_, err = goList(t.Context(), "./no-such-package")
require.Error(t, err)
}

// The gate's exit status is 1 exactly when something the code calls has a fix, and it says what.
Expand Down