615 lines
12 KiB
Go
615 lines
12 KiB
Go
// ©AngelaMos | 2026
|
|
// update.go
|
|
|
|
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(
|
|
"pyproject.toml",
|
|
[]byte(content),
|
|
0o644,
|
|
); err != nil { //nolint:gosec
|
|
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
|
|
})
|
|
}
|