From bc6e282263484575366f915456e2a63aa73c9f30 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 28 Sep 2026 22:39:11 +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 a6c40e4..d3b1a55 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:flaskapi_guard:' uv run pytest -v --cov=flaskapi_guard + IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN || 'test_token' }}' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='test:flaskapi_guard:' uv run pytest -v --cov=flaskapi_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 39944ea15c824b7263de60483c3337da7d9f8bd2 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 28 Sep 2026 23:31:48 +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 d3b1a55..e1cbe08 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:flaskapi_guard:' uv run pytest -v --cov=flaskapi_guard + IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN || 'test_token' }}' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='test:flaskapi_guard:' uv run pytest -v --cov=flaskapi_guard --cov-fail-under=100 From 14d77ceb82579ecf4ada3567095b2ca5afabf187 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, Docker client networks in the whitelist, custom check on the guard request protocol, FlaskGuardResponse export and use --- .github/workflows/docksec.yml | 49 ++++++++++++ .github/workflows/live-smoke.yml | 115 +++++++++++++++++++++++++++ .github/workflows/upstream-drift.yml | 64 +++++++++++++++ examples/simple_app/main.py | 23 ++++-- flaskapi_guard/__init__.py | 3 + 5 files changed, 247 insertions(+), 7 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..e492ef2 --- /dev/null +++ b/.github/workflows/live-smoke.yml @@ -0,0 +1,115 @@ +name: Live Smoke + +on: + push: + branches: + - master + paths: + - 'flaskapi_guard/**' + - 'examples/**' + - '.github/workflows/live-smoke.yml' + pull_request: + paths: + - 'flaskapi_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: flaskapi-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 flaskapi_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_app live_smoke_stack + python3 - <<'EOF' + import pathlib + stack = pathlib.Path("live_smoke_stack") + wheel = next(stack.glob("flaskapi_guard-*.whl")) + req = stack / "requirements.txt" + lines = [line for line in req.read_text().splitlines() if not line.startswith("flaskapi_guard")] + lines.append(f"flaskapi_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/docker-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/docker-compose.yml logs --no-color + + - name: Bring the smoke stack down + if: always() + run: | + docker compose -p "$LIVE_SMOKE_PROJECT" -f live_smoke_stack/docker-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..1ef70ea --- /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 flaskapi-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 flaskapi-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 flaskapi-guard test suite against guard-core@master + run: | + IPINFO_TOKEN='test_token' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='drift:flaskapi_guard:' uv run python -m pytest -v --cov=flaskapi_guard diff --git a/examples/simple_app/main.py b/examples/simple_app/main.py index 9101614..fe4c165 100644 --- a/examples/simple_app/main.py +++ b/examples/simple_app/main.py @@ -15,6 +15,7 @@ from flaskapi_guard import ( BehaviorRule, FlaskAPIGuard, + FlaskGuardResponse, SecurityConfig, SecurityDecorator, cloud_handler, @@ -29,13 +30,19 @@ def custom_request_check(req): - if req.args.get("debug") == "true": - client_host = req.remote_addr or "unknown" + # The check receives the guard's abstract request protocol: + # query params live on req.query_params, not Flask's request.args. + if req.query_params.get("debug") == "true": + client_host = req.client_host or "unknown" logger.warning(f"Blocked debug request from {client_host}") - return Response( - response='{"detail": "Debug mode not allowed"}', - status=403, - content_type="application/json", + # Custom checks must return the guard response type, not a raw + # Flask Response: the pipeline unwraps .body/.status_code itself. + return FlaskGuardResponse( + Response( + response='{"detail": "Debug mode not allowed"}', + status=403, + content_type="application/json", + ) ) return None @@ -49,7 +56,8 @@ def custom_response_modifier(resp): security_config = SecurityConfig( - whitelist=["127.0.0.1", "::1", "10.0.0.0/8"], + # Include the Docker client networks so host curls are not flagged + whitelist=["127.0.0.1", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], blacklist=["192.168.100.0/24"], trusted_proxies=["127.0.0.1", "10.0.0.0/8"], trusted_proxy_depth=2, @@ -63,6 +71,7 @@ def custom_response_modifier(resp): 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", "flaskapi_guard:"), diff --git a/flaskapi_guard/__init__.py b/flaskapi_guard/__init__.py index a561313..63410ba 100644 --- a/flaskapi_guard/__init__.py +++ b/flaskapi_guard/__init__.py @@ -26,6 +26,7 @@ ) from guard_core.sync.protocols.response_protocol import GuardResponse +from flaskapi_guard.adapters import FlaskGuardRequest, FlaskGuardResponse from flaskapi_guard.extension import FlaskAPIGuard try: @@ -36,6 +37,8 @@ __all__ = [ "__version__", "FlaskAPIGuard", + "FlaskGuardRequest", + "FlaskGuardResponse", "SecurityConfig", "SecurityDecorator", "RouteConfig", From 0f038ac99ec1abe156f53fac8a695211331c2101 Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 29 Sep 2026 02:32:38 +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 e492ef2..9eaf76e 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_app live_smoke_stack + wheel=$(ls examples/live_smoke_wheels/flaskapi_guard-*.whl | head -n 1) + cp "$wheel" live_smoke_stack/ python3 - <<'EOF' import pathlib stack = pathlib.Path("live_smoke_stack") From e7ebbca23a63d981a728c6547c51c043a3378039 Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 30 Sep 2026 02:43:43 +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 9eaf76e..83e1bcb 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: