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.