diff --git a/Dockerfile b/Dockerfile index f97aaee8..20d931f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ ARG GOLANGCI_LINT_VERSION=v2.12.2 # Update whenever GOLANGCI_LINT_VERSION changes. ARG GOLANGCI_LINT_SHA256=8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553 ARG GOSEC_VERSION=v2.22.8 +ARG GOVULNCHECK_VERSION=v1.1.4 ARG SEMGREP_VERSION=1.84.1 FROM golang:1.26.3-alpine3.23@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS builder @@ -10,6 +11,7 @@ FROM golang:1.26.3-alpine3.23@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a28 ARG GOLANGCI_LINT_VERSION ARG GOLANGCI_LINT_SHA256 ARG GOSEC_VERSION +ARG GOVULNCHECK_VERSION ARG SEMGREP_VERSION WORKDIR /go/src/github.com/grafana/plugin-validator @@ -41,6 +43,11 @@ RUN set -eux; \ RUN curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | \ sh -s -- -b /usr/local/bin ${GOSEC_VERSION} +# govulncheck is distributed as a Go module — install with `go install` rather +# than a binary tarball. Pinned version is fixed via the ARG above. +RUN go install golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION} && \ + mv "$(go env GOPATH)/bin/govulncheck" /usr/local/bin/govulncheck + RUN python3 -m pip install semgrep==${SEMGREP_VERSION} --ignore-installed --break-system-packages RUN mage -v build:lint @@ -52,12 +59,16 @@ FROM alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a50 ARG GOSEC_VERSION ARG SEMGREP_VERSION -RUN apk add --no-cache git ca-certificates curl wget python3 python3-dev py3-pip alpine-sdk clamav nodejs=24.14.1-r0 npm +# govulncheck source mode shells out to the Go command to load packages. +RUN apk add --no-cache git go ca-certificates curl wget python3 python3-dev py3-pip alpine-sdk clamav nodejs=24.14.1-r0 npm RUN update-ca-certificates RUN freshclam RUN curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b /usr/local/bin ${GOSEC_VERSION} +# govulncheck is built in the builder stage; copy the static binary in. +COPY --from=builder /usr/local/bin/govulncheck /usr/local/bin/govulncheck + # install semgrep RUN python3 -m pip install semgrep==${SEMGREP_VERSION} --ignore-installed --break-system-packages --no-cache-dir diff --git a/README.md b/README.md index 0cf0a771..495b878f 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ This validator makes uses of the following open source security tools: - [osv-scanner](https://github.com/google/osv-scanner) - [semgrep](https://github.com/returntocorp/semgrep) - [gosec](https://github.com/securego/gosec) +- [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) If you run the validator locally or via NPX you can benefit from installing these tools in your system to make them part of your validation checks. @@ -291,6 +292,7 @@ Run "mage gen:readme" to regenerate this section. | Discoverability / `discoverability` | Warns about missing keywords and description that are used for plugin indexing in the catalog. | None | | Go Manifest / `go-manifest` | Validates the build manifest. | None | | Go Security Checker / `go-sec` | Inspects source code for security problems by scanning the Go AST. | [gosec](https://github.com/securego/gosec), `sourceCodeUri` | +| Go Vulnerability Checker / `govulncheck` | Scans Go backend source and plugin backend binaries for known vulnerabilities (govulncheck). | [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck), `sourceCodeUri` for source scans | | JS Source Map / `jsMap` | Checks for required `module.js.map` file(s) in archive. | `sourceCodeUri` | | Legacy Grafana Toolkit usage / `legacybuilder` | Detects the usage of the not longer supported Grafana Toolkit. | None | | Legacy Platform / `legacyplatform` | Detects use of Angular which is deprecated. | None | diff --git a/pkg/analysis/passes/analysis.go b/pkg/analysis/passes/analysis.go index 0343c73b..bb7f3f4e 100644 --- a/pkg/analysis/passes/analysis.go +++ b/pkg/analysis/passes/analysis.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/plugin-validator/pkg/analysis/passes/discoverability" "github.com/grafana/plugin-validator/pkg/analysis/passes/gomanifest" "github.com/grafana/plugin-validator/pkg/analysis/passes/gosec" + "github.com/grafana/plugin-validator/pkg/analysis/passes/govulncheck" "github.com/grafana/plugin-validator/pkg/analysis/passes/grafanadependency" "github.com/grafana/plugin-validator/pkg/analysis/passes/includesnested" "github.com/grafana/plugin-validator/pkg/analysis/passes/jargon" @@ -71,6 +72,7 @@ var Analyzers = []*analysis.Analyzer{ discoverability.Analyzer, gomanifest.Analyzer, gosec.Analyzer, + govulncheck.Analyzer, includesnested.Analyzer, jargon.Analyzer, jssourcemap.Analyzer, diff --git a/pkg/analysis/passes/govulncheck/govulncheck.go b/pkg/analysis/passes/govulncheck/govulncheck.go new file mode 100644 index 00000000..d70bfd76 --- /dev/null +++ b/pkg/analysis/passes/govulncheck/govulncheck.go @@ -0,0 +1,431 @@ +package govulncheck + +import ( + "bytes" + "debug/buildinfo" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/analysis/passes/archive" + "github.com/grafana/plugin-validator/pkg/analysis/passes/nestedmetadata" + "github.com/grafana/plugin-validator/pkg/analysis/passes/sourcecode" + "github.com/grafana/plugin-validator/pkg/logme" +) + +var ( + govulncheckNotInstalled = &analysis.Rule{Name: "govulncheck-not-installed", Severity: analysis.Warning} + govulncheckScanFailed = &analysis.Rule{Name: "govulncheck-scan-failed", Severity: analysis.Error} + govulncheckIssueFound = &analysis.Rule{Name: "govulncheck-issue-found", Severity: analysis.Warning} + govulncheckNoIssuesFound = &analysis.Rule{Name: "govulncheck-no-issues-found", Severity: analysis.OK} +) + +var Analyzer = &analysis.Analyzer{ + Name: "govulncheck", + Requires: []*analysis.Analyzer{archive.Analyzer, nestedmetadata.Analyzer, sourcecode.Analyzer}, + Run: run, + Rules: []*analysis.Rule{govulncheckNotInstalled, govulncheckScanFailed, govulncheckIssueFound, govulncheckNoIssuesFound}, + ReadmeInfo: analysis.ReadmeInfo{ + Name: "Go Vulnerability Checker", + Description: "Scans Go backend source and plugin backend binaries for known vulnerabilities (govulncheck).", + Dependencies: "[govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck), `sourceCodeUri` for source scans", + }, +} + +func run(pass *analysis.Pass) (interface{}, error) { + govulncheckBin, err := exec.LookPath("govulncheck") + if err != nil { + logme.Debugln("govulncheck not installed, skipping govulncheck analysis") + if govulncheckNotInstalled.ReportAll { + pass.ReportResult( + pass.AnalyzerName, + govulncheckNotInstalled, + "govulncheck not installed", + "Skipping govulncheck analysis", + ) + } + return nil, nil + } + + scansPerformed := 0 + scanFailures := 0 + findingsReported := 0 + + // Source scan: mirrors gosec's pattern of silently skipping when source + // isn't provided. Scans whatever go.mod modules exist under the source + // tree without gating on backend status. + sourceCodeDir, ok := pass.ResultOf[sourcecode.Analyzer].(string) + if ok && sourceCodeDir != "" { + moduleDirs, err := goModuleDirs(sourceCodeDir) + if err != nil { + // Report as a diagnostic instead of returning an error: returning + // an error here aborts the whole validator and skips every other + // analyzer. Skip the source scan and continue with binary scans. + scanFailures++ + pass.ReportResult( + pass.AnalyzerName, + govulncheckScanFailed, + "govulncheck source scan failed", + scanFailureDetail(sourceCodeDir, "", err), + ) + moduleDirs = nil + } + sourceFindings := make(map[string]struct{}) + for _, moduleDir := range moduleDirs { + stdout, ok, failureDetail, err := runGovulncheckJSON(govulncheckBin, moduleDir, moduleDir, "-json", "./...") + if err != nil { + scanFailures++ + pass.ReportResult( + pass.AnalyzerName, + govulncheckScanFailed, + "govulncheck source scan failed", + scanFailureDetail(moduleDir, "", err), + ) + continue + } + if !ok { + scanFailures++ + pass.ReportResult( + pass.AnalyzerName, + govulncheckScanFailed, + "govulncheck source scan failed", + failureDetail, + ) + continue + } + scansPerformed++ + osvIDs, err := parseCalledFindings(bytes.NewReader(stdout)) + if err != nil { + logme.Errorln("Error parsing govulncheck source output", "error", err) + scanFailures++ + pass.ReportResult( + pass.AnalyzerName, + govulncheckScanFailed, + "govulncheck source scan failed", + scanFailureDetail(moduleDir, "", err), + ) + continue + } + for id := range osvIDs { + sourceFindings[id] = struct{}{} + } + } + findingsReported += len(sourceFindings) + reportSourceFindings(pass, sourceFindings) + } + + binaryFindings := make(map[string]map[string]struct{}) + binaryPaths, err := getBackendBinaries(pass) + if err != nil { + pass.ReportResult( + pass.AnalyzerName, + govulncheckScanFailed, + "govulncheck binary scan failed", + err.Error(), + ) + return nil, nil + } + for _, binaryPath := range binaryPaths { + stdout, ok, failureDetail, err := runGovulncheckJSON(govulncheckBin, "", filepath.Base(binaryPath), "-mode=binary", "-json", binaryPath) + if err != nil { + scanFailures++ + pass.ReportResult( + pass.AnalyzerName, + govulncheckScanFailed, + fmt.Sprintf("govulncheck binary scan failed for %s", filepath.Base(binaryPath)), + scanFailureDetail(filepath.Base(binaryPath), "", err), + ) + continue + } + if !ok { + scanFailures++ + pass.ReportResult( + pass.AnalyzerName, + govulncheckScanFailed, + fmt.Sprintf("govulncheck binary scan failed for %s", filepath.Base(binaryPath)), + failureDetail, + ) + continue + } + scansPerformed++ + osvIDs, err := parseAllFindings(bytes.NewReader(stdout)) + if err != nil { + logme.Errorln("Error parsing govulncheck binary output", "error", err) + scanFailures++ + pass.ReportResult( + pass.AnalyzerName, + govulncheckScanFailed, + fmt.Sprintf("govulncheck binary scan failed for %s", filepath.Base(binaryPath)), + scanFailureDetail(filepath.Base(binaryPath), "", err), + ) + continue + } + for id := range osvIDs { + if binaryFindings[id] == nil { + binaryFindings[id] = make(map[string]struct{}) + } + binaryFindings[id][filepath.Base(binaryPath)] = struct{}{} + } + } + findingsReported += len(binaryFindings) + reportBinaryFindings(pass, binaryFindings) + + if scansPerformed > 0 && scanFailures == 0 && findingsReported == 0 && govulncheckNoIssuesFound.ReportAll { + pass.ReportResult( + pass.AnalyzerName, + govulncheckNoIssuesFound, + "govulncheck reports no vulnerabilities", + "", + ) + } + + return nil, nil +} + +func runGovulncheckJSON(govulncheckBin, dir, target string, args ...string) ([]byte, bool, string, error) { + cmd := exec.Command(govulncheckBin, args...) + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + // Some govulncheck versions exit 3 when vulnerabilities are found. + // Other non-zero exits are scanner failures, such as package loading + // errors or unsupported binary formats. + exitErr, isExit := err.(*exec.ExitError) + if !isExit { + logme.ErrorF("Error running govulncheck for %s: %v (stderr: %s)", target, err, stderr.String()) + return nil, false, "", err + } + if exitErr.ExitCode() != 3 { + logme.DebugFln("govulncheck scan failed for %s: %v (stderr: %s)", target, err, stderr.String()) + return nil, false, scanFailureDetail(target, stderr.String(), err), nil + } + } + return stdout.Bytes(), true, "", nil +} + +// parseCalledFindings decodes the govulncheck `-json` NDJSON stream and +// returns the set of OSV IDs whose Finding contains a call-site frame +// (i.e. the vulnerable symbol is reachable from user code, not merely +// present in a transitive dependency). +func parseCalledFindings(r io.Reader) (map[string]struct{}, error) { + dec := json.NewDecoder(r) + called := make(map[string]struct{}) + for { + var msg Message + if err := dec.Decode(&msg); err != nil { + if err == io.EOF { + break + } + return nil, err + } + if msg.Finding == nil || msg.Finding.OSV == "" { + continue + } + if isCalled(msg.Finding) { + called[msg.Finding.OSV] = struct{}{} + } + } + return called, nil +} + +func parseAllFindings(r io.Reader) (map[string]struct{}, error) { + dec := json.NewDecoder(r) + found := make(map[string]struct{}) + for { + var msg Message + if err := dec.Decode(&msg); err != nil { + if err == io.EOF { + break + } + return nil, err + } + if msg.Finding == nil || msg.Finding.OSV == "" { + continue + } + found[msg.Finding.OSV] = struct{}{} + } + return found, nil +} + +// isCalled returns true if the Finding's call trace includes a concrete +// call-site frame. govulncheck emits findings at three levels: module, +// package, and symbol/called; only symbol/called findings have a Position. +func isCalled(f *Finding) bool { + for _, frame := range f.Trace { + if frame.Position != nil && frame.Position.Filename != "" { + return true + } + } + return false +} + +func pluralY(n int) string { + if n == 1 { + return "y" + } + return "ies" +} + +func scanFailureDetail(target, stderr string, err error) string { + detail := strings.TrimSpace(stderr) + if detail == "" && err != nil { + detail = err.Error() + } + if detail == "" { + detail = "govulncheck exited unsuccessfully without details." + } + if target != "" { + detail = fmt.Sprintf("%s: %s", target, detail) + } + const maxDetailLen = 1000 + if len(detail) > maxDetailLen { + return detail[:maxDetailLen] + "..." + } + return detail +} + +func goModuleDirs(sourceCodeDir string) ([]string, error) { + var moduleDirs []string + err := filepath.WalkDir(sourceCodeDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "dist", "node_modules", "vendor": + if path != sourceCodeDir { + return filepath.SkipDir + } + } + return nil + } + if d.Name() == "go.mod" { + moduleDirs = append(moduleDirs, filepath.Dir(path)) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("error finding Go modules in %s: %w", sourceCodeDir, err) + } + sort.Strings(moduleDirs) + return moduleDirs, nil +} + +func getBackendBinaries(pass *analysis.Pass) ([]string, error) { + archiveDir, ok := pass.ResultOf[archive.Analyzer].(string) + if !ok || archiveDir == "" { + return nil, nil + } + metadatamap, ok := pass.ResultOf[nestedmetadata.Analyzer].(nestedmetadata.Metadatamap) + if !ok { + return nil, nil + } + + var binaries []string + for pluginJSONPath, data := range metadatamap { + if data.Executable == "" { + continue + } + relativeTo := filepath.Join(archiveDir, filepath.Dir(pluginJSONPath)) + executable := data.Executable + executableParentDir := filepath.Join(relativeTo, filepath.Dir(executable)) + executableName := filepath.Base(executable) + + entries, err := os.ReadDir(executableParentDir) + if err != nil { + return nil, fmt.Errorf("error reading backend binaries for %s: %w", pluginJSONPath, err) + } + var candidateErrors []string + validForExecutable := 0 + for _, entry := range entries { + if entry.IsDir() || !entry.Type().IsRegular() { + continue + } + if !strings.HasPrefix(entry.Name(), executableName) { + continue + } + path := filepath.Join(executableParentDir, entry.Name()) + ok, err := isGoBinaryCandidate(path) + if err != nil { + candidateErrors = append(candidateErrors, err.Error()) + continue + } + if !ok { + continue + } + validForExecutable++ + binaries = append(binaries, path) + } + if validForExecutable == 0 && len(candidateErrors) > 0 { + return nil, fmt.Errorf("no scannable Go backend binary found for %s: %s", pluginJSONPath, strings.Join(candidateErrors, "; ")) + } + } + sort.Strings(binaries) + return binaries, nil +} + +func isGoBinaryCandidate(path string) (bool, error) { + _, err := buildinfo.ReadFile(path) + if err == nil { + return true, nil + } + return false, fmt.Errorf("%s is not a Go binary: %w", path, err) +} + +func reportSourceFindings(pass *analysis.Pass, osvIDs map[string]struct{}) { + if len(osvIDs) == 0 { + return + } + ids := sortedKeys(osvIDs) + pass.ReportResult( + pass.AnalyzerName, + govulncheckIssueFound, + fmt.Sprintf("govulncheck source scan reports %d reachable vulnerabilit%s", len(ids), pluralY(len(ids))), + fmt.Sprintf( + "Run govulncheck https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck in your plugin source to see details. Reachable OSV IDs: %s", + strings.Join(ids, ", "), + ), + ) +} + +func reportBinaryFindings(pass *analysis.Pass, binaryFindings map[string]map[string]struct{}) { + if len(binaryFindings) == 0 { + return + } + ids := make([]string, 0, len(binaryFindings)) + for id := range binaryFindings { + ids = append(ids, id) + } + sort.Strings(ids) + + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, fmt.Sprintf("%s (%s)", id, strings.Join(sortedKeys(binaryFindings[id]), ", "))) + } + + pass.ReportResult( + pass.AnalyzerName, + govulncheckIssueFound, + fmt.Sprintf("govulncheck binary scan reports %d vulnerabilit%s", len(ids), pluralY(len(ids))), + "Detected OSV IDs in backend binaries: "+strings.Join(parts, "; "), + ) +} + +func sortedKeys(values map[string]struct{}) []string { + keys := make([]string, 0, len(values)) + for value := range values { + keys = append(keys, value) + } + sort.Strings(keys) + return keys +} diff --git a/pkg/analysis/passes/govulncheck/govulncheck_test.go b/pkg/analysis/passes/govulncheck/govulncheck_test.go new file mode 100644 index 00000000..59822041 --- /dev/null +++ b/pkg/analysis/passes/govulncheck/govulncheck_test.go @@ -0,0 +1,474 @@ +package govulncheck + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/analysis/passes/archive" + "github.com/grafana/plugin-validator/pkg/analysis/passes/metadata" + "github.com/grafana/plugin-validator/pkg/analysis/passes/nestedmetadata" + "github.com/grafana/plugin-validator/pkg/analysis/passes/sourcecode" +) + +// Sample drawn from real `govulncheck -json` output. NDJSON: one Message per line. +// Two distinct findings: GO-2024-AAAA is "called" (has a frame with a Position +// in user code), GO-2024-BBBB is module-level only (no Position). Only the +// first should be counted. +const sampleNDJSON = ` +{"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.1.4","db":"https://vuln.go.dev","go_version":"go1.26.3","scan_level":"symbol"}} +{"progress":{"message":"Scanning your code and 42 packages across 3 dependent modules for known vulnerabilities..."}} +{"osv":{"id":"GO-2024-AAAA","summary":"Some vuln in pkg/foo"}} +{"osv":{"id":"GO-2024-BBBB","summary":"Module-only finding"}} +{"finding":{"osv":"GO-2024-AAAA","fixed_version":"v1.2.3","trace":[{"module":"example.com/foo","version":"v1.2.0","package":"example.com/foo","function":"Vulnerable","position":{"filename":"/src/plugin/main.go","line":42}},{"module":"example.com/foo","version":"v1.2.0","package":"example.com/foo","function":"main"}]}} +{"finding":{"osv":"GO-2024-BBBB","fixed_version":"v2.0.0","trace":[{"module":"example.com/bar","version":"v0.1.0"}]}} +` + +func TestParseCalledFindings_OnlyCounts_Reachable(t *testing.T) { + got, err := parseCalledFindings(strings.NewReader(sampleNDJSON)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 called finding, got %d (%v)", len(got), got) + } + if _, ok := got["GO-2024-AAAA"]; !ok { + t.Fatalf("expected GO-2024-AAAA in called set, got %v", got) + } + if _, ok := got["GO-2024-BBBB"]; ok { + t.Fatalf("GO-2024-BBBB is module-only and should not be counted") + } +} + +func TestParseCalledFindings_EmptyStream(t *testing.T) { + got, err := parseCalledFindings(strings.NewReader("")) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 findings on empty input, got %d", len(got)) + } +} + +func TestParseCalledFindings_DedupesSameOSV(t *testing.T) { + // Two findings for the same OSV (different call sites) should collapse + // into a single entry in the result set. + const dup = ` +{"finding":{"osv":"GO-2024-XXXX","trace":[{"package":"p","function":"A","position":{"filename":"a.go","line":1}}]}} +{"finding":{"osv":"GO-2024-XXXX","trace":[{"package":"p","function":"B","position":{"filename":"b.go","line":2}}]}} +` + got, err := parseCalledFindings(strings.NewReader(dup)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 deduped OSV, got %d", len(got)) + } +} + +func TestParseCalledFindings_CountsPositionInAnyTraceFrame(t *testing.T) { + const positionInSecondFrame = ` +{"finding":{"osv":"GO-2024-ORDER","trace":[{"package":"p","function":"Vulnerable"},{"package":"p","function":"main","position":{"filename":"main.go","line":12}}]}} +` + got, err := parseCalledFindings(strings.NewReader(positionInSecondFrame)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if _, ok := got["GO-2024-ORDER"]; !ok { + t.Fatalf("expected GO-2024-ORDER in called set, got %v", got) + } +} + +func TestRun_SkipsSilentlyWhenGovulncheckNotInstalled(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{}, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err := Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 0 { + t.Fatalf("expected no diagnostics, got %d (%v)", len(diagnostics), diagnostics) + } +} + +func TestRun_ReportsWhenGovulncheckNotInstalledWithReportAll(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + govulncheckNotInstalled.ReportAll = true + defer func() { + govulncheckNotInstalled.ReportAll = false + }() + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{}, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err := Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d (%v)", len(diagnostics), diagnostics) + } + if diagnostics[0].Name != govulncheckNotInstalled.Name { + t.Fatalf("expected %q diagnostic, got %q", govulncheckNotInstalled.Name, diagnostics[0].Name) + } + if diagnostics[0].Severity != analysis.Warning { + t.Fatalf("expected severity %q, got %q", analysis.Warning, diagnostics[0].Severity) + } +} + +func TestRun_ReportsScanFailureOnNonVulnerabilityExit(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + err := os.WriteFile(fakeGovulncheck, []byte(`#!/bin/sh +printf '{"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck"}}\n' +printf 'loading packages failed\n' >&2 +exit 1 +`), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + sourceDir := t.TempDir() + if err := os.WriteFile(filepath.Join(sourceDir, "go.mod"), []byte("module example.com/plugin\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + sourcecode.Analyzer: sourceDir, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d (%v)", len(diagnostics), diagnostics) + } + if diagnostics[0].Name != govulncheckScanFailed.Name { + t.Fatalf("expected %q diagnostic, got %q", govulncheckScanFailed.Name, diagnostics[0].Name) + } + if !strings.Contains(diagnostics[0].Detail, "loading packages failed") { + t.Fatalf("expected stderr in detail, got %q", diagnostics[0].Detail) + } +} + +func TestRun_ScansBackendBinaries(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + err := os.WriteFile(fakeGovulncheck, []byte(`#!/bin/sh +printf '{"finding":{"osv":"GO-2024-BIN","trace":[{"module":"example.com/mod","version":"v1.2.3"}]}}\n' +`), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + archiveDir := t.TempDir() + binaryName := "test-plugin_linux_amd64" + binaryPath := filepath.Join(archiveDir, binaryName) + testBinary, err := os.Executable() + if err != nil { + t.Fatalf("find test binary: %v", err) + } + testBinaryData, err := os.ReadFile(testBinary) + if err != nil { + t.Fatalf("read test binary: %v", err) + } + if err := os.WriteFile(binaryPath, testBinaryData, 0o755); err != nil { + t.Fatalf("write fake binary: %v", err) + } + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + archive.Analyzer: archiveDir, + nestedmetadata.Analyzer: nestedmetadata.Metadatamap{ + nestedmetadata.MainPluginJson: metadata.Metadata{Executable: "test-plugin"}, + }, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d (%v)", len(diagnostics), diagnostics) + } + if diagnostics[0].Name != govulncheckIssueFound.Name { + t.Fatalf("expected %q diagnostic, got %q", govulncheckIssueFound.Name, diagnostics[0].Name) + } + if !strings.Contains(diagnostics[0].Title, "binary scan reports 1") { + t.Fatalf("expected binary scan title, got %q", diagnostics[0].Title) + } + if !strings.Contains(diagnostics[0].Detail, "GO-2024-BIN") || !strings.Contains(diagnostics[0].Detail, binaryName) { + t.Fatalf("expected OSV and binary name in detail, got %q", diagnostics[0].Detail) + } +} + +func TestRun_ScansBackendBinaryWithoutPlatformSuffix(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + err := os.WriteFile(fakeGovulncheck, []byte(`#!/bin/sh +printf '{"finding":{"osv":"GO-2024-EXACT","trace":[{"module":"example.com/mod","version":"v1.2.3"}]}}\n' +`), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + archiveDir := t.TempDir() + binaryName := "test-plugin" + writeCurrentTestBinary(t, filepath.Join(archiveDir, binaryName)) + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + archive.Analyzer: archiveDir, + nestedmetadata.Analyzer: nestedmetadata.Metadatamap{ + nestedmetadata.MainPluginJson: metadata.Metadata{Executable: "test-plugin"}, + }, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d (%v)", len(diagnostics), diagnostics) + } + if diagnostics[0].Name != govulncheckIssueFound.Name { + t.Fatalf("expected %q diagnostic, got %q", govulncheckIssueFound.Name, diagnostics[0].Name) + } + if !strings.Contains(diagnostics[0].Detail, "GO-2024-EXACT") || !strings.Contains(diagnostics[0].Detail, binaryName) { + t.Fatalf("expected OSV and binary name in detail, got %q", diagnostics[0].Detail) + } +} + +func TestRun_ScansValidBackendBinaryWhenNonGoSiblingMatchesPrefix(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + err := os.WriteFile(fakeGovulncheck, []byte(`#!/bin/sh +printf '{"finding":{"osv":"GO-2024-DECOY","trace":[{"module":"example.com/mod","version":"v1.2.3"}]}}\n' +`), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + archiveDir := t.TempDir() + if err := os.WriteFile(filepath.Join(archiveDir, "test-plugin.sha256"), []byte("not a Go binary"), 0o644); err != nil { + t.Fatalf("write decoy binary sibling: %v", err) + } + binaryName := "test-plugin_linux_amd64" + writeCurrentTestBinary(t, filepath.Join(archiveDir, binaryName)) + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + archive.Analyzer: archiveDir, + nestedmetadata.Analyzer: nestedmetadata.Metadatamap{ + nestedmetadata.MainPluginJson: metadata.Metadata{Executable: "test-plugin"}, + }, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d (%v)", len(diagnostics), diagnostics) + } + if diagnostics[0].Name != govulncheckIssueFound.Name { + t.Fatalf("expected %q diagnostic, got %q", govulncheckIssueFound.Name, diagnostics[0].Name) + } + if !strings.Contains(diagnostics[0].Detail, "GO-2024-DECOY") || !strings.Contains(diagnostics[0].Detail, binaryName) { + t.Fatalf("expected OSV and binary name in detail, got %q", diagnostics[0].Detail) + } +} + +func TestRun_SilentlySkipsSourceScanWhenSourceNotProvided(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + err := os.WriteFile(fakeGovulncheck, []byte("#!/bin/sh\nexit 0\n"), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + nestedmetadata.Analyzer: nestedmetadata.Metadatamap{ + nestedmetadata.MainPluginJson: metadata.Metadata{Backend: true, Executable: "test-plugin"}, + }, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 0 { + t.Fatalf("expected no diagnostics when source not provided, got %d (%v)", len(diagnostics), diagnostics) + } +} + +func TestRun_ScansNestedGoModuleInSource(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + err := os.WriteFile(fakeGovulncheck, []byte(`#!/bin/sh +printf '{"finding":{"osv":"GO-2024-NESTED","trace":[{"package":"p","function":"A","position":{"filename":"main.go","line":1}}]}}\n' +`), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + sourceDir := t.TempDir() + moduleDir := filepath.Join(sourceDir, "backend") + if err := os.Mkdir(moduleDir, 0o755); err != nil { + t.Fatalf("create module dir: %v", err) + } + if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte("module example.com/plugin\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + sourcecode.Analyzer: sourceDir, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d (%v)", len(diagnostics), diagnostics) + } + if diagnostics[0].Name != govulncheckIssueFound.Name { + t.Fatalf("expected %q diagnostic, got %q", govulncheckIssueFound.Name, diagnostics[0].Name) + } + if !strings.Contains(diagnostics[0].Detail, "GO-2024-NESTED") { + t.Fatalf("expected OSV in detail, got %q", diagnostics[0].Detail) + } +} + +func TestRun_ReportsScanFailureForNonGoBackendBinary(t *testing.T) { + binDir := t.TempDir() + fakeGovulncheck := filepath.Join(binDir, "govulncheck") + err := os.WriteFile(fakeGovulncheck, []byte("#!/bin/sh\nexit 0\n"), 0o755) + if err != nil { + t.Fatalf("write fake govulncheck: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + archiveDir := t.TempDir() + binaryName := "test-plugin_linux_amd64" + binaryPath := filepath.Join(archiveDir, binaryName) + if err := os.WriteFile(binaryPath, []byte{0x7f, 'E', 'L', 'F', 0x00}, 0o755); err != nil { + t.Fatalf("write non-Go binary: %v", err) + } + + var diagnostics []analysis.Diagnostic + pass := &analysis.Pass{ + AnalyzerName: Analyzer.Name, + ResultOf: map[*analysis.Analyzer]any{ + archive.Analyzer: archiveDir, + nestedmetadata.Analyzer: nestedmetadata.Metadatamap{ + nestedmetadata.MainPluginJson: metadata.Metadata{Executable: "test-plugin"}, + }, + }, + Report: func(_ string, d analysis.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + } + + _, err = Analyzer.Run(pass) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if len(diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic, got %d (%v)", len(diagnostics), diagnostics) + } + if diagnostics[0].Name != govulncheckScanFailed.Name { + t.Fatalf("expected %q diagnostic, got %q", govulncheckScanFailed.Name, diagnostics[0].Name) + } + if !strings.Contains(diagnostics[0].Detail, "is not a Go binary") { + t.Fatalf("expected non-Go binary detail, got %q", diagnostics[0].Detail) + } +} + +func TestPluralY(t *testing.T) { + if got := pluralY(1); got != "y" { + t.Errorf("pluralY(1) = %q, want %q", got, "y") + } + if got := pluralY(2); got != "ies" { + t.Errorf("pluralY(2) = %q, want %q", got, "ies") + } +} + +func writeCurrentTestBinary(t *testing.T, dst string) { + t.Helper() + + testBinary, err := os.Executable() + if err != nil { + t.Fatalf("find test binary: %v", err) + } + testBinaryData, err := os.ReadFile(testBinary) + if err != nil { + t.Fatalf("read test binary: %v", err) + } + if err := os.WriteFile(dst, testBinaryData, 0o755); err != nil { + t.Fatalf("write fake binary: %v", err) + } +} diff --git a/pkg/analysis/passes/govulncheck/types.go b/pkg/analysis/passes/govulncheck/types.go new file mode 100644 index 00000000..4c6cbff0 --- /dev/null +++ b/pkg/analysis/passes/govulncheck/types.go @@ -0,0 +1,52 @@ +package govulncheck + +// Message is one record from `govulncheck -json` stream output. +// Each line is a single Message with exactly one of the embedded fields populated. +type Message struct { + Config *Config `json:"config,omitempty"` + Progress *Progress `json:"progress,omitempty"` + OSV *OSV `json:"osv,omitempty"` + Finding *Finding `json:"finding,omitempty"` +} + +type Config struct { + ProtocolVersion string `json:"protocol_version,omitempty"` + ScannerName string `json:"scanner_name,omitempty"` + ScannerVersion string `json:"scanner_version,omitempty"` + DB string `json:"db,omitempty"` + DBLastModified string `json:"db_last_modified,omitempty"` + GoVersion string `json:"go_version,omitempty"` + ScanLevel string `json:"scan_level,omitempty"` +} + +type Progress struct { + Message string `json:"message,omitempty"` +} + +type OSV struct { + ID string `json:"id,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// Finding reports one vulnerability hit. Trace describes the call stack from +// user code into the vulnerable symbol; for non-called findings (module- or +// package-level), the user-code frames are absent. +type Finding struct { + OSV string `json:"osv,omitempty"` + FixedVersion string `json:"fixed_version,omitempty"` + Trace []Frame `json:"trace,omitempty"` +} + +type Frame struct { + Module string `json:"module,omitempty"` + Version string `json:"version,omitempty"` + Package string `json:"package,omitempty"` + Function string `json:"function,omitempty"` + Receiver string `json:"receiver,omitempty"` + Position *Pos `json:"position,omitempty"` +} + +type Pos struct { + Filename string `json:"filename,omitempty"` + Line int `json:"line,omitempty"` +} diff --git a/pkg/cmd/plugincheck2/testdata/integration-tests.yaml b/pkg/cmd/plugincheck2/testdata/integration-tests.yaml index ded6acbc..2e606053 100644 --- a/pkg/cmd/plugincheck2/testdata/integration-tests.yaml +++ b/pkg/cmd/plugincheck2/testdata/integration-tests.yaml @@ -8,3 +8,5 @@ analyzers: enabled: false brokenlinks: enabled: false + govulncheck: + enabled: false