diff --git a/PROJECTS/simple-vulnerability-scanner/.gitignore b/PROJECTS/simple-vulnerability-scanner/.gitignore new file mode 100644 index 00000000..18992656 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/.gitignore @@ -0,0 +1,44 @@ +# AngelaMos | 2026 +# .gitignore + +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +.meep + +# Test +*.test +coverage.out +*.prof +trace.out + +# Build +tmp/ + +# Cache +.ruff_cache/ +__pycache__/ +*.pyc + +# 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..4d1b9182 --- /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 --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..bad007cf --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/README.md @@ -0,0 +1,298 @@ +# angela + +A fast Python dependency updater and vulnerability scanner, written in Go. + +angela scans your `pyproject.toml` or `requirements.txt`, updates all dependencies to their latest stable versions, and checks for known CVEs using [OSV.dev](https://osv.dev) — in a single command. + +## Highlights + +- **One command updates everything** in `pyproject.toml` or `requirements.txt`, like `pnpm update` for Python. +- Fast parallel queries against the [PyPI Simple API](https://peps.python.org/pep-0691/) with local ETag caching. +- Built-in vulnerability scanning via [OSV.dev](https://osv.dev) batch queries — no API key required. +- Full [PEP 440](https://peps.python.org/pep-0440/) version parsing with pre-release filtering and epoch support. +- Comment-preserving file updates — inline comments, section headers, and formatting stay intact. +- Configurable via `.angela.toml` or `[tool.angela]` in `pyproject.toml`. +- Single binary with zero runtime dependencies. + +## Installation + +```bash +go install github.com/CarterPerez-dev/angela/cmd/angela@latest +``` + +Or build from source: + +```bash +git clone https://github.com/CarterPerez-dev/angela.git +cd angela +go build -o angela ./cmd/angela +``` + +## Quick start + +```bash +# Initialize a new pyproject.toml +angela init + +# Update all dependencies to latest stable versions +angela update + +# Preview updates without writing changes +angela check + +# Scan for known vulnerabilities +angela scan + +# Update + scan for vulnerabilities in one pass +angela update --vulns +``` + +## Commands + +### `angela init` + +Create a new `pyproject.toml` with angela configuration in the current directory. + +```console +$ angela init + + Created pyproject.toml +``` + +### `angela update` + +Update all dependencies in `pyproject.toml` to their latest stable versions. + +```console +$ angela update + + Scanning 9 dependencies... + + Updates available: + django 3.2.0 -> 6.0.1 (major) + requests 2.28.0 -> 2.32.5 (minor) + flask 2.0.0 -> 3.1.0 (major) + click 8.0.0 -> 8.1.8 (patch) + + Updated pyproject.toml + 9 packages checked + 4 updated + Done in 1.8s +``` + +Flags: + +``` +-f, --file string Path to dependency file (default "pyproject.toml") + --safe Skip major version bumps + --vulns Also scan for vulnerabilities + --include-prerelease Include pre-release versions (alpha, beta, rc, dev) +``` + +### `angela check` + +Show available updates without modifying any files. + +```console +$ angela check +``` + +Accepts the same flags as `update`. Useful for CI or previewing changes before applying them. + +### `angela scan` + +Scan all dependencies for known vulnerabilities via OSV.dev. + +```console +$ angela scan + + Scanning 9 dependencies... + + Vulnerabilities: 36 across 4 packages + + django (30 vulns: 5 critical, 15 high, 10 moderate) + CRITICAL CVE-2022-28346 SQL Injection in Django Fixed: 2.2.28 + CRITICAL CVE-2022-34265 Trunc()/Extract() SQL Injection Fixed: 3.2.14 + HIGH CVE-2023-24580 Resource exhaustion Fixed: 3.2.18 + HIGH CVE-2022-36359 Reflected File Download attack Fixed: 3.2.15 + HIGH CVE-2023-46695 Potential denial-of-service Fixed: 4.2.8 + ...and 25 more + + requests (3 vulns: 3 moderate) + MODERATE CVE-2024-35195 Session verify=False bypass Fixed: 2.32.0 + MODERATE CVE-2023-32681 Proxy-Authorization header leak Fixed: 2.31.0 + MODERATE CVE-2024-47081 .netrc credentials leak via URLs Fixed: 2.32.4 + + Run angela scan -v for full details. + + 9 packages checked + 36 vulnerabilities found + Done in 2.4s +``` + +Use `-v` for full vulnerability details including advisory links: + +```console +$ angela scan -v +``` + +Flags: + +``` +-f, --file string Path to dependency file (default "pyproject.toml") +``` + +### `angela cache clear` + +Remove all cached PyPI responses from `~/.angela/cache/`. + +### Global flags + +``` +-v, --verbose Show full vulnerability details + --min-severity Minimum severity to report (critical, high, moderate, low) +``` + +### Subcommand aliases + +| Command | Alias | +|---------|-------| +| `angela update` | `angela u` | +| `angela check` | `angela c` | +| `angela scan` | `angela s` | + +## File support + +angela works with both `pyproject.toml` and `requirements.txt`. The file type is detected automatically by extension: + +```bash +# pyproject.toml (default) +angela update + +# requirements.txt +angela update -f requirements.txt +``` + +For `pyproject.toml`, angela reads `[project.dependencies]` and `[project.optional-dependencies]`. For `requirements.txt`, it parses standard pip-format lines, skipping comments, blank lines, and pip options (`-r`, `-e`, `--index-url`). + +Both parsers handle PEP 508 dependency strings including extras (`[security,socks]`) and environment markers (`; python_version >= "3.8"`). + +## Configuration + +angela supports project-level configuration through either a standalone `.angela.toml` file or a `[tool.angela]` section in `pyproject.toml`. + +### `.angela.toml` + +```toml +min-severity = "moderate" + +ignore = [ + "django", + "numpy", +] + +ignore-vulns = [ + "CVE-2024-3772", +] +``` + +### `[tool.angela]` in pyproject.toml + +```toml +[tool.angela] +min-severity = "moderate" +ignore = ["django", "numpy"] +ignore-vulns = ["CVE-2024-3772"] +``` + +### Options + +| Key | Type | Description | +|-----|------|-------------| +| `min-severity` | string | Minimum severity to report: `critical`, `high`, `moderate`, `low` | +| `ignore` | string array | Package names to skip during updates | +| `ignore-vulns` | string array | Vulnerability IDs to suppress (CVE or GHSA) | + +### Resolution order + +1. CLI flags (`--min-severity`) +2. `.angela.toml` +3. `[tool.angela]` in `pyproject.toml` +4. Defaults + +## How it works + +``` +1. Parse dependency file + Read pyproject.toml or requirements.txt + Extract package names and version specifiers + +2. Query PyPI (parallel) + Fetch version lists from the Simple API with JSON format + Cache responses locally with ETag support (1-hour TTL) + +3. Resolve updates + Parse versions per PEP 440 (epochs, pre-releases, post-releases) + 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 in a single request + Hydrate results with full advisory details + Deduplicate overlapping CVE/GHSA identifiers + +5. Write updates + Regex-based surgery preserves all comments and formatting + Atomic write via temp file + rename +``` + +## Project structure + +``` +cmd/angela/main.go Entry point +internal/ + cli/ + update.go Commands and orchestration + output.go Terminal formatting + config/ + config.go Configuration loading + pypi/ + client.go PyPI Simple API client with retry + version.go PEP 440 parser and comparator + cache.go File-backed ETag cache + osv/ + client.go OSV.dev batch vulnerability scanner + pyproject/ + parser.go TOML parsing and PEP 508 splitting + writer.go Comment-preserving regex updater + requirements/ + parser.go requirements.txt parsing + writer.go requirements.txt updater +pkg/types/ + types.go Shared domain types +testdata/ Test fixtures +learn/ Educational deep dives +``` + +## 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 coverage report +just ci # Lint + test in one step +just run check # Run angela check via go run +``` + +## Part of Cybersecurity-Projects + +This tool is project #1 in [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) — 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. + +## 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..1672bddb --- /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.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/PROJECTS/simple-vulnerability-scanner/go.sum b/PROJECTS/simple-vulnerability-scanner/go.sum new file mode 100644 index 00000000..0cf2712d --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/go.sum @@ -0,0 +1,24 @@ +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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go new file mode 100644 index 00000000..ceefc9b3 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go @@ -0,0 +1,529 @@ +// ©AngelaMos | 2026 +// output.go + +package cli + +import ( + "fmt" + "sort" + "strings" + + "github.com/CarterPerez-dev/angela/internal/pypi" + "github.com/CarterPerez-dev/angela/internal/ui" + "github.com/CarterPerez-dev/angela/pkg/types" +) + +func printDivider() { + fmt.Printf("\n %s\n", ui.HiBlack(ui.HRule(44))) +} + +func printSectionHeader( + symbol, title string, + symbolColor, titleColor func(a ...any) string, +) { + fmt.Printf( + "\n %s %s\n", + symbolColor(symbol), + titleColor(title), + ) +} + +func PrintUpdates(updates []types.UpdateResult) { + var actionable []types.UpdateResult + for _, u := range updates { + if !u.Skipped { + actionable = append(actionable, u) + } + } + + if len(actionable) == 0 { + printDivider() + fmt.Printf( + "\n %s %s\n", + ui.HiGreen(ui.Check), + ui.HiBlackItalic( + "All dependencies are up to date.", + ), + ) + return + } + + printDivider() + printSectionHeader( + ui.Diamond, "Updates available", + ui.Cyan, ui.Red, + ) + + 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 %s\n", + nameWidth, ui.HiBlue(u.Name), + ui.HiBlackCrossed( + padRight(u.OldVer, oldWidth), + ), + ui.Green(ui.Arrow), + changeColor(u.NewVer), + ui.HiBlack(ui.ArrowRight), + changeColor(u.Change), + ) + } +} + +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 + } + + printDivider() + printSectionHeader( + ui.ArrowRight, "Skipped", + ui.Yellow, ui.Red, + ) + + for _, u := range skipped { + fmt.Printf( + " %s %s\n", + ui.HiBlue(u.Name), + ui.HiBlackItalic(u.Reason), + ) + } +} + +func PrintVulnerabilities( + vulns map[string][]types.Vulnerability, +) { + if len(vulns) == 0 { + return + } + + total := 0 + for _, vl := range vulns { + total += len(vl) + } + + printDivider() + fmt.Printf( + "\n %s %s %s %s %s %s\n", + ui.Red(ui.TriangleUp), + ui.Red("Vulnerabilities:"), + ui.RedBold(fmt.Sprintf("%d", total)), + ui.HiBlack("across"), + ui.RedBold(fmt.Sprintf("%d", len(vulns))), + ui.HiBlack(pluralize("package", len(vulns))), + ) + + pkgs := sortedVulnPackages(vulns) + + if verbose { + printVulnsVerbose(pkgs, vulns) + } else { + printVulnsCompact(pkgs, vulns) + } +} + +const maxVulnsPerPackage = 5 + +func printVulnsCompact( + pkgs []string, + vulns map[string][]types.Vulnerability, +) { + for _, pkg := range pkgs { + vlist := vulns[pkg] + sortVulnsBySeverity(vlist) + + fmt.Printf( + "\n %s %s %s\n", + ui.Blue(ui.Gem), + ui.HiCyan(pkg), + ui.HiBlack(fmt.Sprintf( + "(%d %s: %s)", + len(vlist), + pluralize("vuln", len(vlist)), + severityBreakdown(vlist), + )), + ) + + limit := min(maxVulnsPerPackage, len(vlist)) + for i := range limit { + printVulnLine(vlist[i]) + } + + if len(vlist) > maxVulnsPerPackage { + fmt.Printf( + " %s\n", + ui.HiBlackItalic(fmt.Sprintf( + "...and %d more", + len(vlist)-maxVulnsPerPackage, + )), + ) + } + } + + fmt.Printf( + "\n %s\n", + ui.HiBlackItalic( + "Run angela scan -v for full details.", + ), + ) +} + +func printVulnsVerbose( + pkgs []string, + vulns map[string][]types.Vulnerability, +) { + for _, pkg := range pkgs { + vlist := vulns[pkg] + sortVulnsBySeverity(vlist) + + fmt.Printf( + "\n %s %s %s\n", + ui.Magenta(ui.Gem), + ui.HiCyan(pkg), + ui.HiBlack(fmt.Sprintf( + "(%d %s: %s)", + len(vlist), + pluralize("vuln", len(vlist)), + severityBreakdown(vlist), + )), + ) + + for _, v := range vlist { + id := preferredID(v) + sevColor := severityColorFn(v.Severity) + + fmt.Printf( + "\n %s %s\n", + sevColor( + padRight(strings.ToUpper(v.Severity), 8), + ), + ui.Magenta(id), + ) + fmt.Printf( + " %s\n", + ui.HiBlack(truncate(v.Summary, 72)), + ) + if v.FixedIn != "" { + fmt.Printf( + " %s %s\n", + ui.HiBlack("Fixed in:"), + ui.Green(v.FixedIn), + ) + } + if v.Link != "" { + fmt.Printf( + " %s\n", + ui.BlueItalic(v.Link), + ) + } + } + } + fmt.Println() +} + +func printVulnLine(v types.Vulnerability) { + id := preferredID(v) + sevColor := severityColorFn(v.Severity) + + fixedStr := "" + if v.FixedIn != "" { + fixedStr = ui.HiBlack("Fixed: ") + + ui.Green(v.FixedIn) + } + + fmt.Printf( + " %s %s %s %s\n", + sevColor( + padRight(strings.ToUpper(v.Severity), 8), + ), + ui.Blue(padRight(id, 16)), + ui.HiBlack(truncate(v.Summary, 38)), + fixedStr, + ) +} + +func sortedVulnPackages( + vulns map[string][]types.Vulnerability, +) []string { + pkgs := make([]string, 0, len(vulns)) + for k := range vulns { + pkgs = append(pkgs, k) + } + sort.Slice(pkgs, func(i, j int) bool { + li := len(vulns[pkgs[i]]) + lj := len(vulns[pkgs[j]]) + if li != lj { + return li > lj + } + return pkgs[i] < pkgs[j] + }) + return pkgs +} + +func sortVulnsBySeverity( + vulns []types.Vulnerability, +) { + sort.Slice(vulns, func(i, j int) bool { + ri := severityRank(vulns[i].Severity) + rj := severityRank(vulns[j].Severity) + if ri != rj { + return ri < rj + } + return vulns[i].ID < vulns[j].ID + }) +} + +func severityRank(sev string) int { + switch strings.ToUpper(sev) { + case "CRITICAL": + return 0 + case "HIGH": + return 1 + case "MODERATE": + return 2 + case "LOW": + return 3 + default: + return 4 + } +} + +func severityBreakdown( + vulns []types.Vulnerability, +) string { + counts := make(map[string]int) + for _, v := range vulns { + counts[strings.ToUpper(v.Severity)]++ + } + + order := []string{ + "CRITICAL", "HIGH", "MODERATE", "LOW", "UNKNOWN", + } + + var parts []string + for _, sev := range order { + if n := counts[sev]; n > 0 { + sevColor := severityColorFn(sev) + parts = append(parts, sevColor(fmt.Sprintf( + "%d %s", n, strings.ToLower(sev), + ))) + } + } + return strings.Join(parts, ui.HiBlack(", ")) +} + +func preferredID(v types.Vulnerability) string { + for _, alias := range v.Aliases { + if strings.HasPrefix(alias, "CVE-") { + return alias + } + } + return v.ID +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +func filterIgnoredVulns( + vulns map[string][]types.Vulnerability, + ignoreIDs []string, +) map[string][]types.Vulnerability { + if len(ignoreIDs) == 0 { + return vulns + } + + ignored := make(map[string]bool, len(ignoreIDs)) + for _, id := range ignoreIDs { + ignored[id] = true + } + + filtered := make(map[string][]types.Vulnerability) + for pkg, vlist := range vulns { + var kept []types.Vulnerability + for _, v := range vlist { + if isVulnIgnored(v, ignored) { + continue + } + kept = append(kept, v) + } + if len(kept) > 0 { + filtered[pkg] = kept + } + } + return filtered +} + +func isVulnIgnored( + v types.Vulnerability, ignored map[string]bool, +) bool { + if ignored[v.ID] { + return true + } + for _, alias := range v.Aliases { + if ignored[alias] { + return true + } + } + return false +} + +func filterVulnsBySeverity( + vulns map[string][]types.Vulnerability, + minSev string, +) map[string][]types.Vulnerability { + threshold := severityRank(minSev) + filtered := make(map[string][]types.Vulnerability) + for pkg, vlist := range vulns { + var kept []types.Vulnerability + for _, v := range vlist { + if severityRank(v.Severity) <= threshold { + kept = append(kept, v) + } + } + if len(kept) > 0 { + filtered[pkg] = kept + } + } + return filtered +} + +func PrintSummary( + result types.ScanResult, updated bool, +) { + printDivider() + printSectionHeader( + ui.Star, "Summary", + ui.Green, ui.RedItalic, + ) + + if updated { + fmt.Printf( + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Updated pyproject.toml"), + ) + } + fmt.Printf( + " %s %s %s\n", + ui.Green(ui.Check), + ui.Green(fmt.Sprintf("%d", result.TotalPackages)), + ui.GreenItalic("packages checked"), + ) + if result.TotalUpdated > 0 { + fmt.Printf( + " %s %s %s\n", + ui.Green(ui.ArrowUp), + ui.Green( + fmt.Sprintf("%d", result.TotalUpdated), + ), + ui.GreenItalic("updated"), + ) + } + if result.VulnsScanned { + if result.TotalVulns > 0 { + fmt.Printf( + " %s %s %s\n", + ui.HiRed(ui.Cross), + ui.RedBold( + fmt.Sprintf("%d", result.TotalVulns), + ), + ui.HiRed( + pluralize( + "vulnerability", + result.TotalVulns, + )+" found", + ), + ) + } else { + fmt.Printf( + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("No vulnerabilities found"), + ) + } + } + fmt.Printf( + " %s %s\n\n", + ui.HiCyan(ui.Timer), + ui.HiBlueItalic( + result.Duration.Round(1e6).String(), + ), + ) +} + +func PrintError(msg string) { + fmt.Printf( + "\n %s %s %s\n\n", + ui.RedBold(ui.Cross), + ui.RedBold("error:"), + ui.HiRed(msg), + ) +} + +func changeColorFn( + kind string, +) func(a ...any) string { + switch kind { + case pypi.Major.String(): + return ui.Red + case pypi.Minor.String(): + return ui.HiYellow + default: + return ui.Green + } +} + +func severityColorFn( + sev string, +) func(a ...any) string { + switch strings.ToUpper(sev) { + case "CRITICAL": + return ui.Red + case "HIGH": + return ui.RedBold + case "MODERATE": + return ui.HiYellow + case "LOW": + return ui.Cyan + default: + return ui.HiBlack + } +} + +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..8f91283e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -0,0 +1,614 @@ +// ©AngelaMos | 2026 +// update.go + +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/CarterPerez-dev/angela/internal/config" + "github.com/CarterPerez-dev/angela/internal/osv" + "github.com/CarterPerez-dev/angela/internal/pypi" + "github.com/CarterPerez-dev/angela/internal/pyproject" + "github.com/CarterPerez-dev/angela/internal/requirements" + "github.com/CarterPerez-dev/angela/internal/ui" + "github.com/CarterPerez-dev/angela/pkg/types" + "github.com/spf13/cobra" +) + +var ( + verbose bool + minSeverity string +) + +type updateFlags struct { + file string + safe bool + vulns bool + includePrerelease bool +} + +func defaultCacheDir() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".angela", "cache") +} + +// Execute sets up the CLI and runs the root command +func Execute() { + root := &cobra.Command{ + Use: "angela", + Short: "Python dependency updater and vulnerability scanner", + Long: `angela scans your pyproject.toml, updates dependencies to their +latest stable versions, and checks for known CVEs using OSV.dev.`, + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRun: func( + _ *cobra.Command, _ []string, + ) { + ui.PrintBanner() + }, + } + + defaultHelp := root.HelpFunc() + root.SetHelpFunc( + func(cmd *cobra.Command, args []string) { + if cmd.Root() == cmd { + ui.PrintBannerWithArt() + } else { + ui.PrintBanner() + } + defaultHelp(cmd, args) + }, + ) + + root.PersistentFlags().BoolVarP( + &verbose, "verbose", "v", false, + "show full vulnerability details", + ) + root.PersistentFlags().StringVar( + &minSeverity, "min-severity", "", + "minimum severity to report (critical, high, moderate, low)", + ) + + root.AddCommand( + newInitCmd(), + newUpdateCmd(), + newCheckCmd(), + newScanCmd(), + newCacheCmd(), + ) + + if err := root.Execute(); err != nil { + PrintError(err.Error()) + os.Exit(1) + } +} + +const pyprojectTemplate = `[project] +name = "%s" +version = "0.1.0" +description = "" +requires-python = ">=3.13" +dependencies = [] + +[tool.angela] +# Minimum severity to report (critical, high, moderate, low) +# min-severity = "moderate" + +# Dependencies to skip during updates +# ignore = [] + +# Vulnerability IDs to suppress (accepted risk) +# ignore-vulns = [] +` + +func newInitCmd() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Create a new pyproject.toml with angela configuration", + RunE: func(_ *cobra.Command, _ []string) error { + return runInit() + }, + } +} + +func runInit() error { + if _, err := os.Stat("pyproject.toml"); err == nil { + return fmt.Errorf("pyproject.toml already exists") + } + + dir, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + name := filepath.Base(dir) + content := fmt.Sprintf(pyprojectTemplate, name) + + if err := os.WriteFile( + "pyproject.toml", + []byte(content), + 0o644, + ); err != nil { //nolint:gosec + return fmt.Errorf("write pyproject.toml: %w", err) + } + + fmt.Printf( + "\n %s %s\n\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Created pyproject.toml"), + ) + return nil +} + +func newUpdateCmd() *cobra.Command { + f := &updateFlags{} + + cmd := &cobra.Command{ + Use: "update [path]", + Aliases: []string{"u"}, + Short: "Update dependencies to latest stable versions", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f.file = resolveFile(f.file, args) + return runUpdate(cmd.Context(), f, false) + }, + } + + cmd.Flags().StringVarP( + &f.file, "file", "f", "pyproject.toml", + "path to dependency file", + ) + cmd.Flags().BoolVar( + &f.safe, "safe", false, + "skip major version bumps", + ) + cmd.Flags().BoolVar( + &f.vulns, "vulns", false, + "also scan for vulnerabilities", + ) + cmd.Flags().BoolVar( + &f.includePrerelease, "include-prerelease", false, + "include pre-release versions", + ) + return cmd +} + +func newCheckCmd() *cobra.Command { + f := &updateFlags{} + + cmd := &cobra.Command{ + Use: "check [path]", + Aliases: []string{"c"}, + Short: "Show available updates without modifying files", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f.file = resolveFile(f.file, args) + return runUpdate(cmd.Context(), f, true) + }, + } + + cmd.Flags().StringVarP( + &f.file, "file", "f", "pyproject.toml", + "path to dependency file", + ) + cmd.Flags().BoolVar( + &f.safe, "safe", false, + "skip major version bumps", + ) + cmd.Flags().BoolVar( + &f.vulns, "vulns", false, + "also scan for vulnerabilities", + ) + cmd.Flags().BoolVar( + &f.includePrerelease, "include-prerelease", false, + "include pre-release versions", + ) + return cmd +} + +func newScanCmd() *cobra.Command { + var file string + + cmd := &cobra.Command{ + Use: "scan [path]", + Aliases: []string{"s"}, + Short: "Scan dependencies for known vulnerabilities", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + file = resolveFile(file, args) + return runScan(cmd.Context(), file) + }, + } + + cmd.Flags().StringVarP( + &file, "file", "f", "pyproject.toml", + "path to dependency file", + ) + return cmd +} + +func newCacheCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cache", + Short: "Manage the local response cache", + } + + cmd.AddCommand(&cobra.Command{ + Use: "clear", + Short: "Remove all cached PyPI responses", + RunE: func(_ *cobra.Command, _ []string) error { + client, err := pypi.NewClient(defaultCacheDir()) + if err != nil { + return err + } + if err := client.ClearCache(); err != nil { + return err + } + fmt.Printf( + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Cache cleared."), + ) + return nil + }, + }) + + return cmd +} + +func runUpdate( + ctx context.Context, + f *updateFlags, + dryRun bool, +) error { + start := time.Now() + cfg := config.Load(f.file) + + deps, err := parseDeps(f.file) + if err != nil { + return err + } + + spin := ui.NewSpinner(fmt.Sprintf( + "Scanning %d dependencies...", len(deps), + )) + spin.Start() + + client, err := pypi.NewClient(defaultCacheDir()) + if err != nil { + spin.Stop() + return err + } + + names := make([]string, len(deps)) + for i, d := range deps { + names[i] = d.Name + } + fetched := client.FetchAllVersions(ctx, names) + + versionMap := make(map[string][]string, len(fetched)) + for _, r := range fetched { + if r.Err == nil { + versionMap[r.Name] = r.Versions + } + } + + updates, updateSpecs := resolveUpdates( + deps, versionMap, f.safe, + f.includePrerelease, cfg.Ignore, + ) + + sortUpdates(updates) + + var vulns map[string][]types.Vulnerability + var scanErr error + if f.vulns { + vulns, scanErr = scanForVulns(ctx, deps) + } + + spin.Stop() + + if scanErr != nil { + PrintError(scanErr.Error()) + } + + if !dryRun && len(updateSpecs) > 0 { + if err := updateDepsFile( + f.file, updateSpecs, + ); err != nil { + return fmt.Errorf("write updates: %w", err) + } + } + + minSev := resolveMinSeverity(cfg.MinSeverity) + if vulns != nil { + vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns) + vulns = filterVulnsBySeverity(vulns, minSev) + PrintVulnerabilities(vulns) + } + + PrintUpdates(updates) + PrintSkipped(updates) + + totalVulns := 0 + for _, vl := range vulns { + totalVulns += len(vl) + } + + PrintSummary(types.ScanResult{ + Updates: updates, + Vulnerabilities: vulns, + TotalPackages: len(deps), + TotalUpdated: len(updateSpecs), + TotalVulns: totalVulns, + VulnsScanned: f.vulns, + Duration: time.Since(start), + }, !dryRun && len(updateSpecs) > 0) + + return nil +} + +func runScan(ctx context.Context, file string) error { + start := time.Now() + cfg := config.Load(file) + + deps, err := parseDeps(file) + if err != nil { + return err + } + + spin := ui.NewSpinner(fmt.Sprintf( + "Scanning %d dependencies for vulnerabilities...", + len(deps), + )) + spin.Start() + + minSev := resolveMinSeverity(cfg.MinSeverity) + vulns, scanErr := scanForVulns(ctx, deps) + + spin.Stop() + + if scanErr != nil { + PrintError(scanErr.Error()) + } + + vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns) + vulns = filterVulnsBySeverity(vulns, minSev) + PrintVulnerabilities(vulns) + + totalVulns := 0 + for _, vl := range vulns { + totalVulns += len(vl) + } + + PrintSummary(types.ScanResult{ + TotalPackages: len(deps), + TotalVulns: totalVulns, + VulnsScanned: true, + Duration: time.Since(start), + }, false) + + return nil +} + +func resolveUpdates( + deps []types.Dependency, + versionMap map[string][]string, + safe bool, + includePrerelease bool, + ignoreDeps []string, +) ([]types.UpdateResult, map[string]string) { + var results []types.UpdateResult + specs := make(map[string]string) + + ignoreSet := make(map[string]bool, len(ignoreDeps)) + for _, name := range ignoreDeps { + ignoreSet[pypi.NormalizeName(name)] = true + } + + for _, dep := range deps { + if ignoreSet[pypi.NormalizeName(dep.Name)] { + results = append(results, types.UpdateResult{ + Name: dep.Name, + Skipped: true, + Reason: "ignored in config", + }) + continue + } + + versions, ok := versionMap[dep.Name] + if !ok { + results = append(results, types.UpdateResult{ + Name: dep.Name, + Skipped: true, + Reason: "not found on PyPI", + }) + continue + } + + currentStr := pyproject.ExtractMinVersion(dep.Spec) + if currentStr == "" { + results = append(results, types.UpdateResult{ + Name: dep.Name, + Skipped: true, + Reason: "no version specifier", + }) + continue + } + + current, err := pypi.ParseVersion(currentStr) + if err != nil { + results = append(results, types.UpdateResult{ + Name: dep.Name, + Skipped: true, + Reason: "unparseable version", + }) + continue + } + + var latest pypi.Version + if includePrerelease { + latest, err = latestAny(versions) + } else { + latest, err = pypi.LatestStable(versions) + } + if err != nil { + results = append(results, types.UpdateResult{ + Name: dep.Name, + Skipped: true, + Reason: err.Error(), + }) + continue + } + + if latest.Compare(current) <= 0 { + continue + } + + change := pypi.ClassifyChange(current, latest) + if safe && change == pypi.Major { + results = append(results, types.UpdateResult{ + Name: dep.Name, + OldVer: current.String(), + NewVer: latest.String(), + Change: change.String(), + Skipped: true, + Reason: "major bump (use --all to include)", + }) + continue + } + + newSpec := ">=" + latest.String() + results = append(results, types.UpdateResult{ + Name: dep.Name, + OldVer: current.String(), + NewVer: latest.String(), + OldSpec: dep.Spec, + NewSpec: newSpec, + Change: change.String(), + }) + specs[dep.Name] = newSpec + } + + return results, specs +} + +func scanForVulns( + ctx context.Context, + deps []types.Dependency, +) (map[string][]types.Vulnerability, error) { + var queries []osv.PackageQuery + for _, dep := range deps { + ver := pyproject.ExtractMinVersion(dep.Spec) + if ver == "" { + continue + } + queries = append(queries, osv.PackageQuery{ + Name: dep.Name, + Version: ver, + }) + } + + if len(queries) == 0 { + return nil, nil + } + + client := osv.NewClient() + vulns, err := client.ScanPackages(ctx, queries) + if err != nil { + return nil, fmt.Errorf( + "vulnerability scan: %w", err, + ) + } + return vulns, nil +} + +func latestAny(versions []string) (pypi.Version, error) { + var latest pypi.Version + var found bool + + for _, raw := range versions { + v, err := pypi.ParseVersion(raw) + if err != nil { + continue + } + if !found || v.Compare(latest) > 0 { + latest = v + found = true + } + } + + if !found { + return pypi.Version{}, fmt.Errorf("no versions found") + } + return latest, nil +} + +func resolveFile(flagVal string, args []string) string { + if len(args) > 0 { + path := args[0] + info, err := os.Stat(path) + if err == nil && info.IsDir() { + return filepath.Join(path, "pyproject.toml") + } + return path + } + return flagVal +} + +func isRequirementsTxt(path string) bool { + return strings.HasSuffix(strings.ToLower(path), ".txt") +} + +func parseDeps(file string) ([]types.Dependency, error) { + if isRequirementsTxt(file) { + return requirements.ParseFile(file) + } + return pyproject.ParseFile(file) +} + +func updateDepsFile(file string, specs map[string]string) error { + if isRequirementsTxt(file) { + return requirements.UpdateFile(file, specs) + } + return pyproject.UpdateFile(file, specs) +} + +func resolveMinSeverity(configVal string) string { + if minSeverity != "" { + return minSeverity + } + if configVal != "" { + return configVal + } + return "low" +} + +func sortUpdates(updates []types.UpdateResult) { + order := map[string]int{ + pypi.Major.String(): 0, + pypi.Minor.String(): 1, + pypi.Patch.String(): 2, + } + sort.Slice(updates, func(i, j int) bool { + if updates[i].Skipped != updates[j].Skipped { + return !updates[i].Skipped + } + oi := order[updates[i].Change] + oj := order[updates[j].Change] + if oi != oj { + return oi < oj + } + return updates[i].Name < updates[j].Name + }) +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/config/config.go b/PROJECTS/simple-vulnerability-scanner/internal/config/config.go new file mode 100644 index 00000000..2404c318 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/config/config.go @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// config.go + +package config + +import ( + "os" + "strings" + + toml "github.com/pelletier/go-toml/v2" +) + +// Config holds project-level angela settings loaded from .angela.toml +// or [tool.angela] in pyproject.toml +type Config struct { + MinSeverity string `toml:"min-severity"` + Ignore []string `toml:"ignore"` + IgnoreVulns []string `toml:"ignore-vulns"` +} + +type pyprojectWrapper struct { + Tool struct { + Angela Config `toml:"angela"` + } `toml:"tool"` +} + +// Load reads angela configuration using a cascading resolution order: +// .angela.toml in current directory, then [tool.angela] in pyproject.toml +func Load(pyprojectPath string) Config { + if cfg, err := loadFile(".angela.toml"); err == nil { + return cfg + } + + if cfg, ok := loadFromPyproject(pyprojectPath); ok { + return cfg + } + + return Config{} +} + +func loadFile(path string) (Config, error) { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return Config{}, err + } + + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + + cfg.MinSeverity = strings.ToLower( + strings.TrimSpace(cfg.MinSeverity), + ) + return cfg, nil +} + +func loadFromPyproject(path string) (Config, bool) { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return Config{}, false + } + + var wrapper pyprojectWrapper + if err := toml.Unmarshal(data, &wrapper); err != nil { + return Config{}, false + } + + cfg := wrapper.Tool.Angela + if cfg.MinSeverity == "" && + len(cfg.Ignore) == 0 && + len(cfg.IgnoreVulns) == 0 { + return Config{}, false + } + + cfg.MinSeverity = strings.ToLower( + strings.TrimSpace(cfg.MinSeverity), + ) + return cfg, true +} 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..f0383cf6 --- /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 "MODERATE" + 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..0a3e3a8b --- /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, "MODERATE"}, + {4.0, "MODERATE"}, + {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/internal/requirements/parser.go b/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser.go new file mode 100644 index 00000000..81d1087e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser.go @@ -0,0 +1,91 @@ +// ©AngelaMos | 2026 +// parser.go + +package requirements + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/CarterPerez-dev/angela/pkg/types" +) + +// ParseFile reads a requirements.txt and extracts all dependency declarations +func ParseFile(path string) ([]types.Dependency, error) { + f, err := os.Open(path) //nolint:gosec + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf( + "open %s: file not found", path, + ) + } + return nil, fmt.Errorf("read %s: %w", path, err) + } + defer func() { _ = f.Close() }() //nolint:errcheck + + var deps []types.Dependency + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if line == "" || + strings.HasPrefix(line, "#") || + strings.HasPrefix(line, "-") { + continue + } + + if idx := strings.Index(line, " #"); idx >= 0 { + line = strings.TrimSpace(line[:idx]) + } + + dep := parseLine(line) + if dep.Name != "" { + deps = append(deps, dep) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + if len(deps) == 0 { + return nil, fmt.Errorf( + "%s: no dependencies found", path, + ) + } + return deps, nil +} + +func parseLine(s string) types.Dependency { + var dep types.Dependency + + if idx := strings.Index(s, ";"); idx >= 0 { + dep.Markers = strings.TrimSpace(s[idx+1:]) + s = strings.TrimSpace(s[:idx]) + } + + if start := strings.Index(s, "["); start >= 0 { + end := strings.Index(s, "]") + if end > start { + for _, e := range strings.Split(s[start+1:end], ",") { + dep.Extras = append( + dep.Extras, + strings.TrimSpace(e), + ) + } + s = s[:start] + s[end+1:] + } + } + + if idx := strings.IndexAny(s, "><=!~"); idx >= 0 { + dep.Name = strings.TrimSpace(s[:idx]) + dep.Spec = strings.TrimSpace(s[idx:]) + } else { + dep.Name = strings.TrimSpace(s) + } + + return dep +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser_test.go b/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser_test.go new file mode 100644 index 00000000..506080db --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/requirements/parser_test.go @@ -0,0 +1,122 @@ +// ©AngelaMos | 2026 +// parser_test.go + +package requirements + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseFile(t *testing.T) { + t.Parallel() + + content := `# Core deps +django>=3.2.0 +requests==2.28.1 +flask>=2.0,<3.0 +numpy # pinned +click[extra]>=8.0.0; python_version>="3.8" + +# Options to skip +-r other.txt +-e ./local-pkg +--index-url https://pypi.org/simple/ +` + + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + deps, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if len(deps) != 5 { + t.Fatalf("len = %d, want 5", len(deps)) + } + + tests := []struct { + name string + spec string + }{ + {"django", ">=3.2.0"}, + {"requests", "==2.28.1"}, + {"flask", ">=2.0,<3.0"}, + {"numpy", ""}, + {"click", ">=8.0.0"}, + } + + for i, tt := range tests { + if deps[i].Name != tt.name { + t.Errorf("deps[%d].Name = %q, want %q", i, deps[i].Name, tt.name) + } + if deps[i].Spec != tt.spec { + t.Errorf("deps[%d].Spec = %q, want %q", i, deps[i].Spec, tt.spec) + } + } +} + +func TestParseFileExtras(t *testing.T) { + t.Parallel() + + content := "requests[security,socks]>=2.28.0\n" + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + deps, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if len(deps) != 1 { + t.Fatalf("len = %d, want 1", len(deps)) + } + + if len(deps[0].Extras) != 2 { + t.Fatalf("extras len = %d, want 2", len(deps[0].Extras)) + } + if deps[0].Extras[0] != "security" || deps[0].Extras[1] != "socks" { + t.Errorf("extras = %v, want [security socks]", deps[0].Extras) + } +} + +func TestParseFileMarkers(t *testing.T) { + t.Parallel() + + content := "pywin32>=300; sys_platform==\"win32\"\n" + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + deps, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if deps[0].Markers != `sys_platform=="win32"` { + t.Errorf("markers = %q, want sys_platform==\"win32\"", deps[0].Markers) + } +} + +func TestParseFileEmpty(t *testing.T) { + t.Parallel() + + content := "# just comments\n\n-r other.txt\n" + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + _, err := ParseFile(path) + if err == nil { + t.Error("expected error for empty requirements") + } +} + +func TestParseFileNotFound(t *testing.T) { + t.Parallel() + + _, err := ParseFile("/nonexistent/requirements.txt") + if err == nil { + t.Error("expected error for missing file") + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer.go b/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer.go new file mode 100644 index 00000000..6fb4cecb --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer.go @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// writer.go + +package requirements + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/CarterPerez-dev/angela/internal/pypi" +) + +// UpdateFile replaces version specifiers for the given packages in a +// requirements.txt file, preserving all comments and formatting +func UpdateFile(path string, updates map[string]string) error { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + content := data + for pkg, spec := range updates { + pattern := buildPattern(pkg) + found := false + content = pattern.ReplaceAllFunc( + content, + func(match []byte) []byte { + found = true + return replaceSpec(pattern, match, spec) + }, + ) + if !found { + return fmt.Errorf( + "dependency %q not found in %s", pkg, path, + ) + } + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, content, 0o600); err != nil { + return fmt.Errorf("write temp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) //nolint:errcheck + return fmt.Errorf("rename: %w", err) + } + return nil +} + +func buildPattern(name string) *regexp.Regexp { + normalized := pypi.NormalizeName(name) + parts := strings.Split(normalized, "-") + for i, p := range parts { + parts[i] = regexp.QuoteMeta(p) + } + namePattern := strings.Join(parts, `[-_.]?`) + + return regexp.MustCompile( + `(?im)^(` + namePattern + `)` + + `(\[[^\]]*\])?` + + `(\s*[><=!~][^\n;#]*)`, + ) +} + +func replaceSpec( + re *regexp.Regexp, match []byte, newSpec string, +) []byte { + groups := re.FindSubmatch(match) + if len(groups) < 4 { + return match + } + + var b []byte + b = append(b, groups[1]...) + b = append(b, groups[2]...) + b = append(b, []byte(newSpec)...) + return b +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer_test.go b/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer_test.go new file mode 100644 index 00000000..da7c7b3d --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/requirements/writer_test.go @@ -0,0 +1,92 @@ +// ©AngelaMos | 2026 +// writer_test.go + +package requirements + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdateFile(t *testing.T) { + t.Parallel() + + content := `# Core deps +django>=3.2.0 +requests==2.28.1 # HTTP client +flask>=2.0.0 +` + + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + updates := map[string]string{ + "django": ">=6.0.1", + "requests": ">=2.32.5", + } + + if err := UpdateFile(path, updates); err != nil { + t.Fatalf("UpdateFile: %v", err) + } + + data, _ := os.ReadFile(path) //nolint:errcheck,gosec + result := string(data) + + if !strings.Contains(result, "django>=6.0.1") { + t.Error("django not updated") + } + if !strings.Contains(result, "requests>=2.32.5") { + t.Error("requests not updated") + } + if !strings.Contains(result, "# Core deps") { + t.Error("comment lost") + } + if !strings.Contains(result, "# HTTP client") { + t.Error("inline comment lost") + } + if !strings.Contains(result, "flask>=2.0.0") { + t.Error("flask was incorrectly modified") + } +} + +func TestUpdateFilePreservesFormatting(t *testing.T) { + t.Parallel() + + content := "Django>=3.2.0\nnumpy==1.24.0\n" + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck + + updates := map[string]string{ + "django": ">=6.0.1", + } + + if err := UpdateFile(path, updates); err != nil { + t.Fatalf("UpdateFile: %v", err) + } + + data, _ := os.ReadFile(path) //nolint:errcheck,gosec + result := string(data) + + if !strings.Contains(result, "Django>=6.0.1") { + t.Error("original casing not preserved") + } + if !strings.Contains(result, "numpy==1.24.0") { + t.Error("numpy was incorrectly modified") + } +} + +func TestUpdateFileNotFound(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "requirements.txt") + os.WriteFile(path, []byte("flask>=2.0.0\n"), 0o600) //nolint:errcheck + + err := UpdateFile(path, map[string]string{ + "nonexistent": ">=1.0.0", + }) + if err == nil { + t.Error("expected error for missing dependency") + } +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go new file mode 100644 index 00000000..a849c17e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go @@ -0,0 +1,79 @@ +// ©AngelaMos | 2026 +// banner.go + +package ui + +import "fmt" + +var angelaBanner = []string{ + " ▄▀▄ █▄ █ ▄▀ ██▀ █ ▄▀▄ ", + " █▀█ █ ▀█ ▀▄█ █▄▄ █▄▄ █▀█ ", +} + +var bannerColors = []func(a ...any) string{ + Red, + Blue, +} + +var animeArt = []string{ + "⣿⣿⣿⡷⠊⡢⡹⣦⡑⢂⢕⢂⢕⢂⢕⢂⠕⠔⠌⠝⠛⠶⠶⢶⣦⣄⢂⢕⢂⢕", + "⣿⣿⠏⣠⣾⣦⡐⢌⢿⣷⣦⣅⡑⠕⠡⠐⢿⠿⣛⠟⠛⠛⠛⠛⠡⢷⡈⢂⢕⢂", + "⠟⣡⣾⣿⣿⣿⣿⣦⣑⠝⢿⣿⣿⣿⣿⣿⡵⢁⣤⣶⣶⣿⢿⢿⢿⡟⢻⣤⢑⢂", + "⣾⣿⣿⡿⢟⣛⣻⣿⣿⣿⣦⣬⣙⣻⣿⣿⣷⣿⣿⢟⢝⢕⢕⢕⢕⢽⣿⣿⣷⣔", + "⣿⣿⠵⠚⠉⢀⣀⣀⣈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⢕⢕⢕⢕⢕⢕⣽⣿⣿⣿⣿", + "⢷⣂⣠⣴⣾⡿⡿⡻⡻⣿⣿⣴⣿⣿⣿⣿⣿⣿⣷⣵⣵⣵⣷⣿⣿⣿⣿⣿⣿⡿", + "⢌⠻⣿⡿⡫⡪⡪⡪⡪⣺⣿⣿⣿⣿⣿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃", + "⠣⡁⠹⡪⡪⡪⡪⣪⣾⣿⣿⣿⣿⠋⠐⢉⢍⢄⢌⠻⣿⣿⣿⣿⣿⣿⣿⣿⠏⠈", + "⡣⡘⢄⠙⣾⣾⣾⣿⣿⣿⣿⣿⣿⡀⢐⢕⢕⢕⢕⢕⡘⣿⣿⣿⣿⣿⣿⠏⠠⠈", + "⠌⢊⢂⢣⠹⣿⣿⣿⣿⣿⣿⣿⣿⣧⢐⢕⢕⢕⢕⢕⢅⣿⣿⣿⣿⡿⢋⢜⠠⠈", +} + +var artColors = []func(a ...any) string{ + Red, + Red, + Blue, + Blue, + Red, + Red, + Blue, + Blue, + Blue, + Red, +} + +func PrintBanner() { + fmt.Println() + fmt.Println() + for i, line := range angelaBanner { + c := bannerColors[i%len(bannerColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Printf(" %s\n", White(HRule(52))) + fmt.Printf( + " %s\n\n", + HiBlackItalic( + "Python dependency updater & vulnerability scanner", + ), + ) +} + +func PrintBannerWithArt() { + fmt.Println() + for i, line := range angelaBanner { + c := bannerColors[i%len(bannerColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Printf(" %s\n", White(HRule(52))) + fmt.Printf( + " %s\n", + HiBlackItalic( + "Python dependency updater & vulnerability scanner", + ), + ) + fmt.Println() + for i, line := range animeArt { + c := artColors[i%len(artColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Println() +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go new file mode 100644 index 00000000..01d2beef --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go @@ -0,0 +1,94 @@ +// ©AngelaMos | 2026 +// color.go + +package ui + +import "github.com/fatih/color" + +var ( + Black = color.New(color.FgBlack).SprintFunc() + Red = color.New(color.FgRed).SprintFunc() + Green = color.New(color.FgGreen).SprintFunc() + Yellow = color.New(color.FgYellow).SprintFunc() + Blue = color.New(color.FgBlue).SprintFunc() + Magenta = color.New(color.FgMagenta).SprintFunc() + Cyan = color.New(color.FgCyan).SprintFunc() + White = color.New(color.FgWhite).SprintFunc() + + HiBlack = color.New(color.FgHiBlack).SprintFunc() + HiRed = color.New(color.FgHiRed).SprintFunc() + HiGreen = color.New(color.FgHiGreen).SprintFunc() + HiYellow = color.New(color.FgHiYellow).SprintFunc() + HiBlue = color.New(color.FgHiBlue).SprintFunc() + HiMagenta = color.New(color.FgHiMagenta).SprintFunc() + HiCyan = color.New(color.FgHiCyan).SprintFunc() + HiWhite = color.New(color.FgHiWhite).SprintFunc() + + RedBold = color.New(color.FgRed, color.Bold).SprintFunc() + GreenBold = color.New(color.FgGreen, color.Bold).SprintFunc() + YellowBold = color.New(color.FgYellow, color.Bold).SprintFunc() + BlueBold = color.New(color.FgBlue, color.Bold).SprintFunc() + MagentaBold = color.New( + color.FgMagenta, color.Bold, + ).SprintFunc() + CyanBold = color.New( + color.FgCyan, color.Bold, + ).SprintFunc() + HiRedBold = color.New( + color.FgHiRed, color.Bold, + ).SprintFunc() + HiMagentaBold = color.New( + color.FgHiMagenta, color.Bold, + ).SprintFunc() + HiCyanBold = color.New( + color.FgHiCyan, color.Bold, + ).SprintFunc() + + Dim = color.New(color.Faint).SprintFunc() + DimItalic = color.New( + color.Faint, color.Italic, + ).SprintFunc() + + CyanItalic = color.New( + color.FgCyan, color.Italic, + ).SprintFunc() + MagentaItalic = color.New( + color.FgMagenta, color.Italic, + ).SprintFunc() + RedItalic = color.New( + color.FgRed, color.Italic, + ).SprintFunc() + GreenItalic = color.New( + color.FgGreen, color.Italic, + ).SprintFunc() + HiBlackItalic = color.New( + color.FgHiBlack, color.Italic, + ).SprintFunc() + BlueItalic = color.New( + color.FgBlue, color.Italic, + ).SprintFunc() + HiBlueItalic = color.New( + color.FgHiBlue, color.Italic, + ).SprintFunc() + + CyanUnderline = color.New( + color.FgCyan, color.Underline, + ).SprintFunc() + MagentaUnderline = color.New( + color.FgMagenta, color.Underline, + ).SprintFunc() + + HiCyanBoldItalic = color.New( + color.FgHiCyan, color.Bold, color.Italic, + ).SprintFunc() + HiMagentaBoldItalic = color.New( + color.FgHiMagenta, color.Bold, color.Italic, + ).SprintFunc() + HiRedBoldItalic = color.New( + color.FgHiRed, color.Bold, color.Italic, + ).SprintFunc() + + HiBlackCrossed = color.New( + color.FgHiBlack, color.CrossedOut, + ).SprintFunc() +) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go new file mode 100644 index 00000000..412f283e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go @@ -0,0 +1,83 @@ +// ©AngelaMos | 2026 +// spinner.go + +package ui + +import ( + "fmt" + "strings" + "sync" + "time" +) + +var frames = []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", +} + +type Spinner struct { + msg string + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex + active bool +} + +func NewSpinner(msg string) *Spinner { + return &Spinner{msg: msg} +} + +func (s *Spinner) Start() { + s.mu.Lock() + if s.active { + s.mu.Unlock() + return + } + s.active = true + s.done = make(chan struct{}) + s.wg.Add(1) + s.mu.Unlock() + + go s.run() +} + +func (s *Spinner) Stop() { + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return + } + s.active = false + close(s.done) + s.mu.Unlock() + s.wg.Wait() +} + +func (s *Spinner) run() { + defer s.wg.Done() + fmt.Print("\033[?25l") + + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + idx := 0 + for { + select { + case <-s.done: + clearLine() + fmt.Print("\033[?25h") + return + case <-ticker.C: + frame := frames[idx%len(frames)] + fmt.Printf( + "\r %s %s", + CyanBold(frame), + HiMagenta(s.msg), + ) + idx++ + } + } +} + +func clearLine() { + fmt.Print("\r" + strings.Repeat(" ", 80) + "\r") +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go new file mode 100644 index 00000000..e5706c12 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go @@ -0,0 +1,24 @@ +// ©AngelaMos | 2026 +// symbol.go + +package ui + +import "strings" + +const ( + Arrow = "→" + ArrowRight = "▸" + ArrowUp = "↑" + Diamond = "◆" + Gem = "◈" + Star = "✦" + TriangleUp = "▲" + Check = "✓" + Cross = "✗" + Timer = "⏱" + DividerChar = "━" +) + +func HRule(width int) string { + return strings.Repeat(DividerChar, width) +} diff --git a/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md b/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md new file mode 100644 index 00000000..a7ac4346 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md @@ -0,0 +1,180 @@ +# Go Concurrency Patterns Used in angela + +angela fetches data from PyPI and OSV.dev in parallel. These patterns show up in production codebases at places like Uber and Cloudflare, but they're not complicated — they just require knowing which tool to reach for. + +--- + +## The problem + +angela needs to query PyPI for every dependency in your `pyproject.toml` or `requirements.txt`. A typical project has 20-50 dependencies. Doing those requests one at a time would take 10-25 seconds. Doing them all at once would hammer PyPI with 50 simultaneous connections. + +The answer is **bounded concurrency** — run up to N requests in parallel, queue the rest. + +--- + +## errgroup.SetLimit + +The `golang.org/x/sync/errgroup` package is the go-to for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`: + +```go +g, ctx := errgroup.WithContext(ctx) +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() +``` + +A few things to notice: + +- **`SetLimit(10)`** caps concurrent HTTP requests. PyPI recommends 5-10. +- **Always returns nil** — individual failures get collected in results, not propagated. One package failing shouldn't kill the others. +- **Mutex on the shared slice** — `results` gets appended to from multiple goroutines, so it needs a lock. + +### Why not channels? + +Channels would work, but errgroup gets you the same thing with half the code: + +```go +// Channel-based worker pool: ~30 lines +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, close channels ... + +// errgroup: ~15 lines +g.SetLimit(maxWorkers) +for _, name := range names { + g.Go(func() error { /* ... */ }) +} +g.Wait() +``` + +errgroup handles context cancellation for free and there's no channel lifecycle to think about. + +--- + +## Panic recovery + +Every goroutine angela launches wraps itself in a recover: + +```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 +}) +``` + +An unrecovered panic in a goroutine kills the entire process. For a CLI tool, that means the user gets a stack trace dump instead of an actual error message. The `defer recover()` turns panics into errors that flow through the normal path. + +One subtle thing — the named return `(err error)` is what makes this work. Without it, the deferred function has no way to set the return value. + +--- + +## Context cancellation + +Every HTTP request uses context: + +```go +req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) +``` + +This does a lot of work for you: + +- Ctrl+C cancels pending requests +- If errgroup's context gets cancelled, remaining work stops +- HTTP timeouts are enforced at the transport level + +The context flows from `cobra.Command.Context()` → `runUpdate()` → `FetchAllVersions()` → `FetchVersions()` → the HTTP call itself. Standard Go pattern — context as the first parameter, threaded through everything. + +--- + +## Retry with exponential backoff + +`internal/pypi/client.go` retries 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 +} +``` + +The `1< empty string in ASCII. But Python's actual ordering is: + +``` +1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1 +``` + +A tool that gets this wrong will upgrade users to unstable pre-releases. That's why angela implements the full spec. + +--- + +## How the parser works + +The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to pull out all the components in one pass: + +``` +(?i)^v? +(?:(\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 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 `-1` sentinel for Post and Dev is important — it's how you tell "not present" apart from "present with value 0". Both `1.0.post0` and `1.0.post` are valid PEP 440 (implicit zero), but they're different from `1.0` which has no post-release at all. + +--- + +## Normalization + +PEP 440 allows a bunch of different 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 normalizes all of these during extraction, so the rest of the codebase never has to think about variant spellings. + +--- + +## Version comparison + +Python's `packaging` library compares versions by converting them to tuples that sort naturally. angela does the same thing: + +```go +func (v Version) Compare(other Version) int { + // 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 trick is in the sentinel values: + +| 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 | + +Using `math.MinInt` and `math.MaxInt` means the comparison function is just a sequence of `cmp.Compare` calls — no special-case branching needed. + +--- + +## Stability detection + +A version is **stable** if it has no pre-release tag and no dev tag: + +```go +func (v Version) IsStable() bool { + return v.PreKind == "" && v.Dev < 0 +} +``` + +Post-releases count as stable — `1.0.post1` is a docs fix or metadata correction, not an unstable build. + +--- + +## Takeaways + +A single compiled regex handles every PEP 440 form in one pass. The alternative would be a hand-written state machine with 3x the code and no real upside. + +Normalizing at parse time (alpha→a, preview→rc) keeps things clean — nothing downstream ever has to handle variant spellings. + +The version parser currently has 90+ test cases across 10 functions. When you're implementing something spec-driven like this, writing the test cases from the spec first and then making them pass is the way to go. diff --git a/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md b/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md new file mode 100644 index 00000000..3a3165b7 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md @@ -0,0 +1,175 @@ +# Preserving Comments in Dependency Files + +When angela updates version specifiers, it can't blow away the developer's comments, blank lines, and formatting. This turns out to be a surprisingly annoying problem. Here's how angela handles it for both `pyproject.toml` and `requirements.txt`. + +--- + +## 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 — still there. Same goes for `requirements.txt` — inline comments, section headers, all of it should survive. + +--- + +## Why TOML libraries can't do this + +Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling 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 way to store comment metadata on struct fields. The unmarshal/marshal round-trip just destroys them. This isn't a library bug — it's a fundamental limitation of how Go struct serialization works. + +pelletier/go-toml v2 does have an `unstable` package that exposes AST access with comments preserved in the parse tree, but there's no serializer — you'd have to write your own TOML emitter from scratch. + +--- + +## Regex surgery + +angela's approach: don't parse and re-serialize. Treat the file as a byte buffer and surgically replace only the version specifier. + +The implementation for pyproject.toml lives in `internal/pyproject/writer.go`: + +1. Build a regex that matches the full dependency string inside quotes: + ``` + "requests>=2.28.0" + ``` + +2. Capture groups isolate the pieces: + - Group 1: package name (`requests`) + - Group 2: extras (`[async]` or empty) + - Group 3: version specifier (`>=2.28.0`) + - Group 4: markers (`;python_version>='3.8'` or empty) + +3. Replace only group 3, keep everything else. + +4. Validate before and after — feed the result through go-toml v2's unmarshaler to make sure the edit didn't break the TOML syntax. + +```go +func (u *Updater) UpdateDependency(pkg, newSpec string) error { + 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) +} +``` + +--- + +## requirements.txt uses the same idea + +The `requirements.txt` writer in `internal/requirements/writer.go` works the same way — regex-based surgery on raw bytes. The difference is simpler: no quotes to worry about, no TOML validation needed. Lines look like: + +``` +django>=3.2.0 +requests==2.28.1 # HTTP client +``` + +The regex matches the package name (with PEP 503 normalization) and its version specifier, replaces the spec, and leaves everything else alone — including inline comments. + +Both writers share the same atomic write pattern (temp file + rename) and the same PEP 503 name normalization approach. The pyproject writer just has extra complexity for quote styles and TOML validation. + +--- + +## The tricky parts + +### PEP 503 name normalization + +Package names on PyPI are case-insensitive and treat `-`, `_`, and `.` as equivalent: + +``` +Some_Package == some-package == some.package +``` + +The regex has to 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, `[-_.]?`) +``` + +So the pattern for `some-package` becomes `some[-_.]?package`, which matches `some_package`, `some.package`, and `somepackage`. + +### Go RE2 doesn't support backreferences + +The TOML research suggested using `\1` to match closing quotes with the opening quote. Go's `regexp` package uses RE2, which doesn't do backreferences. + +The workaround: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character. + +### Atomic file writes + +angela writes to a `.tmp` file first, then renames over the original. If the process dies mid-write, the original is untouched: + +```go +func (u *Updater) WriteFile(path string) error { + 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 +} +``` + +It's two syscalls, but the file is never in a half-written state. + +--- + +## This is how production tools do it + +Renovate and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize either — they do the same kind of surgical modification. No TOML, YAML, or JSON library in any language perfectly round-trips formatting and comments. Regex surgery sounds hacky, but it's the approach that actually works. + +Go's RE2 engine guarantees linear-time matching, so even on large files with complex patterns there's no risk of catastrophic backtracking. Just keep in mind it doesn't support backreferences or lookaheads — design your patterns around that. diff --git a/PROJECTS/simple-vulnerability-scanner/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..374c10ce --- /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 (outdated) +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 (outdated) +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", # Code formatter + "ruff>=0.1.0", + "mypy>=1.0.0", +] diff --git a/PROJECTS/simple-vulnerability-scanner/testdata/requirements.txt b/PROJECTS/simple-vulnerability-scanner/testdata/requirements.txt new file mode 100644 index 00000000..813fd38f --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/testdata/requirements.txt @@ -0,0 +1,13 @@ +# Core dependencies +django>=3.2.0 +requests>=2.28.0 +flask>=2.0.0 + +# Dev tools +black>=23.0.0 +pydantic>=1.10.0 + +# Pinned +numpy==1.24.0 +pytest>=7.0.0 +click>=8.0.0