diff --git a/.pyrit_conf_example b/.pyrit_conf_example index e136b7a91..d81ea45e5 100644 --- a/.pyrit_conf_example +++ b/.pyrit_conf_example @@ -65,6 +65,17 @@ initializers: operator: roakey operation: op_trash_panda +# Operator and Operation Labels +# ------------------------------ +# Default labels applied to all attacks created with PyRIT. +# +# - operator: Identifies who is running the attack (e.g., your team name or alias). +# - operation: Groups related attacks under a campaign or engagement name. +# +# Both are optional. +operator: roakey +operation: op_trash_panda + # Initialization Scripts # ---------------------- # List of paths to custom Python scripts containing PyRITInitializer subclasses. diff --git a/build_scripts/prepare_package.py b/build_scripts/prepare_package.py index 8e999c812..7fbba3c8b 100644 --- a/build_scripts/prepare_package.py +++ b/build_scripts/prepare_package.py @@ -45,7 +45,7 @@ def build_frontend(frontend_dir: Path) -> bool: print("\nInstalling frontend dependencies...") try: subprocess.run( - ["npm", "install"], + ["npm", "install", "--legacy-peer-deps"], cwd=frontend_dir, check=True, stdout=subprocess.PIPE, diff --git a/docker/Dockerfile b/docker/Dockerfile index 6b713a797..71b2e1001 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -10,8 +10,8 @@ # python docker/build_pyrit_docker.py --source pypi --version 0.10.0 # ============================================================================ -# Use the devcontainer as base (built by build_pyrit_docker.py) -ARG BASE_IMAGE=pyrit-devcontainer +# No default — callers must pass --build-arg BASE_IMAGE=... (see infra/README.md). +ARG BASE_IMAGE FROM ${BASE_IMAGE} AS production LABEL description="Docker container for PyRIT with Jupyter Notebook and GUI support" diff --git a/docker/QUICKSTART.md b/docker/QUICKSTART.md index 2ba28b778..b17ee69d2 100644 --- a/docker/QUICKSTART.md +++ b/docker/QUICKSTART.md @@ -4,8 +4,40 @@ Docker container for PyRIT with support for both **Jupyter Notebook** and **GUI* ## Prerequisites - Docker installed and running -- `.env` file at `~/.pyrit/.env` with API keys -- Optionally, `~/.pyrit/.env.local` for additional environment variables +- `~/.pyrit/.env` with your API keys and Azure service principal credentials +- `~/.pyrit/.pyrit_conf` with your configuration (operator, operation, initializers) +- Optionally, `~/.pyrit/.env.local` for additional environment overrides + +## Azure Authentication in Docker + +`DefaultAzureCredential` is used automatically. The method depends on +where the container runs: + +### Azure infrastructure (AKS, ACI, Azure VM) + +Managed identity works out of the box — no configuration needed. +Assign the managed identity the **Cognitive Services OpenAI User** role +on your Azure OpenAI resources. + +### Local Docker Desktop + +Managed identity is not available locally. Use a **service principal** +by adding these variables to your `~/.pyrit/.env`: + +```bash +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +``` + +The Azure SDK picks these up automatically and refreshes tokens +without any manual intervention. + +To create a service principal: +```bash +az ad sp create-for-rbac --name pyrit-docker --role "Cognitive Services OpenAI User" \ + --scopes /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ +``` ## Quick Start @@ -41,6 +73,11 @@ GUI mode (port 8000): python docker/run_pyrit_docker.py gui ``` +The run script automatically mounts these files from `~/.pyrit/`: +- `.env` — API keys and service principal credentials (required) +- `.env.local` — Additional environment overrides (optional) +- `.pyrit_conf` — PyRIT configuration: operator, operation, initializers (optional) + ## Image Tags Images are tagged with version information: @@ -77,6 +114,9 @@ docker-compose --profile gui up **.env missing**: Create `.env` file at `~/.pyrit/.env` with your API keys +**Azure auth fails in container**: Add `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and +`AZURE_CLIENT_SECRET` to your `.env` file (see Azure Authentication section above) + **GUI frontend missing**: Build with `--source local` (PyPI builds before GUI release won't work) For complete documentation, see [docker/README.md](./README.md) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0342a85e5..5bc02a404 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -49,6 +49,8 @@ services: - ../assets:/app/assets - ~/.pyrit/.env:/home/vscode/.pyrit/.env:ro - ~/.pyrit/.env.local:/home/vscode/.pyrit/.env.local:ro + - ~/.pyrit/.pyrit_conf:/home/vscode/.pyrit/.pyrit_conf:ro + - ~/.azure:/home/vscode/.azure environment: - PYRIT_MODE=gui restart: unless-stopped diff --git a/docker/run_pyrit_docker.py b/docker/run_pyrit_docker.py index 561a4730e..d390e41e5 100644 --- a/docker/run_pyrit_docker.py +++ b/docker/run_pyrit_docker.py @@ -28,6 +28,7 @@ def run_container(mode, tag="latest"): pyrit_config_dir = Path.home() / ".pyrit" env_file = pyrit_config_dir / ".env" env_local_file = pyrit_config_dir / ".env.local" + pyrit_conf_file = pyrit_config_dir / ".pyrit_conf" print("🐳 PyRIT Docker Runner") print("=" * 60) @@ -69,10 +70,12 @@ def run_container(mode, tag="latest"): # Build docker run command # Mount env files to ~/.pyrit/ where PyRIT expects them + # Use -it for interactive mode (needed for az login device code prompt) cmd = [ "docker", "run", "--rm", + "-it", "--name", container_name, "-p", @@ -88,6 +91,17 @@ def run_container(mode, tag="latest"): print(f" Found .env.local - including it") cmd.extend(["-v", f"{env_local_file}:/home/vscode/.pyrit/.env.local:ro"]) + # Add .pyrit_conf if it exists (sets operator, operation, initializers) + if pyrit_conf_file.exists(): + print(f" Found .pyrit_conf - including it") + cmd.extend(["-v", f"{pyrit_conf_file}:/home/vscode/.pyrit/.pyrit_conf:ro"]) + + # Mount Azure CLI config so 'az login' tokens persist across container restarts + azure_dir = Path.home() / ".azure" + if azure_dir.exists(): + print(f" Found .azure/ - mounting for Azure CLI auth") + cmd.extend(["-v", f"{azure_dir}:/home/vscode/.azure"]) + cmd.append(f"pyrit:{tag}") print() diff --git a/docker/start.sh b/docker/start.sh index f101ee5c9..0dd003229 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -37,6 +37,15 @@ fi echo "Checking PyRIT installation..." python -c "import pyrit; print(f'Running PyRIT version: {pyrit.__version__}')" +# Write .env file from PYRIT_ENV_CONTENTS (injected from Key Vault secret) +if [ -n "$PYRIT_ENV_CONTENTS" ]; then + mkdir -p ~/.pyrit + echo "$PYRIT_ENV_CONTENTS" > ~/.pyrit/.env + echo "Wrote .env file from PYRIT_ENV_CONTENTS ($(wc -l < ~/.pyrit/.env) lines)" +else + echo "No PYRIT_ENV_CONTENTS set — using system environment variables only" +fi + # Start the appropriate service based on PYRIT_MODE if [ "$PYRIT_MODE" = "jupyter" ]; then echo "Starting JupyterLab on port 8888..." @@ -44,7 +53,25 @@ if [ "$PYRIT_MODE" = "jupyter" ]; then exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --NotebookApp.token='' --NotebookApp.password='' --notebook-dir=/app/notebooks elif [ "$PYRIT_MODE" = "gui" ]; then echo "Starting PyRIT GUI on port 8000..." - exec python -m uvicorn pyrit.backend.main:app --host 0.0.0.0 --port 8000 + # Use Azure SQL if AZURE_SQL_SERVER is set (injected by Bicep), otherwise default to SQLite. + # Note: AZURE_SQL_DB_CONNECTION_STRING is in the .env file (loaded by Python dotenv), + # but we use AZURE_SQL_SERVER here because it's a direct env var from the Bicep template. + # Build CLI arguments + BACKEND_ARGS="--host 0.0.0.0 --port 8000" + + if [ -n "$AZURE_SQL_SERVER" ]; then + echo "Using Azure SQL database (server: $AZURE_SQL_SERVER)" + BACKEND_ARGS="$BACKEND_ARGS --database AzureSQL" + else + echo "Using SQLite database (AZURE_SQL_SERVER not set)" + fi + + if [ -n "$PYRIT_INITIALIZER" ]; then + echo "Using initializer: $PYRIT_INITIALIZER" + BACKEND_ARGS="$BACKEND_ARGS --initializers $PYRIT_INITIALIZER" + fi + + exec python -m pyrit.cli.pyrit_backend $BACKEND_ARGS else echo "ERROR: Invalid PYRIT_MODE '$PYRIT_MODE'. Must be 'jupyter' or 'gui'" exit 1 diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 000000000..562828428 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,3 @@ +# Pin npm registry — satisfies CSSC CFS0001 (sibling .npmrc required). +registry=https://registry.npmjs.org/ +legacy-peer-deps=true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 008487a7f..6ce53d601 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "pyrit-frontend", "version": "0.11.1-dev.0", "dependencies": { + "@azure/msal-browser": "^5.5.0", + "@azure/msal-react": "^5.0.7", "@fluentui/react-components": "^9.54.0", "@fluentui/react-icons": "^2.0.258", "axios": "^1.13.5", @@ -18,6 +20,7 @@ "devDependencies": { "@eslint/js": "^9.15.0", "@playwright/test": "^1.40.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", @@ -35,6 +38,7 @@ "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "scheduler": "^0.27.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.6.3", @@ -48,6 +52,40 @@ "dev": true, "license": "MIT" }, + "node_modules/@azure/msal-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.5.0.tgz", + "integrity": "sha512-ImdTOyk8ISv1HMJez2dR0E3D9mBn0lpRieUGXjMGKgI2Ufs7NoIdeSKzm1JUTF0NyJaVbnANplcNLzJiO1G73w==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.3.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.3.0.tgz", + "integrity": "sha512-qNc/noEts5Kf6oLtbuLEg1PkmM+GXEQlxC5VCrmCFbDo3Xyg5hDR3UFR2jZ8MK76epGuBWKSWoa2rCIYZVp5AQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-react": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-5.0.7.tgz", + "integrity": "sha512-PFHKN5BXhGkYQu9tN6t0svhs9LudAppdmJgR60pyCtYHaTDLE0BdG/07Paa5drPmEoD5Fjh+qSfgyMZgYYsCmQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@azure/msal-browser": "^5.5.0", + "react": "^19.2.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -79,7 +117,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1312,7 +1349,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" @@ -3900,7 +3936,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4189,7 +4224,6 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4198,14 +4232,15 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.26", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4215,8 +4250,8 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4288,7 +4323,6 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -4523,7 +4557,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4878,7 +4911,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5357,8 +5389,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -5559,7 +5590,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6664,7 +6694,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8521,7 +8550,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8534,7 +8562,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8799,8 +8826,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", @@ -9143,7 +9170,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9285,7 +9311,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9372,7 +9397,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9501,7 +9525,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9595,7 +9618,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/frontend/package.json b/frontend/package.json index 97af70c36..316aaedc8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,8 @@ "test:e2e:headed": "playwright test --headed" }, "dependencies": { + "@azure/msal-browser": "^5.5.0", + "@azure/msal-react": "^5.0.7", "@fluentui/react-components": "^9.54.0", "@fluentui/react-icons": "^2.0.258", "axios": "^1.13.5", @@ -32,6 +34,7 @@ "devDependencies": { "@eslint/js": "^9.15.0", "@playwright/test": "^1.40.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", @@ -49,6 +52,7 @@ "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "scheduler": "^0.27.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.6.3", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 6481b6b0b..b5d8478b6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -7,6 +7,11 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import App from "./App"; import { attacksApi } from "./services/api"; +// Mock MSAL — App uses useMsal() to wire the instance into the API client +jest.mock("@azure/msal-react", () => ({ + useMsal: () => ({ instance: { getActiveAccount: () => null, getAllAccounts: () => [] } }), +})); + jest.mock("./services/api", () => ({ attacksApi: { getAttack: jest.fn(), @@ -17,6 +22,7 @@ jest.mock("./services/api", () => ({ versionApi: { getVersion: jest.fn().mockResolvedValue({ version: "1.0.0" }), }, + setMsalInstance: jest.fn(), })); const mockedVersionApi = jest.requireMock("./services/api").versionApi; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25dd98032..8bdb2e89b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import { useState, useCallback, useEffect } from 'react' import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components' +import { useMsal } from '@azure/msal-react' +import type { PublicClientApplication } from '@azure/msal-browser' import MainLayout from './components/Layout/MainLayout' import ChatWindow from './components/Chat/ChatWindow' import TargetConfig from './components/Config/TargetConfig' @@ -12,7 +14,7 @@ import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnec import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults' import type { ViewName } from './components/Sidebar/Navigation' import type { TargetInstance, TargetInfo } from './types' -import { attacksApi, versionApi } from './services/api' +import { attacksApi, versionApi, setMsalInstance } from './services/api' const AUTO_DISMISS_MS = 5_000 @@ -36,6 +38,7 @@ function ConnectionBannerContainer() { } function App() { + const { instance } = useMsal() const [isDarkMode, setIsDarkMode] = useState(true) const [currentView, setCurrentView] = useState('chat') const [activeTarget, setActiveTarget] = useState(null) @@ -45,6 +48,11 @@ function App() { /** Persisted filter state for the history view */ const [historyFilters, setHistoryFilters] = useState({ ...DEFAULT_HISTORY_FILTERS }) + // Wire MSAL instance into the API client for Bearer token injection + useEffect(() => { + setMsalInstance(instance as PublicClientApplication) + }, [instance]) + // Fetch default labels from backend configuration on startup useEffect(() => { versionApi.getVersion() diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx new file mode 100644 index 000000000..131a98556 --- /dev/null +++ b/frontend/src/auth/AuthProvider.test.tsx @@ -0,0 +1,290 @@ +import { render, screen, waitFor } from "@testing-library/react"; + +// --------------------------------------------------------------------------- +// Mock setup — declare mock fns before jest.mock so they're hoisted correctly +// --------------------------------------------------------------------------- + +const mockFetchAuthConfig = jest.fn(); +const mockBuildMsalConfig = jest.fn().mockReturnValue({ + auth: { + clientId: "test-client", + authority: "https://login.microsoftonline.com/test-tenant", + redirectUri: "http://localhost", + postLogoutRedirectUri: "http://localhost", + }, + cache: { cacheLocation: "sessionStorage" }, + system: { loggerOptions: { logLevel: 3, piiLoggingEnabled: false } }, +}); +const mockBuildLoginRequest = jest.fn().mockReturnValue({ + scopes: ["test-client/access"], +}); + +jest.mock("./msalConfig", () => ({ + fetchAuthConfig: (...args: unknown[]) => mockFetchAuthConfig(...args), + buildMsalConfig: (...args: unknown[]) => mockBuildMsalConfig(...args), + buildLoginRequest: (...args: unknown[]) => mockBuildLoginRequest(...args), +})); + +const mockSetMsalInstance = jest.fn(); +const mockSetClientId = jest.fn(); + +jest.mock("../services/api", () => ({ + setMsalInstance: (...args: unknown[]) => mockSetMsalInstance(...args), + setClientId: (...args: unknown[]) => mockSetClientId(...args), +})); + +// MSAL browser mocks — PublicClientApplication instance methods +const mockInitialize = jest.fn().mockResolvedValue(undefined); +const mockHandleRedirectPromise = jest.fn().mockResolvedValue(null); +const mockGetActiveAccount = jest.fn().mockReturnValue(null); +const mockGetAllAccounts = jest.fn().mockReturnValue([]); +const mockSetActiveAccount = jest.fn(); +const mockAddEventCallback = jest.fn(); +const mockLoginRedirect = jest.fn().mockResolvedValue(undefined); + +jest.mock("@azure/msal-browser", () => ({ + PublicClientApplication: jest.fn().mockImplementation(() => ({ + initialize: mockInitialize, + handleRedirectPromise: mockHandleRedirectPromise, + getActiveAccount: mockGetActiveAccount, + getAllAccounts: mockGetAllAccounts, + setActiveAccount: mockSetActiveAccount, + addEventCallback: mockAddEventCallback, + loginRedirect: mockLoginRedirect, + })), + EventType: { LOGIN_SUCCESS: "msal:loginSuccess" }, + InteractionRequiredAuthError: class extends Error {}, +})); + +// MSAL React mocks — MsalProvider renders children, templates render children +// unconditionally so we can test both authenticated and unauthenticated paths. +jest.mock("@azure/msal-react", () => ({ + MsalProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AuthenticatedTemplate: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + UnauthenticatedTemplate: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + useMsal: () => ({ + instance: { + loginRedirect: mockLoginRedirect, + getActiveAccount: mockGetActiveAccount, + getAllAccounts: mockGetAllAccounts, + }, + }), +})); + +import { AuthProvider } from "./AuthProvider"; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AuthProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetActiveAccount.mockReturnValue(null); + mockGetAllAccounts.mockReturnValue([]); + mockHandleRedirectPromise.mockResolvedValue(null); + }); + + // Test 10: fetchAuthConfig never resolves → stuck in loading state + it("shows loading state while initializing", () => { + mockFetchAuthConfig.mockReturnValue(new Promise(() => {})); + + render( + +
Child
+
+ ); + + expect(screen.getByText("Initializing authentication...")).toBeInTheDocument(); + }); + + // Test 11: empty clientId + tenantId → auth disabled, children render directly + it("renders children directly when auth is disabled", async () => { + mockFetchAuthConfig.mockResolvedValue({ + clientId: "", + tenantId: "", + allowedGroupId: "", + }); + + render( + +
Hello
+
+ ); + + await waitFor(() => { + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("msal-provider")).not.toBeInTheDocument(); + }); + + // Test 12: clientId present but tenantId missing → still disabled (OR branch) + it("renders children when only tenantId is missing", async () => { + mockFetchAuthConfig.mockResolvedValue({ + clientId: "some-client", + tenantId: "", + allowedGroupId: "", + }); + + render( + +
Hello
+
+ ); + + await waitFor(() => { + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + }); + + // Test 13: fetchAuthConfig rejects with Error → err.message shown + it("shows error when initialization fails with Error", async () => { + mockFetchAuthConfig.mockRejectedValue(new Error("Config fetch failed")); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(screen.getByText("Authentication Error")).toBeInTheDocument(); + expect(screen.getByText("Config fetch failed")).toBeInTheDocument(); + }); + }); + + // Test 14: fetchAuthConfig rejects with non-Error → generic message + it("shows generic error when initialization fails with non-Error", async () => { + mockFetchAuthConfig.mockRejectedValue("string error"); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(screen.getByText("Authentication Error")).toBeInTheDocument(); + expect( + screen.getByText("Failed to initialize authentication") + ).toBeInTheDocument(); + }); + }); + + // Test 15: full happy path → MSAL initialized, MsalProvider renders + it("initializes MSAL and renders MsalProvider when auth is enabled", async () => { + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupId: "g1", + }); + + render( + +
Hello
+
+ ); + + await waitFor(() => { + expect(screen.getByTestId("msal-provider")).toBeInTheDocument(); + }); + expect(mockInitialize).toHaveBeenCalled(); + expect(mockHandleRedirectPromise).toHaveBeenCalled(); + expect(mockSetMsalInstance).toHaveBeenCalled(); + expect(mockSetClientId).toHaveBeenCalledWith("test-client"); + }); + + // Test 16: handleRedirectPromise returns account → setActiveAccount called + it("sets active account from redirect result", async () => { + const mockAccount = { username: "user@test.com" }; + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupId: "g1", + }); + mockHandleRedirectPromise.mockResolvedValue({ account: mockAccount }); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(mockSetActiveAccount).toHaveBeenCalledWith(mockAccount); + }); + }); + + // Test 17: no redirect, no active account, but cached account exists + it("falls back to cached account when no redirect result", async () => { + const cachedAccount = { username: "cached@test.com" }; + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupId: "g1", + }); + mockGetActiveAccount.mockReturnValue(null); + mockGetAllAccounts.mockReturnValue([cachedAccount]); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(mockSetActiveAccount).toHaveBeenCalledWith(cachedAccount); + }); + }); + + // Test 18: UnauthenticatedTemplate renders LoginRedirect text + it("renders LoginRedirect in unauthenticated template", async () => { + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupId: "g1", + }); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(screen.getByText("Redirecting to login...")).toBeInTheDocument(); + }); + }); + + // Test 19: loginRedirect rejects → .catch logs the error + it("handles login redirect failure gracefully", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupId: "g1", + }); + mockLoginRedirect.mockRejectedValue(new Error("Redirect failed")); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + "Login redirect failed:", + expect.any(Error) + ); + }); + + consoleSpy.mockRestore(); + }); +}); diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx new file mode 100644 index 000000000..67c0214ee --- /dev/null +++ b/frontend/src/auth/AuthProvider.tsx @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Auth provider that wraps the app in MsalProvider and handles login. + * + * - Fetches MSAL config from the backend at startup + * - Creates MSAL PublicClientApplication instance + * - Redirects unauthenticated users to Entra ID login + * - Shows a loading state while auth initializes + */ + +import { useState, useEffect, type ReactNode } from 'react' +import { + PublicClientApplication, + EventType, + type AuthenticationResult, +} from '@azure/msal-browser' +import { + MsalProvider, + AuthenticatedTemplate, + UnauthenticatedTemplate, + useMsal, +} from '@azure/msal-react' +import { fetchAuthConfig, buildMsalConfig, buildLoginRequest } from './msalConfig' +import { setMsalInstance as setApiMsalInstance, setClientId as setApiClientId } from '../services/api' + +function LoginRedirect() { + const { instance } = useMsal() + + useEffect(() => { + const doLogin = async () => { + const config = await fetchAuthConfig() + instance.loginRedirect(buildLoginRequest(config.clientId)).catch((error) => { + console.error('Login redirect failed:', error) + }) + } + doLogin() + }, [instance]) + + return
Redirecting to login...
+} + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [msalInstance, setMsalInstance] = useState(null) + const [authDisabled, setAuthDisabled] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + async function initMsal() { + try { + const config = await fetchAuthConfig() + + // If no auth config (local dev), skip MSAL entirely + if (!config.clientId || !config.tenantId) { + if (!cancelled) { + setMsalInstance(null) // null signals "auth disabled" + setAuthDisabled(true) + } + return + } + + const msalConfig = buildMsalConfig(config) + const instance = new PublicClientApplication(msalConfig) + await instance.initialize() + + // Handle redirect response (after coming back from login) + instance.addEventCallback((event) => { + if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) { + const result = event.payload as AuthenticationResult + instance.setActiveAccount(result.account) + } + }) + + // Await the redirect promise FIRST — on the initial redirect back + // from Entra, this caches the token and returns the auth result. + // On normal page loads (no redirect hash) it resolves to null. + const redirectResult = await instance.handleRedirectPromise() + if (redirectResult?.account) { + instance.setActiveAccount(redirectResult.account) + } + + // Fall back to any cached account from a previous session + if (!instance.getActiveAccount()) { + const accounts = instance.getAllAccounts() + if (accounts.length > 0) { + instance.setActiveAccount(accounts[0]) + } + } + + if (!cancelled) { + // Wire MSAL into the API client BEFORE React re-render, + // so child components' effects already have the token available. + setApiMsalInstance(instance) + setApiClientId(config.clientId) + setMsalInstance(instance) + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to initialize authentication') + } + } + } + + initMsal() + return () => { + cancelled = true + } + }, []) + + if (error) { + return ( +
+

Authentication Error

+

{error}

+
+ ) + } + + if (!msalInstance) { + // Auth disabled (local dev) — render children directly + if (authDisabled) { + return <>{children} + } + return
Initializing authentication...
+ } + + return ( + + {children} + + + + + ) +} diff --git a/frontend/src/auth/msalConfig.test.ts b/frontend/src/auth/msalConfig.test.ts new file mode 100644 index 000000000..813f35e92 --- /dev/null +++ b/frontend/src/auth/msalConfig.test.ts @@ -0,0 +1,121 @@ +// msalConfig.ts imports { LogLevel } from @azure/msal-browser — mock it +// so we don't need the real MSAL SDK in the test environment. +jest.mock("@azure/msal-browser", () => ({ + LogLevel: { Warning: 3 }, +})); + +import { buildMsalConfig, getApiScopes, buildLoginRequest } from "./msalConfig"; + +describe("msalConfig", () => { + // Tests 1-2: getApiScopes — two branches on the !clientId check (line 75) + describe("getApiScopes", () => { + it("returns default scopes when clientId is empty", () => { + expect(getApiScopes("")).toEqual(["openid", "profile", "email"]); + }); + + it("returns client-specific scope when clientId is provided", () => { + expect(getApiScopes("my-client-id")).toEqual(["my-client-id/access"]); + }); + }); + + // Tests 3-4: buildLoginRequest — wraps getApiScopes in { scopes } + describe("buildLoginRequest", () => { + it("builds request with client-specific scopes", () => { + expect(buildLoginRequest("my-client-id")).toEqual({ + scopes: ["my-client-id/access"], + }); + }); + + it("builds request with default scopes when clientId is empty", () => { + expect(buildLoginRequest("")).toEqual({ + scopes: ["openid", "profile", "email"], + }); + }); + }); + + // Test 5: buildMsalConfig — assembles MSAL Configuration from AuthConfig + describe("buildMsalConfig", () => { + it("builds correct MSAL configuration", () => { + const authConfig = { + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupId: "group-1", + }; + const result = buildMsalConfig(authConfig); + + expect(result.auth.clientId).toBe("test-client"); + expect(result.auth.authority).toBe( + "https://login.microsoftonline.com/test-tenant" + ); + expect(result.auth.redirectUri).toBe(window.location.origin); + expect(result.auth.postLogoutRedirectUri).toBe(window.location.origin); + expect(result.cache?.cacheLocation).toBe("sessionStorage"); + expect(result.system?.loggerOptions?.piiLoggingEnabled).toBe(false); + }); + }); + + // Tests 6-9: fetchAuthConfig — module-level _cachedConfig state. + // jest.resetModules() + dynamic import() gives each test a fresh module. + describe("fetchAuthConfig", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.resetModules(); + jest.doMock("@azure/msal-browser", () => ({ + LogLevel: { Warning: 3 }, + })); + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("fetches config from /api/auth/config", async () => { + const mockConfig = { clientId: "abc", tenantId: "xyz", allowedGroupId: "g1" }; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockConfig), + }); + + const { fetchAuthConfig } = await import("./msalConfig"); + const result = await fetchAuthConfig(); + + expect(result).toEqual(mockConfig); + expect(global.fetch).toHaveBeenCalledWith("/api/auth/config"); + }); + + it("returns cached config on subsequent calls", async () => { + const mockConfig = { clientId: "abc", tenantId: "xyz", allowedGroupId: "g1" }; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockConfig), + }); + + const { fetchAuthConfig } = await import("./msalConfig"); + await fetchAuthConfig(); + const secondResult = await fetchAuthConfig(); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(secondResult).toEqual(mockConfig); + }); + + it("returns empty config when response is not ok", async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: false }); + + const { fetchAuthConfig } = await import("./msalConfig"); + const result = await fetchAuthConfig(); + + expect(result).toEqual({ clientId: "", tenantId: "", allowedGroupId: "" }); + }); + + it("returns empty config on network error", async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error("Network error")); + + const { fetchAuthConfig } = await import("./msalConfig"); + const result = await fetchAuthConfig(); + + expect(result).toEqual({ clientId: "", tenantId: "", allowedGroupId: "" }); + }); + }); +}); diff --git a/frontend/src/auth/msalConfig.ts b/frontend/src/auth/msalConfig.ts new file mode 100644 index 000000000..a035ccbb0 --- /dev/null +++ b/frontend/src/auth/msalConfig.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * MSAL configuration for Entra ID PKCE authentication. + * + * The client ID and tenant ID are injected at runtime via the /api/auth/config + * endpoint (served by the backend from environment variables). This avoids + * hardcoding tenant-specific values in the frontend bundle. + * + * Uses access tokens (not ID tokens) with an API-specific scope so that + * Entra ID includes the `groups` claim for group-based authorization. + */ + +import { type Configuration, LogLevel } from '@azure/msal-browser' + +export interface AuthConfig { + clientId: string + tenantId: string + allowedGroupId: string +} + +let _cachedConfig: AuthConfig | null = null + +export async function fetchAuthConfig(): Promise { + if (_cachedConfig) return _cachedConfig + + try { + const response = await fetch('/api/auth/config') + if (!response.ok) { + // Auth endpoint not available — treat as auth disabled + return { clientId: '', tenantId: '', allowedGroupId: '' } + } + _cachedConfig = (await response.json()) as AuthConfig + return _cachedConfig + } catch { + // Network error (e.g., backend not running yet) — treat as auth disabled + return { clientId: '', tenantId: '', allowedGroupId: '' } + } +} + +export function buildMsalConfig(authConfig: AuthConfig): Configuration { + return { + auth: { + clientId: authConfig.clientId, + authority: `https://login.microsoftonline.com/${authConfig.tenantId}`, + redirectUri: window.location.origin, + postLogoutRedirectUri: window.location.origin, + }, + cache: { + cacheLocation: 'sessionStorage', + }, + system: { + loggerOptions: { + logLevel: LogLevel.Warning, + piiLoggingEnabled: false, + }, + }, + } +} + +/** + * Build the API scopes for token acquisition. + * + * Requests the explicit `access` scope rather than `.default` to avoid + * triggering admin consent in corporate tenants. The `.default` scope requires + * the app to list itself in `requiredResourceAccess`, which triggers mandatory + * admin consent in the Microsoft tenant. Using the explicit scope bypasses this. + * + * The `access` scope is defined in the app registration's "Expose an API" + * configuration. Access tokens include the `groups` claim when the app manifest + * has `groupMembershipClaims: "SecurityGroup"` configured. + */ +export function getApiScopes(clientId: string): string[] { + if (!clientId) return ['openid', 'profile', 'email'] + return [`${clientId}/access`] +} + +export function buildLoginRequest(clientId: string) { + return { + scopes: getApiScopes(clientId), + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d5fda85bd..9ff966daf 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,12 +1,15 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' +import { AuthProvider } from './auth/AuthProvider' import './styles/global.css' document.title = 'Co-PyRIT' ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index cd7e5e7b8..7b1e6f645 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -48,25 +48,25 @@ describe("api service", () => { expect(typeof requestInterceptor).toBe("function"); }); - it("request interceptor adds X-Request-ID header", () => { + it("request interceptor adds X-Request-ID header", async () => { const headers: Record & { set: (k: string, v: string) => void } = Object.assign( {} as Record, { set(k: string, v: string) { this[k] = v; } } ); const config = { headers }; - const result = requestInterceptor(config); + const result = await requestInterceptor(config); expect(result.headers["X-Request-ID"]).toBeDefined(); expect(typeof result.headers["X-Request-ID"]).toBe("string"); expect(result.headers["X-Request-ID"].length).toBeGreaterThan(0); }); - it("request interceptor generates UUID-like format", () => { + it("request interceptor generates UUID-like format", async () => { const headers: Record & { set: (k: string, v: string) => void } = Object.assign( {} as Record, { set(k: string, v: string) { this[k] = v; } } ); const config = { headers }; - const result = requestInterceptor(config); + const result = await requestInterceptor(config); // UUID v4 pattern: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx expect(result.headers["X-Request-ID"]).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c6f682bf8..9ccb05894 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { InteractionRequiredAuthError, type PublicClientApplication } from '@azure/msal-browser' import { toApiError } from './errors' import type { TargetInstance, @@ -44,18 +45,77 @@ function generateRequestId(): string { }) } -apiClient.interceptors.request.use((config) => { +// --------------------------------------------------------------------------- +// MSAL token acquisition for API calls +// --------------------------------------------------------------------------- + +let _msalInstance: PublicClientApplication | null = null +let _clientId: string = '' + +export function setMsalInstance(instance: PublicClientApplication): void { + _msalInstance = instance +} + +export function setClientId(clientId: string): void { + _clientId = clientId +} + +function getApiScopes(): string[] { + if (!_clientId) return ['openid', 'profile', 'email'] + return [`${_clientId}/access`] +} + +async function getAccessToken(forceRefresh = false): Promise { + if (!_msalInstance) return null + + const account = _msalInstance.getActiveAccount() + if (!account) return null + + try { + const response = await _msalInstance.acquireTokenSilent({ + scopes: getApiScopes(), + account, + forceRefresh, + }) + return response.accessToken + } catch (error) { + if (error instanceof InteractionRequiredAuthError) { + await _msalInstance.acquireTokenRedirect({ + scopes: getApiScopes(), + }) + } + return null + } +} + +apiClient.interceptors.request.use(async (config) => { config.headers.set('X-Request-ID', generateRequestId()) + + const token = await getAccessToken() + if (token) { + config.headers.set('Authorization', `Bearer ${token}`) + } + return config }) // --------------------------------------------------------------------------- -// Response interceptor: log errors with request context +// Response interceptor: retry once on 401 with forced token refresh // --------------------------------------------------------------------------- apiClient.interceptors.response.use( (response) => response, - (error) => { + async (error) => { + const originalRequest = error?.config + if (error?.response?.status === 401 && originalRequest && !originalRequest._retried) { + originalRequest._retried = true + const freshToken = await getAccessToken(true) + if (freshToken) { + originalRequest.headers.set('Authorization', `Bearer ${freshToken}`) + return apiClient(originalRequest) + } + } + const apiError = toApiError(error) const method = error?.config?.method?.toUpperCase() ?? '?' const url = error?.config?.url ?? '?' diff --git a/gui-deploy.yml b/gui-deploy.yml new file mode 100644 index 000000000..267c3fe23 --- /dev/null +++ b/gui-deploy.yml @@ -0,0 +1,229 @@ +# CI/CD pipeline for CoPyRIT GUI deployment. +# +# Triggers on changes to GUI-relevant paths on the main branch. +# Builds Docker image, pushes to ACR, deploys to test ACA environment. +# Production deployment is opt-in via the deployToProd parameter. +# +# All infrastructure details (IDs, connection strings, resource names) are +# stored in ADO variable groups — nothing sensitive appears in this file. +# Required variable groups: +# - copyrit-gui-common (azureServiceConnection, acrName, acrLoginServer, imageName) +# - copyrit-gui-test (resourceGroup, appName, entraTenantId, entraClientId, +# allowedGroupObjectIds, sqlServerFqdn, sqlDatabaseName, +# keyVaultResourceId, acrResourceId, enablePrivateEndpoint, enableOtel) +# - copyrit-gui-prod (same keys as test, with production values) + +trigger: + branches: + include: + - main + paths: + include: + - pyrit/backend/** + - frontend/** + - docker/** + - infra/** + +pr: none + +parameters: + - name: deployToProd + displayName: 'Deploy to production' + type: boolean + default: false + +# Service connection must be a compile-time variable (not from a variable group) +# because azureSubscription is validated during YAML parsing. +variables: + azureServiceConnection: 'copyrit-gui-azure' + +stages: + # ────────────────────────────────────────────── + # Stage 1: Build Docker image and push to ACR + # ────────────────────────────────────────────── + - stage: Build + displayName: 'Build and Push Image' + variables: + - group: copyrit-gui-common + pool: + vmImage: 'ubuntu-latest' + jobs: + - job: BuildAndPush + displayName: 'Build Docker image and push to ACR' + steps: + - checkout: self + fetchDepth: 1 + + - task: AzureCLI@2 + displayName: 'Build and push Docker image' + inputs: + azureSubscription: '$(azureServiceConnection)' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -euo pipefail + + echo "=== Building devcontainer base image ===" + docker build \ + -f .devcontainer/Dockerfile \ + -t pyrit-devcontainer \ + .devcontainer/ + + echo "=== Building production image ===" + docker build \ + -f docker/Dockerfile \ + -t $(acrLoginServer)/$(imageName):$(Build.SourceVersion) \ + -t $(acrLoginServer)/$(imageName):latest \ + --build-arg BASE_IMAGE=pyrit-devcontainer \ + --build-arg PYRIT_SOURCE=local \ + --build-arg GIT_COMMIT=$(Build.SourceVersion) \ + --build-arg GIT_MODIFIED=false \ + . + + echo "=== Pushing to ACR ===" + az acr login --name $(acrName) + docker push $(acrLoginServer)/$(imageName):$(Build.SourceVersion) + docker push $(acrLoginServer)/$(imageName):latest + + echo "✅ Image pushed: $(acrLoginServer)/$(imageName):$(Build.SourceVersion)" + + # ────────────────────────────────────────────── + # Stage 2: Deploy to test environment + # ────────────────────────────────────────────── + - stage: DeployTest + displayName: 'Deploy to Test' + dependsOn: Build + variables: + - group: copyrit-gui-common + - group: copyrit-gui-test + pool: + vmImage: 'ubuntu-latest' + jobs: + - deployment: DeployToTest + displayName: 'Deploy to test environment' + environment: 'copyrit-test' + strategy: + runOnce: + deploy: + steps: + - checkout: self + fetchDepth: 1 + + - task: AzureCLI@2 + displayName: 'Deploy Bicep to test' + inputs: + azureSubscription: '$(azureServiceConnection)' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -euo pipefail + + az deployment group create \ + --resource-group $(resourceGroup) \ + --template-file $(Build.SourcesDirectory)/infra/main.bicep \ + --parameters appName=$(appName) \ + --parameters containerImage=$(acrLoginServer)/$(imageName):$(Build.SourceVersion) \ + --parameters entraTenantId=$(entraTenantId) \ + --parameters entraClientId=$(entraClientId) \ + --parameters allowedGroupObjectIds="$(allowedGroupObjectIds)" \ + --parameters sqlServerFqdn=$(sqlServerFqdn) \ + --parameters sqlDatabaseName=$(sqlDatabaseName) \ + --parameters keyVaultResourceId=$(keyVaultResourceId) \ + --parameters acrResourceId=$(acrResourceId) \ + --parameters enablePrivateEndpoint=$(enablePrivateEndpoint) \ + --parameters enableOtel=$(enableOtel) \ + --parameters envSecretName=$(envSecretName) + + - task: AzureCLI@2 + displayName: 'Health check' + inputs: + azureSubscription: '$(azureServiceConnection)' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -euo pipefail + + FQDN=$(az containerapp show \ + --name $(appName) \ + --resource-group $(resourceGroup) \ + --query "properties.configuration.ingress.fqdn" -o tsv) + + echo "Testing https://$FQDN/api/health" + + curl --retry 5 --retry-delay 15 --retry-all-errors \ + --fail --max-time 10 \ + "https://$FQDN/api/health" + + echo "" + echo "✅ Test environment health check passed" + + # ────────────────────────────────────────────── + # Stage 3: Deploy to production (manual trigger) + # ────────────────────────────────────────────── + - ${{ if eq(parameters.deployToProd, true) }}: + - stage: DeployProd + displayName: 'Deploy to Production' + dependsOn: DeployTest + variables: + - group: copyrit-gui-common + - group: copyrit-gui-prod + pool: + vmImage: 'ubuntu-latest' + jobs: + - deployment: DeployToProd + displayName: 'Deploy to production environment' + environment: 'copyrit-prod' + strategy: + runOnce: + deploy: + steps: + - checkout: self + fetchDepth: 1 + + - task: AzureCLI@2 + displayName: 'Deploy Bicep to prod' + inputs: + azureSubscription: '$(azureServiceConnection)' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -euo pipefail + + az deployment group create \ + --resource-group $(resourceGroup) \ + --template-file $(Build.SourcesDirectory)/infra/main.bicep \ + --parameters appName=$(appName) \ + --parameters containerImage=$(acrLoginServer)/$(imageName):$(Build.SourceVersion) \ + --parameters entraTenantId=$(entraTenantId) \ + --parameters entraClientId=$(entraClientId) \ + --parameters allowedGroupObjectIds="$(allowedGroupObjectIds)" \ + --parameters sqlServerFqdn=$(sqlServerFqdn) \ + --parameters sqlDatabaseName=$(sqlDatabaseName) \ + --parameters keyVaultResourceId=$(keyVaultResourceId) \ + --parameters acrResourceId=$(acrResourceId) \ + --parameters enablePrivateEndpoint=$(enablePrivateEndpoint) \ + --parameters enableOtel=$(enableOtel) \ + --parameters envSecretName=$(envSecretName) + + - task: AzureCLI@2 + displayName: 'Health check' + inputs: + azureSubscription: '$(azureServiceConnection)' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -euo pipefail + + FQDN=$(az containerapp show \ + --name $(appName) \ + --resource-group $(resourceGroup) \ + --query "properties.configuration.ingress.fqdn" -o tsv) + + echo "Testing https://$FQDN/api/health" + + curl --retry 5 --retry-delay 15 --retry-all-errors \ + --fail --max-time 10 \ + "https://$FQDN/api/health" + + echo "" + echo "✅ Production health check passed" diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 000000000..6d151716c --- /dev/null +++ b/infra/README.md @@ -0,0 +1,452 @@ +# CoPyRIT GUI — Azure Deployment + +Deploy the CoPyRIT GUI as an Azure Container App with +[MSAL](https://learn.microsoft.com/en-us/entra/msal/) PKCE authentication, +managed identity, security response headers, and no embedded secrets. + +## Architecture + +``` +Users ──→ MSAL PKCE auth ──→ Container App + ↓ + MSAL PKCE auth + ↓ + FastAPI JWT middleware + ↓ + User-Assigned MI + ↙ ↙ ↓ ↘ ↘ + Azure SQL ACR Azure OpenAI Key Vault Storage + (MI auth) (AcrPull) (RBAC) (secret refs) (Blob) +``` + +Logging & monitoring: +``` +ACA Environment → Log Analytics (app logs) +Container App → Application Insights (OTel traces, when enabled) +``` + +## Development Workflow + +### Local development + +```bash +cd frontend +npm install # one-time: install frontend dependencies +npm start # starts both backend (port 8000) and frontend (port 3000) +``` + +`npm start` runs `dev.py`, which launches the FastAPI backend and Vite dev server +together, waits for the health check, and prints URLs when ready. Press Ctrl+C to +stop both. + +When `ENTRA_TENANT_ID` and `ENTRA_CLIENT_ID` are not set, auth is disabled — +all requests are allowed. Swagger UI is available at `http://localhost:8000/docs`. + +> ⚠️ Auth-disabled mode is for **local development only**. Never deploy to a +> network-accessible environment without both env vars set. + +### Promotion flow + +``` +Local dev → Push to branch → Trigger pipeline in ADO → Deploys to test → Opt-in prod deploy +``` + +The CI/CD pipeline (`gui-deploy.yml`) automates build → push → deploy. +Production is opt-in via `deployToProd: true`. + +## Security + +- **Authentication**: [MSAL](https://learn.microsoft.com/en-us/entra/msal/) + [PKCE](https://oauth.net/2/pkce/) on the frontend (`@azure/msal-browser`) + + FastAPI JWT middleware on the backend. Validates Bearer tokens against Entra ID + JWKS. PKCE (public client) — no client secrets or certificates needed. +- **Authorization**: Entra group check via `allowedGroupObjectIds` param. Requires + `groupMembershipClaims: "ApplicationGroup"` + optional claims + each security group + assigned to the enterprise app (see Prerequisites §3). If no group restriction is + set, all authenticated users pass. +- **Identity**: User-assigned managed identity (UAMI) — created before the container + app so [RBAC](https://learn.microsoft.com/en-us/azure/role-based-access-control/) + roles are active before the first revision starts. `AZURE_CLIENT_ID` is set to the + UAMI's client ID so `DefaultAzureCredential` uses the correct identity. +- **Network** (opt-in hardening): + - **Private Endpoint** (`enablePrivateEndpoint=true`): disables public access + entirely — the app is only reachable via the PE's private network. Requires VNet + peering or VPN to reach. When PE is enabled, `allowedCidr` is ignored. + - **IP restriction** (`allowedCidr`): restricts ingress to a + [CIDR](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing) range. + Only applies when PE is disabled. Empty = no IP-level restriction (auth still + required). + - Neither is required — MSAL auth + group checks are the primary access controls. +- **Response headers**: `SecurityHeadersMiddleware` adds + [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), + HSTS (production only), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, + Permissions-Policy, and Cache-Control (`no-store` on API routes). Swagger/OpenAPI + disabled in production. +- **Data**: Azure SQL with managed identity authentication (no passwords) +- **Secrets**: Key Vault with RBAC (existing vault, secrets referenced via + [ACA](https://learn.microsoft.com/en-us/azure/container-apps/) secretRef) +- **Images**: Unique tags or digests required — `:latest` is detected by a soft guardrail +- **Supply chain**: [ACR](https://learn.microsoft.com/en-us/azure/container-registry/) + pull via managed identity RBAC (must be granted manually; see Post-Deployment §2). + `frontend/.npmrc` pins the npm registry. `docker/Dockerfile` declares + `ARG BASE_IMAGE` with no default — all callers pass it explicitly to avoid container + supply chain security scanner warnings. +- **Tags**: All resources tagged with Service/Owner/DataClass for governance +- **Logging**: Log Analytics (app logs) + optional + [OTel](https://opentelemetry.io/) via Application Insights + +## Prerequisites + +> **Before you begin**: Run `az login` and confirm your subscription with +> `az account show`. You need permissions to create Entra app registrations, +> security groups, and Azure resource deployments. + +The Bicep template creates most infrastructure automatically (ACR, Log Analytics, +managed identity). Entra ID resources must be created +separately (Microsoft Graph, not ARM). Key Vault must be an existing vault +(avoids purge-protection issues on redeployment). RBAC role assignments must +be created manually — see [Post-Deployment §2](#post-deployment). + +**Requirements:** +- Azure CLI **2.84+** (version 2.77 has a known `content-already-consumed` bug) +- Container image must be pushed to ACR **before** deployment + +**Quick reference** — what you need before running `az deployment group create`: + +| # | What | How | Key Output | +|---|------|-----|------------| +| 1 | Resource group | `az group create` | `` name | +| 2 | Entra app registration | Portal or CLI (Graph API) | `entraClientId`, `entraTenantId` | +| 3 | Security group + SP assignment | Portal or CLI | `allowedGroupObjectIds` | +| 4 | SQL server with Entra admin | Existing server | `sqlServerFqdn`, `sqlDatabaseName` | +| 5 | Container image in ACR | Docker build + push | `containerImage` | +| 6 | Key Vault | Existing vault | `keyVaultResourceId` | + +### 1. Resource group + +```bash +az group create --name --location +``` + +### 2. Entra ID app registration (manual — not an ARM resource) + +No secrets or certificates needed — MSAL PKCE uses only the client ID (public client). + +```bash +# Create app registration (--service-management-reference may be required by your org) +az ad app create --display-name pyrit-gui --sign-in-audience AzureADMyOrg \ + --service-management-reference "" + +# Get the client ID (use this as entraClientId) +APP_ID=$(az ad app list --display-name pyrit-gui --query '[0].appId' -o tsv) +echo "entraClientId: $APP_ID" + +# Get the tenant ID (use this as entraTenantId) +az account show --query tenantId -o tsv +``` + +> **Note**: The redirect URI requires the app FQDN, which is only known after +> the first deployment. After deploying, set the SPA redirect URI: +> ```bash +> FQDN=$(az deployment group show -g -n main \ +> --query properties.outputs.appFqdn.value -o tsv) +> az ad app update --id $APP_ID \ +> --spa-redirect-uris "https://$FQDN" +> ``` + +**Expose an API scope** (required — the frontend requests `{clientId}/access` tokens): + +1. In Azure Portal → App registrations → your app → **Expose an API** +2. Set the Application ID URI (accept the default `api://`) +3. **Add a scope**: value = `access`, admin consent display name = "Access PyRIT GUI", + who can consent = "Admins and users", state = Enabled + +Or via CLI: +```bash +# Set application ID URI +APP_OBJ_ID=$(az ad app show --id $APP_ID --query id -o tsv) +az rest --method PATCH \ + --url "https://graph.microsoft.com/v1.0/applications/$APP_OBJ_ID" \ + --body "{\"identifierUris\": [\"api://$APP_ID\"]}" + +# Add the 'access' scope (generate a unique GUID for the scope ID) +SCOPE_ID=$(python3 -c "import uuid; print(uuid.uuid4())") +az rest --method PATCH \ + --url "https://graph.microsoft.com/v1.0/applications/$APP_OBJ_ID" \ + --body "{\"api\":{\"oauth2PermissionScopes\":[{\"id\":\"$SCOPE_ID\",\"isEnabled\":true,\"type\":\"User\",\"value\":\"access\",\"adminConsentDisplayName\":\"Access PyRIT GUI\",\"adminConsentDescription\":\"Allow access to the PyRIT GUI API\",\"userConsentDisplayName\":\"Access PyRIT GUI\",\"userConsentDescription\":\"Allow access to the PyRIT GUI API\"}]}}" +``` + +**Configure group claims** for group-based authorization: + +```bash +# Set groupMembershipClaims to ApplicationGroup (not SecurityGroup — the latter +# causes groups overage for users in >200 groups, breaking token-based group checks) +az rest --method PATCH \ + --url "https://graph.microsoft.com/v1.0/applications/$APP_OBJ_ID" \ + --body '{"groupMembershipClaims": "ApplicationGroup"}' +``` + +Then add `groups` as an optional claim for both ID tokens and access tokens: +Azure Portal → App registrations → your app → Token configuration → Add optional +claim → Token type: Access → check `groups` → Save. Repeat for ID token. + +### 3. Entra security groups (required for group-based authorization) + +Create one or more security groups for authorized users. Multiple groups can be +specified as comma-separated IDs in `allowedGroupObjectIds`. + +```bash +# Create security group for authorized users +# NOTE: This may require elevated permissions. If it fails, create the group +# in Azure Portal → Entra ID → Groups → New group (Security type). +az ad group create --display-name "MyApp-Users" --mail-nickname myapp-users + +# Get the group Object ID (use this as allowedGroupObjectIds) +GROUP_ID=$(az ad group show --group "MyApp-Users" --query id -o tsv) +echo "allowedGroupObjectIds: $GROUP_ID" + +# Add users to the group +az ad group member add --group "MyApp-Users" --member-id + +# List current members +az ad group member list --group "MyApp-Users" --query '[].displayName' -o tsv +``` + +**IMPORTANT: Assign each group to the enterprise application.** This is required for +`ApplicationGroup` to emit group IDs in tokens: + +```bash +# Get the service principal (enterprise app) object ID +SP_ID=$(az ad sp show --id $APP_ID --query id -o tsv) + +# Assign the security group (uses default access role) +az rest --method POST \ + --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_ID/appRoleAssignments" \ + --body "{\"principalId\": \"$GROUP_ID\", \"resourceId\": \"$SP_ID\", \"appRoleId\": \"00000000-0000-0000-0000-000000000000\"}" + +# Restrict token issuance to assigned users/groups only (recommended). +# Without this, any tenant user can obtain a token — they'll get a 403 from +# the backend group check, but defense-in-depth says reject at the IdP level. +az ad sp update --id $SP_ID --set appRoleAssignmentRequired=true +``` + +**Nested groups**: Entra enterprise app assignment does **not** cascade to nested +groups. If group A contains group B as a member, only direct members of A are +considered assigned. To grant access to members of B, assign B to the enterprise +app separately and include both group IDs in `allowedGroupObjectIds`. + +**App roles** (optional): You can define custom app roles on the app registration +(e.g., `MyApp.User.All`) and assign groups to specific roles instead of the +default access role. The backend currently authorizes via the `groups` token claim, +not `roles`, so app roles serve as organizational metadata and for +`appRoleAssignmentRequired` gating at the IdP level. + +### 4. Azure SQL server with Entra admin (existing) + +The container app's managed identity authenticates via Entra — no SQL passwords. + +```bash +# Check if Entra admin is already configured +az sql server ad-admin list \ + --resource-group --server-name + +# Set Entra admin (if not configured) — use your own user or a group +az sql server ad-admin create \ + --resource-group \ + --server-name \ + --display-name "SQL Entra Admin" \ + --object-id + +# Get the SQL server FQDN (use this as sqlServerFqdn) +az sql server show \ + --resource-group --name \ + --query fullyQualifiedDomainName -o tsv +``` + +### 5. Container image (**must be pushed to ACR before deployment**) + +A shared ACR is used by both test and prod environments. + +```bash +# Build image locally +cd +python docker/build_pyrit_docker.py --source local + +# Tag with commit SHA (never use :latest) +COMMIT_SHA=$(git rev-parse --short HEAD) +ACR_NAME= + +docker tag pyrit:latest $ACR_NAME.azurecr.io/pyrit:$COMMIT_SHA +az acr login --name $ACR_NAME +docker push $ACR_NAME.azurecr.io/pyrit:$COMMIT_SHA +echo "containerImage: $ACR_NAME.azurecr.io/pyrit:$COMMIT_SHA" +``` + +> **Note**: The CI/CD pipeline handles build + push automatically. Manual push is +> only needed for the initial bootstrap or if deploying outside the pipeline. + +### 6. Key Vault (existing — required) + +Use an existing Key Vault to avoid soft-delete/purge-protection naming conflicts +on redeployment. The managed identity must be granted `Key Vault Secrets User` on +the vault manually (the Bicep template does **not** create RBAC role assignments). + +```bash +# Create a vault (if your org doesn't provide one) +az keyvault create \ + --resource-group \ + --name \ + --enable-rbac-authorization true \ + --enable-purge-protection true + +# Get the vault resource ID (use this as keyVaultResourceId) +az keyvault show --name --query id -o tsv +``` + +> **Note**: The vault should have `enableRbacAuthorization: true` so the managed +> identity can be granted access. Diagnostic settings (AuditEvent logs) should be +> configured on the vault separately by the vault owner. + +## Deploy + +```bash +# Copy and fill in parameters +cp infra/parameters.example.json infra/parameters.json +# Edit parameters.json with your values + +# Deploy +az deployment group create \ + --resource-group \ + --template-file infra/main.bicep \ + --parameters @infra/parameters.json +``` + +## Post-Deployment + +1. **Set SPA redirect URI** on the app registration (requires the FQDN from deploy output): + ```bash + FQDN=$(az deployment group show -g -n main \ + --query properties.outputs.appFqdn.value -o tsv) + az ad app update --id \ + --spa-redirect-uris "https://$FQDN" + ``` + +2. **Grant managed identity RBAC** (required — the Bicep template does **not** create + role assignments; the app will fail to start without AcrPull and KV roles): + ```bash + MI_ID=$(az deployment group show -g -n main \ + --query properties.outputs.managedIdentityPrincipalId.value -o tsv) + + # Required — app won't start without these + # To find acrResourceId: az acr show --name --query id -o tsv + az role assignment create --assignee-object-id $MI_ID \ + --assignee-principal-type ServicePrincipal --role "AcrPull" --scope + az role assignment create --assignee-object-id $MI_ID \ + --assignee-principal-type ServicePrincipal --role "Key Vault Secrets User" --scope + + # Grant based on which services you use (scope as narrowly as possible) + az role assignment create --assignee-object-id $MI_ID \ + --assignee-principal-type ServicePrincipal --role "Cognitive Services OpenAI User" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ + az role assignment create --assignee-object-id $MI_ID \ + --assignee-principal-type ServicePrincipal --role "Cognitive Services User" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ + az role assignment create --assignee-object-id $MI_ID \ + --assignee-principal-type ServicePrincipal --role "Storage Blob Data Contributor" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.Storage/storageAccounts/ + az role assignment create --assignee-object-id $MI_ID \ + --assignee-principal-type ServicePrincipal --role "Azure ML Data Scientist" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.MachineLearningServices/workspaces/ + ``` + +3. **Create Azure SQL contained user** for the managed identity: + ```sql + -- Connect as Entra admin (Azure Portal Query Editor, Azure Data Studio, or sqlcmd) + CREATE USER [-identity] FROM EXTERNAL PROVIDER; + ALTER ROLE db_datareader ADD MEMBER [-identity]; + ALTER ROLE db_datawriter ADD MEMBER [-identity]; + ``` + +4. **Manage access** — Add or remove users via Entra security groups + (`allowedGroupObjectIds`). Each group must also be assigned to the enterprise app. + +5. **Set CORS origins** for production (the Bicep template does not set this): + ```bash + az containerapp update -n -g \ + --set-env-vars "PYRIT_CORS_ORIGINS=https://$FQDN" + ``` + +## Access the GUI + +```bash +az deployment group show -g -n main --query properties.outputs.appFqdn.value -o tsv +``` + +Open `https://` in a browser. If `allowedCidr` is set, only traffic from +that CIDR range can reach the app. + +## Configuration: .pyrit_conf and .env + +The template replaces `.pyrit_conf` and `.env` with Bicep parameters — no files +needed in the container. + +### .pyrit_conf fields → Bicep params + +| .pyrit_conf field | Bicep param | Env var | Notes | +|-------------------|-------------|---------|-------| +| `initializers` | `pyritInitializer` | `PYRIT_INITIALIZER` | Default `targets airt`: `targets` populates the TargetRegistry (read by the GUI); `airt` loads converter, scorer, and adversarial defaults | +| `operator` | — | Set per-user in the GUI | | +| `operation` | — | Set per-user in the GUI | | + +### .env file → Key Vault secret + +The entire `.env` file is stored as a single Key Vault secret (`env-global` by +default). The template references it via ACA secret and injects it as the +`PYRIT_ENV_CONTENTS` env var. PyRIT parses this at startup to set all endpoint, +model, and API key environment variables. + +To update the `.env` contents: +```bash +az keyvault secret set --vault-name --name env-global --file ~/.pyrit/.env +``` + +> ⚠️ `PYRIT_ENV_CONTENTS` may contain API keys. Ensure application logging does +> **not** dump environment variables or process state. + +Azure services (OpenAI, Content Safety, Speech) support managed identity — when +API key env vars are not set, PyRIT auto-falls back to `DefaultAzureCredential`, +which picks up the container app's user-assigned MI. Non-Azure providers (OpenAI +Platform, Groq, Google Gemini) require API keys in the `.env`. + +## Notes + +- **Network hardening** (opt-in): Both Private Endpoint and IP restriction are + optional. See the Security section for details. The CI/CD pipeline controls + `enablePrivateEndpoint` via the ADO variable group — check your pipeline variables + to confirm the current posture. +- **Log Analytics shared key**: `listKeys()` is the standard ACA pattern. The key is + used during deployment only, not exposed to the application. +- **Workload profiles**: Consumption tier. Defaults to 1 replica (no auto-scale). +- **Key Vault**: Must be an existing vault. RBAC must be granted manually (see + Post-Deployment §2). +- **OpenTelemetry**: When `enableOtel=true`, configure the agent post-deploy: + ```bash + AI_CONN=$(az deployment group show -g -n main \ + --query properties.outputs.appInsightsConnectionString.value -o tsv) + az containerapp env telemetry app-insights set \ + --name -env -g --connection-string "$AI_CONN" + ``` +- **Existing resources**: Log Analytics, VNet, and ACR can be provided as existing + resources to skip creation. +- **Azure CLI**: Version 2.84+ required (2.77 has a known bug). + +## Teardown and Redeployment + +```bash +az group delete --name --yes +``` + +Key Vault is external to the RG — no purge-protection naming conflicts. + +> **Note**: Entra ID resources (app registration, security groups) are **not** deleted +> by `az group delete`. Remove them manually if no longer needed. diff --git a/infra/main.bicep b/infra/main.bicep new file mode 100644 index 000000000..31560c2b9 --- /dev/null +++ b/infra/main.bicep @@ -0,0 +1,531 @@ +// ============================================================================ +// PyRIT GUI — Azure Container Apps Deployment (Security-Hardened) +// +// Deploys the CoPyRIT GUI as an Azure Container App with: +// - Workload profiles environment with public ingress + optional IP restriction +// - MSAL PKCE authentication (frontend) + FastAPI JWT middleware (backend) +// - User-assigned managed identity for Azure SQL, ACR, Azure OpenAI, Key Vault +// - Azure SQL (existing) via managed identity — no passwords +// - Key Vault for secrets (referenced via ACA secretRef, not embedded) +// - Centralized logging via Log Analytics (configurable retention) +// - No storage account keys, no embedded secrets, no :latest tags +// +// Prerequisites: +// 1. An Entra ID app registration (no secrets/certs needed — PKCE public client) +// 2. A container image pushed to an Azure Container Registry (unique tag or digest) +// 3. Existing Azure SQL server with Entra admin configured +// +// Usage: +// az deployment group create \ +// --resource-group \ +// --template-file infra/main.bicep \ +// --parameters appName=pyrit-gui \ +// containerImage=.azurecr.io/pyrit: \ +// entraClientId= \ +// entraTenantId= \ +// allowedGroupObjectIds= \ +// allowedCidr='' \ +// sqlServerFqdn=.database.windows.net \ +// sqlDatabaseName= \ +// keyVaultResourceId= +// ============================================================================ + +// --- Parameters --- + +@description('Name for the Container App and related resources') +param appName string = 'pyrit-gui' + +@description('Azure region for all resources') +param location string = resourceGroup().location + +@description('Container image — must use a unique tag (commit SHA) or digest, never :latest. Enforce in CI pipeline.') +@metadata({ example: 'myacr.azurecr.io/pyrit:a1b2c3d or myacr.azurecr.io/pyrit@sha256:...' }) +param containerImage string + +@description('Entra ID tenant ID') +param entraTenantId string + +@description('Entra ID app registration client ID (no secrets needed)') +param entraClientId string + +@description('Object ID of the Entra security group allowed to access the GUI') +@metadata({ description: 'Find this in Azure Portal → Entra ID → Groups → your group → Object ID' }) +param allowedGroupObjectIds string + +@description('CIDR range allowed to reach the app (e.g., your corp VPN CIDR). Empty = no IP restriction, all traffic allowed.') +param allowedCidr string = '' + +@description('Human-readable description for the IP restriction rule') +param allowedCidrDescription string = 'Allowed IP range' + +@description('Azure SQL server FQDN (e.g., myserver.database.windows.net)') +param sqlServerFqdn string + +@description('Azure SQL database name') +param sqlDatabaseName string + +// --- PyRIT Configuration (.pyrit_conf equivalent) --- +// Note: operator and operation are per-user settings configured in the GUI, +// not deployment-level config. + +@description('PyRIT initializer to run. Default "targets airt" registers target configs + attack defaults.') +param pyritInitializer string = 'targets airt' + +@description('Key Vault secret name containing the .env file contents (all endpoints, models, and API keys). The secret is mounted as an env var and PyRIT parses it at startup.') +param envSecretName string = 'env-global' + +@description('Container CPU cores') +param cpuCores string = '1.0' + +@description('Container memory in GB') +param memoryGb string = '2.0' + +@description('Minimum number of replicas') +param minReplicas int = 1 + +@description('Maximum number of replicas') +param maxReplicas int = 1 + +@description('Azure Container Registry name (for managed identity pull). Used if acrResourceId is not provided.') +param acrName string = '' + +@description('Enable Private Endpoint for the ACA environment. When false, uses public access with IP restrictions.') +param enablePrivateEndpoint bool = true + +@description('VNet address prefix (used only when creating a new VNet)') +param vnetAddressPrefix string = '10.0.0.0/16' + +@description('Subnet address prefix for the Private Endpoint (used only when creating a new subnet)') +param subnetAddressPrefix string = '10.0.0.0/24' + +@description('Resource ID of an existing subnet for the Private Endpoint. If empty, a new VNet + subnet is created.') +@metadata({ example: '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/' }) +param infrastructureSubnetId string = '' + +@description('Log Analytics retention in days (used only when creating a new workspace)') +param logRetentionDays int = 90 + +@description('Resource ID of an existing Log Analytics workspace. If provided, you must also provide logAnalyticsCustomerId. Recommended for orgs with a central governance workspace.') +param logAnalyticsWorkspaceId string = '' + +@description('Customer ID of an existing Log Analytics workspace (required if logAnalyticsWorkspaceId is provided)') +param logAnalyticsCustomerId string = '' + +@secure() +@description('Shared key of an existing Log Analytics workspace (required if logAnalyticsWorkspaceId is provided). This is used only for ACA log ingestion config.') +param logAnalyticsSharedKey string = '' + +@description('Resource ID of an existing Key Vault (required). Use your org\'s governed vault to avoid soft-delete/purge-protection issues on redeployment.') +param keyVaultResourceId string + +@description('Resource ID of the Azure Container Registry (for AcrPull role assignment). Recommended over acrName for IaC-managed access.') +param acrResourceId string = '' + +@description('Resource tags applied to all resources (ownership + data classification)') +param tags object = { + Service: 'pyrit-gui' + Owner: '' + DataClass: '' +} + +@description('Enable OpenTelemetry managed agent for audit logging. Creates Application Insights and wires the ACA managed OTel collector.') +param enableOtel bool = false + +// Soft guardrail: detect :latest usage (enforced via output warning) +var imageUsesLatest = endsWith(containerImage, ':latest') + +// Determine whether to create or reference existing resources +var createLogAnalytics = logAnalyticsWorkspaceId == '' +var createVnet = enablePrivateEndpoint && infrastructureSubnetId == '' +var createAcr = acrResourceId == '' && acrName == '' + +// ============================================================================ +// VNet + Subnet (created only if infrastructureSubnetId is not provided) +// The subnet hosts the Private Endpoint for the ACA environment — no ACA +// delegation needed (that's only for VNet-integrated internal environments). +// ============================================================================ +resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' = if (createVnet) { + name: '${appName}-vnet' + location: location + tags: tags + properties: { + addressSpace: { + addressPrefixes: [ + vnetAddressPrefix + ] + } + subnets: [ + { + name: '${appName}-pe-subnet' + properties: { + addressPrefix: subnetAddressPrefix + privateEndpointNetworkPolicies: 'Disabled' + } + } + ] + } +} + +var effectiveSubnetId = createVnet ? vnet.properties.subnets[0].id : infrastructureSubnetId + +// ============================================================================ +// Azure Container Registry (created only if neither acrResourceId nor acrName is provided) +// ============================================================================ +resource newAcr 'Microsoft.ContainerRegistry/registries@2023-08-01-preview' = if (createAcr) { + name: '${replace(appName, '-', '')}acr' + location: location + tags: tags + sku: { + name: 'Standard' + } + properties: { + adminUserEnabled: false + } +} + +var effectiveAcrName = createAcr ? newAcr.name : (acrName != '' ? acrName : last(split(acrResourceId, '/'))) +var effectiveAcrServer = '${effectiveAcrName}.azurecr.io' + +// ============================================================================ +// Log Analytics Workspace +// Created only if logAnalyticsWorkspaceId is not provided. For orgs with a +// central governance workspace, pass the existing workspace ID instead. +// Note: The ACA environment requires a shared key to connect to Log Analytics. +// This is the only supported integration method as of the 2024-03-01 API. +// The key is used during deployment for log ingestion config only — it is NOT +// injected into the container or accessible to application code. +// ============================================================================ +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (createLogAnalytics) { + name: '${appName}-logs' + location: location + tags: tags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: logRetentionDays + } +} + +var effectiveLogAnalyticsCustomerIdValue = createLogAnalytics ? logAnalytics.properties.customerId : logAnalyticsCustomerId +var effectiveLogAnalyticsKeyValue = createLogAnalytics ? logAnalytics.listKeys().primarySharedKey : logAnalyticsSharedKey + +// ============================================================================ +// Application Insights (created when OTel is enabled — destination for traces/logs) +// ============================================================================ +resource appInsights 'Microsoft.Insights/components@2020-02-02' = if (enableOtel) { + name: '${appName}-ai' + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: createLogAnalytics ? logAnalytics.id : logAnalyticsWorkspaceId + } +} + +// ============================================================================ +// User-Assigned Managed Identity +// Created BEFORE the container app so roles can be granted before the first +// revision starts. This avoids the chicken-and-egg problem with system-assigned +// MI where the revision tries to pull images / access KV before RBAC propagates. +// ============================================================================ +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: '${appName}-identity' + location: location + tags: tags +} + +// ============================================================================ +// Key Vault (existing — avoids soft-delete/purge-protection redeployment issues) +// All auth uses managed identity (Azure SQL, ACR, AOAI). The vault is for +// downstream API keys or sensitive config added as ACA Key Vault secret +// references. Ensure the vault has RBAC authorization enabled. +// ============================================================================ +// Extract KV name and resource group from the resource ID. +// keyVaultResourceId format: /subscriptions/.../resourceGroups//providers/.../vaults/ +var keyVaultName = last(split(keyVaultResourceId, '/')) + +// ============================================================================ +// RBAC role assignments are NOT managed by this template. +// Grant the following roles to the UAMI manually before first deployment: +// - Key Vault Secrets User on the Key Vault +// - AcrPull on the ACR +// See Post-Deployment in infra/README.md for commands. +// ============================================================================ + +// ============================================================================ +// Azure Container Apps Environment (workload profiles, public network disabled) +// Uses Private Endpoint pattern instead of VNet-integrated internal mode: +// - Environment is NOT VNet-integrated (no internal ILB) +// - Public network access is disabled +// - A Private Endpoint provides corp-reachable connectivity via Private Link +// - Private DNS zone resolves the FQDN to the private endpoint IP +// +// OTel: When enableOtel=true, configure the managed OTel agent +// as a post-deploy CLI step (2024-03-01 schema does not support it natively). +// ============================================================================ +resource acaEnvironment 'Microsoft.App/managedEnvironments@2024-10-02-preview' = { + name: '${appName}-env' + location: location + tags: tags + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: effectiveLogAnalyticsCustomerIdValue + sharedKey: effectiveLogAnalyticsKeyValue + } + } + publicNetworkAccess: enablePrivateEndpoint ? 'Disabled' : 'Enabled' + workloadProfiles: [ + { + name: 'Consumption' + workloadProfileType: 'Consumption' + } + ] + } +} + +// NOTE: When enableOtel=true, configure the OpenTelemetry managed agent on the +// environment as a post-deployment step using az CLI: +// az containerapp env telemetry app-insights set \ +// --name ${appName}-env -g \ +// --connection-string +// The Bicep API (2024-03-01) does not support openTelemetryConfiguration natively. + +// ============================================================================ +// Private Endpoint for ACA Environment (corp-reachable via Private Link) +// The PE must be in a VNet that corp VPN/ExpressRoute can reach. +// ============================================================================ +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2023-11-01' = if (enablePrivateEndpoint) { + name: '${appName}-pe' + location: location + tags: tags + properties: { + subnet: { + id: effectiveSubnetId + } + privateLinkServiceConnections: [ + { + name: '${appName}-pe-connection' + properties: { + privateLinkServiceId: acaEnvironment.id + groupIds: [ + 'managedEnvironments' + ] + } + } + ] + } +} + +// Private DNS Zone for ACA Private Endpoint resolution +resource privateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = if (enablePrivateEndpoint) { + name: 'privatelink.${location}.azurecontainerapps.io' + location: 'global' + tags: tags +} + +// Link DNS zone to the VNet so clients in the VNet can resolve +resource dnsZoneVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (enablePrivateEndpoint) { + name: '${appName}-dns-link' + parent: privateDnsZone + location: 'global' + tags: tags + properties: { + virtualNetwork: { + id: createVnet ? vnet.id : join(take(split(infrastructureSubnetId, '/'), 9), '/') + } + registrationEnabled: false + } +} + +// DNS record group for the private endpoint +resource privateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2023-11-01' = if (enablePrivateEndpoint) { + name: 'default' + parent: privateEndpoint + properties: { + privateDnsZoneConfigs: [ + { + name: 'aca-dns-config' + properties: { + privateDnsZoneId: privateDnsZone.id + } + } + ] + } +} + +// ============================================================================ +// Container App — PyRIT GUI +// ============================================================================ +resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { + name: appName + location: location + tags: tags + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentity.id}': {} + } + } + // RBAC roles (AcrPull, KV Secrets User) must be granted manually before + // the first deployment — see infra/README.md Post-Deployment §2. + dependsOn: [] + properties: { + managedEnvironmentId: acaEnvironment.id + configuration: { + // Single revision mode — only one revision serves traffic (appropriate for GUI) + activeRevisionsMode: 'Single' + + // Ingress — external at the app level (access is controlled by + // Private Endpoint + disabled public network access, not ingress type) + ingress: { + external: true + targetPort: 8000 + transport: 'http' + allowInsecure: false + ipSecurityRestrictions: allowedCidr != '' ? [ + { + name: 'allowed-cidr' + description: allowedCidrDescription + ipAddressRange: allowedCidr + action: 'Allow' + } + ] : [] + } + + // ACR pull with managed identity (works whether ACR is created or existing) + registries: [ + { + server: effectiveAcrServer + identity: managedIdentity.id + } + ] + + // Key Vault secret reference for the .env file contents + secrets: [ + { + name: 'env-file' + keyVaultUrl: 'https://${keyVaultName}${environment().suffixes.keyvaultDns}/secrets/${envSecretName}' + identity: managedIdentity.id + } + ] + } + + template: { + containers: [ + { + name: 'pyrit-gui' + image: containerImage + resources: { + cpu: json(cpuCores) + memory: '${memoryGb}Gi' + } + env: [ + { + name: 'PYRIT_MODE' + value: 'gui' + } + { + name: 'AZURE_SQL_SERVER' + value: sqlServerFqdn + } + { + name: 'AZURE_SQL_DATABASE' + value: sqlDatabaseName + } + // .pyrit_conf equivalent (operator/operation set per-user in GUI) + { + name: 'PYRIT_INITIALIZER' + value: pyritInitializer + } + // .env file contents from Key Vault — PyRIT parses this at startup + { + name: 'PYRIT_ENV_CONTENTS' + secretRef: 'env-file' + } + // MSAL PKCE auth config — frontend uses these to authenticate users + // Easy Auth is NOT used because the tenant blocks client secrets/certs + // on app registrations. PKCE (public client) needs no secrets. + { + name: 'ENTRA_CLIENT_ID' + value: entraClientId + } + { + name: 'ENTRA_TENANT_ID' + value: entraTenantId + } + { + name: 'ENTRA_ALLOWED_GROUP_IDS' + value: allowedGroupObjectIds + } + // OTel: point the SDK at the ACA managed agent (localhost sidecar) + { + name: 'OTEL_EXPORTER_OTLP_ENDPOINT' + value: enableOtel ? 'http://localhost:4318' : '' + } + { + name: 'OTEL_SERVICE_NAME' + value: appName + } + // DefaultAzureCredential needs the UAMI client ID to pick the correct identity + { + name: 'AZURE_CLIENT_ID' + value: managedIdentity.properties.clientId + } + ] + } + ] + scale: { + minReplicas: minReplicas + maxReplicas: maxReplicas + } + } + } +} + +// ============================================================================ +// NOTE: Easy Auth (authConfigs) is intentionally NOT used. +// The tenant's credential policy blocks client secrets and trusted-CA-only +// certificates on app registrations, making Easy Auth's OAuth authorization +// code flow impossible. Instead, authentication is handled in-app using +// MSAL with PKCE (public client flow) — no secrets needed. +// The frontend uses @azure/msal-browser for login; the backend validates +// JWTs from the Authorization header against Entra JWKS. +// ============================================================================ + +// ============================================================================ +// Outputs +// ============================================================================ + +@description('The FQDN of the deployed Container App') +output appFqdn string = containerApp.properties.configuration.ingress.fqdn + +@description('The default domain of the ACA environment') +output environmentDefaultDomain string = acaEnvironment.properties.defaultDomain + +@description('Private Endpoint resource ID (empty when PE is disabled)') +output privateEndpointId string = enablePrivateEndpoint ? privateEndpoint.id : '' + +@description('The principal ID of the user-assigned managed identity — grant this Cognitive Services OpenAI User on your AOAI instances and db_datareader/db_datawriter on Azure SQL') +output managedIdentityPrincipalId string = managedIdentity.properties.principalId + +@description('The resource ID of the user-assigned managed identity') +output managedIdentityResourceId string = managedIdentity.id + +@description('IMPORTANT: Create an Azure AD contained user in the target database for this managed identity. See README post-deployment steps.') +output sqlAadSetupRequired string = 'Run CREATE USER [${appName}-identity] FROM EXTERNAL PROVIDER on database ${sqlDatabaseName}' + +@description('Key Vault name (existing)') +output keyVaultName string = keyVaultName + +@description('ACR login server') +output acrLoginServer string = effectiveAcrServer + +@description('VNet name (if created by this template)') +output vnetName string = createVnet ? vnet.name : 'N/A (existing VNet used)' + +@description('Application Insights connection string (if OTel enabled)') +output appInsightsConnectionString string = enableOtel ? appInsights.properties.ConnectionString : 'N/A (OTel disabled)' diff --git a/infra/main.json b/infra/main.json new file mode 100644 index 000000000..b93b4809f --- /dev/null +++ b/infra/main.json @@ -0,0 +1,579 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "16381559389474872325" + } + }, + "parameters": { + "appName": { + "type": "string", + "defaultValue": "pyrit-gui", + "metadata": { + "description": "Name for the Container App and related resources" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Azure region for all resources" + } + }, + "containerImage": { + "type": "string", + "metadata": { + "example": "myacr.azurecr.io/pyrit:a1b2c3d or myacr.azurecr.io/pyrit@sha256:...", + "description": "Container image — must use a unique tag (commit SHA) or digest, never :latest. Enforce in CI pipeline." + } + }, + "entraTenantId": { + "type": "string", + "metadata": { + "description": "Entra ID tenant ID" + } + }, + "entraClientId": { + "type": "string", + "metadata": { + "description": "Entra ID app registration client ID (no secrets needed)" + } + }, + "allowedGroupObjectIds": { + "type": "string", + "metadata": { + "description": "Object ID of the Entra security group allowed to access the GUI" + } + }, + "allowedCidr": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "CIDR range allowed to reach the app (e.g., your corp VPN CIDR). Empty = no IP restriction, all traffic allowed." + } + }, + "allowedCidrDescription": { + "type": "string", + "defaultValue": "Allowed IP range", + "metadata": { + "description": "Human-readable description for the IP restriction rule" + } + }, + "sqlServerFqdn": { + "type": "string", + "metadata": { + "description": "Azure SQL server FQDN (e.g., myserver.database.windows.net)" + } + }, + "sqlDatabaseName": { + "type": "string", + "metadata": { + "description": "Azure SQL database name" + } + }, + "pyritInitializer": { + "type": "string", + "defaultValue": "targets airt", + "metadata": { + "description": "PyRIT initializer to run. Default \"targets airt\" registers target configs + attack defaults." + } + }, + "envSecretName": { + "type": "string", + "defaultValue": "env-global", + "metadata": { + "description": "Key Vault secret name containing the .env file contents (all endpoints, models, and API keys). The secret is mounted as an env var and PyRIT parses it at startup." + } + }, + "cpuCores": { + "type": "string", + "defaultValue": "1.0", + "metadata": { + "description": "Container CPU cores" + } + }, + "memoryGb": { + "type": "string", + "defaultValue": "2.0", + "metadata": { + "description": "Container memory in GB" + } + }, + "minReplicas": { + "type": "int", + "defaultValue": 1, + "metadata": { + "description": "Minimum number of replicas" + } + }, + "maxReplicas": { + "type": "int", + "defaultValue": 1, + "metadata": { + "description": "Maximum number of replicas" + } + }, + "acrName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Azure Container Registry name (for managed identity pull). Used if acrResourceId is not provided." + } + }, + "enablePrivateEndpoint": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Enable Private Endpoint for the ACA environment. When false, uses public access with IP restrictions." + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "10.0.0.0/16", + "metadata": { + "description": "VNet address prefix (used only when creating a new VNet)" + } + }, + "subnetAddressPrefix": { + "type": "string", + "defaultValue": "10.0.0.0/24", + "metadata": { + "description": "Subnet address prefix for the Private Endpoint (used only when creating a new subnet)" + } + }, + "infrastructureSubnetId": { + "type": "string", + "defaultValue": "", + "metadata": { + "example": "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/", + "description": "Resource ID of an existing subnet for the Private Endpoint. If empty, a new VNet + subnet is created." + } + }, + "logRetentionDays": { + "type": "int", + "defaultValue": 90, + "metadata": { + "description": "Log Analytics retention in days (used only when creating a new workspace)" + } + }, + "logAnalyticsWorkspaceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource ID of an existing Log Analytics workspace. If provided, you must also provide logAnalyticsCustomerId. Recommended for orgs with a central governance workspace." + } + }, + "logAnalyticsCustomerId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Customer ID of an existing Log Analytics workspace (required if logAnalyticsWorkspaceId is provided)" + } + }, + "logAnalyticsSharedKey": { + "type": "securestring", + "defaultValue": "", + "metadata": { + "description": "Shared key of an existing Log Analytics workspace (required if logAnalyticsWorkspaceId is provided). This is used only for ACA log ingestion config." + } + }, + "keyVaultResourceId": { + "type": "string", + "metadata": { + "description": "Resource ID of an existing Key Vault (required). Use your org's governed vault to avoid soft-delete/purge-protection issues on redeployment." + } + }, + "acrResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource ID of the Azure Container Registry (for AcrPull role assignment). Recommended over acrName for IaC-managed access." + } + }, + "tags": { + "type": "object", + "defaultValue": { + "Service": "pyrit-gui", + "Owner": "", + "DataClass": "" + }, + "metadata": { + "description": "Resource tags applied to all resources (ownership + data classification)" + } + }, + "enableOtel": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Enable OpenTelemetry managed agent for audit logging. Creates Application Insights and wires the ACA managed OTel collector." + } + } + }, + "variables": { + "imageUsesLatest": "[endsWith(parameters('containerImage'), ':latest')]", + "createLogAnalytics": "[equals(parameters('logAnalyticsWorkspaceId'), '')]", + "createVnet": "[and(parameters('enablePrivateEndpoint'), equals(parameters('infrastructureSubnetId'), ''))]", + "createAcr": "[and(equals(parameters('acrResourceId'), ''), equals(parameters('acrName'), ''))]", + "effectiveAcrName": "[if(variables('createAcr'), format('{0}acr', replace(parameters('appName'), '-', '')), if(not(equals(parameters('acrName'), '')), parameters('acrName'), last(split(parameters('acrResourceId'), '/'))))]", + "effectiveAcrServer": "[format('{0}.azurecr.io', variables('effectiveAcrName'))]", + "keyVaultName": "[last(split(parameters('keyVaultResourceId'), '/'))]" + }, + "resources": [ + { + "condition": "[variables('createVnet')]", + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2023-11-01", + "name": "[format('{0}-vnet', parameters('appName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "[parameters('vnetAddressPrefix')]" + ] + }, + "subnets": [ + { + "name": "[format('{0}-pe-subnet', parameters('appName'))]", + "properties": { + "addressPrefix": "[parameters('subnetAddressPrefix')]", + "privateEndpointNetworkPolicies": "Disabled" + } + } + ] + } + }, + { + "condition": "[variables('createAcr')]", + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-08-01-preview", + "name": "[format('{0}acr', replace(parameters('appName'), '-', ''))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Standard" + }, + "properties": { + "adminUserEnabled": false + } + }, + { + "condition": "[variables('createLogAnalytics')]", + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2023-09-01", + "name": "[format('{0}-logs', parameters('appName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": "[parameters('logRetentionDays')]" + } + }, + { + "condition": "[parameters('enableOtel')]", + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[format('{0}-ai', parameters('appName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[if(variables('createLogAnalytics'), resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-logs', parameters('appName'))), parameters('logAnalyticsWorkspaceId'))]" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-logs', parameters('appName')))]" + ] + }, + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2023-01-31", + "name": "[format('{0}-identity', parameters('appName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.App/managedEnvironments", + "apiVersion": "2024-10-02-preview", + "name": "[format('{0}-env', parameters('appName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "appLogsConfiguration": { + "destination": "log-analytics", + "logAnalyticsConfiguration": { + "customerId": "[if(variables('createLogAnalytics'), reference(resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-logs', parameters('appName'))), '2023-09-01').customerId, parameters('logAnalyticsCustomerId'))]", + "sharedKey": "[if(variables('createLogAnalytics'), listKeys(resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-logs', parameters('appName'))), '2023-09-01').primarySharedKey, parameters('logAnalyticsSharedKey'))]" + } + }, + "publicNetworkAccess": "[if(parameters('enablePrivateEndpoint'), 'Disabled', 'Enabled')]", + "workloadProfiles": [ + { + "name": "Consumption", + "workloadProfileType": "Consumption" + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-logs', parameters('appName')))]" + ] + }, + { + "condition": "[parameters('enablePrivateEndpoint')]", + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2023-11-01", + "name": "[format('{0}-pe', parameters('appName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "subnet": { + "id": "[if(variables('createVnet'), reference(resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('appName'))), '2023-11-01').subnets[0].id, parameters('infrastructureSubnetId'))]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-pe-connection', parameters('appName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.App/managedEnvironments', format('{0}-env', parameters('appName')))]", + "groupIds": [ + "managedEnvironments" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.App/managedEnvironments', format('{0}-env', parameters('appName')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('appName')))]" + ] + }, + { + "condition": "[parameters('enablePrivateEndpoint')]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2024-06-01", + "name": "[format('privatelink.{0}.azurecontainerapps.io', parameters('location'))]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enablePrivateEndpoint')]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', format('privatelink.{0}.azurecontainerapps.io', parameters('location')), format('{0}-dns-link', parameters('appName')))]", + "location": "global", + "tags": "[parameters('tags')]", + "properties": { + "virtualNetwork": { + "id": "[if(variables('createVnet'), resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('appName'))), join(take(split(parameters('infrastructureSubnetId'), '/'), 9), '/'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.azurecontainerapps.io', parameters('location')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('appName')))]" + ] + }, + { + "condition": "[parameters('enablePrivateEndpoint')]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2023-11-01", + "name": "[format('{0}/{1}', format('{0}-pe', parameters('appName')), 'default')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "aca-dns-config", + "properties": { + "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.azurecontainerapps.io', parameters('location')))]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.azurecontainerapps.io', parameters('location')))]", + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-pe', parameters('appName')))]" + ] + }, + { + "type": "Microsoft.App/containerApps", + "apiVersion": "2024-03-01", + "name": "[parameters('appName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-identity', parameters('appName'))))]": {} + } + }, + "properties": { + "managedEnvironmentId": "[resourceId('Microsoft.App/managedEnvironments', format('{0}-env', parameters('appName')))]", + "configuration": { + "activeRevisionsMode": "Single", + "ingress": { + "external": true, + "targetPort": 8000, + "transport": "http", + "allowInsecure": false, + "ipSecurityRestrictions": "[if(not(equals(parameters('allowedCidr'), '')), createArray(createObject('name', 'allowed-cidr', 'description', parameters('allowedCidrDescription'), 'ipAddressRange', parameters('allowedCidr'), 'action', 'Allow')), createArray())]" + }, + "registries": [ + { + "server": "[variables('effectiveAcrServer')]", + "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-identity', parameters('appName')))]" + } + ], + "secrets": [ + { + "name": "env-file", + "keyVaultUrl": "[format('https://{0}{1}/secrets/{2}', variables('keyVaultName'), environment().suffixes.keyvaultDns, parameters('envSecretName'))]", + "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-identity', parameters('appName')))]" + } + ] + }, + "template": { + "containers": [ + { + "name": "pyrit-gui", + "image": "[parameters('containerImage')]", + "resources": { + "cpu": "[json(parameters('cpuCores'))]", + "memory": "[format('{0}Gi', parameters('memoryGb'))]" + }, + "env": [ + { + "name": "PYRIT_MODE", + "value": "gui" + }, + { + "name": "AZURE_SQL_SERVER", + "value": "[parameters('sqlServerFqdn')]" + }, + { + "name": "AZURE_SQL_DATABASE", + "value": "[parameters('sqlDatabaseName')]" + }, + { + "name": "PYRIT_INITIALIZER", + "value": "[parameters('pyritInitializer')]" + }, + { + "name": "PYRIT_ENV_CONTENTS", + "secretRef": "env-file" + }, + { + "name": "ENTRA_CLIENT_ID", + "value": "[parameters('entraClientId')]" + }, + { + "name": "ENTRA_TENANT_ID", + "value": "[parameters('entraTenantId')]" + }, + { + "name": "ENTRA_ALLOWED_GROUP_IDS", + "value": "[parameters('allowedGroupObjectIds')]" + }, + { + "name": "OTEL_EXPORTER_OTLP_ENDPOINT", + "value": "[if(parameters('enableOtel'), 'http://localhost:4318', '')]" + }, + { + "name": "OTEL_SERVICE_NAME", + "value": "[parameters('appName')]" + }, + { + "name": "AZURE_CLIENT_ID", + "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-identity', parameters('appName'))), '2023-01-31').clientId]" + } + ] + } + ], + "scale": { + "minReplicas": "[parameters('minReplicas')]", + "maxReplicas": "[parameters('maxReplicas')]" + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.App/managedEnvironments', format('{0}-env', parameters('appName')))]", + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-identity', parameters('appName')))]", + "[resourceId('Microsoft.ContainerRegistry/registries', format('{0}acr', replace(parameters('appName'), '-', '')))]" + ] + } + ], + "outputs": { + "appFqdn": { + "type": "string", + "metadata": { + "description": "The FQDN of the deployed Container App" + }, + "value": "[reference(resourceId('Microsoft.App/containerApps', parameters('appName')), '2024-03-01').configuration.ingress.fqdn]" + }, + "environmentDefaultDomain": { + "type": "string", + "metadata": { + "description": "The default domain of the ACA environment" + }, + "value": "[reference(resourceId('Microsoft.App/managedEnvironments', format('{0}-env', parameters('appName'))), '2024-10-02-preview').defaultDomain]" + }, + "privateEndpointId": { + "type": "string", + "metadata": { + "description": "Private Endpoint resource ID (empty when PE is disabled)" + }, + "value": "[if(parameters('enablePrivateEndpoint'), resourceId('Microsoft.Network/privateEndpoints', format('{0}-pe', parameters('appName'))), '')]" + }, + "managedIdentityPrincipalId": { + "type": "string", + "metadata": { + "description": "The principal ID of the user-assigned managed identity — grant this Cognitive Services OpenAI User on your AOAI instances and db_datareader/db_datawriter on Azure SQL" + }, + "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-identity', parameters('appName'))), '2023-01-31').principalId]" + }, + "managedIdentityResourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the user-assigned managed identity" + }, + "value": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-identity', parameters('appName')))]" + }, + "sqlAadSetupRequired": { + "type": "string", + "metadata": { + "description": "IMPORTANT: Create an Azure AD contained user in the target database for this managed identity. See README post-deployment steps." + }, + "value": "[format('Run CREATE USER [{0}-identity] FROM EXTERNAL PROVIDER on database {1}', parameters('appName'), parameters('sqlDatabaseName'))]" + }, + "keyVaultName": { + "type": "string", + "metadata": { + "description": "Key Vault name (existing)" + }, + "value": "[variables('keyVaultName')]" + }, + "acrLoginServer": { + "type": "string", + "metadata": { + "description": "ACR login server" + }, + "value": "[variables('effectiveAcrServer')]" + }, + "vnetName": { + "type": "string", + "metadata": { + "description": "VNet name (if created by this template)" + }, + "value": "[if(variables('createVnet'), format('{0}-vnet', parameters('appName')), 'N/A (existing VNet used)')]" + }, + "appInsightsConnectionString": { + "type": "string", + "metadata": { + "description": "Application Insights connection string (if OTel enabled)" + }, + "value": "[if(parameters('enableOtel'), reference(resourceId('Microsoft.Insights/components', format('{0}-ai', parameters('appName'))), '2020-02-02').ConnectionString, 'N/A (OTel disabled)')]" + } + } +} diff --git a/infra/parameters.example.json b/infra/parameters.example.json new file mode 100644 index 000000000..28380a12f --- /dev/null +++ b/infra/parameters.example.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appName": { + "value": "pyrit-gui" + }, + "containerImage": { + "value": "YOUR_ACR.azurecr.io/pyrit:COMMIT_SHA_OR_DIGEST" + }, + "entraTenantId": { + "value": "YOUR_TENANT_ID" + }, + "entraClientId": { + "value": "YOUR_APP_REGISTRATION_CLIENT_ID" + }, + "allowedGroupObjectIds": { + "value": "YOUR_ENTRA_GROUP_OBJECT_IDS" + }, + "allowedCidr": { + "value": "" + }, + "sqlServerFqdn": { + "value": "YOUR_SQL_SERVER.database.windows.net" + }, + "sqlDatabaseName": { + "value": "YOUR_DATABASE_NAME" + }, + "pyritInitializer": { + "value": "targets airt" + }, + "envSecretName": { + "value": "env-global" + }, + "logRetentionDays": { + "value": 90 + }, + "enableOtel": { + "value": false + }, + "tags": { + "value": { + "Service": "pyrit-gui", + "Owner": "", + "DataClass": "" + } + }, + + "_comment_optional": "--- Below are optional: omit to let the template create resources ---", + + "infrastructureSubnetId": { + "value": "" + }, + "acrName": { + "value": "" + }, + "acrResourceId": { + "value": "" + }, + "keyVaultResourceId": { + "value": "/subscriptions/YOUR_SUB/resourceGroups/YOUR_RG/providers/Microsoft.KeyVault/vaults/YOUR_VAULT" + }, + "logAnalyticsWorkspaceId": { + "value": "" + }, + "logAnalyticsCustomerId": { + "value": "" + }, + "logAnalyticsSharedKey": { + "value": "" + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 2473ea941..018564d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "openpyxl>=3.1.5", "pillow>=12.1.1", "pydantic>=2.11.5", + "PyJWT[crypto]>=2.8.0", "pyodbc>=5.1.0", "pypdf>=6.8.0", "python-docx>=1.1.0", diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py index 4d6b8da1c..f580a6a0f 100644 --- a/pyrit/backend/main.py +++ b/pyrit/backend/main.py @@ -16,8 +16,9 @@ from fastapi.staticfiles import StaticFiles import pyrit -from pyrit.backend.middleware import RequestIdMiddleware, register_error_handlers -from pyrit.backend.routes import attacks, converters, health, labels, media, targets, version +from pyrit.backend.middleware import RequestIdMiddleware, SecurityHeadersMiddleware, register_error_handlers +from pyrit.backend.middleware.auth import EntraAuthMiddleware +from pyrit.backend.routes import attacks, auth, converters, health, labels, media, targets, version from pyrit.memory import CentralMemory # Check for development mode from environment variable @@ -47,14 +48,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: description="Python Risk Identification Tool for LLMs - REST API", version=pyrit.__version__, lifespan=lifespan, + docs_url="/docs" if DEV_MODE else None, + redoc_url="/redoc" if DEV_MODE else None, + openapi_url="/openapi.json" if DEV_MODE else None, ) # Register RFC 7807 error handlers register_error_handlers(app) +# Security response headers (CSP, HSTS, X-Frame-Options, etc.) +# Registered first so headers are applied even on early returns (e.g. auth 401s) +app.add_middleware(SecurityHeadersMiddleware, dev_mode=DEV_MODE) + # Attach X-Request-ID to every request/response for log correlation app.add_middleware(RequestIdMiddleware) +# Entra ID JWT validation (PKCE — no client secrets needed) +# Disabled automatically if ENTRA_TENANT_ID / ENTRA_CLIENT_ID are not set +app.add_middleware(EntraAuthMiddleware) + # Configure CORS _default_origins = "http://localhost:3000,http://localhost:5173" @@ -64,8 +76,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: CORSMiddleware, allow_origins=_cors_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Request-ID"], ) @@ -75,6 +87,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(converters.router, prefix="/api", tags=["converters"]) app.include_router(labels.router, prefix="/api", tags=["labels"]) app.include_router(health.router, prefix="/api", tags=["health"]) +app.include_router(auth.router, prefix="/api", tags=["auth"]) app.include_router(media.router, prefix="/api", tags=["media"]) app.include_router(version.router, tags=["version"]) diff --git a/pyrit/backend/middleware/__init__.py b/pyrit/backend/middleware/__init__.py index e7c2b855e..b73a95d52 100644 --- a/pyrit/backend/middleware/__init__.py +++ b/pyrit/backend/middleware/__init__.py @@ -5,5 +5,6 @@ from pyrit.backend.middleware.error_handlers import register_error_handlers from pyrit.backend.middleware.request_id import RequestIdMiddleware +from pyrit.backend.middleware.security_headers import SecurityHeadersMiddleware -__all__ = ["register_error_handlers", "RequestIdMiddleware"] +__all__ = ["register_error_handlers", "RequestIdMiddleware", "SecurityHeadersMiddleware"] diff --git a/pyrit/backend/middleware/auth.py b/pyrit/backend/middleware/auth.py new file mode 100644 index 000000000..aea1187c0 --- /dev/null +++ b/pyrit/backend/middleware/auth.py @@ -0,0 +1,261 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Entra ID JWT validation middleware for FastAPI. + +Validates Bearer tokens from the Authorization header against Entra ID JWKS. +Uses PKCE (public client) flow — no client secrets needed. + +The middleware: +- Skips auth for health check and auth config endpoints +- Validates JWT signature against Entra ID's JWKS endpoint +- Verifies issuer, audience, and expiration +- Optionally checks group membership (handles groups overage for users in >200 groups) +- Attaches user info to request.state for use by route handlers +""" + +import logging +import os +from dataclasses import dataclass +from typing import Any, Optional + +import httpx +import jwt +from jwt import PyJWKClient +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.types import ASGIApp + +logger = logging.getLogger(__name__) + +# Paths that bypass authentication +_PUBLIC_PATHS = { + "/api/health", + "/api/auth/config", + "/api/media", +} + + +@dataclass +class AuthenticatedUser: + """User identity extracted from a validated JWT.""" + + oid: str + name: str + email: str + groups: list[str] + + +class EntraAuthMiddleware(BaseHTTPMiddleware): + """Validate Entra ID JWTs on every request (except public paths).""" + + def __init__(self, app: ASGIApp) -> None: + """Initialize the middleware with Entra ID configuration from environment variables.""" + super().__init__(app) + self._tenant_id = os.getenv("ENTRA_TENANT_ID", "") + self._client_id = os.getenv("ENTRA_CLIENT_ID", "") + groups_raw = os.getenv("ENTRA_ALLOWED_GROUP_IDS", "") + self._allowed_group_ids: set[str] = {g.strip() for g in groups_raw.split(",") if g.strip()} + self._enabled = bool(self._tenant_id and self._client_id) + + if self._enabled: + jwks_url = f"https://login.microsoftonline.com/{self._tenant_id}/discovery/v2.0/keys" + self._jwks_client = PyJWKClient(jwks_url, cache_keys=True) + self._issuer = f"https://login.microsoftonline.com/{self._tenant_id}/v2.0" + logger.info("Entra ID auth middleware enabled (tenant=%s)", self._tenant_id) + else: + self._jwks_client = None + self._issuer = "" + logger.warning( + "Entra ID auth middleware DISABLED — ENTRA_TENANT_ID or ENTRA_CLIENT_ID not set. " + "All requests will be allowed without authentication." + ) + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """ + Validate the Bearer token and attach user info to request.state. + + Args: + request: The incoming HTTP request. + call_next: The next middleware / route handler. + + Returns: + Response with 401 if auth fails, otherwise the normal response. + """ + # Skip auth for public paths and static files + path = request.url.path + if not self._enabled or path in _PUBLIC_PATHS or not path.startswith("/api"): + return await call_next(request) + + # Extract Bearer token + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return JSONResponse( + status_code=401, + content={"detail": "Missing or invalid Authorization header"}, + ) + + token = auth_header[7:] # Strip "Bearer " + + # Validate JWT + user, claims = self._validate_token(token) + if user is None: + return JSONResponse( + status_code=401, + content={"detail": "Invalid or expired token"}, + ) + + # Handle groups overage: when user is in >200 groups, Entra replaces the + # groups array with _claim_sources containing a Graph API URL. + if not user.groups and self._allowed_group_ids and "_claim_sources" in claims: + user.groups = await self._resolve_groups_overage_async(claims, token) + + # Authorization: check group membership + if not self._is_authorized(user): + logger.warning( + "User %s (%s) denied — groups=%s, allowed_groups=%s", + user.email, + user.oid, + user.groups, + self._allowed_group_ids, + ) + return JSONResponse( + status_code=403, + content={"detail": "You are not authorized to access this application"}, + ) + + # Attach user to request state + request.state.user = user + return await call_next(request) + + def _is_authorized(self, user: AuthenticatedUser) -> bool: + """ + Check if the user is authorized via group membership. + + Authorization passes if: + - No group restrictions are configured (open to all authenticated users) + - The user's groups intersect with the allowed group IDs + + Returns: + True if the user is authorized, False otherwise. + """ + if not self._allowed_group_ids: + return True + return bool(self._allowed_group_ids & set(user.groups)) + + async def _resolve_groups_overage_async(self, claims: dict[str, Any], token: str) -> list[str]: + """ + Resolve group membership via Microsoft Graph when groups overage occurs. + + When a user is in >200 groups, Entra ID replaces the `groups` claim with + `_claim_sources` containing a Graph API endpoint. This method calls the + Microsoft Graph `getMemberObjects` endpoint to retrieve transitive group + memberships, using the user's access token. + + Args: + claims: The decoded JWT claims containing _claim_sources. + token: The raw Bearer token to forward to Graph API. + + Returns: + List of group IDs the user belongs to, or empty list on failure. + """ + try: + claim_sources = claims.get("_claim_sources", {}) + src = claim_sources.get("src1", {}) + endpoint = src.get("endpoint", "") + + if not endpoint: + logger.debug("No overage endpoint found in _claim_sources") + return [] + + # The _claim_sources endpoint may be a legacy graph.windows.net URL. + # Rewrite to Microsoft Graph (graph.microsoft.com) which is the + # supported API. The legacy Azure AD Graph was retired in 2023. + if "graph.windows.net" in endpoint: + # Legacy format: https://graph.windows.net/{tenant}/users/{oid}/getMemberObjects + # Graph format: https://graph.microsoft.com/v1.0/me/getMemberObjects + endpoint = "https://graph.microsoft.com/v1.0/me/getMemberObjects" + + all_group_ids: list[str] = [] + async with httpx.AsyncClient() as client: + response = await client.post( + endpoint, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={"securityEnabledOnly": True}, + timeout=10.0, + ) + + if response.status_code != 200: + logger.warning( + "Groups overage endpoint returned %d: %s", + response.status_code, + response.text[:200], + ) + return [] + + data = response.json() + all_group_ids.extend(data.get("value", [])) + + # Handle pagination — Graph may return @odata.nextLink for large results + next_link = data.get("@odata.nextLink") + while next_link: + response = await client.get( + next_link, + headers={"Authorization": f"Bearer {token}"}, + timeout=10.0, + ) + if response.status_code != 200: + logger.warning("Overage pagination failed at %s: %d", next_link, response.status_code) + break + data = response.json() + all_group_ids.extend(data.get("value", [])) + next_link = data.get("@odata.nextLink") + + logger.debug("Overage resolution returned %d group memberships", len(all_group_ids)) + return all_group_ids + + except Exception as e: + logger.warning("Failed to resolve groups overage: %s", e) + return [] + + def _validate_token(self, token: str) -> tuple[Optional[AuthenticatedUser], dict[str, Any]]: + """ + Validate a JWT against Entra ID JWKS. + + Args: + token: The raw JWT string. + + Returns: + Tuple of (AuthenticatedUser, claims) if valid, (None, {}) if validation fails. + """ + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=self._client_id, + issuer=self._issuer, + options={"require": ["exp", "iss", "aud", "sub"]}, + ) + user = AuthenticatedUser( + oid=claims.get("oid", claims.get("sub", "")), + name=claims.get("name", ""), + email=claims.get("preferred_username", claims.get("email", "")), + groups=claims.get("groups", []), + ) + return user, claims + except jwt.ExpiredSignatureError: + logger.debug("Token expired") + return None, {} + except jwt.InvalidTokenError as e: + logger.debug("Token validation failed: %s", e) + return None, {} + except Exception as e: + logger.warning("Unexpected error during token validation: %s", e) + return None, {} diff --git a/pyrit/backend/middleware/security_headers.py b/pyrit/backend/middleware/security_headers.py new file mode 100644 index 000000000..3d0be3644 --- /dev/null +++ b/pyrit/backend/middleware/security_headers.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Middleware that adds security-related HTTP response headers. + +Applied headers: +- Content-Security-Policy (separate policies for API vs frontend) +- Strict-Transport-Security (production only) +- X-Content-Type-Options: nosniff +- X-Frame-Options: DENY +- Referrer-Policy: strict-origin-when-cross-origin +- Permissions-Policy (disables unused browser APIs) +- Cache-Control: no-store (API routes only) +""" + +import logging + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import ASGIApp + +logger = logging.getLogger(__name__) + +# Swagger / ReDoc paths — these load scripts and styles from CDN, +# so CSP is skipped in dev mode (production disables these routes entirely). +_DOCS_PATHS = {"/docs", "/redoc", "/openapi.json"} + +# CSP for API responses — as strict as possible. +_API_CSP = "default-src 'none'; frame-ancestors 'none'" + +# CSP for frontend SPA — allows self-hosted scripts and Fluent UI / Griffel +# runtime style injection ('unsafe-inline' for style-src only). +_FRONTEND_CSP = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https://*.blob.core.windows.net; " + "media-src 'self' https://*.blob.core.windows.net; " + "font-src 'self' data:; " + "connect-src 'self' https://login.microsoftonline.com; " + "frame-ancestors 'none'" +) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Inject security response headers on every request.""" + + def __init__(self, app: ASGIApp, dev_mode: bool = False) -> None: + """ + Initialize the middleware. + + Args: + app: The ASGI application. + dev_mode: When True, HSTS is omitted to avoid breaking local HTTP. + """ + super().__init__(app) + self._dev_mode = dev_mode + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """ + Add security headers to the response. + + Args: + request: The incoming HTTP request. + call_next: The next middleware / route handler. + + Returns: + Response with security headers applied. + """ + response = await call_next(request) + path = request.url.path + + # --- Headers applied to ALL responses --- + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + + # HSTS only in production (HTTPS) + if not self._dev_mode: + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + + # --- Path-dependent headers --- + if path.startswith("/api"): + response.headers["Content-Security-Policy"] = _API_CSP + response.headers["Cache-Control"] = "no-store" + elif self._dev_mode and path in _DOCS_PATHS: + pass # No CSP — Swagger/ReDoc load from CDN + else: + response.headers["Content-Security-Policy"] = _FRONTEND_CSP + + return response diff --git a/pyrit/backend/routes/auth.py b/pyrit/backend/routes/auth.py new file mode 100644 index 000000000..951061898 --- /dev/null +++ b/pyrit/backend/routes/auth.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Auth configuration endpoint. + +Serves non-secret Entra ID configuration to the frontend so MSAL can be +initialized without hardcoding tenant-specific values in the JS bundle. +""" + +import os + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/auth/config") +async def get_auth_config_async() -> dict[str, str]: + """ + Return Entra ID configuration for the frontend MSAL client. + + These values are non-secret (client ID, tenant ID) and are needed by + the frontend to initialize MSAL for PKCE login. The allowed group IDs + are included so the frontend can show appropriate error messages. + + Returns: + dict: Auth configuration with clientId, tenantId, allowedGroupIds. + """ + return { + "clientId": os.getenv("ENTRA_CLIENT_ID", ""), + "tenantId": os.getenv("ENTRA_TENANT_ID", ""), + "allowedGroupIds": os.getenv("ENTRA_ALLOWED_GROUP_IDS", ""), + } diff --git a/uv.lock b/uv.lock index 968200513..516857bfe 100644 --- a/uv.lock +++ b/uv.lock @@ -2109,6 +2109,7 @@ dependencies = [ { name = "griffecli" }, { name = "griffelib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/04/56/28a0accac339c164b52a92c6cfc45a903acc0c174caa5c1713803467b533/griffe-2.0.0.tar.gz", hash = "sha256:c68979cd8395422083a51ea7cf02f9c119d889646d99b7b656ee43725de1b80f", size = 293906, upload-time = "2026-03-23T21:06:53.402Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, ] @@ -2121,6 +2122,7 @@ dependencies = [ { name = "colorama" }, { name = "griffelib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/a4/f8/2e129fd4a86e52e58eefe664de05e7d502decf766e7316cc9e70fdec3e18/griffecli-2.0.0.tar.gz", hash = "sha256:312fa5ebb4ce6afc786356e2d0ce85b06c1c20d45abc42d74f0cda65e159f6ef", size = 56213, upload-time = "2026-03-23T21:06:54.8Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, ] @@ -2129,6 +2131,7 @@ wheels = [ name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] @@ -5769,7 +5772,7 @@ wheels = [ [[package]] name = "pyrit" -version = "0.11.1.dev0" +version = "0.12.1.dev0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -5794,6 +5797,7 @@ dependencies = [ { name = "openpyxl" }, { name = "pillow" }, { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyodbc" }, { name = "pypdf" }, { name = "python-docx" }, @@ -5962,6 +5966,7 @@ requires-dist = [ { name = "playwright", marker = "extra == 'playwright'", specifier = ">=1.49.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" }, { name = "pydantic", specifier = ">=2.11.5" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, { name = "pyodbc", specifier = ">=5.1.0" }, { name = "pypdf", specifier = ">=6.8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" },