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
152 changes: 152 additions & 0 deletions cmd/nightshift/commands/deps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package commands

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/marcus/nightshift/internal/config"
"github.com/marcus/nightshift/internal/db"
"github.com/marcus/nightshift/internal/deps"
)

var depsCmd = &cobra.Command{
Use: "deps [path]",
Short: "Scan dependencies for security and maintenance risks",
Long: `Analyze Go module dependencies for:
- Security vulnerabilities (via OSV.dev)
- Maintenance health (via GitHub API)
- License risks (via heuristic matching)

Results are scored by severity and optionally saved to the database.
Set GITHUB_TOKEN for enhanced GitHub API rate limits.`,
RunE: func(cmd *cobra.Command, args []string) error {
project, _ := cmd.Flags().GetString("project")
jsonOutput, _ := cmd.Flags().GetBool("json")
save, _ := cmd.Flags().GetBool("save")
dbPath, _ := cmd.Flags().GetString("db")

if project == "" && len(args) > 0 {
project = args[0]
}
if project == "" {
var err error
project, err = os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
}

return runDeps(project, jsonOutput, save, dbPath)
},
}

func init() {
depsCmd.Flags().StringP("project", "p", "", "Project path containing go.mod")
depsCmd.Flags().Bool("json", false, "Output as JSON")
depsCmd.Flags().Bool("save", false, "Save results to database")
depsCmd.Flags().String("db", "", "Database path (uses config if not set)")
rootCmd.AddCommand(depsCmd)
}

func runDeps(project string, jsonOutput, save bool, dbPath string) error {
scanner := deps.NewScanner(deps.ScanOptions{
ProjectPath: project,
Concurrency: 5,
})

result, err := scanner.Scan(cmdContext())
if err != nil {
return fmt.Errorf("scanning dependencies: %w", err)
}

if jsonOutput {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}

// Terminal output
printDepsResult(result)

// Save if requested
if save {
if err := saveDepsResult(result, dbPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to save results: %v\n", err)
}
}

return nil
}

func printDepsResult(result *deps.ScanResult) {
fmt.Printf("Dependency Risk Scan: %s\n", result.Project)
fmt.Printf("Duration: %s | Dependencies: %d (%d direct)\n\n", result.Duration, result.TotalDeps, result.DirectDeps)

if len(result.Findings) == 0 {
fmt.Println("No risk findings detected.")
return
}

fmt.Printf("Findings: %d total\n", len(result.Findings))
for level, count := range result.Summary {
fmt.Printf(" %s: %d\n", riskLabel(level), count)
}
fmt.Println()

for _, f := range result.Findings {
fmt.Printf(" %s [%s] %s\n", riskLabel(f.Risk), f.Category, f.Title)
fmt.Printf(" %s@%s\n", f.Module, f.Version)
if f.Description != "" {
fmt.Printf(" %s\n", f.Description)
}
fmt.Println()
}
}

func riskLabel(level deps.RiskLevel) string {
switch level {
case deps.RiskCritical:
return "\033[31mCRITICAL\033[0m"
case deps.RiskHigh:
return "\033[91mHIGH\033[0m"
case deps.RiskMedium:
return "\033[33mMEDIUM\033[0m"
case deps.RiskLow:
return "\033[36mLOW\033[0m"
default:
return "NONE"
}
}

func saveDepsResult(result *deps.ScanResult, dbPath string) error {
if dbPath == "" {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
dbPath = cfg.ExpandedDBPath()
}

database, err := db.Open(dbPath)
if err != nil {
return fmt.Errorf("opening database: %w", err)
}
defer func() { _ = database.Close() }()

store := deps.NewStore(database.SQL())
scanID, err := store.SaveScanResult(result)
if err != nil {
return fmt.Errorf("saving result: %w", err)
}

fmt.Fprintf(os.Stderr, "Results saved (scan ID: %d)\n", scanID)
return nil
}

func cmdContext() context.Context {
return context.Background()
}
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/marcus/nightshift

go 1.24.0
go 1.25.0

require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
Expand All @@ -13,6 +13,8 @@ require (
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
golang.org/x/mod v0.35.0
golang.org/x/sync v0.20.0
modernc.org/sqlite v1.35.0
)

Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand All @@ -118,8 +118,8 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
33 changes: 33 additions & 0 deletions internal/db/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ var migrations = []Migration{
Description: "add branch column to run_history",
SQL: migration005SQL,
},
{
Version: 6,
Description: "add dep_scans and dep_findings tables for dependency risk scanning",
SQL: migration006SQL,
},
}

const migration002SQL = `
Expand Down Expand Up @@ -121,6 +126,34 @@ const migration005SQL = `
ALTER TABLE run_history ADD COLUMN branch TEXT NOT NULL DEFAULT '';
`

const migration006SQL = `
CREATE TABLE IF NOT EXISTS dep_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL,
timestamp DATETIME NOT NULL,
duration_ms INTEGER NOT NULL,
total_deps INTEGER NOT NULL,
direct_deps INTEGER NOT NULL,
summary TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS dep_findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL REFERENCES dep_scans(id),
module TEXT NOT NULL,
version TEXT NOT NULL,
category TEXT NOT NULL,
risk TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
reference TEXT
);

CREATE INDEX IF NOT EXISTS idx_dep_scans_project ON dep_scans(project, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_dep_findings_scan ON dep_findings(scan_id);
CREATE INDEX IF NOT EXISTS idx_dep_findings_risk ON dep_findings(risk, module);
`

// Migrate runs all pending migrations inside transactions.
func Migrate(db *sql.DB) error {
if db == nil {
Expand Down
43 changes: 43 additions & 0 deletions internal/deps/gomod.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package deps

import (
"fmt"
"os"
"path/filepath"

"golang.org/x/mod/modfile"
)

// ParseGoMod reads and parses go.mod at the given project path,
// returning all dependencies.
func ParseGoMod(projectPath string) ([]Dependency, error) {
goModPath := filepath.Join(projectPath, "go.mod")
data, err := os.ReadFile(goModPath)
if err != nil {
return nil, fmt.Errorf("reading go.mod: %w", err)
}

f, err := modfile.Parse(goModPath, data, nil)
if err != nil {
return nil, fmt.Errorf("parsing go.mod: %w", err)
}

// Collect indirect modules for lookup
indirect := make(map[string]bool)
for _, req := range f.Require {
if req.Indirect {
indirect[req.Mod.Path] = true
}
}

var deps []Dependency
for _, req := range f.Require {
deps = append(deps, Dependency{
Module: req.Mod.Path,
Version: req.Mod.Version,
Direct: !indirect[req.Mod.Path],
})
}

return deps, nil
}
104 changes: 104 additions & 0 deletions internal/deps/gomod_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package deps

import (
"os"
"path/filepath"
"testing"
)

func TestParseGoMod(t *testing.T) {
tests := []struct {
name string
content string
wantDeps int
wantDirect int
wantErr bool
}{
{
name: "simple module with direct and indirect deps",
content: `module example.com/myproject

go 1.21

require (
github.com/foo/bar v1.2.3
github.com/baz/qux v0.1.0 // indirect
)
`,
wantDeps: 2,
wantDirect: 1,
},
{
name: "no dependencies",
content: `module example.com/empty

go 1.21
`,
wantDeps: 0,
wantDirect: 0,
},
{
name: "invalid go.mod",
content: `this is not valid`,
wantErr: true,
},
{
name: "multiple direct deps",
content: `module example.com/multi

go 1.22

require (
github.com/a/b v1.0.0
github.com/c/d v0.9.0
github.com/e/f v1.2.3
github.com/g/h v0.5.0 // indirect
)
`,
wantDeps: 4,
wantDirect: 3,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(tt.content), 0644)
if err != nil {
t.Fatal(err)
}

deps, err := ParseGoMod(dir)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if len(deps) != tt.wantDeps {
t.Errorf("got %d deps, want %d", len(deps), tt.wantDeps)
}

directCount := 0
for _, d := range deps {
if d.Direct {
directCount++
}
}
if directCount != tt.wantDirect {
t.Errorf("got %d direct deps, want %d", directCount, tt.wantDirect)
}
})
}
}

func TestParseGoMod_MissingFile(t *testing.T) {
_, err := ParseGoMod(t.TempDir())
if err == nil {
t.Fatal("expected error for missing go.mod")
}
}
Loading