Skip to content
Open
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
7 changes: 2 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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*['\"]"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]),
]

Expand Down
29 changes: 18 additions & 11 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 #
# ------------------------------------------------------------------ #
Expand All @@ -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
Expand All @@ -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", "")
Expand Down
16 changes: 16 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jwt>` 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 <jwt>` 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
Expand Down
92 changes: 92 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -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
Loading