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
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
# Database
DATABASE_URL=postgresql://openshield:openshield@localhost:5432/openshield

# Auth
# Auth - see docs/security/authentication.md
# shared_secret (default, local/CI) or oidc (enterprise identity provider)
OPENSHIELD_AUTH_MODE=shared_secret
JWT_SECRET=change-me-in-production
# Optional in shared_secret mode; validated when set
JWT_ISSUER=
JWT_AUDIENCE=
# Required when OPENSHIELD_AUTH_MODE=oidc
OIDC_ISSUER=
OIDC_AUDIENCE=
OIDC_JWKS_URL=
# Optional oidc settings
OIDC_ALLOWED_TENANTS=
OIDC_ROLE_CLAIM=roles
OIDC_ROLE_MAP=

# Optional - comma-separated subscription_id allowlist for POST /api/scans/trigger.
# Unset accepts any subscription_id (matches historical behavior); the API
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,21 @@ jobs:
run: npm run test:a11y && npm run test:i18n

- name: Build
run: npm run build
# A canary bearer value proves a build-time token variable can no
# longer reach the public bundle (issue #294). It is assembled at run
# time so the workflow file itself contains no JWT-shaped string.
env:
CANARY_HEADER: eyJhbGciOiJIUzI1NiJ9
CANARY_PAYLOAD: eyJjYW5hcnkiOiJvcGVuc2hpZWxkLWNpIn0
run: VITE_JWT_TOKEN="${CANARY_HEADER}.${CANARY_PAYLOAD}.canary-signature" npm run build

- name: Assert no bearer credential in the public bundle
run: |
if grep -rnoE 'eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}|dev-local-token|canary-signature' dist; then
echo "::error::A JWT-shaped credential or token bootstrap value is present in frontend/dist."
exit 1
fi
echo "OK: no bearer credential found in frontend/dist"

# Website validation joins CI Summary; website.yml handles Pages deployment.
website:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- OIDC bearer-token verification (`OPENSHIELD_AUTH_MODE=oidc`) with JWKS signature, issuer, audience, tenant and IdP app-role enforcement (#294)
- Azure Network Layer Assurance API with 20-domain coverage, network-rule classification, and authoritative IP forwarding and direct Internet route checks
- Azure Resource Graph inventory snapshots as the first OpenShield Evidence Graph foundation
- Azure Data Link Layer Assurance API with LLC and MAC coverage plus ExpressRoute Direct MACsec checks
Expand All @@ -36,6 +37,7 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Security

- Dashboard no longer embeds a build-time bearer token or a `dev-local-token` fallback, keeps tokens in memory only, and purges legacy `localStorage` tokens; CI fails if a JWT-shaped value reaches the public bundle (#294)
- Upgraded cryptography to 50.0.0 to address CVE-2026-69247
- AI provider errors no longer expose upstream response details
- Request body limits, AI rate limiting, and playbook path validation added
Expand Down
55 changes: 24 additions & 31 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
import os
import sys

import jwt
from dotenv import load_dotenv
from flask import Flask, g, jsonify, request
from flask_cors import CORS
from werkzeug.middleware.proxy_fix import ProxyFix

from api.auth import SHARED_SECRET_MODE, KNOWN_ROLES, WRITE_ROLES, TokenRejected, build_verifier
from api.models.finding import DatabaseManager, get_pool_stats
from api.observability import (
configure_logging,
Expand Down Expand Up @@ -40,14 +40,13 @@
_GENERATE_CMD = 'python -c "import secrets; print(secrets.token_urlsafe(32))"'

# A token's signature proves who signed it, not what the bearer is allowed to
# do. Every accepted token must carry one of these roles (see issue #294):
# a missing/unrecognized role is treated the same as an invalid signature.
# Only operator/admin may perform a write (any non-GET/HEAD); viewer is
# read-only. This is enforced regardless of demo mode - public_demo only
# ever widens *read* access to skip the token requirement entirely, it does
# not touch write authorization.
_KNOWN_ROLES = {"viewer", "operator", "admin"}
_WRITE_ROLES = {"operator", "admin"}
# do. Every accepted token must carry one of these roles (see issue #294 and
# api/auth.py). Only operator/admin may perform a write (any non-GET/HEAD);
# viewer is read-only. This is enforced regardless of demo mode - public_demo
# only ever widens *read* access to skip the token requirement entirely, it
# does not touch write authorization.
_KNOWN_ROLES = KNOWN_ROLES
_WRITE_ROLES = WRITE_ROLES

# Generous enough for legitimate manual or automated readiness checks from
# one source, but bounded well under the default pool size
Expand Down Expand Up @@ -156,6 +155,16 @@ def create_app() -> Flask:
# Configuration & Security #
# ------------------------------------------------------------------ #
app.config["JWT_SECRET"] = _resolve_jwt_secret()
# Read at request time so a rotated secret or test override applies.
verifier = build_verifier(lambda: app.config["JWT_SECRET"])
app.config["AUTH_MODE"] = verifier.mode
if verifier.mode == SHARED_SECRET_MODE and _is_production():
logger.warning(
"!!! SECURITY WARNING: OPENSHIELD_AUTH_MODE=shared_secret IN PRODUCTION !!! "
"Anyone holding JWT_SECRET can mint any role. Configure OPENSHIELD_AUTH_MODE=oidc "
"with OIDC_ISSUER, OIDC_AUDIENCE and OIDC_JWKS_URL for enterprise deployments "
"(docs/security/authentication.md)."
)
app.config["MAX_CONTENT_LENGTH"] = _MAX_CONTENT_LENGTH

# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -226,28 +235,12 @@ def verify_jwt() -> None:

token = auth.split(" ", 1)[1]
try:
payload = jwt.decode(
token,
app.config["JWT_SECRET"],
algorithms=["HS256"],
# A token with no expiry can never be invalidated short of a
# full JWT_SECRET rotation - require every accepted token to
# carry one (issue #294). MissingRequiredClaimError is a
# subclass of InvalidTokenError, so it's already handled by
# the except clause below.
options={"require": ["exp"]},
)
g.user = payload
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token has expired", "request_id": get_request_id()}), 401
except jwt.InvalidTokenError:
logger.warning("Invalid JWT token")
return jsonify({"error": "Invalid token", "request_id": get_request_id()}), 401

role = payload.get("role")
if role not in _KNOWN_ROLES:
logger.warning("JWT rejected: missing or unrecognized role %r", role)
return jsonify({"error": "Invalid token", "request_id": get_request_id()}), 401
principal = verifier.verify(token)
except TokenRejected as rejected:
return jsonify({"error": rejected.message, "request_id": get_request_id()}), rejected.status
g.user = principal

role = principal["role"]
if request.method not in ("GET", "HEAD") and role not in _WRITE_ROLES:
return jsonify(
{
Expand Down
Loading
Loading