Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions cmd/depscan.go
Original file line number Diff line number Diff line change
@@ -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)
}
144 changes: 144 additions & 0 deletions cmd/depscan_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
91 changes: 91 additions & 0 deletions docs/depscan.md
Original file line number Diff line number Diff line change
@@ -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 <high\|medium\|low>` | 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.
Loading