Compare commits

..

12 Commits

Author SHA1 Message Date
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
36 changed files with 1901 additions and 907 deletions
+9
View File
@@ -30,6 +30,15 @@ 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)
SIEM_ENABLED=false
SIEM_WEBHOOK_URL=
+15 -7
View File
@@ -17,21 +17,29 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
run: |
apt-get update && apt-get install -y python3 python3-venv || true
python3 --version
- name: Install dependencies
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-dev.txt
- name: Lint with ruff
run: ruff check .
run: |
source .venv/bin/activate
ruff check .
- name: Format check with ruff
run: ruff format --check .
run: |
source .venv/bin/activate
ruff format --check .
- name: Run tests
run: pytest -q
run: |
source .venv/bin/activate
pytest -q
+11
View File
@@ -2,11 +2,22 @@
.DS_Store
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
.*venv*/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage*
coverage.xml
.vscode/
.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)
- **Web Framework**: FastAPI + Uvicorn (Gunicorn in production)
- **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/`)
- **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
- **Deployment**: Docker Compose (dev), Docker Compose + nginx (prod)
- **CI/CD**: Gitea Actions (lint + test + Docker build + release)
- **Secrets Storage**: Environment variables (`.env`) or optional Azure Key Vault
## Project Structure
```
backend/
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)
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
Dockerfile # python:3.11-slim image, non-root user, version baked at build
mcp_server.py # Standalone MCP server for Claude Desktop / Cursor integration
@@ -34,6 +38,9 @@ backend/
health.py # GET /health, GET /metrics
rules.py # Rule-based alerting endpoints
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/
auth.py # Client credentials token acquisition for 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
```
Key variables:
### Core variables
- `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_TENANT_ID`, `AUTH_CLIENT_ID` — token validation audience/issuer
- `AUTH_ALLOWED_ROLES`, `AUTH_ALLOWED_GROUPS` — comma-separated access control lists
- `ENABLE_PERIODIC_FETCH`, `FETCH_INTERVAL_MINUTES` — background ingestion scheduler
- `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`)
- `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_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
@@ -102,7 +135,9 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
- `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`)
- `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
@@ -162,16 +197,30 @@ When adding new features or bug fixes, add or update tests in `backend/tests/`.
- Auth middleware and token validation
- API endpoints (`/api/events`, `/api/fetch-audit-logs`, `/api/ask`)
- NLQ time range extraction, entity extraction, query building
- Rate limiting behavior
## Security Considerations
- **Secrets**: `CLIENT_SECRET`, `LLM_API_KEY`, and other credentials come from `.env`. 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.
- **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.
- **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/audience claims. Tokens are decoded with RS256 signature verification.
- **Role/Group gating**: Access is allowed if the token's `roles` intersect `AUTH_ALLOWED_ROLES` or `groups` intersect `AUTH_ALLOWED_GROUPS`. If neither list is configured, all authenticated users are allowed — 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.
- **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.
### 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
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
- **backend** — FastAPI application (Gunicorn + Uvicorn workers)
- **mongo** — MongoDB data store (not exposed externally)
- **valkey** — Redis-compatible cache and async job queue (not exposed externally)
## Prerequisites
@@ -20,7 +21,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
1. **Clone / pull the latest release**
```bash
git checkout v1.1.0
git checkout v1.7.14
```
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**
```bash
export AOC_VERSION=v1.1.0
export AOC_VERSION=v1.7.14
```
4. **Deploy**
@@ -53,7 +54,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
## Updating to a new release
```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 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/`.
## Security hardening
## Security Hardening
- 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.
- nginx adds security headers (`X-Frame-Options`, `X-Content-Type-Options`, etc.).
- 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
```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 up -d
```
## Monitoring
- Prometheus metrics: `http://your-host/metrics`
- Prometheus metrics: `http://your-host/metrics` (IP-restricted by default)
- Health check: `http://your-host/health`
- 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 nginx
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`.
+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.
- Natural language query (`/api/ask`) powered by LLM (OpenAI, Azure OpenAI, or any compatible API).
- MCP server for Claude Desktop / Cursor integration.
- Optional Azure Key Vault integration for secrets storage.
## Prerequisites (macOS)
- Python 3.11
- 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
- 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`
- 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_MODEL=gpt-4o-mini
# 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)
```bash
docker compose up --build
@@ -76,7 +112,7 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
## API
- `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/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`)
@@ -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.
## 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
# ensure Mongo + backend network are up
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 ...`.
## 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
- 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`).
- 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).
- 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.
- If using Azure Key Vault, ensure the runtime identity (managed identity, service principal, or local Azure CLI) has `Get` permission on secrets.
+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
```
+25 -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.
- [x] AI feature flag (`AI_FEATURES_ENABLED`) to gate LLM-dependent features
@@ -76,7 +76,26 @@ 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: Multi-Tenancy (Premium) ⏸️
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.
@@ -88,10 +107,10 @@ Status: **Planned — not started**. Architecture designed, pending validation o
- Super-admin role for MSP staff to access all tenants
### Implementation phases
- **Phase 6.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 6.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.1** (23 days): Tenant model & registry, tenant-aware data layer, per-tenant Graph API auth
- **Phase 7.2** (1 day): Tenant-scoped API routes, tenant-specific config endpoints
- **Phase 7.3** (2 days): Frontend tenant switcher, tenant name display, admin page
- **Phase 7.4** (1 day): License gating — signed JWT `LICENSE_KEY` gates multi-tenant mode
### Licensing model
- 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.12
1.7.17
+27 -11
View File
@@ -1,4 +1,6 @@
import asyncio
import contextvars
import threading
import time
import requests
@@ -20,23 +22,37 @@ from jwt.algorithms import RSAAlgorithm
_auth_context: contextvars.ContextVar[dict | None] = contextvars.ContextVar("auth_context", default=None)
JWKS_CACHE = {"exp": 0, "keys": []}
_jwks_lock = threading.Lock()
logger = structlog.get_logger("aoc.auth")
def _get_jwks():
now = time.time()
if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
return JWKS_CACHE["keys"]
def _fetch_jwks_blocking() -> list:
"""Fetch JWKS from Microsoft — runs in a thread, never in the event loop."""
oidc = requests.get(
f"https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration",
timeout=10,
).json()
jwks_uri = oidc["jwks_uri"]
keys = requests.get(jwks_uri, timeout=10).json()["keys"]
JWKS_CACHE["keys"] = keys
JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h
return keys
return requests.get(jwks_uri, timeout=10).json()["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:
@@ -96,7 +112,7 @@ def user_can_access_privacy_services(claims: dict) -> bool:
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:
user = {"sub": "anonymous"}
_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")
token = authorization.split(" ", 1)[1]
jwks = _get_jwks()
jwks = await _get_jwks_async()
claims = _decode_token(token, jwks)
if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS):
+21 -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):
@@ -80,6 +86,15 @@ class Settings(BaseSettings):
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()
@@ -134,3 +149,8 @@ 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" />
<title>Admin Operations Center</title>
<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="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
<script src="/app.js?v=1"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.15.12/dist/cdn.min.js" integrity="sha384-MKLWq9B+VC0W3U8kDIBEsSu8uCnQ1B0UQpRaB+F7uR5ocXFbymMUKuLRntu5LLdu" 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>
<body>
<div class="page" x-data="aocApp()" x-init="initApp()">
@@ -452,827 +453,5 @@
</footer>
</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>
</html>
+17 -1
View File
@@ -10,12 +10,15 @@ import structlog
from audit_trail import log_action
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 fastapi import FastAPI, HTTPException, Request
@@ -109,7 +112,7 @@ async def security_headers_middleware(request: Request, call_next):
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net alcdn.msauth.net; "
"script-src 'self' cdn.jsdelivr.net alcdn.msauth.net; "
"style-src 'self' 'unsafe-inline'; "
"connect-src 'self' https://login.microsoftonline.com; "
"frame-src 'self' https://login.microsoftonline.com; "
@@ -275,6 +278,19 @@ async def start_periodic_fetch():
auth_enabled=AUTH_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:
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):
@@ -51,35 +53,35 @@ class SourceHealthResponse(BaseModel):
class TagsUpdateRequest(BaseModel):
tags: list[str]
tags: list[str] = Field(..., max_length=50)
class BulkTagsRequest(BaseModel):
tags: list[str]
mode: str = "append" # "append" or "replace"
tags: list[str] = Field(..., max_length=50)
mode: Literal["append", "replace"] = "append"
class CommentAddRequest(BaseModel):
text: str
text: str = Field(..., min_length=1, max_length=5000)
class AlertCondition(BaseModel):
field: str
op: str # eq, neq, contains, in, after_hours
field: str = Field(..., max_length=100)
op: Literal["eq", "neq", "contains", "in", "after_hours", "threshold_count"]
value: str | list[str] | None = None
class AlertRuleResponse(BaseModel):
id: str | None = None
name: str
name: str = Field(..., max_length=200)
enabled: bool
severity: str
conditions: list[AlertCondition]
message: str
severity: Literal["high", "medium", "low"]
conditions: list[AlertCondition] = Field(..., max_length=20)
message: str = Field(..., max_length=1000)
class AskRequest(BaseModel):
question: str
question: str = Field(..., min_length=1, max_length=2000)
services: list[str] | None = None
actor: 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)
"""
import ipaddress
from datetime import UTC, datetime
from urllib.parse import urlparse
import requests
import structlog
@@ -15,6 +17,26 @@ logger = structlog.get_logger("aoc.notifications")
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(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
@@ -142,6 +164,12 @@ def send_notification(
if not webhook_url:
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 = {
"slack": _build_slack_payload,
"teams": _build_teams_payload,
+4
View File
@@ -42,6 +42,8 @@ def _get_path_category(path: str) -> str:
return "ask"
if path.startswith("/api/events/bulk-tags"):
return "write"
if "/explain" in path:
return "explain"
return "default"
@@ -51,6 +53,8 @@ def _limit_for_category(category: str) -> tuple[int, int]:
return (10, 3600) # 10 per hour
if category == "ask":
return (30, 60) # 30 per minute
if category == "explain":
return (20, 60) # 20 per minute — LLM + Graph API calls
if category == "write":
return (20, 60) # 20 per minute
return (RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
+5
View File
@@ -16,3 +16,8 @@ gunicorn
mcp
redis
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."""
import re
from typing import Literal
from auth import require_auth
from bson import ObjectId
from database import alerts_collection
@@ -10,7 +13,7 @@ router = APIRouter(dependencies=[Depends(require_auth)])
class AlertStatusUpdate(BaseModel):
status: str # open | acknowledged | resolved | false_positive
status: Literal["open", "acknowledged", "resolved", "false_positive"]
class AlertListResponse(BaseModel):
@@ -32,7 +35,7 @@ def list_alerts(
if severity:
query["severity"] = severity
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)
skip = (page - 1) * page_size
+8 -1
View File
@@ -7,6 +7,7 @@ import httpx
import structlog
from auth import require_auth, user_can_access_privacy_services
from config import (
LLM_ALLOWED_DOMAINS,
LLM_API_KEY,
LLM_API_VERSION,
LLM_BASE_URL,
@@ -398,7 +399,7 @@ def _format_events_for_llm(
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
parsed = urlparse(url)
@@ -420,6 +421,12 @@ def _validate_llm_url(url: str):
except ValueError:
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:
base = base_url.rstrip("/")
+4 -2
View File
@@ -75,12 +75,14 @@ def run_fetch(hours: int = 168):
@router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse)
def fetch_logs(
async def fetch_logs(
hours: int = Query(default=168, ge=1, le=720),
user: dict = Depends(require_auth),
):
import asyncio
try:
result = run_fetch(hours=hours)
result = await asyncio.to_thread(run_fetch, hours=hours)
log_action(
"fetch_audit_logs",
"/api/fetch-audit-logs",
+20 -9
View File
@@ -7,10 +7,18 @@ import structlog
from auth import require_auth
from database import saved_searches_collection
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
router = APIRouter(dependencies=[Depends(require_auth)])
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:
return user.get("sub", "anonymous")
@@ -29,22 +37,25 @@ async def list_saved_searches(user: dict = Depends(require_auth)):
@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."""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Name is required")
sub = _user_sub(user)
existing = saved_searches_collection.count_documents({"created_by": sub})
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 = {
"_id": str(uuid.uuid4()),
"name": name,
"filters": filters,
"name": body.name,
"filters": body.filters,
"created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
"created_by": _user_sub(user),
"created_by": sub,
}
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")
return doc
-1
View File
@@ -52,7 +52,6 @@ async def graph_webhook(request: Request):
"Received Graph notification",
change_type=notification.get("changeType"),
resource=notification.get("resource"),
client_state=client_state,
)
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 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")
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):
"""Forward a normalized event to the configured SIEM webhook."""
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
return
try:
_validate_siem_url(SIEM_WEBHOOK_URL)
res = requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
res.raise_for_status()
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):
end = datetime.utcnow()
earliest_allowed = end - timedelta(days=_API_MAX_LOOKBACK_DAYS)
max_window_start = end - timedelta(hours=_API_MAX_WINDOW_HOURS)
if since:
# Office 365 API expects format without Z
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:
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")
+2 -2
View File
@@ -157,8 +157,8 @@ def test_saved_searches_delete_not_found(client, monkeypatch):
def test_saved_searches_create_validation(client, monkeypatch):
monkeypatch.setattr("auth.AUTH_ENABLED", False)
response = client.post("/api/saved-searches", json={"name": " ", "filters": {}})
assert response.status_code == 400
response = client.post("/api/saved-searches", json={"name": "", "filters": {}})
assert response.status_code == 422
def test_privacy_filtering_events_by_operation(client, mock_events_collection, monkeypatch):
+1 -1
View File
@@ -141,7 +141,7 @@ class TestBuildEventQuery:
class TestAskEndpoint:
def test_ask_empty_question(self, client):
response = client.post("/api/ask", json={"question": ""})
assert response.status_code == 400
assert response.status_code == 422
def test_ask_no_events(self, client):
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
import auth
@@ -28,19 +29,19 @@ def test_allowed_by_group():
@patch("auth.AUTH_ENABLED", False)
def test_require_auth_disabled():
claims = require_auth(None)
claims = asyncio.run(require_auth(None))
assert claims["sub"] == "anonymous"
@patch("auth.AUTH_ENABLED", True)
def test_require_auth_missing_header():
with pytest.raises(HTTPException) as exc_info:
require_auth(None)
asyncio.run(require_auth(None))
assert exc_info.value.status_code == 401
@patch("auth.AUTH_ENABLED", True)
def test_require_auth_invalid_bearer():
with pytest.raises(HTTPException) as exc_info:
require_auth("Basic abc")
asyncio.run(require_auth("Basic abc"))
assert exc_info.value.status_code == 401
+1 -1
View File
@@ -33,7 +33,7 @@ services:
- mongo
- redis
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
worker:
build: ./backend
+2 -4
View File
@@ -30,11 +30,9 @@ http {
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;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
# Security headers — most headers are set by the backend; only add non-duplicates here.
# X-XSS-Protection is kept for legacy browser compatibility.
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Upstream backend
upstream aoc_backend {