diff --git a/.ai/stack-profiles/diffrat-cli.md b/.ai/stack-profiles/diffrat-cli.md index ce85180..42f3be5 100644 --- a/.ai/stack-profiles/diffrat-cli.md +++ b/.ai/stack-profiles/diffrat-cli.md @@ -19,6 +19,7 @@ pytest ruff format --check src tests ruff check . mypy . +bandit -r src/diffrat/checks.py src/diffrat/review_quality.py src/diffrat/scoring.py python -m diffrat --help diffrat --help ``` diff --git a/.github/workflows/validate-workflow-contracts.yml b/.github/workflows/validate-workflow-contracts.yml index 5b6def7..673dc76 100644 --- a/.github/workflows/validate-workflow-contracts.yml +++ b/.github/workflows/validate-workflow-contracts.yml @@ -46,5 +46,12 @@ jobs: - name: Mypy run: mypy . + - name: Bandit + run: > + bandit -r + src/diffrat/checks.py + src/diffrat/review_quality.py + src/diffrat/scoring.py + - name: Pytest run: pytest tests/ -q diff --git a/CHANGELOG.md b/CHANGELOG.md index be23394..675274e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `--check` bandit false positives on Diffrat itself (`subprocess` import/run in + `checks.py`; pillar id `"maintainable"` in `review_quality.py`) - Track `.ai/ideas/implemented/` docs referenced by `decisions.md` ### Changed @@ -18,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `./scripts/setup-ai-workflow.sh` first - CI runs `ruff format --check src tests`, `ruff check .`, and `mypy .` in addition to pytest (matches README / stack-profile quality gates) +- CI / `[dev]` include `bandit` on `--check` dogfood modules (`checks.py`, + `review_quality.py`, `scoring.py`) - One-shot `ruff format` on `src/` and `tests/` ## [1.1.1] - 2026-08-09 diff --git a/README.md b/README.md index 7bbfd7c..c265a65 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ pytest ruff format --check src tests ruff check . mypy . +bandit -r src/diffrat/checks.py src/diffrat/review_quality.py src/diffrat/scoring.py ``` ## Architecture and context diff --git a/pyproject.toml b/pyproject.toml index e7cd198..ce161ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ Issues = "https://github.com/szymoniwacz/diffrat/issues" Changelog = "https://github.com/szymoniwacz/diffrat/blob/main/CHANGELOG.md" [project.optional-dependencies] -dev = ["pytest>=8.0", "mypy>=1.8", "ruff>=0.4"] +dev = ["pytest>=8.0", "mypy>=1.8", "ruff>=0.4", "bandit>=1.7"] [project.scripts] diffrat = "diffrat.__main__:main" diff --git a/src/diffrat/checks.py b/src/diffrat/checks.py index 9fe49a9..8911b59 100644 --- a/src/diffrat/checks.py +++ b/src/diffrat/checks.py @@ -4,7 +4,7 @@ import shlex import shutil -import subprocess +import subprocess # nosec B404 import sys from dataclasses import dataclass from pathlib import PurePosixPath @@ -254,7 +254,7 @@ def run_checks( ) ) continue - completed = subprocess.run( + completed = subprocess.run( # nosec B603 list(spec.argv), cwd=cwd, capture_output=True, diff --git a/src/diffrat/review_quality.py b/src/diffrat/review_quality.py index ebc2a16..23ee827 100644 --- a/src/diffrat/review_quality.py +++ b/src/diffrat/review_quality.py @@ -12,7 +12,10 @@ PillarId = Literal["understand", "focused", "maintainable"] PillarStatus = Literal["ok", "warn", "risk"] -_DEFAULT_PILLAR: PillarId = "maintainable" +# Pillar id label (JSON/API contract); not a credential. +PILLAR_MAINTAINABLE: PillarId = "maintainable" # nosec B105 + +_DEFAULT_PILLAR: PillarId = PILLAR_MAINTAINABLE @dataclass(frozen=True, slots=True) @@ -43,7 +46,7 @@ class ReviewQualityPillarResult: label="One thing well", ), ReviewQualityPillar( - id="maintainable", + id=PILLAR_MAINTAINABLE, label="Safe to change in six months", ), ) @@ -66,22 +69,22 @@ class ReviewQualityPillarResult: "wip_commits": "focused", "mixed_concerns": "focused", # maintainable — safety, tests, dependencies, fragile patterns - "security_sensitive_paths": "maintainable", - "ci_workflow_paths": "maintainable", - "possible_secret": "maintainable", - "dangerous_call": "maintainable", - "config_or_deps": "maintainable", - "suspicious_constant_change": "maintainable", - "tests_touched": "maintainable", - "source_without_tests": "maintainable", - "source_heavy_without_tests": "maintainable", - "ci_without_tests": "maintainable", - "missing_test_file": "maintainable", - "lockfile_without_manifest": "maintainable", - "manifest_without_lockfile": "maintainable", - "debug_leftover": "maintainable", - "broad_exception": "maintainable", - "hardcoded_url_or_ip": "maintainable", + "security_sensitive_paths": PILLAR_MAINTAINABLE, + "ci_workflow_paths": PILLAR_MAINTAINABLE, + "possible_secret": PILLAR_MAINTAINABLE, + "dangerous_call": PILLAR_MAINTAINABLE, + "config_or_deps": PILLAR_MAINTAINABLE, + "suspicious_constant_change": PILLAR_MAINTAINABLE, + "tests_touched": PILLAR_MAINTAINABLE, + "source_without_tests": PILLAR_MAINTAINABLE, + "source_heavy_without_tests": PILLAR_MAINTAINABLE, + "ci_without_tests": PILLAR_MAINTAINABLE, + "missing_test_file": PILLAR_MAINTAINABLE, + "lockfile_without_manifest": PILLAR_MAINTAINABLE, + "manifest_without_lockfile": PILLAR_MAINTAINABLE, + "debug_leftover": PILLAR_MAINTAINABLE, + "broad_exception": PILLAR_MAINTAINABLE, + "hardcoded_url_or_ip": PILLAR_MAINTAINABLE, } diff --git a/tests/test_bandit_self.py b/tests/test_bandit_self.py new file mode 100644 index 0000000..378ad29 --- /dev/null +++ b/tests/test_bandit_self.py @@ -0,0 +1,28 @@ +"""Dogfood: bandit stays clean on modules that --check commonly scans.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_BANDIT_TARGETS = ( + "src/diffrat/checks.py", + "src/diffrat/review_quality.py", + "src/diffrat/scoring.py", +) + + +@pytest.mark.skipif(shutil.which("bandit") is None, reason="bandit not installed") +def test_bandit_clean_on_check_dogfood_modules() -> None: + completed = subprocess.run( + ["bandit", "-r", *_BANDIT_TARGETS], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr