From 58c55a7ff3d0d2b8203632be57f12a7de21b07d6 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 13 Oct 2026 12:30:07 -0300 Subject: [PATCH] vulngate: each program scanned on its own, in a process of its own govulncheck builds one call graph of everything it is handed: spin's ./... at once peaked at 11.5 GB, and a plain govulncheck of it was OOM-killed during the gate, where its largest program alone is 4.1 GB. vulngate scans each main package the patterns name, then what no main imports, each as this binary run again so a run's memory goes with it, and judges every run's findings together: 4.2 GB and 46 s on spin. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 10 +-- cmd/vulngate/main.go | 138 ++++++++++++++++++++++++++++++++++---- cmd/vulngate/main_test.go | 133 +++++++++++++++++++++++++++++++++++- 3 files changed, 263 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ece7a1e..13f1790 100644 --- a/README.md +++ b/README.md @@ -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 ` 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, ` # 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. diff --git a/cmd/vulngate/main.go b/cmd/vulngate/main.go index b0271ec..8ecd12d 100644 --- a/cmd/vulngate/main.go +++ b/cmd/vulngate/main.go @@ -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 ( @@ -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 @@ -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) diff --git a/cmd/vulngate/main_test.go b/cmd/vulngate/main_test.go index 4c0d49f..27f5703 100644 --- a/cmd/vulngate/main_test.go +++ b/cmd/vulngate/main_test.go @@ -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" ) @@ -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) @@ -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.