diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a27c2d..92a27bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,12 @@ jobs: - name: Install dependencies run: uv sync --all-groups + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build Rust extension + run: uv run maturin develop + - name: Lint with ruff run: uv run ruff check src/ tests/ @@ -40,17 +46,23 @@ jobs: - name: Run tests run: uv run pytest tests/ -v - # ── Rust stubs (uncomment when devbrief-core crate is added) ────────────── - # rust-check: - # name: Rust clippy & test - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - name: Install Rust toolchain - # uses: dtolnay/rust-toolchain@stable - # with: - # components: clippy - # - name: Clippy - # run: cargo clippy --all-targets --all-features -- -D warnings - # - name: Rust tests - # run: cargo test + rust-check: + name: Rust clippy & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Clippy + run: cargo clippy --manifest-path rust/Cargo.toml -- -D warnings + env: + PYO3_BUILD_EXTENSION_MODULE: "1" + + - name: Rust unit tests + run: cargo test --manifest-path rust/Cargo.toml + env: + PYO3_BUILD_EXTENSION_MODULE: "1" diff --git a/.github/workflows/release-wheels.yml b/.github/workflows/release-wheels.yml new file mode 100644 index 0000000..f8e7aea --- /dev/null +++ b/.github/workflows/release-wheels.yml @@ -0,0 +1,113 @@ +name: Release Wheels + +on: + push: + tags: + - "v*" + +permissions: + contents: read + id-token: write # Required for PyPI OIDC trusted publishing + +jobs: + linux: + name: Build wheels (Linux) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + command: build + args: --release --out dist --interpreter python${{ matrix.python-version }} + manylinux: auto + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.python-version }} + path: dist/ + + macos: + name: Build wheels (macOS) + runs-on: macos-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + command: build + args: --release --out dist --interpreter python${{ matrix.python-version }} + target: universal2-apple-darwin + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.python-version }} + path: dist/ + + windows: + name: Build wheels (Windows) + runs-on: windows-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + command: build + args: --release --out dist --interpreter python${{ matrix.python-version }} + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.python-version }} + path: dist/ + + sdist: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist/ + + publish-pypi: + name: Publish to PyPI + needs: [linux, macos, windows, sdist] + runs-on: ubuntu-latest + environment: pypi # Requires a "pypi" environment configured in repo settings + + steps: + - name: Download all wheels + uses: actions/download-artifact@v4 + with: + pattern: wheels-* + path: dist/ + merge-multiple: true + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + # Uses OIDC trusted publishing — no API token needed. + # Configure at: https://pypi.org/manage/project/devbrief/settings/publishing/ diff --git a/CLAUDE.md b/CLAUDE.md index c220a24..7504e5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ devbrief/ | devbrief repo | LIVE | v0.3.2, cache layer (SHA-keyed, ~/.cache/devbrief/), --no-cache/--refresh | | devbrief auth | LIVE | v0.2.0, key validation, config write/read/clear, 600 perms | | devbrief logs | LIVE | v0.3.0, FastAPI+HTMX polling dashboard, ring buffer, file (1s tail)/stdin | -| devbrief env | PLANNED | Rust entry point via maturin/PyO3 | +| devbrief env | IN PROGRESS | v0.4.0, Rust active (maturin/PyO3), gitignore audit + .env drift + secret scan | | devbrief api | PLANNED | | | devbrief infra | PLANNED | | | devbrief pr | PLANNED | | @@ -128,6 +128,12 @@ devbrief/ - **Ruff** for linting and formatting — no other linters, no black, no flake8. - **Type hints everywhere** — no untyped functions, no `Any` without explanation. - **Rust:** `clippy` clean, zero warnings allowed. +- **Rust testing:** `cargo test` requires two things: `crate-type = ["cdylib", "rlib"]` (rlib lets + the linker produce a test binary) and `PYO3_BUILD_EXTENSION_MODULE=1` (tells PyO3 not to link + against libpython, which may not be available as a shared lib on the host). Our tests only call + pure Rust functions so they do not need a live Python interpreter. Run as: + `PYO3_BUILD_EXTENSION_MODULE=1 cargo test --manifest-path rust/Cargo.toml`. + The `rust-check` CI job sets this env var automatically. - **Tests required** for every new command and every credential resolution path. - **Default model:** `claude-sonnet-4-6`. Never hardcode a model string in any command file. Model is always resolved via `resolve_model()` in `devbrief.core.credentials` (env var `DEVBRIEF_MODEL` → `config.toml [anthropic] default_model` → `"claude-sonnet-4-6"`). - **Graceful degradation:** If Rust extension is unavailable, fall back to Python implementation. Never hard-crash on missing native extension. @@ -153,4 +159,7 @@ devbrief/ 4. [x] v0.3.0: `devbrief logs` — FastAPI+HTMX polling dashboard, ring buffer, file/stdin 5. [x] v0.3.1: `devbrief repo` cache layer — SHA-keyed local cache, --no-cache/--refresh flags 6. [x] v0.3.2: `github.py` migrated from `requests` to `httpx` — closes HTTP client tech debt -7. [ ] Await spec card before touching any subcommand +7. [x] v0.4.0: `devbrief env` — gitignore audit, .env drift (Rust), secret scan (Rust), stub types +8. [x] Rust unit tests: `["cdylib","rlib"]`, `tempfile` dev-dep, 12 `#[cfg(test)]` tests, + `rust-check` CI job active, `PYO3_BUILD_EXTENSION_MODULE=1` for cargo test +9. [ ] Await spec card before touching any subcommand diff --git a/README.md b/README.md index e6d4b96..4e2552b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - **`devbrief repo`** — takes a GitHub URL, pulls repository metadata, README, and file tree, then asks Claude to produce a structured brief directly in your terminal. - **`devbrief logs`** — streams a log file (or stdin) into a local browser dashboard with live filtering, level highlighting, and rolling metrics. +- **`devbrief env`** — audits a project directory for environment hygiene: `.gitignore` coverage, `.env` / `.env.example` key drift, and secret pattern detection in committed files. ![devbrief repo cache demo](assets/devbrief-cache.gif) @@ -70,6 +71,7 @@ export ANTHROPIC_API_KEY=sk-ant-... │ repo Analyze a GitHub repository. │ │ auth Manage API credentials. │ │ logs Stream logs into a live dashboard. │ +│ env Check project environment health. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` @@ -139,6 +141,43 @@ devbrief logs /var/log/app.log --port 8080 The dashboard auto-detects common log formats (JSON structured logs, ISO timestamp prefix, `[LEVEL]`, `LEVEL:`) and supports live client-side filtering by level, keyword, and time range. New lines appended to the file appear within ~3 seconds. +### devbrief env + +```bash +devbrief env [PATH] [--strict] [--quiet] +``` + +Audits a project directory for common environment-hygiene issues. Three checks run in sequence: + +1. **.gitignore audit** — verifies the file exists and warns on each missing advisory entry (`.env`, `.env.local`, `.env.*.local`, `*.pem`, `*.key`, `id_rsa`, `id_rsa.*`, `.aws/credentials`). +2. **.env drift** — compares keys between `.env` and `.env.example`; warns on keys missing from `.env` or undocumented in `.env.example`. +3. **Secret scan** — walks the directory tree (respecting `.gitignore`) and flags lines matching five patterns: Anthropic API keys, OpenAI API keys, AWS access key IDs, GitHub tokens, and PEM private key headers. Implemented as a compiled Rust extension for near-native performance on large trees. + +**Examples:** + +```bash +# Audit the current directory +devbrief env + +# Audit a specific project root +devbrief env /path/to/project + +# Exit code 1 on any warning (CI-friendly strict mode) +devbrief env --strict + +# Plain text output (no Rich formatting, useful for scripts) +devbrief env --quiet +``` + +| Option | Description | +|---|---| +| `PATH` | Project root to scan (default: current directory) | +| `--strict` | Treat warnings as errors — exit 1 if any warnings present | +| `--quiet` | Suppress Rich formatting; plain text output only | +| `--help` | Show usage and exit | + +Exit code `0` when all checks pass (or warnings only without `--strict`). Exit code `1` on any error, or any warning under `--strict`. + --- ## Credential resolution order @@ -198,24 +237,30 @@ If the GitHub API is unreachable, the most recent cached brief for that URL is s ## Development -Requires [uv](https://docs.astral.sh/uv/). +Requires [uv](https://docs.astral.sh/uv/) and [Rust](https://rustup.rs/) (stable toolchain). ```bash git clone https://github.com/s3bc40/devbrief cd devbrief uv sync --all-groups +uv run maturin develop # compile the Rust extension ``` ### Run locally ```bash uv run devbrief repo https://github.com/s3bc40/devbrief +uv run devbrief env . ``` ### Run tests ```bash +# Python test suite (122 tests) uv run pytest + +# Rust unit tests (12 tests) +PYO3_BUILD_EXTENSION_MODULE=1 cargo test --manifest-path rust/Cargo.toml ``` ### Lint @@ -229,28 +274,36 @@ uv run ruff format src/ tests/ ``` src/devbrief/ -├── cli.py # Typer app — registers all subcommands +├── cli.py # Typer app — registers all subcommands +├── _devbrief_core.pyi # Type stubs for the Rust extension +├── py.typed # PEP 561 marker ├── commands/ -│ ├── repo.py # devbrief repo -│ ├── auth.py # devbrief auth -│ └── logs.py # devbrief logs — FastAPI server, log parser, ring buffer +│ ├── repo.py # devbrief repo +│ ├── auth.py # devbrief auth +│ ├── logs.py # devbrief logs — FastAPI server, log parser, ring buffer +│ └── env.py # devbrief env — gitignore audit, .env drift, secret scan ├── templates/ -│ ├── base.html # Base HTML layout (HTMX) +│ ├── base.html # Base HTML layout (HTMX) │ └── logs/ -│ └── dashboard.html # Log dashboard template +│ └── dashboard.html # Log dashboard template ├── core/ -│ ├── credentials.py # API key + model resolution chain -│ ├── config.py # Config file read/write (~/.config/devbrief/config.toml) -│ └── cache.py # Local brief cache (~/.cache/devbrief/) -├── github.py # GitHub REST API fetchers -├── brief.py # Prompt construction and Claude API call -└── display.py # Rich terminal rendering +│ ├── credentials.py # API key + model resolution chain +│ ├── config.py # Config file read/write (~/.config/devbrief/config.toml) +│ └── cache.py # Local brief cache (~/.cache/devbrief/) +├── github.py # GitHub REST API fetchers +├── brief.py # Prompt construction and Claude API call +└── display.py # Rich terminal rendering +rust/ +├── Cargo.toml # Rust crate (cdylib + rlib) +└── src/lib.rs # PyO3 extension: diff_env_files, scan_secrets tests/ -├── test_credentials.py # Credential resolution + auth command tests -├── test_logs.py # Log parser, ring buffer, polling endpoints -├── test_cache.py # Cache module + repo cache integration tests -├── test_github.py -└── test_display.py +├── test_credentials.py # Credential resolution + auth command tests +├── test_logs.py # Log parser, ring buffer, polling endpoints +├── test_cache.py # Cache module + repo cache integration tests +├── test_github.py # GitHub fetcher tests +├── test_display.py # Rich display tests +├── test_env.py # env command tests (Python orchestration layer) +└── MANUAL_QA.md # Manual QA scenarios for devbrief env v0.4.0 ``` --- diff --git a/pyproject.toml b/pyproject.toml index d5d3ec7..f9e3b5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "devbrief" -version = "0.3.2" +version = "0.4.0" description = "Generate a human-readable brief for any GitHub repository using Claude AI" readme = "README.md" requires-python = ">=3.12" @@ -40,7 +40,6 @@ Repository = "https://github.com/s3bc40/devbrief" devbrief = "devbrief.cli:app" [tool.maturin] -features = ["pyo3/extension-module"] python-source = "src" module-name = "devbrief._devbrief_core" exclude = [ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2b68765..407e7e5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,18 +2,183 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + [[package]] name = "indoc" version = "2.0.7" @@ -23,12 +188,42 @@ dependencies = [ "rustversion", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "memoffset" version = "0.9.1" @@ -50,6 +245,16 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -129,11 +334,62 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rust" version = "0.1.0" dependencies = [ + "ignore", "pyo3", + "regex", + "tempfile", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -142,6 +398,63 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "syn" version = "2.0.117" @@ -159,14 +472,213 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unindent" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 608c774..746c081 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -6,7 +6,14 @@ edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] name = "_devbrief_core" -crate-type = ["cdylib"] +# cdylib → Python extension (.so) +# rlib → required by `cargo test` to build the test binary +crate-type = ["cdylib", "rlib"] [dependencies] pyo3 = "0.27.0" +regex = "1" +ignore = "0.4" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml new file mode 100644 index 0000000..d0ead5e --- /dev/null +++ b/rust/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 8a72466..8378fb6 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,13 +1,348 @@ +use ignore::Walk; use pyo3::prelude::*; +use regex::Regex; +use std::collections::HashSet; +use std::fs; +use std::io::Read; + +// --------------------------------------------------------------------------- +// Types — pub so the pymodule can re-export them via #[pymodule_export] +// --------------------------------------------------------------------------- + +#[pyclass] +pub struct EnvDiff { + #[pyo3(get)] + pub missing_from_env: Vec, + #[pyo3(get)] + pub undocumented_in_example: Vec, +} + +#[pyclass] +pub struct SecretMatch { + #[pyo3(get)] + pub file: String, + #[pyo3(get)] + pub line: usize, + #[pyo3(get)] + pub pattern_name: String, + #[pyo3(get)] + pub masked_value: String, +} + +// --------------------------------------------------------------------------- +// Pure Rust helpers — pub(crate) so the #[cfg(test)] module can call them +// --------------------------------------------------------------------------- + +pub(crate) fn parse_env_keys(content: &str) -> HashSet { + content + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .filter_map(|l| l.split('=').next()) + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()) + .collect() +} + +pub(crate) fn diff_env_files_impl(env_path: &str, example_path: &str) -> EnvDiff { + let env_keys = match fs::read_to_string(env_path) { + Ok(c) => parse_env_keys(&c), + Err(_) => { + return EnvDiff { + missing_from_env: vec![], + undocumented_in_example: vec![], + }; + } + }; + let example_keys = match fs::read_to_string(example_path) { + Ok(c) => parse_env_keys(&c), + Err(_) => { + return EnvDiff { + missing_from_env: vec![], + undocumented_in_example: vec![], + }; + } + }; + + let mut missing: Vec = example_keys.difference(&env_keys).cloned().collect(); + let mut undocumented: Vec = env_keys.difference(&example_keys).cloned().collect(); + missing.sort(); + undocumented.sort(); + + EnvDiff { + missing_from_env: missing, + undocumented_in_example: undocumented, + } +} + +const SECRET_PATTERNS: &[(&str, &str)] = &[ + ("anthropic_api_key", r"sk-ant-[a-zA-Z0-9]{32,}"), + ("openai_api_key", r"sk-[a-zA-Z0-9]{48,}"), + ("aws_access_key_id", r"AKIA[0-9A-Z]{16}"), + ("github_token", r"(ghp_|gho_|ghu_|ghs_)[a-zA-Z0-9]{36,}"), + ( + "private_key_header", + r"-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----", + ), +]; + +pub(crate) fn scan_secrets_impl(path: &str) -> Vec { + let compiled: Vec<(&str, Regex)> = SECRET_PATTERNS + .iter() + .map(|(name, pat)| (*name, Regex::new(pat).expect("invalid pattern"))) + .collect(); + + let mut results = Vec::new(); + + for entry in Walk::new(path) { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let file_path = entry.path(); + if !file_path.is_file() { + continue; + } + + let path_str = file_path.to_string_lossy().to_string(); + + // Skip rust/target/ unconditionally regardless of .gitignore + if path_str.contains("rust/target") || path_str.contains("rust\\target") { + continue; + } + + // Read file bytes + let mut file = match fs::File::open(file_path) { + Ok(f) => f, + Err(_) => continue, + }; + let mut buf = Vec::new(); + if file.read_to_end(&mut buf).is_err() { + continue; + } + + // Skip binary files: check first 512 bytes for null bytes + if buf[..buf.len().min(512)].contains(&0) { + continue; + } + + // Skip non-UTF-8 files + let content = match String::from_utf8(buf) { + Ok(s) => s, + Err(_) => continue, + }; + + for (line_idx, line) in content.lines().enumerate() { + for (pattern_name, regex) in &compiled { + if let Some(mat) = regex.find(line) { + let matched = mat.as_str(); + let masked = if matched.len() >= 4 { + format!("{}***", &matched[..4]) + } else { + "***".to_string() + }; + results.push(SecretMatch { + file: path_str.clone(), + line: line_idx + 1, + pattern_name: pattern_name.to_string(), + masked_value: masked, + }); + } + } + } + } + + results +} + +// --------------------------------------------------------------------------- +// PyO3 module — thin wrappers; all logic lives in the pub(crate) functions +// --------------------------------------------------------------------------- -/// A Python module implemented in Rust. #[pymodule] mod _devbrief_core { use pyo3::prelude::*; - /// Formats the sum of two numbers as string. + #[pymodule_export] + use super::EnvDiff; + #[pymodule_export] + use super::SecretMatch; + + /// Compare .env and .env.example key sets. + /// Returns an empty diff (not an error) when either file is absent. #[pyfunction] - fn sum_as_string(a: usize, b: usize) -> PyResult { - Ok((a + b).to_string()) + fn diff_env_files(env_path: &str, example_path: &str) -> super::EnvDiff { + super::diff_env_files_impl(env_path, example_path) + } + + /// Walk `path` recursively (respecting .gitignore) and return secret matches. + #[pyfunction] + fn scan_secrets(path: &str) -> Vec { + super::scan_secrets_impl(path) + } +} + +// --------------------------------------------------------------------------- +// Rust unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + // ── parse_env_keys ──────────────────────────────────────────────────── + + #[test] + fn test_parse_env_keys_empty_input() { + assert!(parse_env_keys("").is_empty()); + } + + #[test] + fn test_parse_env_keys_skips_comment_lines() { + let keys = parse_env_keys("# comment=yes\nAPI_KEY=abc\n"); + assert!(!keys.contains("# comment=yes")); + assert!(keys.contains("API_KEY")); + assert_eq!(keys.len(), 1); + } + + #[test] + fn test_parse_env_keys_skips_blank_lines() { + let keys = parse_env_keys("\n\nDB_URL=postgres\n\n"); + assert!(keys.contains("DB_URL")); + assert_eq!(keys.len(), 1); + } + + #[test] + fn test_parse_env_keys_trims_key_whitespace() { + // " MY_KEY = value " → key should be "MY_KEY" + let keys = parse_env_keys(" MY_KEY = value \n"); + assert!(keys.contains("MY_KEY"), "got: {:?}", keys); + assert_eq!(keys.len(), 1); + } + + #[test] + fn test_parse_env_keys_deduplicates() { + // HashSet: repeated key counts once + let keys = parse_env_keys("DUPE=first\nDUPE=second\n"); + assert!(keys.contains("DUPE")); + assert_eq!(keys.len(), 1); + } + + // ── diff_env_files_impl ─────────────────────────────────────────────── + + #[test] + fn test_diff_key_missing_from_env() { + let dir = tempdir().unwrap(); + let env = dir.path().join(".env"); + let example = dir.path().join(".env.example"); + fs::write(&env, "API_KEY=abc\n").unwrap(); + fs::write(&example, "API_KEY=\nDB_URL=\n").unwrap(); + + let result = diff_env_files_impl(env.to_str().unwrap(), example.to_str().unwrap()); + + assert!(result.missing_from_env.contains(&"DB_URL".to_string())); + assert!(result.undocumented_in_example.is_empty()); + } + + #[test] + fn test_diff_undocumented_key_in_example() { + let dir = tempdir().unwrap(); + let env = dir.path().join(".env"); + let example = dir.path().join(".env.example"); + fs::write(&env, "API_KEY=abc\nSECRET_VAR=xyz\n").unwrap(); + fs::write(&example, "API_KEY=\n").unwrap(); + + let result = diff_env_files_impl(env.to_str().unwrap(), example.to_str().unwrap()); + + assert!( + result + .undocumented_in_example + .contains(&"SECRET_VAR".to_string()) + ); + assert!(result.missing_from_env.is_empty()); + } + + #[test] + fn test_diff_clean_state_returns_empty() { + let dir = tempdir().unwrap(); + let env = dir.path().join(".env"); + let example = dir.path().join(".env.example"); + fs::write(&env, "API_KEY=abc\nDB_URL=postgres\n").unwrap(); + fs::write(&example, "API_KEY=\nDB_URL=\n").unwrap(); + + let result = diff_env_files_impl(env.to_str().unwrap(), example.to_str().unwrap()); + + assert!(result.missing_from_env.is_empty()); + assert!(result.undocumented_in_example.is_empty()); + } + + // ── scan_secrets_impl ───────────────────────────────────────────────── + + #[test] + fn test_scan_aws_access_key_matches() { + let dir = tempdir().unwrap(); + // AKIA + 16 uppercase/digit chars = valid aws_access_key_id + fs::write( + dir.path().join("config.py"), + "AWS_ACCESS_KEY_ID = \"AKIAIOSFODNN7EXAMPLE\"\n", + ) + .unwrap(); + + let results = scan_secrets_impl(dir.path().to_str().unwrap()); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].pattern_name, "aws_access_key_id"); + assert_eq!(results[0].masked_value, "AKIA***"); + assert_eq!(results[0].line, 1); + } + + #[test] + fn test_scan_anthropic_key_matches() { + let dir = tempdir().unwrap(); + // sk-ant- + 32 alphanumeric chars + fs::write( + dir.path().join("secrets.txt"), + "ANTHROPIC_API_KEY=sk-ant-abcdefghijklmnopqrstuvwxyz123456\n", + ) + .unwrap(); + + let results = scan_secrets_impl(dir.path().to_str().unwrap()); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].pattern_name, "anthropic_api_key"); + assert_eq!(results[0].masked_value, "sk-a***"); + } + + #[test] + fn test_scan_private_key_header_matches() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("id_rsa"), + "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQ\n", + ) + .unwrap(); + + let results = scan_secrets_impl(dir.path().to_str().unwrap()); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].pattern_name, "private_key_header"); + assert_eq!(results[0].masked_value, "----***"); + } + + #[test] + fn test_scan_clean_file_no_match() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("main.py"), + "# No secrets here\nprint('hello world')\n", + ) + .unwrap(); + + let results = scan_secrets_impl(dir.path().to_str().unwrap()); + + assert!(results.is_empty()); } } diff --git a/src/devbrief/_devbrief_core.pyi b/src/devbrief/_devbrief_core.pyi new file mode 100644 index 0000000..a62afd2 --- /dev/null +++ b/src/devbrief/_devbrief_core.pyi @@ -0,0 +1,49 @@ +"""Type stubs for the devbrief Rust extension (_devbrief_core). + +These declarations mirror the #[pyclass] / #[pyfunction] definitions in +rust/src/lib.rs and are consumed by IDEs and static type checkers (mypy, +pyright). The compiled .so provides no inline annotations on its own. +""" + +class EnvDiff: + """Result of comparing .env and .env.example key sets.""" + + missing_from_env: list[str] + """Keys present in .env.example but absent from .env.""" + + undocumented_in_example: list[str] + """Keys present in .env but absent from .env.example.""" + +class SecretMatch: + """A line in a committed file that matched a secret pattern.""" + + file: str + """Path to the file containing the match.""" + + line: int + """1-indexed line number of the match.""" + + pattern_name: str + """Name of the pattern that matched (e.g. 'aws_access_key_id').""" + + masked_value: str + """First 4 characters of the matched value followed by '***'.""" + +def diff_env_files(env_path: str, example_path: str) -> EnvDiff: + """Compare key sets between *env_path* and *example_path*. + + Returns an empty :class:`EnvDiff` (no error) if either file is absent. + Blank lines and comment lines (``#``) are ignored. Only the left-hand + side of each ``KEY=value`` pair is considered. + """ + ... + +def scan_secrets(path: str) -> list[SecretMatch]: + """Walk *path* recursively and return lines matching secret patterns. + + The walk respects ``.gitignore`` rules (via the ``ignore`` crate). + Binary files and ``rust/target/`` are skipped unconditionally. + Five patterns are checked: ``anthropic_api_key``, ``openai_api_key``, + ``aws_access_key_id``, ``github_token``, ``private_key_header``. + """ + ... diff --git a/src/devbrief/cli.py b/src/devbrief/cli.py index 45b54b1..9b25a42 100644 --- a/src/devbrief/cli.py +++ b/src/devbrief/cli.py @@ -1,7 +1,10 @@ +from importlib.metadata import version as _pkg_version + from dotenv import load_dotenv import typer from devbrief.commands.auth import auth_command +from devbrief.commands.env import env_command from devbrief.commands.logs import logs_command from devbrief.commands.repo import repo_command @@ -9,6 +12,28 @@ app = typer.Typer(help="Project situational awareness.") + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"devbrief {_pkg_version('devbrief')}") + raise typer.Exit() + + +@app.callback() +def _cli( + version: bool = typer.Option( + False, + "--version", + "-v", + callback=_version_callback, + is_eager=True, + help="Show version and exit.", + ), +) -> None: + pass + + app.command("repo")(repo_command) app.command("auth")(auth_command) app.command("logs")(logs_command) +app.command("env")(env_command) diff --git a/src/devbrief/commands/env.py b/src/devbrief/commands/env.py new file mode 100644 index 0000000..38e6252 --- /dev/null +++ b/src/devbrief/commands/env.py @@ -0,0 +1,272 @@ +"""devbrief env — Project environment health checks.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING, Annotated + +import typer +from rich.console import Console + +if TYPE_CHECKING: + from devbrief._devbrief_core import EnvDiff, SecretMatch + +# --------------------------------------------------------------------------- +# Rust extension — imported at module level for easy mocking in tests. +# Falls back gracefully if the extension is not compiled. +# +# Variables are declared with explicit Optional[Callable] types so that: +# • the IDE resolves EnvDiff / SecretMatch via the .pyi stub, and +# • no `type: ignore` is needed on the None fallback. +# --------------------------------------------------------------------------- + +_diff_env_files: Callable[[str, str], "EnvDiff"] | None = None +_scan_secrets: Callable[[str], "list[SecretMatch]"] | None = None + +try: + from devbrief._devbrief_core import diff_env_files as _diff_env_files + from devbrief._devbrief_core import scan_secrets as _scan_secrets +except ImportError: + pass # already None — Rust extension not compiled + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +REQUIRED_GITIGNORE_ENTRIES: list[str] = [ + ".env", + ".env.local", + ".env.*.local", + "*.pem", + "*.key", + "id_rsa", + "id_rsa.*", + ".aws/credentials", +] + +_console = Console() + + +# --------------------------------------------------------------------------- +# Check helpers — each returns (errors, warnings) +# --------------------------------------------------------------------------- + + +def _check_gitignore(root: Path, quiet: bool) -> tuple[int, int]: + """Check .gitignore presence and required entries.""" + errors = 0 + warnings = 0 + gitignore = root / ".gitignore" + + if not gitignore.exists(): + errors += 1 + if quiet: + print("ERROR .gitignore not found") + else: + _console.print( + " [red]\u274c[/red] [bold].gitignore[/bold] [red]not found[/red]" + ) + return errors, warnings + + if quiet: + print("OK .gitignore present") + else: + _console.print( + " [green]\u2705[/green] [bold].gitignore[/bold] present" + ) + + lines = { + line.strip() + for line in gitignore.read_text(encoding="utf-8", errors="replace").splitlines() + } + for entry in REQUIRED_GITIGNORE_ENTRIES: + if entry not in lines: + warnings += 1 + if quiet: + print(f"WARN .gitignore missing entry: {entry}") + else: + _console.print( + f" [yellow]\u26a0\ufe0f[/yellow] [bold].gitignore[/bold]" + f" missing entry: {entry}" + ) + + return errors, warnings + + +def _check_env_drift(root: Path, quiet: bool) -> tuple[int, int]: + """Check .env vs .env.example key drift via Rust extension.""" + errors = 0 + warnings = 0 + + if _diff_env_files is None: + if quiet: + print("INFO .env drift Rust extension unavailable, skipping") + else: + _console.print( + " [dim]\u2139\ufe0f .env drift Rust extension unavailable, skipping[/dim]" + ) + return errors, warnings + + env_path = root / ".env" + example_path = root / ".env.example" + + if not env_path.exists(): + if quiet: + print("INFO .env drift .env not found, skipping") + else: + _console.print( + " [dim]\u2139\ufe0f .env drift .env not found, skipping[/dim]" + ) + return errors, warnings + + if not example_path.exists(): + if quiet: + print("INFO .env drift .env.example not found, skipping") + else: + _console.print( + " [dim]\u2139\ufe0f .env drift .env.example not found, skipping[/dim]" + ) + return errors, warnings + + diff = _diff_env_files(str(env_path), str(example_path)) + + for key in diff.missing_from_env: + warnings += 1 + if quiet: + print(f"WARN .env drift {key} missing from .env") + else: + _console.print( + f" [yellow]\u26a0\ufe0f[/yellow] [bold].env drift[/bold]" + f" {key} missing from .env" + ) + + for key in diff.undocumented_in_example: + warnings += 1 + if quiet: + print(f"WARN .env drift {key} undocumented in .env.example") + else: + _console.print( + f" [yellow]\u26a0\ufe0f[/yellow] [bold].env drift[/bold]" + f" {key} undocumented in .env.example" + ) + + if not diff.missing_from_env and not diff.undocumented_in_example: + if quiet: + print("OK .env drift all .env.example keys present") + else: + _console.print( + " [green]\u2705[/green] [bold].env drift[/bold] all .env.example keys present" + ) + + return errors, warnings + + +def _check_secrets(root: Path, quiet: bool) -> tuple[int, int]: + """Scan committed files for secret patterns via Rust extension.""" + errors = 0 + warnings = 0 + + if _scan_secrets is None: + if quiet: + print("INFO Secret scan Rust extension unavailable, skipping") + else: + _console.print( + " [dim]\u2139\ufe0f Secret scan Rust extension unavailable, skipping[/dim]" + ) + return errors, warnings + + matches = _scan_secrets(str(root)) + + for match in matches: + errors += 1 + loc = f"{match.file}:{match.line}" + if quiet: + print( + f"ERROR Secret detected {loc} \u2014" + f" {match.pattern_name} ({match.masked_value})" + ) + else: + _console.print( + f" [red]\u274c[/red] [bold]Secret detected[/bold] " + f"[cyan]{loc}[/cyan] \u2014 {match.pattern_name}" + f" ([yellow]{match.masked_value}[/yellow])" + ) + + return errors, warnings + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def env_command( + path: Annotated[ + Path, + typer.Argument( + help="Project root to scan. Default: current working directory." + ), + ] = Path("."), + strict: Annotated[ + bool, + typer.Option( + "--strict", help="Treat warnings as errors. Exit 1 if any warnings." + ), + ] = False, + quiet: Annotated[ + bool, + typer.Option( + "--quiet", help="Suppress Rich formatting. Plain text output only." + ), + ] = False, +) -> None: + """Check project environment health: .gitignore, .env drift, and secret scanning.""" + root = path.resolve() + + if not quiet: + _console.print() + + total_errors = 0 + total_warnings = 0 + + e, w = _check_gitignore(root, quiet) + total_errors += e + total_warnings += w + + if not quiet: + _console.print() + + e, w = _check_env_drift(root, quiet) + total_errors += e + total_warnings += w + + if not quiet: + _console.print() + + e, w = _check_secrets(root, quiet) + total_errors += e + total_warnings += w + + if not quiet: + _console.print() + + # Summary line + parts: list[str] = [] + if total_errors: + parts.append(f"{total_errors} error{'s' if total_errors != 1 else ''}") + if total_warnings: + parts.append(f"{total_warnings} warning{'s' if total_warnings != 1 else ''}") + summary = " \u00b7 ".join(parts) if parts else "all checks passed" + + if quiet: + print(summary) + elif total_errors: + _console.print(f" [bold red]{summary}[/bold red]") + elif total_warnings: + _console.print(f" [bold yellow]{summary}[/bold yellow]") + else: + _console.print(f" [bold green]{summary}[/bold green]") + + if total_errors or (strict and total_warnings): + raise typer.Exit(code=1) diff --git a/src/devbrief/py.typed b/src/devbrief/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/MANUAL_QA.md b/tests/MANUAL_QA.md new file mode 100644 index 0000000..71e3990 --- /dev/null +++ b/tests/MANUAL_QA.md @@ -0,0 +1,178 @@ +# Manual QA — devbrief env v0.4.0 + +Run these from the **devbrief repo root** after `uv run maturin develop`. +Each scenario is one terminal command and one expected result. + +--- + +## Prerequisites + +```bash +uv run maturin develop +``` + +--- + +## Check 1 — .gitignore audit + +### Scenario 1: clean run on devbrief itself + +```bash +uv run devbrief env . +``` + +**Expected:** `.gitignore` shows ✅ present. Any missing advisory entries show ⚠️. No errors on this check since devbrief has a `.gitignore`. + +--- + +### Scenario 2: missing .gitignore + +```bash +mkdir /tmp/test-env-empty && uv run devbrief env /tmp/test-env-empty +``` + +**Expected:** ❌ `.gitignore` not found. Exit code 1. + +--- + +### Scenario 3: .gitignore present but missing entries + +```bash +mkdir /tmp/test-env-partial +echo "node_modules" > /tmp/test-env-partial/.gitignore +uv run devbrief env /tmp/test-env-partial +``` + +**Expected:** ✅ `.gitignore` present, then ⚠️ for each missing advisory entry (`.env`, `*.pem`, etc.). + +--- + +## Check 2 — .env drift + +### Scenario 4: clean drift — keys match + +```bash +mkdir /tmp/test-env-drift +printf "API_KEY=\nDB_URL=\n" > /tmp/test-env-drift/.env.example +printf "API_KEY=abc\nDB_URL=postgres://...\n" > /tmp/test-env-drift/.env +echo "node_modules" > /tmp/test-env-drift/.gitignore +uv run devbrief env /tmp/test-env-drift +``` + +**Expected:** ✅ `.env drift` — all `.env.example` keys present. + +--- + +### Scenario 5: key missing from .env + +```bash +mkdir /tmp/test-env-missing +printf "API_KEY=\nDB_URL=\nSECRET=\n" > /tmp/test-env-missing/.env.example +printf "API_KEY=abc\n" > /tmp/test-env-missing/.env +echo "node_modules" > /tmp/test-env-missing/.gitignore +uv run devbrief env /tmp/test-env-missing +``` + +**Expected:** ⚠️ `DB_URL` and `SECRET` missing from `.env`. + +--- + +### Scenario 6: undocumented key in .env + +```bash +mkdir /tmp/test-env-undoc +printf "API_KEY=\n" > /tmp/test-env-undoc/.env.example +printf "API_KEY=abc\nMY_SECRET_VAR=xyz\n" > /tmp/test-env-undoc/.env +echo "node_modules" > /tmp/test-env-undoc/.gitignore +uv run devbrief env /tmp/test-env-undoc +``` + +**Expected:** ⚠️ `MY_SECRET_VAR` undocumented in `.env.example`. + +--- + +### Scenario 7: no .env at all + +```bash +mkdir /tmp/test-env-noenv +printf "API_KEY=\n" > /tmp/test-env-noenv/.env.example +echo "node_modules" > /tmp/test-env-noenv/.gitignore +uv run devbrief env /tmp/test-env-noenv +``` + +**Expected:** INFO — `.env` not found, skipping. Not an error. + +--- + +## Check 3 — Secret detection + +### Scenario 8: AWS access key in a Python file + +```bash +mkdir /tmp/test-secrets +echo "node_modules" > /tmp/test-secrets/.gitignore +echo 'AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"' > /tmp/test-secrets/config.py +uv run devbrief env /tmp/test-secrets +``` + +**Expected:** ❌ Secret detected — `config.py:1` — `aws_access_key_id` — `AKIA***`. Exit code 1. + +--- + +### Scenario 9: secret in a gitignored file — must NOT be flagged + +```bash +mkdir /tmp/test-secrets-ignored +printf ".env\n" > /tmp/test-secrets-ignored/.gitignore +printf 'ANTHROPIC_API_KEY=sk-ant-abc123def456ghi789jkl012mno345pqr678stu\n' \ + > /tmp/test-secrets-ignored/.env +uv run devbrief env /tmp/test-secrets-ignored +``` + +**Expected:** No secret detection error. `.env` is gitignored so the Rust scanner skips it. + +--- + +### Scenario 10: private key header + +```bash +mkdir /tmp/test-privkey +echo "node_modules" > /tmp/test-privkey/.gitignore +echo "-----BEGIN RSA PRIVATE KEY-----" > /tmp/test-privkey/id_rsa.pub +uv run devbrief env /tmp/test-privkey +``` + +**Expected:** ❌ Secret detected — `id_rsa.pub:1` — `private_key_header`. Exit code 1. + +--- + +## Flags + +### Scenario 11: --strict promotes warnings to errors + +```bash +uv run devbrief env /tmp/test-env-partial --strict +echo "Exit code: $?" +``` + +**Expected:** Exit code 1 even when only warnings are present (missing advisory entries). + +--- + +### Scenario 12: --quiet suppresses Rich output + +```bash +uv run devbrief env . --quiet +``` + +**Expected:** Plain text output. No Rich markup, no color codes, no emoji padding. + +--- + +## Cleanup + +```bash +rm -rf /tmp/test-env-empty /tmp/test-env-partial /tmp/test-env-drift \ + /tmp/test-env-missing /tmp/test-env-undoc /tmp/test-env-noenv \ + /tmp/test-secrets /tmp/test-secrets-ignored /tmp/test-privkey +``` diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 0000000..20f7476 --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,350 @@ +"""Tests for devbrief env subcommand — Python orchestration layer only. + +Rust functions (_diff_env_files, _scan_secrets) are mocked; Rust internals +are not tested here. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from devbrief.cli import app + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + + +def _make_env_diff( + missing_from_env: list[str] | None = None, + undocumented_in_example: list[str] | None = None, +) -> MagicMock: + diff = MagicMock() + diff.missing_from_env = missing_from_env or [] + diff.undocumented_in_example = undocumented_in_example or [] + return diff + + +def _make_secret_match( + file: str = "src/config.py", + line: int = 12, + pattern_name: str = "aws_access_key_id", + masked_value: str = "AKIA***", +) -> MagicMock: + m = MagicMock() + m.file = file + m.line = line + m.pattern_name = pattern_name + m.masked_value = masked_value + return m + + +def _invoke(tmp_path: Path, extra_args: list[str] | None = None) -> object: + runner = CliRunner() + args = ["env", str(tmp_path)] + (extra_args or []) + return runner.invoke(app, args) + + +# --------------------------------------------------------------------------- +# Check 1: .gitignore presence and entry audit (Python-only) +# --------------------------------------------------------------------------- + + +class TestGitignoreCheck: + def test_missing_gitignore_is_error(self, tmp_path: Path) -> None: + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert result.exit_code == 1 + assert "not found" in result.output + + def test_present_gitignore_shows_ok(self, tmp_path: Path) -> None: + full_ignore = "\n".join( + [ + ".env", + ".env.local", + ".env.*.local", + "*.pem", + "*.key", + "id_rsa", + "id_rsa.*", + ".aws/credentials", + ] + ) + _write(tmp_path / ".gitignore", full_ignore) + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert result.exit_code == 0 + assert "present" in result.output + + def test_missing_entries_produce_warnings(self, tmp_path: Path) -> None: + # .gitignore exists but is empty — all entries missing + _write(tmp_path / ".gitignore", "") + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + # Warnings only → exit 0 without --strict + assert result.exit_code == 0 + assert "missing entry" in result.output + + def test_missing_entries_with_strict_exits_1(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path, ["--strict"]) + assert result.exit_code == 1 + + def test_specific_missing_entry_named_in_output(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", ".env\n.env.local\n") + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert "*.pem" in result.output + + def test_quiet_mode_plain_text(self, tmp_path: Path) -> None: + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path, ["--quiet"]) + assert result.exit_code == 1 + # No Rich markup brackets in quiet output + assert "[red]" not in result.output + assert "not found" in result.output + + +# --------------------------------------------------------------------------- +# Check 2: .env vs .env.example drift +# --------------------------------------------------------------------------- + + +class TestEnvDriftCheck: + def test_missing_key_from_env_is_warning(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + _write(tmp_path / ".env", "") + _write(tmp_path / ".env.example", "") + diff = _make_env_diff(missing_from_env=["DATABASE_URL"]) + with patch("devbrief.commands.env._diff_env_files", return_value=diff): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert result.exit_code == 0 + assert "DATABASE_URL" in result.output + assert "missing from .env" in result.output + + def test_undocumented_key_is_warning(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + _write(tmp_path / ".env", "") + _write(tmp_path / ".env.example", "") + diff = _make_env_diff(undocumented_in_example=["SECRET_KEY"]) + with patch("devbrief.commands.env._diff_env_files", return_value=diff): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert result.exit_code == 0 + assert "SECRET_KEY" in result.output + assert "undocumented in .env.example" in result.output + + def test_clean_state_shows_ok(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + _write(tmp_path / ".env", "") + _write(tmp_path / ".env.example", "") + diff = _make_env_diff() + with patch("devbrief.commands.env._diff_env_files", return_value=diff): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert "all .env.example keys present" in result.output + + def test_missing_env_file_shows_info(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + # No .env file created + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert ".env not found, skipping" in result.output + + def test_missing_env_example_file_shows_info(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + _write(tmp_path / ".env", "FOO=bar\n") + # No .env.example created + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert ".env.example not found, skipping" in result.output + + def test_rust_unavailable_shows_info(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + _write(tmp_path / ".env", "FOO=bar\n") + _write(tmp_path / ".env.example", "FOO=\n") + with patch("devbrief.commands.env._diff_env_files", None): + with patch("devbrief.commands.env._scan_secrets", None): + result = _invoke(tmp_path) + assert "unavailable" in result.output + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Check 3: Secret pattern detection +# --------------------------------------------------------------------------- + + +class TestScanSecretsCheck: + def test_match_found_is_error(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + match = _make_secret_match( + file="src/config.py", + line=12, + pattern_name="aws_access_key_id", + masked_value="AKIA***", + ) + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[match]): + result = _invoke(tmp_path) + assert result.exit_code == 1 + assert "Secret detected" in result.output + assert "src/config.py:12" in result.output + assert "aws_access_key_id" in result.output + assert "AKIA***" in result.output + + def test_no_match_no_error(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + # Warnings only from empty gitignore, but no errors from secrets + assert "Secret detected" not in result.output + + def test_skips_gitignored_path_no_error(self, tmp_path: Path) -> None: + """When scan_secrets returns empty (gitignored file skipped), no errors reported.""" + _write(tmp_path / ".gitignore", "") + # Rust layer would skip the gitignored file; simulate with empty result + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert "Secret detected" not in result.output + + def test_skips_binary_no_error(self, tmp_path: Path) -> None: + """When scan_secrets returns empty (binary file skipped), no errors reported.""" + _write(tmp_path / ".gitignore", "") + # Rust layer would skip the binary file; simulate with empty result + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert "Secret detected" not in result.output + + def test_multiple_matches_all_reported(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + matches = [ + _make_secret_match(file="a.py", line=1, pattern_name="aws_access_key_id"), + _make_secret_match( + file="b.py", line=5, pattern_name="github_token", masked_value="ghp_***" + ), + ] + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=matches): + result = _invoke(tmp_path) + assert result.exit_code == 1 + assert "a.py:1" in result.output + assert "b.py:5" in result.output + + def test_rust_unavailable_shows_info(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") + with patch("devbrief.commands.env._diff_env_files", None): + with patch("devbrief.commands.env._scan_secrets", None): + result = _invoke(tmp_path) + assert "unavailable" in result.output + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Summary line +# --------------------------------------------------------------------------- + + +class TestSummaryLine: + def test_all_pass_message(self, tmp_path: Path) -> None: + full_ignore = "\n".join( + [ + ".env", + ".env.local", + ".env.*.local", + "*.pem", + "*.key", + "id_rsa", + "id_rsa.*", + ".aws/credentials", + ] + ) + _write(tmp_path / ".gitignore", full_ignore) + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path) + assert "all checks passed" in result.output + assert result.exit_code == 0 + + def test_summary_counts_errors_and_warnings(self, tmp_path: Path) -> None: + _write(tmp_path / ".gitignore", "") # 8 missing entries → 8 warnings + match = _make_secret_match() # 1 secret → 1 error + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[match]): + result = _invoke(tmp_path) + assert "1 error" in result.output + assert "warning" in result.output + assert result.exit_code == 1 + + def test_quiet_summary_plain(self, tmp_path: Path) -> None: + full_ignore = "\n".join( + [ + ".env", + ".env.local", + ".env.*.local", + "*.pem", + "*.key", + "id_rsa", + "id_rsa.*", + ".aws/credentials", + ] + ) + _write(tmp_path / ".gitignore", full_ignore) + with patch( + "devbrief.commands.env._diff_env_files", return_value=_make_env_diff() + ): + with patch("devbrief.commands.env._scan_secrets", return_value=[]): + result = _invoke(tmp_path, ["--quiet"]) + assert "all checks passed" in result.output + assert "[green]" not in result.output diff --git a/uv.lock b/uv.lock index 38eda5e..81418a4 100644 --- a/uv.lock +++ b/uv.lock @@ -208,7 +208,7 @@ wheels = [ [[package]] name = "devbrief" -version = "0.3.2" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "anthropic" },