Cybersecurity-Projects/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/update.go

640 lines
14 KiB
Go

/*
©AngelaMos | 2026
update.go
CLI commands and scan orchestration for angela
Defines all five cobra commands (init, update, check, scan, cache) and
the core runUpdate and runScan functions that coordinate PyPI version
fetching, OSV vulnerability scanning, dependency file writes, and result
output. The verbose and minSeverity flags are package-level and shared
with output.go.
Key exports:
Execute - entry point called by main; sets up and runs the cobra root command
Connects to:
main.go - Execute called from main
config.go - calls Load before each run
pypi/client.go - creates Client and calls FetchAllVersions
osv/client.go - calls NewClient and ScanPackages
pyproject/parser.go - calls ParseFile and ExtractMinVersion
requirements/parser.go - calls ParseFile when the target file ends in .txt
pyproject/writer.go - calls UpdateFile after resolving new versions
requirements/writer.go - calls UpdateFile when the target file ends in .txt
output.go - calls all Print functions to render results
ui - uses PrintBanner and NewSpinner
*/
package cli
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/CarterPerez-dev/angela/internal/config"
"github.com/CarterPerez-dev/angela/internal/osv"
"github.com/CarterPerez-dev/angela/internal/pypi"
"github.com/CarterPerez-dev/angela/internal/pyproject"
"github.com/CarterPerez-dev/angela/internal/requirements"
"github.com/CarterPerez-dev/angela/internal/ui"
"github.com/CarterPerez-dev/angela/pkg/types"
"github.com/spf13/cobra"
)
var (
verbose bool
minSeverity string
)
type updateFlags struct {
file string
safe bool
vulns bool
includePrerelease bool
}
func defaultCacheDir() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".angela", "cache")
}
// Execute sets up the CLI and runs the root command
func Execute() {
root := &cobra.Command{
Use: "angela",
Short: "Python dependency updater and vulnerability scanner",
Long: `angela scans your pyproject.toml, updates dependencies to their
latest stable versions, and checks for known CVEs using OSV.dev.`,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRun: func(
_ *cobra.Command, _ []string,
) {
ui.PrintBanner()
},
}
defaultHelp := root.HelpFunc()
root.SetHelpFunc(
func(cmd *cobra.Command, args []string) {
if cmd.Root() == cmd {
ui.PrintBannerWithArt()
} else {
ui.PrintBanner()
}
defaultHelp(cmd, args)
},
)
root.PersistentFlags().BoolVarP(
&verbose, "verbose", "v", false,
"show full vulnerability details",
)
root.PersistentFlags().StringVar(
&minSeverity, "min-severity", "",
"minimum severity to report (critical, high, moderate, low)",
)
root.AddCommand(
newInitCmd(),
newUpdateCmd(),
newCheckCmd(),
newScanCmd(),
newCacheCmd(),
)
if err := root.Execute(); err != nil {
PrintError(err.Error())
os.Exit(1)
}
}
const pyprojectTemplate = `[project]
name = "%s"
version = "0.1.0"
description = ""
requires-python = ">=3.13"
dependencies = []
[tool.angela]
# Minimum severity to report (critical, high, moderate, low)
# min-severity = "moderate"
# Dependencies to skip during updates
# ignore = []
# Vulnerability IDs to suppress (accepted risk)
# ignore-vulns = []
`
func newInitCmd() *cobra.Command {
return &cobra.Command{
Use: "init",
Short: "Create a new pyproject.toml with angela configuration",
RunE: func(_ *cobra.Command, _ []string) error {
return runInit()
},
}
}
func runInit() error {
if _, err := os.Stat("pyproject.toml"); err == nil {
return fmt.Errorf("pyproject.toml already exists")
}
dir, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
name := filepath.Base(dir)
content := fmt.Sprintf(pyprojectTemplate, name)
if err := os.WriteFile( //nolint:gosec // pyproject.toml needs 0644 for uv/pip reads
"pyproject.toml",
[]byte(content),
0o644,
); err != nil {
return fmt.Errorf("write pyproject.toml: %w", err)
}
fmt.Printf(
"\n %s %s\n\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Created pyproject.toml"),
)
return nil
}
func newUpdateCmd() *cobra.Command {
f := &updateFlags{}
cmd := &cobra.Command{
Use: "update [path]",
Aliases: []string{"u"},
Short: "Update dependencies to latest stable versions",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
f.file = resolveFile(f.file, args)
return runUpdate(cmd.Context(), f, false)
},
}
cmd.Flags().StringVarP(
&f.file, "file", "f", "pyproject.toml",
"path to dependency file",
)
cmd.Flags().BoolVar(
&f.safe, "safe", false,
"skip major version bumps",
)
cmd.Flags().BoolVar(
&f.vulns, "vulns", false,
"also scan for vulnerabilities",
)
cmd.Flags().BoolVar(
&f.includePrerelease, "include-prerelease", false,
"include pre-release versions",
)
return cmd
}
func newCheckCmd() *cobra.Command {
f := &updateFlags{}
cmd := &cobra.Command{
Use: "check [path]",
Aliases: []string{"c"},
Short: "Show available updates without modifying files",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
f.file = resolveFile(f.file, args)
return runUpdate(cmd.Context(), f, true)
},
}
cmd.Flags().StringVarP(
&f.file, "file", "f", "pyproject.toml",
"path to dependency file",
)
cmd.Flags().BoolVar(
&f.safe, "safe", false,
"skip major version bumps",
)
cmd.Flags().BoolVar(
&f.vulns, "vulns", false,
"also scan for vulnerabilities",
)
cmd.Flags().BoolVar(
&f.includePrerelease, "include-prerelease", false,
"include pre-release versions",
)
return cmd
}
func newScanCmd() *cobra.Command {
var file string
cmd := &cobra.Command{
Use: "scan [path]",
Aliases: []string{"s"},
Short: "Scan dependencies for known vulnerabilities",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
file = resolveFile(file, args)
return runScan(cmd.Context(), file)
},
}
cmd.Flags().StringVarP(
&file, "file", "f", "pyproject.toml",
"path to dependency file",
)
return cmd
}
func newCacheCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "Manage the local response cache",
}
cmd.AddCommand(&cobra.Command{
Use: "clear",
Short: "Remove all cached PyPI responses",
RunE: func(_ *cobra.Command, _ []string) error {
client, err := pypi.NewClient(defaultCacheDir())
if err != nil {
return err
}
if err := client.ClearCache(); err != nil {
return err
}
fmt.Printf(
" %s %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Cache cleared."),
)
return nil
},
})
return cmd
}
func runUpdate(
ctx context.Context,
f *updateFlags,
dryRun bool,
) error {
start := time.Now()
cfg := config.Load(f.file)
deps, err := parseDeps(f.file)
if err != nil {
return err
}
spin := ui.NewSpinner(fmt.Sprintf(
"Scanning %d dependencies...", len(deps),
))
spin.Start()
client, err := pypi.NewClient(defaultCacheDir())
if err != nil {
spin.Stop()
return err
}
names := make([]string, len(deps))
for i, d := range deps {
names[i] = d.Name
}
fetched := client.FetchAllVersions(ctx, names)
versionMap := make(map[string][]string, len(fetched))
for _, r := range fetched {
if r.Err == nil {
versionMap[r.Name] = r.Versions
}
}
updates, updateSpecs := resolveUpdates(
deps, versionMap, f.safe,
f.includePrerelease, cfg.Ignore,
)
sortUpdates(updates)
var vulns map[string][]types.Vulnerability
var scanErr error
if f.vulns {
vulns, scanErr = scanForVulns(ctx, deps)
}
spin.Stop()
if scanErr != nil {
PrintError(scanErr.Error())
}
if !dryRun && len(updateSpecs) > 0 {
if err := updateDepsFile(
f.file, updateSpecs,
); err != nil {
return fmt.Errorf("write updates: %w", err)
}
}
minSev := resolveMinSeverity(cfg.MinSeverity)
if vulns != nil {
vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns)
vulns = filterVulnsBySeverity(vulns, minSev)
PrintVulnerabilities(vulns)
}
PrintUpdates(updates)
PrintSkipped(updates)
totalVulns := 0
for _, vl := range vulns {
totalVulns += len(vl)
}
PrintSummary(types.ScanResult{
Updates: updates,
Vulnerabilities: vulns,
TotalPackages: len(deps),
TotalUpdated: len(updateSpecs),
TotalVulns: totalVulns,
VulnsScanned: f.vulns,
Duration: time.Since(start),
}, !dryRun && len(updateSpecs) > 0)
return nil
}
func runScan(ctx context.Context, file string) error {
start := time.Now()
cfg := config.Load(file)
deps, err := parseDeps(file)
if err != nil {
return err
}
spin := ui.NewSpinner(fmt.Sprintf(
"Scanning %d dependencies for vulnerabilities...",
len(deps),
))
spin.Start()
minSev := resolveMinSeverity(cfg.MinSeverity)
vulns, scanErr := scanForVulns(ctx, deps)
spin.Stop()
if scanErr != nil {
PrintError(scanErr.Error())
}
vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns)
vulns = filterVulnsBySeverity(vulns, minSev)
PrintVulnerabilities(vulns)
totalVulns := 0
for _, vl := range vulns {
totalVulns += len(vl)
}
PrintSummary(types.ScanResult{
TotalPackages: len(deps),
TotalVulns: totalVulns,
VulnsScanned: true,
Duration: time.Since(start),
}, false)
return nil
}
func resolveUpdates(
deps []types.Dependency,
versionMap map[string][]string,
safe bool,
includePrerelease bool,
ignoreDeps []string,
) ([]types.UpdateResult, map[string]string) {
var results []types.UpdateResult
specs := make(map[string]string)
ignoreSet := make(map[string]bool, len(ignoreDeps))
for _, name := range ignoreDeps {
ignoreSet[pypi.NormalizeName(name)] = true
}
for _, dep := range deps {
if ignoreSet[pypi.NormalizeName(dep.Name)] {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: "ignored in config",
})
continue
}
versions, ok := versionMap[dep.Name]
if !ok {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: "not found on PyPI",
})
continue
}
currentStr := pyproject.ExtractMinVersion(dep.Spec)
if currentStr == "" {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: "no version specifier",
})
continue
}
current, err := pypi.ParseVersion(currentStr)
if err != nil {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: "unparseable version",
})
continue
}
var latest pypi.Version
if includePrerelease {
latest, err = latestAny(versions)
} else {
latest, err = pypi.LatestStable(versions)
}
if err != nil {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: err.Error(),
})
continue
}
if latest.Compare(current) <= 0 {
continue
}
change := pypi.ClassifyChange(current, latest)
if safe && change == pypi.Major {
results = append(results, types.UpdateResult{
Name: dep.Name,
OldVer: current.String(),
NewVer: latest.String(),
Change: change.String(),
Skipped: true,
Reason: "major bump (use --all to include)",
})
continue
}
newSpec := ">=" + latest.String()
results = append(results, types.UpdateResult{
Name: dep.Name,
OldVer: current.String(),
NewVer: latest.String(),
OldSpec: dep.Spec,
NewSpec: newSpec,
Change: change.String(),
})
specs[dep.Name] = newSpec
}
return results, specs
}
func scanForVulns(
ctx context.Context,
deps []types.Dependency,
) (map[string][]types.Vulnerability, error) {
var queries []osv.PackageQuery
for _, dep := range deps {
ver := pyproject.ExtractMinVersion(dep.Spec)
if ver == "" {
continue
}
queries = append(queries, osv.PackageQuery{
Name: dep.Name,
Version: ver,
})
}
if len(queries) == 0 {
return nil, nil
}
client := osv.NewClient()
vulns, err := client.ScanPackages(ctx, queries)
if err != nil {
return nil, fmt.Errorf(
"vulnerability scan: %w", err,
)
}
return vulns, nil
}
func latestAny(versions []string) (pypi.Version, error) {
var latest pypi.Version
var found bool
for _, raw := range versions {
v, err := pypi.ParseVersion(raw)
if err != nil {
continue
}
if !found || v.Compare(latest) > 0 {
latest = v
found = true
}
}
if !found {
return pypi.Version{}, fmt.Errorf("no versions found")
}
return latest, nil
}
func resolveFile(flagVal string, args []string) string {
if len(args) > 0 {
path := args[0]
info, err := os.Stat(path)
if err == nil && info.IsDir() {
return filepath.Join(path, "pyproject.toml")
}
return path
}
return flagVal
}
func isRequirementsTxt(path string) bool {
return strings.HasSuffix(strings.ToLower(path), ".txt")
}
func parseDeps(file string) ([]types.Dependency, error) {
if isRequirementsTxt(file) {
return requirements.ParseFile(file)
}
return pyproject.ParseFile(file)
}
func updateDepsFile(file string, specs map[string]string) error {
if isRequirementsTxt(file) {
return requirements.UpdateFile(file, specs)
}
return pyproject.UpdateFile(file, specs)
}
func resolveMinSeverity(configVal string) string {
if minSeverity != "" {
return minSeverity
}
if configVal != "" {
return configVal
}
return "low"
}
func sortUpdates(updates []types.UpdateResult) {
order := map[string]int{
pypi.Major.String(): 0,
pypi.Minor.String(): 1,
pypi.Patch.String(): 2,
}
sort.Slice(updates, func(i, j int) bool {
if updates[i].Skipped != updates[j].Skipped {
return !updates[i].Skipped
}
oi := order[updates[i].Change]
oj := order[updates[j].Change]
if oi != oj {
return oi < oj
}
return updates[i].Name < updates[j].Name
})
}