From 8d68eefde94a712ccdd424aba2e1703837d57bd9 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 28 Sep 2026 22:39:18 +0200 Subject: [PATCH 1/5] ci(workflows): add issue-link gate and fork-safe IPINFO_TOKEN fallback --- .github/workflows/ci.yml | 2 +- .github/workflows/issue-link.yml | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/issue-link.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6710709..9d3baf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,4 +78,4 @@ jobs: - name: Run tests run: | - IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN }}' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='test:djangoapi_guard:' uv run pytest -v --cov=djangoapi_guard + IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN || 'test_token' }}' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='test:djangoapi_guard:' uv run pytest -v --cov=djangoapi_guard diff --git a/.github/workflows/issue-link.yml b/.github/workflows/issue-link.yml new file mode 100644 index 0000000..5f600ac --- /dev/null +++ b/.github/workflows/issue-link.yml @@ -0,0 +1,36 @@ +name: Issue link + +on: + pull_request: + types: [opened, edited, reopened, synchronize, labeled, unlabeled] + +permissions: + issues: read + pull-requests: read + +jobs: + issue-link: + name: issue-link + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const pr = context.payload.pull_request; + if (pr.user.type === 'Bot') return core.info(`bot author ${pr.user.login}, exempt`); + if (pr.labels.some((label) => label.name === 'no-issue')) return core.info('no-issue label, exempt'); + const pattern = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|delivers?(?:\s+issue)?)\b:?\s*(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/|#)(\d+)/gi; + const numbers = [...new Set([...(pr.body || '').matchAll(pattern)].map((match) => Number(match[1])))]; + if (numbers.length === 0) return core.setFailed('PR body must say "Closes #N" for an open issue in this repo, or the PR must carry the no-issue label'); + const reasons = []; + for (const number of numbers) { + try { + const { data } = await github.rest.issues.get({ ...context.repo, issue_number: number }); + if (data.pull_request) reasons.push(`#${number} is a pull request, not an issue`); + else if (data.state !== 'open') reasons.push(`#${number} is ${data.state}`); + else return core.info(`delivers open issue #${number}`); + } catch (error) { + reasons.push(`#${number}: ${error.message}`); + } + } + core.setFailed(reasons.join('; ')); From db856777e36c91cab02663722a45a0952817c7c7 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 28 Sep 2026 23:31:53 +0200 Subject: [PATCH 2/5] ci: enforce the 100% coverage gate --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d3baf7..b173669 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,4 +78,4 @@ jobs: - name: Run tests run: | - IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN || 'test_token' }}' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='test:djangoapi_guard:' uv run pytest -v --cov=djangoapi_guard + IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN || 'test_token' }}' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='test:djangoapi_guard:' uv run pytest -v --cov=djangoapi_guard --cov-fail-under=100 From addbde9454e0f8201305d698c24a9fedb17e5f4f Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 29 Sep 2026 02:26:15 +0200 Subject: [PATCH 3/5] ci: finish adapter parity with the family baseline - add live-smoke, upstream-drift, and docksec workflows - fix the example app for guard-core 4.2: response-body scan for the return_pattern demo, custom check on the guard request protocol, guard response types, modifier headers via the wrapper mapping - quote the trailing-colon redis prefix env value (compose v5 parser) --- .github/workflows/docksec.yml | 49 +++++++++ .github/workflows/live-smoke.yml | 116 ++++++++++++++++++++ .github/workflows/upstream-drift.yml | 64 +++++++++++ examples/simple/compose.yml | 2 +- examples/simple/example_project/settings.py | 32 ++++-- 5 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/docksec.yml create mode 100644 .github/workflows/live-smoke.yml create mode 100644 .github/workflows/upstream-drift.yml diff --git a/.github/workflows/docksec.yml b/.github/workflows/docksec.yml new file mode 100644 index 0000000..36de464 --- /dev/null +++ b/.github/workflows/docksec.yml @@ -0,0 +1,49 @@ +name: DockSec + +on: + push: + branches: [master] + paths: + - 'Dockerfile' + - '.github/workflows/docksec.yml' + pull_request: + paths: + - 'Dockerfile' + - '.github/workflows/docksec.yml' + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + scan: + name: Scan Dockerfile + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run DockSec + uses: OWASP/DockSec@d52c8e928552dc39003abda21ac6bc4662b06c39 # v2026.9.21 + with: + dockerfile: Dockerfile + scan_only: 'true' + sarif: 'true' + output_dir: ${{ github.workspace }}/docksec-results + + - name: Upload SARIF to Code Scanning + # Not `if: always()`: on a fork pull_request, GITHUB_TOKEN is read-only and this + # upload would fail every time, turning a routine fork contribution into a red + # required check. Skip it there instead of forcing a failure. + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v3 + with: + sarif_file: docksec-results + category: docksec diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml new file mode 100644 index 0000000..95a067b --- /dev/null +++ b/.github/workflows/live-smoke.yml @@ -0,0 +1,116 @@ +name: Live Smoke + +on: + push: + branches: + - master + paths: + - 'djangoapi_guard/**' + - 'examples/**' + - '.github/workflows/live-smoke.yml' + pull_request: + paths: + - 'djangoapi_guard/**' + - 'examples/**' + - '.github/workflows/live-smoke.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: live-smoke-${ github.ref } + cancel-in-progress: true + +jobs: + live-smoke: + name: Dockerized smoke run (simple_app) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + LIVE_SMOKE_PROJECT: djapi-guard-live-smoke + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Install uv + run: | + pip install uv + + - name: Build the djangoapi_guard wheel from this checkout + run: | + uv build --wheel --out-dir examples/live_smoke_wheels + + - name: Stage the example app with the checkout wheel + run: | + rm -rf live_smoke_stack + cp -r examples/simple live_smoke_stack + python3 - <<'EOF' + import pathlib + stack = pathlib.Path("live_smoke_stack") + # PyPI dist name is djapi-guard; the wheel file is djapi_guard-*.whl + wheel = next(stack.glob("djapi_guard-*.whl")) + req = stack / "requirements.txt" + lines = [line for line in req.read_text().splitlines() if not line.startswith("djapi-guard")] + lines.append(f"djapi-guard @ file:///app/{wheel.name}") + req.write_text("\n".join(lines) + "\n") + dockerfile = stack / "Dockerfile" + df = dockerfile.read_text() + anchor = "COPY requirements.txt /app/" + assert anchor in df + dockerfile.write_text(df.replace(anchor, anchor + f"\nCOPY {wheel.name} /app/")) + EOF + + - name: Bring the smoke stack up + run: | + docker compose -p "$LIVE_SMOKE_PROJECT" -f live_smoke_stack/compose.yml up --build -d --wait + + - name: Run smoke assertions + run: | + set -euo pipefail + base="http://localhost:8000" + + # Wait for the app to finish initializing (engine redis + ipinfo setup) + ready="" + for i in $(seq 1 30); do + if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$base/")" = "200" ]; then + ready=1; break + fi + sleep 2 + done + if [ -z "$ready" ]; then + echo "FAIL: app never became ready"; exit 1 + fi + + check() { + local name="$1" expected="$2"; shift 2 + local status + status=$(curl -sS --max-time 10 -o /tmp/smoke_body -w '%{http_code}' "$@") || { + echo "FAIL: $name (curl error)"; exit 1 + } + if [ "$status" != "$expected" ]; then + echo "FAIL: $name (expected $expected, got $status)"; cat /tmp/smoke_body; exit 1 + fi + echo "ok: $name -> $status" + } + + check "GET / serves" 200 "$base/" + check "GET /health serves" 200 "$base/health" + + - name: Dump container logs on failure + if: failure() + run: | + docker compose -p "$LIVE_SMOKE_PROJECT" -f live_smoke_stack/compose.yml logs --no-color + + - name: Bring the smoke stack down + if: always() + run: | + docker compose -p "$LIVE_SMOKE_PROJECT" -f live_smoke_stack/compose.yml down -v --remove-orphans diff --git a/.github/workflows/upstream-drift.yml b/.github/workflows/upstream-drift.yml new file mode 100644 index 0000000..ea66eb8 --- /dev/null +++ b/.github/workflows/upstream-drift.yml @@ -0,0 +1,64 @@ +name: Upstream Drift + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +jobs: + drift: + name: Test against guard-core@master + runs-on: ubuntu-latest + + services: + redis: + image: redis + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout djapi-guard + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Checkout guard-core@master + uses: actions/checkout@v7 + with: + repository: rennf93/guard-core + ref: master + path: guard-core-upstream + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Install uv + run: | + pip install uv + + - name: Build the guard-core wheel from guard-core@master + run: | + cd guard-core-upstream + pip install build + python -m build --wheel --outdir ../wheels + + - name: Install the djapi-guard dev environment + run: | + uv sync --extra dev + + - name: Force-install the guard-core@master wheel over the resolved one + run: | + wheel=$(ls wheels/guard_core-*.whl | head -n 1) + uv pip install --python .venv/bin/python --no-deps --force-reinstall "$wheel" + + - name: Run the djapi-guard test suite against guard-core@master + run: | + IPINFO_TOKEN='test_token' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='drift:djangoapi_guard:' uv run python -m pytest -v --cov=djangoapi_guard diff --git a/examples/simple/compose.yml b/examples/simple/compose.yml index cdf3b2f..9a0d614 100644 --- a/examples/simple/compose.yml +++ b/examples/simple/compose.yml @@ -9,7 +9,7 @@ services: environment: - DJANGO_SETTINGS_MODULE=example_project.settings - REDIS_URL=redis://redis:6379 - - REDIS_PREFIX=djangoapi_guard: + - "REDIS_PREFIX=djangoapi_guard:" - IPINFO_TOKEN=${IPINFO_TOKEN:-test_token} volumes: - ./:/app:z diff --git a/examples/simple/example_project/settings.py b/examples/simple/example_project/settings.py index d171d28..764ea5b 100644 --- a/examples/simple/example_project/settings.py +++ b/examples/simple/example_project/settings.py @@ -1,21 +1,30 @@ import os -from django.http import HttpRequest, HttpResponse, JsonResponse +from django.http import JsonResponse from djangoapi_guard import SecurityConfig - - -def custom_request_check(request: HttpRequest) -> HttpResponse | None: - if request.GET.get("debug") == "true": - return JsonResponse({"detail": "Debug mode not allowed"}, status=403) +from djangoapi_guard.adapters import DjangoGuardResponse + + +def custom_request_check(request) -> DjangoGuardResponse | None: + # The check receives the guard's abstract request protocol: query + # params live on request.query_params, not Django's request.GET. + # Custom checks must return the guard response type, not a raw + # HttpResponse: the pipeline unwraps .body/.status_code itself. + if request.query_params.get("debug") == "true": + return DjangoGuardResponse( + JsonResponse({"detail": "Debug mode not allowed"}, status=403) + ) return None -def custom_response_modifier(response: HttpResponse) -> HttpResponse: - response["X-Content-Type-Options"] = "nosniff" - response["X-Frame-Options"] = "DENY" - response["X-XSS-Protection"] = "1; mode=block" - response["Referrer-Policy"] = "strict-origin-when-cross-origin" +def custom_response_modifier(response): + # The pipeline passes the guard response wrapper: mutate headers + # through its MutableMapping, not Django's item assignment. + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" return response @@ -56,6 +65,7 @@ def custom_response_modifier(response: HttpResponse) -> HttpResponse: auto_ban_threshold=5, auto_ban_duration=300, enable_penetration_detection=True, + behavior_scan_response_body=True, enable_redis=True, redis_url=os.environ.get("REDIS_URL", "redis://localhost:6379"), redis_prefix=os.environ.get("REDIS_PREFIX", "djangoapi_guard:"), From ec933bfe7db472d1b4088fade8d28df301bb4753 Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 29 Sep 2026 02:32:44 +0200 Subject: [PATCH 4/5] fix(ci): copy the built wheel into the smoke stack before staging --- .github/workflows/live-smoke.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index 95a067b..163dcd6 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -53,6 +53,8 @@ jobs: run: | rm -rf live_smoke_stack cp -r examples/simple live_smoke_stack + wheel=$(ls examples/live_smoke_wheels/djapi_guard-*.whl | head -n 1) + cp "$wheel" live_smoke_stack/ python3 - <<'EOF' import pathlib stack = pathlib.Path("live_smoke_stack") From 8683fddea11ec3c5559a6222b1d2459cb1f9637c Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 30 Sep 2026 02:43:54 +0200 Subject: [PATCH 5/5] fix(ci): use a real expression in the live-smoke concurrency group --- .github/workflows/live-smoke.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index 163dcd6..d47dc7a 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -19,7 +19,7 @@ permissions: contents: read concurrency: - group: live-smoke-${ github.ref } + group: live-smoke-${{ github.ref }} cancel-in-progress: true jobs: