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", +]