From 47cb20d23f8e6b7c56ee824abf41a97c855e68e4 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 28 Jan 2026 21:13:19 -0500 Subject: [PATCH] looks magnificent --- .../simple-vulnerability-scanner/.gitignore | 8 + .../simple-vulnerability-scanner/README.md | 323 +++++++++++------- PROJECTS/simple-vulnerability-scanner/go.mod | 6 +- PROJECTS/simple-vulnerability-scanner/go.sum | 13 +- .../internal/cli/output.go | 265 ++++++++------ .../internal/cli/update.go | 117 +++++-- .../internal/ui/banner.go | 79 +++++ .../internal/ui/color.go | 94 +++++ .../internal/ui/spinner.go | 83 +++++ .../internal/ui/symbol.go | 24 ++ .../learn/GO-CONCURRENCY-PATTERNS.md | 80 ++--- .../learn/PEP440-VERSION-PARSING.md | 50 ++- .../learn/TOML-COMMENT-PRESERVATION.md | 68 ++-- .../testdata/pyproject.toml | 4 +- 14 files changed, 866 insertions(+), 348 deletions(-) create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/ui/color.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go create mode 100644 PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go diff --git a/PROJECTS/simple-vulnerability-scanner/.gitignore b/PROJECTS/simple-vulnerability-scanner/.gitignore index abe0ce23..18992656 100644 --- a/PROJECTS/simple-vulnerability-scanner/.gitignore +++ b/PROJECTS/simple-vulnerability-scanner/.gitignore @@ -12,10 +12,18 @@ bin/ # Test *.test +coverage.out +*.prof +trace.out # Build tmp/ +# Cache +.ruff_cache/ +__pycache__/ +*.pyc + # IDE .idea/ .vscode/ diff --git a/PROJECTS/simple-vulnerability-scanner/README.md b/PROJECTS/simple-vulnerability-scanner/README.md index 9dbca503..bad007cf 100644 --- a/PROJECTS/simple-vulnerability-scanner/README.md +++ b/PROJECTS/simple-vulnerability-scanner/README.md @@ -1,23 +1,23 @@ # angela -A fast CLI tool that updates Python dependencies in `pyproject.toml` and scans for known vulnerabilities using [OSV.dev](https://osv.dev). +A fast Python dependency updater and vulnerability scanner, written in Go. -Built in Go for speed — parallel HTTP requests, local caching with ETag support, and a single binary with zero runtime dependencies. +angela scans your `pyproject.toml` or `requirements.txt`, updates all dependencies to their latest stable versions, and checks for known CVEs using [OSV.dev](https://osv.dev) — in a single command. ---- +## Highlights -## Why +- **One command updates everything** in `pyproject.toml` or `requirements.txt`, like `pnpm update` for Python. +- Fast parallel queries against the [PyPI Simple API](https://peps.python.org/pep-0691/) with local ETag caching. +- Built-in vulnerability scanning via [OSV.dev](https://osv.dev) batch queries — no API key required. +- Full [PEP 440](https://peps.python.org/pep-0440/) version parsing with pre-release filtering and epoch support. +- Comment-preserving file updates — inline comments, section headers, and formatting stay intact. +- Configurable via `.angela.toml` or `[tool.angela]` in `pyproject.toml`. +- Single binary with zero runtime dependencies. -The Python ecosystem lacks a single tool that updates all dependencies in `pyproject.toml` the way `pnpm update` works for JavaScript. Existing options either show outdated packages without updating them (`pip list --outdated`), upgrade one at a time (`pip install --upgrade`), or require CI infrastructure (Dependabot, Renovate). - -**angela** fills this gap: one command updates everything, with built-in CVE scanning. - ---- - -## Install +## Installation ```bash -go install github.com/CarterPerez-dev/angela@latest +go install github.com/CarterPerez-dev/angela/cmd/angela@latest ``` Or build from source: @@ -25,119 +25,211 @@ Or build from source: ```bash git clone https://github.com/CarterPerez-dev/angela.git cd angela -go build -o bin/angela ./cmd/angela +go build -o angela ./cmd/angela ``` ---- - -## Usage - -### Update all dependencies +## Quick start ```bash +# Initialize a new pyproject.toml +angela init + +# Update all dependencies to latest stable versions angela update -``` -Reads `pyproject.toml` in the current directory, queries PyPI for the latest stable versions, and writes the updates back to the file. - -### Dry run (show what would change) - -```bash +# Preview updates without writing changes angela check -``` -Shows available updates and vulnerabilities without modifying any files. - -### Scan for vulnerabilities only - -```bash +# Scan for known vulnerabilities angela scan + +# Update + scan for vulnerabilities in one pass +angela update --vulns ``` -Checks all pinned dependencies against the OSV.dev vulnerability database. +## Commands -### Update with vulnerability scan +### `angela init` -```bash -angela update --scan-vulns +Create a new `pyproject.toml` with angela configuration in the current directory. + +```console +$ angela init + + Created pyproject.toml ``` -Updates dependencies and reports any known CVEs in your current versions. +### `angela update` -### Skip major version bumps +Update all dependencies in `pyproject.toml` to their latest stable versions. -```bash -angela update --safe -``` +```console +$ angela update -Only applies minor and patch updates, skipping anything that crosses a major version boundary. - -### Include pre-release versions - -```bash -angela update --include-prerelease -``` - -Considers alpha, beta, release candidate, and dev versions when resolving the latest. - -### Specify a different file - -```bash -angela update --file path/to/pyproject.toml -``` - -### Clear the local cache - -```bash -angela cache clear -``` - -Removes all cached PyPI responses from `~/.angela/cache/`. - ---- - -## Example output - -``` Scanning 9 dependencies... Updates available: - django 3.2.0 -> 5.1.5 (major) - requests 2.28.0 -> 2.32.3 (minor) - click 8.0.0 -> 8.1.8 (patch) - pydantic 2.0.0 -> 2.10.6 (minor) + django 3.2.0 -> 6.0.1 (major) + requests 2.28.0 -> 2.32.5 (minor) flask 2.0.0 -> 3.1.0 (major) - pytest 7.0.0 -> 8.3.4 (major) - black 23.0.0 -> 25.1.0 (major) - ruff 0.1.0 -> 0.9.4 (minor) - mypy 1.0.0 -> 1.14.1 (minor) - - Vulnerabilities found: - django - GHSA-2hrw-hx67-34x6 [CRITICAL] Potential denial-of-service in django.utils.text.Truncator - Fixed in: 4.2.16 - requests - GHSA-9wx4-h78v-vm56 [MEDIUM] Requests `Session` object does not verify requests after making first request with verify=False - Fixed in: 2.32.0 + click 8.0.0 -> 8.1.8 (patch) Updated pyproject.toml 9 packages checked - 9 updated + 4 updated + Done in 1.8s +``` + +Flags: + +``` +-f, --file string Path to dependency file (default "pyproject.toml") + --safe Skip major version bumps + --vulns Also scan for vulnerabilities + --include-prerelease Include pre-release versions (alpha, beta, rc, dev) +``` + +### `angela check` + +Show available updates without modifying any files. + +```console +$ angela check +``` + +Accepts the same flags as `update`. Useful for CI or previewing changes before applying them. + +### `angela scan` + +Scan all dependencies for known vulnerabilities via OSV.dev. + +```console +$ angela scan + + Scanning 9 dependencies... + + Vulnerabilities: 36 across 4 packages + + django (30 vulns: 5 critical, 15 high, 10 moderate) + CRITICAL CVE-2022-28346 SQL Injection in Django Fixed: 2.2.28 + CRITICAL CVE-2022-34265 Trunc()/Extract() SQL Injection Fixed: 3.2.14 + HIGH CVE-2023-24580 Resource exhaustion Fixed: 3.2.18 + HIGH CVE-2022-36359 Reflected File Download attack Fixed: 3.2.15 + HIGH CVE-2023-46695 Potential denial-of-service Fixed: 4.2.8 + ...and 25 more + + requests (3 vulns: 3 moderate) + MODERATE CVE-2024-35195 Session verify=False bypass Fixed: 2.32.0 + MODERATE CVE-2023-32681 Proxy-Authorization header leak Fixed: 2.31.0 + MODERATE CVE-2024-47081 .netrc credentials leak via URLs Fixed: 2.32.4 + + Run angela scan -v for full details. + + 9 packages checked 36 vulnerabilities found Done in 2.4s ``` ---- +Use `-v` for full vulnerability details including advisory links: + +```console +$ angela scan -v +``` + +Flags: + +``` +-f, --file string Path to dependency file (default "pyproject.toml") +``` + +### `angela cache clear` + +Remove all cached PyPI responses from `~/.angela/cache/`. + +### Global flags + +``` +-v, --verbose Show full vulnerability details + --min-severity Minimum severity to report (critical, high, moderate, low) +``` + +### Subcommand aliases + +| Command | Alias | +|---------|-------| +| `angela update` | `angela u` | +| `angela check` | `angela c` | +| `angela scan` | `angela s` | + +## File support + +angela works with both `pyproject.toml` and `requirements.txt`. The file type is detected automatically by extension: + +```bash +# pyproject.toml (default) +angela update + +# requirements.txt +angela update -f requirements.txt +``` + +For `pyproject.toml`, angela reads `[project.dependencies]` and `[project.optional-dependencies]`. For `requirements.txt`, it parses standard pip-format lines, skipping comments, blank lines, and pip options (`-r`, `-e`, `--index-url`). + +Both parsers handle PEP 508 dependency strings including extras (`[security,socks]`) and environment markers (`; python_version >= "3.8"`). + +## Configuration + +angela supports project-level configuration through either a standalone `.angela.toml` file or a `[tool.angela]` section in `pyproject.toml`. + +### `.angela.toml` + +```toml +min-severity = "moderate" + +ignore = [ + "django", + "numpy", +] + +ignore-vulns = [ + "CVE-2024-3772", +] +``` + +### `[tool.angela]` in pyproject.toml + +```toml +[tool.angela] +min-severity = "moderate" +ignore = ["django", "numpy"] +ignore-vulns = ["CVE-2024-3772"] +``` + +### Options + +| Key | Type | Description | +|-----|------|-------------| +| `min-severity` | string | Minimum severity to report: `critical`, `high`, `moderate`, `low` | +| `ignore` | string array | Package names to skip during updates | +| `ignore-vulns` | string array | Vulnerability IDs to suppress (CVE or GHSA) | + +### Resolution order + +1. CLI flags (`--min-severity`) +2. `.angela.toml` +3. `[tool.angela]` in `pyproject.toml` +4. Defaults ## How it works ``` -1. Parse pyproject.toml - Extract all [project.dependencies] and [project.optional-dependencies] +1. Parse dependency file + Read pyproject.toml or requirements.txt + Extract package names and version specifiers -2. Query PyPI Simple API (parallel) - Fetch version lists using the lightweight JSON format - ETag-based caching avoids redundant downloads +2. Query PyPI (parallel) + Fetch version lists from the Simple API with JSON format + Cache responses locally with ETag support (1-hour TTL) 3. Resolve updates Parse versions per PEP 440 (epochs, pre-releases, post-releases) @@ -145,40 +237,43 @@ Removes all cached PyPI responses from `~/.angela/cache/`. Classify changes as major, minor, or patch 4. Scan for vulnerabilities (optional) - Batch query OSV.dev for all dependencies + Batch query OSV.dev for all dependencies in a single request Hydrate results with full advisory details Deduplicate overlapping CVE/GHSA identifiers 5. Write updates - Regex-based surgery preserves comments and formatting + Regex-based surgery preserves all comments and formatting Atomic write via temp file + rename ``` ---- - -## Architecture +## Project structure ``` -cmd/angela/main.go CLI entry point +cmd/angela/main.go Entry point internal/ cli/ - update.go Command definitions and orchestration - output.go Terminal formatting with color + update.go Commands and orchestration + output.go Terminal formatting + config/ + config.go Configuration loading pypi/ - version.go PEP 440 parser and comparator - client.go PyPI Simple API client with retry - cache.go File-backed ETag cache + client.go PyPI Simple API client with retry + version.go PEP 440 parser and comparator + cache.go File-backed ETag cache osv/ - client.go OSV.dev batch vulnerability scanner + client.go OSV.dev batch vulnerability scanner pyproject/ - parser.go TOML parsing and PEP 508 splitting - writer.go Comment-preserving regex updater + parser.go TOML parsing and PEP 508 splitting + writer.go Comment-preserving regex updater + requirements/ + parser.go requirements.txt parsing + writer.go requirements.txt updater pkg/types/ - types.go Shared domain types + types.go Shared domain types +testdata/ Test fixtures +learn/ Educational deep dives ``` ---- - ## Development Requires Go 1.24+ and [just](https://github.com/casey/just). @@ -187,20 +282,16 @@ Requires Go 1.24+ and [just](https://github.com/casey/just). just test # Run all tests with race detector just lint # Run golangci-lint just build # Build binary to bin/angela -just cover # Generate HTML coverage report -just check # Lint + test in one step +just cover # Generate coverage report +just ci # Lint + test in one step just run check # Run angela check via go run ``` ---- - ## Part of Cybersecurity-Projects -This tool is project #1 in the [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) repository — a collection of 60 security-focused projects built for learning and reference. The code is written to be educational: clear structure, proper error handling, and thorough testing. +This tool is project #1 in [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) — a collection of 60 security-focused projects built for learning and reference. -See the [`learn/`](learn/) directory for deep dives into the techniques used here. - ---- +The code is written to be educational: clear structure, proper error handling, and thorough testing. See the [`learn/`](learn/) directory for deep dives into the techniques used. ## License diff --git a/PROJECTS/simple-vulnerability-scanner/go.mod b/PROJECTS/simple-vulnerability-scanner/go.mod index de7bc54f..1672bddb 100644 --- a/PROJECTS/simple-vulnerability-scanner/go.mod +++ b/PROJECTS/simple-vulnerability-scanner/go.mod @@ -11,8 +11,8 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.25.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.40.0 // indirect ) diff --git a/PROJECTS/simple-vulnerability-scanner/go.sum b/PROJECTS/simple-vulnerability-scanner/go.sum index 8f7f53f3..0cf2712d 100644 --- a/PROJECTS/simple-vulnerability-scanner/go.sum +++ b/PROJECTS/simple-vulnerability-scanner/go.sum @@ -3,9 +3,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -13,13 +12,13 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go index 70e2c963..ceefc9b3 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/output.go @@ -9,31 +9,25 @@ import ( "strings" "github.com/CarterPerez-dev/angela/internal/pypi" + "github.com/CarterPerez-dev/angela/internal/ui" "github.com/CarterPerez-dev/angela/pkg/types" - "github.com/fatih/color" ) -var ( - bold = color.New(color.Bold).SprintFunc() - dim = color.New(color.Faint).SprintFunc() - cyan = color.New(color.FgCyan, color.Bold).SprintFunc() - green = color.New(color.FgGreen).SprintFunc() - yellow = color.New(color.FgYellow).SprintFunc() - red = color.New(color.FgRed).SprintFunc() - redBold = color.New(color.FgRed, color.Bold).SprintFunc() - greenBold = color.New(color.FgGreen, color.Bold).SprintFunc() - white = color.New(color.FgWhite, color.Bold).SprintFunc() -) +func printDivider() { + fmt.Printf("\n %s\n", ui.HiBlack(ui.HRule(44))) +} -// PrintScanning announces the start of a scan -func PrintScanning(count int) { +func printSectionHeader( + symbol, title string, + symbolColor, titleColor func(a ...any) string, +) { fmt.Printf( - "\n %s %d dependencies...\n\n", - cyan("Scanning"), count, + "\n %s %s\n", + symbolColor(symbol), + titleColor(title), ) } -// PrintUpdates displays a formatted table of available dependency updates func PrintUpdates(updates []types.UpdateResult) { var actionable []types.UpdateResult for _, u := range updates { @@ -43,11 +37,22 @@ func PrintUpdates(updates []types.UpdateResult) { } if len(actionable) == 0 { - fmt.Printf(" %s\n\n", dim("All dependencies are up to date.")) + printDivider() + fmt.Printf( + "\n %s %s\n", + ui.HiGreen(ui.Check), + ui.HiBlackItalic( + "All dependencies are up to date.", + ), + ) return } - fmt.Printf(" %s\n", cyan("Updates available:")) + printDivider() + printSectionHeader( + ui.Diamond, "Updates available", + ui.Cyan, ui.Red, + ) nameWidth := 0 oldWidth := 0 @@ -63,18 +68,19 @@ func PrintUpdates(updates []types.UpdateResult) { for _, u := range actionable { changeColor := changeColorFn(u.Change) fmt.Printf( - " %-*s %s %s %s %s\n", - nameWidth, white(u.Name), - dim(padRight(u.OldVer, oldWidth)), - dim("->"), + " %-*s %s %s %s %s %s\n", + nameWidth, ui.HiBlue(u.Name), + ui.HiBlackCrossed( + padRight(u.OldVer, oldWidth), + ), + ui.Green(ui.Arrow), changeColor(u.NewVer), - dim("("+u.Change+")"), + ui.HiBlack(ui.ArrowRight), + changeColor(u.Change), ) } - fmt.Println() } -// PrintSkipped displays dependencies that were not updated with reasons func PrintSkipped(updates []types.UpdateResult) { var skipped []types.UpdateResult for _, u := range updates { @@ -86,14 +92,21 @@ func PrintSkipped(updates []types.UpdateResult) { return } - fmt.Printf(" %s\n", dim("Skipped:")) + printDivider() + printSectionHeader( + ui.ArrowRight, "Skipped", + ui.Yellow, ui.Red, + ) + for _, u := range skipped { - fmt.Printf(" %s %s\n", dim(u.Name), dim(u.Reason)) + fmt.Printf( + " %s %s\n", + ui.HiBlue(u.Name), + ui.HiBlackItalic(u.Reason), + ) } - fmt.Println() } -// PrintVulnerabilities displays security advisories in compact or verbose mode func PrintVulnerabilities( vulns map[string][]types.Vulnerability, ) { @@ -106,12 +119,15 @@ func PrintVulnerabilities( total += len(vl) } + printDivider() fmt.Printf( - " %s %d across %d %s\n", - redBold("Vulnerabilities:"), - total, - len(vulns), - pluralize("package", len(vulns)), + "\n %s %s %s %s %s %s\n", + ui.Red(ui.TriangleUp), + ui.Red("Vulnerabilities:"), + ui.RedBold(fmt.Sprintf("%d", total)), + ui.HiBlack("across"), + ui.RedBold(fmt.Sprintf("%d", len(vulns))), + ui.HiBlack(pluralize("package", len(vulns))), ) pkgs := sortedVulnPackages(vulns) @@ -134,11 +150,15 @@ func printVulnsCompact( sortVulnsBySeverity(vlist) fmt.Printf( - "\n %s (%d %s: %s)\n", - white(pkg), - len(vlist), - pluralize("vuln", len(vlist)), - severityBreakdown(vlist), + "\n %s %s %s\n", + ui.Blue(ui.Gem), + ui.HiCyan(pkg), + ui.HiBlack(fmt.Sprintf( + "(%d %s: %s)", + len(vlist), + pluralize("vuln", len(vlist)), + severityBreakdown(vlist), + )), ) limit := min(maxVulnsPerPackage, len(vlist)) @@ -148,8 +168,8 @@ func printVulnsCompact( if len(vlist) > maxVulnsPerPackage { fmt.Printf( - " %s\n", - dim(fmt.Sprintf( + " %s\n", + ui.HiBlackItalic(fmt.Sprintf( "...and %d more", len(vlist)-maxVulnsPerPackage, )), @@ -158,8 +178,10 @@ func printVulnsCompact( } fmt.Printf( - "\n %s\n\n", - dim("Run angela scan -v for full details."), + "\n %s\n", + ui.HiBlackItalic( + "Run angela scan -v for full details.", + ), ) } @@ -172,11 +194,15 @@ func printVulnsVerbose( sortVulnsBySeverity(vlist) fmt.Printf( - "\n %s (%d %s: %s)\n", - white(pkg), - len(vlist), - pluralize("vuln", len(vlist)), - severityBreakdown(vlist), + "\n %s %s %s\n", + ui.Magenta(ui.Gem), + ui.HiCyan(pkg), + ui.HiBlack(fmt.Sprintf( + "(%d %s: %s)", + len(vlist), + pluralize("vuln", len(vlist)), + severityBreakdown(vlist), + )), ) for _, v := range vlist { @@ -184,22 +210,28 @@ func printVulnsVerbose( sevColor := severityColorFn(v.Severity) fmt.Printf( - "\n %s %s\n", + "\n %s %s\n", sevColor( padRight(strings.ToUpper(v.Severity), 8), ), - bold(id), + ui.Magenta(id), + ) + fmt.Printf( + " %s\n", + ui.HiBlack(truncate(v.Summary, 72)), ) - fmt.Printf(" %s\n", truncate(v.Summary, 72)) if v.FixedIn != "" { fmt.Printf( - " %s %s\n", - dim("Fixed in:"), - green(v.FixedIn), + " %s %s\n", + ui.HiBlack("Fixed in:"), + ui.Green(v.FixedIn), ) } if v.Link != "" { - fmt.Printf(" %s\n", dim(v.Link)) + fmt.Printf( + " %s\n", + ui.BlueItalic(v.Link), + ) } } } @@ -212,14 +244,17 @@ func printVulnLine(v types.Vulnerability) { fixedStr := "" if v.FixedIn != "" { - fixedStr = dim("Fixed: ") + green(v.FixedIn) + fixedStr = ui.HiBlack("Fixed: ") + + ui.Green(v.FixedIn) } fmt.Printf( - " %s %s %s %s\n", - sevColor(padRight(strings.ToUpper(v.Severity), 8)), - bold(padRight(id, 16)), - truncate(v.Summary, 38), + " %s %s %s %s\n", + sevColor( + padRight(strings.ToUpper(v.Severity), 8), + ), + ui.Blue(padRight(id, 16)), + ui.HiBlack(truncate(v.Summary, 38)), fixedStr, ) } @@ -242,7 +277,9 @@ func sortedVulnPackages( return pkgs } -func sortVulnsBySeverity(vulns []types.Vulnerability) { +func sortVulnsBySeverity( + vulns []types.Vulnerability, +) { sort.Slice(vulns, func(i, j int) bool { ri := severityRank(vulns[i].Severity) rj := severityRank(vulns[j].Severity) @@ -283,12 +320,13 @@ func severityBreakdown( var parts []string for _, sev := range order { if n := counts[sev]; n > 0 { - parts = append(parts, fmt.Sprintf( + sevColor := severityColorFn(sev) + parts = append(parts, sevColor(fmt.Sprintf( "%d %s", n, strings.ToLower(sev), - )) + ))) } } - return strings.Join(parts, ", ") + return strings.Join(parts, ui.HiBlack(", ")) } func preferredID(v types.Vulnerability) string { @@ -336,7 +374,9 @@ func filterIgnoredVulns( return filtered } -func isVulnIgnored(v types.Vulnerability, ignored map[string]bool) bool { +func isVulnIgnored( + v types.Vulnerability, ignored map[string]bool, +) bool { if ignored[v.ID] { return true } @@ -368,73 +408,106 @@ func filterVulnsBySeverity( return filtered } -// PrintSummary displays final counts after an update or scan operation -func PrintSummary(result types.ScanResult, updated bool) { +func PrintSummary( + result types.ScanResult, updated bool, +) { + printDivider() + printSectionHeader( + ui.Star, "Summary", + ui.Green, ui.RedItalic, + ) + if updated { - fmt.Printf(" %s\n", greenBold("Updated pyproject.toml")) + fmt.Printf( + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Updated pyproject.toml"), + ) } fmt.Printf( - " %s packages checked\n", - bold(fmt.Sprintf("%d", result.TotalPackages)), + " %s %s %s\n", + ui.Green(ui.Check), + ui.Green(fmt.Sprintf("%d", result.TotalPackages)), + ui.GreenItalic("packages checked"), ) if result.TotalUpdated > 0 { fmt.Printf( - " %s updated\n", - bold(fmt.Sprintf("%d", result.TotalUpdated)), + " %s %s %s\n", + ui.Green(ui.ArrowUp), + ui.Green( + fmt.Sprintf("%d", result.TotalUpdated), + ), + ui.GreenItalic("updated"), ) } if result.VulnsScanned { if result.TotalVulns > 0 { fmt.Printf( - " %s\n", - red(fmt.Sprintf( - "%d %s found", - result.TotalVulns, - pluralize("vulnerability", result.TotalVulns), - )), + " %s %s %s\n", + ui.HiRed(ui.Cross), + ui.RedBold( + fmt.Sprintf("%d", result.TotalVulns), + ), + ui.HiRed( + pluralize( + "vulnerability", + result.TotalVulns, + )+" found", + ), ) } else { fmt.Printf( - " %s\n", - green("No vulnerabilities found"), + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("No vulnerabilities found"), ) } } fmt.Printf( - " %s %s\n\n", - dim("Done in"), - dim(result.Duration.Round(1e6).String()), + " %s %s\n\n", + ui.HiCyan(ui.Timer), + ui.HiBlueItalic( + result.Duration.Round(1e6).String(), + ), ) } -// PrintError displays a user-friendly error message func PrintError(msg string) { - fmt.Printf("\n %s %s\n\n", red("error:"), msg) + fmt.Printf( + "\n %s %s %s\n\n", + ui.RedBold(ui.Cross), + ui.RedBold("error:"), + ui.HiRed(msg), + ) } -func changeColorFn(kind string) func(a ...any) string { +func changeColorFn( + kind string, +) func(a ...any) string { switch kind { case pypi.Major.String(): - return red + return ui.Red case pypi.Minor.String(): - return yellow + return ui.HiYellow default: - return green + return ui.Green } } -func severityColorFn(sev string) func(a ...any) string { +func severityColorFn( + sev string, +) func(a ...any) string { switch strings.ToUpper(sev) { case "CRITICAL": - return redBold + return ui.Red case "HIGH": - return red + return ui.RedBold case "MODERATE": - return yellow + return ui.HiYellow case "LOW": - return cyan + return ui.Cyan default: - return dim + return ui.HiBlack } } diff --git a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go index df7d5247..8f91283e 100644 --- a/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/simple-vulnerability-scanner/internal/cli/update.go @@ -17,6 +17,7 @@ import ( "github.com/CarterPerez-dev/angela/internal/pypi" "github.com/CarterPerez-dev/angela/internal/pyproject" "github.com/CarterPerez-dev/angela/internal/requirements" + "github.com/CarterPerez-dev/angela/internal/ui" "github.com/CarterPerez-dev/angela/pkg/types" "github.com/spf13/cobra" ) @@ -50,8 +51,25 @@ func Execute() { latest stable versions, and checks for known CVEs using OSV.dev.`, SilenceUsage: true, SilenceErrors: true, + PersistentPreRun: func( + _ *cobra.Command, _ []string, + ) { + ui.PrintBanner() + }, } + defaultHelp := root.HelpFunc() + root.SetHelpFunc( + func(cmd *cobra.Command, args []string) { + if cmd.Root() == cmd { + ui.PrintBannerWithArt() + } else { + ui.PrintBanner() + } + defaultHelp(cmd, args) + }, + ) + root.PersistentFlags().BoolVarP( &verbose, "verbose", "v", false, "show full vulnerability details", @@ -116,11 +134,19 @@ func runInit() error { name := filepath.Base(dir) content := fmt.Sprintf(pyprojectTemplate, name) - if err := os.WriteFile("pyproject.toml", []byte(content), 0o644); err != nil { //nolint:gosec + if err := os.WriteFile( + "pyproject.toml", + []byte(content), + 0o644, + ); err != nil { //nolint:gosec return fmt.Errorf("write pyproject.toml: %w", err) } - fmt.Printf("\n %s pyproject.toml\n\n", greenBold("Created")) + fmt.Printf( + "\n %s %s\n\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Created pyproject.toml"), + ) return nil } @@ -128,17 +154,19 @@ func newUpdateCmd() *cobra.Command { f := &updateFlags{} cmd := &cobra.Command{ - Use: "update", + Use: "update [path]", Aliases: []string{"u"}, Short: "Update dependencies to latest stable versions", - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f.file = resolveFile(f.file, args) return runUpdate(cmd.Context(), f, false) }, } cmd.Flags().StringVarP( &f.file, "file", "f", "pyproject.toml", - "path to pyproject.toml", + "path to dependency file", ) cmd.Flags().BoolVar( &f.safe, "safe", false, @@ -159,17 +187,19 @@ func newCheckCmd() *cobra.Command { f := &updateFlags{} cmd := &cobra.Command{ - Use: "check", + Use: "check [path]", Aliases: []string{"c"}, Short: "Show available updates without modifying files", - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f.file = resolveFile(f.file, args) return runUpdate(cmd.Context(), f, true) }, } cmd.Flags().StringVarP( &f.file, "file", "f", "pyproject.toml", - "path to pyproject.toml", + "path to dependency file", ) cmd.Flags().BoolVar( &f.safe, "safe", false, @@ -190,17 +220,19 @@ func newScanCmd() *cobra.Command { var file string cmd := &cobra.Command{ - Use: "scan", + Use: "scan [path]", Aliases: []string{"s"}, Short: "Scan dependencies for known vulnerabilities", - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + file = resolveFile(file, args) return runScan(cmd.Context(), file) }, } cmd.Flags().StringVarP( &file, "file", "f", "pyproject.toml", - "path to pyproject.toml", + "path to dependency file", ) return cmd } @@ -222,7 +254,11 @@ func newCacheCmd() *cobra.Command { if err := client.ClearCache(); err != nil { return err } - fmt.Println(" Cache cleared.") + fmt.Printf( + " %s %s\n", + ui.HiGreen(ui.Check), + ui.HiGreen("Cache cleared."), + ) return nil }, }) @@ -243,10 +279,14 @@ func runUpdate( return err } - PrintScanning(len(deps)) + spin := ui.NewSpinner(fmt.Sprintf( + "Scanning %d dependencies...", len(deps), + )) + spin.Start() client, err := pypi.NewClient(defaultCacheDir()) if err != nil { + spin.Stop() return err } @@ -271,12 +311,21 @@ func runUpdate( sortUpdates(updates) var vulns map[string][]types.Vulnerability + var scanErr error if f.vulns { - vulns = scanForVulns(ctx, deps) + vulns, scanErr = scanForVulns(ctx, deps) + } + + spin.Stop() + + if scanErr != nil { + PrintError(scanErr.Error()) } if !dryRun && len(updateSpecs) > 0 { - if err := updateDepsFile(f.file, updateSpecs); err != nil { + if err := updateDepsFile( + f.file, updateSpecs, + ); err != nil { return fmt.Errorf("write updates: %w", err) } } @@ -318,10 +367,21 @@ func runScan(ctx context.Context, file string) error { return err } - PrintScanning(len(deps)) + spin := ui.NewSpinner(fmt.Sprintf( + "Scanning %d dependencies for vulnerabilities...", + len(deps), + )) + spin.Start() minSev := resolveMinSeverity(cfg.MinSeverity) - vulns := scanForVulns(ctx, deps) + vulns, scanErr := scanForVulns(ctx, deps) + + spin.Stop() + + if scanErr != nil { + PrintError(scanErr.Error()) + } + vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns) vulns = filterVulnsBySeverity(vulns, minSev) PrintVulnerabilities(vulns) @@ -446,7 +506,7 @@ func resolveUpdates( func scanForVulns( ctx context.Context, deps []types.Dependency, -) map[string][]types.Vulnerability { +) (map[string][]types.Vulnerability, error) { var queries []osv.PackageQuery for _, dep := range deps { ver := pyproject.ExtractMinVersion(dep.Spec) @@ -460,16 +520,17 @@ func scanForVulns( } if len(queries) == 0 { - return nil + return nil, nil } client := osv.NewClient() vulns, err := client.ScanPackages(ctx, queries) if err != nil { - PrintError(fmt.Sprintf("vulnerability scan: %v", err)) - return nil + return nil, fmt.Errorf( + "vulnerability scan: %w", err, + ) } - return vulns + return vulns, nil } func latestAny(versions []string) (pypi.Version, error) { @@ -493,6 +554,18 @@ func latestAny(versions []string) (pypi.Version, error) { return latest, nil } +func resolveFile(flagVal string, args []string) string { + if len(args) > 0 { + path := args[0] + info, err := os.Stat(path) + if err == nil && info.IsDir() { + return filepath.Join(path, "pyproject.toml") + } + return path + } + return flagVal +} + func isRequirementsTxt(path string) bool { return strings.HasSuffix(strings.ToLower(path), ".txt") } diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go new file mode 100644 index 00000000..a849c17e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/banner.go @@ -0,0 +1,79 @@ +// ©AngelaMos | 2026 +// banner.go + +package ui + +import "fmt" + +var angelaBanner = []string{ + " ▄▀▄ █▄ █ ▄▀ ██▀ █ ▄▀▄ ", + " █▀█ █ ▀█ ▀▄█ █▄▄ █▄▄ █▀█ ", +} + +var bannerColors = []func(a ...any) string{ + Red, + Blue, +} + +var animeArt = []string{ + "⣿⣿⣿⡷⠊⡢⡹⣦⡑⢂⢕⢂⢕⢂⢕⢂⠕⠔⠌⠝⠛⠶⠶⢶⣦⣄⢂⢕⢂⢕", + "⣿⣿⠏⣠⣾⣦⡐⢌⢿⣷⣦⣅⡑⠕⠡⠐⢿⠿⣛⠟⠛⠛⠛⠛⠡⢷⡈⢂⢕⢂", + "⠟⣡⣾⣿⣿⣿⣿⣦⣑⠝⢿⣿⣿⣿⣿⣿⡵⢁⣤⣶⣶⣿⢿⢿⢿⡟⢻⣤⢑⢂", + "⣾⣿⣿⡿⢟⣛⣻⣿⣿⣿⣦⣬⣙⣻⣿⣿⣷⣿⣿⢟⢝⢕⢕⢕⢕⢽⣿⣿⣷⣔", + "⣿⣿⠵⠚⠉⢀⣀⣀⣈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⢕⢕⢕⢕⢕⢕⣽⣿⣿⣿⣿", + "⢷⣂⣠⣴⣾⡿⡿⡻⡻⣿⣿⣴⣿⣿⣿⣿⣿⣿⣷⣵⣵⣵⣷⣿⣿⣿⣿⣿⣿⡿", + "⢌⠻⣿⡿⡫⡪⡪⡪⡪⣺⣿⣿⣿⣿⣿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃", + "⠣⡁⠹⡪⡪⡪⡪⣪⣾⣿⣿⣿⣿⠋⠐⢉⢍⢄⢌⠻⣿⣿⣿⣿⣿⣿⣿⣿⠏⠈", + "⡣⡘⢄⠙⣾⣾⣾⣿⣿⣿⣿⣿⣿⡀⢐⢕⢕⢕⢕⢕⡘⣿⣿⣿⣿⣿⣿⠏⠠⠈", + "⠌⢊⢂⢣⠹⣿⣿⣿⣿⣿⣿⣿⣿⣧⢐⢕⢕⢕⢕⢕⢅⣿⣿⣿⣿⡿⢋⢜⠠⠈", +} + +var artColors = []func(a ...any) string{ + Red, + Red, + Blue, + Blue, + Red, + Red, + Blue, + Blue, + Blue, + Red, +} + +func PrintBanner() { + fmt.Println() + fmt.Println() + for i, line := range angelaBanner { + c := bannerColors[i%len(bannerColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Printf(" %s\n", White(HRule(52))) + fmt.Printf( + " %s\n\n", + HiBlackItalic( + "Python dependency updater & vulnerability scanner", + ), + ) +} + +func PrintBannerWithArt() { + fmt.Println() + for i, line := range angelaBanner { + c := bannerColors[i%len(bannerColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Printf(" %s\n", White(HRule(52))) + fmt.Printf( + " %s\n", + HiBlackItalic( + "Python dependency updater & vulnerability scanner", + ), + ) + fmt.Println() + for i, line := range animeArt { + c := artColors[i%len(artColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Println() +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go new file mode 100644 index 00000000..01d2beef --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/color.go @@ -0,0 +1,94 @@ +// ©AngelaMos | 2026 +// color.go + +package ui + +import "github.com/fatih/color" + +var ( + Black = color.New(color.FgBlack).SprintFunc() + Red = color.New(color.FgRed).SprintFunc() + Green = color.New(color.FgGreen).SprintFunc() + Yellow = color.New(color.FgYellow).SprintFunc() + Blue = color.New(color.FgBlue).SprintFunc() + Magenta = color.New(color.FgMagenta).SprintFunc() + Cyan = color.New(color.FgCyan).SprintFunc() + White = color.New(color.FgWhite).SprintFunc() + + HiBlack = color.New(color.FgHiBlack).SprintFunc() + HiRed = color.New(color.FgHiRed).SprintFunc() + HiGreen = color.New(color.FgHiGreen).SprintFunc() + HiYellow = color.New(color.FgHiYellow).SprintFunc() + HiBlue = color.New(color.FgHiBlue).SprintFunc() + HiMagenta = color.New(color.FgHiMagenta).SprintFunc() + HiCyan = color.New(color.FgHiCyan).SprintFunc() + HiWhite = color.New(color.FgHiWhite).SprintFunc() + + RedBold = color.New(color.FgRed, color.Bold).SprintFunc() + GreenBold = color.New(color.FgGreen, color.Bold).SprintFunc() + YellowBold = color.New(color.FgYellow, color.Bold).SprintFunc() + BlueBold = color.New(color.FgBlue, color.Bold).SprintFunc() + MagentaBold = color.New( + color.FgMagenta, color.Bold, + ).SprintFunc() + CyanBold = color.New( + color.FgCyan, color.Bold, + ).SprintFunc() + HiRedBold = color.New( + color.FgHiRed, color.Bold, + ).SprintFunc() + HiMagentaBold = color.New( + color.FgHiMagenta, color.Bold, + ).SprintFunc() + HiCyanBold = color.New( + color.FgHiCyan, color.Bold, + ).SprintFunc() + + Dim = color.New(color.Faint).SprintFunc() + DimItalic = color.New( + color.Faint, color.Italic, + ).SprintFunc() + + CyanItalic = color.New( + color.FgCyan, color.Italic, + ).SprintFunc() + MagentaItalic = color.New( + color.FgMagenta, color.Italic, + ).SprintFunc() + RedItalic = color.New( + color.FgRed, color.Italic, + ).SprintFunc() + GreenItalic = color.New( + color.FgGreen, color.Italic, + ).SprintFunc() + HiBlackItalic = color.New( + color.FgHiBlack, color.Italic, + ).SprintFunc() + BlueItalic = color.New( + color.FgBlue, color.Italic, + ).SprintFunc() + HiBlueItalic = color.New( + color.FgHiBlue, color.Italic, + ).SprintFunc() + + CyanUnderline = color.New( + color.FgCyan, color.Underline, + ).SprintFunc() + MagentaUnderline = color.New( + color.FgMagenta, color.Underline, + ).SprintFunc() + + HiCyanBoldItalic = color.New( + color.FgHiCyan, color.Bold, color.Italic, + ).SprintFunc() + HiMagentaBoldItalic = color.New( + color.FgHiMagenta, color.Bold, color.Italic, + ).SprintFunc() + HiRedBoldItalic = color.New( + color.FgHiRed, color.Bold, color.Italic, + ).SprintFunc() + + HiBlackCrossed = color.New( + color.FgHiBlack, color.CrossedOut, + ).SprintFunc() +) diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go new file mode 100644 index 00000000..412f283e --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/spinner.go @@ -0,0 +1,83 @@ +// ©AngelaMos | 2026 +// spinner.go + +package ui + +import ( + "fmt" + "strings" + "sync" + "time" +) + +var frames = []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", +} + +type Spinner struct { + msg string + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex + active bool +} + +func NewSpinner(msg string) *Spinner { + return &Spinner{msg: msg} +} + +func (s *Spinner) Start() { + s.mu.Lock() + if s.active { + s.mu.Unlock() + return + } + s.active = true + s.done = make(chan struct{}) + s.wg.Add(1) + s.mu.Unlock() + + go s.run() +} + +func (s *Spinner) Stop() { + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return + } + s.active = false + close(s.done) + s.mu.Unlock() + s.wg.Wait() +} + +func (s *Spinner) run() { + defer s.wg.Done() + fmt.Print("\033[?25l") + + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + idx := 0 + for { + select { + case <-s.done: + clearLine() + fmt.Print("\033[?25h") + return + case <-ticker.C: + frame := frames[idx%len(frames)] + fmt.Printf( + "\r %s %s", + CyanBold(frame), + HiMagenta(s.msg), + ) + idx++ + } + } +} + +func clearLine() { + fmt.Print("\r" + strings.Repeat(" ", 80) + "\r") +} diff --git a/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go b/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go new file mode 100644 index 00000000..e5706c12 --- /dev/null +++ b/PROJECTS/simple-vulnerability-scanner/internal/ui/symbol.go @@ -0,0 +1,24 @@ +// ©AngelaMos | 2026 +// symbol.go + +package ui + +import "strings" + +const ( + Arrow = "→" + ArrowRight = "▸" + ArrowUp = "↑" + Diamond = "◆" + Gem = "◈" + Star = "✦" + TriangleUp = "▲" + Check = "✓" + Cross = "✗" + Timer = "⏱" + DividerChar = "━" +) + +func HRule(width int) string { + return strings.Repeat(DividerChar, width) +} diff --git a/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md b/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md index d49304b1..a7ac4346 100644 --- a/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md +++ b/PROJECTS/simple-vulnerability-scanner/learn/GO-CONCURRENCY-PATTERNS.md @@ -1,20 +1,20 @@ # Go Concurrency Patterns Used in angela -This document explains the concurrency patterns angela uses to fetch data from PyPI and OSV.dev in parallel. These are production patterns used at Uber, Google, and Cloudflare — not textbook exercises. +angela fetches data from PyPI and OSV.dev in parallel. These patterns show up in production codebases at places like Uber and Cloudflare, but they're not complicated — they just require knowing which tool to reach for. --- ## The problem -angela needs to query PyPI for every dependency in your `pyproject.toml`. A typical project has 20-50 dependencies. Making those requests sequentially would take 10-25 seconds. Making them all at once would hammer PyPI with 50 simultaneous connections. +angela needs to query PyPI for every dependency in your `pyproject.toml` or `requirements.txt`. A typical project has 20-50 dependencies. Doing those requests one at a time would take 10-25 seconds. Doing them all at once would hammer PyPI with 50 simultaneous connections. -The solution: **bounded concurrency** — run up to N requests in parallel, queuing the rest. +The answer is **bounded concurrency** — run up to N requests in parallel, queue the rest. --- -## Pattern 1: errgroup.SetLimit +## errgroup.SetLimit -The `golang.org/x/sync/errgroup` package provides the cleanest API for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`: +The `golang.org/x/sync/errgroup` package is the go-to for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`: ```go g, ctx := errgroup.WithContext(ctx) @@ -35,18 +35,18 @@ for _, name := range names { _ = g.Wait() ``` -Key decisions: +A few things to notice: -1. **`SetLimit(10)`** — caps concurrent HTTP requests. PyPI recommends 5-10. -2. **Always returns nil** — individual package failures are collected in results, not propagated. One failed package shouldn't cancel the others. -3. **Mutex protects shared slice** — `results` is appended to from multiple goroutines. +- **`SetLimit(10)`** caps concurrent HTTP requests. PyPI recommends 5-10. +- **Always returns nil** — individual failures get collected in results, not propagated. One package failing shouldn't kill the others. +- **Mutex on the shared slice** — `results` gets appended to from multiple goroutines, so it needs a lock. ### Why not channels? -A channel-based worker pool would work, but errgroup.SetLimit does the same thing with less code: +Channels would work, but errgroup gets you the same thing with half the code: ```go -// Channel approach: ~30 lines of setup +// Channel-based worker pool: ~30 lines jobs := make(chan string, len(names)) results := make(chan FetchResult, len(names)) for range maxWorkers { @@ -57,9 +57,9 @@ for range maxWorkers { } }() } -// ... send jobs, collect results ... +// ... send jobs, collect results, close channels ... -// errgroup approach: ~15 lines +// errgroup: ~15 lines g.SetLimit(maxWorkers) for _, name := range names { g.Go(func() error { /* ... */ }) @@ -67,13 +67,13 @@ for _, name := range names { g.Wait() ``` -The errgroup version is shorter, handles context cancellation automatically, and has no channel lifecycle to manage. +errgroup handles context cancellation for free and there's no channel lifecycle to think about. --- -## Pattern 2: Panic recovery in goroutines +## Panic recovery -Every goroutine angela launches includes panic recovery: +Every goroutine angela launches wraps itself in a recover: ```go g.Go(func() (err error) { @@ -89,32 +89,33 @@ g.Go(func() (err error) { }) ``` -**Why this matters**: an unrecovered panic in a goroutine kills the entire process. In a CLI tool, that means the user sees a stack trace instead of a helpful error message. The `defer recover()` converts panics into errors that flow through the normal error path. +An unrecovered panic in a goroutine kills the entire process. For a CLI tool, that means the user gets a stack trace dump instead of an actual error message. The `defer recover()` turns panics into errors that flow through the normal path. -The named return `(err error)` is essential — it lets the deferred function assign the recovered panic as the return value. +One subtle thing — the named return `(err error)` is what makes this work. Without it, the deferred function has no way to set the return value. --- -## Pattern 3: Context cancellation +## Context cancellation -Every HTTP request in angela uses context: +Every HTTP request uses context: ```go req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) ``` -This means: -- If the user presses Ctrl+C, pending requests are cancelled -- If `errgroup.WithContext` detects an error, remaining work stops +This does a lot of work for you: + +- Ctrl+C cancels pending requests +- If errgroup's context gets cancelled, remaining work stops - HTTP timeouts are enforced at the transport level -The context flows from `cobra.Command.Context()` through `runUpdate()` through `FetchAllVersions()` through `FetchVersions()` to the actual HTTP call. This is the standard Go pattern: context as the first parameter, threaded through the entire call chain. +The context flows from `cobra.Command.Context()` → `runUpdate()` → `FetchAllVersions()` → `FetchVersions()` → the HTTP call itself. Standard Go pattern — context as the first parameter, threaded through everything. --- -## Pattern 4: Retry with exponential backoff +## Retry with exponential backoff -`internal/pypi/client.go` implements retry for transient failures: +`internal/pypi/client.go` retries transient failures: ```go for attempt := range maxRetries { @@ -142,18 +143,13 @@ for attempt := range maxRetries { } ``` -Design choices: - -1. **Exponential backoff** — `1< `` in ASCII. But in Python's world: +If you naively compare version strings, `1.0a1` looks "newer" than `1.0` because `a` > empty string in ASCII. But Python's actual ordering is: ``` 1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1 ``` -A tool that doesn't understand this will upgrade users to **unstable pre-releases**. That's why angela implements the full PEP 440 parser. +A tool that gets this wrong will upgrade users to unstable pre-releases. That's why angela implements the full spec. --- -## How angela's parser works +## How the parser works -The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to extract all components in one pass: +The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to pull out all the components in one pass: ``` (?i)^v? @@ -53,7 +53,7 @@ The parser lives in `internal/pypi/version.go`. It uses a single compiled regex (?:\+([a-z0-9]...))?$ # local version ``` -Each named section maps to a field in the `Version` struct: +Each section maps to a field in the `Version` struct: ```go type Version struct { @@ -68,13 +68,13 @@ type Version struct { } ``` -The sentinel value `-1` for Post and Dev is critical — it distinguishes "not present" from "present with value 0". Both `1.0.post0` and `1.0.post` are valid (implicit zero), but they're different from `1.0` (no post-release at all). +The `-1` sentinel for Post and Dev is important — it's how you tell "not present" apart from "present with value 0". Both `1.0.post0` and `1.0.post` are valid PEP 440 (implicit zero), but they're different from `1.0` which has no post-release at all. --- -## Normalization edge cases +## Normalization -PEP 440 allows multiple spellings that all mean the same thing: +PEP 440 allows a bunch of different spellings that all mean the same thing: | Input | Normalized | |-------|-----------| @@ -86,13 +86,13 @@ PEP 440 allows multiple spellings that all mean the same thing: | `1.0a` | `1.0a0` (implicit zero) | | `v1.0` | `1.0` (strip leading v) | -The parser handles all of these by normalizing during extraction. +The parser normalizes all of these during extraction, so the rest of the codebase never has to think about variant spellings. --- -## Version comparison: the tuple trick +## Version comparison -Python's `packaging` library compares versions by converting them into tuples that sort naturally. angela uses the same idea: +Python's `packaging` library compares versions by converting them to tuples that sort naturally. angela does the same thing: ```go func (v Version) Compare(other Version) int { @@ -104,7 +104,7 @@ func (v Version) Compare(other Version) int { } ``` -The key insight is the sentinel values for sorting: +The trick is in the sentinel values: | Component state | Sort key | |----------------|----------| @@ -114,13 +114,13 @@ The key insight is the sentinel values for sorting: | Pre-release `rc1` | `2, 1` — rc rank 2 | | Final release (no pre) | `MaxInt, MaxInt` — sorts after all pre-releases | -This produces the correct PEP 440 ordering without any special-case branching in the comparison loop. +Using `math.MinInt` and `math.MaxInt` means the comparison function is just a sequence of `cmp.Compare` calls — no special-case branching needed. --- ## Stability detection -A version is **stable** if it has no pre-release tag AND no dev tag: +A version is **stable** if it has no pre-release tag and no dev tag: ```go func (v Version) IsStable() bool { @@ -128,16 +128,14 @@ func (v Version) IsStable() bool { } ``` -Post-releases are stable — `1.0.post1` is a documentation fix or metadata correction, not an unstable build. +Post-releases count as stable — `1.0.post1` is a docs fix or metadata correction, not an unstable build. --- -## What to learn from this +## Takeaways -1. **Regex can be the right tool** — a single compiled regex handles all PEP 440 forms in one pass. The alternative (hand-written state machine) would be 3x the code for no real benefit. +A single compiled regex handles every PEP 440 form in one pass. The alternative would be a hand-written state machine with 3x the code and no real upside. -2. **Sentinel values simplify comparison** — using `math.MinInt` and `math.MaxInt` lets the comparison function be a clean sequence of `cmp.Compare` calls without branches. +Normalizing at parse time (alpha→a, preview→rc) keeps things clean — nothing downstream ever has to handle variant spellings. -3. **Test-driven development** — the version parser has 67 test cases across 10 functions. When implementing something spec-driven like PEP 440, write the test cases first from the spec, then make them pass. - -4. **Normalization at parse time** — by normalizing spellings during parsing (alpha→a, preview→rc), the rest of the codebase never has to think about variant forms. +The version parser currently has 90+ test cases across 10 functions. When you're implementing something spec-driven like this, writing the test cases from the spec first and then making them pass is the way to go. diff --git a/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md b/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md index 64b02898..3a3165b7 100644 --- a/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md +++ b/PROJECTS/simple-vulnerability-scanner/learn/TOML-COMMENT-PRESERVATION.md @@ -1,6 +1,6 @@ -# Preserving TOML Comments: Why Regex Surgery is the Right Call +# Preserving Comments in Dependency Files -When angela updates version specifiers in `pyproject.toml`, it must preserve every comment, blank line, and formatting choice the developer made. This is harder than it sounds. This document explains the approach and why alternatives don't work. +When angela updates version specifiers, it can't blow away the developer's comments, blank lines, and formatting. This turns out to be a surprisingly annoying problem. Here's how angela handles it for both `pyproject.toml` and `requirements.txt`. --- @@ -36,13 +36,13 @@ dependencies = [ ] ``` -Only the version numbers changed. Every comment, every space, every quote style — preserved. +Only the version numbers changed. Every comment, every space, every quote style — still there. Same goes for `requirements.txt` — inline comments, section headers, all of it should survive. --- ## Why TOML libraries can't do this -Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling TOML into Go structs, then marshaling back: +Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling into Go structs, then marshaling back: ```go var proj PyProject @@ -51,32 +51,32 @@ proj.Dependencies[0] = "requests>=2.32.3" output, _ := toml.Marshal(proj) // comments gone, formatting changed ``` -Go's reflection system has no mechanism to store comment metadata on struct fields. The unmarshal/marshal round-trip fundamentally **destroys comments**. This isn't a bug — it's an architectural limitation of how Go TOML libraries work. +Go's reflection system has no way to store comment metadata on struct fields. The unmarshal/marshal round-trip just destroys them. This isn't a library bug — it's a fundamental limitation of how Go struct serialization works. -pelletier/go-toml v2 has an `unstable` package with AST access that preserves comments in the parse tree, but it provides no serialization — you'd have to write your own TOML emitter from the AST. +pelletier/go-toml v2 does have an `unstable` package that exposes AST access with comments preserved in the parse tree, but there's no serializer — you'd have to write your own TOML emitter from scratch. --- -## The regex surgery approach +## Regex surgery -angela's solution: don't parse and re-serialize. Instead, treat the file as a byte buffer and surgically replace only the version specifier substring. +angela's approach: don't parse and re-serialize. Treat the file as a byte buffer and surgically replace only the version specifier. -The implementation in `internal/pyproject/writer.go`: +The implementation for pyproject.toml lives in `internal/pyproject/writer.go`: -1. **Build a regex** that matches the full dependency string including quotes: +1. Build a regex that matches the full dependency string inside quotes: ``` "requests>=2.28.0" ``` -2. **Capture groups** isolate the parts we need: +2. Capture groups isolate the pieces: - Group 1: package name (`requests`) - Group 2: extras (`[async]` or empty) - Group 3: version specifier (`>=2.28.0`) - Group 4: markers (`;python_version>='3.8'` or empty) -3. **Replace only group 3** (the version spec) while preserving everything else. +3. Replace only group 3, keep everything else. -4. **Validate before and after** — feed the result through go-toml v2's unmarshaler to catch syntax errors. +4. Validate before and after — feed the result through go-toml v2's unmarshaler to make sure the edit didn't break the TOML syntax. ```go func (u *Updater) UpdateDependency(pkg, newSpec string) error { @@ -90,7 +90,6 @@ func (u *Updater) UpdateDependency(pkg, newSpec string) error { }, ) if found { - // Re-validate TOML syntax var probe map[string]any if err := toml.Unmarshal(u.content, &probe); err != nil { return fmt.Errorf("update produced invalid TOML: %w", err) @@ -104,6 +103,21 @@ func (u *Updater) UpdateDependency(pkg, newSpec string) error { --- +## requirements.txt uses the same idea + +The `requirements.txt` writer in `internal/requirements/writer.go` works the same way — regex-based surgery on raw bytes. The difference is simpler: no quotes to worry about, no TOML validation needed. Lines look like: + +``` +django>=3.2.0 +requests==2.28.1 # HTTP client +``` + +The regex matches the package name (with PEP 503 normalization) and its version specifier, replaces the spec, and leaves everything else alone — including inline comments. + +Both writers share the same atomic write pattern (temp file + rename) and the same PEP 503 name normalization approach. The pyproject writer just has extra complexity for quote styles and TOML validation. + +--- + ## The tricky parts ### PEP 503 name normalization @@ -114,7 +128,7 @@ Package names on PyPI are case-insensitive and treat `-`, `_`, and `.` as equiva Some_Package == some-package == some.package ``` -The regex must match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`: +The regex has to match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`: ```go parts := strings.Split(normalized, "-") @@ -124,17 +138,17 @@ for i, p := range parts { namePattern := strings.Join(parts, `[-_.]?`) ``` -This means the pattern for `some-package` becomes `some[-_.]?package`, matching `some_package`, `some.package`, and `somepackage`. +So the pattern for `some-package` becomes `some[-_.]?package`, which matches `some_package`, `some.package`, and `somepackage`. ### Go RE2 doesn't support backreferences -The TOML research suggested using `\1` to match the closing quote with the opening quote. Go's `regexp` package uses RE2, which doesn't support backreferences. +The TOML research suggested using `\1` to match closing quotes with the opening quote. Go's `regexp` package uses RE2, which doesn't do backreferences. -The fix: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character. +The workaround: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character. ### Atomic file writes -Even the write is careful — angela writes to a `.tmp` file first, then renames over the original. If the process is killed mid-write, the original file is untouched: +angela writes to a `.tmp` file first, then renames over the original. If the process dies mid-write, the original is untouched: ```go func (u *Updater) WriteFile(path string) error { @@ -150,20 +164,12 @@ func (u *Updater) WriteFile(path string) error { } ``` +It's two syscalls, but the file is never in a half-written state. + --- ## This is how production tools do it -Renovate (GitHub's automated dependency updater) and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize — they surgically modify. The reason is the same: no TOML/YAML/JSON library in any language perfectly round-trips formatting and comments. +Renovate and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize either — they do the same kind of surgical modification. No TOML, YAML, or JSON library in any language perfectly round-trips formatting and comments. Regex surgery sounds hacky, but it's the approach that actually works. ---- - -## What to learn from this - -1. **Sometimes the "crude" approach is correct** — regex surgery sounds hacky, but it's the only way to preserve comments in TOML. The fancier approaches (AST manipulation, custom serializer) are more complex and still lose whitespace. - -2. **Validate at boundaries** — angela validates TOML syntax before and after every edit. The regex does the surgery; go-toml v2 confirms the patient survived. - -3. **Atomic writes prevent corruption** — write to temp + rename is the standard pattern for file updates in Unix. It's two syscalls, but it guarantees the file is never half-written. - -4. **Know your regex engine** — Go uses RE2, which guarantees linear-time matching but doesn't support backreferences or lookaheads. Design patterns accordingly. +Go's RE2 engine guarantees linear-time matching, so even on large files with complex patterns there's no risk of catastrophic backtracking. Just keep in mind it doesn't support backreferences or lookaheads — design your patterns around that. diff --git a/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml b/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml index 7b1cd7e2..374c10ce 100644 --- a/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml +++ b/PROJECTS/simple-vulnerability-scanner/testdata/pyproject.toml @@ -4,7 +4,7 @@ name = "sample-app" version = "0.1.0" requires-python = ">=3.10" -# Core runtime dependencies +# Core runtime dependencies (outdated) dependencies = [ "requests>=2.28.0", "django>=3.2,<4.0", @@ -14,7 +14,7 @@ dependencies = [ ] [project.optional-dependencies] -# Development and testing tools +# Development and testing tools (outdated) dev = [ "pytest>=7.0.0", "black>=23.0.0", # Code formatter