diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc429fe..6ec918f 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*['\"]" @@ -337,6 +333,7 @@ jobs: 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 @@ -380,7 +377,7 @@ jobs: ("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"]), + ("Python test suite", os.environ["PYTEST"]), ("Rule regression tests", os.environ["RULE_TESTS"]), ] diff --git a/api/app.py b/api/app.py index 771904b..9fda9cd 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,6 +109,17 @@ 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 # # ------------------------------------------------------------------ # @@ -127,9 +133,8 @@ def create_app() -> Flask: db.run_migrations() else: logger.info( - "DATABASE_URL not set — skipping migrations. " - "This is expected during unit tests and local development " - "without a database." + "DATABASE_URL not set — skipping database migrations. " + "Set DATABASE_URL to connect to PostgreSQL." ) @app.teardown_appcontext @@ -155,7 +160,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", "") 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 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..88e2527 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,92 @@ +"""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) + + +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(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() + app.config["TESTING"] = True + app.config["JWT_SECRET"] = _SECRET + return app.test_client() + + +@pytest.fixture +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 + 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) + 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