Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/docker_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ jobs:
}
Write-Output "${service}: verified 127.0.0.1:${port}:${port}/tcp"
}
if ($services.'pyrit-gui'.environment.PYRIT_AUTH_MODE -ne 'local') {
throw "Expected pyrit-gui to select PYRIT_AUTH_MODE=local."
}
Write-Output "pyrit-gui: verified PYRIT_AUTH_MODE=local"

build-and-test-local:
name: Build and test (local)
Expand Down
10 changes: 6 additions & 4 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ There are several ways to run CoPyRIT:
If you have PyRIT installed, use the `pyrit_backend` command to start the server. The bundled frontend is served automatically.

```bash
pyrit_backend
pyrit_backend --auth-mode local
```

Then open `http://localhost:8000` in your browser.

Authentication-disabled local servers deny administrator operations by default. To enable administrator operations
Local mode deliberately disables incoming-request Entra authentication and is
restricted to a loopback host by the packaged CLI. Authentication-disabled local
servers deny administrator operations by default. To enable administrator operations
(configuration, initializer administration, and creating targets that run model repository code) for a trusted local
development server, set `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`. Never use this
setting on a network-accessible deployment.
development server, set `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`. Never use these local settings on a
network-accessible deployment.

### Docker

Expand Down
10 changes: 10 additions & 0 deletions docker/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ GUI mode (port 8000):
python docker/run_pyrit_docker.py gui
```

The GUI helper explicitly selects local unauthenticated mode and publishes port
8000 only on `127.0.0.1`. It is available from the Docker host, but not through
the host's LAN address. The API keys in `~/.pyrit/.env` authenticate PyRIT to
configured targets; they do not authenticate incoming GUI users.

The run script automatically mounts these files from `~/.pyrit/`:
- `.env` — API keys (required)
- `.env.local` — Additional environment overrides (optional)
Expand Down Expand Up @@ -90,6 +95,11 @@ docker compose --profile jupyter up --build
docker compose --profile gui up --build
```

The shipped GUI Compose profile likewise combines `PYRIT_AUTH_MODE=local` with
the loopback-only `127.0.0.1:8000:8000` publication. Shared deployments must
instead select `PYRIT_AUTH_MODE=entra` and provide complete `ENTRA_*`
configuration.

Both profiles publish ports on `127.0.0.1` only by default. Open the GUI at
`http://127.0.0.1:8000` or Jupyter at `http://127.0.0.1:8888` on the Docker host.
Jupyter requires the access token shown in the container logs.
Expand Down
6 changes: 6 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ This Docker container provides a pre-configured environment for running PyRIT (P

This README contains technical details for working with the Docker setup locally.

> **Security:** The shipped GUI Compose profile is intentionally local-only. It
> combines `PYRIT_AUTH_MODE=local` with a `127.0.0.1:8000` host publication.
> Do not republish that container to a LAN or public interface. Shared
> deployments must use `PYRIT_AUTH_MODE=entra` with complete Entra settings.
> Target API keys authenticate outbound provider calls, not incoming GUI users.

## Docker CI

The `docker_build` workflow builds the devcontainer base, builds the local-source
Expand Down
1 change: 1 addition & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ services:
- ~/.azure:/home/vscode/.azure
environment:
- PYRIT_MODE=gui
- PYRIT_AUTH_MODE=local
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/api/health || exit 1"]
Expand Down
2 changes: 2 additions & 0 deletions docker/run_pyrit_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ def run_container(mode, tag="latest"):
"-v",
f"{env_file}:/home/vscode/.pyrit/.env:ro",
]
if mode == "gui":
cmd.extend(["-e", "PYRIT_AUTH_MODE=local"])

# Add .env.local if it exists
if env_local_file.exists():
Expand Down
6 changes: 5 additions & 1 deletion docker/smoke_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

container_id=$(docker create --env "PYRIT_MODE=$mode" --publish "127.0.0.1::$port" "$image")
docker_args=(--env "PYRIT_MODE=$mode")
if [[ "$mode" == "gui" ]]; then
docker_args+=(--env "PYRIT_AUTH_MODE=local")
fi
container_id=$(docker create "${docker_args[@]}" --publish "127.0.0.1::$port" "$image")
docker start "$container_id"
address=$(docker port "$container_id" "$port/tcp")
base_url="http://$address"
Expand Down
5 changes: 5 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,11 @@ servers with reuse disabled, even when `E2E_FRONTEND_PORT` is set.
The frontend proxies API requests to `http://localhost:8000` in development.
Configure this in `vite.config.ts` if needed.

The Vite development server binds to `127.0.0.1` by default so its `/api`
proxy cannot expose a local unauthenticated backend to the LAN. Environments
that already provide an isolated network boundary, such as a devcontainer with
controlled port forwarding, can explicitly set `PYRIT_FRONTEND_HOST=0.0.0.0`.

The Vite development server disables its own CORS handling. Use the frontend's
same-origin `/api` proxy for API requests. Cross-origin API preflights pass to
the backend, which applies its configured origin policy. Do not enable
Expand Down
35 changes: 25 additions & 10 deletions frontend/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import time
from pathlib import Path

from pyrit.common.authentication import should_default_to_local_authentication

# Ensure emoji and other Unicode characters don't crash on Windows consoles
# that use legacy encodings like cp1252. Characters that can't be encoded
# are replaced with '?' instead of raising UnicodeEncodeError.
Expand Down Expand Up @@ -182,6 +184,8 @@ def start_backend(*, config_file: str | None = None, initializers: list[str] | N
# Set development mode environment variable
env = os.environ.copy()
env["PYRIT_DEV_MODE"] = "true"
if should_default_to_local_authentication(environment=env):
env["PYRIT_AUTH_MODE"] = "local"
# Force unbuffered output so init logs appear in real-time when piped
env["PYTHONUNBUFFERED"] = "1"

Expand Down Expand Up @@ -226,20 +230,30 @@ def start_frontend():

# Start frontend process with stdout piped so we can detect the actual port
npm_cmd = "npm.cmd" if is_windows() else "npm"
environment = os.environ.copy()
environment.setdefault("PYRIT_FRONTEND_HOST", "127.0.0.1")
environment["PYRIT_PYTHON"] = sys.executable
return subprocess.Popen(
[npm_cmd, "run", "dev"],
env={**os.environ, "PYRIT_PYTHON": sys.executable},
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)


def _parse_vite_port(output: str) -> int | None:
"""Return the port from Vite's loopback-ready message."""
import re

match = re.search(r"Local:\s+http://[^:/\s]+:(\d+)", output)
return int(match.group(1)) if match else None


def _detect_frontend_port(frontend_process, *, timeout: int = 10) -> int:
"""Read Vite's stdout to find the actual port it bound to.

Falls back to 3000 if detection times out.
"""
import re
import selectors

default_port = 3000
Expand All @@ -258,11 +272,10 @@ def _detect_frontend_port(frontend_process, *, timeout: int = 10) -> int:
if data:
collected += data
text = collected.decode(errors="replace")
# Vite prints: ➜ Local: http://localhost:XXXX/
match = re.search(r"Local:\s+http://localhost:(\d+)", text)
if match:
port = _parse_vite_port(text)
if port is not None:
sel.close()
return int(match.group(1))
return port
if frontend_process.poll() is not None:
break

Expand All @@ -273,16 +286,16 @@ def _detect_frontend_port(frontend_process, *, timeout: int = 10) -> int:
return default_port


def _forward_backend_output(backend_process):
"""Forward backend stdout to the terminal in a background thread.
def _forward_process_output(process):
"""Forward child-process stdout to the terminal in a background thread.

Runs until the pipe is closed (process exits or stdout is exhausted).
"""
import threading

def _reader():
try:
for line in backend_process.stdout:
for line in process.stdout:
sys.stdout.buffer.write(line)
sys.stdout.buffer.flush()
except (ValueError, OSError):
Expand Down Expand Up @@ -331,11 +344,12 @@ def start_servers(*, config_file: str | None = None):
print("⏳ Waiting for backend to initialize (this may take a minute)...")

# Forward backend output to the terminal in real-time
_forward_backend_output(backend)
_forward_process_output(backend)

# Start frontend in parallel while backend initializes
frontend = start_frontend()
frontend_port = _detect_frontend_port(frontend)
_forward_process_output(frontend)

# Now wait for backend to be actually healthy
backend_healthy = _wait_for_backend(backend)
Expand Down Expand Up @@ -508,6 +522,7 @@ def main():
time.sleep(1)
frontend = start_frontend()
frontend_port = _detect_frontend_port(frontend)
_forward_process_output(frontend)
print(f"✅ Frontend running on http://localhost:{frontend_port} (PID: {frontend.pid})")
print("\nPress Ctrl+C to stop")
try:
Expand Down
1 change: 1 addition & 0 deletions frontend/e2e/fixtures/analytics_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ def main() -> None:
"LOCALAPPDATA": str(directory),
"XDG_CONFIG_HOME": str(directory),
"XDG_DATA_HOME": str(directory),
"PYRIT_AUTH_MODE": "local",
"PYRIT_CONFIG_FILE": str(config),
"PYRIT_DEV_MODE": "true",
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/fixtures/manual_send_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def _initialize_offline_async(self: ConfigurationLoader, *, raise_on_initi
async def _offline_lifespan_async(application: FastAPI) -> AsyncGenerator[None, None]:
config = ConfigurationLoader(memory_db_type="in_memory", env_files=[], env_akv_ref=[])
with (
patch.dict(os.environ, {"PYRIT_DEV_MODE": "true"}, clear=True),
patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local", "PYRIT_DEV_MODE": "true"}, clear=True),
patch.object(
ConfigurationFileService,
"_read_source_async",
Expand Down
2 changes: 1 addition & 1 deletion frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default defineConfig({
{
command:
`cd .. && uv run --no-sync python -m pyrit.backend.pyrit_backend ` +
`--host 127.0.0.1 --port ${E2E_BACKEND_PORT} --log-level warning ` +
`--auth-mode local --host 127.0.0.1 --port ${E2E_BACKEND_PORT} --log-level warning ` +
"--config-file tests/end_to_end/test_config.yaml",
env: { PYRIT_DEV_MODE: "true" },
url: `${E2E_BACKEND_URL}/api/health`,
Expand Down
3 changes: 2 additions & 1 deletion frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ if (!isCompatibilityId(compatibilityId)
const logger = createLogger()
const originalError = logger.error
const backendUrl = process.env.PYRIT_BACKEND_URL ?? 'http://127.0.0.1:8000'
const frontendHost = process.env.PYRIT_FRONTEND_HOST ?? '127.0.0.1'
let proxyWarned = false
logger.error = (msg, options) => {
if (typeof msg === 'string' && msg.includes('http proxy error')) {
Expand Down Expand Up @@ -58,7 +59,7 @@ export default defineConfig({
},
server: {
port: 3000,
host: true, // Listen on all interfaces for devcontainer
host: frontendHost,
Comment thread
jsong468 marked this conversation as resolved.
// Improve HMR performance for devcontainer
hmr: {
overlay: false,
Expand Down
4 changes: 4 additions & 0 deletions infra/application.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
name: 'PYRIT_MODE'
value: 'gui'
}
{
name: 'PYRIT_AUTH_MODE'
value: 'entra'
}
{
name: 'AZURE_SQL_SERVER'
value: sqlServerFqdn
Expand Down
20 changes: 16 additions & 4 deletions pyrit/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@ FastAPI-based REST API for PyRIT.
### Run the Server

```bash
# Development server with auto-reload
python -m pyrit.backend.main
# Loopback-only local development without Entra authentication
python -m pyrit.backend.pyrit_backend --auth-mode local --host 127.0.0.1 --reload

# Or with uvicorn directly
uvicorn pyrit.backend.main:app --reload --host 0.0.0.0 --port 8000
# Equivalent direct Uvicorn launch
PYRIT_AUTH_MODE=local python -m uvicorn pyrit.backend.main:app --reload --host 127.0.0.1 --port 8000
```

The API will be available at `http://localhost:8000`

For a shared deployment, set `PYRIT_AUTH_MODE=entra`, configure
`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, and `ENTRA_ALLOWED_GROUP_IDS`, and then
bind the server to the intended network interface. Startup fails when neither
explicit local mode nor complete Entra configuration is present. Target-provider
API keys do not authenticate incoming GUI users.

### API Documentation

- Swagger UI: `http://localhost:8000/docs`
Expand Down Expand Up @@ -250,6 +256,12 @@ CLI wheel, and backend must be available before enabling enforcement in deployme
## Configuration

Environment variables:
- `PYRIT_AUTH_MODE` - Incoming-request authentication mode: `local` or `entra`
- `ENTRA_TENANT_ID` - Entra directory used for GUI sign-in
- `ENTRA_CLIENT_ID` - Entra application registration used by the GUI
- `ENTRA_ALLOWED_GROUP_IDS` - Comma-separated groups allowed to use protected APIs
- `ENTRA_ADMIN_GROUP_ID` - Optional group allowed to use administrator APIs
- `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN` - Local-mode-only administrator override
- `PYRIT_API_HOST` - Host to bind to (default: localhost)
- `PYRIT_API_PORT` - Port to listen on (default: 8000)
- `PYRIT_API_RELOAD` - Enable auto-reload (default: false)
Loading
Loading