diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..83251cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +# Self-test CI for the dev-rig. +# +# The rig's other workflows are all reusable (workflow_call) and were never +# exercised end-to-end. This workflow invokes each of them against the rig +# itself — using LOCAL refs (./.github/workflows/*.yml) so a PR validates its +# own versions of the workflows, not the ones already on main. It both proves +# the reusable workflows work and guards them against silent regressions. + +name: CI (self-test) + +on: + push: + branches: [main] + pull_request: # all PRs, regardless of base — so stacked branches self-test too + +jobs: + lint: + uses: ./.github/workflows/lint.yml + with: + source-dirs: "src/legionforge_dev_rig" + extra-mypy-deps: "types-PyYAML httpx respx pytest" + + test: + uses: ./.github/workflows/test.yml + with: + coverage-source: "legionforge_dev_rig" + coverage-threshold: 80 + + sast: + uses: ./.github/workflows/sast.yml + with: + source-dirs: "src/legionforge_dev_rig" + semgrep-configs: "p/python" + permissions: + security-events: write + + audit: + uses: ./.github/workflows/audit.yml + + supply-chain: + uses: ./.github/workflows/supply-chain.yml + with: + # Pull the risky-exec ruleset from THIS branch so self-CI tests the PR's + # rules, not whatever is on main. + rig-ref: ${{ github.head_ref || github.ref_name }} + secrets: inherit + + secrets: + uses: ./.github/workflows/secrets.yml + + sbom: + uses: ./.github/workflows/sbom.yml diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index 07722db..59dc378 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -74,7 +74,7 @@ jobs: run: | semgrep --config .dev-rig/semgrep/legionforge-risky-exec.yml \ --error --metrics=off \ - --exclude .dev-rig . + --exclude .dev-rig --exclude .claude --exclude node_modules --exclude semgrep . # ── Optional Socket.dev behavioral npm scan ────────────────────────────────── # Self-skips when no SOCKET_SECURITY_API_KEY secret is provided, so callers diff --git a/pyproject.toml b/pyproject.toml index 4fa14ef..003cd56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,18 @@ analysis = [ "pre-commit>=3.7", "types-PyYAML", ] +# The `dev` extra is the contract the reusable lint.yml / test.yml workflows +# install (`pip install -e .[dev]`). Consuming projects provide their own; the +# rig defines one so it can self-test those workflows (see ci.yml). +dev = [ + "ruff>=0.4", + "bandit[toml]>=1.7", + "mypy>=1.10", + "pytest>=8", + "pytest-asyncio>=0.23", + "pytest-cov>=5", + "types-PyYAML", +] [tool.pytest.ini_options] asyncio_mode = "auto" @@ -43,6 +55,11 @@ ignore = [ "S607", # partial executable path — acceptable for system binaries ] +[tool.ruff.lint.per-file-ignores] +# Tests and examples legitimately use `assert` (S101) for verification. +"tests/**" = ["S101"] +"examples/**" = ["S101"] + [tool.bandit] exclude_dirs = [".venv", "venv", ".git", "tests"] skips = [ diff --git a/scripts/audit.ps1 b/scripts/audit.ps1 index 1b80923..4de4dae 100644 --- a/scripts/audit.ps1 +++ b/scripts/audit.ps1 @@ -146,7 +146,7 @@ if (Get-Command osv-scanner -ErrorAction SilentlyContinue) { # Runs only when the repo contains shell scripts. $shellFiles = Get-ChildItem -Path $ProjectPath -Recurse -File -Include *.sh, *.bash -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -notmatch '[\\/](\.git|\.venv|node_modules)[\\/]' } + Where-Object { $_.FullName -notmatch '[\\/](\.git|\.venv|node_modules|\.claude)[\\/]' } if ($shellFiles) { if (Get-Command shellcheck -ErrorAction SilentlyContinue) { Invoke-Tool "shellcheck" { @@ -193,7 +193,8 @@ if (Test-Path $riskyRules) { -v "${ProjectPath}:/src" ` -v "${rigSemgrep}:/rules:ro" ` semgrep/semgrep ` - semgrep --config /rules/legionforge-risky-exec.yml /src --error + semgrep --config /rules/legionforge-risky-exec.yml /src --error ` + --exclude .claude --exclude .git --exclude node_modules --exclude semgrep } } diff --git a/scripts/audit.sh b/scripts/audit.sh index b062f63..b53eff1 100644 --- a/scripts/audit.sh +++ b/scripts/audit.sh @@ -113,7 +113,8 @@ fi shell_files=() while IFS= read -r f; do shell_files+=("$f"); done < <( find "$PROJECT" -type f \( -name '*.sh' -o -name '*.bash' \) \ - -not -path '*/.git/*' -not -path '*/.venv/*' -not -path '*/node_modules/*' 2>/dev/null + -not -path '*/.git/*' -not -path '*/.venv/*' -not -path '*/node_modules/*' \ + -not -path '*/.claude/*' 2>/dev/null ) if [[ ${#shell_files[@]} -gt 0 ]]; then if command -v shellcheck > /dev/null 2>&1; then @@ -156,7 +157,8 @@ if [[ -f "$RISKY_RULES" ]]; then -v "${WIN_PROJECT}:/src" \ -v "${WIN_RIG_SEMGREP}:/rules:ro" \ semgrep/semgrep \ - semgrep --config /rules/legionforge-risky-exec.yml /src --error + semgrep --config /rules/legionforge-risky-exec.yml /src --error \ + --exclude .claude --exclude .git --exclude node_modules --exclude semgrep fi # ── Summary ─────────────────────────────────────────────────────────────────── diff --git a/semgrep/legionforge-risky-exec.yml b/semgrep/legionforge-risky-exec.yml index 42b5abd..6b59686 100644 --- a/semgrep/legionforge-risky-exec.yml +++ b/semgrep/legionforge-risky-exec.yml @@ -7,6 +7,12 @@ # Scope: shell (.sh/.bash), PowerShell (.ps1/.psm1), and CI YAML, matched in # Semgrep "generic" mode so a single ruleset spans every file type. # +# Comment handling: rules use pattern-regex (raw text), which bypasses generic +# mode's comment tokenizer — so each rule ANDs a `pattern-not-regex` excluding +# full-line #-comments. This keeps documented usage examples (e.g. a +# "# curl ... | bash" install note) from tripping the rules; only executable +# lines are flagged. (# is the line-comment char in shell, PowerShell, and YAML.) +# # Run: # semgrep --config semgrep/legionforge-risky-exec.yml --error # Wired into scripts/audit.sh and .github/workflows/supply-chain.yml. @@ -25,6 +31,7 @@ rules: include: ["*.sh", "*.bash", "*.yml", "*.yaml", "*.zsh"] patterns: - pattern-regex: '(curl|wget)\b[^\n|]*\|[^\n]*\b(sh|bash|zsh|dash)\b' + - pattern-not-regex: '(?m)^\s*#.*' # ── PowerShell download-cradle ─────────────────────────────────────────────── - id: lf-powershell-download-cradle @@ -42,6 +49,7 @@ rules: - pattern-regex: '(?i)(New-Object\s+Net\.WebClient)[^\n]*\.DownloadString' - pattern-regex: '(?i)DownloadString\([^\n]*\)[^\n]*\|\s*(iex|Invoke-Expression)' - pattern-regex: '(?i)(iwr|Invoke-WebRequest|curl)\b[^\n|]*\|\s*(iex|Invoke-Expression)' + - pattern-not-regex: '(?m)^\s*#.*' # ── Decode-and-execute (obfuscation) ───────────────────────────────────────── - id: lf-decode-and-exec @@ -58,6 +66,7 @@ rules: - pattern-regex: '\bbase64\s+(-d|--decode)\b[^\n]*\|[^\n]*\b(sh|bash|python|perl|node)\b' - pattern-regex: '(?i)\[Convert\]::FromBase64String\([^\n]*\)[^\n]*(iex|Invoke-Expression)' - pattern-regex: '(?i)-(enc|encodedcommand)\b' + - pattern-not-regex: '(?m)^\s*#.*' # ── eval over fetched / command-substituted content ────────────────────────── - id: lf-eval-remote @@ -71,6 +80,7 @@ rules: include: ["*.sh", "*.bash", "*.zsh", "*.yml", "*.yaml"] patterns: - pattern-regex: '\beval\b[^\n]*\$\((curl|wget)\b' + - pattern-not-regex: '(?m)^\s*#.*' # ── TLS verification disabled on download ──────────────────────────────────── - id: lf-tls-verification-disabled @@ -88,6 +98,7 @@ rules: - pattern-regex: '\bcurl\b[^\n]*\s(-k|--insecure)\b' - pattern-regex: '\bwget\b[^\n]*--no-check-certificate\b' - pattern-regex: '(?i)ServerCertificateValidationCallback\s*=\s*\{?\s*\$?true' + - pattern-not-regex: '(?m)^\s*#.*' # ── Install straight from a VCS URL / git ref ──────────────────────────────── - id: lf-install-from-vcs-url @@ -105,6 +116,7 @@ rules: - pattern-regex: '\bpip(3)?\s+install\b[^\n]*\bgit\+' - pattern-regex: '\bnpm\s+(install|i)\b[^\n]*\b(git\+|https?://|github:)' - pattern-regex: '\bcargo\s+install\b[^\n]*--git\b' + - pattern-not-regex: '(?m)^\s*#.*' # ── npm install scripts re-enabled inline ──────────────────────────────────── - id: lf-npm-scripts-reenabled @@ -118,3 +130,4 @@ rules: include: ["*.sh", "*.bash", "*.yml", "*.yaml"] patterns: - pattern-regex: '\bnpm\b[^\n]*(--foreground-scripts|--ignore-scripts[=\s]+false)' + - pattern-not-regex: '(?m)^\s*#.*' diff --git a/src/legionforge_dev_rig/fixtures/http.py b/src/legionforge_dev_rig/fixtures/http.py index 1e2c988..a5cef48 100644 --- a/src/legionforge_dev_rig/fixtures/http.py +++ b/src/legionforge_dev_rig/fixtures/http.py @@ -1,5 +1,5 @@ """Shared httpx / respx fixtures for async HTTP provider testing.""" -from collections.abc import AsyncGenerator, Generator +from collections.abc import Generator from typing import Any import httpx @@ -26,9 +26,13 @@ def test_something(respx_mock_base_url): @pytest.fixture def mock_http_client(respx_mock_base_url: respx.MockRouter) -> httpx.AsyncClient: """ - An httpx.AsyncClient wired into the respx mock router. + An httpx.AsyncClient whose requests are intercepted by the respx mock. Pass this directly to provider constructors that accept a client parameter. + The respx_mock_base_url fixture activates respx.mock(), which patches httpx's + transport globally — so a plain AsyncClient is routed through the mock. (Do + NOT pass the router as `transport=`; a MockRouter is not an httpx transport.) + Usage: async def test_health(mock_http_client): respx_mock_base_url.get("http://127.0.0.1:11434/api/tags").mock( @@ -37,7 +41,7 @@ async def test_health(mock_http_client): provider = OllamaProvider(client=mock_http_client) assert await provider.health_check() is True """ - return httpx.AsyncClient(transport=respx_mock_base_url) + return httpx.AsyncClient() def json_response(data: dict[str, Any], status: int = 200) -> httpx.Response: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b227387 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +# Re-export the shared fixtures so pytest discovers them in the rig's own +# self-test suite (mirrors the consuming-project pattern in examples/conftest.py). +from legionforge_dev_rig.fixtures import mock_http_client, respx_mock_base_url + +__all__ = ["mock_http_client", "respx_mock_base_url"] diff --git a/tests/test_fixtures_http.py b/tests/test_fixtures_http.py new file mode 100644 index 0000000..94239a8 --- /dev/null +++ b/tests/test_fixtures_http.py @@ -0,0 +1,56 @@ +"""Tests for the shared httpx/respx fixtures shipped by the dev-rig. + +These also serve as the rig's own self-test target so test.yml is validated +end-to-end against the rig itself (see .github/workflows/ci.yml). +""" +import httpx +import pytest + +from legionforge_dev_rig.fixtures import http as fx + + +def test_json_response_defaults() -> None: + resp = fx.json_response({"ok": True}) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +def test_json_response_custom_status() -> None: + resp = fx.json_response({"created": 1}, status=201) + assert resp.status_code == 201 + assert resp.json() == {"created": 1} + + +def test_error_response_with_detail() -> None: + resp = fx.error_response(404, "missing") + assert resp.status_code == 404 + assert resp.json() == {"detail": "missing"} + + +def test_error_response_without_detail() -> None: + resp = fx.error_response(500) + assert resp.status_code == 500 + assert resp.json() == {} + + +def test_respx_fixture_stubs_route(respx_mock_base_url) -> None: + respx_mock_base_url.get("http://svc.local/ping").mock( + return_value=httpx.Response(200, json={"pong": True}) + ) + # respx.mock() is active via the fixture; a plain client is intercepted. + with httpx.Client() as client: + r = client.get("http://svc.local/ping") + assert r.json() == {"pong": True} + + +@pytest.mark.asyncio +async def test_mock_http_client_is_async_and_wired( + mock_http_client, respx_mock_base_url +) -> None: + respx_mock_base_url.get("http://svc.local/health").mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with mock_http_client as client: + r = await client.get("http://svc.local/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"}