From 642f283d52e1556ffd394a7c581c481337857e6e Mon Sep 17 00:00:00 2001 From: tester Date: Thu, 14 May 2026 02:43:52 -0700 Subject: [PATCH 1/2] feat: add td depscan dependency risk scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `td depscan` command that analyzes the current project's Go module dependencies for security and maintenance risks. The new internal/depscan package parses go.mod via `go mod edit -json` and reads go.sum, then applies offline static heuristics: pseudo-versions, pre-1.0 versions, +incompatible tags, replace directives, large indirect dependency surface, and a stale `go` directive. Optional dynamic checks integrate govulncheck (--vuln) and `go list -m -u` (--check-updates), degrading gracefully when the tool or network is unavailable. The cobra command self-registers in the 'system' group and supports --json, --check-updates, --vuln, and --severity flags, rendering a severity-grouped human-readable report or JSON. Includes unit and command tests plus docs/depscan.md. Nightshift-Task: dependency-risk Nightshift-Ref: https://github.com/marcus/nightshift πŸ€– Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/depscan.go | 134 +++++++++ cmd/depscan_test.go | 144 ++++++++++ docs/depscan.md | 91 ++++++ internal/depscan/depscan.go | 472 +++++++++++++++++++++++++++++++ internal/depscan/depscan_test.go | 305 ++++++++++++++++++++ internal/depscan/dynamic.go | 151 ++++++++++ 6 files changed, 1297 insertions(+) create mode 100644 cmd/depscan.go create mode 100644 cmd/depscan_test.go create mode 100644 docs/depscan.md create mode 100644 internal/depscan/depscan.go create mode 100644 internal/depscan/depscan_test.go create mode 100644 internal/depscan/dynamic.go diff --git a/cmd/depscan.go b/cmd/depscan.go new file mode 100644 index 00000000..bf5d7950 --- /dev/null +++ b/cmd/depscan.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/marcus/td/internal/depscan" + "github.com/marcus/td/internal/output" + "github.com/spf13/cobra" +) + +var ( + depscanJSON bool + depscanCheckUpdates bool + depscanVuln bool + depscanSeverity string +) + +var depscanCmd = &cobra.Command{ + Use: "depscan", + Short: "Analyze Go module dependencies for security and maintenance risks", + GroupID: "system", + Long: `Analyze the current project's Go module dependencies for security and +maintenance risks. + +Static, offline heuristics always run: + - pseudo-versions dependencies pinned to untagged commits + - pre-1.0 v0.x modules with no API-stability guarantee + - +incompatible modules not migrated to Go modules + - replace directive supply-chain / local-override risk + - indirect surface unusually large transitive dependency graphs + - go directive a go.mod 'go' version lagging the toolchain + +Optional dynamic checks degrade gracefully when offline: + --vuln run govulncheck for known CVEs (requires govulncheck on PATH) + --check-updates run 'go list -m -u' to flag outdated modules`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + minSeverity, err := depscan.ParseSeverity(depscanSeverity) + if err != nil { + return err + } + + report, err := depscan.Scan(depscan.Options{ + CheckUpdates: depscanCheckUpdates, + Vuln: depscanVuln, + }) + if err != nil { + return err + } + report = depscan.FilterBySeverity(report, minSeverity) + + if depscanJSON { + return output.JSON(report) + } + renderReport(report, minSeverity) + return nil + }, +} + +// renderReport prints a grouped, human-readable dependency risk report. +func renderReport(report *depscan.Report, minSeverity depscan.Severity) { + output.Info("Dependency Risk Scan: %s", report.Module) + output.Info("go.mod: %s (go %s)", report.GoMod, report.Go) + s := report.Summary + output.Info("Dependencies: %d direct, %d indirect | go.sum: %d verified modules", + s.DirectDeps, s.IndirectDeps, s.GoSumModules) + output.Info("Findings: %d total (%d high, %d medium, %d low)", + s.Total, s.High, s.Medium, s.Low) + if minSeverity != depscan.SeverityLow { + output.Info("Filtered to severity >= %s", minSeverity) + } + + for _, note := range report.Notes { + output.Warning("%s", note) + } + + if len(report.Findings) == 0 { + fmt.Println() + output.Success("No dependency risks found at the requested severity.") + return + } + + groups := []struct { + sev depscan.Severity + label string + }{ + {depscan.SeverityHigh, "HIGH"}, + {depscan.SeverityMedium, "MEDIUM"}, + {depscan.SeverityLow, "LOW"}, + } + for _, g := range groups { + var lines []string + for _, f := range report.Findings { + if f.Severity != g.sev { + continue + } + lines = append(lines, formatFinding(f)) + } + if len(lines) == 0 { + continue + } + fmt.Print(output.SectionHeader(fmt.Sprintf("%s severity (%d)", g.label, len(lines)))) + for _, line := range lines { + fmt.Println(line) + } + } +} + +// formatFinding renders a single finding as an indented bullet line. +func formatFinding(f depscan.Finding) string { + var b strings.Builder + b.WriteString(" - [") + b.WriteString(f.Category) + b.WriteString("] ") + if f.Module != "" { + b.WriteString(f.Module) + if f.Version != "" { + b.WriteString("@") + b.WriteString(f.Version) + } + b.WriteString(": ") + } + b.WriteString(f.Detail) + return b.String() +} + +func init() { + depscanCmd.Flags().BoolVar(&depscanJSON, "json", false, "Output the report as JSON") + depscanCmd.Flags().BoolVar(&depscanCheckUpdates, "check-updates", false, "Check for outdated modules via 'go list -m -u' (requires network)") + depscanCmd.Flags().BoolVar(&depscanVuln, "vuln", false, "Run govulncheck for known CVEs (requires govulncheck on PATH)") + depscanCmd.Flags().StringVar(&depscanSeverity, "severity", "low", "Minimum severity to report: high, medium, or low") + rootCmd.AddCommand(depscanCmd) +} diff --git a/cmd/depscan_test.go b/cmd/depscan_test.go new file mode 100644 index 00000000..a1f4fec6 --- /dev/null +++ b/cmd/depscan_test.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/marcus/td/internal/depscan" +) + +// runDepscan executes the depscan command with the given flags, capturing +// stdout. Flags are reset to their defaults afterward. +func runDepscan(t *testing.T, flags map[string]string) (string, error) { + t.Helper() + for k, v := range flags { + if err := depscanCmd.Flags().Set(k, v); err != nil { + t.Fatalf("set flag %s=%s: %v", k, v, err) + } + } + defer func() { + for k := range flags { + if f := depscanCmd.Flags().Lookup(k); f != nil { + _ = depscanCmd.Flags().Set(k, f.DefValue) + } + } + }() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe failed: %v", err) + } + os.Stdout = w + + runErr := depscanCmd.RunE(depscanCmd, []string{}) + + _ = w.Close() + os.Stdout = oldStdout + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String(), runErr +} + +func TestDepscanFlagsDefined(t *testing.T) { + for _, name := range []string{"json", "check-updates", "vuln", "severity"} { + if depscanCmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to be defined", name) + } + } + if depscanCmd.GroupID != "system" { + t.Errorf("depscan should be in the system group, got %q", depscanCmd.GroupID) + } +} + +func TestDepscanRegisteredOnRoot(t *testing.T) { + for _, c := range rootCmd.Commands() { + if c.Name() == "depscan" { + return + } + } + t.Error("depscan command not registered on rootCmd") +} + +// TestDepscanDefault runs the command against td's own go.mod (the test +// process runs inside the module, so `go env GOMOD` resolves it). +func TestDepscanDefault(t *testing.T) { + out, err := runDepscan(t, nil) + if err != nil { + t.Fatalf("depscan returned error: %v", err) + } + if !strings.Contains(out, "Dependency Risk Scan:") { + t.Errorf("expected report header in output, got:\n%s", out) + } + if !strings.Contains(out, "github.com/marcus/td") { + t.Errorf("expected td module path in output, got:\n%s", out) + } + if !strings.Contains(out, "Dependencies:") { + t.Errorf("expected dependency summary line, got:\n%s", out) + } +} + +func TestDepscanJSON(t *testing.T) { + out, err := runDepscan(t, map[string]string{"json": "true"}) + if err != nil { + t.Fatalf("depscan --json returned error: %v", err) + } + var report depscan.Report + if err := json.Unmarshal([]byte(out), &report); err != nil { + t.Fatalf("depscan --json did not emit valid JSON: %v\noutput:\n%s", err, out) + } + if report.Module != "github.com/marcus/td" { + t.Errorf("unexpected module in JSON report: %q", report.Module) + } + if report.Summary.Total != len(report.Findings) { + t.Errorf("summary total %d != findings length %d", report.Summary.Total, len(report.Findings)) + } + if report.Summary.DirectDeps == 0 { + t.Error("expected at least one direct dependency in JSON report") + } + // Findings must be severity-ordered. + for i := 1; i < len(report.Findings); i++ { + prev, cur := report.Findings[i-1].Severity, report.Findings[i].Severity + if rankOf(prev) < rankOf(cur) { + t.Errorf("findings not severity-ordered at %d: %s before %s", i, prev, cur) + } + } +} + +func TestDepscanSeverityFilter(t *testing.T) { + out, err := runDepscan(t, map[string]string{"json": "true", "severity": "high"}) + if err != nil { + t.Fatalf("depscan --severity high returned error: %v", err) + } + var report depscan.Report + if err := json.Unmarshal([]byte(out), &report); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + for _, f := range report.Findings { + if f.Severity != depscan.SeverityHigh { + t.Errorf("severity=high filter leaked a %s finding: %+v", f.Severity, f) + } + } +} + +func TestDepscanInvalidSeverity(t *testing.T) { + _, err := runDepscan(t, map[string]string{"severity": "bogus"}) + if err == nil { + t.Error("expected error for invalid --severity value") + } +} + +func rankOf(s depscan.Severity) int { + switch s { + case depscan.SeverityHigh: + return 3 + case depscan.SeverityMedium: + return 2 + default: + return 1 + } +} diff --git a/docs/depscan.md b/docs/depscan.md new file mode 100644 index 00000000..b04622f6 --- /dev/null +++ b/docs/depscan.md @@ -0,0 +1,91 @@ +# `td depscan` β€” Dependency Risk Scanner + +`td depscan` analyzes the current project's Go module dependencies for security +and maintenance risks. It parses `go.mod` (via `go mod edit -json`) and `go.sum`, +applies a set of offline static heuristics, and can optionally layer in +`govulncheck` and `go list -m -u` for known-CVE and outdated-module detection. + +The command lives in the **System Commands** group and takes no positional +arguments. + +``` +td depscan [flags] +``` + +## Flags + +| Flag | Description | +| --- | --- | +| `--json` | Emit the full report as JSON instead of the human-readable view. | +| `--severity ` | Minimum severity to report. Defaults to `low` (everything). | +| `--check-updates` | Run `go list -m -u -json all` to flag outdated modules. Requires network access. | +| `--vuln` | Run `govulncheck -json ./...` for known CVEs. Requires `govulncheck` on `PATH`. | + +## Risk heuristics + +### Static (always run, fully offline) + +| Category | Severity | What it flags | +| --- | --- | --- | +| `pseudo-version` | medium (direct) / low (indirect) | A dependency pinned to an untagged commit (e.g. `v0.0.0-20250623103423-23b8fd6302d7`). Untagged commits are not part of a released, reviewed version. | +| `pre-1.0` | medium (direct) / low (indirect) | A `v0.x` module. Pre-1.0 modules make no API-stability promise, so upgrades can break unexpectedly. | +| `incompatible` | medium | A `+incompatible` version β€” the module is at `v2+` but has not adopted Go modules' major-version import path, so the toolchain cannot reason about its compatibility. | +| `replace-directive` | high (local path) / medium (module) | A `replace` directive. A directive pointing at a local filesystem path is **high** severity because the build is not reproducible outside the current checkout; a module-to-module replacement is **medium** (supply-chain override risk). | +| `indirect-surface` | medium (β‰₯60, or >5Γ— direct) / low (β‰₯30) | An unusually large transitive dependency graph. More indirect dependencies means more code to audit and a larger supply-chain attack surface. | +| `go-directive` | medium (β‰₯4 behind) / low (β‰₯2 behind) | The `go` directive in `go.mod` lags the building toolchain by several minor versions, so language and standard-library security fixes may not be in use. | + +### Dynamic (opt-in, degrade gracefully) + +- **`--vuln`** runs `govulncheck` and parses its streamed JSON output. Each + distinct OSV advisory that affects the build is reported as a **high** + severity `vulnerability` finding. If `govulncheck` is not installed, or it + produces no output (e.g. offline with no cached vulnerability database), the + scan is skipped and a note is added to the report rather than failing. +- **`--check-updates`** runs `go list -m -u -json all` and reports every module + with a newer version available as a **low** severity `outdated` finding. If + the module graph cannot be loaded (e.g. offline), the check is skipped with a + note. + +Skipped dynamic checks appear under `notes` in JSON output and as warnings in +the human-readable view; they never cause the command to error. + +## Output + +The human-readable report prints a summary header (module, `go.mod` path, `go` +directive, dependency counts, `go.sum` verified-module count, and finding totals +by severity), followed by findings grouped under `HIGH` / `MEDIUM` / `LOW` +sections. Findings are severity-ranked. + +With `--json`, the command prints a single JSON object: + +```json +{ + "module": "github.com/marcus/td", + "go_mod_path": "/path/to/go.mod", + "go_directive": "1.25.5", + "summary": { + "total": 36, "high": 0, "medium": 11, "low": 25, + "direct_deps": 16, "indirect_deps": 40, "go_sum_modules": 80, + "vuln_checked": false, "updates_checked": false + }, + "findings": [ + { + "severity": "medium", + "category": "pseudo-version", + "module": "github.com/charmbracelet/bubbles", + "version": "v0.21.1-0.20250623103423-23b8fd6302d7", + "detail": "direct dependency pinned to an untagged commit (pseudo-version)" + } + ], + "notes": ["govulncheck not found on PATH; skipped known-CVE scan ..."] +} +``` + +## Exit & usage behavior + +`td depscan` exits non-zero only on an operational error β€” for example, when no +`go.mod` can be found from the working directory, when `go mod edit -json` fails, +or when an invalid `--severity` value is passed. The **presence of findings does +not change the exit code**: a scan that surfaces high-severity risks still exits +`0`. This keeps the command safe to run in informational contexts; gate CI on +the JSON output (`summary.high`, `summary.total`) if you need a hard failure. diff --git a/internal/depscan/depscan.go b/internal/depscan/depscan.go new file mode 100644 index 00000000..c4d98211 --- /dev/null +++ b/internal/depscan/depscan.go @@ -0,0 +1,472 @@ +// Package depscan analyzes a Go module's dependencies for security and +// maintenance risks. It parses go.mod (via `go mod edit -json`) and go.sum, +// applies offline static heuristics, and optionally integrates govulncheck +// and `go list -m -u` for known-CVE and outdated-module detection. +package depscan + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" +) + +// Severity ranks a finding's importance. +type Severity string + +const ( + SeverityHigh Severity = "high" + SeverityMedium Severity = "medium" + SeverityLow Severity = "low" +) + +// rank returns a numeric weight for ordering (higher == more severe). +func (s Severity) rank() int { + switch s { + case SeverityHigh: + return 3 + case SeverityMedium: + return 2 + case SeverityLow: + return 1 + default: + return 0 + } +} + +// AtLeast reports whether s is at least as severe as min. +func (s Severity) AtLeast(min Severity) bool { + return s.rank() >= min.rank() +} + +// ParseSeverity converts a string to a Severity, defaulting to low on +// unrecognized input. +func ParseSeverity(s string) (Severity, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "high": + return SeverityHigh, nil + case "medium", "med": + return SeverityMedium, nil + case "low", "": + return SeverityLow, nil + default: + return SeverityLow, fmt.Errorf("invalid severity %q (want high|medium|low)", s) + } +} + +// Categories for findings. +const ( + CategoryPseudoVersion = "pseudo-version" + CategoryPreRelease = "pre-1.0" + CategoryIncompatible = "incompatible" + CategoryReplace = "replace-directive" + CategoryIndirect = "indirect-surface" + CategoryGoDirective = "go-directive" + CategoryVulnerability = "vulnerability" + CategoryOutdated = "outdated" +) + +// Finding is a single dependency risk observation. +type Finding struct { + Severity Severity `json:"severity"` + Category string `json:"category"` + Module string `json:"module,omitempty"` + Version string `json:"version,omitempty"` + Detail string `json:"detail"` +} + +// Summary holds aggregate counts for a report. +type Summary struct { + Total int `json:"total"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + DirectDeps int `json:"direct_deps"` + IndirectDeps int `json:"indirect_deps"` + GoSumModules int `json:"go_sum_modules"` + VulnChecked bool `json:"vuln_checked"` + UpdatesChecked bool `json:"updates_checked"` +} + +// Report is the full result of a dependency scan. +type Report struct { + Module string `json:"module"` + GoMod string `json:"go_mod_path"` + Go string `json:"go_directive"` + Summary Summary `json:"summary"` + Findings []Finding `json:"findings"` + Notes []string `json:"notes,omitempty"` +} + +// modInfo mirrors the JSON emitted by `go mod edit -json`. +type modInfo struct { + Module struct { + Path string `json:"Path"` + } `json:"Module"` + Go string `json:"Go"` + Require []struct { + Path string `json:"Path"` + Version string `json:"Version"` + Indirect bool `json:"Indirect"` + } `json:"Require"` + Replace []struct { + Old struct { + Path string `json:"Path"` + Version string `json:"Version"` + } `json:"Old"` + New struct { + Path string `json:"Path"` + Version string `json:"Version"` + } `json:"New"` + } `json:"Replace"` +} + +// Options controls which checks run. +type Options struct { + // Dir is the directory to scan from. Empty means the current directory. + Dir string + // CheckUpdates enables `go list -m -u` outdated-module detection. + CheckUpdates bool + // Vuln enables govulncheck integration when the tool is on PATH. + Vuln bool +} + +// pseudoVersionRe matches the timestamp + commit-hash suffix of a Go +// pseudo-version. The timestamp is preceded by '-' in the base form +// (v0.0.0-20250623103423-23b8fd6302d7) or '.' in the pre-release form +// (v0.21.1-0.20250623103423-23b8fd6302d7). +var pseudoVersionRe = regexp.MustCompile(`[-.][0-9]{14}-[0-9a-f]{12}$`) + +// IsPseudoVersion reports whether version is a Go pseudo-version (a synthetic +// version derived from an untagged commit). +func IsPseudoVersion(version string) bool { + return pseudoVersionRe.MatchString(version) +} + +// IsPreRelease reports whether version is a pre-1.0 (v0.x) release. +func IsPreRelease(version string) bool { + return strings.HasPrefix(version, "v0.") +} + +// IsIncompatible reports whether version carries the +incompatible suffix. +func IsIncompatible(version string) bool { + return strings.HasSuffix(version, "+incompatible") +} + +// findGoMod resolves the path to the active module's go.mod. It prefers +// `go env GOMOD`, then falls back to walking up from dir. +func findGoMod(dir string) (string, error) { + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return "", err + } + } + cmd := exec.Command("go", "env", "GOMOD") + cmd.Dir = dir + if out, err := cmd.Output(); err == nil { + p := strings.TrimSpace(string(out)) + if p != "" && p != os.DevNull { + return p, nil + } + } + // Fallback: walk up looking for go.mod. + cur := dir + for { + candidate := filepath.Join(cur, "go.mod") + if st, err := os.Stat(candidate); err == nil && !st.IsDir() { + return candidate, nil + } + parent := filepath.Dir(cur) + if parent == cur { + break + } + cur = parent + } + return "", fmt.Errorf("no go.mod found from %s", dir) +} + +// parseGoMod runs `go mod edit -json` against goModPath and decodes the result. +func parseGoMod(goModPath string) (*modInfo, error) { + cmd := exec.Command("go", "mod", "edit", "-json") + cmd.Dir = filepath.Dir(goModPath) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("go mod edit -json failed: %w", err) + } + return decodeModInfo(out) +} + +// decodeModInfo decodes `go mod edit -json` output. Split out for testing. +func decodeModInfo(data []byte) (*modInfo, error) { + var mi modInfo + if err := json.Unmarshal(data, &mi); err != nil { + return nil, fmt.Errorf("parsing go.mod JSON: %w", err) + } + return &mi, nil +} + +// countGoSumModules counts the distinct module@version entries verified in +// go.sum (lines ending in a plain hash, not the /go.mod hash variant). +func countGoSumModules(goModPath string) int { + data, err := os.ReadFile(filepath.Join(filepath.Dir(goModPath), "go.sum")) + if err != nil { + return 0 + } + seen := map[string]struct{}{} + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + // fields[1] is either "vX.Y.Z" or "vX.Y.Z/go.mod". + if strings.HasSuffix(fields[1], "/go.mod") { + continue + } + seen[fields[0]+"@"+fields[1]] = struct{}{} + } + return len(seen) +} + +// goMinor extracts the minor version from a Go version string such as +// "1.25.5" or "go1.26.3". Returns -1 when it cannot be parsed. +func goMinor(v string) int { + v = strings.TrimPrefix(v, "go") + parts := strings.Split(v, ".") + if len(parts) < 2 { + return -1 + } + n, err := strconv.Atoi(parts[1]) + if err != nil { + return -1 + } + return n +} + +// Scan runs the dependency analysis described by opts and returns a Report. +// Static heuristics always run offline; dynamic checks degrade gracefully and +// append entries to Report.Notes when skipped. +func Scan(opts Options) (*Report, error) { + goModPath, err := findGoMod(opts.Dir) + if err != nil { + return nil, err + } + mi, err := parseGoMod(goModPath) + if err != nil { + return nil, err + } + + report := &Report{ + Module: mi.Module.Path, + GoMod: goModPath, + Go: mi.Go, + } + + direct, indirect := 0, 0 + for _, r := range mi.Require { + if r.Indirect { + indirect++ + } else { + direct++ + } + report.Findings = append(report.Findings, classifyVersion(r.Path, r.Version, r.Indirect)...) + } + + // Replace directives: supply-chain / local-override risk. + for _, rep := range mi.Replace { + sev := SeverityMedium + detail := fmt.Sprintf("replaced with %s", rep.New.Path) + if rep.New.Version != "" { + detail += " " + rep.New.Version + } + if !strings.Contains(rep.New.Path, ".") || strings.HasPrefix(rep.New.Path, ".") || strings.HasPrefix(rep.New.Path, "/") { + // Local filesystem replacement: not reproducible for other builds. + sev = SeverityHigh + detail = fmt.Sprintf("replaced with local path %s (not reproducible outside this checkout)", rep.New.Path) + } + report.Findings = append(report.Findings, Finding{ + Severity: sev, + Category: CategoryReplace, + Module: rep.Old.Path, + Version: rep.Old.Version, + Detail: detail, + }) + } + + // Indirect dependency surface area. + if f, ok := classifyIndirectSurface(direct, indirect); ok { + report.Findings = append(report.Findings, f) + } + + // Stale go directive relative to the building toolchain. + if f, ok := classifyGoDirective(mi.Go, runtime.Version()); ok { + report.Findings = append(report.Findings, f) + } + + // Dynamic checks. + if opts.Vuln { + findings, note := runGovulncheck(filepath.Dir(goModPath)) + report.Findings = append(report.Findings, findings...) + if note != "" { + report.Notes = append(report.Notes, note) + } else { + report.Summary.VulnChecked = true + } + } + if opts.CheckUpdates { + findings, note := runUpdateCheck(filepath.Dir(goModPath)) + report.Findings = append(report.Findings, findings...) + if note != "" { + report.Notes = append(report.Notes, note) + } else { + report.Summary.UpdatesChecked = true + } + } + + report.Summary.DirectDeps = direct + report.Summary.IndirectDeps = indirect + report.Summary.GoSumModules = countGoSumModules(goModPath) + finalize(report) + return report, nil +} + +// classifyVersion produces static findings for a single required module. +func classifyVersion(path, version string, indirect bool) []Finding { + var findings []Finding + scope := "direct" + if indirect { + scope = "indirect" + } + switch { + case IsPseudoVersion(version): + sev := SeverityMedium + if indirect { + sev = SeverityLow + } + findings = append(findings, Finding{ + Severity: sev, + Category: CategoryPseudoVersion, + Module: path, + Version: version, + Detail: fmt.Sprintf("%s dependency pinned to an untagged commit (pseudo-version)", scope), + }) + case IsIncompatible(version): + findings = append(findings, Finding{ + Severity: SeverityMedium, + Category: CategoryIncompatible, + Module: path, + Version: version, + Detail: fmt.Sprintf("%s dependency uses +incompatible (module is not yet migrated to Go modules)", scope), + }) + case IsPreRelease(version): + sev := SeverityLow + if !indirect { + sev = SeverityMedium + } + findings = append(findings, Finding{ + Severity: sev, + Category: CategoryPreRelease, + Module: path, + Version: version, + Detail: fmt.Sprintf("%s dependency is pre-1.0 (v0.x); API stability is not guaranteed", scope), + }) + } + return findings +} + +// classifyIndirectSurface flags an unusually large indirect-dependency surface. +func classifyIndirectSurface(direct, indirect int) (Finding, bool) { + if indirect < 30 { + return Finding{}, false + } + sev := SeverityLow + detail := fmt.Sprintf("%d indirect dependencies expand the audit and supply-chain surface", indirect) + if indirect >= 60 || (direct > 0 && indirect > direct*5) { + sev = SeverityMedium + detail = fmt.Sprintf("%d indirect dependencies (vs %d direct) is a large transitive surface to audit", indirect, direct) + } + return Finding{ + Severity: sev, + Category: CategoryIndirect, + Detail: detail, + }, true +} + +// classifyGoDirective flags a go.mod `go` directive that lags the toolchain. +func classifyGoDirective(goDirective, toolchain string) (Finding, bool) { + dm := goMinor(goDirective) + tm := goMinor(toolchain) + if dm < 0 || tm < 0 { + return Finding{}, false + } + lag := tm - dm + if lag < 2 { + return Finding{}, false + } + sev := SeverityLow + if lag >= 4 { + sev = SeverityMedium + } + return Finding{ + Severity: sev, + Category: CategoryGoDirective, + Detail: fmt.Sprintf("go directive (1.%d) lags the building toolchain (%s) by %d minor versions; language and security fixes may be unavailable", + dm, strings.TrimPrefix(toolchain, "go"), lag), + }, true +} + +// finalize sorts findings by severity and fills in summary counts. +func finalize(r *Report) { + sort.SliceStable(r.Findings, func(i, j int) bool { + if r.Findings[i].Severity.rank() != r.Findings[j].Severity.rank() { + return r.Findings[i].Severity.rank() > r.Findings[j].Severity.rank() + } + if r.Findings[i].Category != r.Findings[j].Category { + return r.Findings[i].Category < r.Findings[j].Category + } + return r.Findings[i].Module < r.Findings[j].Module + }) + r.Summary.Total = len(r.Findings) + r.Summary.High, r.Summary.Medium, r.Summary.Low = 0, 0, 0 + for _, f := range r.Findings { + switch f.Severity { + case SeverityHigh: + r.Summary.High++ + case SeverityMedium: + r.Summary.Medium++ + case SeverityLow: + r.Summary.Low++ + } + } +} + +// FilterBySeverity returns a copy of report containing only findings at or +// above min severity, with summary counts recomputed. +func FilterBySeverity(report *Report, min Severity) *Report { + if min == SeverityLow { + return report + } + filtered := *report + filtered.Findings = nil + for _, f := range report.Findings { + if f.Severity.AtLeast(min) { + filtered.Findings = append(filtered.Findings, f) + } + } + finalize(&filtered) + // finalize overwrites dep counts indirectly? Noβ€”it only touches Total/High/ + // Medium/Low. Restore the surface counts that came from the original scan. + filtered.Summary.DirectDeps = report.Summary.DirectDeps + filtered.Summary.IndirectDeps = report.Summary.IndirectDeps + filtered.Summary.GoSumModules = report.Summary.GoSumModules + filtered.Summary.VulnChecked = report.Summary.VulnChecked + filtered.Summary.UpdatesChecked = report.Summary.UpdatesChecked + return &filtered +} diff --git a/internal/depscan/depscan_test.go b/internal/depscan/depscan_test.go new file mode 100644 index 00000000..10af541d --- /dev/null +++ b/internal/depscan/depscan_test.go @@ -0,0 +1,305 @@ +package depscan + +import ( + "testing" +) + +func TestIsPseudoVersion(t *testing.T) { + cases := []struct { + version string + want bool + }{ + {"v0.0.0-20250623103423-23b8fd6302d7", true}, + {"v0.21.1-0.20250623103423-23b8fd6302d7", true}, + {"v1.2.3-0.20191109021931-daa7c04131f5", true}, + {"v1.2.3", false}, + {"v0.10.0", false}, + {"v3.2.1+incompatible", false}, + {"v0.0.0", false}, + } + for _, c := range cases { + if got := IsPseudoVersion(c.version); got != c.want { + t.Errorf("IsPseudoVersion(%q) = %v, want %v", c.version, got, c.want) + } + } +} + +func TestIsPreRelease(t *testing.T) { + if !IsPreRelease("v0.8.0") { + t.Error("v0.8.0 should be pre-release") + } + if IsPreRelease("v1.0.0") { + t.Error("v1.0.0 should not be pre-release") + } + if IsPreRelease("v10.0.0") { + t.Error("v10.0.0 should not be pre-release") + } +} + +func TestIsIncompatible(t *testing.T) { + if !IsIncompatible("v3.2.1+incompatible") { + t.Error("expected +incompatible to be detected") + } + if IsIncompatible("v1.2.3") { + t.Error("v1.2.3 is compatible") + } +} + +func TestParseSeverity(t *testing.T) { + cases := []struct { + in string + want Severity + wantErr bool + }{ + {"high", SeverityHigh, false}, + {"MEDIUM", SeverityMedium, false}, + {"med", SeverityMedium, false}, + {"low", SeverityLow, false}, + {"", SeverityLow, false}, + {"bogus", SeverityLow, true}, + } + for _, c := range cases { + got, err := ParseSeverity(c.in) + if (err != nil) != c.wantErr { + t.Errorf("ParseSeverity(%q) err = %v, wantErr %v", c.in, err, c.wantErr) + } + if got != c.want { + t.Errorf("ParseSeverity(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestSeverityAtLeast(t *testing.T) { + if !SeverityHigh.AtLeast(SeverityLow) { + t.Error("high should be at least low") + } + if SeverityLow.AtLeast(SeverityMedium) { + t.Error("low should not be at least medium") + } + if !SeverityMedium.AtLeast(SeverityMedium) { + t.Error("medium should be at least medium") + } +} + +func TestDecodeModInfo(t *testing.T) { + data := []byte(`{ + "Module": {"Path": "example.com/proj"}, + "Go": "1.22", + "Require": [ + {"Path": "example.com/a", "Version": "v1.0.0"}, + {"Path": "example.com/b", "Version": "v0.0.0-20250101000000-abcdefabcdef", "Indirect": true} + ], + "Replace": [ + {"Old": {"Path": "example.com/a"}, "New": {"Path": "../local/a"}} + ] + }`) + mi, err := decodeModInfo(data) + if err != nil { + t.Fatalf("decodeModInfo: %v", err) + } + if mi.Module.Path != "example.com/proj" { + t.Errorf("module path = %q", mi.Module.Path) + } + if mi.Go != "1.22" { + t.Errorf("go directive = %q", mi.Go) + } + if len(mi.Require) != 2 { + t.Fatalf("require count = %d, want 2", len(mi.Require)) + } + if !mi.Require[1].Indirect { + t.Error("second require should be indirect") + } + if len(mi.Replace) != 1 || mi.Replace[0].New.Path != "../local/a" { + t.Errorf("replace not parsed: %+v", mi.Replace) + } +} + +func TestDecodeModInfoInvalid(t *testing.T) { + if _, err := decodeModInfo([]byte("not json")); err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestClassifyVersion(t *testing.T) { + // Pseudo-version, direct -> medium. + f := classifyVersion("example.com/a", "v0.0.0-20250101000000-abcdefabcdef", false) + if len(f) != 1 || f[0].Category != CategoryPseudoVersion || f[0].Severity != SeverityMedium { + t.Errorf("direct pseudo-version: %+v", f) + } + // Pseudo-version, indirect -> low. + f = classifyVersion("example.com/a", "v0.0.0-20250101000000-abcdefabcdef", true) + if len(f) != 1 || f[0].Severity != SeverityLow { + t.Errorf("indirect pseudo-version: %+v", f) + } + // +incompatible -> medium. + f = classifyVersion("example.com/a", "v3.0.0+incompatible", false) + if len(f) != 1 || f[0].Category != CategoryIncompatible { + t.Errorf("incompatible: %+v", f) + } + // Pre-1.0 direct -> medium. + f = classifyVersion("example.com/a", "v0.8.0", false) + if len(f) != 1 || f[0].Category != CategoryPreRelease || f[0].Severity != SeverityMedium { + t.Errorf("pre-release direct: %+v", f) + } + // Pre-1.0 indirect -> low. + f = classifyVersion("example.com/a", "v0.8.0", true) + if len(f) != 1 || f[0].Severity != SeverityLow { + t.Errorf("pre-release indirect: %+v", f) + } + // Stable release -> no findings. + f = classifyVersion("example.com/a", "v1.4.2", false) + if len(f) != 0 { + t.Errorf("stable release should produce no findings: %+v", f) + } +} + +func TestClassifyIndirectSurface(t *testing.T) { + if _, ok := classifyIndirectSurface(10, 5); ok { + t.Error("small surface should not be flagged") + } + f, ok := classifyIndirectSurface(10, 35) + if !ok || f.Severity != SeverityLow { + t.Errorf("moderate surface: %+v ok=%v", f, ok) + } + f, ok = classifyIndirectSurface(10, 80) + if !ok || f.Severity != SeverityMedium { + t.Errorf("large surface: %+v ok=%v", f, ok) + } + // Disproportionate ratio. + f, ok = classifyIndirectSurface(5, 31) + if !ok || f.Severity != SeverityMedium { + t.Errorf("disproportionate surface: %+v ok=%v", f, ok) + } +} + +func TestClassifyGoDirective(t *testing.T) { + if _, ok := classifyGoDirective("1.25", "go1.26.3"); ok { + t.Error("1-version lag should not be flagged") + } + f, ok := classifyGoDirective("1.24", "go1.26.3") + if !ok || f.Severity != SeverityLow { + t.Errorf("2-version lag: %+v ok=%v", f, ok) + } + f, ok = classifyGoDirective("1.20", "go1.26.3") + if !ok || f.Severity != SeverityMedium { + t.Errorf("6-version lag: %+v ok=%v", f, ok) + } + if _, ok := classifyGoDirective("garbage", "go1.26.3"); ok { + t.Error("unparseable directive should not be flagged") + } +} + +func TestGoMinor(t *testing.T) { + cases := map[string]int{ + "1.25.5": 25, + "go1.26.3": 26, + "1.22": 22, + "garbage": -1, + "1": -1, + } + for in, want := range cases { + if got := goMinor(in); got != want { + t.Errorf("goMinor(%q) = %d, want %d", in, got, want) + } + } +} + +func TestFinalizeSortsAndCounts(t *testing.T) { + r := &Report{Findings: []Finding{ + {Severity: SeverityLow, Category: CategoryPreRelease, Module: "z"}, + {Severity: SeverityHigh, Category: CategoryReplace, Module: "a"}, + {Severity: SeverityMedium, Category: CategoryIncompatible, Module: "m"}, + {Severity: SeverityHigh, Category: CategoryReplace, Module: "b"}, + }} + finalize(r) + if r.Summary.Total != 4 || r.Summary.High != 2 || r.Summary.Medium != 1 || r.Summary.Low != 1 { + t.Errorf("summary counts wrong: %+v", r.Summary) + } + // High first, and within equal severity+category, sorted by module. + if r.Findings[0].Severity != SeverityHigh || r.Findings[0].Module != "a" { + t.Errorf("first finding = %+v", r.Findings[0]) + } + if r.Findings[1].Module != "b" { + t.Errorf("second finding should be module b, got %+v", r.Findings[1]) + } + if r.Findings[3].Severity != SeverityLow { + t.Errorf("last finding should be low: %+v", r.Findings[3]) + } +} + +func TestFilterBySeverity(t *testing.T) { + r := &Report{ + Findings: []Finding{ + {Severity: SeverityLow, Category: CategoryPreRelease}, + {Severity: SeverityHigh, Category: CategoryReplace}, + {Severity: SeverityMedium, Category: CategoryIncompatible}, + }, + Summary: Summary{DirectDeps: 12, IndirectDeps: 40, GoSumModules: 99}, + } + finalize(r) + + // low == identity. + if got := FilterBySeverity(r, SeverityLow); got != r { + t.Error("filtering at low should return the same report") + } + + med := FilterBySeverity(r, SeverityMedium) + if med.Summary.Total != 2 || med.Summary.High != 1 || med.Summary.Medium != 1 || med.Summary.Low != 0 { + t.Errorf("medium filter summary: %+v", med.Summary) + } + // Surface counts must survive filtering. + if med.Summary.DirectDeps != 12 || med.Summary.IndirectDeps != 40 || med.Summary.GoSumModules != 99 { + t.Errorf("surface counts lost after filter: %+v", med.Summary) + } + + high := FilterBySeverity(r, SeverityHigh) + if high.Summary.Total != 1 || high.Findings[0].Category != CategoryReplace { + t.Errorf("high filter: %+v", high.Findings) + } +} + +func TestParseGovulncheck(t *testing.T) { + // Two streamed messages: one OSV definition, one finding referencing it. + data := []byte(`{"osv":{"id":"GO-2024-0001","summary":"Example vuln in pkg"}} +{"finding":{"osv":"GO-2024-0001","trace":[{"module":"example.com/pkg","version":"v1.2.0"}]}} +{"finding":{"osv":"GO-2024-0001","trace":[{"module":"example.com/pkg","version":"v1.2.0"}]}}`) + findings := parseGovulncheck(data) + if len(findings) != 1 { + t.Fatalf("expected 1 deduplicated finding, got %d", len(findings)) + } + f := findings[0] + if f.Severity != SeverityHigh || f.Category != CategoryVulnerability { + t.Errorf("vuln finding severity/category: %+v", f) + } + if f.Module != "example.com/pkg" || f.Version != "v1.2.0" { + t.Errorf("vuln finding module/version: %+v", f) + } + if f.Detail != "GO-2024-0001: Example vuln in pkg" { + t.Errorf("vuln finding detail: %q", f.Detail) + } +} + +func TestParseGovulncheckEmpty(t *testing.T) { + if f := parseGovulncheck([]byte("")); len(f) != 0 { + t.Errorf("empty input should yield no findings, got %+v", f) + } +} + +func TestParseUpdateList(t *testing.T) { + data := []byte(`{"Path":"example.com/proj","Version":"v1.0.0","Main":true} +{"Path":"example.com/a","Version":"v1.0.0","Update":{"Version":"v1.1.0"}} +{"Path":"example.com/b","Version":"v2.0.0","Indirect":true,"Update":{"Version":"v2.3.1"}} +{"Path":"example.com/c","Version":"v3.0.0"}`) + findings := parseUpdateList(data) + if len(findings) != 2 { + t.Fatalf("expected 2 outdated findings, got %d: %+v", len(findings), findings) + } + for _, f := range findings { + if f.Category != CategoryOutdated || f.Severity != SeverityLow { + t.Errorf("outdated finding wrong shape: %+v", f) + } + } + if findings[0].Module != "example.com/a" || findings[1].Module != "example.com/b" { + t.Errorf("unexpected modules: %+v", findings) + } +} diff --git a/internal/depscan/dynamic.go b/internal/depscan/dynamic.go new file mode 100644 index 00000000..70cfdb89 --- /dev/null +++ b/internal/depscan/dynamic.go @@ -0,0 +1,151 @@ +package depscan + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" +) + +// runGovulncheck runs `govulncheck -json ./...` in dir and parses the result +// for known vulnerabilities. It degrades gracefully: when govulncheck is not +// on PATH (or fails, e.g. offline with no cached DB) it returns a human note +// instead of findings. +func runGovulncheck(dir string) ([]Finding, string) { + if _, err := exec.LookPath("govulncheck"); err != nil { + return nil, "govulncheck not found on PATH; skipped known-CVE scan (install with: go install golang.org/x/vuln/cmd/govulncheck@latest)" + } + cmd := exec.Command("govulncheck", "-json", "./...") + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + // govulncheck exits non-zero when vulnerabilities are found; that is not an + // error for us, so we parse stdout regardless of exit code and only treat a + // total lack of output as a failure. + runErr := cmd.Run() + if stdout.Len() == 0 { + msg := "govulncheck produced no output; skipped known-CVE scan (network or vuln DB may be unavailable)" + if runErr != nil { + msg = fmt.Sprintf("govulncheck failed: %v; skipped known-CVE scan", runErr) + } + return nil, msg + } + return parseGovulncheck(stdout.Bytes()), "" +} + +// govulnMessage is one streamed message from `govulncheck -json`. +type govulnMessage struct { + OSV *struct { + ID string `json:"id"` + Summary string `json:"summary"` + } `json:"osv"` + Finding *struct { + OSV string `json:"osv"` + Trace []struct { + Module string `json:"module"` + Version string `json:"version"` + } `json:"trace"` + } `json:"finding"` +} + +// parseGovulncheck decodes the streamed JSON output of govulncheck into +// findings, deduplicating by OSV id. Split out from runGovulncheck for testing. +func parseGovulncheck(data []byte) []Finding { + dec := json.NewDecoder(bytes.NewReader(data)) + summaries := map[string]string{} + type vuln struct { + module, version string + } + vulns := map[string]vuln{} + for dec.More() { + var msg govulnMessage + if err := dec.Decode(&msg); err != nil { + break + } + if msg.OSV != nil { + summaries[msg.OSV.ID] = msg.OSV.Summary + } + if msg.Finding != nil && msg.Finding.OSV != "" { + v := vulns[msg.Finding.OSV] + for _, t := range msg.Finding.Trace { + if t.Module != "" { + v.module = t.Module + v.version = t.Version + break + } + } + vulns[msg.Finding.OSV] = v + } + } + var findings []Finding + for id, v := range vulns { + detail := id + if s := summaries[id]; s != "" { + detail = fmt.Sprintf("%s: %s", id, s) + } + findings = append(findings, Finding{ + Severity: SeverityHigh, + Category: CategoryVulnerability, + Module: v.module, + Version: v.version, + Detail: detail, + }) + } + return findings +} + +// runUpdateCheck runs `go list -m -u -json all` in dir to find modules with a +// newer version available. It degrades gracefully when the module graph cannot +// be loaded (e.g. offline), returning a note instead of findings. +func runUpdateCheck(dir string) ([]Finding, string) { + cmd := exec.Command("go", "list", "-m", "-u", "-json", "all") + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Sprintf("could not check for updates (`go list -m -u` failed: %v); network may be unavailable", err) + } + return parseUpdateList(stdout.Bytes()), "" +} + +// goListModule is one module object from `go list -m -u -json all`. +type goListModule struct { + Path string `json:"Path"` + Version string `json:"Version"` + Indirect bool `json:"Indirect"` + Main bool `json:"Main"` + Update *struct { + Version string `json:"Version"` + } `json:"Update"` +} + +// parseUpdateList decodes the streamed JSON output of `go list -m -u -json all` +// into outdated-module findings. Split out from runUpdateCheck for testing. +func parseUpdateList(data []byte) []Finding { + dec := json.NewDecoder(bytes.NewReader(data)) + var findings []Finding + for dec.More() { + var m goListModule + if err := dec.Decode(&m); err != nil { + break + } + if m.Main || m.Update == nil || m.Update.Version == "" { + continue + } + scope := "direct" + sev := SeverityLow + if m.Indirect { + scope = "indirect" + } + findings = append(findings, Finding{ + Severity: sev, + Category: CategoryOutdated, + Module: m.Path, + Version: m.Version, + Detail: fmt.Sprintf("%s dependency is outdated; %s is available", scope, m.Update.Version), + }) + } + return findings +} From cb39a38a0875676939beb123b151ca7cd3d8055c Mon Sep 17 00:00:00 2001 From: tester Date: Thu, 14 May 2026 02:50:07 -0700 Subject: [PATCH 2/2] fix: surface go.sum unavailability in depscan report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit countGoSumModules now returns a found-bool so Scan can distinguish "no hashed modules" from a missing/unreadable go.sum, appending a note to the report in the latter case instead of silently reporting 0. Adds TestCountGoSumModules covering both paths. Nightshift-Task: dependency-risk Nightshift-Ref: https://github.com/marcus/nightshift πŸ€– Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/depscan/depscan.go | 16 +++++++++++----- internal/depscan/depscan_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/internal/depscan/depscan.go b/internal/depscan/depscan.go index c4d98211..ea83b055 100644 --- a/internal/depscan/depscan.go +++ b/internal/depscan/depscan.go @@ -214,11 +214,13 @@ func decodeModInfo(data []byte) (*modInfo, error) { } // countGoSumModules counts the distinct module@version entries verified in -// go.sum (lines ending in a plain hash, not the /go.mod hash variant). -func countGoSumModules(goModPath string) int { +// go.sum (lines ending in a plain hash, not the /go.mod hash variant). The +// bool return reports whether go.sum was found and readable, so callers can +// distinguish "no hashed modules" from "no go.sum". +func countGoSumModules(goModPath string) (int, bool) { data, err := os.ReadFile(filepath.Join(filepath.Dir(goModPath), "go.sum")) if err != nil { - return 0 + return 0, false } seen := map[string]struct{}{} for _, line := range strings.Split(string(data), "\n") { @@ -232,7 +234,7 @@ func countGoSumModules(goModPath string) int { } seen[fields[0]+"@"+fields[1]] = struct{}{} } - return len(seen) + return len(seen), true } // goMinor extracts the minor version from a Go version string such as @@ -332,7 +334,11 @@ func Scan(opts Options) (*Report, error) { report.Summary.DirectDeps = direct report.Summary.IndirectDeps = indirect - report.Summary.GoSumModules = countGoSumModules(goModPath) + if n, ok := countGoSumModules(goModPath); ok { + report.Summary.GoSumModules = n + } else if len(mi.Require) > 0 { + report.Notes = append(report.Notes, "go.sum not found or unreadable; hash-verified module count unavailable") + } finalize(report) return report, nil } diff --git a/internal/depscan/depscan_test.go b/internal/depscan/depscan_test.go index 10af541d..7b209ef4 100644 --- a/internal/depscan/depscan_test.go +++ b/internal/depscan/depscan_test.go @@ -1,6 +1,8 @@ package depscan import ( + "os" + "path/filepath" "testing" ) @@ -258,6 +260,28 @@ func TestFilterBySeverity(t *testing.T) { } } +func TestCountGoSumModules(t *testing.T) { + dir := t.TempDir() + goMod := filepath.Join(dir, "go.mod") + + // Missing go.sum: ok == false. + if n, ok := countGoSumModules(goMod); ok || n != 0 { + t.Errorf("missing go.sum: got (%d, %v), want (0, false)", n, ok) + } + + // Present go.sum: distinct module@version, ignoring /go.mod hash lines. + sum := "example.com/a v1.0.0 h1:aaa=\n" + + "example.com/a v1.0.0/go.mod h1:bbb=\n" + + "example.com/b v2.1.0 h1:ccc=\n" + + "example.com/b v2.1.0/go.mod h1:ddd=\n" + if err := os.WriteFile(filepath.Join(dir, "go.sum"), []byte(sum), 0o644); err != nil { + t.Fatalf("write go.sum: %v", err) + } + if n, ok := countGoSumModules(goMod); !ok || n != 2 { + t.Errorf("present go.sum: got (%d, %v), want (2, true)", n, ok) + } +} + func TestParseGovulncheck(t *testing.T) { // Two streamed messages: one OSV definition, one finding referencing it. data := []byte(`{"osv":{"id":"GO-2024-0001","summary":"Example vuln in pkg"}}