Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 --cov-fail-under=100
49 changes: 49 additions & 0 deletions .github/workflows/docksec.yml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions .github/workflows/issue-link.yml
Original file line number Diff line number Diff line change
@@ -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('; '));
118 changes: 118 additions & 0 deletions .github/workflows/live-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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
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")
# 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
64 changes: 64 additions & 0 deletions .github/workflows/upstream-drift.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion examples/simple/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 21 additions & 11 deletions examples/simple/example_project/settings.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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:"),
Expand Down
Loading