From d9445e06b5264cc0142698c29bd2262a83530390 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 27 Jan 2026 23:17:50 -0500 Subject: [PATCH 01/10] feat: complete phase 1 intial build --- .../simple-vulnerability-scanner/.gitignore | 36 + .../.golangci.yml | 113 ++++ .../simple-vulnerability-scanner/Justfile | 134 ++++ .../simple-vulnerability-scanner/README.md | 207 ++++++ .../cmd/angela/main.go | 10 + PROJECTS/simple-vulnerability-scanner/go.mod | 18 + PROJECTS/simple-vulnerability-scanner/go.sum | 25 + .../internal/cli/output.go | 218 ++++++ .../internal/cli/update.go | 416 ++++++++++++ .../internal/osv/client.go | 401 +++++++++++ .../internal/osv/client_test.go | 254 +++++++ .../internal/pypi/cache.go | 105 +++ .../internal/pypi/cache_test.go | 200 ++++++ .../internal/pypi/client.go | 225 +++++++ .../internal/pypi/client_test.go | 42 ++ .../internal/pypi/version.go | 313 +++++++++ .../internal/pypi/version_test.go | 623 ++++++++++++++++++ .../internal/pyproject/parser.go | 117 ++++ .../internal/pyproject/parser_test.go | 159 +++++ .../internal/pyproject/writer.go | 141 ++++ .../internal/pyproject/writer_test.go | 177 +++++ .../learn/GO-CONCURRENCY-PATTERNS.md | 190 ++++++ .../learn/PEP440-VERSION-PARSING.md | 143 ++++ .../learn/TOML-COMMENT-PRESERVATION.md | 169 +++++ .../pkg/types/types.go | 48 ++ .../testdata/pyproject.toml | 23 + 26 files changed, 4507 insertions(+) create mode 100644 PROJECTS/simple-vulnerability-scanner/.gitignore create mode 100644 PROJECTS/simple-vulnerability-scanner/.golangci.yml create mode 100644 PROJECTS/simple-vulnerability-scanner/Justfile create mode 100644 PROJECTS/simple-vulnerability-scanner/README.md create mode 100644 PROJECTS/simple-vulnerability-scanner/cmd/angela/main.go create mode 100644 PROJECTS/simple-vulnerability-scanner/go.mod create mode 100644 PROJECTS/simple-vulnerability-scanner/go.sum create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/cli/output.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/cli/update.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/osv/client.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/osv/client_test.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pypi/cache.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pypi/cache_test.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pypi/client.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pypi/client_test.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pypi/version.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pypi/version_test.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pyproject/parser.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pyproject/parser_test.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pyproject/writer.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/pyproject/writer_test.go create mode 100644 PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md create mode 100644 PROJECTS/simple-vulnerability-scanner/learn/PEP440-VERSION-PARSING.md create mode 100644 PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md create mode 100644 PROJECTS/simple-vulnerability-scanner/pkg/types/types.go create mode 100644 PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml diff --git a/PROJECTS/simple-vulnerability-scanner/.gitignore b/PROJECTS/simple-vulnerability-scanner/.gitignore new file mode 100644 index 00000000..abe0ce23 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/.gitignore @@ -0,0 +1,36 @@ +# AngelaMos | 2026 +# .gitignore + +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +.meep + +# Test +*.test + +# Build +tmp/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Debug +__debug_bin* diff --git a/PROJECTS/simple-vulnerability-scanner/.golangci.yml b/PROJECTS/simple-vulnerability-scanner/.golangci.yml new file mode 100644 index 00000000..3719239e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/.golangci.yml @@ -0,0 +1,113 @@ +# AngelaMos | 2026 +# .golangci.yml + +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - gosec + - bodyclose + - nilerr + - errorlint + - exhaustive + - gocritic + - funlen + - gocognit + - dupl + - goconst + - ineffassign + - unused + - unconvert + - unparam + - testifylint + - fatcontext + + settings: + errcheck: + check-type-assertions: true + check-blank: true + + funlen: + lines: 100 + statements: 50 + + gocognit: + min-complexity: 20 + + govet: + enable-all: true + disable: + - fieldalignment + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-declaration + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + + staticcheck: + checks: + - all + + gosec: + excludes: + - G104 + + sloglint: + no-mixed-args: true + kv-only: true + context: all + +issues: + max-same-issues: 50 + exclude-dirs: + - vendor + - testdata + exclude-rules: + - path: _test\.go + linters: + - funlen + - dupl + - goconst + + +formatters: + enable: + - gci # Groups imports + - gofumpt # Whitespace + - golines # Vertical wrap + settings: + golines: + max-len: 80 + reformat-tags: true + goimports: + local-prefixes: + - github.com/carterperez-dev/angela + gci: + sections: + - standard + - default + - prefix(github.com/carterperez-dev) + custom-order: true + gofumpt: + extra-rules: true diff --git a/PROJECTS/simple-vulnerability-scanner/Justfile b/PROJECTS/simple-vulnerability-scanner/Justfile new file mode 100644 index 00000000..78897c28 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/Justfile @@ -0,0 +1,134 @@ +# ============================================================================= +# AngelaMos | 2026 +# Justfile +# ============================================================================= +# angela — Python dependency updater and vulnerability scanner +# ============================================================================= + +set export +set shell := ["bash", "-uc"] + +project := file_name(justfile_directory()) +version := `git describe --tags --always 2>/dev/null || echo "dev"` + +# ============================================================================= +# Default +# ============================================================================= + +default: + @just --list --unsorted + +# ============================================================================= +# Linting and Formatting +# ============================================================================= + +[group('lint')] +lint *ARGS: + golangci-lint run --timeout=5m {{ARGS}} + +[group('lint')] +lint-fix: + golangci-lint run --timeout=5m --fix + +[group('lint')] +fmt: + gofumpt -w . + golines -w --max-len=80 . + +[group('lint')] +tidy: + go mod tidy + +[group('lint')] +vet: + go vet ./... + +# ============================================================================= +# Testing +# ============================================================================= + +[group('test')] +test *ARGS: + go test -race ./... {{ARGS}} + +[group('test')] +test-v *ARGS: + go test -race -v ./... {{ARGS}} + +[group('test')] +cover: + go test -race -cover ./... + +# ============================================================================= +# CI / Quality +# ============================================================================= + +[group('ci')] +ci: lint test + @echo "All checks passed." + +[group('ci')] +check: lint vet + +# ============================================================================= +# Development +# ============================================================================= + +[group('dev')] +run *ARGS: + go run ./cmd/angela {{ARGS}} + +[group('dev')] +dev-check: + go run ./cmd/angela check --file testdata/pyproject.toml + +[group('dev')] +dev-update: + go run ./cmd/angela update --file testdata/pyproject.toml + +[group('dev')] +dev-scan: + go run ./cmd/angela scan --file testdata/pyproject.toml + +[group('dev')] +dev-full: + go run ./cmd/angela update --scan-vulns --file testdata/pyproject.toml + +# ============================================================================= +# Build (Production) +# ============================================================================= + +[group('prod')] +build: + go build -ldflags="-s -w" -o bin/angela ./cmd/angela + @echo "Built: bin/angela ($(du -h bin/angela | cut -f1))" + +[group('prod')] +build-debug: + go build -o bin/angela ./cmd/angela + +[group('prod')] +install: + go install ./cmd/angela + +# ============================================================================= +# Utilities +# ============================================================================= + +[group('util')] +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "Go: $(go version | cut -d' ' -f3)" + @echo "OS: {{os()}} ({{arch()}})" + @echo "Module: $(head -1 go.mod | cut -d' ' -f2)" + +[group('util')] +clean: + -rm -rf bin/ + @echo "Cleaned build artifacts." + +[group('util')] +reset-cache: + rm -rf ~/.angela/cache/ + @echo "Cache cleared." diff --git a/PROJECTS/simple-vulnerability-scanner/README.md b/PROJECTS/simple-vulnerability-scanner/README.md new file mode 100644 index 00000000..9dbca503 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/README.md @@ -0,0 +1,207 @@ +# angela + +A fast CLI tool that updates Python dependencies in `pyproject.toml` and scans for known vulnerabilities using [OSV.dev](https://osv.dev). + +Built in Go for speed — parallel HTTP requests, local caching with ETag support, and a single binary with zero runtime dependencies. + +--- + +## Why + +The Python ecosystem lacks a single tool that updates all dependencies in `pyproject.toml` the way `pnpm update` works for JavaScript. Existing options either show outdated packages without updating them (`pip list --outdated`), upgrade one at a time (`pip install --upgrade`), or require CI infrastructure (Dependabot, Renovate). + +**angela** fills this gap: one command updates everything, with built-in CVE scanning. + +--- + +## Install + +```bash +go install github.com/CarterPerez-dev/angela@latest +``` + +Or build from source: + +```bash +git clone https://github.com/CarterPerez-dev/angela.git +cd angela +go build -o bin/angela ./cmd/angela +``` + +--- + +## Usage + +### Update all dependencies + +```bash +angela update +``` + +Reads `pyproject.toml` in the current directory, queries PyPI for the latest stable versions, and writes the updates back to the file. + +### Dry run (show what would change) + +```bash +angela check +``` + +Shows available updates and vulnerabilities without modifying any files. + +### Scan for vulnerabilities only + +```bash +angela scan +``` + +Checks all pinned dependencies against the OSV.dev vulnerability database. + +### Update with vulnerability scan + +```bash +angela update --scan-vulns +``` + +Updates dependencies and reports any known CVEs in your current versions. + +### Skip major version bumps + +```bash +angela update --safe +``` + +Only applies minor and patch updates, skipping anything that crosses a major version boundary. + +### Include pre-release versions + +```bash +angela update --include-prerelease +``` + +Considers alpha, beta, release candidate, and dev versions when resolving the latest. + +### Specify a different file + +```bash +angela update --file path/to/pyproject.toml +``` + +### Clear the local cache + +```bash +angela cache clear +``` + +Removes all cached PyPI responses from `~/.angela/cache/`. + +--- + +## Example output + +``` + Scanning 9 dependencies... + + Updates available: + django 3.2.0 -> 5.1.5 (major) + requests 2.28.0 -> 2.32.3 (minor) + click 8.0.0 -> 8.1.8 (patch) + pydantic 2.0.0 -> 2.10.6 (minor) + flask 2.0.0 -> 3.1.0 (major) + pytest 7.0.0 -> 8.3.4 (major) + black 23.0.0 -> 25.1.0 (major) + ruff 0.1.0 -> 0.9.4 (minor) + mypy 1.0.0 -> 1.14.1 (minor) + + Vulnerabilities found: + django + GHSA-2hrw-hx67-34x6 [CRITICAL] Potential denial-of-service in django.utils.text.Truncator + Fixed in: 4.2.16 + requests + GHSA-9wx4-h78v-vm56 [MEDIUM] Requests `Session` object does not verify requests after making first request with verify=False + Fixed in: 2.32.0 + + Updated pyproject.toml + 9 packages checked + 9 updated + 36 vulnerabilities found + Done in 2.4s +``` + +--- + +## How it works + +``` +1. Parse pyproject.toml + Extract all [project.dependencies] and [project.optional-dependencies] + +2. Query PyPI Simple API (parallel) + Fetch version lists using the lightweight JSON format + ETag-based caching avoids redundant downloads + +3. Resolve updates + Parse versions per PEP 440 (epochs, pre-releases, post-releases) + Filter to stable releases by default + Classify changes as major, minor, or patch + +4. Scan for vulnerabilities (optional) + Batch query OSV.dev for all dependencies + Hydrate results with full advisory details + Deduplicate overlapping CVE/GHSA identifiers + +5. Write updates + Regex-based surgery preserves comments and formatting + Atomic write via temp file + rename +``` + +--- + +## Architecture + +``` +cmd/angela/main.go CLI entry point +internal/ + cli/ + update.go Command definitions and orchestration + output.go Terminal formatting with color + pypi/ + version.go PEP 440 parser and comparator + client.go PyPI Simple API client with retry + cache.go File-backed ETag cache + osv/ + client.go OSV.dev batch vulnerability scanner + pyproject/ + parser.go TOML parsing and PEP 508 splitting + writer.go Comment-preserving regex updater +pkg/types/ + types.go Shared domain types +``` + +--- + +## Development + +Requires Go 1.24+ and [just](https://github.com/casey/just). + +```bash +just test # Run all tests with race detector +just lint # Run golangci-lint +just build # Build binary to bin/angela +just cover # Generate HTML coverage report +just check # Lint + test in one step +just run check # Run angela check via go run +``` + +--- + +## Part of Cybersecurity-Projects + +This tool is project #1 in the [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) repository — a collection of 60 security-focused projects built for learning and reference. The code is written to be educational: clear structure, proper error handling, and thorough testing. + +See the [`learn/`](learn/) directory for deep dives into the techniques used here. + +--- + +## License + +MIT diff --git a/PROJECTS/simple-vulnerability-scanner/cmd/angela/main.go b/PROJECTS/simple-vulnerability-scanner/cmd/angela/main.go new file mode 100644 index 00000000..e649746f --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/cmd/angela/main.go @@ -0,0 +1,10 @@ +// ©AngelaMos | 2026 +// main.go + +package main + +import "github.com/CarterPerez-dev/angela/internal/cli" + +func main() { + cli.Execute() +} diff --git a/PROJECTS/simple-vulnerability-scanner/go.mod b/PROJECTS/simple-vulnerability-scanner/go.mod new file mode 100644 index 00000000..de7bc54f --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/go.mod @@ -0,0 +1,18 @@ +module github.com/CarterPerez-dev/angela + +go 1.24.4 + +require ( + github.com/fatih/color v1.18.0 + github.com/pelletier/go-toml/v2 v2.2.4 + github.com/spf13/cobra v1.10.2 + golang.org/x/sync v0.19.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/PROJECTS/simple-vulnerability-scanner/go.sum b/PROJECTS/simple-vulnerability-scanner/go.sum new file mode 100644 index 00000000..8f7f53f3 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/go.sum @@ -0,0 +1,25 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go new file mode 100644 index 00000000..79aa1f7e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go @@ -0,0 +1,218 @@ +// ©AngelaMos | 2026 +// output.go + +package cli + +import ( + "fmt" + "strings" + + "github.com/CarterPerez-dev/angela/internal/pypi" + "github.com/CarterPerez-dev/angela/pkg/types" + "github.com/fatih/color" +) + +var ( + bold = color.New(color.Bold).SprintFunc() + dim = color.New(color.Faint).SprintFunc() + cyan = color.New(color.FgCyan, color.Bold).SprintFunc() + green = color.New(color.FgGreen).SprintFunc() + yellow = color.New(color.FgYellow).SprintFunc() + red = color.New(color.FgRed).SprintFunc() + redBold = color.New(color.FgRed, color.Bold).SprintFunc() + greenBold = color.New(color.FgGreen, color.Bold).SprintFunc() + white = color.New(color.FgWhite, color.Bold).SprintFunc() +) + +// PrintScanning announces the start of a scan +func PrintScanning(count int) { + fmt.Printf( + "\n %s %d dependencies...\n\n", + cyan("Scanning"), count, + ) +} + +// PrintUpdates displays a formatted table of available dependency updates +func PrintUpdates(updates []types.UpdateResult) { + var actionable []types.UpdateResult + for _, u := range updates { + if !u.Skipped { + actionable = append(actionable, u) + } + } + + if len(actionable) == 0 { + fmt.Printf(" %s\n\n", dim("All dependencies are up to date.")) + return + } + + fmt.Printf(" %s\n", cyan("Updates available:")) + + nameWidth := 0 + oldWidth := 0 + for _, u := range actionable { + if len(u.Name) > nameWidth { + nameWidth = len(u.Name) + } + if len(u.OldVer) > oldWidth { + oldWidth = len(u.OldVer) + } + } + + for _, u := range actionable { + changeColor := changeColorFn(u.Change) + fmt.Printf( + " %-*s %s %s %s %s\n", + nameWidth, white(u.Name), + dim(padRight(u.OldVer, oldWidth)), + dim("->"), + changeColor(u.NewVer), + dim("("+u.Change+")"), + ) + } + fmt.Println() +} + +// PrintSkipped displays dependencies that were not updated with reasons +func PrintSkipped(updates []types.UpdateResult) { + var skipped []types.UpdateResult + for _, u := range updates { + if u.Skipped { + skipped = append(skipped, u) + } + } + if len(skipped) == 0 { + return + } + + fmt.Printf(" %s\n", dim("Skipped:")) + for _, u := range skipped { + fmt.Printf(" %s %s\n", dim(u.Name), dim(u.Reason)) + } + fmt.Println() +} + +// PrintVulnerabilities displays a formatted list of security advisories +func PrintVulnerabilities( + vulns map[string][]types.Vulnerability, +) { + if len(vulns) == 0 { + return + } + + fmt.Printf(" %s\n", redBold("Vulnerabilities found:")) + + for pkg, vlist := range vulns { + fmt.Printf(" %s\n", white(pkg)) + for _, v := range vlist { + sevColor := severityColorFn(v.Severity) + fmt.Printf( + " %s %s %s\n", + bold(v.ID), + sevColor("["+v.Severity+"]"), + v.Summary, + ) + if v.FixedIn != "" { + fmt.Printf( + " %s %s\n", + dim("Fixed in:"), + green(v.FixedIn), + ) + } + if v.Link != "" { + fmt.Printf( + " %s\n", + dim(v.Link), + ) + } + } + } + fmt.Println() +} + +// PrintSummary displays final counts after an update or scan operation +func PrintSummary(result types.ScanResult, updated bool) { + if updated { + fmt.Printf(" %s\n", greenBold("Updated pyproject.toml")) + } + fmt.Printf( + " %s packages checked\n", + bold(fmt.Sprintf("%d", result.TotalPackages)), + ) + if result.TotalUpdated > 0 { + fmt.Printf( + " %s updated\n", + bold(fmt.Sprintf("%d", result.TotalUpdated)), + ) + } + if result.VulnsScanned { + if result.TotalVulns > 0 { + fmt.Printf( + " %s\n", + red(fmt.Sprintf( + "%d %s found", + result.TotalVulns, + pluralize("vulnerability", result.TotalVulns), + )), + ) + } else { + fmt.Printf( + " %s\n", + green("No vulnerabilities found"), + ) + } + } + fmt.Printf( + " %s %s\n\n", + dim("Done in"), + dim(result.Duration.Round(1e6).String()), + ) +} + +// PrintError displays a user-friendly error message +func PrintError(msg string) { + fmt.Printf("\n %s %s\n\n", red("error:"), msg) +} + +func changeColorFn(kind string) func(a ...any) string { + switch kind { + case pypi.Major.String(): + return red + case pypi.Minor.String(): + return yellow + default: + return green + } +} + +func severityColorFn(sev string) func(a ...any) string { + switch strings.ToUpper(sev) { + case "CRITICAL": + return redBold + case "HIGH": + return red + case "MEDIUM": + return yellow + case "LOW": + return cyan + default: + return dim + } +} + +func padRight(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} + +func pluralize(word string, n int) string { + if n == 1 { + return word + } + if strings.HasSuffix(word, "y") { + return word[:len(word)-1] + "ies" + } + return word + "s" +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go new file mode 100644 index 00000000..53b27c55 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -0,0 +1,416 @@ +// ©AngelaMos | 2026 +// update.go + +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "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/pkg/types" + "github.com/spf13/cobra" +) + +type updateFlags struct { + file string + safe bool + scanVulns 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, + } + + root.AddCommand( + newUpdateCmd(), + newCheckCmd(), + newScanCmd(), + newCacheCmd(), + ) + + if err := root.Execute(); err != nil { + PrintError(err.Error()) + os.Exit(1) + } +} + +func newUpdateCmd() *cobra.Command { + f := &updateFlags{} + + cmd := &cobra.Command{ + Use: "update", + Short: "Update dependencies to latest stable versions", + RunE: func(cmd *cobra.Command, _ []string) error { + return runUpdate(cmd.Context(), f, false) + }, + } + + cmd.Flags().StringVarP( + &f.file, "file", "f", "pyproject.toml", + "path to pyproject.toml", + ) + cmd.Flags().BoolVar( + &f.safe, "safe", false, + "skip major version bumps", + ) + cmd.Flags().BoolVar( + &f.scanVulns, "scan-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", + Short: "Show available updates without modifying files", + RunE: func(cmd *cobra.Command, _ []string) error { + f.scanVulns = true + return runUpdate(cmd.Context(), f, true) + }, + } + + cmd.Flags().StringVarP( + &f.file, "file", "f", "pyproject.toml", + "path to pyproject.toml", + ) + cmd.Flags().BoolVar( + &f.safe, "safe", false, + "skip major version bumps", + ) + 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", + Short: "Scan dependencies for known vulnerabilities", + RunE: func(cmd *cobra.Command, _ []string) error { + return runScan(cmd.Context(), file) + }, + } + + cmd.Flags().StringVarP( + &file, "file", "f", "pyproject.toml", + "path to pyproject.toml", + ) + 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.Println(" Cache cleared.") + return nil + }, + }) + + return cmd +} + +func runUpdate( + ctx context.Context, + f *updateFlags, + dryRun bool, +) error { + start := time.Now() + + deps, err := pyproject.ParseFile(f.file) + if err != nil { + return err + } + + PrintScanning(len(deps)) + + client, err := pypi.NewClient(defaultCacheDir()) + if err != nil { + 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, + ) + + sortUpdates(updates) + + var vulns map[string][]types.Vulnerability + if f.scanVulns { + vulns = scanForVulns(ctx, deps) + } + + if !dryRun && len(updateSpecs) > 0 { + if err := pyproject.UpdateFile(f.file, updateSpecs); err != nil { + return fmt.Errorf("write updates: %w", err) + } + } + + PrintUpdates(updates) + PrintSkipped(updates) + + if vulns != nil { + PrintVulnerabilities(vulns) + } + + 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.scanVulns, + Duration: time.Since(start), + }, !dryRun && len(updateSpecs) > 0) + + return nil +} + +func runScan(ctx context.Context, file string) error { + start := time.Now() + + deps, err := pyproject.ParseFile(file) + if err != nil { + return err + } + + PrintScanning(len(deps)) + + vulns := scanForVulns(ctx, deps) + 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, +) ([]types.UpdateResult, map[string]string) { + var results []types.UpdateResult + specs := make(map[string]string) + + for _, dep := range deps { + 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 { + 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 + } + + client := osv.NewClient() + vulns, err := client.ScanPackages(ctx, queries) + if err != nil { + PrintError(fmt.Sprintf("vulnerability scan: %v", err)) + return nil + } + return vulns +} + +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 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 + }) +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/osv/client.go b/PROJECTS/simple-vulnerability-scanner/internal/osv/client.go new file mode 100644 index 00000000..cb1ab8aa --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/osv/client.go @@ -0,0 +1,401 @@ +// ©AngelaMos | 2026 +// client.go + +package osv + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/CarterPerez-dev/angela/pkg/types" + "golang.org/x/sync/errgroup" +) + +const ( + batchEndpoint = "https://api.osv.dev/v1/querybatch" + vulnEndpoint = "https://api.osv.dev/v1/vulns/" + userAgent = "angela/0.1.0 (https://github.com/CarterPerez-dev/angela)" + maxHydrate = 15 +) + +// Client queries the OSV.dev API for known vulnerabilities +type Client struct { + http *http.Client +} + +// NewClient creates an OSV client with sensible defaults +func NewClient() *Client { + return &Client{ + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// PackageQuery identifies a single package+version to check +type PackageQuery struct { + Name string + Version string +} + +type batchRequest struct { + Queries []query `json:"queries"` +} + +type query struct { + Package pkg `json:"package"` + Version string `json:"version"` +} + +type pkg struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` +} + +type batchResponse struct { + Results []batchResult `json:"results"` +} + +type batchResult struct { + Vulns []vulnRef `json:"vulns"` +} + +type vulnRef struct { + ID string `json:"id"` + Modified string `json:"modified"` +} + +type osvVuln struct { + ID string `json:"id"` + Summary string `json:"summary"` + Aliases []string `json:"aliases"` + Severity []severity `json:"severity"` + Affected []affected `json:"affected"` + References []reference `json:"references"` + DatabaseSpecific map[string]any `json:"database_specific"` +} + +type severity struct { + Type string `json:"type"` + Score string `json:"score"` +} + +type affected struct { + Package pkg `json:"package"` + Ranges []rng `json:"ranges"` +} + +type rng struct { + Type string `json:"type"` + Events []event `json:"events"` +} + +type event struct { + Introduced string `json:"introduced,omitempty"` + Fixed string `json:"fixed,omitempty"` +} + +type reference struct { + Type string `json:"type"` + URL string `json:"url"` +} + +// ScanPackages checks a list of packages for known vulnerabilities and returns +// per-package results with full vulnerability details. Duplicates caused by +// overlapping CVE/GHSA identifiers are removed. +func (c *Client) ScanPackages( + ctx context.Context, + packages []PackageQuery, +) (map[string][]types.Vulnerability, error) { + if len(packages) == 0 { + return nil, nil + } + + batch, err := c.queryBatch(ctx, packages) + if err != nil { + return nil, fmt.Errorf("osv batch query: %w", err) + } + + allIDs := collectUniqueIDs(batch) + if len(allIDs) == 0 { + return nil, nil + } + + vulnMap, err := c.hydrateAll(ctx, allIDs) + if err != nil { + return nil, fmt.Errorf("osv hydrate: %w", err) + } + + return buildResults(packages, batch, vulnMap), nil +} + +func (c *Client) queryBatch( + ctx context.Context, + packages []PackageQuery, +) (*batchResponse, error) { + queries := make([]query, len(packages)) + for i, p := range packages { + queries[i] = query{ + Package: pkg{Name: p.Name, Ecosystem: "PyPI"}, + Version: p.Version, + } + } + + body, err := json.Marshal(batchRequest{Queries: queries}) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, batchEndpoint, + bytes.NewReader(body), + ) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("osv returned %d", resp.StatusCode) + } + + var result batchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode batch response: %w", err) + } + return &result, nil +} + +func (c *Client) fetchVuln( + ctx context.Context, + id string, +) (*osvVuln, error) { + req, err := http.NewRequestWithContext( + ctx, http.MethodGet, vulnEndpoint+id, nil, + ) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "osv vuln %s returned %d", id, resp.StatusCode, + ) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var v osvVuln + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("decode vuln %s: %w", id, err) + } + return &v, nil +} + +func (c *Client) hydrateAll( + ctx context.Context, + ids []string, +) (map[string]*osvVuln, error) { + var mu sync.Mutex + result := make(map[string]*osvVuln, len(ids)) + + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(maxHydrate) + + for _, id := range ids { + g.Go(func() (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf( + "panic hydrating %s: %v", id, r, + ) + } + }() + + v, fetchErr := c.fetchVuln(ctx, id) + if fetchErr != nil { + return fetchErr + } + mu.Lock() + result[id] = v + mu.Unlock() + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + return result, nil +} + +func collectUniqueIDs(batch *batchResponse) []string { + seen := make(map[string]bool) + var ids []string + for _, r := range batch.Results { + for _, v := range r.Vulns { + if !seen[v.ID] { + seen[v.ID] = true + ids = append(ids, v.ID) + } + } + } + return ids +} + +func buildResults( + packages []PackageQuery, + batch *batchResponse, + vulnMap map[string]*osvVuln, +) map[string][]types.Vulnerability { + results := make(map[string][]types.Vulnerability) + + for i, br := range batch.Results { + if len(br.Vulns) == 0 { + continue + } + + name := packages[i].Name + seen := make(map[string]bool) + + for _, vr := range br.Vulns { + ov, ok := vulnMap[vr.ID] + if !ok { + continue + } + + if isDuplicate(ov, seen) { + continue + } + + seen[ov.ID] = true + for _, alias := range ov.Aliases { + seen[alias] = true + } + + results[name] = append(results[name], toVulnerability(ov)) + } + } + return results +} + +func isDuplicate(v *osvVuln, seen map[string]bool) bool { + if seen[v.ID] { + return true + } + for _, alias := range v.Aliases { + if seen[alias] { + return true + } + } + return false +} + +func toVulnerability(v *osvVuln) types.Vulnerability { + return types.Vulnerability{ + ID: v.ID, + Aliases: v.Aliases, + Summary: v.Summary, + Severity: extractSeverity(v), + FixedIn: extractFixed(v.Affected), + Link: extractLink(v.References), + } +} + +func extractSeverity(v *osvVuln) string { + for _, s := range v.Severity { + if s.Type != "CVSS_V3" && s.Type != "CVSS_V4" { + continue + } + + if score, err := strconv.ParseFloat(s.Score, 64); err == nil { + return classifyScore(score) + } + + if idx := strings.LastIndex(s.Score, "/"); idx >= 0 { + chunk := s.Score[idx+1:] + if score, err := strconv.ParseFloat(chunk, 64); err == nil { + return classifyScore(score) + } + } + } + + if v.DatabaseSpecific != nil { + if sev, ok := v.DatabaseSpecific["severity"].(string); ok { + return strings.ToUpper(sev) + } + } + + return "UNKNOWN" +} + +func classifyScore(score float64) string { + switch { + case score >= 9.0: + return "CRITICAL" + case score >= 7.0: + return "HIGH" + case score >= 4.0: + return "MEDIUM" + case score > 0: + return "LOW" + default: + return "NONE" + } +} + +func extractFixed(affected []affected) string { + for _, a := range affected { + if !strings.EqualFold(a.Package.Ecosystem, "PyPI") { + continue + } + for _, r := range a.Ranges { + for _, e := range r.Events { + if e.Fixed != "" { + return e.Fixed + } + } + } + } + return "" +} + +func extractLink(refs []reference) string { + for _, r := range refs { + if r.Type == "ADVISORY" { + return r.URL + } + } + for _, r := range refs { + if r.Type == "WEB" || r.Type == "REPORT" { + return r.URL + } + } + if len(refs) > 0 { + return refs[0].URL + } + return "" +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/osv/client_test.go b/PROJECTS/simple-vulnerability-scanner/internal/osv/client_test.go new file mode 100644 index 00000000..db5b9f9e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/osv/client_test.go @@ -0,0 +1,254 @@ +// ©AngelaMos | 2026 +// client_test.go + +package osv + +import ( + "testing" +) + +func TestExtractSeverityCVSSV3(t *testing.T) { + t.Parallel() + + v := &osvVuln{ + Severity: []severity{ + {Type: "CVSS_V3", Score: "9.8"}, + }, + } + got := extractSeverity(v) + if got != "CRITICAL" { //nolint:goconst + t.Errorf("extractSeverity = %q, want CRITICAL", got) + } +} + +func TestExtractSeverityCVSSV4(t *testing.T) { + t.Parallel() + + v := &osvVuln{ + Severity: []severity{ + {Type: "CVSS_V4", Score: "7.5"}, + }, + } + got := extractSeverity(v) + if got != "HIGH" { + t.Errorf("extractSeverity = %q, want HIGH", got) + } +} + +func TestExtractSeverityCVSSVector(t *testing.T) { + t.Parallel() + + v := &osvVuln{ + Severity: []severity{ + { + Type: "CVSS_V3", + Score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/9.8", + }, + }, + } + got := extractSeverity(v) + if got != "CRITICAL" { + t.Errorf("extractSeverity = %q, want CRITICAL", got) + } +} + +func TestExtractSeverityDatabaseSpecific(t *testing.T) { + t.Parallel() + + v := &osvVuln{ + DatabaseSpecific: map[string]any{ + "severity": "MODERATE", + }, + } + got := extractSeverity(v) + if got != "MODERATE" { + t.Errorf("extractSeverity = %q, want MODERATE", got) + } +} + +func TestExtractSeverityUnknown(t *testing.T) { + t.Parallel() + + v := &osvVuln{} + got := extractSeverity(v) + if got != "UNKNOWN" { + t.Errorf("extractSeverity = %q, want UNKNOWN", got) + } +} + +func TestClassifyScore(t *testing.T) { + t.Parallel() + + tests := []struct { + score float64 + want string + }{ + {9.8, "CRITICAL"}, + {9.0, "CRITICAL"}, + {8.5, "HIGH"}, + {7.0, "HIGH"}, + {6.9, "MEDIUM"}, + {4.0, "MEDIUM"}, + {3.9, "LOW"}, + {0.1, "LOW"}, + {0.0, "NONE"}, + } + + for _, tt := range tests { + got := classifyScore(tt.score) + if got != tt.want { + t.Errorf( + "classifyScore(%v) = %q, want %q", + tt.score, got, tt.want, + ) + } + } +} + +func TestIsDuplicate(t *testing.T) { + t.Parallel() + + seen := map[string]bool{ + "CVE-2023-1234": true, + } + + v1 := &osvVuln{ + ID: "GHSA-xxxx-yyyy", + Aliases: []string{"CVE-2023-1234"}, + } + if !isDuplicate(v1, seen) { + t.Error("should detect duplicate via alias") + } + + v2 := &osvVuln{ + ID: "CVE-2023-1234", + Aliases: []string{}, + } + if !isDuplicate(v2, seen) { + t.Error("should detect duplicate via direct ID") + } + + v3 := &osvVuln{ + ID: "PYSEC-2024-001", + Aliases: []string{"CVE-2024-9999"}, + } + if isDuplicate(v3, seen) { + t.Error("should not flag non-duplicate as duplicate") + } +} + +func TestExtractFixed(t *testing.T) { + t.Parallel() + + aff := []affected{ + { + Package: pkg{Name: "django", Ecosystem: "PyPI"}, + Ranges: []rng{ + { + Type: "ECOSYSTEM", + Events: []event{ + {Introduced: "0"}, + {Fixed: "4.2.8"}, + }, + }, + }, + }, + } + + got := extractFixed(aff) + if got != "4.2.8" { + t.Errorf("extractFixed = %q, want 4.2.8", got) + } +} + +func TestExtractFixedNoFix(t *testing.T) { + t.Parallel() + + aff := []affected{ + { + Package: pkg{Name: "django", Ecosystem: "PyPI"}, + Ranges: []rng{ + { + Type: "ECOSYSTEM", + Events: []event{{Introduced: "0"}}, + }, + }, + }, + } + + got := extractFixed(aff) + if got != "" { + t.Errorf("extractFixed = %q, want empty", got) + } +} + +func TestExtractLink(t *testing.T) { + t.Parallel() + + refs := []reference{ + {Type: "WEB", URL: "https://example.com"}, + { + Type: "ADVISORY", + URL: "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + }, + } + + got := extractLink(refs) + if got != "https://nvd.nist.gov/vuln/detail/CVE-2023-1234" { + t.Errorf("extractLink preferred ADVISORY, got %q", got) + } +} + +func TestExtractLinkFallback(t *testing.T) { + t.Parallel() + + refs := []reference{ + {Type: "PACKAGE", URL: "https://pypi.org/project/django"}, + {Type: "WEB", URL: "https://example.com/info"}, + } + + got := extractLink(refs) + if got != "https://example.com/info" { + t.Errorf("extractLink should fall back to WEB, got %q", got) + } +} + +func TestExtractLinkEmpty(t *testing.T) { + t.Parallel() + + got := extractLink(nil) + if got != "" { + t.Errorf("extractLink(nil) = %q, want empty", got) + } +} + +func TestCollectUniqueIDs(t *testing.T) { + t.Parallel() + + batch := &batchResponse{ + Results: []batchResult{ + {Vulns: []vulnRef{ + {ID: "CVE-A"}, + {ID: "CVE-B"}, + }}, + {Vulns: []vulnRef{ + {ID: "CVE-B"}, + {ID: "CVE-C"}, + }}, + {Vulns: nil}, + }, + } + + ids := collectUniqueIDs(batch) + if len(ids) != 3 { + t.Fatalf("len = %d, want 3", len(ids)) + } + + seen := make(map[string]bool) + for _, id := range ids { + if seen[id] { + t.Errorf("duplicate ID: %s", id) + } + seen[id] = true + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pypi/cache.go b/PROJECTS/simple-vulnerability-scanner/internal/pypi/cache.go new file mode 100644 index 00000000..a589cc15 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pypi/cache.go @@ -0,0 +1,105 @@ +// ©AngelaMos | 2026 +// cache.go + +package pypi + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// DefaultCacheTTL defines how long cached PyPI responses remain valid +const DefaultCacheTTL = 1 * time.Hour + +// Cache provides file-backed storage for PyPI API responses with ETag support +type Cache struct { + dir string + ttl time.Duration +} + +// CacheEntry holds a cached PyPI response alongside its freshness metadata +type CacheEntry struct { + ETag string `json:"etag"` + Versions []string `json:"versions"` + CachedAt time.Time `json:"cached_at"` +} + +// NewCache creates a file-backed cache at the given directory +func NewCache(dir string, ttl time.Duration) (*Cache, error) { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, err + } + return &Cache{dir: dir, ttl: ttl}, nil +} + +// Get retrieves a cache entry by package name, returning false on miss +func (c *Cache) Get(key string) (*CacheEntry, bool) { + data, err := os.ReadFile(c.path(key)) + if err != nil { + return nil, false + } + + var entry CacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return nil, false + } + return &entry, true +} + +// IsFresh reports whether the entry is still within its TTL window +func (c *Cache) IsFresh(entry *CacheEntry) bool { + return time.Since(entry.CachedAt) <= c.ttl +} + +// Set writes a cache entry atomically using rename-over-temp +func (c *Cache) Set(key string, entry *CacheEntry) error { + data, err := json.Marshal(entry) + if err != nil { + return err + } + + tmp, err := os.CreateTemp(c.dir, "tmp-*.json") + if err != nil { + return err + } + + if _, writeErr := tmp.Write(data); writeErr != nil { + _ = tmp.Close() //nolint:errcheck + _ = os.Remove(tmp.Name()) //nolint:errcheck + return writeErr + } + _ = tmp.Close() //nolint:errcheck + + return os.Rename(tmp.Name(), c.path(key)) +} + +// Touch refreshes the CachedAt timestamp without changing stored data +func (c *Cache) Touch(key string) { + entry, ok := c.Get(key) + if !ok { + return + } + entry.CachedAt = time.Now() + _ = c.Set(key, entry) //nolint:errcheck +} + +// Clear removes all cached entries from disk +func (c *Cache) Clear() error { + entries, err := os.ReadDir(c.dir) + if err != nil { + return err + } + for _, e := range entries { + if filepath.Ext(e.Name()) == ".json" { + _ = os.Remove(filepath.Join(c.dir, e.Name())) //nolint:errcheck + } + } + return nil +} + +func (c *Cache) path(key string) string { + safe := filepath.Base(key) + return filepath.Join(c.dir, safe+".json") +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pypi/cache_test.go b/PROJECTS/simple-vulnerability-scanner/internal/pypi/cache_test.go new file mode 100644 index 00000000..9e8483ca --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pypi/cache_test.go @@ -0,0 +1,200 @@ +// ©AngelaMos | 2026 +// cache_test.go + +package pypi + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCacheGetSetRoundTrip(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cache, err := NewCache(dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + entry := &CacheEntry{ + ETag: `"abc123"`, + Versions: []string{"1.0", "1.1", "2.0"}, + CachedAt: time.Now(), + } + + if err := cache.Set("requests", entry); err != nil { + t.Fatalf("Set: %v", err) + } + + got, ok := cache.Get("requests") + if !ok { + t.Fatal("Get returned false after Set") + } + if got.ETag != entry.ETag { + t.Errorf("ETag = %q, want %q", got.ETag, entry.ETag) + } + if len(got.Versions) != len(entry.Versions) { + t.Fatalf( + "Versions len = %d, want %d", + len(got.Versions), len(entry.Versions), + ) + } + for i, v := range got.Versions { + if v != entry.Versions[i] { + t.Errorf( + "Versions[%d] = %q, want %q", + i, v, entry.Versions[i], + ) + } + } +} + +func TestCacheGetMiss(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cache, err := NewCache(dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + _, ok := cache.Get("nonexistent") + if ok { + t.Fatal("Get returned true for missing key") + } +} + +func TestCacheIsFresh(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cache, err := NewCache(dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + fresh := &CacheEntry{CachedAt: time.Now()} + if !cache.IsFresh(fresh) { + t.Error("entry cached just now should be fresh") + } + + stale := &CacheEntry{ + CachedAt: time.Now().Add(-2 * time.Hour), + } + if cache.IsFresh(stale) { + t.Error("entry cached 2 hours ago should be stale") + } +} + +func TestCacheTouch(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cache, err := NewCache(dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + old := time.Now().Add(-30 * time.Minute) + entry := &CacheEntry{ + ETag: `"xyz"`, + Versions: []string{"1.0"}, + CachedAt: old, + } + if err := cache.Set("flask", entry); err != nil { + t.Fatalf("Set: %v", err) + } + + cache.Touch("flask") + + got, ok := cache.Get("flask") + if !ok { + t.Fatal("Get returned false after Touch") + } + if !got.CachedAt.After(old) { + t.Error("Touch did not update CachedAt") + } +} + +func TestCacheClear(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cache, err := NewCache(dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + for _, name := range []string{"a", "b", "c"} { + setErr := cache.Set(name, &CacheEntry{ + Versions: []string{"1.0"}, + CachedAt: time.Now(), + }) + if setErr != nil { + t.Fatalf("Set %s: %v", name, setErr) + } + } + + if clearErr := cache.Clear(); clearErr != nil { + t.Fatalf("Clear: %v", clearErr) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + + jsonCount := 0 + for _, e := range entries { + if filepath.Ext(e.Name()) == ".json" { + jsonCount++ + } + } + if jsonCount != 0 { + t.Errorf("Clear left %d .json files", jsonCount) + } +} + +func TestCachePathTraversal(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cache, err := NewCache(dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + entry := &CacheEntry{ + Versions: []string{"1.0"}, + CachedAt: time.Now(), + } + if err := cache.Set("../../../etc/passwd", entry); err != nil { + t.Fatalf("Set: %v", err) + } + + got, ok := cache.Get("../../../etc/passwd") + if !ok { + t.Fatal("Get returned false for traversal key") + } + if got.Versions[0] != "1.0" { + t.Error("wrong version returned") + } + + path := filepath.Join(dir, "passwd.json") + if _, err := os.Stat(path); err != nil { + t.Errorf( + "expected file at safe path %s, got: %v", + path, err, + ) + } + + unsafePath := filepath.Join( + dir, "../../../etc/passwd.json", + ) + if _, err := os.Stat(unsafePath); err == nil { + t.Error("file created at unsafe traversal path") + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pypi/client.go b/PROJECTS/simple-vulnerability-scanner/internal/pypi/client.go new file mode 100644 index 00000000..6205d9b2 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pypi/client.go @@ -0,0 +1,225 @@ +// ©AngelaMos | 2026 +// client.go + +package pypi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "sync" + "time" + + "golang.org/x/sync/errgroup" +) + +const ( + DefaultMaxWorkers = 10 + DefaultTimeout = 30 * time.Second + DefaultUserAgent = "angela/0.1.0 (https://github.com/CarterPerez-dev/angela)" + + simpleAPIBase = "https://pypi.org/simple/" + simpleAPIAccept = "application/vnd.pypi.simple.v1+json" + + maxRetries = 3 + baseRetryMs = 500 +) + +var nameNormalizeRe = regexp.MustCompile(`[-_.]+`) + +// Client queries the PyPI Simple API for package version information +type Client struct { + http *http.Client + cache *Cache + maxWorkers int + userAgent string +} + +type simpleAPIResponse struct { + Name string `json:"name"` + Versions []string `json:"versions"` +} + +// FetchResult holds the outcome of querying a single package +type FetchResult struct { + Name string + Versions []string + Err error +} + +// NewClient creates a PyPI client backed by a file cache at cacheDir +func NewClient(cacheDir string) (*Client, error) { + cache, err := NewCache(cacheDir, DefaultCacheTTL) + if err != nil { + return nil, fmt.Errorf("init cache: %w", err) + } + + return &Client{ + http: &http.Client{ + Timeout: DefaultTimeout, + Transport: &http.Transport{ + MaxIdleConnsPerHost: DefaultMaxWorkers, + MaxConnsPerHost: DefaultMaxWorkers, + IdleConnTimeout: 90 * time.Second, + }, + }, + cache: cache, + maxWorkers: DefaultMaxWorkers, + userAgent: DefaultUserAgent, + }, nil +} + +// FetchVersions returns all known versions for a single PyPI package +func (c *Client) FetchVersions( + ctx context.Context, + name string, +) ([]string, error) { + normalized := NormalizeName(name) + + entry, hit := c.cache.Get(normalized) + if hit && c.cache.IsFresh(entry) { + return entry.Versions, nil + } + + url := simpleAPIBase + normalized + "/" + req, err := http.NewRequestWithContext( + ctx, http.MethodGet, url, nil, + ) + if err != nil { + return nil, fmt.Errorf("build request for %s: %w", name, err) + } + req.Header.Set("Accept", simpleAPIAccept) + req.Header.Set("User-Agent", c.userAgent) + + if entry != nil && entry.ETag != "" { + req.Header.Set("If-None-Match", entry.ETag) + } + + resp, err := c.doWithRetry(ctx, req) + if err != nil { + if entry != nil { + return entry.Versions, nil + } + return nil, fmt.Errorf("fetch %s: %w", name, err) + } + defer func() { _ = resp.Body.Close() }() //nolint:errcheck + + switch resp.StatusCode { + case http.StatusNotModified: + c.cache.Touch(normalized) + return entry.Versions, nil + + case http.StatusNotFound: + return nil, fmt.Errorf( + "package %q not found on PyPI", name, + ) + + case http.StatusOK: + var result simpleAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode %s: %w", name, err) + } + _ = c.cache.Set(normalized, &CacheEntry{ //nolint:errcheck + ETag: resp.Header.Get("ETag"), + Versions: result.Versions, + CachedAt: time.Now(), + }) + return result.Versions, nil + + default: + return nil, fmt.Errorf( + "PyPI returned %d for %s", resp.StatusCode, name, + ) + } +} + +// FetchAllVersions queries multiple packages concurrently and returns +// per-package results. Failures for individual packages do not prevent +// the remaining packages from being fetched. +func (c *Client) FetchAllVersions( + ctx context.Context, + names []string, +) []FetchResult { + var ( + mu sync.Mutex + results = make([]FetchResult, 0, len(names)) + ) + + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(c.maxWorkers) + + for _, name := range names { + g.Go(func() (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic fetching %s: %v", name, r) + } + }() + + versions, fetchErr := c.FetchVersions(ctx, name) + mu.Lock() + results = append(results, FetchResult{ + Name: name, + Versions: versions, + Err: fetchErr, + }) + mu.Unlock() + return nil + }) + } + + _ = g.Wait() //nolint:errcheck + return results +} + +// ClearCache removes all cached PyPI responses +func (c *Client) ClearCache() error { + return c.cache.Clear() +} + +// NormalizeName converts a PyPI package name to its canonical form per PEP 503 +func NormalizeName(name string) string { + return nameNormalizeRe.ReplaceAllString(strings.ToLower(name), "-") +} + +func (c *Client) doWithRetry( + ctx context.Context, + req *http.Request, +) (*http.Response, error) { + var lastErr error + + for attempt := range maxRetries { + if attempt > 0 { + shift := uint(attempt - 1) //nolint:gosec + delay := time.Duration(1<= http.StatusInternalServerError { + _ = resp.Body.Close() //nolint:errcheck + lastErr = fmt.Errorf( + "server error: %d", resp.StatusCode, + ) + continue + } + + return resp, nil + } + + return nil, fmt.Errorf( + "after %d attempts: %w", maxRetries, lastErr, + ) +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pypi/client_test.go b/PROJECTS/simple-vulnerability-scanner/internal/pypi/client_test.go new file mode 100644 index 00000000..0c1b01ae --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pypi/client_test.go @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// client_test.go + +package pypi + +import ( + "testing" +) + +func TestNormalizeName(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {"requests", "requests"}, + {"Requests", "requests"}, + {"REQUESTS", "requests"}, + {"some-package", "some-package"}, + {"some_package", "some-package"}, + {"some.package", "some-package"}, + {"Some_Package", "some-package"}, + {"my---package", "my-package"}, + {"my__.package", "my-package"}, + {"Flask", "flask"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + + got := NormalizeName(tt.input) + if got != tt.want { + t.Errorf( + "NormalizeName(%q) = %q, want %q", + tt.input, got, tt.want, + ) + } + }) + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pypi/version.go b/PROJECTS/simple-vulnerability-scanner/internal/pypi/version.go new file mode 100644 index 00000000..9a5c88a0 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pypi/version.go @@ -0,0 +1,313 @@ +// ©AngelaMos | 2026 +// version.go + +package pypi + +import ( + "cmp" + "errors" + "fmt" + "math" + "regexp" + "strconv" + "strings" +) + +// ErrInvalidVersion indicates a string that does not conform to PEP 440 +var ErrInvalidVersion = errors.New("invalid PEP 440 version") + +// ChangeKind classifies a version bump by semver magnitude +type ChangeKind int + +const ( + Patch ChangeKind = iota + 1 + Minor + Major +) + +// String returns the lowercase name of the change kind +func (c ChangeKind) String() string { + switch c { + case Patch: + return "patch" + case Minor: + return "minor" + case Major: + return "major" + default: + return "unknown" + } +} + +// Version represents a parsed PEP 440 version with all optional components +type Version struct { + Raw string + Epoch int + Release []int + PreKind string + PreNum int + Post int + Dev int + Local string +} + +var versionRe = regexp.MustCompile( + `(?i)^v?` + + `(?:(\d+)!)?` + + `(\d+(?:\.\d+)*)` + + `(?:[-_.]?(alpha|a|beta|b|preview|pre|c|rc)[-_.]?(\d*))?` + + `(?:[-_.]?(post|rev|r)[-_.]?(\d*)|-(\d+))?` + + `(?:[-_.]?(dev)[-_.]?(\d*))?` + + `(?:\+([a-z0-9](?:[a-z0-9._-]*[a-z0-9])?))?$`, +) + +// ParseVersion parses a PEP 440 version string into its structured components +func ParseVersion(s string) (Version, error) { + normalized := strings.ToLower(strings.TrimSpace(s)) + m := versionRe.FindStringSubmatch(normalized) + if m == nil { + return Version{}, fmt.Errorf("%w: %q", ErrInvalidVersion, s) + } + + v := Version{ + Raw: s, + Post: -1, + Dev: -1, + } + + if m[1] != "" { + v.Epoch = mustAtoi(m[1]) + } + + for _, seg := range strings.Split(m[2], ".") { + v.Release = append(v.Release, mustAtoi(seg)) + } + + if m[3] != "" { + v.PreKind = normalizePreKind(m[3]) + v.PreNum = optionalAtoi(m[4]) + } + + switch { + case m[5] != "": + v.Post = optionalAtoi(m[6]) + case m[7] != "": + v.Post = mustAtoi(m[7]) + } + + if m[8] != "" { + v.Dev = optionalAtoi(m[9]) + } + + v.Local = m[10] + + return v, nil +} + +// String returns the canonical PEP 440 representation +func (v Version) String() string { + var b strings.Builder + + if v.Epoch != 0 { + fmt.Fprintf(&b, "%d!", v.Epoch) + } + + for i, n := range v.Release { + if i > 0 { + b.WriteByte('.') + } + fmt.Fprintf(&b, "%d", n) + } + + if v.PreKind != "" { + fmt.Fprintf(&b, "%s%d", v.PreKind, v.PreNum) + } + + if v.Post >= 0 { + fmt.Fprintf(&b, ".post%d", v.Post) + } + + if v.Dev >= 0 { + fmt.Fprintf(&b, ".dev%d", v.Dev) + } + + if v.Local != "" { + fmt.Fprintf(&b, "+%s", v.Local) + } + + return b.String() +} + +// IsStable reports whether the version is a stable (non-pre, non-dev) release +func (v Version) IsStable() bool { + return v.PreKind == "" && v.Dev < 0 +} + +// Compare returns -1, 0, or 1 following PEP 440 ordering rules. +// +// The ordering within a given release segment is: +// +// 1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1 +func (v Version) Compare(other Version) int { + if c := cmp.Compare(v.Epoch, other.Epoch); c != 0 { + return c + } + + if c := compareRelease(v.Release, other.Release); c != 0 { + return c + } + + vpt, vpn := preKey(v) + opt, opn := preKey(other) + if c := cmp.Compare(vpt, opt); c != 0 { + return c + } + if c := cmp.Compare(vpn, opn); c != 0 { + return c + } + + if c := cmp.Compare(postKey(v), postKey(other)); c != 0 { + return c + } + + return cmp.Compare(devKey(v), devKey(other)) +} + +// ClassifyChange determines whether moving from v to other is a major, minor, +// or patch bump based on their release segments +func ClassifyChange(from, to Version) ChangeKind { + fromR := padRelease(from.Release, 3) + toR := padRelease(to.Release, 3) + + if fromR[0] != toR[0] { + return Major + } + if fromR[1] != toR[1] { + return Minor + } + return Patch +} + +// LatestStable finds the highest stable version from a list of version strings +func LatestStable(versions []string) (Version, error) { + var latest Version + var found bool + + for _, raw := range versions { + v, err := ParseVersion(raw) + if err != nil { + continue + } + if !v.IsStable() { + continue + } + if !found || v.Compare(latest) > 0 { + latest = v + found = true + } + } + + if !found { + return Version{}, errors.New("no stable version found") + } + return latest, nil +} + +func compareRelease(a, b []int) int { + maxLen := max(len(a), len(b)) + for i := range maxLen { + av, bv := 0, 0 + if i < len(a) { + av = a[i] + } + if i < len(b) { + bv = b[i] + } + if c := cmp.Compare(av, bv); c != 0 { + return c + } + } + return 0 +} + +// preKey produces a sortable tuple for the pre-release component. +// +// Versions with only a dev suffix (no pre, no post) sort before all +// pre-releases. Pre-releases sort by kind (a < b < rc) then number. +// Final and post-releases sort after all pre-releases. +func preKey(v Version) (int, int) { + hasPre := v.PreKind != "" + hasDev := v.Dev >= 0 + hasPost := v.Post >= 0 + + switch { + case !hasPre && !hasPost && hasDev: + return math.MinInt, math.MinInt + case hasPre: + return preKindRank(v.PreKind), v.PreNum + default: + return math.MaxInt, math.MaxInt + } +} + +func postKey(v Version) int { + if v.Post < 0 { + return math.MinInt + } + return v.Post +} + +func devKey(v Version) int { + if v.Dev < 0 { + return math.MaxInt + } + return v.Dev +} + +func preKindRank(kind string) int { + switch kind { + case "a": + return 0 + case "b": + return 1 + case "rc": + return 2 + default: + return -1 + } +} + +func normalizePreKind(s string) string { + switch strings.ToLower(s) { + case "a", "alpha": + return "a" + case "b", "beta": + return "b" + case "rc", "c", "pre", "preview": + return "rc" + default: + return s + } +} + +func padRelease(r []int, n int) []int { + if len(r) >= n { + return r[:n] + } + padded := make([]int, n) + copy(padded, r) + return padded +} + +func mustAtoi(s string) int { + n, _ := strconv.Atoi(s) //nolint:errcheck + return n +} + +func optionalAtoi(s string) int { + if s == "" { + return 0 + } + n, _ := strconv.Atoi(s) //nolint:errcheck + return n +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pypi/version_test.go b/PROJECTS/simple-vulnerability-scanner/internal/pypi/version_test.go new file mode 100644 index 00000000..becb0e66 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pypi/version_test.go @@ -0,0 +1,623 @@ +// ©AngelaMos | 2026 +// version_test.go + +package pypi + +import ( + "errors" + "testing" +) + +//nolint:gocognit,funlen +func TestParseVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want Version + wantErr bool + }{ + { + name: "simple three-segment", + input: "1.2.3", + want: Version{ + Raw: "1.2.3", Release: []int{1, 2, 3}, + Post: -1, Dev: -1, + }, + }, + { + name: "two segments", + input: "2.0", + want: Version{ + Raw: "2.0", Release: []int{2, 0}, + Post: -1, Dev: -1, + }, + }, + { + name: "single segment", + input: "42", + want: Version{ + Raw: "42", Release: []int{42}, + Post: -1, Dev: -1, + }, + }, + { + name: "leading v prefix", + input: "v1.0.0", + want: Version{ + Raw: "v1.0.0", Release: []int{1, 0, 0}, + Post: -1, Dev: -1, + }, + }, + { + name: "epoch", + input: "2!1.0", + want: Version{ + Raw: "2!1.0", Epoch: 2, Release: []int{1, 0}, + Post: -1, Dev: -1, + }, + }, + { + name: "alpha pre-release", + input: "1.0a1", + want: Version{ + Raw: "1.0a1", Release: []int{1, 0}, + PreKind: "a", PreNum: 1, Post: -1, Dev: -1, + }, + }, + { + name: "beta pre-release", + input: "1.0b2", + want: Version{ + Raw: "1.0b2", Release: []int{1, 0}, + PreKind: "b", PreNum: 2, Post: -1, Dev: -1, + }, + }, + { + name: "release candidate", + input: "1.0rc1", + want: Version{ + Raw: "1.0rc1", Release: []int{1, 0}, + PreKind: "rc", PreNum: 1, Post: -1, Dev: -1, + }, + }, + { + name: "alpha spelled out", + input: "1.0alpha1", + want: Version{ + Raw: "1.0alpha1", Release: []int{1, 0}, + PreKind: "a", PreNum: 1, Post: -1, Dev: -1, + }, + }, + { + name: "beta spelled out", + input: "1.0beta3", + want: Version{ + Raw: "1.0beta3", Release: []int{1, 0}, + PreKind: "b", PreNum: 3, Post: -1, Dev: -1, + }, + }, + { + name: "preview normalizes to rc", + input: "1.0preview2", + want: Version{ + Raw: "1.0preview2", Release: []int{1, 0}, + PreKind: "rc", PreNum: 2, Post: -1, Dev: -1, + }, + }, + { + name: "c normalizes to rc", + input: "1.0c1", + want: Version{ + Raw: "1.0c1", Release: []int{1, 0}, + PreKind: "rc", PreNum: 1, Post: -1, Dev: -1, + }, + }, + { + name: "pre normalizes to rc", + input: "1.0pre4", + want: Version{ + Raw: "1.0pre4", Release: []int{1, 0}, + PreKind: "rc", PreNum: 4, Post: -1, Dev: -1, + }, + }, + { + name: "pre-release with dot separator", + input: "1.0.a1", + want: Version{ + Raw: "1.0.a1", Release: []int{1, 0}, + PreKind: "a", PreNum: 1, Post: -1, Dev: -1, + }, + }, + { + name: "pre-release with dash separator", + input: "1.0-alpha1", + want: Version{ + Raw: "1.0-alpha1", Release: []int{1, 0}, + PreKind: "a", PreNum: 1, Post: -1, Dev: -1, + }, + }, + { + name: "pre-release with underscore separator", + input: "1.0_b2", + want: Version{ + Raw: "1.0_b2", Release: []int{1, 0}, + PreKind: "b", PreNum: 2, Post: -1, Dev: -1, + }, + }, + { + name: "implicit pre-release zero", + input: "1.0a", + want: Version{ + Raw: "1.0a", Release: []int{1, 0}, + PreKind: "a", PreNum: 0, Post: -1, Dev: -1, + }, + }, + { + name: "post-release", + input: "1.0.post1", + want: Version{ + Raw: "1.0.post1", Release: []int{1, 0}, + Post: 1, Dev: -1, + }, + }, + { + name: "post-release implicit zero", + input: "1.0.post", + want: Version{ + Raw: "1.0.post", Release: []int{1, 0}, + Post: 0, Dev: -1, + }, + }, + { + name: "implicit post via dash-number", + input: "1.0-1", + want: Version{ + Raw: "1.0-1", Release: []int{1, 0}, + Post: 1, Dev: -1, + }, + }, + { + name: "rev normalizes to post", + input: "1.0.rev2", + want: Version{ + Raw: "1.0.rev2", Release: []int{1, 0}, + Post: 2, Dev: -1, + }, + }, + { + name: "r normalizes to post", + input: "1.0.r3", + want: Version{ + Raw: "1.0.r3", Release: []int{1, 0}, + Post: 3, Dev: -1, + }, + }, + { + name: "dev release", + input: "1.0.dev1", + want: Version{ + Raw: "1.0.dev1", Release: []int{1, 0}, + Post: -1, Dev: 1, + }, + }, + { + name: "dev release implicit zero", + input: "1.0.dev", + want: Version{ + Raw: "1.0.dev", Release: []int{1, 0}, + Post: -1, Dev: 0, + }, + }, + { + name: "dev release with dash separator", + input: "1.0-dev3", + want: Version{ + Raw: "1.0-dev3", Release: []int{1, 0}, + Post: -1, Dev: 3, + }, + }, + { + name: "local version", + input: "1.0+ubuntu1", + want: Version{ + Raw: "1.0+ubuntu1", Release: []int{1, 0}, + Post: -1, Dev: -1, Local: "ubuntu1", + }, + }, + { + name: "full complex version", + input: "2!1.2.3a4.post5.dev6+local7", + want: Version{ + Raw: "2!1.2.3a4.post5.dev6+local7", + Epoch: 2, Release: []int{1, 2, 3}, + PreKind: "a", PreNum: 4, + Post: 5, Dev: 6, Local: "local7", + }, + }, + { + name: "post and dev combined", + input: "1.0.post1.dev2", + want: Version{ + Raw: "1.0.post1.dev2", Release: []int{1, 0}, + Post: 1, Dev: 2, + }, + }, + { + name: "pre and dev combined", + input: "1.0a1.dev1", + want: Version{ + Raw: "1.0a1.dev1", Release: []int{1, 0}, + PreKind: "a", PreNum: 1, Post: -1, Dev: 1, + }, + }, + { + name: "leading zeros normalized", + input: "1.01.010", + want: Version{ + Raw: "1.01.010", Release: []int{1, 1, 10}, + Post: -1, Dev: -1, + }, + }, + { + name: "many release segments", + input: "1.2.3.4.5", + want: Version{ + Raw: "1.2.3.4.5", Release: []int{1, 2, 3, 4, 5}, + Post: -1, Dev: -1, + }, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "garbage", + input: "not-a-version", + wantErr: true, + }, + { + name: "just text", + input: "latest", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseVersion(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf( + "ParseVersion(%q) succeeded, want error", + tt.input, + ) + } + if !errors.Is(err, ErrInvalidVersion) { + t.Fatalf( + "ParseVersion(%q) err = %v, want ErrInvalidVersion", + tt.input, err, + ) + } + return + } + if err != nil { + t.Fatalf("ParseVersion(%q) error: %v", tt.input, err) + } + + if got.Epoch != tt.want.Epoch { + t.Errorf("Epoch = %d, want %d", got.Epoch, tt.want.Epoch) + } + if !intSliceEqual(got.Release, tt.want.Release) { + t.Errorf( + "Release = %v, want %v", + got.Release, tt.want.Release, + ) + } + if got.PreKind != tt.want.PreKind { + t.Errorf( + "PreKind = %q, want %q", + got.PreKind, tt.want.PreKind, + ) + } + if got.PreNum != tt.want.PreNum { + t.Errorf("PreNum = %d, want %d", got.PreNum, tt.want.PreNum) + } + if got.Post != tt.want.Post { + t.Errorf("Post = %d, want %d", got.Post, tt.want.Post) + } + if got.Dev != tt.want.Dev { + t.Errorf("Dev = %d, want %d", got.Dev, tt.want.Dev) + } + if got.Local != tt.want.Local { + t.Errorf( + "Local = %q, want %q", + got.Local, tt.want.Local, + ) + } + }) + } +} + +func TestVersionString(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {"1.0", "1.0"}, + {"1.2.3", "1.2.3"}, + {"2!1.0", "2!1.0"}, + {"1.0a1", "1.0a1"}, + {"1.0b2", "1.0b2"}, + {"1.0rc1", "1.0rc1"}, + {"1.0.post1", "1.0.post1"}, + {"1.0.dev1", "1.0.dev1"}, + {"1.0+local", "1.0+local"}, + {"1.0alpha1", "1.0a1"}, + {"1.0beta2", "1.0b2"}, + {"1.0preview1", "1.0rc1"}, + {"1.0c3", "1.0rc3"}, + {"1.0pre1", "1.0rc1"}, + {"v1.0.0", "1.0.0"}, + {"1.0a", "1.0a0"}, + {"1.0.post", "1.0.post0"}, + {"1.0.dev", "1.0.dev0"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + + v, err := ParseVersion(tt.input) + if err != nil { + t.Fatalf("ParseVersion(%q) error: %v", tt.input, err) + } + got := v.String() + if got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestVersionIsStable(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want bool + }{ + {"1.0", true}, + {"1.2.3", true}, + {"1.0.post1", true}, + {"2!1.0", true}, + {"1.0+local", true}, + {"1.0a1", false}, + {"1.0b1", false}, + {"1.0rc1", false}, + {"1.0.dev1", false}, + {"1.0a1.dev1", false}, + {"1.0.post1.dev1", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + + v, err := ParseVersion(tt.input) + if err != nil { + t.Fatalf("ParseVersion(%q) error: %v", tt.input, err) + } + if got := v.IsStable(); got != tt.want { + t.Errorf("IsStable() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestVersionCompare(t *testing.T) { + t.Parallel() + + ordered := []string{ + "1.0.dev0", + "1.0.dev1", + "1.0a1.dev1", + "1.0a1", + "1.0a2", + "1.0b1", + "1.0b2", + "1.0rc1", + "1.0rc2", + "1.0", + "1.0.post0", + "1.0.post1", + "1.0.post2", + "1.1", + "2.0", + } + + versions := make([]Version, len(ordered)) + for i, s := range ordered { + v, err := ParseVersion(s) + if err != nil { + t.Fatalf("ParseVersion(%q) error: %v", s, err) + } + versions[i] = v + } + + for i := range len(versions) { + for j := range len(versions) { + got := versions[i].Compare(versions[j]) + var want int + switch { + case i < j: + want = -1 + case i > j: + want = 1 + default: + want = 0 + } + + if (want < 0 && got >= 0) || + (want > 0 && got <= 0) || + (want == 0 && got != 0) { + t.Errorf( + "Compare(%s, %s) = %d, want sign %d", + ordered[i], ordered[j], got, want, + ) + } + } + } +} + +func TestVersionCompareEpoch(t *testing.T) { + t.Parallel() + + low, err := ParseVersion("99.0") + if err != nil { + t.Fatal(err) + } + high, err := ParseVersion("2!1.0") + if err != nil { + t.Fatal(err) + } + + if got := low.Compare(high); got >= 0 { + t.Errorf("99.0 should be less than 2!1.0, got %d", got) + } + if got := high.Compare(low); got <= 0 { + t.Errorf("2!1.0 should be greater than 99.0, got %d", got) + } +} + +func TestVersionCompareImplicitZeros(t *testing.T) { + t.Parallel() + + a, err := ParseVersion("1.0") + if err != nil { + t.Fatal(err) + } + b, err := ParseVersion("1.0.0") + if err != nil { + t.Fatal(err) + } + c, err := ParseVersion("1.0.0.0") + if err != nil { + t.Fatal(err) + } + + if got := a.Compare(b); got != 0 { + t.Errorf("1.0 vs 1.0.0 = %d, want 0", got) + } + if got := b.Compare(c); got != 0 { + t.Errorf("1.0.0 vs 1.0.0.0 = %d, want 0", got) + } + if got := a.Compare(c); got != 0 { + t.Errorf("1.0 vs 1.0.0.0 = %d, want 0", got) + } +} + +func TestClassifyChange(t *testing.T) { + t.Parallel() + + tests := []struct { + from string + to string + want ChangeKind + }{ + {"1.0.0", "1.0.1", Patch}, + {"1.0.0", "1.1.0", Minor}, + {"1.0.0", "2.0.0", Major}, + {"1.0", "1.1", Minor}, + {"1.0", "2.0", Major}, + {"3.2.0", "4.2.8", Major}, + {"2.28.1", "2.31.0", Minor}, + {"7.4.0", "7.4.3", Patch}, + } + + for _, tt := range tests { + t.Run(tt.from+"->"+tt.to, func(t *testing.T) { + t.Parallel() + + from, err := ParseVersion(tt.from) + if err != nil { + t.Fatal(err) + } + to, err := ParseVersion(tt.to) + if err != nil { + t.Fatal(err) + } + if got := ClassifyChange(from, to); got != tt.want { + t.Errorf( + "ClassifyChange(%s, %s) = %s, want %s", + tt.from, tt.to, got, tt.want, + ) + } + }) + } +} + +func TestLatestStable(t *testing.T) { + t.Parallel() + + versions := []string{ + "1.0", + "1.1", + "1.2a1", + "1.2b1", + "1.2rc1", + "1.2.dev1", + "1.1.1", + "1.1.2", + "0.9", + } + + got, err := LatestStable(versions) + if err != nil { + t.Fatalf("LatestStable() error: %v", err) + } + if got.String() != "1.1.2" { + t.Errorf("LatestStable() = %s, want 1.1.2", got.String()) + } +} + +func TestLatestStableNoStable(t *testing.T) { + t.Parallel() + + versions := []string{"1.0a1", "1.0b1", "1.0.dev1"} + _, err := LatestStable(versions) + if err == nil { + t.Fatal("LatestStable() should fail when no stable versions exist") + } +} + +func TestLatestStableSkipsInvalid(t *testing.T) { + t.Parallel() + + versions := []string{"not-a-version", "also-bad", "1.0", "???"} + got, err := LatestStable(versions) + if err != nil { + t.Fatalf("LatestStable() error: %v", err) + } + if got.String() != "1.0" { + t.Errorf("LatestStable() = %s, want 1.0", got.String()) + } +} + +func intSliceEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pyproject/parser.go b/PROJECTS/simple-vulnerability-scanner/internal/pyproject/parser.go new file mode 100644 index 00000000..9fd9daf4 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pyproject/parser.go @@ -0,0 +1,117 @@ +// ©AngelaMos | 2026 +// parser.go + +package pyproject + +import ( + "fmt" + "os" + "strings" + + "github.com/CarterPerez-dev/angela/pkg/types" + toml "github.com/pelletier/go-toml/v2" +) + +type pyProjectFile struct { + Project struct { + Name string `toml:"name"` + Version string `toml:"version"` + Dependencies []string `toml:"dependencies"` + OptionalDependencies map[string][]string `toml:"optional-dependencies"` + } `toml:"project"` +} + +// ParseFile reads a pyproject.toml and extracts all dependency declarations +func ParseFile(path string) ([]types.Dependency, error) { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf( + "open %s: file not found (run from your project root)", + path, + ) + } + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var proj pyProjectFile + if err := toml.Unmarshal(data, &proj); err != nil { + return nil, fmt.Errorf( + "parse %s: invalid TOML syntax: %w", path, err, + ) + } + + var deps []types.Dependency + + for _, raw := range proj.Project.Dependencies { + deps = append(deps, ParseDependency(raw, "")) + } + + for group, entries := range proj.Project.OptionalDependencies { + for _, raw := range entries { + deps = append(deps, ParseDependency(raw, group)) + } + } + + if len(deps) == 0 { + return nil, fmt.Errorf( + "%s: no dependencies found in [project.dependencies] or [project.optional-dependencies]", + path, + ) + } + return deps, nil +} + +// ParseDependency splits a PEP 508 dependency string into its components +func ParseDependency(raw, group string) types.Dependency { + dep := types.Dependency{Group: group} + s := strings.TrimSpace(raw) + + if idx := strings.Index(s, ";"); idx >= 0 { + dep.Markers = strings.TrimSpace(s[idx+1:]) + s = s[:idx] + } + s = strings.TrimSpace(s) + + if start := strings.Index(s, "["); start >= 0 { + end := strings.Index(s, "]") + if end > start { + for _, e := range strings.Split(s[start+1:end], ",") { + dep.Extras = append( + dep.Extras, + strings.TrimSpace(e), + ) + } + s = s[:start] + s[end+1:] + } + } + + if idx := strings.IndexAny(s, "><=!~"); idx >= 0 { + dep.Name = strings.TrimSpace(s[:idx]) + dep.Spec = strings.TrimSpace(s[idx:]) + } else { + dep.Name = strings.TrimSpace(s) + } + + return dep +} + +// ExtractMinVersion returns the first pinned or lower-bound version from a +// PEP 440 version specifier string. Returns empty if no version is specified. +func ExtractMinVersion(spec string) string { + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + switch { + case strings.HasPrefix(part, ">="): + return strings.TrimSpace(part[2:]) + case strings.HasPrefix(part, "=="): + return strings.TrimSuffix( + strings.TrimSpace(part[2:]), + ".*", + ) + case strings.HasPrefix(part, "~="): + return strings.TrimSpace(part[2:]) + } + } + return "" +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pyproject/parser_test.go b/PROJECTS/simple-vulnerability-scanner/internal/pyproject/parser_test.go new file mode 100644 index 00000000..98d4bb09 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pyproject/parser_test.go @@ -0,0 +1,159 @@ +// ©AngelaMos | 2026 +// parser_test.go + +package pyproject + +import ( + "testing" +) + +//nolint:gocognit,funlen +func TestParseDependency(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantPkg string + wantSpc string + extras []string + markers string + }{ + { + name: "simple with lower bound", + input: "requests>=2.28.0", + wantPkg: "requests", + wantSpc: ">=2.28.0", + }, + { + name: "range constraint", + input: "django>=3.2,<4.0", + wantPkg: "django", + wantSpc: ">=3.2,<4.0", + }, + { + name: "extras", + input: "flask[async]>=2.0", + wantPkg: "flask", + wantSpc: ">=2.0", + extras: []string{"async"}, + }, + { + name: "multiple extras", + input: "requests[security, socks]>=2.28.0", + wantPkg: "requests", + wantSpc: ">=2.28.0", + extras: []string{"security", "socks"}, + }, + { + name: "markers", + input: `pandas>=1.5; python_version>='3.8'`, + wantPkg: "pandas", + wantSpc: ">=1.5", + markers: "python_version>='3.8'", + }, + { + name: "bare name no version", + input: "numpy", + wantPkg: "numpy", + wantSpc: "", + }, + { + name: "exact pin", + input: "black==23.7.0", + wantPkg: "black", + wantSpc: "==23.7.0", + }, + { + name: "compatible release", + input: "setuptools~=68.0", + wantPkg: "setuptools", + wantSpc: "~=68.0", + }, + { + name: "not equal", + input: "urllib3!=2.0.0", + wantPkg: "urllib3", + wantSpc: "!=2.0.0", + }, + { + name: "whitespace around", + input: " click >= 8.0.0 ", + wantPkg: "click", + wantSpc: ">= 8.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dep := ParseDependency(tt.input, "") + + if dep.Name != tt.wantPkg { + t.Errorf( + "Name = %q, want %q", dep.Name, tt.wantPkg, + ) + } + if dep.Spec != tt.wantSpc { + t.Errorf( + "Spec = %q, want %q", dep.Spec, tt.wantSpc, + ) + } + if tt.markers != "" && dep.Markers != tt.markers { + t.Errorf( + "Markers = %q, want %q", + dep.Markers, tt.markers, + ) + } + if len(tt.extras) > 0 { + if len(dep.Extras) != len(tt.extras) { + t.Fatalf( + "Extras = %v, want %v", + dep.Extras, tt.extras, + ) + } + for i, e := range tt.extras { + if dep.Extras[i] != e { + t.Errorf( + "Extras[%d] = %q, want %q", + i, dep.Extras[i], e, + ) + } + } + } + }) + } +} + +func TestExtractMinVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + spec string + want string + }{ + {">=2.28.0", "2.28.0"}, + {">=3.2,<4.0", "3.2"}, + {"==23.7.0", "23.7.0"}, + {"==3.2.*", "3.2"}, + {"~=68.0", "68.0"}, + {"!=2.0", ""}, + {">1.0", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.spec, func(t *testing.T) { + t.Parallel() + + got := ExtractMinVersion(tt.spec) + if got != tt.want { + t.Errorf( + "ExtractMinVersion(%q) = %q, want %q", + tt.spec, got, tt.want, + ) + } + }) + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pyproject/writer.go b/PROJECTS/simple-vulnerability-scanner/internal/pyproject/writer.go new file mode 100644 index 00000000..3f1b4cb2 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pyproject/writer.go @@ -0,0 +1,141 @@ +// ©AngelaMos | 2026 +// writer.go + +package pyproject + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/CarterPerez-dev/angela/internal/pypi" + toml "github.com/pelletier/go-toml/v2" +) + +// Updater performs surgical edits on raw pyproject.toml bytes, preserving +// all comments, whitespace, and key ordering +type Updater struct { + content []byte +} + +// NewUpdater wraps raw file content after validating it as syntactically +// correct TOML +func NewUpdater(content []byte) (*Updater, error) { + var probe map[string]any + if err := toml.Unmarshal(content, &probe); err != nil { + return nil, fmt.Errorf("invalid TOML: %w", err) + } + return &Updater{content: content}, nil +} + +// UpdateDependency replaces the version specifier for a single dependency +// while preserving quotes, extras, markers, and surrounding formatting. +// Tries double-quote patterns first, then single-quote. +func (u *Updater) UpdateDependency(pkg, newSpec string) error { + for _, q := range []byte{'"', '\''} { + pattern := buildDepPattern(pkg, q) + found := false + u.content = pattern.ReplaceAllFunc( + u.content, + func(match []byte) []byte { + found = true + return replaceSpec(pattern, match, newSpec, q) + }, + ) + if found { + var probe map[string]any + if err := toml.Unmarshal(u.content, &probe); err != nil { + return fmt.Errorf( + "update produced invalid TOML: %w", err, + ) + } + return nil + } + } + return fmt.Errorf("dependency %q not found", pkg) +} + +// Bytes returns the current file content +func (u *Updater) Bytes() []byte { + return u.content +} + +// WriteFile atomically writes the updated content to disk +func (u *Updater) WriteFile(path string) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, u.content, 0o600); err != nil { + return fmt.Errorf("write temp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) //nolint:errcheck + return fmt.Errorf("rename: %w", err) + } + return nil +} + +func buildDepPattern(name string, quote byte) *regexp.Regexp { + normalized := pypi.NormalizeName(name) + parts := strings.Split(normalized, "-") + for i, p := range parts { + parts[i] = regexp.QuoteMeta(p) + } + namePattern := strings.Join(parts, `[-_.]?`) + + q := string(quote) + notQ := `[^` + q + `]` + + return regexp.MustCompile( + `(?i)` + + q + + `(` + namePattern + `)` + + `(\[[^\]]*\])?` + + `(\s*[><=!~]` + notQ + `*?)` + + `(;` + notQ + `*)?` + + q, + ) +} + +func replaceSpec( + re *regexp.Regexp, match []byte, newSpec string, quote byte, +) []byte { + groups := re.FindSubmatch(match) + if len(groups) < 5 { + return match + } + + name := groups[1] + extras := groups[2] + markers := groups[4] + + var b []byte + b = append(b, quote) + b = append(b, name...) + b = append(b, extras...) + b = append(b, []byte(newSpec)...) + b = append(b, markers...) + b = append(b, quote) + return b +} + +// UpdateFile is a convenience that reads, updates all given dependencies, +// and writes back atomically +func UpdateFile(path string, updates map[string]string) error { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + u, err := NewUpdater(data) + if err != nil { + return err + } + + for pkg, spec := range updates { + if err := u.UpdateDependency(pkg, spec); err != nil { + return fmt.Errorf("update %s: %w", pkg, err) + } + } + + return u.WriteFile(path) +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/pyproject/writer_test.go b/PROJECTS/simple-vulnerability-scanner/internal/pyproject/writer_test.go new file mode 100644 index 00000000..e3b9e465 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/pyproject/writer_test.go @@ -0,0 +1,177 @@ +// ©AngelaMos | 2026 +// writer_test.go + +package pyproject + +import ( + "strings" + "testing" +) + +const sampleTOML = `# Project configuration +[project] +name = "myapp" +version = "1.0.0" + +# Core dependencies +dependencies = [ + "requests>=2.28.0", # HTTP library + "django>=3.2,<4.0", + "flask[async]>=2.0", + "numpy", +] + +[project.optional-dependencies] +# Development tools +dev = [ + "pytest>=7.0.0", + "black==23.7.0", # Code formatter +] +` + +func TestUpdaterPreservesComments(t *testing.T) { + t.Parallel() + + u, err := NewUpdater([]byte(sampleTOML)) + if err != nil { + t.Fatalf("NewUpdater error: %v", err) + } + + if err := u.UpdateDependency("requests", ">=2.31.0"); err != nil { + t.Fatalf("UpdateDependency error: %v", err) + } + + result := string(u.Bytes()) + + if !strings.Contains(result, `"requests>=2.31.0"`) { + t.Error("version was not updated") + } + if !strings.Contains(result, "# HTTP library") { + t.Error("inline comment was lost") + } + if !strings.Contains(result, "# Project configuration") { + t.Error("header comment was lost") + } + if !strings.Contains(result, "# Core dependencies") { + t.Error("section comment was lost") + } + if !strings.Contains(result, "# Development tools") { + t.Error("optional-deps comment was lost") + } +} + +func TestUpdaterPreservesExtras(t *testing.T) { + t.Parallel() + + u, err := NewUpdater([]byte(sampleTOML)) + if err != nil { + t.Fatal(err) + } + + if err := u.UpdateDependency("flask", ">=3.0"); err != nil { + t.Fatal(err) + } + + result := string(u.Bytes()) + if !strings.Contains(result, `"flask[async]>=3.0"`) { + t.Errorf("extras lost or version not updated: %s", result) + } +} + +func TestUpdaterHandlesExactPin(t *testing.T) { + t.Parallel() + + u, err := NewUpdater([]byte(sampleTOML)) + if err != nil { + t.Fatal(err) + } + + if err := u.UpdateDependency("black", ">=24.0.0"); err != nil { + t.Fatal(err) + } + + result := string(u.Bytes()) + if !strings.Contains(result, `"black>=24.0.0"`) { + t.Errorf("exact pin not updated: %s", result) + } + if !strings.Contains(result, "# Code formatter") { + t.Error("inline comment was lost") + } +} + +func TestUpdaterNotFound(t *testing.T) { + t.Parallel() + + u, err := NewUpdater([]byte(sampleTOML)) + if err != nil { + t.Fatal(err) + } + + err = u.UpdateDependency("nonexistent", ">=1.0") + if err == nil { + t.Fatal("expected error for missing dependency") + } +} + +func TestUpdaterSkipsBareNames(t *testing.T) { + t.Parallel() + + u, err := NewUpdater([]byte(sampleTOML)) + if err != nil { + t.Fatal(err) + } + + err = u.UpdateDependency("numpy", ">=1.26.0") + if err == nil { + t.Fatal("expected error: bare name has no version spec to match") + } +} + +func TestUpdaterMultipleUpdates(t *testing.T) { + t.Parallel() + + u, err := NewUpdater([]byte(sampleTOML)) + if err != nil { + t.Fatal(err) + } + + updates := map[string]string{ + "requests": ">=2.31.0", + "django": ">=4.2.8", + "pytest": ">=8.0.0", + } + + for pkg, spec := range updates { + if err := u.UpdateDependency(pkg, spec); err != nil { + t.Fatalf("update %s: %v", pkg, err) + } + } + + result := string(u.Bytes()) + + if !strings.Contains(result, `"requests>=2.31.0"`) { + t.Error("requests not updated") + } + if !strings.Contains(result, `"django>=4.2.8"`) { + t.Error("django not updated") + } + if !strings.Contains(result, `"pytest>=8.0.0"`) { + t.Error("pytest not updated") + } + + if !strings.Contains(result, "# HTTP library") { + t.Error("comment lost after multiple updates") + } + if !strings.Contains(result, "# Code formatter") { + t.Error("comment lost after multiple updates") + } +} + +func TestUpdaterInvalidTOML(t *testing.T) { + t.Parallel() + + _, err := NewUpdater([]byte(`[invalid`)) + if err == nil { + t.Fatal("expected error for invalid TOML") + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md b/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md new file mode 100644 index 00000000..d49304b1 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md @@ -0,0 +1,190 @@ +# Go Concurrency Patterns Used in angela + +This document explains the concurrency patterns angela uses to fetch data from PyPI and OSV.dev in parallel. These are production patterns used at Uber, Google, and Cloudflare — not textbook exercises. + +--- + +## The problem + +angela needs to query PyPI for every dependency in your `pyproject.toml`. A typical project has 20-50 dependencies. Making those requests sequentially would take 10-25 seconds. Making them all at once would hammer PyPI with 50 simultaneous connections. + +The solution: **bounded concurrency** — run up to N requests in parallel, queuing the rest. + +--- + +## Pattern 1: errgroup.SetLimit + +The `golang.org/x/sync/errgroup` package provides the cleanest API for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`: + +```go +g, ctx := errgroup.WithContext(ctx) +g.SetLimit(c.maxWorkers) // max 10 concurrent goroutines + +for _, name := range names { + g.Go(func() error { + versions, err := c.FetchVersions(ctx, name) + mu.Lock() + results = append(results, FetchResult{ + Name: name, Versions: versions, Err: err, + }) + mu.Unlock() + return nil + }) +} + +_ = g.Wait() +``` + +Key decisions: + +1. **`SetLimit(10)`** — caps concurrent HTTP requests. PyPI recommends 5-10. +2. **Always returns nil** — individual package failures are collected in results, not propagated. One failed package shouldn't cancel the others. +3. **Mutex protects shared slice** — `results` is appended to from multiple goroutines. + +### Why not channels? + +A channel-based worker pool would work, but errgroup.SetLimit does the same thing with less code: + +```go +// Channel approach: ~30 lines of setup +jobs := make(chan string, len(names)) +results := make(chan FetchResult, len(names)) +for range maxWorkers { + go func() { + for name := range jobs { + // ... fetch ... + results <- result + } + }() +} +// ... send jobs, collect results ... + +// errgroup approach: ~15 lines +g.SetLimit(maxWorkers) +for _, name := range names { + g.Go(func() error { /* ... */ }) +} +g.Wait() +``` + +The errgroup version is shorter, handles context cancellation automatically, and has no channel lifecycle to manage. + +--- + +## Pattern 2: Panic recovery in goroutines + +Every goroutine angela launches includes panic recovery: + +```go +g.Go(func() (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic fetching %s: %v", name, r) + } + }() + + versions, fetchErr := c.FetchVersions(ctx, name) + // ... + return nil +}) +``` + +**Why this matters**: an unrecovered panic in a goroutine kills the entire process. In a CLI tool, that means the user sees a stack trace instead of a helpful error message. The `defer recover()` converts panics into errors that flow through the normal error path. + +The named return `(err error)` is essential — it lets the deferred function assign the recovered panic as the return value. + +--- + +## Pattern 3: Context cancellation + +Every HTTP request in angela uses context: + +```go +req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) +``` + +This means: +- If the user presses Ctrl+C, pending requests are cancelled +- If `errgroup.WithContext` detects an error, remaining work stops +- HTTP timeouts are enforced at the transport level + +The context flows from `cobra.Command.Context()` through `runUpdate()` through `FetchAllVersions()` through `FetchVersions()` to the actual HTTP call. This is the standard Go pattern: context as the first parameter, threaded through the entire call chain. + +--- + +## Pattern 4: Retry with exponential backoff + +`internal/pypi/client.go` implements retry for transient failures: + +```go +for attempt := range maxRetries { + if attempt > 0 { + delay := time.Duration(1<= 500 { + lastErr = fmt.Errorf("server error: %d", resp.StatusCode) + continue + } + + return resp, nil +} +``` + +Design choices: + +1. **Exponential backoff** — `1< `` in ASCII. But in Python's world: + +``` +1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1 +``` + +A tool that doesn't understand this will upgrade users to **unstable pre-releases**. That's why angela implements the full PEP 440 parser. + +--- + +## How angela's parser works + +The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to extract all components in one pass: + +``` +(?i)^v? +(?:(\d+)!)? # epoch +(\d+(?:\.\d+)*) # release segments +(?:[-_.]?(alpha|a|beta|b|...|rc)[-_.]?(\d*))? # pre-release +(?:[-_.]?(post|rev|r)[-_.]?(\d*)|-(\d+))? # post-release +(?:[-_.]?(dev)[-_.]?(\d*))? # dev release +(?:\+([a-z0-9]...))?$ # local version +``` + +Each named section maps to a field in the `Version` struct: + +```go +type Version struct { + Raw string + Epoch int + Release []int // [1, 2, 3] for "1.2.3" + PreKind string // "a", "b", or "rc" + PreNum int + Post int // -1 means absent + Dev int // -1 means absent + Local string +} +``` + +The sentinel value `-1` for Post and Dev is critical — it distinguishes "not present" from "present with value 0". Both `1.0.post0` and `1.0.post` are valid (implicit zero), but they're different from `1.0` (no post-release at all). + +--- + +## Normalization edge cases + +PEP 440 allows multiple spellings that all mean the same thing: + +| Input | Normalized | +|-------|-----------| +| `alpha` | `a` | +| `beta` | `b` | +| `c`, `pre`, `preview` | `rc` | +| `rev`, `r` | `post` | +| `1.0-1` | `1.0.post1` (implicit post via dash-number) | +| `1.0a` | `1.0a0` (implicit zero) | +| `v1.0` | `1.0` (strip leading v) | + +The parser handles all of these by normalizing during extraction. + +--- + +## Version comparison: the tuple trick + +Python's `packaging` library compares versions by converting them into tuples that sort naturally. angela uses the same idea: + +```go +func (v Version) Compare(other Version) int { + // 1. Compare epochs + // 2. Compare release segments (with implicit zero extension) + // 3. Compare pre-release (using sentinel values) + // 4. Compare post-release + // 5. Compare dev-release +} +``` + +The key insight is the sentinel values for sorting: + +| Component state | Sort key | +|----------------|----------| +| Dev-only (no pre, no post) | `MinInt, MinInt` — sorts before everything | +| Pre-release `a1` | `0, 1` — alpha rank 0 | +| Pre-release `b2` | `1, 2` — beta rank 1 | +| Pre-release `rc1` | `2, 1` — rc rank 2 | +| Final release (no pre) | `MaxInt, MaxInt` — sorts after all pre-releases | + +This produces the correct PEP 440 ordering without any special-case branching in the comparison loop. + +--- + +## Stability detection + +A version is **stable** if it has no pre-release tag AND no dev tag: + +```go +func (v Version) IsStable() bool { + return v.PreKind == "" && v.Dev < 0 +} +``` + +Post-releases are stable — `1.0.post1` is a documentation fix or metadata correction, not an unstable build. + +--- + +## What to learn from this + +1. **Regex can be the right tool** — a single compiled regex handles all PEP 440 forms in one pass. The alternative (hand-written state machine) would be 3x the code for no real benefit. + +2. **Sentinel values simplify comparison** — using `math.MinInt` and `math.MaxInt` lets the comparison function be a clean sequence of `cmp.Compare` calls without branches. + +3. **Test-driven development** — the version parser has 67 test cases across 10 functions. When implementing something spec-driven like PEP 440, write the test cases first from the spec, then make them pass. + +4. **Normalization at parse time** — by normalizing spellings during parsing (alpha→a, preview→rc), the rest of the codebase never has to think about variant forms. diff --git a/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md b/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md new file mode 100644 index 00000000..64b02898 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md @@ -0,0 +1,169 @@ +# Preserving TOML Comments: Why Regex Surgery is the Right Call + +When angela updates version specifiers in `pyproject.toml`, it must preserve every comment, blank line, and formatting choice the developer made. This is harder than it sounds. This document explains the approach and why alternatives don't work. + +--- + +## The constraint + +A developer's `pyproject.toml` looks like this: + +```toml +[project] +name = "myapp" +version = "1.0.0" + +# Core runtime dependencies +dependencies = [ + "requests>=2.28.0", # HTTP library - pin for security + "django>=3.2,<4.0", + "flask[async]>=2.0", +] +``` + +After running `angela update`, the file should look like: + +```toml +[project] +name = "myapp" +version = "1.0.0" + +# Core runtime dependencies +dependencies = [ + "requests>=2.32.3", # HTTP library - pin for security + "django>=5.1.5", + "flask[async]>=3.1.0", +] +``` + +Only the version numbers changed. Every comment, every space, every quote style — preserved. + +--- + +## Why TOML libraries can't do this + +Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling TOML into Go structs, then marshaling back: + +```go +var proj PyProject +toml.Unmarshal(data, &proj) // comments stripped +proj.Dependencies[0] = "requests>=2.32.3" +output, _ := toml.Marshal(proj) // comments gone, formatting changed +``` + +Go's reflection system has no mechanism to store comment metadata on struct fields. The unmarshal/marshal round-trip fundamentally **destroys comments**. This isn't a bug — it's an architectural limitation of how Go TOML libraries work. + +pelletier/go-toml v2 has an `unstable` package with AST access that preserves comments in the parse tree, but it provides no serialization — you'd have to write your own TOML emitter from the AST. + +--- + +## The regex surgery approach + +angela's solution: don't parse and re-serialize. Instead, treat the file as a byte buffer and surgically replace only the version specifier substring. + +The implementation in `internal/pyproject/writer.go`: + +1. **Build a regex** that matches the full dependency string including quotes: + ``` + "requests>=2.28.0" + ``` + +2. **Capture groups** isolate the parts we need: + - Group 1: package name (`requests`) + - Group 2: extras (`[async]` or empty) + - Group 3: version specifier (`>=2.28.0`) + - Group 4: markers (`;python_version>='3.8'` or empty) + +3. **Replace only group 3** (the version spec) while preserving everything else. + +4. **Validate before and after** — feed the result through go-toml v2's unmarshaler to catch syntax errors. + +```go +func (u *Updater) UpdateDependency(pkg, newSpec string) error { + for _, q := range []byte{'"', '\''} { + pattern := buildDepPattern(pkg, q) + found := false + u.content = pattern.ReplaceAllFunc(u.content, + func(match []byte) []byte { + found = true + return replaceSpec(pattern, match, newSpec, q) + }, + ) + if found { + // Re-validate TOML syntax + var probe map[string]any + if err := toml.Unmarshal(u.content, &probe); err != nil { + return fmt.Errorf("update produced invalid TOML: %w", err) + } + return nil + } + } + return fmt.Errorf("dependency %q not found", pkg) +} +``` + +--- + +## The tricky parts + +### PEP 503 name normalization + +Package names on PyPI are case-insensitive and treat `-`, `_`, and `.` as equivalent: + +``` +Some_Package == some-package == some.package +``` + +The regex must match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`: + +```go +parts := strings.Split(normalized, "-") +for i, p := range parts { + parts[i] = regexp.QuoteMeta(p) +} +namePattern := strings.Join(parts, `[-_.]?`) +``` + +This means the pattern for `some-package` becomes `some[-_.]?package`, matching `some_package`, `some.package`, and `somepackage`. + +### Go RE2 doesn't support backreferences + +The TOML research suggested using `\1` to match the closing quote with the opening quote. Go's `regexp` package uses RE2, which doesn't support backreferences. + +The fix: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character. + +### Atomic file writes + +Even the write is careful — angela writes to a `.tmp` file first, then renames over the original. If the process is killed mid-write, the original file is untouched: + +```go +func (u *Updater) WriteFile(path string) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, u.content, 0o600); err != nil { + return fmt.Errorf("write temp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename: %w", err) + } + return nil +} +``` + +--- + +## This is how production tools do it + +Renovate (GitHub's automated dependency updater) and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize — they surgically modify. The reason is the same: no TOML/YAML/JSON library in any language perfectly round-trips formatting and comments. + +--- + +## What to learn from this + +1. **Sometimes the "crude" approach is correct** — regex surgery sounds hacky, but it's the only way to preserve comments in TOML. The fancier approaches (AST manipulation, custom serializer) are more complex and still lose whitespace. + +2. **Validate at boundaries** — angela validates TOML syntax before and after every edit. The regex does the surgery; go-toml v2 confirms the patient survived. + +3. **Atomic writes prevent corruption** — write to temp + rename is the standard pattern for file updates in Unix. It's two syscalls, but it guarantees the file is never half-written. + +4. **Know your regex engine** — Go uses RE2, which guarantees linear-time matching but doesn't support backreferences or lookaheads. Design patterns accordingly. diff --git a/PROJECTS/simple-vulnerability-scanner/pkg/types/types.go b/PROJECTS/simple-vulnerability-scanner/pkg/types/types.go new file mode 100644 index 00000000..fc1e35a3 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/pkg/types/types.go @@ -0,0 +1,48 @@ +// ©AngelaMos | 2026 +// types.go + +package types + +import "time" + +// Dependency represents a single parsed dependency from a pyproject.toml file +type Dependency struct { + Name string + Spec string + Extras []string + Markers string + Group string +} + +// UpdateResult holds the outcome of checking one dependency for updates +type UpdateResult struct { + Name string + OldSpec string + NewSpec string + OldVer string + NewVer string + Change string + Skipped bool + Reason string +} + +// Vulnerability represents a single security advisory for a package +type Vulnerability struct { + ID string + Aliases []string + Summary string + Severity string + FixedIn string + Link string +} + +// ScanResult holds the aggregated output of a full dependency scan +type ScanResult struct { + Updates []UpdateResult + Vulnerabilities map[string][]Vulnerability + TotalPackages int + TotalUpdated int + TotalVulns int + VulnsScanned bool + Duration time.Duration +} diff --git a/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml b/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml new file mode 100644 index 00000000..7b1cd7e2 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml @@ -0,0 +1,23 @@ +# Sample pyproject.toml for testing angela +[project] +name = "sample-app" +version = "0.1.0" +requires-python = ">=3.10" + +# Core runtime dependencies +dependencies = [ + "requests>=2.28.0", + "django>=3.2,<4.0", + "click>=8.0.0", + "pydantic>=2.0", + "flask[async]>=2.0", +] + +[project.optional-dependencies] +# Development and testing tools +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", # Code formatter + "ruff>=0.1.0", + "mypy>=1.0.0", +] From 58d63b63cab84ae7883b7d645be53d98f62e2f2b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 27 Jan 2026 23:46:34 -0500 Subject: [PATCH 02/10] rename --scan-vulns to --vulns, add subcommand aliases, make check updates-only by default --- .../simple-vulnerability-scanner/Justfile | 2 +- .../internal/cli/update.go | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/PROJECTS/simple-vulnerability-scanner/Justfile b/PROJECTS/simple-vulnerability-scanner/Justfile index 78897c28..4d1b9182 100644 --- a/PROJECTS/simple-vulnerability-scanner/Justfile +++ b/PROJECTS/simple-vulnerability-scanner/Justfile @@ -92,7 +92,7 @@ dev-scan: [group('dev')] dev-full: - go run ./cmd/angela update --scan-vulns --file testdata/pyproject.toml + go run ./cmd/angela update --vulns --file testdata/pyproject.toml # ============================================================================= # Build (Production) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index 53b27c55..ad4cf8a6 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -21,7 +21,7 @@ import ( type updateFlags struct { file string safe bool - scanVulns bool + vulns bool includePrerelease bool } @@ -61,8 +61,9 @@ func newUpdateCmd() *cobra.Command { f := &updateFlags{} cmd := &cobra.Command{ - Use: "update", - Short: "Update dependencies to latest stable versions", + Use: "update", + Aliases: []string{"u"}, + Short: "Update dependencies to latest stable versions", RunE: func(cmd *cobra.Command, _ []string) error { return runUpdate(cmd.Context(), f, false) }, @@ -77,7 +78,7 @@ func newUpdateCmd() *cobra.Command { "skip major version bumps", ) cmd.Flags().BoolVar( - &f.scanVulns, "scan-vulns", false, + &f.vulns, "vulns", false, "also scan for vulnerabilities", ) cmd.Flags().BoolVar( @@ -91,10 +92,10 @@ func newCheckCmd() *cobra.Command { f := &updateFlags{} cmd := &cobra.Command{ - Use: "check", - Short: "Show available updates without modifying files", + Use: "check", + Aliases: []string{"c"}, + Short: "Show available updates without modifying files", RunE: func(cmd *cobra.Command, _ []string) error { - f.scanVulns = true return runUpdate(cmd.Context(), f, true) }, } @@ -107,6 +108,10 @@ func newCheckCmd() *cobra.Command { &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", @@ -118,8 +123,9 @@ func newScanCmd() *cobra.Command { var file string cmd := &cobra.Command{ - Use: "scan", - Short: "Scan dependencies for known vulnerabilities", + Use: "scan", + Aliases: []string{"s"}, + Short: "Scan dependencies for known vulnerabilities", RunE: func(cmd *cobra.Command, _ []string) error { return runScan(cmd.Context(), file) }, @@ -196,7 +202,7 @@ func runUpdate( sortUpdates(updates) var vulns map[string][]types.Vulnerability - if f.scanVulns { + if f.vulns { vulns = scanForVulns(ctx, deps) } @@ -224,7 +230,7 @@ func runUpdate( TotalPackages: len(deps), TotalUpdated: len(updateSpecs), TotalVulns: totalVulns, - VulnsScanned: f.scanVulns, + VulnsScanned: f.vulns, Duration: time.Since(start), }, !dryRun && len(updateSpecs) > 0) From 4c65756ba92d6cd818c3bdab92441b975ba5f16d Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 27 Jan 2026 23:50:52 -0500 Subject: [PATCH 03/10] add global --verbose / -v flag for detailed vulnerability output --- .../simple-vulnerability-scanner/internal/cli/update.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index ad4cf8a6..444f0585 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -18,6 +18,8 @@ import ( "github.com/spf13/cobra" ) +var verbose bool + type updateFlags struct { file string safe bool @@ -44,6 +46,11 @@ latest stable versions, and checks for known CVEs using OSV.dev.`, SilenceErrors: true, } + root.PersistentFlags().BoolVarP( + &verbose, "verbose", "v", false, + "show full vulnerability details", + ) + root.AddCommand( newUpdateCmd(), newCheckCmd(), From c9cf020de447ad769e3e5916f3f365ee4c4a45a3 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 28 Jan 2026 00:23:43 -0500 Subject: [PATCH 04/10] overhaul vuln output with compact/verbose modes, reorder vulns before updates --- .../internal/cli/output.go | 209 ++++++++++++++++-- .../internal/cli/update.go | 6 +- .../internal/osv/client.go | 2 +- .../internal/osv/client_test.go | 4 +- 4 files changed, 199 insertions(+), 22 deletions(-) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go index 79aa1f7e..f4d1d5e2 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go @@ -5,6 +5,7 @@ package cli import ( "fmt" + "sort" "strings" "github.com/CarterPerez-dev/angela/internal/pypi" @@ -92,7 +93,7 @@ func PrintSkipped(updates []types.UpdateResult) { fmt.Println() } -// PrintVulnerabilities displays a formatted list of security advisories +// PrintVulnerabilities displays security advisories in compact or verbose mode func PrintVulnerabilities( vulns map[string][]types.Vulnerability, ) { @@ -100,36 +101,212 @@ func PrintVulnerabilities( return } - fmt.Printf(" %s\n", redBold("Vulnerabilities found:")) + total := 0 + for _, vl := range vulns { + total += len(vl) + } - for pkg, vlist := range vulns { - fmt.Printf(" %s\n", white(pkg)) - for _, v := range vlist { - sevColor := severityColorFn(v.Severity) + fmt.Printf( + " %s %d across %d %s\n", + redBold("Vulnerabilities:"), + total, + len(vulns), + pluralize("package", len(vulns)), + ) + + pkgs := sortedVulnPackages(vulns) + + if verbose { + printVulnsVerbose(pkgs, vulns) + } else { + printVulnsCompact(pkgs, vulns) + } +} + +const maxVulnsPerPackage = 5 + +func printVulnsCompact( + pkgs []string, + vulns map[string][]types.Vulnerability, +) { + for _, pkg := range pkgs { + vlist := vulns[pkg] + sortVulnsBySeverity(vlist) + + fmt.Printf( + "\n %s (%d %s: %s)\n", + white(pkg), + len(vlist), + pluralize("vuln", len(vlist)), + severityBreakdown(vlist), + ) + + limit := min(maxVulnsPerPackage, len(vlist)) + for i := range limit { + printVulnLine(vlist[i]) + } + + if len(vlist) > maxVulnsPerPackage { fmt.Printf( - " %s %s %s\n", - bold(v.ID), - sevColor("["+v.Severity+"]"), - v.Summary, + " %s\n", + dim(fmt.Sprintf( + "...and %d more", + len(vlist)-maxVulnsPerPackage, + )), ) + } + } + + fmt.Printf( + "\n %s\n\n", + dim("Run angela scan -v for full details."), + ) +} + +func printVulnsVerbose( + pkgs []string, + vulns map[string][]types.Vulnerability, +) { + for _, pkg := range pkgs { + vlist := vulns[pkg] + sortVulnsBySeverity(vlist) + + fmt.Printf( + "\n %s (%d %s: %s)\n", + white(pkg), + len(vlist), + pluralize("vuln", len(vlist)), + severityBreakdown(vlist), + ) + + for _, v := range vlist { + id := preferredID(v) + sevColor := severityColorFn(v.Severity) + + fmt.Printf( + "\n %s %s\n", + sevColor( + padRight(strings.ToUpper(v.Severity), 8), + ), + bold(id), + ) + fmt.Printf(" %s\n", v.Summary) if v.FixedIn != "" { fmt.Printf( - " %s %s\n", + " %s %s\n", dim("Fixed in:"), green(v.FixedIn), ) } if v.Link != "" { - fmt.Printf( - " %s\n", - dim(v.Link), - ) + fmt.Printf(" %s\n", dim(v.Link)) } } } fmt.Println() } +func printVulnLine(v types.Vulnerability) { + id := preferredID(v) + sevColor := severityColorFn(v.Severity) + + fixedStr := "" + if v.FixedIn != "" { + fixedStr = dim("Fixed: ") + green(v.FixedIn) + } + + fmt.Printf( + " %s %s %s %s\n", + sevColor(padRight(strings.ToUpper(v.Severity), 8)), + bold(padRight(id, 16)), + truncate(v.Summary, 38), + fixedStr, + ) +} + +func sortedVulnPackages( + vulns map[string][]types.Vulnerability, +) []string { + pkgs := make([]string, 0, len(vulns)) + for k := range vulns { + pkgs = append(pkgs, k) + } + sort.Slice(pkgs, func(i, j int) bool { + li := len(vulns[pkgs[i]]) + lj := len(vulns[pkgs[j]]) + if li != lj { + return li > lj + } + return pkgs[i] < pkgs[j] + }) + return pkgs +} + +func sortVulnsBySeverity(vulns []types.Vulnerability) { + sort.Slice(vulns, func(i, j int) bool { + ri := severityRank(vulns[i].Severity) + rj := severityRank(vulns[j].Severity) + if ri != rj { + return ri < rj + } + return vulns[i].ID < vulns[j].ID + }) +} + +func severityRank(sev string) int { + switch strings.ToUpper(sev) { + case "CRITICAL": + return 0 + case "HIGH": + return 1 + case "MODERATE": + return 2 + case "LOW": + return 3 + default: + return 4 + } +} + +func severityBreakdown( + vulns []types.Vulnerability, +) string { + counts := make(map[string]int) + for _, v := range vulns { + counts[strings.ToUpper(v.Severity)]++ + } + + order := []string{ + "CRITICAL", "HIGH", "MODERATE", "LOW", "UNKNOWN", + } + + var parts []string + for _, sev := range order { + if n := counts[sev]; n > 0 { + parts = append(parts, fmt.Sprintf( + "%d %s", n, strings.ToLower(sev), + )) + } + } + return strings.Join(parts, ", ") +} + +func preferredID(v types.Vulnerability) string { + for _, alias := range v.Aliases { + if strings.HasPrefix(alias, "CVE-") { + return alias + } + } + return v.ID +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + // PrintSummary displays final counts after an update or scan operation func PrintSummary(result types.ScanResult, updated bool) { if updated { @@ -191,7 +368,7 @@ func severityColorFn(sev string) func(a ...any) string { return redBold case "HIGH": return red - case "MEDIUM": + case "MODERATE": return yellow case "LOW": return cyan diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index 444f0585..2d279585 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -219,13 +219,13 @@ func runUpdate( } } - PrintUpdates(updates) - PrintSkipped(updates) - if vulns != nil { PrintVulnerabilities(vulns) } + PrintUpdates(updates) + PrintSkipped(updates) + totalVulns := 0 for _, vl := range vulns { totalVulns += len(vl) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/osv/client.go b/PROJECTS/simple-vulnerability-scanner/internal/osv/client.go index cb1ab8aa..f0383cf6 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/osv/client.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/osv/client.go @@ -359,7 +359,7 @@ func classifyScore(score float64) string { case score >= 7.0: return "HIGH" case score >= 4.0: - return "MEDIUM" + return "MODERATE" case score > 0: return "LOW" default: diff --git a/PROJECTS/simple-vulnerability-scanner/internal/osv/client_test.go b/PROJECTS/simple-vulnerability-scanner/internal/osv/client_test.go index db5b9f9e..0a3e3a8b 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/osv/client_test.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/osv/client_test.go @@ -87,8 +87,8 @@ func TestClassifyScore(t *testing.T) { {9.0, "CRITICAL"}, {8.5, "HIGH"}, {7.0, "HIGH"}, - {6.9, "MEDIUM"}, - {4.0, "MEDIUM"}, + {6.9, "MODERATE"}, + {4.0, "MODERATE"}, {3.9, "LOW"}, {0.1, "LOW"}, {0.0, "NONE"}, From d73241dc8611f28d61eb8391baaed22384cb149c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 28 Jan 2026 00:36:01 -0500 Subject: [PATCH 05/10] add --min-severity flag to filter vulnerabilities by severity level --- .../internal/cli/output.go | 20 +++++++++++++++++++ .../internal/cli/update.go | 11 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go index f4d1d5e2..2711bd35 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go @@ -307,6 +307,26 @@ func truncate(s string, maxLen int) string { return s[:maxLen-3] + "..." } +func filterVulnsBySeverity( + vulns map[string][]types.Vulnerability, + minSev string, +) map[string][]types.Vulnerability { + threshold := severityRank(minSev) + filtered := make(map[string][]types.Vulnerability) + for pkg, vlist := range vulns { + var kept []types.Vulnerability + for _, v := range vlist { + if severityRank(v.Severity) <= threshold { + kept = append(kept, v) + } + } + if len(kept) > 0 { + filtered[pkg] = kept + } + } + return filtered +} + // PrintSummary displays final counts after an update or scan operation func PrintSummary(result types.ScanResult, updated bool) { if updated { diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index 2d279585..a11b38f0 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -18,7 +18,10 @@ import ( "github.com/spf13/cobra" ) -var verbose bool +var ( + verbose bool + minSeverity string +) type updateFlags struct { file string @@ -50,6 +53,10 @@ latest stable versions, and checks for known CVEs using OSV.dev.`, &verbose, "verbose", "v", false, "show full vulnerability details", ) + root.PersistentFlags().StringVar( + &minSeverity, "min-severity", "low", + "minimum severity to report (critical, high, moderate, low)", + ) root.AddCommand( newUpdateCmd(), @@ -220,6 +227,7 @@ func runUpdate( } if vulns != nil { + vulns = filterVulnsBySeverity(vulns, minSeverity) PrintVulnerabilities(vulns) } @@ -255,6 +263,7 @@ func runScan(ctx context.Context, file string) error { PrintScanning(len(deps)) vulns := scanForVulns(ctx, deps) + vulns = filterVulnsBySeverity(vulns, minSeverity) PrintVulnerabilities(vulns) totalVulns := 0 From 841d287893f955ffadde231613bbdec52689ca0c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 28 Jan 2026 01:24:43 -0500 Subject: [PATCH 06/10] add .angela.toml and [tool.angela] config with ignore lists --- .../internal/cli/output.go | 41 ++++++++++ .../internal/cli/update.go | 41 +++++++++- .../internal/config/config.go | 80 +++++++++++++++++++ 3 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/config/config.go diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go index 2711bd35..1e5e58d2 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go @@ -307,6 +307,47 @@ func truncate(s string, maxLen int) string { return s[:maxLen-3] + "..." } +func filterIgnoredVulns( + vulns map[string][]types.Vulnerability, + ignoreIDs []string, +) map[string][]types.Vulnerability { + if len(ignoreIDs) == 0 { + return vulns + } + + ignored := make(map[string]bool, len(ignoreIDs)) + for _, id := range ignoreIDs { + ignored[id] = true + } + + filtered := make(map[string][]types.Vulnerability) + for pkg, vlist := range vulns { + var kept []types.Vulnerability + for _, v := range vlist { + if isVulnIgnored(v, ignored) { + continue + } + kept = append(kept, v) + } + if len(kept) > 0 { + filtered[pkg] = kept + } + } + return filtered +} + +func isVulnIgnored(v types.Vulnerability, ignored map[string]bool) bool { + if ignored[v.ID] { + return true + } + for _, alias := range v.Aliases { + if ignored[alias] { + return true + } + } + return false +} + func filterVulnsBySeverity( vulns map[string][]types.Vulnerability, minSev string, diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index a11b38f0..9c9e9044 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -11,6 +11,7 @@ import ( "sort" "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" @@ -54,7 +55,7 @@ latest stable versions, and checks for known CVEs using OSV.dev.`, "show full vulnerability details", ) root.PersistentFlags().StringVar( - &minSeverity, "min-severity", "low", + &minSeverity, "min-severity", "", "minimum severity to report (critical, high, moderate, low)", ) @@ -183,6 +184,7 @@ func runUpdate( dryRun bool, ) error { start := time.Now() + cfg := config.Load(f.file) deps, err := pyproject.ParseFile(f.file) if err != nil { @@ -210,7 +212,8 @@ func runUpdate( } updates, updateSpecs := resolveUpdates( - deps, versionMap, f.safe, f.includePrerelease, + deps, versionMap, f.safe, + f.includePrerelease, cfg.Ignore, ) sortUpdates(updates) @@ -226,8 +229,10 @@ func runUpdate( } } + minSev := resolveMinSeverity(cfg.MinSeverity) if vulns != nil { - vulns = filterVulnsBySeverity(vulns, minSeverity) + vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns) + vulns = filterVulnsBySeverity(vulns, minSev) PrintVulnerabilities(vulns) } @@ -254,6 +259,7 @@ func runUpdate( func runScan(ctx context.Context, file string) error { start := time.Now() + cfg := config.Load(file) deps, err := pyproject.ParseFile(file) if err != nil { @@ -262,8 +268,10 @@ func runScan(ctx context.Context, file string) error { PrintScanning(len(deps)) + minSev := resolveMinSeverity(cfg.MinSeverity) vulns := scanForVulns(ctx, deps) - vulns = filterVulnsBySeverity(vulns, minSeverity) + vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns) + vulns = filterVulnsBySeverity(vulns, minSev) PrintVulnerabilities(vulns) totalVulns := 0 @@ -286,11 +294,26 @@ func resolveUpdates( 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{ @@ -418,6 +441,16 @@ func latestAny(versions []string) (pypi.Version, error) { return latest, nil } +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, diff --git a/PROJECTS/simple-vulnerability-scanner/internal/config/config.go b/PROJECTS/simple-vulnerability-scanner/internal/config/config.go new file mode 100644 index 00000000..2404c318 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/config/config.go @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// config.go + +package config + +import ( + "os" + "strings" + + toml "github.com/pelletier/go-toml/v2" +) + +// Config holds project-level angela settings loaded from .angela.toml +// or [tool.angela] in pyproject.toml +type Config struct { + MinSeverity string `toml:"min-severity"` + Ignore []string `toml:"ignore"` + IgnoreVulns []string `toml:"ignore-vulns"` +} + +type pyprojectWrapper struct { + Tool struct { + Angela Config `toml:"angela"` + } `toml:"tool"` +} + +// Load reads angela configuration using a cascading resolution order: +// .angela.toml in current directory, then [tool.angela] in pyproject.toml +func Load(pyprojectPath string) Config { + if cfg, err := loadFile(".angela.toml"); err == nil { + return cfg + } + + if cfg, ok := loadFromPyproject(pyprojectPath); ok { + return cfg + } + + return Config{} +} + +func loadFile(path string) (Config, error) { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return Config{}, err + } + + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + + cfg.MinSeverity = strings.ToLower( + strings.TrimSpace(cfg.MinSeverity), + ) + return cfg, nil +} + +func loadFromPyproject(path string) (Config, bool) { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return Config{}, false + } + + var wrapper pyprojectWrapper + if err := toml.Unmarshal(data, &wrapper); err != nil { + return Config{}, false + } + + cfg := wrapper.Tool.Angela + if cfg.MinSeverity == "" && + len(cfg.Ignore) == 0 && + len(cfg.IgnoreVulns) == 0 { + return Config{}, false + } + + cfg.MinSeverity = strings.ToLower( + strings.TrimSpace(cfg.MinSeverity), + ) + return cfg, true +} From fb109314f0c33b314e3a481dd2c39682871c8d89 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 28 Jan 2026 13:12:09 -0500 Subject: [PATCH 07/10] truncate long vuln summaries in verbose mode to prevent ugly wrapping --- PROJECTS/simple-vulnerability-scanner/internal/cli/output.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go index 1e5e58d2..70e2c963 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go @@ -190,7 +190,7 @@ func printVulnsVerbose( ), bold(id), ) - fmt.Printf(" %s\n", v.Summary) + fmt.Printf(" %s\n", truncate(v.Summary, 72)) if v.FixedIn != "" { fmt.Printf( " %s %s\n", From b0c58a529d7234777c4b1e7056b9a4cfb0b801bc Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 28 Jan 2026 14:52:46 -0500 Subject: [PATCH 08/10] add angela init command to scaffold pyproject.toml with config --- .../internal/cli/update.go | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index 9c9e9044..bb6f887f 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -60,6 +60,7 @@ latest stable versions, and checks for known CVEs using OSV.dev.`, ) root.AddCommand( + newInitCmd(), newUpdateCmd(), newCheckCmd(), newScanCmd(), @@ -72,6 +73,55 @@ latest stable versions, and checks for known CVEs using OSV.dev.`, } } +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 pyproject.toml\n\n", greenBold("Created")) + return nil +} + func newUpdateCmd() *cobra.Command { f := &updateFlags{} From 2d84ef62f773d9a7e02bb1805454fba5061228a2 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 28 Jan 2026 16:30:50 -0500 Subject: [PATCH 09/10] add requirements.txt parsing, scanning, and updating support --- .../internal/cli/update.go | 26 +++- .../internal/requirements/parser.go | 91 +++++++++++++ .../internal/requirements/parser_test.go | 122 ++++++++++++++++++ .../internal/requirements/writer.go | 80 ++++++++++++ .../internal/requirements/writer_test.go | 92 +++++++++++++ .../testdata/requirements.txt | 13 ++ 6 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/requirements/parser.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/requirements/parser_test.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/requirements/writer.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/requirements/writer_test.go create mode 100644 PROJECTS/simple-vulnerability-scanner/testdata/requirements.txt diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index bb6f887f..df7d5247 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -9,12 +9,14 @@ import ( "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/pkg/types" "github.com/spf13/cobra" ) @@ -236,7 +238,7 @@ func runUpdate( start := time.Now() cfg := config.Load(f.file) - deps, err := pyproject.ParseFile(f.file) + deps, err := parseDeps(f.file) if err != nil { return err } @@ -274,7 +276,7 @@ func runUpdate( } if !dryRun && len(updateSpecs) > 0 { - if err := pyproject.UpdateFile(f.file, updateSpecs); err != nil { + if err := updateDepsFile(f.file, updateSpecs); err != nil { return fmt.Errorf("write updates: %w", err) } } @@ -311,7 +313,7 @@ func runScan(ctx context.Context, file string) error { start := time.Now() cfg := config.Load(file) - deps, err := pyproject.ParseFile(file) + deps, err := parseDeps(file) if err != nil { return err } @@ -491,6 +493,24 @@ func latestAny(versions []string) (pypi.Version, error) { return latest, nil } +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 diff --git a/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser.go b/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser.go new file mode 100644 index 00000000..81d1087e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser.go @@ -0,0 +1,91 @@ +// ©AngelaMos | 2026 +// parser.go + +package requirements + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/CarterPerez-dev/angela/pkg/types" +) + +// ParseFile reads a requirements.txt and extracts all dependency declarations +func ParseFile(path string) ([]types.Dependency, error) { + f, err := os.Open(path) //nolint:gosec + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf( + "open %s: file not found", path, + ) + } + return nil, fmt.Errorf("read %s: %w", path, err) + } + defer func() { _ = f.Close() }() //nolint:errcheck + + var deps []types.Dependency + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if line == "" || + strings.HasPrefix(line, "#") || + strings.HasPrefix(line, "-") { + continue + } + + if idx := strings.Index(line, " #"); idx >= 0 { + line = strings.TrimSpace(line[:idx]) + } + + dep := parseLine(line) + if dep.Name != "" { + deps = append(deps, dep) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + if len(deps) == 0 { + return nil, fmt.Errorf( + "%s: no dependencies found", path, + ) + } + return deps, nil +} + +func parseLine(s string) types.Dependency { + var dep types.Dependency + + if idx := strings.Index(s, ";"); idx >= 0 { + dep.Markers = strings.TrimSpace(s[idx+1:]) + s = strings.TrimSpace(s[:idx]) + } + + if start := strings.Index(s, "["); start >= 0 { + end := strings.Index(s, "]") + if end > start { + for _, e := range strings.Split(s[start+1:end], ",") { + dep.Extras = append( + dep.Extras, + strings.TrimSpace(e), + ) + } + s = s[:start] + s[end+1:] + } + } + + if idx := strings.IndexAny(s, "><=!~"); idx >= 0 { + dep.Name = strings.TrimSpace(s[:idx]) + dep.Spec = strings.TrimSpace(s[idx:]) + } else { + dep.Name = strings.TrimSpace(s) + } + + return dep +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser_test.go b/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser_test.go new file mode 100644 index 00000000..506080db --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser_test.go @@ -0,0 +1,122 @@ +// ©AngelaMos | 2026 +// parser_test.go + +package requirements + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseFile(t *testing.T) { + t.Parallel() + + content := `# Core deps +django>=3.2.0 +requests==2.28.1 +flask>=2.0,<3.0 +numpy # pinned +click[extra]>=8.0.0; python_version>="3.8" + +# Options to skip +-r other.txt +-e ./local-pkg +--index-url https://pypi.org/simple/ +` + + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + deps, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if len(deps) != 5 { + t.Fatalf("len = %d, want 5", len(deps)) + } + + tests := []struct { + name string + spec string + }{ + {"django", ">=3.2.0"}, + {"requests", "==2.28.1"}, + {"flask", ">=2.0,<3.0"}, + {"numpy", ""}, + {"click", ">=8.0.0"}, + } + + for i, tt := range tests { + if deps[i].Name != tt.name { + t.Errorf("deps[%d].Name = %q, want %q", i, deps[i].Name, tt.name) + } + if deps[i].Spec != tt.spec { + t.Errorf("deps[%d].Spec = %q, want %q", i, deps[i].Spec, tt.spec) + } + } +} + +func TestParseFileExtras(t *testing.T) { + t.Parallel() + + content := "requests[security,socks]>=2.28.0\n" + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + deps, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if len(deps) != 1 { + t.Fatalf("len = %d, want 1", len(deps)) + } + + if len(deps[0].Extras) != 2 { + t.Fatalf("extras len = %d, want 2", len(deps[0].Extras)) + } + if deps[0].Extras[0] != "security" || deps[0].Extras[1] != "socks" { + t.Errorf("extras = %v, want [security socks]", deps[0].Extras) + } +} + +func TestParseFileMarkers(t *testing.T) { + t.Parallel() + + content := "pywin32>=300; sys_platform==\"win32\"\n" + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + deps, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if deps[0].Markers != `sys_platform=="win32"` { + t.Errorf("markers = %q, want sys_platform==\"win32\"", deps[0].Markers) + } +} + +func TestParseFileEmpty(t *testing.T) { + t.Parallel() + + content := "# just comments\n\n-r other.txt\n" + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + _, err := ParseFile(path) + if err == nil { + t.Error("expected error for empty requirements") + } +} + +func TestParseFileNotFound(t *testing.T) { + t.Parallel() + + _, err := ParseFile("/nonexistent/requirements.txt") + if err == nil { + t.Error("expected error for missing file") + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer.go b/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer.go new file mode 100644 index 00000000..6fb4cecb --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer.go @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// writer.go + +package requirements + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/CarterPerez-dev/angela/internal/pypi" +) + +// UpdateFile replaces version specifiers for the given packages in a +// requirements.txt file, preserving all comments and formatting +func UpdateFile(path string, updates map[string]string) error { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + content := data + for pkg, spec := range updates { + pattern := buildPattern(pkg) + found := false + content = pattern.ReplaceAllFunc( + content, + func(match []byte) []byte { + found = true + return replaceSpec(pattern, match, spec) + }, + ) + if !found { + return fmt.Errorf( + "dependency %q not found in %s", pkg, path, + ) + } + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, content, 0o600); err != nil { + return fmt.Errorf("write temp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) //nolint:errcheck + return fmt.Errorf("rename: %w", err) + } + return nil +} + +func buildPattern(name string) *regexp.Regexp { + normalized := pypi.NormalizeName(name) + parts := strings.Split(normalized, "-") + for i, p := range parts { + parts[i] = regexp.QuoteMeta(p) + } + namePattern := strings.Join(parts, `[-_.]?`) + + return regexp.MustCompile( + `(?im)^(` + namePattern + `)` + + `(\[[^\]]*\])?` + + `(\s*[><=!~][^\n;#]*)`, + ) +} + +func replaceSpec( + re *regexp.Regexp, match []byte, newSpec string, +) []byte { + groups := re.FindSubmatch(match) + if len(groups) < 4 { + return match + } + + var b []byte + b = append(b, groups[1]...) + b = append(b, groups[2]...) + b = append(b, []byte(newSpec)...) + return b +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer_test.go b/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer_test.go new file mode 100644 index 00000000..da7c7b3d --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer_test.go @@ -0,0 +1,92 @@ +// ©AngelaMos | 2026 +// writer_test.go + +package requirements + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdateFile(t *testing.T) { + t.Parallel() + + content := `# Core deps +django>=3.2.0 +requests==2.28.1 # HTTP client +flask>=2.0.0 +` + + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + updates := map[string]string{ + "django": ">=6.0.1", + "requests": ">=2.32.5", + } + + if err := UpdateFile(path, updates); err != nil { + t.Fatalf("UpdateFile: %v", err) + } + + data, _ := os.ReadFile(path) //nolint:errcheck,gosec + result := string(data) + + if !strings.Contains(result, "django>=6.0.1") { + t.Error("django not updated") + } + if !strings.Contains(result, "requests>=2.32.5") { + t.Error("requests not updated") + } + if !strings.Contains(result, "# Core deps") { + t.Error("comment lost") + } + if !strings.Contains(result, "# HTTP client") { + t.Error("inline comment lost") + } + if !strings.Contains(result, "flask>=2.0.0") { + t.Error("flask was incorrectly modified") + } +} + +func TestUpdateFilePreservesFormatting(t *testing.T) { + t.Parallel() + + content := "Django>=3.2.0\nnumpy==1.24.0\n" + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + updates := map[string]string{ + "django": ">=6.0.1", + } + + if err := UpdateFile(path, updates); err != nil { + t.Fatalf("UpdateFile: %v", err) + } + + data, _ := os.ReadFile(path) //nolint:errcheck,gosec + result := string(data) + + if !strings.Contains(result, "Django>=6.0.1") { + t.Error("original casing not preserved") + } + if !strings.Contains(result, "numpy==1.24.0") { + t.Error("numpy was incorrectly modified") + } +} + +func TestUpdateFileNotFound(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte("flask>=2.0.0\n"), 0o600) //nolint:errcheck + + err := UpdateFile(path, map[string]string{ + "nonexistent": ">=1.0.0", + }) + if err == nil { + t.Error("expected error for missing dependency") + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/testdata/requirements.txt b/PROJECTS/simple-vulnerability-scanner/testdata/requirements.txt new file mode 100644 index 00000000..813fd38f --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/testdata/requirements.txt @@ -0,0 +1,13 @@ +# Core dependencies +django>=3.2.0 +requests>=2.28.0 +flask>=2.0.0 + +# Dev tools +black>=23.0.0 +pydantic>=1.10.0 + +# Pinned +numpy==1.24.0 +pytest>=7.0.0 +click>=8.0.0 From 47cb20d23f8e6b7c56ee824abf41a97c855e68e4 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 28 Jan 2026 21:13:19 -0500 Subject: [PATCH 10/10] looks magnificent --- .../simple-vulnerability-scanner/.gitignore | 8 + .../simple-vulnerability-scanner/README.md | 323 +++++++++++------- PROJECTS/simple-vulnerability-scanner/go.mod | 6 +- PROJECTS/simple-vulnerability-scanner/go.sum | 13 +- .../internal/cli/output.go | 265 ++++++++------ .../internal/cli/update.go | 117 +++++-- .../internal/ui/banner.go | 79 +++++ .../internal/ui/color.go | 94 +++++ .../internal/ui/spinner.go | 83 +++++ .../internal/ui/symbol.go | 24 ++ .../learn/GO-CONCURRENCY-PATTERNS.md | 80 ++--- .../learn/PEP440-VERSION-PARSING.md | 50 ++- .../learn/TOML-COMMENT-PRESERVATION.md | 68 ++-- .../testdata/pyproject.toml | 4 +- 14 files changed, 866 insertions(+), 348 deletions(-) create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/ui/color.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go diff --git a/PROJECTS/simple-vulnerability-scanner/.gitignore b/PROJECTS/simple-vulnerability-scanner/.gitignore index abe0ce23..18992656 100644 --- a/PROJECTS/simple-vulnerability-scanner/.gitignore +++ b/PROJECTS/simple-vulnerability-scanner/.gitignore @@ -12,10 +12,18 @@ bin/ # Test *.test +coverage.out +*.prof +trace.out # Build tmp/ +# Cache +.ruff_cache/ +__pycache__/ +*.pyc + # IDE .idea/ .vscode/ diff --git a/PROJECTS/simple-vulnerability-scanner/README.md b/PROJECTS/simple-vulnerability-scanner/README.md index 9dbca503..bad007cf 100644 --- a/PROJECTS/simple-vulnerability-scanner/README.md +++ b/PROJECTS/simple-vulnerability-scanner/README.md @@ -1,23 +1,23 @@ # angela -A fast CLI tool that updates Python dependencies in `pyproject.toml` and scans for known vulnerabilities using [OSV.dev](https://osv.dev). +A fast Python dependency updater and vulnerability scanner, written in Go. -Built in Go for speed — parallel HTTP requests, local caching with ETag support, and a single binary with zero runtime dependencies. +angela scans your `pyproject.toml` or `requirements.txt`, updates all dependencies to their latest stable versions, and checks for known CVEs using [OSV.dev](https://osv.dev) — in a single command. ---- +## Highlights -## Why +- **One command updates everything** in `pyproject.toml` or `requirements.txt`, like `pnpm update` for Python. +- Fast parallel queries against the [PyPI Simple API](https://peps.python.org/pep-0691/) with local ETag caching. +- Built-in vulnerability scanning via [OSV.dev](https://osv.dev) batch queries — no API key required. +- Full [PEP 440](https://peps.python.org/pep-0440/) version parsing with pre-release filtering and epoch support. +- Comment-preserving file updates — inline comments, section headers, and formatting stay intact. +- Configurable via `.angela.toml` or `[tool.angela]` in `pyproject.toml`. +- Single binary with zero runtime dependencies. -The Python ecosystem lacks a single tool that updates all dependencies in `pyproject.toml` the way `pnpm update` works for JavaScript. Existing options either show outdated packages without updating them (`pip list --outdated`), upgrade one at a time (`pip install --upgrade`), or require CI infrastructure (Dependabot, Renovate). - -**angela** fills this gap: one command updates everything, with built-in CVE scanning. - ---- - -## Install +## Installation ```bash -go install github.com/CarterPerez-dev/angela@latest +go install github.com/CarterPerez-dev/angela/cmd/angela@latest ``` Or build from source: @@ -25,119 +25,211 @@ Or build from source: ```bash git clone https://github.com/CarterPerez-dev/angela.git cd angela -go build -o bin/angela ./cmd/angela +go build -o angela ./cmd/angela ``` ---- - -## Usage - -### Update all dependencies +## Quick start ```bash +# Initialize a new pyproject.toml +angela init + +# Update all dependencies to latest stable versions angela update -``` -Reads `pyproject.toml` in the current directory, queries PyPI for the latest stable versions, and writes the updates back to the file. - -### Dry run (show what would change) - -```bash +# Preview updates without writing changes angela check -``` -Shows available updates and vulnerabilities without modifying any files. - -### Scan for vulnerabilities only - -```bash +# Scan for known vulnerabilities angela scan + +# Update + scan for vulnerabilities in one pass +angela update --vulns ``` -Checks all pinned dependencies against the OSV.dev vulnerability database. +## Commands -### Update with vulnerability scan +### `angela init` -```bash -angela update --scan-vulns +Create a new `pyproject.toml` with angela configuration in the current directory. + +```console +$ angela init + + Created pyproject.toml ``` -Updates dependencies and reports any known CVEs in your current versions. +### `angela update` -### Skip major version bumps +Update all dependencies in `pyproject.toml` to their latest stable versions. -```bash -angela update --safe -``` +```console +$ angela update -Only applies minor and patch updates, skipping anything that crosses a major version boundary. - -### Include pre-release versions - -```bash -angela update --include-prerelease -``` - -Considers alpha, beta, release candidate, and dev versions when resolving the latest. - -### Specify a different file - -```bash -angela update --file path/to/pyproject.toml -``` - -### Clear the local cache - -```bash -angela cache clear -``` - -Removes all cached PyPI responses from `~/.angela/cache/`. - ---- - -## Example output - -``` Scanning 9 dependencies... Updates available: - django 3.2.0 -> 5.1.5 (major) - requests 2.28.0 -> 2.32.3 (minor) - click 8.0.0 -> 8.1.8 (patch) - pydantic 2.0.0 -> 2.10.6 (minor) + django 3.2.0 -> 6.0.1 (major) + requests 2.28.0 -> 2.32.5 (minor) flask 2.0.0 -> 3.1.0 (major) - pytest 7.0.0 -> 8.3.4 (major) - black 23.0.0 -> 25.1.0 (major) - ruff 0.1.0 -> 0.9.4 (minor) - mypy 1.0.0 -> 1.14.1 (minor) - - Vulnerabilities found: - django - GHSA-2hrw-hx67-34x6 [CRITICAL] Potential denial-of-service in django.utils.text.Truncator - Fixed in: 4.2.16 - requests - GHSA-9wx4-h78v-vm56 [MEDIUM] Requests `Session` object does not verify requests after making first request with verify=False - Fixed in: 2.32.0 + click 8.0.0 -> 8.1.8 (patch) Updated pyproject.toml 9 packages checked - 9 updated + 4 updated + Done in 1.8s +``` + +Flags: + +``` +-f, --file string Path to dependency file (default "pyproject.toml") + --safe Skip major version bumps + --vulns Also scan for vulnerabilities + --include-prerelease Include pre-release versions (alpha, beta, rc, dev) +``` + +### `angela check` + +Show available updates without modifying any files. + +```console +$ angela check +``` + +Accepts the same flags as `update`. Useful for CI or previewing changes before applying them. + +### `angela scan` + +Scan all dependencies for known vulnerabilities via OSV.dev. + +```console +$ angela scan + + Scanning 9 dependencies... + + Vulnerabilities: 36 across 4 packages + + django (30 vulns: 5 critical, 15 high, 10 moderate) + CRITICAL CVE-2022-28346 SQL Injection in Django Fixed: 2.2.28 + CRITICAL CVE-2022-34265 Trunc()/Extract() SQL Injection Fixed: 3.2.14 + HIGH CVE-2023-24580 Resource exhaustion Fixed: 3.2.18 + HIGH CVE-2022-36359 Reflected File Download attack Fixed: 3.2.15 + HIGH CVE-2023-46695 Potential denial-of-service Fixed: 4.2.8 + ...and 25 more + + requests (3 vulns: 3 moderate) + MODERATE CVE-2024-35195 Session verify=False bypass Fixed: 2.32.0 + MODERATE CVE-2023-32681 Proxy-Authorization header leak Fixed: 2.31.0 + MODERATE CVE-2024-47081 .netrc credentials leak via URLs Fixed: 2.32.4 + + Run angela scan -v for full details. + + 9 packages checked 36 vulnerabilities found Done in 2.4s ``` ---- +Use `-v` for full vulnerability details including advisory links: + +```console +$ angela scan -v +``` + +Flags: + +``` +-f, --file string Path to dependency file (default "pyproject.toml") +``` + +### `angela cache clear` + +Remove all cached PyPI responses from `~/.angela/cache/`. + +### Global flags + +``` +-v, --verbose Show full vulnerability details + --min-severity Minimum severity to report (critical, high, moderate, low) +``` + +### Subcommand aliases + +| Command | Alias | +|---------|-------| +| `angela update` | `angela u` | +| `angela check` | `angela c` | +| `angela scan` | `angela s` | + +## File support + +angela works with both `pyproject.toml` and `requirements.txt`. The file type is detected automatically by extension: + +```bash +# pyproject.toml (default) +angela update + +# requirements.txt +angela update -f requirements.txt +``` + +For `pyproject.toml`, angela reads `[project.dependencies]` and `[project.optional-dependencies]`. For `requirements.txt`, it parses standard pip-format lines, skipping comments, blank lines, and pip options (`-r`, `-e`, `--index-url`). + +Both parsers handle PEP 508 dependency strings including extras (`[security,socks]`) and environment markers (`; python_version >= "3.8"`). + +## Configuration + +angela supports project-level configuration through either a standalone `.angela.toml` file or a `[tool.angela]` section in `pyproject.toml`. + +### `.angela.toml` + +```toml +min-severity = "moderate" + +ignore = [ + "django", + "numpy", +] + +ignore-vulns = [ + "CVE-2024-3772", +] +``` + +### `[tool.angela]` in pyproject.toml + +```toml +[tool.angela] +min-severity = "moderate" +ignore = ["django", "numpy"] +ignore-vulns = ["CVE-2024-3772"] +``` + +### Options + +| Key | Type | Description | +|-----|------|-------------| +| `min-severity` | string | Minimum severity to report: `critical`, `high`, `moderate`, `low` | +| `ignore` | string array | Package names to skip during updates | +| `ignore-vulns` | string array | Vulnerability IDs to suppress (CVE or GHSA) | + +### Resolution order + +1. CLI flags (`--min-severity`) +2. `.angela.toml` +3. `[tool.angela]` in `pyproject.toml` +4. Defaults ## How it works ``` -1. Parse pyproject.toml - Extract all [project.dependencies] and [project.optional-dependencies] +1. Parse dependency file + Read pyproject.toml or requirements.txt + Extract package names and version specifiers -2. Query PyPI Simple API (parallel) - Fetch version lists using the lightweight JSON format - ETag-based caching avoids redundant downloads +2. Query PyPI (parallel) + Fetch version lists from the Simple API with JSON format + Cache responses locally with ETag support (1-hour TTL) 3. Resolve updates Parse versions per PEP 440 (epochs, pre-releases, post-releases) @@ -145,40 +237,43 @@ Removes all cached PyPI responses from `~/.angela/cache/`. Classify changes as major, minor, or patch 4. Scan for vulnerabilities (optional) - Batch query OSV.dev for all dependencies + Batch query OSV.dev for all dependencies in a single request Hydrate results with full advisory details Deduplicate overlapping CVE/GHSA identifiers 5. Write updates - Regex-based surgery preserves comments and formatting + Regex-based surgery preserves all comments and formatting Atomic write via temp file + rename ``` ---- - -## Architecture +## Project structure ``` -cmd/angela/main.go CLI entry point +cmd/angela/main.go Entry point internal/ cli/ - update.go Command definitions and orchestration - output.go Terminal formatting with color + update.go Commands and orchestration + output.go Terminal formatting + config/ + config.go Configuration loading pypi/ - version.go PEP 440 parser and comparator - client.go PyPI Simple API client with retry - cache.go File-backed ETag cache + client.go PyPI Simple API client with retry + version.go PEP 440 parser and comparator + cache.go File-backed ETag cache osv/ - client.go OSV.dev batch vulnerability scanner + client.go OSV.dev batch vulnerability scanner pyproject/ - parser.go TOML parsing and PEP 508 splitting - writer.go Comment-preserving regex updater + parser.go TOML parsing and PEP 508 splitting + writer.go Comment-preserving regex updater + requirements/ + parser.go requirements.txt parsing + writer.go requirements.txt updater pkg/types/ - types.go Shared domain types + types.go Shared domain types +testdata/ Test fixtures +learn/ Educational deep dives ``` ---- - ## Development Requires Go 1.24+ and [just](https://github.com/casey/just). @@ -187,20 +282,16 @@ Requires Go 1.24+ and [just](https://github.com/casey/just). just test # Run all tests with race detector just lint # Run golangci-lint just build # Build binary to bin/angela -just cover # Generate HTML coverage report -just check # Lint + test in one step +just cover # Generate coverage report +just ci # Lint + test in one step just run check # Run angela check via go run ``` ---- - ## Part of Cybersecurity-Projects -This tool is project #1 in the [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) repository — a collection of 60 security-focused projects built for learning and reference. The code is written to be educational: clear structure, proper error handling, and thorough testing. +This tool is project #1 in [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) — a collection of 60 security-focused projects built for learning and reference. -See the [`learn/`](learn/) directory for deep dives into the techniques used here. - ---- +The code is written to be educational: clear structure, proper error handling, and thorough testing. See the [`learn/`](learn/) directory for deep dives into the techniques used. ## License diff --git a/PROJECTS/simple-vulnerability-scanner/go.mod b/PROJECTS/simple-vulnerability-scanner/go.mod index de7bc54f..1672bddb 100644 --- a/PROJECTS/simple-vulnerability-scanner/go.mod +++ b/PROJECTS/simple-vulnerability-scanner/go.mod @@ -11,8 +11,8 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.25.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.40.0 // indirect ) diff --git a/PROJECTS/simple-vulnerability-scanner/go.sum b/PROJECTS/simple-vulnerability-scanner/go.sum index 8f7f53f3..0cf2712d 100644 --- a/PROJECTS/simple-vulnerability-scanner/go.sum +++ b/PROJECTS/simple-vulnerability-scanner/go.sum @@ -3,9 +3,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -13,13 +12,13 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -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= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go index 70e2c963..ceefc9b3 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go @@ -9,31 +9,25 @@ import ( "strings" "github.com/CarterPerez-dev/angela/internal/pypi" + "github.com/CarterPerez-dev/angela/internal/ui" "github.com/CarterPerez-dev/angela/pkg/types" - "github.com/fatih/color" ) -var ( - bold = color.New(color.Bold).SprintFunc() - dim = color.New(color.Faint).SprintFunc() - cyan = color.New(color.FgCyan, color.Bold).SprintFunc() - green = color.New(color.FgGreen).SprintFunc() - yellow = color.New(color.FgYellow).SprintFunc() - red = color.New(color.FgRed).SprintFunc() - redBold = color.New(color.FgRed, color.Bold).SprintFunc() - greenBold = color.New(color.FgGreen, color.Bold).SprintFunc() - white = color.New(color.FgWhite, color.Bold).SprintFunc() -) +func printDivider() { + fmt.Printf("\n %s\n", ui.HiBlack(ui.HRule(44))) +} -// PrintScanning announces the start of a scan -func PrintScanning(count int) { +func printSectionHeader( + symbol, title string, + symbolColor, titleColor func(a ...any) string, +) { fmt.Printf( - "\n %s %d dependencies...\n\n", - cyan("Scanning"), count, + "\n %s %s\n", + symbolColor(symbol), + titleColor(title), ) } -// PrintUpdates displays a formatted table of available dependency updates func PrintUpdates(updates []types.UpdateResult) { var actionable []types.UpdateResult for _, u := range updates { @@ -43,11 +37,22 @@ func PrintUpdates(updates []types.UpdateResult) { } if len(actionable) == 0 { - fmt.Printf(" %s\n\n", dim("All dependencies are up to date.")) + printDivider() + fmt.Printf( + "\n %s %s\n", + ui.HiGreen(ui.Check), + ui.HiBlackItalic( + "All dependencies are up to date.", + ), + ) return } - fmt.Printf(" %s\n", cyan("Updates available:")) + printDivider() + printSectionHeader( + ui.Diamond, "Updates available", + ui.Cyan, ui.Red, + ) nameWidth := 0 oldWidth := 0 @@ -63,18 +68,19 @@ func PrintUpdates(updates []types.UpdateResult) { for _, u := range actionable { changeColor := changeColorFn(u.Change) fmt.Printf( - " %-*s %s %s %s %s\n", - nameWidth, white(u.Name), - dim(padRight(u.OldVer, oldWidth)), - dim("->"), + " %-*s %s %s %s %s %s\n", + nameWidth, ui.HiBlue(u.Name), + ui.HiBlackCrossed( + padRight(u.OldVer, oldWidth), + ), + ui.Green(ui.Arrow), changeColor(u.NewVer), - dim("("+u.Change+")"), + ui.HiBlack(ui.ArrowRight), + changeColor(u.Change), ) } - fmt.Println() } -// PrintSkipped displays dependencies that were not updated with reasons func PrintSkipped(updates []types.UpdateResult) { var skipped []types.UpdateResult for _, u := range updates { @@ -86,14 +92,21 @@ func PrintSkipped(updates []types.UpdateResult) { return } - fmt.Printf(" %s\n", dim("Skipped:")) + printDivider() + printSectionHeader( + ui.ArrowRight, "Skipped", + ui.Yellow, ui.Red, + ) + for _, u := range skipped { - fmt.Printf(" %s %s\n", dim(u.Name), dim(u.Reason)) + fmt.Printf( + " %s %s\n", + ui.HiBlue(u.Name), + ui.HiBlackItalic(u.Reason), + ) } - fmt.Println() } -// PrintVulnerabilities displays security advisories in compact or verbose mode func PrintVulnerabilities( vulns map[string][]types.Vulnerability, ) { @@ -106,12 +119,15 @@ func PrintVulnerabilities( total += len(vl) } + printDivider() fmt.Printf( - " %s %d across %d %s\n", - redBold("Vulnerabilities:"), - total, - len(vulns), - pluralize("package", len(vulns)), + "\n %s %s %s %s %s %s\n", + ui.Red(ui.TriangleUp), + ui.Red("Vulnerabilities:"), + ui.RedBold(fmt.Sprintf("%d", total)), + ui.HiBlack("across"), + ui.RedBold(fmt.Sprintf("%d", len(vulns))), + ui.HiBlack(pluralize("package", len(vulns))), ) pkgs := sortedVulnPackages(vulns) @@ -134,11 +150,15 @@ func printVulnsCompact( sortVulnsBySeverity(vlist) fmt.Printf( - "\n %s (%d %s: %s)\n", - white(pkg), - len(vlist), - pluralize("vuln", len(vlist)), - severityBreakdown(vlist), + "\n %s %s %s\n", + ui.Blue(ui.Gem), + ui.HiCyan(pkg), + ui.HiBlack(fmt.Sprintf( + "(%d %s: %s)", + len(vlist), + pluralize("vuln", len(vlist)), + severityBreakdown(vlist), + )), ) limit := min(maxVulnsPerPackage, len(vlist)) @@ -148,8 +168,8 @@ func printVulnsCompact( if len(vlist) > maxVulnsPerPackage { fmt.Printf( - " %s\n", - dim(fmt.Sprintf( + " %s\n", + ui.HiBlackItalic(fmt.Sprintf( "...and %d more", len(vlist)-maxVulnsPerPackage, )), @@ -158,8 +178,10 @@ func printVulnsCompact( } fmt.Printf( - "\n %s\n\n", - dim("Run angela scan -v for full details."), + "\n %s\n", + ui.HiBlackItalic( + "Run angela scan -v for full details.", + ), ) } @@ -172,11 +194,15 @@ func printVulnsVerbose( sortVulnsBySeverity(vlist) fmt.Printf( - "\n %s (%d %s: %s)\n", - white(pkg), - len(vlist), - pluralize("vuln", len(vlist)), - severityBreakdown(vlist), + "\n %s %s %s\n", + ui.Magenta(ui.Gem), + ui.HiCyan(pkg), + ui.HiBlack(fmt.Sprintf( + "(%d %s: %s)", + len(vlist), + pluralize("vuln", len(vlist)), + severityBreakdown(vlist), + )), ) for _, v := range vlist { @@ -184,22 +210,28 @@ func printVulnsVerbose( sevColor := severityColorFn(v.Severity) fmt.Printf( - "\n %s %s\n", + "\n %s %s\n", sevColor( padRight(strings.ToUpper(v.Severity), 8), ), - bold(id), + ui.Magenta(id), + ) + fmt.Printf( + " %s\n", + ui.HiBlack(truncate(v.Summary, 72)), ) - fmt.Printf(" %s\n", truncate(v.Summary, 72)) if v.FixedIn != "" { fmt.Printf( - " %s %s\n", - dim("Fixed in:"), - green(v.FixedIn), + " %s %s\n", + ui.HiBlack("Fixed in:"), + ui.Green(v.FixedIn), ) } if v.Link != "" { - fmt.Printf(" %s\n", dim(v.Link)) + fmt.Printf( + " %s\n", + ui.BlueItalic(v.Link), + ) } } } @@ -212,14 +244,17 @@ func printVulnLine(v types.Vulnerability) { fixedStr := "" if v.FixedIn != "" { - fixedStr = dim("Fixed: ") + green(v.FixedIn) + fixedStr = ui.HiBlack("Fixed: ") + + ui.Green(v.FixedIn) } fmt.Printf( - " %s %s %s %s\n", - sevColor(padRight(strings.ToUpper(v.Severity), 8)), - bold(padRight(id, 16)), - truncate(v.Summary, 38), + " %s %s %s %s\n", + sevColor( + padRight(strings.ToUpper(v.Severity), 8), + ), + ui.Blue(padRight(id, 16)), + ui.HiBlack(truncate(v.Summary, 38)), fixedStr, ) } @@ -242,7 +277,9 @@ func sortedVulnPackages( return pkgs } -func sortVulnsBySeverity(vulns []types.Vulnerability) { +func sortVulnsBySeverity( + vulns []types.Vulnerability, +) { sort.Slice(vulns, func(i, j int) bool { ri := severityRank(vulns[i].Severity) rj := severityRank(vulns[j].Severity) @@ -283,12 +320,13 @@ func severityBreakdown( var parts []string for _, sev := range order { if n := counts[sev]; n > 0 { - parts = append(parts, fmt.Sprintf( + sevColor := severityColorFn(sev) + parts = append(parts, sevColor(fmt.Sprintf( "%d %s", n, strings.ToLower(sev), - )) + ))) } } - return strings.Join(parts, ", ") + return strings.Join(parts, ui.HiBlack(", ")) } func preferredID(v types.Vulnerability) string { @@ -336,7 +374,9 @@ func filterIgnoredVulns( return filtered } -func isVulnIgnored(v types.Vulnerability, ignored map[string]bool) bool { +func isVulnIgnored( + v types.Vulnerability, ignored map[string]bool, +) bool { if ignored[v.ID] { return true } @@ -368,73 +408,106 @@ func filterVulnsBySeverity( return filtered } -// PrintSummary displays final counts after an update or scan operation -func PrintSummary(result types.ScanResult, updated bool) { +func PrintSummary( + result types.ScanResult, updated bool, +) { + printDivider() + printSectionHeader( + ui.Star, "Summary", + ui.Green, ui.RedItalic, + ) + if updated { - fmt.Printf(" %s\n", greenBold("Updated pyproject.toml")) + fmt.Printf( + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Updated pyproject.toml"), + ) } fmt.Printf( - " %s packages checked\n", - bold(fmt.Sprintf("%d", result.TotalPackages)), + " %s %s %s\n", + ui.Green(ui.Check), + ui.Green(fmt.Sprintf("%d", result.TotalPackages)), + ui.GreenItalic("packages checked"), ) if result.TotalUpdated > 0 { fmt.Printf( - " %s updated\n", - bold(fmt.Sprintf("%d", result.TotalUpdated)), + " %s %s %s\n", + ui.Green(ui.ArrowUp), + ui.Green( + fmt.Sprintf("%d", result.TotalUpdated), + ), + ui.GreenItalic("updated"), ) } if result.VulnsScanned { if result.TotalVulns > 0 { fmt.Printf( - " %s\n", - red(fmt.Sprintf( - "%d %s found", - result.TotalVulns, - pluralize("vulnerability", result.TotalVulns), - )), + " %s %s %s\n", + ui.HiRed(ui.Cross), + ui.RedBold( + fmt.Sprintf("%d", result.TotalVulns), + ), + ui.HiRed( + pluralize( + "vulnerability", + result.TotalVulns, + )+" found", + ), ) } else { fmt.Printf( - " %s\n", - green("No vulnerabilities found"), + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("No vulnerabilities found"), ) } } fmt.Printf( - " %s %s\n\n", - dim("Done in"), - dim(result.Duration.Round(1e6).String()), + " %s %s\n\n", + ui.HiCyan(ui.Timer), + ui.HiBlueItalic( + result.Duration.Round(1e6).String(), + ), ) } -// PrintError displays a user-friendly error message func PrintError(msg string) { - fmt.Printf("\n %s %s\n\n", red("error:"), msg) + fmt.Printf( + "\n %s %s %s\n\n", + ui.RedBold(ui.Cross), + ui.RedBold("error:"), + ui.HiRed(msg), + ) } -func changeColorFn(kind string) func(a ...any) string { +func changeColorFn( + kind string, +) func(a ...any) string { switch kind { case pypi.Major.String(): - return red + return ui.Red case pypi.Minor.String(): - return yellow + return ui.HiYellow default: - return green + return ui.Green } } -func severityColorFn(sev string) func(a ...any) string { +func severityColorFn( + sev string, +) func(a ...any) string { switch strings.ToUpper(sev) { case "CRITICAL": - return redBold + return ui.Red case "HIGH": - return red + return ui.RedBold case "MODERATE": - return yellow + return ui.HiYellow case "LOW": - return cyan + return ui.Cyan default: - return dim + return ui.HiBlack } } diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index df7d5247..8f91283e 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -50,8 +51,25 @@ func Execute() { 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", @@ -116,11 +134,19 @@ func runInit() error { name := filepath.Base(dir) content := fmt.Sprintf(pyprojectTemplate, name) - if err := os.WriteFile("pyproject.toml", []byte(content), 0o644); err != nil { //nolint:gosec + 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 pyproject.toml\n\n", greenBold("Created")) + fmt.Printf( + "\n %s %s\n\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Created pyproject.toml"), + ) return nil } @@ -128,17 +154,19 @@ func newUpdateCmd() *cobra.Command { f := &updateFlags{} cmd := &cobra.Command{ - Use: "update", + Use: "update [path]", Aliases: []string{"u"}, Short: "Update dependencies to latest stable versions", - RunE: func(cmd *cobra.Command, _ []string) error { + 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 pyproject.toml", + "path to dependency file", ) cmd.Flags().BoolVar( &f.safe, "safe", false, @@ -159,17 +187,19 @@ func newCheckCmd() *cobra.Command { f := &updateFlags{} cmd := &cobra.Command{ - Use: "check", + Use: "check [path]", Aliases: []string{"c"}, Short: "Show available updates without modifying files", - RunE: func(cmd *cobra.Command, _ []string) error { + 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 pyproject.toml", + "path to dependency file", ) cmd.Flags().BoolVar( &f.safe, "safe", false, @@ -190,17 +220,19 @@ func newScanCmd() *cobra.Command { var file string cmd := &cobra.Command{ - Use: "scan", + Use: "scan [path]", Aliases: []string{"s"}, Short: "Scan dependencies for known vulnerabilities", - RunE: func(cmd *cobra.Command, _ []string) error { + 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 pyproject.toml", + "path to dependency file", ) return cmd } @@ -222,7 +254,11 @@ func newCacheCmd() *cobra.Command { if err := client.ClearCache(); err != nil { return err } - fmt.Println(" Cache cleared.") + fmt.Printf( + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Cache cleared."), + ) return nil }, }) @@ -243,10 +279,14 @@ func runUpdate( return err } - PrintScanning(len(deps)) + spin := ui.NewSpinner(fmt.Sprintf( + "Scanning %d dependencies...", len(deps), + )) + spin.Start() client, err := pypi.NewClient(defaultCacheDir()) if err != nil { + spin.Stop() return err } @@ -271,12 +311,21 @@ func runUpdate( sortUpdates(updates) var vulns map[string][]types.Vulnerability + var scanErr error if f.vulns { - vulns = scanForVulns(ctx, deps) + 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 { + if err := updateDepsFile( + f.file, updateSpecs, + ); err != nil { return fmt.Errorf("write updates: %w", err) } } @@ -318,10 +367,21 @@ func runScan(ctx context.Context, file string) error { return err } - PrintScanning(len(deps)) + spin := ui.NewSpinner(fmt.Sprintf( + "Scanning %d dependencies for vulnerabilities...", + len(deps), + )) + spin.Start() minSev := resolveMinSeverity(cfg.MinSeverity) - vulns := scanForVulns(ctx, deps) + vulns, scanErr := scanForVulns(ctx, deps) + + spin.Stop() + + if scanErr != nil { + PrintError(scanErr.Error()) + } + vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns) vulns = filterVulnsBySeverity(vulns, minSev) PrintVulnerabilities(vulns) @@ -446,7 +506,7 @@ func resolveUpdates( func scanForVulns( ctx context.Context, deps []types.Dependency, -) map[string][]types.Vulnerability { +) (map[string][]types.Vulnerability, error) { var queries []osv.PackageQuery for _, dep := range deps { ver := pyproject.ExtractMinVersion(dep.Spec) @@ -460,16 +520,17 @@ func scanForVulns( } if len(queries) == 0 { - return nil + return nil, nil } client := osv.NewClient() vulns, err := client.ScanPackages(ctx, queries) if err != nil { - PrintError(fmt.Sprintf("vulnerability scan: %v", err)) - return nil + return nil, fmt.Errorf( + "vulnerability scan: %w", err, + ) } - return vulns + return vulns, nil } func latestAny(versions []string) (pypi.Version, error) { @@ -493,6 +554,18 @@ func latestAny(versions []string) (pypi.Version, error) { 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") } diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go new file mode 100644 index 00000000..a849c17e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go @@ -0,0 +1,79 @@ +// ©AngelaMos | 2026 +// banner.go + +package ui + +import "fmt" + +var angelaBanner = []string{ + " ▄▀▄ █▄ █ ▄▀ ██▀ █ ▄▀▄ ", + " █▀█ █ ▀█ ▀▄█ █▄▄ █▄▄ █▀█ ", +} + +var bannerColors = []func(a ...any) string{ + Red, + Blue, +} + +var animeArt = []string{ + "⣿⣿⣿⡷⠊⡢⡹⣦⡑⢂⢕⢂⢕⢂⢕⢂⠕⠔⠌⠝⠛⠶⠶⢶⣦⣄⢂⢕⢂⢕", + "⣿⣿⠏⣠⣾⣦⡐⢌⢿⣷⣦⣅⡑⠕⠡⠐⢿⠿⣛⠟⠛⠛⠛⠛⠡⢷⡈⢂⢕⢂", + "⠟⣡⣾⣿⣿⣿⣿⣦⣑⠝⢿⣿⣿⣿⣿⣿⡵⢁⣤⣶⣶⣿⢿⢿⢿⡟⢻⣤⢑⢂", + "⣾⣿⣿⡿⢟⣛⣻⣿⣿⣿⣦⣬⣙⣻⣿⣿⣷⣿⣿⢟⢝⢕⢕⢕⢕⢽⣿⣿⣷⣔", + "⣿⣿⠵⠚⠉⢀⣀⣀⣈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⢕⢕⢕⢕⢕⢕⣽⣿⣿⣿⣿", + "⢷⣂⣠⣴⣾⡿⡿⡻⡻⣿⣿⣴⣿⣿⣿⣿⣿⣿⣷⣵⣵⣵⣷⣿⣿⣿⣿⣿⣿⡿", + "⢌⠻⣿⡿⡫⡪⡪⡪⡪⣺⣿⣿⣿⣿⣿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃", + "⠣⡁⠹⡪⡪⡪⡪⣪⣾⣿⣿⣿⣿⠋⠐⢉⢍⢄⢌⠻⣿⣿⣿⣿⣿⣿⣿⣿⠏⠈", + "⡣⡘⢄⠙⣾⣾⣾⣿⣿⣿⣿⣿⣿⡀⢐⢕⢕⢕⢕⢕⡘⣿⣿⣿⣿⣿⣿⠏⠠⠈", + "⠌⢊⢂⢣⠹⣿⣿⣿⣿⣿⣿⣿⣿⣧⢐⢕⢕⢕⢕⢕⢅⣿⣿⣿⣿⡿⢋⢜⠠⠈", +} + +var artColors = []func(a ...any) string{ + Red, + Red, + Blue, + Blue, + Red, + Red, + Blue, + Blue, + Blue, + Red, +} + +func PrintBanner() { + fmt.Println() + fmt.Println() + for i, line := range angelaBanner { + c := bannerColors[i%len(bannerColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Printf(" %s\n", White(HRule(52))) + fmt.Printf( + " %s\n\n", + HiBlackItalic( + "Python dependency updater & vulnerability scanner", + ), + ) +} + +func PrintBannerWithArt() { + fmt.Println() + for i, line := range angelaBanner { + c := bannerColors[i%len(bannerColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Printf(" %s\n", White(HRule(52))) + fmt.Printf( + " %s\n", + HiBlackItalic( + "Python dependency updater & vulnerability scanner", + ), + ) + fmt.Println() + for i, line := range animeArt { + c := artColors[i%len(artColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Println() +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go new file mode 100644 index 00000000..01d2beef --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go @@ -0,0 +1,94 @@ +// ©AngelaMos | 2026 +// color.go + +package ui + +import "github.com/fatih/color" + +var ( + Black = color.New(color.FgBlack).SprintFunc() + Red = color.New(color.FgRed).SprintFunc() + Green = color.New(color.FgGreen).SprintFunc() + Yellow = color.New(color.FgYellow).SprintFunc() + Blue = color.New(color.FgBlue).SprintFunc() + Magenta = color.New(color.FgMagenta).SprintFunc() + Cyan = color.New(color.FgCyan).SprintFunc() + White = color.New(color.FgWhite).SprintFunc() + + HiBlack = color.New(color.FgHiBlack).SprintFunc() + HiRed = color.New(color.FgHiRed).SprintFunc() + HiGreen = color.New(color.FgHiGreen).SprintFunc() + HiYellow = color.New(color.FgHiYellow).SprintFunc() + HiBlue = color.New(color.FgHiBlue).SprintFunc() + HiMagenta = color.New(color.FgHiMagenta).SprintFunc() + HiCyan = color.New(color.FgHiCyan).SprintFunc() + HiWhite = color.New(color.FgHiWhite).SprintFunc() + + RedBold = color.New(color.FgRed, color.Bold).SprintFunc() + GreenBold = color.New(color.FgGreen, color.Bold).SprintFunc() + YellowBold = color.New(color.FgYellow, color.Bold).SprintFunc() + BlueBold = color.New(color.FgBlue, color.Bold).SprintFunc() + MagentaBold = color.New( + color.FgMagenta, color.Bold, + ).SprintFunc() + CyanBold = color.New( + color.FgCyan, color.Bold, + ).SprintFunc() + HiRedBold = color.New( + color.FgHiRed, color.Bold, + ).SprintFunc() + HiMagentaBold = color.New( + color.FgHiMagenta, color.Bold, + ).SprintFunc() + HiCyanBold = color.New( + color.FgHiCyan, color.Bold, + ).SprintFunc() + + Dim = color.New(color.Faint).SprintFunc() + DimItalic = color.New( + color.Faint, color.Italic, + ).SprintFunc() + + CyanItalic = color.New( + color.FgCyan, color.Italic, + ).SprintFunc() + MagentaItalic = color.New( + color.FgMagenta, color.Italic, + ).SprintFunc() + RedItalic = color.New( + color.FgRed, color.Italic, + ).SprintFunc() + GreenItalic = color.New( + color.FgGreen, color.Italic, + ).SprintFunc() + HiBlackItalic = color.New( + color.FgHiBlack, color.Italic, + ).SprintFunc() + BlueItalic = color.New( + color.FgBlue, color.Italic, + ).SprintFunc() + HiBlueItalic = color.New( + color.FgHiBlue, color.Italic, + ).SprintFunc() + + CyanUnderline = color.New( + color.FgCyan, color.Underline, + ).SprintFunc() + MagentaUnderline = color.New( + color.FgMagenta, color.Underline, + ).SprintFunc() + + HiCyanBoldItalic = color.New( + color.FgHiCyan, color.Bold, color.Italic, + ).SprintFunc() + HiMagentaBoldItalic = color.New( + color.FgHiMagenta, color.Bold, color.Italic, + ).SprintFunc() + HiRedBoldItalic = color.New( + color.FgHiRed, color.Bold, color.Italic, + ).SprintFunc() + + HiBlackCrossed = color.New( + color.FgHiBlack, color.CrossedOut, + ).SprintFunc() +) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go new file mode 100644 index 00000000..412f283e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go @@ -0,0 +1,83 @@ +// ©AngelaMos | 2026 +// spinner.go + +package ui + +import ( + "fmt" + "strings" + "sync" + "time" +) + +var frames = []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", +} + +type Spinner struct { + msg string + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex + active bool +} + +func NewSpinner(msg string) *Spinner { + return &Spinner{msg: msg} +} + +func (s *Spinner) Start() { + s.mu.Lock() + if s.active { + s.mu.Unlock() + return + } + s.active = true + s.done = make(chan struct{}) + s.wg.Add(1) + s.mu.Unlock() + + go s.run() +} + +func (s *Spinner) Stop() { + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return + } + s.active = false + close(s.done) + s.mu.Unlock() + s.wg.Wait() +} + +func (s *Spinner) run() { + defer s.wg.Done() + fmt.Print("\033[?25l") + + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + idx := 0 + for { + select { + case <-s.done: + clearLine() + fmt.Print("\033[?25h") + return + case <-ticker.C: + frame := frames[idx%len(frames)] + fmt.Printf( + "\r %s %s", + CyanBold(frame), + HiMagenta(s.msg), + ) + idx++ + } + } +} + +func clearLine() { + fmt.Print("\r" + strings.Repeat(" ", 80) + "\r") +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go new file mode 100644 index 00000000..e5706c12 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go @@ -0,0 +1,24 @@ +// ©AngelaMos | 2026 +// symbol.go + +package ui + +import "strings" + +const ( + Arrow = "→" + ArrowRight = "▸" + ArrowUp = "↑" + Diamond = "◆" + Gem = "◈" + Star = "✦" + TriangleUp = "▲" + Check = "✓" + Cross = "✗" + Timer = "⏱" + DividerChar = "━" +) + +func HRule(width int) string { + return strings.Repeat(DividerChar, width) +} diff --git a/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md b/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md index d49304b1..a7ac4346 100644 --- a/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md +++ b/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md @@ -1,20 +1,20 @@ # Go Concurrency Patterns Used in angela -This document explains the concurrency patterns angela uses to fetch data from PyPI and OSV.dev in parallel. These are production patterns used at Uber, Google, and Cloudflare — not textbook exercises. +angela fetches data from PyPI and OSV.dev in parallel. These patterns show up in production codebases at places like Uber and Cloudflare, but they're not complicated — they just require knowing which tool to reach for. --- ## The problem -angela needs to query PyPI for every dependency in your `pyproject.toml`. A typical project has 20-50 dependencies. Making those requests sequentially would take 10-25 seconds. Making them all at once would hammer PyPI with 50 simultaneous connections. +angela needs to query PyPI for every dependency in your `pyproject.toml` or `requirements.txt`. A typical project has 20-50 dependencies. Doing those requests one at a time would take 10-25 seconds. Doing them all at once would hammer PyPI with 50 simultaneous connections. -The solution: **bounded concurrency** — run up to N requests in parallel, queuing the rest. +The answer is **bounded concurrency** — run up to N requests in parallel, queue the rest. --- -## Pattern 1: errgroup.SetLimit +## errgroup.SetLimit -The `golang.org/x/sync/errgroup` package provides the cleanest API for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`: +The `golang.org/x/sync/errgroup` package is the go-to for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`: ```go g, ctx := errgroup.WithContext(ctx) @@ -35,18 +35,18 @@ for _, name := range names { _ = g.Wait() ``` -Key decisions: +A few things to notice: -1. **`SetLimit(10)`** — caps concurrent HTTP requests. PyPI recommends 5-10. -2. **Always returns nil** — individual package failures are collected in results, not propagated. One failed package shouldn't cancel the others. -3. **Mutex protects shared slice** — `results` is appended to from multiple goroutines. +- **`SetLimit(10)`** caps concurrent HTTP requests. PyPI recommends 5-10. +- **Always returns nil** — individual failures get collected in results, not propagated. One package failing shouldn't kill the others. +- **Mutex on the shared slice** — `results` gets appended to from multiple goroutines, so it needs a lock. ### Why not channels? -A channel-based worker pool would work, but errgroup.SetLimit does the same thing with less code: +Channels would work, but errgroup gets you the same thing with half the code: ```go -// Channel approach: ~30 lines of setup +// Channel-based worker pool: ~30 lines jobs := make(chan string, len(names)) results := make(chan FetchResult, len(names)) for range maxWorkers { @@ -57,9 +57,9 @@ for range maxWorkers { } }() } -// ... send jobs, collect results ... +// ... send jobs, collect results, close channels ... -// errgroup approach: ~15 lines +// errgroup: ~15 lines g.SetLimit(maxWorkers) for _, name := range names { g.Go(func() error { /* ... */ }) @@ -67,13 +67,13 @@ for _, name := range names { g.Wait() ``` -The errgroup version is shorter, handles context cancellation automatically, and has no channel lifecycle to manage. +errgroup handles context cancellation for free and there's no channel lifecycle to think about. --- -## Pattern 2: Panic recovery in goroutines +## Panic recovery -Every goroutine angela launches includes panic recovery: +Every goroutine angela launches wraps itself in a recover: ```go g.Go(func() (err error) { @@ -89,32 +89,33 @@ g.Go(func() (err error) { }) ``` -**Why this matters**: an unrecovered panic in a goroutine kills the entire process. In a CLI tool, that means the user sees a stack trace instead of a helpful error message. The `defer recover()` converts panics into errors that flow through the normal error path. +An unrecovered panic in a goroutine kills the entire process. For a CLI tool, that means the user gets a stack trace dump instead of an actual error message. The `defer recover()` turns panics into errors that flow through the normal path. -The named return `(err error)` is essential — it lets the deferred function assign the recovered panic as the return value. +One subtle thing — the named return `(err error)` is what makes this work. Without it, the deferred function has no way to set the return value. --- -## Pattern 3: Context cancellation +## Context cancellation -Every HTTP request in angela uses context: +Every HTTP request uses context: ```go req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) ``` -This means: -- If the user presses Ctrl+C, pending requests are cancelled -- If `errgroup.WithContext` detects an error, remaining work stops +This does a lot of work for you: + +- Ctrl+C cancels pending requests +- If errgroup's context gets cancelled, remaining work stops - HTTP timeouts are enforced at the transport level -The context flows from `cobra.Command.Context()` through `runUpdate()` through `FetchAllVersions()` through `FetchVersions()` to the actual HTTP call. This is the standard Go pattern: context as the first parameter, threaded through the entire call chain. +The context flows from `cobra.Command.Context()` → `runUpdate()` → `FetchAllVersions()` → `FetchVersions()` → the HTTP call itself. Standard Go pattern — context as the first parameter, threaded through everything. --- -## Pattern 4: Retry with exponential backoff +## Retry with exponential backoff -`internal/pypi/client.go` implements retry for transient failures: +`internal/pypi/client.go` retries transient failures: ```go for attempt := range maxRetries { @@ -142,18 +143,13 @@ for attempt := range maxRetries { } ``` -Design choices: - -1. **Exponential backoff** — `1< `` in ASCII. But in Python's world: +If you naively compare version strings, `1.0a1` looks "newer" than `1.0` because `a` > empty string in ASCII. But Python's actual ordering is: ``` 1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1 ``` -A tool that doesn't understand this will upgrade users to **unstable pre-releases**. That's why angela implements the full PEP 440 parser. +A tool that gets this wrong will upgrade users to unstable pre-releases. That's why angela implements the full spec. --- -## How angela's parser works +## How the parser works -The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to extract all components in one pass: +The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to pull out all the components in one pass: ``` (?i)^v? @@ -53,7 +53,7 @@ The parser lives in `internal/pypi/version.go`. It uses a single compiled regex (?:\+([a-z0-9]...))?$ # local version ``` -Each named section maps to a field in the `Version` struct: +Each section maps to a field in the `Version` struct: ```go type Version struct { @@ -68,13 +68,13 @@ type Version struct { } ``` -The sentinel value `-1` for Post and Dev is critical — it distinguishes "not present" from "present with value 0". Both `1.0.post0` and `1.0.post` are valid (implicit zero), but they're different from `1.0` (no post-release at all). +The `-1` sentinel for Post and Dev is important — it's how you tell "not present" apart from "present with value 0". Both `1.0.post0` and `1.0.post` are valid PEP 440 (implicit zero), but they're different from `1.0` which has no post-release at all. --- -## Normalization edge cases +## Normalization -PEP 440 allows multiple spellings that all mean the same thing: +PEP 440 allows a bunch of different spellings that all mean the same thing: | Input | Normalized | |-------|-----------| @@ -86,13 +86,13 @@ PEP 440 allows multiple spellings that all mean the same thing: | `1.0a` | `1.0a0` (implicit zero) | | `v1.0` | `1.0` (strip leading v) | -The parser handles all of these by normalizing during extraction. +The parser normalizes all of these during extraction, so the rest of the codebase never has to think about variant spellings. --- -## Version comparison: the tuple trick +## Version comparison -Python's `packaging` library compares versions by converting them into tuples that sort naturally. angela uses the same idea: +Python's `packaging` library compares versions by converting them to tuples that sort naturally. angela does the same thing: ```go func (v Version) Compare(other Version) int { @@ -104,7 +104,7 @@ func (v Version) Compare(other Version) int { } ``` -The key insight is the sentinel values for sorting: +The trick is in the sentinel values: | Component state | Sort key | |----------------|----------| @@ -114,13 +114,13 @@ The key insight is the sentinel values for sorting: | Pre-release `rc1` | `2, 1` — rc rank 2 | | Final release (no pre) | `MaxInt, MaxInt` — sorts after all pre-releases | -This produces the correct PEP 440 ordering without any special-case branching in the comparison loop. +Using `math.MinInt` and `math.MaxInt` means the comparison function is just a sequence of `cmp.Compare` calls — no special-case branching needed. --- ## Stability detection -A version is **stable** if it has no pre-release tag AND no dev tag: +A version is **stable** if it has no pre-release tag and no dev tag: ```go func (v Version) IsStable() bool { @@ -128,16 +128,14 @@ func (v Version) IsStable() bool { } ``` -Post-releases are stable — `1.0.post1` is a documentation fix or metadata correction, not an unstable build. +Post-releases count as stable — `1.0.post1` is a docs fix or metadata correction, not an unstable build. --- -## What to learn from this +## Takeaways -1. **Regex can be the right tool** — a single compiled regex handles all PEP 440 forms in one pass. The alternative (hand-written state machine) would be 3x the code for no real benefit. +A single compiled regex handles every PEP 440 form in one pass. The alternative would be a hand-written state machine with 3x the code and no real upside. -2. **Sentinel values simplify comparison** — using `math.MinInt` and `math.MaxInt` lets the comparison function be a clean sequence of `cmp.Compare` calls without branches. +Normalizing at parse time (alpha→a, preview→rc) keeps things clean — nothing downstream ever has to handle variant spellings. -3. **Test-driven development** — the version parser has 67 test cases across 10 functions. When implementing something spec-driven like PEP 440, write the test cases first from the spec, then make them pass. - -4. **Normalization at parse time** — by normalizing spellings during parsing (alpha→a, preview→rc), the rest of the codebase never has to think about variant forms. +The version parser currently has 90+ test cases across 10 functions. When you're implementing something spec-driven like this, writing the test cases from the spec first and then making them pass is the way to go. diff --git a/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md b/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md index 64b02898..3a3165b7 100644 --- a/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md +++ b/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md @@ -1,6 +1,6 @@ -# Preserving TOML Comments: Why Regex Surgery is the Right Call +# Preserving Comments in Dependency Files -When angela updates version specifiers in `pyproject.toml`, it must preserve every comment, blank line, and formatting choice the developer made. This is harder than it sounds. This document explains the approach and why alternatives don't work. +When angela updates version specifiers, it can't blow away the developer's comments, blank lines, and formatting. This turns out to be a surprisingly annoying problem. Here's how angela handles it for both `pyproject.toml` and `requirements.txt`. --- @@ -36,13 +36,13 @@ dependencies = [ ] ``` -Only the version numbers changed. Every comment, every space, every quote style — preserved. +Only the version numbers changed. Every comment, every space, every quote style — still there. Same goes for `requirements.txt` — inline comments, section headers, all of it should survive. --- ## Why TOML libraries can't do this -Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling TOML into Go structs, then marshaling back: +Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling into Go structs, then marshaling back: ```go var proj PyProject @@ -51,32 +51,32 @@ proj.Dependencies[0] = "requests>=2.32.3" output, _ := toml.Marshal(proj) // comments gone, formatting changed ``` -Go's reflection system has no mechanism to store comment metadata on struct fields. The unmarshal/marshal round-trip fundamentally **destroys comments**. This isn't a bug — it's an architectural limitation of how Go TOML libraries work. +Go's reflection system has no way to store comment metadata on struct fields. The unmarshal/marshal round-trip just destroys them. This isn't a library bug — it's a fundamental limitation of how Go struct serialization works. -pelletier/go-toml v2 has an `unstable` package with AST access that preserves comments in the parse tree, but it provides no serialization — you'd have to write your own TOML emitter from the AST. +pelletier/go-toml v2 does have an `unstable` package that exposes AST access with comments preserved in the parse tree, but there's no serializer — you'd have to write your own TOML emitter from scratch. --- -## The regex surgery approach +## Regex surgery -angela's solution: don't parse and re-serialize. Instead, treat the file as a byte buffer and surgically replace only the version specifier substring. +angela's approach: don't parse and re-serialize. Treat the file as a byte buffer and surgically replace only the version specifier. -The implementation in `internal/pyproject/writer.go`: +The implementation for pyproject.toml lives in `internal/pyproject/writer.go`: -1. **Build a regex** that matches the full dependency string including quotes: +1. Build a regex that matches the full dependency string inside quotes: ``` "requests>=2.28.0" ``` -2. **Capture groups** isolate the parts we need: +2. Capture groups isolate the pieces: - Group 1: package name (`requests`) - Group 2: extras (`[async]` or empty) - Group 3: version specifier (`>=2.28.0`) - Group 4: markers (`;python_version>='3.8'` or empty) -3. **Replace only group 3** (the version spec) while preserving everything else. +3. Replace only group 3, keep everything else. -4. **Validate before and after** — feed the result through go-toml v2's unmarshaler to catch syntax errors. +4. Validate before and after — feed the result through go-toml v2's unmarshaler to make sure the edit didn't break the TOML syntax. ```go func (u *Updater) UpdateDependency(pkg, newSpec string) error { @@ -90,7 +90,6 @@ func (u *Updater) UpdateDependency(pkg, newSpec string) error { }, ) if found { - // Re-validate TOML syntax var probe map[string]any if err := toml.Unmarshal(u.content, &probe); err != nil { return fmt.Errorf("update produced invalid TOML: %w", err) @@ -104,6 +103,21 @@ func (u *Updater) UpdateDependency(pkg, newSpec string) error { --- +## requirements.txt uses the same idea + +The `requirements.txt` writer in `internal/requirements/writer.go` works the same way — regex-based surgery on raw bytes. The difference is simpler: no quotes to worry about, no TOML validation needed. Lines look like: + +``` +django>=3.2.0 +requests==2.28.1 # HTTP client +``` + +The regex matches the package name (with PEP 503 normalization) and its version specifier, replaces the spec, and leaves everything else alone — including inline comments. + +Both writers share the same atomic write pattern (temp file + rename) and the same PEP 503 name normalization approach. The pyproject writer just has extra complexity for quote styles and TOML validation. + +--- + ## The tricky parts ### PEP 503 name normalization @@ -114,7 +128,7 @@ Package names on PyPI are case-insensitive and treat `-`, `_`, and `.` as equiva Some_Package == some-package == some.package ``` -The regex must match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`: +The regex has to match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`: ```go parts := strings.Split(normalized, "-") @@ -124,17 +138,17 @@ for i, p := range parts { namePattern := strings.Join(parts, `[-_.]?`) ``` -This means the pattern for `some-package` becomes `some[-_.]?package`, matching `some_package`, `some.package`, and `somepackage`. +So the pattern for `some-package` becomes `some[-_.]?package`, which matches `some_package`, `some.package`, and `somepackage`. ### Go RE2 doesn't support backreferences -The TOML research suggested using `\1` to match the closing quote with the opening quote. Go's `regexp` package uses RE2, which doesn't support backreferences. +The TOML research suggested using `\1` to match closing quotes with the opening quote. Go's `regexp` package uses RE2, which doesn't do backreferences. -The fix: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character. +The workaround: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character. ### Atomic file writes -Even the write is careful — angela writes to a `.tmp` file first, then renames over the original. If the process is killed mid-write, the original file is untouched: +angela writes to a `.tmp` file first, then renames over the original. If the process dies mid-write, the original is untouched: ```go func (u *Updater) WriteFile(path string) error { @@ -150,20 +164,12 @@ func (u *Updater) WriteFile(path string) error { } ``` +It's two syscalls, but the file is never in a half-written state. + --- ## This is how production tools do it -Renovate (GitHub's automated dependency updater) and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize — they surgically modify. The reason is the same: no TOML/YAML/JSON library in any language perfectly round-trips formatting and comments. +Renovate and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize either — they do the same kind of surgical modification. No TOML, YAML, or JSON library in any language perfectly round-trips formatting and comments. Regex surgery sounds hacky, but it's the approach that actually works. ---- - -## What to learn from this - -1. **Sometimes the "crude" approach is correct** — regex surgery sounds hacky, but it's the only way to preserve comments in TOML. The fancier approaches (AST manipulation, custom serializer) are more complex and still lose whitespace. - -2. **Validate at boundaries** — angela validates TOML syntax before and after every edit. The regex does the surgery; go-toml v2 confirms the patient survived. - -3. **Atomic writes prevent corruption** — write to temp + rename is the standard pattern for file updates in Unix. It's two syscalls, but it guarantees the file is never half-written. - -4. **Know your regex engine** — Go uses RE2, which guarantees linear-time matching but doesn't support backreferences or lookaheads. Design patterns accordingly. +Go's RE2 engine guarantees linear-time matching, so even on large files with complex patterns there's no risk of catastrophic backtracking. Just keep in mind it doesn't support backreferences or lookaheads — design your patterns around that. diff --git a/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml b/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml index 7b1cd7e2..374c10ce 100644 --- a/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml +++ b/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml @@ -4,7 +4,7 @@ name = "sample-app" version = "0.1.0" requires-python = ">=3.10" -# Core runtime dependencies +# Core runtime dependencies (outdated) dependencies = [ "requests>=2.28.0", "django>=3.2,<4.0", @@ -14,7 +14,7 @@ dependencies = [ ] [project.optional-dependencies] -# Development and testing tools +# Development and testing tools (outdated) dev = [ "pytest>=7.0.0", "black>=23.0.0", # Code formatter