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

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

"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"

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

var depsCmd = &cobra.Command{
Use: "deps",
Short: "Scan dependencies for security, maintenance, and license risks",
Long: `Scan Go module dependencies across managed projects for:
- Security vulnerabilities (via OSV.dev)
- Maintenance risks (archived repos, stale projects)
- License concerns (copyleft, unknown licenses)

Results are scored Critical/High/Medium/Low and stored in the database.`,
RunE: func(cmd *cobra.Command, args []string) error {
project, _ := cmd.Flags().GetString("project")
jsonOutput, _ := cmd.Flags().GetBool("json")
dbPath, _ := cmd.Flags().GetString("db")

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

func init() {
depsCmd.Flags().StringP("project", "p", "", "Scan a specific project path")
depsCmd.Flags().Bool("json", false, "Output results as JSON")
depsCmd.Flags().String("db", "", "Database path (uses config if not set)")
rootCmd.AddCommand(depsCmd)
}

func runDeps(project string, jsonOutput bool, dbPath string) error {
logger := logging.Component("deps")

if dbPath == "" {
cfg, err := config.Load()
if err != nil {
logger.Warnf("could not load config for db path, using default: %v", err)
} else {
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())
if err := store.InitTables(); err != nil {
return fmt.Errorf("initializing tables: %w", err)
}

githubToken := os.Getenv("GITHUB_TOKEN")
scanner := deps.NewScanner(store, githubToken, logger)

ctx := context.Background()

var paths []string
if project != "" {
paths = []string{project}
} else {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
for _, p := range cfg.Projects {
paths = append(paths, p.Path)
}
if len(paths) == 0 {
return fmt.Errorf("no projects configured; use --project to specify a path")
}
}

results, scanErr := scanner.ScanAll(ctx, paths)

if jsonOutput {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(results); err != nil {
return fmt.Errorf("encoding JSON: %w", err)
}
} else {
renderResults(results, scanErr)
}

for _, r := range results {
for _, f := range r.Findings {
if f.RiskLevel == deps.RiskCritical {
if scanErr != nil {
fmt.Fprintf(os.Stderr, "\nWarning: scan completed with errors: %v\n", scanErr)
}
os.Exit(1)
}
}
}

if scanErr != nil {
fmt.Fprintf(os.Stderr, "\nWarning: scan completed with errors: %v\n", scanErr)
}

return nil
}

func renderResults(results []*deps.ScanResult, scanErr error) {
criticalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
highStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true)
mediumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226"))
lowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
headerStyle := lipgloss.NewStyle().Bold(true).Underline(true)

for _, result := range results {
critical, high, medium, low := deps.SummarizeResults(result)
fmt.Printf("\n%s (%d deps)\n", headerStyle.Render(result.Project), len(result.Deps))
fmt.Printf(" Critical: %s High: %s Medium: %s Low: %s\n",
criticalStyle.Render(fmt.Sprintf("%d", critical)),
highStyle.Render(fmt.Sprintf("%d", high)),
mediumStyle.Render(fmt.Sprintf("%d", medium)),
lowStyle.Render(fmt.Sprintf("%d", low)),
)

if len(result.Findings) == 0 {
fmt.Println(" No issues found.")
continue
}

fmt.Println()
for _, f := range result.Findings {
var styled string
badge := strings.ToUpper(string(f.RiskLevel))
switch f.RiskLevel {
case deps.RiskCritical:
styled = criticalStyle.Render(fmt.Sprintf(" [%s]", badge))
case deps.RiskHigh:
styled = highStyle.Render(fmt.Sprintf(" [%s]", badge))
case deps.RiskMedium:
styled = mediumStyle.Render(fmt.Sprintf(" [%s]", badge))
default:
styled = lowStyle.Render(fmt.Sprintf(" [%s]", badge))
}
fmt.Printf("%s %s\n", styled, f.Detail)
}
}

if scanErr != nil {
fmt.Fprintf(os.Stderr, "\nWarning: %v\n", scanErr)
}

fmt.Println()
}
3 changes: 2 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,7 @@ 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
modernc.org/sqlite v1.35.0
)

Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ 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/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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/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,
scanned_at DATETIME NOT NULL,
total_deps INTEGER,
critical_count INTEGER,
high_count INTEGER,
medium_count INTEGER,
low_count INTEGER
);

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,
risk_level TEXT NOT NULL,
category TEXT NOT NULL,
detail TEXT NOT NULL,
cve_id TEXT,
cvss_score REAL
);

CREATE INDEX IF NOT EXISTS idx_dep_scans_project ON dep_scans(project, scanned_at DESC);
CREATE INDEX IF NOT EXISTS idx_dep_findings_scan ON dep_findings(scan_id);
`

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

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

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

// ParseGoMod reads and parses a go.mod file from the given project path,
// returning all required 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)
}

replacements := make(map[string]struct {
mod string
version string
})
for _, rep := range f.Replace {
if rep.New.Path != "" {
replacements[rep.Old.Path] = struct {
mod string
version string
}{mod: rep.New.Path, version: rep.New.Version}
}
}

var deps []Dependency
for _, req := range f.Require {
mod := req.Mod.Path
ver := req.Mod.Version
if rep, ok := replacements[mod]; ok {
mod = rep.mod
ver = rep.version
}
deps = append(deps, Dependency{
Module: mod,
Version: ver,
Indirect: req.Indirect,
})
}

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

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

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

go 1.21

require (
github.com/foo/bar v1.2.3
github.com/baz/qux v0.1.0 // indirect
)
`,
wantDeps: []Dependency{
{Module: "github.com/foo/bar", Version: "v1.2.3", Indirect: false},
{Module: "github.com/baz/qux", Version: "v0.1.0", Indirect: true},
},
},
{
name: "replace directive",
content: `module example.com/mymod

go 1.21

require github.com/old/mod v1.0.0

replace github.com/old/mod => github.com/new/mod v2.0.0
`,
wantDeps: []Dependency{
{Module: "github.com/new/mod", Version: "v2.0.0", Indirect: false},
},
},
{
name: "empty go.mod",
content: "module example.com/mymod\n\ngo 1.21\n",
},
}

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

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

if len(deps) != len(tt.wantDeps) {
t.Fatalf("got %d deps, want %d", len(deps), len(tt.wantDeps))
}
for i, got := range deps {
want := tt.wantDeps[i]
if got.Module != want.Module || got.Version != want.Version || got.Indirect != want.Indirect {
t.Errorf("dep[%d] = %+v, want %+v", i, got, want)
}
}
})
}
}

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