From 6fe9566590155e6c51522636a8e9820c9a8f2f0f Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 15 Jun 2026 12:15:17 +0100 Subject: [PATCH 1/7] Update public access paths and database migration logic Refactor public access logic and improve database migration handling. --- api/app.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/api/app.py b/api/app.py index 053e32d..5a8345b 100644 --- a/api/app.py +++ b/api/app.py @@ -18,13 +18,8 @@ ) logger = logging.getLogger(__name__) -# Paths that do not require a JWT token -# All GET requests are public — the dashboard is a public demo of seeded data. -# POST endpoints (scan trigger, AI) remain JWT-protected. -def _is_public_get(path: str) -> bool: - if path in ("/", "/health"): - return True - return path.startswith("/api/") +# Paths that are always public regardless of environment or demo mode +_ALWAYS_PUBLIC = {"/", "/health"} _INSECURE_JWT_DEFAULT = "change-me-in-production" _MIN_JWT_SECRET_LENGTH = 32 @@ -114,12 +109,29 @@ def create_app() -> Flask: allowed_origins = allowed_origins_raw.split(",") CORS(app, resources={r"/*": {"origins": allowed_origins}}) + # ------------------------------------------------------------------ # + # Demo mode # + # ------------------------------------------------------------------ # + public_demo = os.environ.get("OPENSHIELD_PUBLIC_DEMO", "false").lower() == "true" + if public_demo: + logger.warning( + "PUBLIC DEMO MODE ENABLED (OPENSHIELD_PUBLIC_DEMO=true): " + "Unauthenticated GET requests to /api/* are permitted. " + "Do not use this setting with real Azure scan data in production." + ) + # ------------------------------------------------------------------ # # Database Management # # ------------------------------------------------------------------ # - with app.app_context(): - db = DatabaseManager() - db.run_migrations() + if os.environ.get("DATABASE_URL"): + with app.app_context(): + db = DatabaseManager() + db.run_migrations() + else: + logger.info( + "DATABASE_URL not set — skipping database migrations. " + "Set DATABASE_URL to connect to PostgreSQL." + ) @app.teardown_appcontext def close_db(error=None): @@ -144,7 +156,9 @@ def verify_jwt() -> None: """Validate the Bearer token on every non-public, non-OPTIONS request.""" if request.method == "OPTIONS": return None - if request.method == "GET" and _is_public_get(request.path): + if request.path in _ALWAYS_PUBLIC: + return None + if public_demo and request.method == "GET": return None auth = request.headers.get("Authorization", "") From 7043b4acbecb3e89d9d52d56bbdb896fed6d0545 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 15 Jun 2026 12:21:20 +0100 Subject: [PATCH 2/7] Add tests for JWT authentication middleware This file contains tests for JWT authentication middleware, including both production and demo modes. It tests various endpoints for JWT requirements and public access. --- tests/test_auth.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_auth.py diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..5b73992 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,96 @@ +"""Tests for JWT authentication middleware — production and demo modes.""" + +import os +import secrets +import time + +import jwt +import pytest + + +_SECRET = secrets.token_urlsafe(32) + + +def _make_token() -> str: + payload = { + "sub": "test-user", + "role": "admin", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + } + return jwt.encode(payload, _SECRET, algorithm="HS256") + + +@pytest.fixture +def prod_client(): + """Flask test client with default (JWT-required) auth mode.""" + os.environ.pop("OPENSHIELD_PUBLIC_DEMO", None) + from api.app import create_app + app = create_app() + app.config["TESTING"] = True + app.config["JWT_SECRET"] = _SECRET + return app.test_client() + + +@pytest.fixture +def demo_client(): + """Flask test client with OPENSHIELD_PUBLIC_DEMO=true.""" + os.environ["OPENSHIELD_PUBLIC_DEMO"] = "true" + try: + from api.app import create_app + app = create_app() + app.config["TESTING"] = True + app.config["JWT_SECRET"] = _SECRET + yield app.test_client() + finally: + os.environ.pop("OPENSHIELD_PUBLIC_DEMO", None) + + +# ── /health is always public ───────────────────────────────────────────────── + +def test_health_public_in_default_mode(prod_client): + assert prod_client.get("/health").status_code == 200 + + +def test_health_public_in_demo_mode(demo_client): + assert demo_client.get("/health").status_code == 200 + + +# ── Default mode: GET /api/* requires JWT ──────────────────────────────────── + +def test_api_get_requires_jwt_no_header(prod_client): + assert prod_client.get("/api/findings").status_code == 401 + + +def test_api_get_requires_jwt_bad_token(prod_client): + resp = prod_client.get("/api/findings", headers={"Authorization": "Bearer not-a-real-token"}) + assert resp.status_code == 401 + + +def test_api_get_passes_auth_with_valid_jwt(prod_client): + headers = {"Authorization": f"Bearer {_make_token()}"} + resp = prod_client.get("/api/findings", headers=headers) + # Auth passed — downstream may be 200 or 500 (no DB in CI), but must not be 401 + assert resp.status_code != 401 + + +def test_api_post_requires_jwt_in_default_mode(prod_client): + resp = prod_client.post("/api/scans/trigger", json={}) + assert resp.status_code == 401 + + +# ── Demo mode: GET /api/* is public, POST still requires JWT ───────────────── + +def test_demo_get_allowed_without_jwt(demo_client): + resp = demo_client.get("/api/findings") + assert resp.status_code != 401 + + +def test_demo_post_still_requires_jwt(demo_client): + resp = demo_client.post("/api/scans/trigger", json={}) + assert resp.status_code == 401 + + +def test_demo_score_allowed_without_jwt(demo_client): + resp = demo_client.get("/api/score") + assert resp.status_code != 401 From e828bcf2850c2da6ae2e161418ade06f843b432b Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 15 Jun 2026 12:29:44 +0100 Subject: [PATCH 3/7] Enhance API reference with authentication details Added authentication details and public demo mode information to the API reference. --- docs/api-reference.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a796c9..82a2084 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2,6 +2,22 @@ The OpenShield API is a Flask app registered in `api/app.py`. All `GET` requests (including `/health` and all `/api/*` GET routes) are public — no token needed. `POST` endpoints (`/api/scans/trigger`, `/api/ai/*`) require an `Authorization: Bearer ` header signed with `JWT_SECRET`. +The OpenShield API is a Flask app registered in `api/app.py`. + +## Authentication + +`/health` and `/` are always public. All other routes — including all `/api/*` GET endpoints — require an `Authorization: Bearer ` header signed with `JWT_SECRET`. + +### Public demo mode + +Set `OPENSHIELD_PUBLIC_DEMO=true` to allow unauthenticated GET requests to `/api/*`. This is intended for local development and public demo dashboards where the data is not sensitive. POST endpoints (scan trigger, AI) always require a valid JWT regardless of this setting. + +| Environment variable | Value | GET /api/* behavior | +|---|---|---| +| `OPENSHIELD_PUBLIC_DEMO` | not set or `false` | JWT required (default) | +| `OPENSHIELD_PUBLIC_DEMO` | `true` | public, no JWT needed | + +Do not enable `OPENSHIELD_PUBLIC_DEMO` in a deployment that holds real Azure scan data. --- ## GET /health From a4e86364ae133e5840cd750fe2a01a7cad773d69 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 15 Jun 2026 12:32:13 +0100 Subject: [PATCH 4/7] Add auth tests to regression test suite --- .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 46f174f..1ebd5d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,7 +335,7 @@ jobs: DATABASE_URL: "postgresql://ci:ci@localhost/ci_db" run: | echo "=== Running rule regression tests ===" - pytest tests/test_rules_*.py -v --tb=short + pytest tests/test_rules_*.py tests/test_auth.py -v --tb=short # ── Final summary — always runs, shows per-check pass/fail ──────── - name: CI Summary From da738b5bf30c537fc5ab93cacbd26e8dc35962ea Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 15 Jun 2026 12:45:56 +0100 Subject: [PATCH 5/7] Remove DATABASE_URL from CI workflow Removed DATABASE_URL environment variable from rule regression tests. --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ebd5d0..c858f17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -331,8 +331,7 @@ jobs: # ── CHECK 8: Rule regression tests (MockAzureClient, no Azure creds) ── - name: Rule regression tests id: rule_tests - env: - DATABASE_URL: "postgresql://ci:ci@localhost/ci_db" + run: | echo "=== Running rule regression tests ===" pytest tests/test_rules_*.py tests/test_auth.py -v --tb=short From 5614d9a4554480eb5272a20c4695ff5aec35555d Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 22 Jun 2026 12:49:13 +0100 Subject: [PATCH 6/7] Update ci.yml --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c858f17..8a73c01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,10 +122,6 @@ jobs: id: cred_scan run: | echo "=== Scanning for hardcoded credentials ===" - # Each credential-name pattern requires a quoted string literal on the - # right-hand side. This flags real hardcoded values (api_key = "sk-...") - # while ignoring safe assignments to function calls or expressions - # (api_key = str(data.get(...)) , _SECRET = secrets.token_urlsafe(32)). PATTERNS=( "password\s*=\s*['\"]" "secret\s*=\s*['\"]" @@ -328,13 +324,33 @@ jobs: print(f"All compliance controls map to existing rule files. ({len(existing_ids)} rules checked)") PYEOF + # ── CHECK 8: All regression tests (Mocked dependencies) ────────── + - name: All regression tests + id: all_tests + env: + DATABASE_URL: "postgresql://ci:ci@localhost/ci_db" + OPENSHIELD_ENV: "testing" + run: | + echo "=== Running all regression tests ===" + pytest tests/test_*.py -v --tb=short + # ── CHECK 8: Rule regression tests (MockAzureClient, no Azure creds) ── - name: Rule regression tests id: rule_tests - run: | echo "=== Running rule regression tests ===" - pytest tests/test_rules_*.py tests/test_auth.py -v --tb=short + pytest tests/test_rules_*.py tests/test_clean_scan.py -v --tb=short + + # ── CHECK 8: Full Python test suite ────────────────────────────── + - name: Run Python test suite + id: pytest_check + run: | + echo "=== Running full Python test suite ===" + python -m pytest tests/ \ + --ignore=tests/smoke_test.py \ + -v --tb=short -q + env: + OPENSHIELD_ENV: "development" # ── Final summary — always runs, shows per-check pass/fail ──────── - name: CI Summary @@ -347,6 +363,8 @@ jobs: JSON: ${{ steps.json_check.outcome }} API: ${{ steps.api_check.outcome }} XREF: ${{ steps.xref_check.outcome }} + ALL_TESTS: ${{ steps.all_tests.outcome }} + PYTEST: ${{ steps.pytest_check.outcome }} RULE_TESTS: ${{ steps.rule_tests.outcome }} run: | python - <<'PYEOF' @@ -360,6 +378,8 @@ jobs: ("Compliance JSON validation", os.environ["JSON"]), ("API syntax check", os.environ["API"]), ("Compliance vs rule cross-reference", os.environ["XREF"]), + ("All regression tests", os.environ["ALL_TESTS"]), + ("Python test suite", os.environ["PYTEST"]), ("Rule regression tests", os.environ["RULE_TESTS"]), ] From 15450f0ec4ee7fa7e42d104f60934a680646a7a8 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 22 Jun 2026 13:41:01 +0100 Subject: [PATCH 7/7] fix: mock DatabaseManager in test_auth.py fixtures to prevent CI DB connection --- tests/test_auth.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 5b73992..88e2527 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,12 +1,10 @@ """Tests for JWT authentication middleware — production and demo modes.""" - import os import secrets import time - import jwt import pytest - +from unittest.mock import MagicMock _SECRET = secrets.token_urlsafe(32) @@ -22,8 +20,9 @@ def _make_token() -> str: @pytest.fixture -def prod_client(): +def prod_client(monkeypatch): """Flask test client with default (JWT-required) auth mode.""" + monkeypatch.setattr("api.app.DatabaseManager", MagicMock()) os.environ.pop("OPENSHIELD_PUBLIC_DEMO", None) from api.app import create_app app = create_app() @@ -33,8 +32,9 @@ def prod_client(): @pytest.fixture -def demo_client(): +def demo_client(monkeypatch): """Flask test client with OPENSHIELD_PUBLIC_DEMO=true.""" + monkeypatch.setattr("api.app.DatabaseManager", MagicMock()) os.environ["OPENSHIELD_PUBLIC_DEMO"] = "true" try: from api.app import create_app @@ -47,7 +47,6 @@ def demo_client(): # ── /health is always public ───────────────────────────────────────────────── - def test_health_public_in_default_mode(prod_client): assert prod_client.get("/health").status_code == 200 @@ -57,7 +56,6 @@ def test_health_public_in_demo_mode(demo_client): # ── Default mode: GET /api/* requires JWT ──────────────────────────────────── - def test_api_get_requires_jwt_no_header(prod_client): assert prod_client.get("/api/findings").status_code == 401 @@ -70,7 +68,6 @@ def test_api_get_requires_jwt_bad_token(prod_client): def test_api_get_passes_auth_with_valid_jwt(prod_client): headers = {"Authorization": f"Bearer {_make_token()}"} resp = prod_client.get("/api/findings", headers=headers) - # Auth passed — downstream may be 200 or 500 (no DB in CI), but must not be 401 assert resp.status_code != 401 @@ -80,7 +77,6 @@ def test_api_post_requires_jwt_in_default_mode(prod_client): # ── Demo mode: GET /api/* is public, POST still requires JWT ───────────────── - def test_demo_get_allowed_without_jwt(demo_client): resp = demo_client.get("/api/findings") assert resp.status_code != 401