Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe95dfcfce | |||
| 8d951fc335 | |||
| 35eca65234 | |||
| 07a841615b | |||
| c086fa4260 | |||
| be700fefc3 | |||
| e2cea50d87 | |||
| 7fe53f882a | |||
| d01e7801ed | |||
| 7cd7709b4a | |||
| 9cd50d1257 | |||
| 646d61f72e | |||
| 5f7a98f21c | |||
| 19ed231a31 | |||
| f812fda150 | |||
| a194c78c59 | |||
| e984899d4c | |||
| b618cb29ea | |||
| 3e1416cd52 | |||
| 94983c43e9 | |||
| 0a16cf6870 | |||
| e348881083 | |||
| a220494bcf | |||
| 5bda1dd616 | |||
| 3e333291c6 | |||
| aa62528862 | |||
| ac155d8843 | |||
| ed7465f5cd | |||
| 0eebcd0765 | |||
| 67f3c28e82 | |||
| 04c41ee740 | |||
| cbd46adaa6 | |||
| e4bafbc4b0 | |||
| f75f165911 | |||
| 47e0dfc2ca | |||
| 2fffe3aec2 | |||
| b2f4cabef4 | |||
| e069869a94 | |||
| fb2386e190 | |||
| 05f5f07e7b | |||
| 681f7d468a | |||
| fb5d45dfb3 | |||
| 658ddd0aac | |||
| a5db0d363d | |||
| 43582692ba | |||
| 5122739c01 | |||
| 6cf5c0a28b | |||
| 6aa47e9b1e | |||
| 60b6ad15c4 | |||
| b4e504a87b | |||
| b728abb5ee | |||
| d100388c7d | |||
| 11fd87411d | |||
| 6a80bf4eb9 | |||
| 5e02f5a402 | |||
| 0c3e5ec57b | |||
| a255be93fe | |||
| cfe9397cc5 |
44
.env.example
44
.env.example
@@ -27,6 +27,18 @@ RETENTION_DAYS=0
|
|||||||
# Optional: comma-separated CORS origins (e.g., http://localhost:3000,https://app.example.com)
|
# Optional: comma-separated CORS origins (e.g., http://localhost:3000,https://app.example.com)
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# OpenAPI docs exposure (set true only for dev)
|
||||||
|
DOCS_ENABLED=false
|
||||||
|
|
||||||
|
# LLM endpoint domain restriction (comma-separated, supports wildcards like *.openai.azure.com)
|
||||||
|
# LLM_ALLOWED_DOMAINS=api.openai.com,*.openai.azure.com
|
||||||
|
|
||||||
|
# SIEM webhook domain restriction (comma-separated)
|
||||||
|
# SIEM_ALLOWED_DOMAINS=your-siem.com
|
||||||
|
|
||||||
|
# Optional Azure Key Vault for secrets storage
|
||||||
|
# AZURE_KEY_VAULT_NAME=your-keyvault-name
|
||||||
|
|
||||||
# Optional: SIEM export webhook (e.g., Splunk HEC, Sentinel, or generic syslog webhook)
|
# Optional: SIEM export webhook (e.g., Splunk HEC, Sentinel, or generic syslog webhook)
|
||||||
SIEM_ENABLED=false
|
SIEM_ENABLED=false
|
||||||
SIEM_WEBHOOK_URL=
|
SIEM_WEBHOOK_URL=
|
||||||
@@ -34,6 +46,10 @@ SIEM_WEBHOOK_URL=
|
|||||||
# Optional: enable rule-based alerting during ingestion
|
# Optional: enable rule-based alerting during ingestion
|
||||||
ALERTS_ENABLED=false
|
ALERTS_ENABLED=false
|
||||||
|
|
||||||
|
# Optional: enable AI/natural-language features (/api/ask, MCP server)
|
||||||
|
# Set to false to completely disable AI endpoints and UI elements
|
||||||
|
AI_FEATURES_ENABLED=true
|
||||||
|
|
||||||
# Optional: LLM configuration for natural language querying (/api/ask)
|
# Optional: LLM configuration for natural language querying (/api/ask)
|
||||||
# Supports any OpenAI-compatible API (OpenAI, Azure OpenAI, Ollama, etc.)
|
# Supports any OpenAI-compatible API (OpenAI, Azure OpenAI, Ollama, etc.)
|
||||||
# For Azure OpenAI / MS Foundry, set BASE_URL to your deployment endpoint
|
# For Azure OpenAI / MS Foundry, set BASE_URL to your deployment endpoint
|
||||||
@@ -42,6 +58,32 @@ ALERTS_ENABLED=false
|
|||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_BASE_URL=https://api.openai.com/v1
|
LLM_BASE_URL=https://api.openai.com/v1
|
||||||
LLM_MODEL=gpt-4o-mini
|
LLM_MODEL=gpt-4o-mini
|
||||||
LLM_MAX_EVENTS=50
|
LLM_MAX_EVENTS=200
|
||||||
LLM_TIMEOUT_SECONDS=30
|
LLM_TIMEOUT_SECONDS=30
|
||||||
LLM_API_VERSION=
|
LLM_API_VERSION=
|
||||||
|
|
||||||
|
# Valkey (caching + async job queue for LLM calls)
|
||||||
|
# In Docker Compose, this is set automatically to redis://redis:6379/0
|
||||||
|
# For local dev, start Valkey with: docker run -d -p 6379:6379 valkey/valkey:8-alpine
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# UI default page size (number of events shown per page)
|
||||||
|
DEFAULT_PAGE_SIZE=24
|
||||||
|
|
||||||
|
# Alert notifications (optional)
|
||||||
|
# Send triggered admin-ops alerts to a webhook (Slack, Teams, or generic)
|
||||||
|
ALERT_WEBHOOK_URL=
|
||||||
|
ALERT_WEBHOOK_FORMAT=generic # generic | slack | teams
|
||||||
|
ALERT_DEDUPE_MINUTES=15
|
||||||
|
|
||||||
|
# Webhook security (optional but strongly recommended)
|
||||||
|
# Set this to the same clientState used when creating Graph subscriptions
|
||||||
|
WEBHOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Optional: privacy / access control
|
||||||
|
# Hide entire services from users without PRIVACY_SERVICE_ROLES
|
||||||
|
# PRIVACY_SERVICES=Exchange,Teams
|
||||||
|
# Hide specific operations across all services from users without PRIVACY_SERVICE_ROLES
|
||||||
|
# PRIVACY_SENSITIVE_OPERATIONS=MailItemsAccessed,Search-Mailbox,Send,ChatMessageRead
|
||||||
|
# Comma-separated list of Entra roles that can access privacy-sensitive data
|
||||||
|
# PRIVACY_SERVICE_ROLES=SecurityAdministrator,ComplianceAdministrator
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ jobs:
|
|||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.cqre.net -u ${{ github.actor }} --password-stdin 2>&1 | grep -v "WARNING! Your credentials are stored unencrypted"
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.cqre.net -u ${{ github.actor }} --password-stdin 2>&1 | grep -v "WARNING! Your credentials are stored unencrypted"
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker build ./backend --tag git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
|
run: docker build ./backend --build-arg VERSION=${{ gitea.ref_name }} --tag git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
|
||||||
|
|
||||||
- name: Push Docker image
|
- name: Tag as latest
|
||||||
|
run: docker tag git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }} git.cqre.net/cqrenet/aoc-backend:latest
|
||||||
|
|
||||||
|
- name: Push version tag
|
||||||
run: docker push git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
|
run: docker push git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
|
||||||
|
|
||||||
|
- name: Push latest tag
|
||||||
|
run: docker push git.cqre.net/cqrenet/aoc-backend:latest
|
||||||
|
|||||||
154
AGENTS.md
154
AGENTS.md
@@ -6,28 +6,41 @@ AOC is a FastAPI microservice that ingests Microsoft Entra (Azure AD) audit logs
|
|||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Runtime**: Python 3.11
|
- **Runtime**: Python 3.11 (3.14 for tests)
|
||||||
- **Web Framework**: FastAPI + Uvicorn
|
- **Web Framework**: FastAPI + Uvicorn (Gunicorn in production)
|
||||||
- **Database**: MongoDB (PyMongo)
|
- **Database**: MongoDB (PyMongo)
|
||||||
- **Frontend**: Vanilla HTML/CSS/JS (served as static files from `backend/frontend/`)
|
- **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)
|
- **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
|
- **External APIs**: Microsoft Graph API, Office 365 Management Activity API, Azure OpenAI / MS Foundry
|
||||||
- **Deployment**: Docker Compose
|
- **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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
main.py # FastAPI app, router registration, background periodic fetch
|
main.py # FastAPI app, router registration, background periodic fetch
|
||||||
config.py # Environment-based configuration (loads .env)
|
config.py # Pydantic Settings configuration (loads .env + optional Key Vault)
|
||||||
database.py # MongoClient setup (db = micro_soc, collection = events)
|
database.py # MongoClient setup (db = micro_soc, collection = events)
|
||||||
auth.py # OIDC Bearer token validation, JWKS caching, role/group checks
|
auth.py # OIDC Bearer token validation, JWKS caching, role/group checks
|
||||||
|
secrets_manager.py # Optional Azure Key Vault integration for secrets
|
||||||
|
rate_limiter.py # Redis-backed fixed-window rate limiter (fail-closed)
|
||||||
requirements.txt # Python dependencies
|
requirements.txt # Python dependencies
|
||||||
Dockerfile # python:3.11-slim image
|
Dockerfile # python:3.11-slim image, non-root user, version baked at build
|
||||||
|
mcp_server.py # Standalone MCP server for Claude Desktop / Cursor integration
|
||||||
routes/
|
routes/
|
||||||
fetch.py # GET /api/fetch-audit-logs, run_fetch()
|
fetch.py # GET /api/fetch-audit-logs, run_fetch()
|
||||||
events.py # GET /api/events, GET /api/filter-options
|
events.py # GET /api/events, GET /api/filter-options, PATCH tags, POST comments
|
||||||
config.py # GET /api/config/auth
|
config.py # GET /api/config/auth, GET /api/config/features
|
||||||
|
ask.py # POST /api/ask — natural language query with LLM
|
||||||
|
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/
|
graph/
|
||||||
auth.py # Client credentials token acquisition for Graph
|
auth.py # Client credentials token acquisition for Graph
|
||||||
audit_logs.py # Fetch and enrich directory audit logs from Graph
|
audit_logs.py # Fetch and enrich directory audit logs from Graph
|
||||||
@@ -41,7 +54,7 @@ backend/
|
|||||||
mappings.yml # User-editable category labels and summary templates
|
mappings.yml # User-editable category labels and summary templates
|
||||||
maintenance.py # CLI for re-normalization and deduplication of stored events
|
maintenance.py # CLI for re-normalization and deduplication of stored events
|
||||||
frontend/
|
frontend/
|
||||||
index.html # Single-page UI with filters, pagination, raw-event modal
|
index.html # Single-page UI with filters, pagination, ask panel, raw-event modal
|
||||||
style.css # Dark-themed stylesheet
|
style.css # Dark-themed stylesheet
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -53,7 +66,7 @@ Copy `.env.example` to `.env` at the repo root and fill in values:
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Key variables:
|
### Core variables
|
||||||
- `TENANT_ID`, `CLIENT_ID`, `CLIENT_SECRET` — Microsoft app registration credentials (application permissions)
|
- `TENANT_ID`, `CLIENT_ID`, `CLIENT_SECRET` — Microsoft app registration credentials (application permissions)
|
||||||
- `AUTH_ENABLED` — set `true` to protect API/UI with OIDC Bearer tokens
|
- `AUTH_ENABLED` — set `true` to protect API/UI with OIDC Bearer tokens
|
||||||
- `AUTH_TENANT_ID`, `AUTH_CLIENT_ID` — token validation audience/issuer
|
- `AUTH_TENANT_ID`, `AUTH_CLIENT_ID` — token validation audience/issuer
|
||||||
@@ -61,6 +74,35 @@ Key variables:
|
|||||||
- `ENABLE_PERIODIC_FETCH`, `FETCH_INTERVAL_MINUTES` — background ingestion scheduler
|
- `ENABLE_PERIODIC_FETCH`, `FETCH_INTERVAL_MINUTES` — background ingestion scheduler
|
||||||
- `MONGO_ROOT_USERNAME`, `MONGO_ROOT_PASSWORD`, `MONGO_PORT` — used by Docker Compose for MongoDB
|
- `MONGO_ROOT_USERNAME`, `MONGO_ROOT_PASSWORD`, `MONGO_PORT` — used by Docker Compose for MongoDB
|
||||||
|
|
||||||
|
### AI / LLM variables
|
||||||
|
- `AI_FEATURES_ENABLED` — set `false` to completely disable AI endpoints and UI (default `true`)
|
||||||
|
- `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
|
## Build and Run Commands
|
||||||
|
|
||||||
**Docker Compose (recommended):**
|
**Docker Compose (recommended):**
|
||||||
@@ -87,35 +129,97 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
- `GET /api/fetch-audit-logs?hours=168` — pulls last N hours (capped at 720 / 30 days) from all sources, normalizes, dedupes, and upserts into MongoDB
|
- `GET /api/fetch-audit-logs?hours=168` — pulls last N hours (capped at 720 / 30 days) from all sources, normalizes, dedupes, and upserts into MongoDB
|
||||||
- `GET /api/events` — list stored events with filters (`service`, `actor`, `operation`, `result`, `start`, `end`, `search`) and pagination (`page`, `page_size`)
|
- `GET /api/events` — list stored events with filters (`service`, `actor`, `operation`, `result`, `start`, `end`, `search`) and cursor-based pagination
|
||||||
- `GET /api/filter-options` — best-effort distinct values for UI dropdowns
|
- `GET /api/filter-options` — best-effort distinct values for UI dropdowns
|
||||||
- `GET /api/config/auth` — auth configuration exposed to the frontend
|
- `GET /api/config/auth` — auth configuration exposed to the frontend
|
||||||
|
- `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 (IP-restricted by default)
|
||||||
|
- `GET /api/source-health` — last fetch status per ingestion source
|
||||||
|
- `GET /api/version` — running version
|
||||||
|
|
||||||
|
## MCP Server
|
||||||
|
|
||||||
|
A standalone MCP server (`backend/mcp_server.py`) exposes audit log tools for Claude Desktop, Cursor, and other MCP clients.
|
||||||
|
|
||||||
|
Available tools:
|
||||||
|
- `search_events` — Search by entity, service, operation, result, time range
|
||||||
|
- `get_event` — Retrieve a single event by ID (raw JSON)
|
||||||
|
- `get_summary` — Aggregated counts by service, operation, result, actor
|
||||||
|
- `ask` — Natural language question (returns recent events + guidance)
|
||||||
|
|
||||||
|
**Claude Desktop config** (`~/.config/claude/claude_desktop_config.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"aoc": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["/path/to/aoc/backend/mcp_server.py"],
|
||||||
|
"env": {"MONGO_URI": "mongodb://root:example@localhost:27017/"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP server imports `database.py` directly and does not go through the FastAPI layer, so it shares the same MongoDB connection but bypasses auth.
|
||||||
|
|
||||||
|
## AI Feature Flag
|
||||||
|
|
||||||
|
Set `AI_FEATURES_ENABLED=false` in `.env` to:
|
||||||
|
- Prevent the `ask` router from being registered in FastAPI
|
||||||
|
- Hide the "Ask a question" panel in the frontend
|
||||||
|
- Return `ai_features_enabled: false` from `/api/config/features`
|
||||||
|
|
||||||
|
This is intended for the open-core monetization split: core features (ingestion, filtering, search, export) are always available; premium AI features (NLQ, MCP) can be disabled.
|
||||||
|
|
||||||
## Code Conventions
|
## Code Conventions
|
||||||
|
|
||||||
- Python modules use absolute imports within the `backend/` package (e.g., `from graph.auth import get_access_token`). When running locally, ensure the working directory is `backend/` so these resolve correctly.
|
- Python modules use absolute imports within the `backend/` package (e.g., `from graph.auth import get_access_token`). When running locally, ensure the working directory is `backend/` so these resolve correctly.
|
||||||
- No formal formatter or linter is configured. Keep changes consistent with the existing style: simple functions, explicit exception handling, and informative docstrings.
|
- The project uses `ruff` for linting and formatting. Run `ruff check . && ruff format .` before committing.
|
||||||
- The frontend is a single HTML file with inline JavaScript. It relies on the MSAL.js CDN (`https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js`).
|
- Keep changes consistent with the existing style: simple functions, explicit exception handling, and informative docstrings.
|
||||||
|
- The frontend is a single HTML file with inline JavaScript and Alpine.js.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
There are currently **no automated tests** in this repository. When adding new features or bug fixes, verify behavior manually:
|
Tests run with pytest and mongomock (no real MongoDB required):
|
||||||
|
|
||||||
1. Start the server (Docker Compose or local uvicorn).
|
```bash
|
||||||
2. Run a smoke test:
|
cd backend
|
||||||
```bash
|
python -m venv .venv_test
|
||||||
curl http://localhost:8000/api/events
|
source .venv_test/bin/activate
|
||||||
curl http://localhost:8000/api/fetch-audit-logs
|
pip install -r requirements.txt
|
||||||
```
|
pytest tests/ -q
|
||||||
3. Open http://localhost:8000 in a browser, apply filters, paginate, and click "View raw event".
|
```
|
||||||
|
|
||||||
|
When adding new features or bug fixes, add or update tests in `backend/tests/`. The test suite covers:
|
||||||
|
- Event normalization and deduplication
|
||||||
|
- 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
|
## Security Considerations
|
||||||
|
|
||||||
- **Secrets**: `CLIENT_SECRET` and other credentials come from `.env`. Never commit `.env`.
|
- **Secrets**: `CLIENT_SECRET`, `LLM_API_KEY`, and other credentials come from `.env` or Azure Key Vault. Never commit `.env`.
|
||||||
- **Auth validation**: When `AUTH_ENABLED=true`, the backend fetches JWKS from `https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration`, caches keys for 1 hour, and validates tenant/issuer claims. Tokens are decoded without strict signature verification (`jwt.get_unverified_claims`), so the tenant and issuer checks are the primary gate.
|
- **Auth validation**: When `AUTH_ENABLED=true`, the backend fetches JWKS from `https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration`, caches keys for 1 hour, and validates tenant/issuer/audience claims. Tokens are decoded with RS256 signature verification.
|
||||||
- **Role/Group gating**: Access is allowed if the token’s `roles` intersect `AUTH_ALLOWED_ROLES` or `groups` intersect `AUTH_ALLOWED_GROUPS`. If neither list is configured, all authenticated users are allowed.
|
- **Role/Group gating**: Access is allowed if the token's `roles` intersect `AUTH_ALLOWED_ROLES` or `groups` intersect `AUTH_ALLOWED_GROUPS`. If neither list is configured, all authenticated users are allowed — a startup warning is logged in this case.
|
||||||
|
- **CORS**: When `AUTH_ENABLED=true` and `CORS_ORIGINS="*"`, `allow_credentials` is forced to `false` to prevent cross-origin token leakage.
|
||||||
|
- **Rate limiting**: Redis-backed fixed-window rate limiting with per-category limits (fetch=10/hr, ask=30/min, write=20/min, default=120/min). Fails closed (returns 429) when Redis is unavailable.
|
||||||
- **Pagination limits**: `page_size` is clamped to a maximum of 500 to prevent large queries.
|
- **Pagination limits**: `page_size` is clamped to a maximum of 500 to prevent large queries.
|
||||||
- **Fetch window cap**: `hours` is clamped to 720 (30 days) to avoid runaway API calls.
|
- **Fetch window cap**: `hours` is clamped to 720 (30 days) to avoid runaway API calls.
|
||||||
|
- **LLM SSRF guard**: `LLM_BASE_URL` must be HTTPS and cannot point to private IPs. Optional `LLM_ALLOWED_DOMAINS` restricts to specific domains.
|
||||||
|
- **SIEM SSRF guard**: `SIEM_WEBHOOK_URL` has the same validation as LLM URLs, plus optional `SIEM_ALLOWED_DOMAINS`.
|
||||||
|
- **Metrics IP gating**: `/metrics` is restricted to private/loopback IPs by default via `METRICS_ALLOWED_IPS`.
|
||||||
|
- **OpenAPI docs**: Disabled by default (`DOCS_ENABLED=false`). Enable only in development.
|
||||||
|
- **CSP**: Content-Security-Policy headers are set on all responses. `unsafe-eval` is required for Alpine.js v3 expression evaluation.
|
||||||
|
- **SRI**: CDN scripts (Alpine.js, MSAL.js) include Subresource Integrity hashes to prevent supply chain compromise.
|
||||||
|
- **MCP server**: The MCP server bypasses auth entirely. Only run it in trusted environments or behind a VPN.
|
||||||
|
|
||||||
|
### Security Documentation
|
||||||
|
|
||||||
|
- `PEN_TEST_REPORT_v1.7.11.md` — Internal soft penetration test findings and remediation
|
||||||
|
- `THREAT_MODEL_v1.7.13.md` — Comprehensive threat model covering Entra/token abuse vectors
|
||||||
|
|
||||||
## Maintenance and Operations
|
## Maintenance and Operations
|
||||||
|
|
||||||
|
|||||||
54
DEPLOY.md
54
DEPLOY.md
@@ -7,6 +7,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
|
|||||||
- **nginx** — reverse proxy, TLS termination, static file serving
|
- **nginx** — reverse proxy, TLS termination, static file serving
|
||||||
- **backend** — FastAPI application (Gunicorn + Uvicorn workers)
|
- **backend** — FastAPI application (Gunicorn + Uvicorn workers)
|
||||||
- **mongo** — MongoDB data store (not exposed externally)
|
- **mongo** — MongoDB data store (not exposed externally)
|
||||||
|
- **valkey** — Redis-compatible cache and async job queue (not exposed externally)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
|
|||||||
1. **Clone / pull the latest release**
|
1. **Clone / pull the latest release**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout v1.1.0
|
git checkout v1.7.14
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Copy and edit environment variables**
|
2. **Copy and edit environment variables**
|
||||||
@@ -33,7 +34,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
|
|||||||
3. **Set the release version**
|
3. **Set the release version**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export AOC_VERSION=v1.1.0
|
export AOC_VERSION=v1.7.14
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Deploy**
|
4. **Deploy**
|
||||||
@@ -53,7 +54,7 @@ AOC runs as a set of Docker containers orchestrated by Docker Compose:
|
|||||||
## Updating to a new release
|
## Updating to a new release
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export AOC_VERSION=v1.2.0
|
export AOC_VERSION=v1.7.14
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
```
|
```
|
||||||
@@ -75,24 +76,56 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
|
|
||||||
Replace the `nginx` service in `docker-compose.prod.yml` with a Certbot-friendly setup (e.g., use the `nginx-proxy` + `acme-companion` stack) or mount the Certbot certificates into `nginx/ssl/`.
|
Replace the `nginx` service in `docker-compose.prod.yml` with a Certbot-friendly setup (e.g., use the `nginx-proxy` + `acme-companion` stack) or mount the Certbot certificates into `nginx/ssl/`.
|
||||||
|
|
||||||
## Security hardening
|
## Security Hardening
|
||||||
|
|
||||||
- MongoDB is **not exposed** to the host — only the backend container can reach it.
|
- MongoDB is **not exposed** to the host — only the backend container can reach it.
|
||||||
|
- Valkey/Redis is **not exposed** to the host — only the backend container can reach it.
|
||||||
- The backend runs as a non-root (`aoc`) user inside the container.
|
- The backend runs as a non-root (`aoc`) user inside the container.
|
||||||
- nginx adds security headers (`X-Frame-Options`, `X-Content-Type-Options`, etc.).
|
- nginx adds security headers (`X-Frame-Options`, `X-Content-Type-Options`, etc.).
|
||||||
- Keep `.env` out of version control — it is listed in `.gitignore`.
|
- Keep `.env` out of version control — it is listed in `.gitignore`.
|
||||||
|
- Set `AUTH_ENABLED=true` and configure `AUTH_ALLOWED_ROLES` or `AUTH_ALLOWED_GROUPS` to restrict access to admin/security roles.
|
||||||
|
- Set explicit `CORS_ORIGINS` — do not use `*` in production when auth is enabled.
|
||||||
|
- Set `DOCS_ENABLED=false` to hide OpenAPI docs (`/docs`, `/openapi.json`).
|
||||||
|
- Configure `WEBHOOK_CLIENT_SECRET` to validate Graph webhook notifications.
|
||||||
|
- Set `LLM_ALLOWED_DOMAINS` if using AI features (e.g. `api.openai.com,*.openai.azure.com`).
|
||||||
|
- Set `SIEM_ALLOWED_DOMAINS` if using SIEM forwarding.
|
||||||
|
- Review `METRICS_ALLOWED_IPS` — defaults to private networks + loopback.
|
||||||
|
|
||||||
|
## Azure Key Vault (Optional)
|
||||||
|
|
||||||
|
To eliminate long-lived secrets from `.env`:
|
||||||
|
|
||||||
|
1. Create an Azure Key Vault and add these secrets:
|
||||||
|
- `aoc-client-secret` — your Graph app `CLIENT_SECRET`
|
||||||
|
- `aoc-llm-api-key` — your `LLM_API_KEY` (if using AI)
|
||||||
|
- `aoc-mongo-uri` — your `MONGO_URI`
|
||||||
|
- `aoc-webhook-client-secret` — your `WEBHOOK_CLIENT_SECRET`
|
||||||
|
|
||||||
|
2. Uncomment `azure-identity` and `azure-keyvault-secrets` in `backend/requirements.txt`
|
||||||
|
|
||||||
|
3. Set `AZURE_KEY_VAULT_NAME=your-keyvault-name` in `.env`
|
||||||
|
|
||||||
|
4. Grant the container identity `Get` permission on secrets:
|
||||||
|
- If using Azure Container Instances / AKS: assign a managed identity
|
||||||
|
- If using VM: assign a managed identity or use a service principal
|
||||||
|
- If using local Docker: authenticate via `az login` on the host
|
||||||
|
|
||||||
|
5. Rebuild and redeploy:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export AOC_VERSION=v1.0.3
|
export AOC_VERSION=v1.7.13
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
- Prometheus metrics: `http://your-host/metrics`
|
- Prometheus metrics: `http://your-host/metrics` (IP-restricted by default)
|
||||||
- Health check: `http://your-host/health`
|
- Health check: `http://your-host/health`
|
||||||
- Container logs:
|
- Container logs:
|
||||||
|
|
||||||
@@ -100,4 +133,13 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
docker compose -f docker-compose.prod.yml logs -f backend
|
docker compose -f docker-compose.prod.yml logs -f backend
|
||||||
docker compose -f docker-compose.prod.yml logs -f nginx
|
docker compose -f docker-compose.prod.yml logs -f nginx
|
||||||
docker compose -f docker-compose.prod.yml logs -f mongo
|
docker compose -f docker-compose.prod.yml logs -f mongo
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f valkey
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Auth warning in logs**: "AUTH_ENABLED is true but no AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS are configured" — set these to restrict access.
|
||||||
|
- **CORS issues**: Set `CORS_ORIGINS` to your exact frontend origin(s). Wildcard with auth enabled disables credentials.
|
||||||
|
- **Rate limiting 429s**: Check Redis/Valkey connectivity. The rate limiter fails closed (returns 429) when Redis is down.
|
||||||
|
- **LLM errors**: Verify `LLM_BASE_URL` is in `LLM_ALLOWED_DOMAINS` if the allowlist is configured.
|
||||||
|
- **SIEM not forwarding**: Verify `SIEM_WEBHOOK_URL` uses HTTPS and is in `SIEM_ALLOWED_DOMAINS`.
|
||||||
|
|||||||
203
PEN_TEST_REPORT_v1.7.11.md
Normal file
203
PEN_TEST_REPORT_v1.7.11.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# AOC v1.7.11 Soft Penetration Test Report
|
||||||
|
|
||||||
|
**Date:** 2026-04-27
|
||||||
|
**Target:** Local AOC instance (port 8001), auth disabled, AI disabled
|
||||||
|
**Tester:** Automated + manual curl-based probing
|
||||||
|
**Scope:** FastAPI backend, REST API endpoints, middleware, headers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
AOC v1.7.11 has one **CRITICAL** vulnerability (CORS credentials leak) and several defense-in-depth gaps. The good news: input validation, NoSQL injection resistance, and error handling are solid. The bad news: CORS is dangerously permissive, security headers are missing, and the rate limiter fails open on Redis failure.
|
||||||
|
|
||||||
|
| Severity | Count | Categories |
|
||||||
|
|----------|-------|------------|
|
||||||
|
| CRITICAL | 1 | CORS with credentials |
|
||||||
|
| HIGH | 1 | Missing security headers |
|
||||||
|
| MEDIUM | 2 | Fail-open rate limiter, OpenAPI exposure |
|
||||||
|
| LOW | 2 | Information disclosure, webhook content injection |
|
||||||
|
| INFO | 3 | Positive findings (no stack traces, input validation, NoSQL resistance) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
|
||||||
|
### 1. CORS Reflects Any Origin with `allow_credentials=true`
|
||||||
|
|
||||||
|
**Finding:** The CORS middleware returns `Access-Control-Allow-Origin: <any origin>` AND `Access-Control-Allow-Credentials: true` for every origin that sends an `Origin` header.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```bash
|
||||||
|
curl -H "Origin: https://evil-attacker.com" http://localhost:8001/api/config/auth
|
||||||
|
# Response headers:
|
||||||
|
# access-control-allow-origin: https://evil-attacker.com
|
||||||
|
# access-control-allow-credentials: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** An attacker can host a malicious page on any domain and make authenticated cross-origin requests to the AOC API using the victim's browser cookies/tokens. This effectively bypasses Same-Origin Policy for authenticated actions.
|
||||||
|
|
||||||
|
**Root Cause:** `main.py` configures CORS with `allow_origins=["*"]` (from `CORS_ORIGINS` env var, default `"*"`) AND `allow_credentials=True`. According to CORS spec, a wildcard origin with credentials is technically invalid, but Starlette/FastAPI appears to reflect the request origin instead.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- When `AUTH_ENABLED=true`, reject requests from origins not in an explicit allowlist.
|
||||||
|
- Set `allow_credentials=False` if wildcard origins are needed.
|
||||||
|
- Or, require `CORS_ORIGINS` to be explicitly configured (no default wildcard) when auth is enabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH
|
||||||
|
|
||||||
|
### 2. Missing Security Headers
|
||||||
|
|
||||||
|
**Finding:** The following security headers are absent from all responses:
|
||||||
|
|
||||||
|
| Header | Purpose | Status |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| `X-Content-Type-Options: nosniff` | Prevents MIME sniffing | MISSING |
|
||||||
|
| `X-Frame-Options: DENY` or `SAMEORIGIN` | Clickjacking protection | MISSING |
|
||||||
|
| `Strict-Transport-Security` | HSTS enforcement | MISSING |
|
||||||
|
| `Referrer-Policy: strict-origin-when-cross-origin` | Limits referrer leakage | MISSING |
|
||||||
|
| `Permissions-Policy` | Restricts browser features | MISSING |
|
||||||
|
|
||||||
|
**Impact:** Increased attack surface for clickjacking, MIME confusion attacks, and information leakage via referrer headers.
|
||||||
|
|
||||||
|
**Recommendation:** Add a security headers middleware to set these on all responses. HSTS only when served over HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM
|
||||||
|
|
||||||
|
### 3. Rate Limiter Fails Open on Redis Failure
|
||||||
|
|
||||||
|
**Finding:** In `rate_limiter.py` line 81-82:
|
||||||
|
```python
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Rate limiter Redis error; allowing request", error=str(exc))
|
||||||
|
```
|
||||||
|
|
||||||
|
If Redis becomes unreachable, all rate limits are silently bypassed.
|
||||||
|
|
||||||
|
**Evidence:** When Redis was down, 150+ requests to `/api/events` all returned 200 with no 429s.
|
||||||
|
|
||||||
|
**Impact:** A DoS on Redis (or a network partition) removes all rate limiting, allowing unlimited API abuse.
|
||||||
|
|
||||||
|
**Recommendation:** Make the rate limiter fail-closed: return 429 or 503 when Redis is unavailable, or use an in-memory fallback with a conservative limit.
|
||||||
|
|
||||||
|
### 4. OpenAPI Schema Publicly Exposed
|
||||||
|
|
||||||
|
**Finding:** `/docs`, `/redoc`, and `/openapi.json` are accessible without authentication and return the full API schema.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8001/openapi.json | jq '.paths | keys'
|
||||||
|
# Returns all 15+ API paths including internal endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Attackers get a complete map of the API, including request/response schemas, parameter types, and endpoint structure. This significantly reduces reconnaissance time.
|
||||||
|
|
||||||
|
**Recommendation:** Disable OpenAPI docs in production (`docs_url=None, redoc_url=None, openapi_url=None`) or gate them behind admin authentication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW
|
||||||
|
|
||||||
|
### 5. Information Disclosure via `/api/config/auth` and `/metrics`
|
||||||
|
|
||||||
|
**Finding:**
|
||||||
|
- `/api/config/auth` leaks `tenant_id` and `client_id` even when auth is disabled. These values fall back to the Graph API credentials (`TENANT_ID`/`CLIENT_ID`), which may be sensitive.
|
||||||
|
- `/metrics` exposes Python version (`3.14.3`), GC statistics, and application-internal metric names.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth_enabled": false,
|
||||||
|
"tenant_id": "0ec9f34c-17c8-4541-b084-7d64ecdcc997",
|
||||||
|
"client_id": "cc31fd45-1eca-431f-a2c6-ba81cd4c5d50"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Low direct impact (tenant/client IDs are not secrets), but aids reconnaissance and narrows the attack surface.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Return empty strings for `tenant_id`/`client_id` when `auth_enabled=false`.
|
||||||
|
- Gate `/metrics` behind IP allowlist or admin auth (standard Prometheus practice).
|
||||||
|
|
||||||
|
### 6. Webhook Validation Token Echoed Without Sanitization
|
||||||
|
|
||||||
|
**Finding:** The `/api/webhooks/graph` endpoint echoes `validationToken` query parameter as `text/plain` without any sanitization or length limits.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8001/api/webhooks/graph?validationToken=<script>alert(1)</script>"
|
||||||
|
# Returns: <script>alert(1)</script> with Content-Type: text/plain
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Low in the intended Microsoft Graph flow (token is Microsoft-generated), but if the endpoint is hit directly, an attacker could use this for cache poisoning, response splitting, or social engineering by making the endpoint return attacker-controlled content.
|
||||||
|
|
||||||
|
**Recommendation:** Validate the validationToken format (e.g., JWT-like structure, length limits) before echoing, or set `Content-Type: text/plain; charset=utf-8` with `X-Content-Type-Options: nosniff` to reduce MIME confusion risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## INFO (Positive Findings)
|
||||||
|
|
||||||
|
### A. No Stack Traces in Error Responses
|
||||||
|
|
||||||
|
All errors (422, 404, 429, 500 if triggered) return generic JSON messages without internal details or stack traces. Good.
|
||||||
|
|
||||||
|
### B. Pydantic Input Validation is Effective
|
||||||
|
|
||||||
|
- `page_size` capped at 500 (returns 422 for 501, 0, -1)
|
||||||
|
- `hours` capped at 720 (returns 422 for 721)
|
||||||
|
- Invalid cursors return 400 with "Invalid cursor"
|
||||||
|
- Malformed JSON bodies return 422 with field-level validation errors
|
||||||
|
- `AlertCondition` op field strictly validated against `Literal["eq", "neq", "contains", "in", "after_hours"]`
|
||||||
|
|
||||||
|
### C. NoSQL Injection Resistant
|
||||||
|
|
||||||
|
MongoDB operators passed as string filter values are treated as literals, not operators:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8001/api/events?operation=\$ne"
|
||||||
|
# Returns 0 results (treated as literal string "$ne")
|
||||||
|
```
|
||||||
|
|
||||||
|
The `_build_query()` function in `events.py` uses `re.escape()` for search input and constructs queries safely.
|
||||||
|
|
||||||
|
### D. Bulk Tags Pre-Count Check Works
|
||||||
|
|
||||||
|
`bulk_tags` endpoint capped at 10,000 matched documents via pre-count check. 93 events were successfully tagged with no bypass.
|
||||||
|
|
||||||
|
### E. Rate Limiting Works When Redis is Healthy
|
||||||
|
|
||||||
|
- `/api/fetch-audit-logs`: 429 after 11 requests (limit: 10/hr)
|
||||||
|
- `/api/events`: 429 after ~120 requests (limit: 120/min)
|
||||||
|
- Exempt paths work correctly: `/health`, `/metrics`, `/api/config/auth`, `/api/config/features`
|
||||||
|
- `Retry-After` header is returned on 429 responses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations Summary
|
||||||
|
|
||||||
|
| Priority | Action | Effort |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| P0 | Fix CORS: do not allow credentials with wildcard/reflected origins | Small |
|
||||||
|
| P1 | Add security headers middleware (X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy) | Small |
|
||||||
|
| P2 | Make rate limiter fail-closed on Redis errors | Small |
|
||||||
|
| P2 | Disable OpenAPI docs in production or gate behind auth | Small |
|
||||||
|
| P3 | Sanitize or validate webhook validationToken before echo | Small |
|
||||||
|
| P3 | Gate `/metrics` behind IP allowlist | Small |
|
||||||
|
| P3 | Hide tenant_id/client_id from `/api/config/auth` when auth is disabled | Tiny |
|
||||||
|
| P4 | Consider Alpine.js CSP build to remove `unsafe-eval` from script-src | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Environment
|
||||||
|
|
||||||
|
```
|
||||||
|
Backend: uvicorn on localhost:8001 (auth=false, ai=false)
|
||||||
|
MongoDB: docker container, port 27018
|
||||||
|
Redis: docker container, port 6380
|
||||||
|
```
|
||||||
|
|
||||||
|
*Test commands and raw outputs available in `/tmp/pen_test*.sh` scripts.*
|
||||||
84
README.md
84
README.md
@@ -9,13 +9,16 @@ FastAPI microservice that ingests Microsoft Entra (Azure AD) and other admin aud
|
|||||||
- Office 365 Management Activity API client for Exchange/SharePoint/Teams admin audit logs.
|
- Office 365 Management Activity API client for Exchange/SharePoint/Teams admin audit logs.
|
||||||
- Frontend served from the backend for filtering/searching events and viewing raw entries.
|
- Frontend served from the backend for filtering/searching events and viewing raw entries.
|
||||||
- Optional OIDC bearer auth (Entra) to protect the API/UI and gate access by roles/groups.
|
- Optional OIDC bearer auth (Entra) to protect the API/UI and gate access by roles/groups.
|
||||||
|
- Natural language query (`/api/ask`) powered by LLM (OpenAI, Azure OpenAI, or any compatible API).
|
||||||
|
- MCP server for Claude Desktop / Cursor integration.
|
||||||
|
- Optional Azure Key Vault integration for secrets storage.
|
||||||
|
|
||||||
## Prerequisites (macOS)
|
## Prerequisites (macOS)
|
||||||
- Python 3.11
|
- Python 3.11
|
||||||
- Docker Desktop (for the quickest start) or a local MongoDB instance
|
- Docker Desktop (for the quickest start) or a local MongoDB instance
|
||||||
- An Entra app registration with **Application** permission `AuditLog.Read.All` and admin consent granted
|
- An Entra app registration with **Application** permission `AuditLog.Read.All` and admin consent granted
|
||||||
- Also required to fetch other sources:
|
- Also required to fetch other sources:
|
||||||
- `https://manage.office.com/.default` (Audit API) with `ActivityFeed.Read`/`ActivityFeed.ReadDlp` (built into the app registration’s API permissions for Office 365 Management APIs)
|
- `https://manage.office.com/.default` (Audit API) with `ActivityFeed.Read`/`ActivityFeed.ReadDlp` (built into the app registration's API permissions for Office 365 Management APIs)
|
||||||
- Intune audit: `DeviceManagementConfiguration.Read.All` (or broader) for `/deviceManagement/auditEvents`
|
- Intune audit: `DeviceManagementConfiguration.Read.All` (or broader) for `/deviceManagement/auditEvents`
|
||||||
- Optional API protection: configure `AUTH_ENABLED=true` and set `AUTH_TENANT_ID`/`AUTH_CLIENT_ID` (the audience) plus allowed roles/groups.
|
- Optional API protection: configure `AUTH_ENABLED=true` and set `AUTH_TENANT_ID`/`AUTH_CLIENT_ID` (the audience) plus allowed roles/groups.
|
||||||
|
|
||||||
@@ -38,8 +41,52 @@ cp .env.example .env
|
|||||||
|
|
||||||
# Optional: CORS origins if the frontend is served separately
|
# Optional: CORS origins if the frontend is served separately
|
||||||
# CORS_ORIGINS=http://localhost:3000,https://app.example.com
|
# CORS_ORIGINS=http://localhost:3000,https://app.example.com
|
||||||
|
|
||||||
|
# Optional: enable AI/natural-language features (/api/ask, MCP server)
|
||||||
|
# AI_FEATURES_ENABLED=true
|
||||||
|
|
||||||
|
# Optional: LLM configuration for natural language querying
|
||||||
|
# LLM_API_KEY=...
|
||||||
|
# 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)
|
## Run with Docker Compose (recommended)
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
@@ -65,7 +112,8 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `GET /health` — health check with MongoDB connectivity status.
|
- `GET /health` — health check with MongoDB connectivity status.
|
||||||
- `GET /metrics` — Prometheus metrics for request latency, fetch volume, and errors.
|
- `GET /metrics` — Prometheus metrics for request latency, fetch volume, and errors (IP-restricted).
|
||||||
|
- `GET /api/version` — running version (baked into the Docker image at build time).
|
||||||
- `GET /api/fetch-audit-logs` — pulls the last 7 days by default (override with `?hours=N`, capped to 30 days) of:
|
- `GET /api/fetch-audit-logs` — pulls the last 7 days by default (override with `?hours=N`, capped to 30 days) of:
|
||||||
- Entra directory audit logs (`/auditLogs/directoryAudits`)
|
- Entra directory audit logs (`/auditLogs/directoryAudits`)
|
||||||
- Exchange/SharePoint/Teams admin audits (via Office 365 Management Activity API)
|
- Exchange/SharePoint/Teams admin audits (via Office 365 Management Activity API)
|
||||||
@@ -82,11 +130,34 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
- `GET /api/source-health` — last fetch status for each ingestion source (`directory`, `unified`, `intune`).
|
- `GET /api/source-health` — last fetch status for each ingestion source (`directory`, `unified`, `intune`).
|
||||||
- `PATCH /api/events/{id}/tags` — update tags on an event (e.g., `investigating`, `false_positive`).
|
- `PATCH /api/events/{id}/tags` — update tags on an event (e.g., `investigating`, `false_positive`).
|
||||||
- `POST /api/events/{id}/comments` — add a comment to an event.
|
- `POST /api/events/{id}/comments` — add a comment to an event.
|
||||||
|
- `POST /api/events/{id}/explain` — AI explanation of a single audit event with security context (requires `LLM_API_KEY`).
|
||||||
|
- `POST /api/ask` — natural language query. Returns a narrative answer + referenced events. Supports time ranges, entity names, and respects active UI filters. Only available when `AI_FEATURES_ENABLED=true`.
|
||||||
|
- `GET /api/config/features` — feature flags (`ai_features_enabled`).
|
||||||
- `GET /api/rules` — list alert rules.
|
- `GET /api/rules` — list alert rules.
|
||||||
- `POST /api/rules` — create an alert rule.
|
- `POST /api/rules` — create an alert rule.
|
||||||
- `PUT /api/rules/{id}` — update an alert rule.
|
- `PUT /api/rules/{id}` — update an alert rule.
|
||||||
- `DELETE /api/rules/{id}` — delete an alert rule.
|
- `DELETE /api/rules/{id}` — delete an alert rule.
|
||||||
|
|
||||||
|
### MCP Server
|
||||||
|
AOC exposes an MCP interface in two forms:
|
||||||
|
|
||||||
|
**1. HTTP/SSE (production)** — mounted at `/mcp` inside the FastAPI app, behind OIDC auth:
|
||||||
|
- `GET /mcp/sse` — establish SSE stream (requires Bearer token if `AUTH_ENABLED=true`)
|
||||||
|
- `POST /mcp/messages/?session_id=...` — send tool calls
|
||||||
|
|
||||||
|
This is the recommended way to use MCP against a remote deployment like `aoc.cqre.net`. Any MCP client that supports SSE transport (e.g. Cursor, Claude Desktop with an SSE bridge, or custom scripts) can connect using the same Entra token as the web UI.
|
||||||
|
|
||||||
|
**2. stdio (local development)** — `python backend/mcp_server.py`:
|
||||||
|
- Runs as a local subprocess for Claude Desktop
|
||||||
|
- Connects directly to MongoDB (bypasses FastAPI auth)
|
||||||
|
- Useful for local development when you have the repo cloned and MongoDB running locally
|
||||||
|
|
||||||
|
Available tools (both transports):
|
||||||
|
- `search_events` — filter by entity, service, operation, result, time range.
|
||||||
|
- `get_event` — retrieve raw event JSON by ID.
|
||||||
|
- `get_summary` — aggregated summary (service, operation, result, actor counts) for the last N days.
|
||||||
|
- `ask` — natural language query returning recent events.
|
||||||
|
|
||||||
Stored document shape (collection `micro_soc.events`):
|
Stored document shape (collection `micro_soc.events`):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -136,7 +207,7 @@ curl http://localhost:8000/api/fetch-audit-logs
|
|||||||
- Visit the UI at http://localhost:8000 to filter by user/service/action/result/time, search raw text, paginate, and view raw events.
|
- Visit the UI at http://localhost:8000 to filter by user/service/action/result/time, search raw text, paginate, and view raw events.
|
||||||
|
|
||||||
## Maintenance (Dockerized)
|
## Maintenance (Dockerized)
|
||||||
Use the backend image so you don’t need a local venv:
|
Use the backend image so you don't need a local venv:
|
||||||
```bash
|
```bash
|
||||||
# ensure Mongo + backend network are up
|
# ensure Mongo + backend network are up
|
||||||
docker compose up -d mongo
|
docker compose up -d mongo
|
||||||
@@ -147,10 +218,15 @@ docker compose run --rm backend python maintenance.py dedupe
|
|||||||
```
|
```
|
||||||
Omit `--limit` to process all events. You can also run commands inside a running backend container with `docker compose exec backend ...`.
|
Omit `--limit` to process all events. You can also run commands inside a running backend container with `docker compose exec backend ...`.
|
||||||
|
|
||||||
|
## Security Documentation
|
||||||
|
- `PEN_TEST_REPORT_v1.7.11.md` — Penetration test findings and remediation
|
||||||
|
- `THREAT_MODEL_v1.7.13.md` — Comprehensive threat model covering Entra application abuse, token handling, data exfiltration vectors
|
||||||
|
|
||||||
## Notes / Troubleshooting
|
## Notes / Troubleshooting
|
||||||
- Ensure `TENANT_ID`, `CLIENT_ID`, and `CLIENT_SECRET` match an app registration with `AuditLog.Read.All` (application) permission and admin consent.
|
- Ensure `TENANT_ID`, `CLIENT_ID`, and `CLIENT_SECRET` match an app registration with `AuditLog.Read.All` (application) permission and admin consent.
|
||||||
- Additional permissions: Office 365 Management Activity (`ActivityFeed.Read`), and Intune audit (`DeviceManagementConfiguration.Read.All`).
|
- Additional permissions: Office 365 Management Activity (`ActivityFeed.Read`), and Intune audit (`DeviceManagementConfiguration.Read.All`).
|
||||||
- Auth: if `AUTH_ENABLED=true`, issued tokens must be from `AUTH_TENANT_ID`, audience = `AUTH_CLIENT_ID`; access is granted if roles or groups overlap `AUTH_ALLOWED_ROLES`/`AUTH_ALLOWED_GROUPS` (if set).
|
- Auth: if `AUTH_ENABLED=true`, issued tokens must be from `AUTH_TENANT_ID`, audience = `AUTH_CLIENT_ID`; access is granted if roles or groups overlap `AUTH_ALLOWED_ROLES`/`AUTH_ALLOWED_GROUPS` (if set). A startup warning is logged if auth is enabled but no roles/groups are configured.
|
||||||
- Backfill limits: Management Activity API typically exposes ~7 days of history via API (longer if your tenant has extended/Advanced Audit retention). Directory/Intune audit retention follows your tenant policy (commonly 30–90 days, longer with Advanced Audit).
|
- Backfill limits: Management Activity API typically exposes ~7 days of history via API (longer if your tenant has extended/Advanced Audit retention). Directory/Intune audit retention follows your tenant policy (commonly 30–90 days, longer with Advanced Audit).
|
||||||
- If you change Mongo credentials/ports, update `MONGO_URI` in `.env` (Docker Compose passes it through to the backend).
|
- If you change Mongo credentials/ports, update `MONGO_URI` in `.env` (Docker Compose passes it through to the backend).
|
||||||
- The service uses the `micro_soc` database and `events` collection by default; adjust in `backend/config.py` if needed.
|
- The service uses the `micro_soc` database and `events` collection by default; adjust in `backend/config.py` if needed.
|
||||||
|
- If using Azure Key Vault, ensure the runtime identity (managed identity, service principal, or local Azure CLI) has `Get` permission on secrets.
|
||||||
|
|||||||
78
RELEASE_NOTES_v1.2.5.md
Normal file
78
RELEASE_NOTES_v1.2.5.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# AOC v1.2.5 Release Notes
|
||||||
|
|
||||||
|
**Release date:** 2026-04-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's new
|
||||||
|
|
||||||
|
### Natural language query (`/api/ask`)
|
||||||
|
Ask questions in plain English and get AI-generated answers backed by your audit logs.
|
||||||
|
|
||||||
|
- **Regex-based parsing** extracts time ranges (`last 3 days`, `yesterday`, `today`) and entities (`device ABC123`, `user bob@example.com`) without calling an LLM.
|
||||||
|
- **AI narrative summarisation** via any OpenAI-compatible API (OpenAI, Azure OpenAI, MS Foundry, Ollama).
|
||||||
|
- **Graceful fallback** when no LLM is configured — returns a structured bullet list with a clear error banner.
|
||||||
|
- **Cited evidence** — every answer includes the raw events that back it up.
|
||||||
|
|
||||||
|
### Filter-aware queries
|
||||||
|
The ask endpoint now respects the filter panel. When you set **Service = Exchange**, **Result = failure** and ask *"What happened to device X?"*, the LLM only sees failed Exchange events for that device.
|
||||||
|
|
||||||
|
### Scales to thousands of events
|
||||||
|
For large result sets (>50 events), the LLM receives an **aggregated overview** instead of a raw dump:
|
||||||
|
- Counts by service, action, result, and actor
|
||||||
|
- Failure highlights
|
||||||
|
- The 50 most recent raw events as samples
|
||||||
|
|
||||||
|
This keeps token usage low while preserving accuracy.
|
||||||
|
|
||||||
|
### Azure OpenAI / MS Foundry support
|
||||||
|
- Automatic `api-key` header detection for Azure endpoints.
|
||||||
|
- `LLM_API_VERSION` config for Azure `api-version` query parameters.
|
||||||
|
- `max_completion_tokens` support for newer model deployments.
|
||||||
|
|
||||||
|
### Version display
|
||||||
|
- `GET /api/version` endpoint reads the `VERSION` file.
|
||||||
|
- Frontend shows a version badge in the header (e.g., **1.2.5**).
|
||||||
|
|
||||||
|
### Production hardening (from v1.1.0)
|
||||||
|
- Dockerfile runs as non-root user with Gunicorn + Uvicorn workers.
|
||||||
|
- `docker-compose.prod.yml` with internal-only MongoDB, health checks, and nginx reverse proxy.
|
||||||
|
- Security headers (`X-Frame-Options`, `X-Content-Type-Options`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add to your `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for AI narrative summarisation
|
||||||
|
LLM_API_KEY=your-key
|
||||||
|
LLM_BASE_URL=https://api.openai.com/v1
|
||||||
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
LLM_MAX_EVENTS=200
|
||||||
|
LLM_TIMEOUT_SECONDS=30
|
||||||
|
LLM_API_VERSION= # set for Azure OpenAI, e.g. 2024-12-01-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
For Azure OpenAI / MS Foundry:
|
||||||
|
```bash
|
||||||
|
LLM_BASE_URL=https://your-resource.openai.azure.com/openai/deployments/your-deployment
|
||||||
|
LLM_API_KEY=your-azure-key
|
||||||
|
LLM_API_VERSION=2024-12-01-preview
|
||||||
|
LLM_MODEL=your-deployment-name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade notes
|
||||||
|
|
||||||
|
No breaking changes. Existing `/api/events`, filters, pagination, tags, and comments work unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker image
|
||||||
|
|
||||||
|
```
|
||||||
|
git.cqre.net/cqrenet/aoc-backend:v1.2.5
|
||||||
|
```
|
||||||
43
RELEASE_NOTES_v1.7.12.md
Normal file
43
RELEASE_NOTES_v1.7.12.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# AOC v1.7.12 Release Notes
|
||||||
|
|
||||||
|
**Release Date:** 2026-04-27
|
||||||
|
|
||||||
|
## Security Hardening (Penetration Test Remediation)
|
||||||
|
|
||||||
|
This release addresses all findings from the internal soft penetration test of v1.7.11.
|
||||||
|
|
||||||
|
### Critical Fix: CORS Credentials Leak
|
||||||
|
- **Issue:** When `AUTH_ENABLED=true` and `CORS_ORIGINS="*"`, the CORS middleware reflected any origin with `Access-Control-Allow-Credentials: true`, allowing cross-origin authenticated requests from attacker-controlled domains.
|
||||||
|
- **Fix:** When auth is enabled with a wildcard origin, `allow_credentials` is now forced to `False`. CORS still works for unauthenticated requests, but bearer tokens cannot be leaked cross-origin.
|
||||||
|
|
||||||
|
### High Fix: Missing Security Headers
|
||||||
|
- Added `X-Content-Type-Options: nosniff`
|
||||||
|
- Added `X-Frame-Options: DENY`
|
||||||
|
- Added `Referrer-Policy: strict-origin-when-cross-origin`
|
||||||
|
- Added `Permissions-Policy` restricting browser features (accelerometer, camera, geolocation, gyroscope, magnetometer, microphone, payment, USB)
|
||||||
|
|
||||||
|
### Medium Fixes
|
||||||
|
- **Rate limiter fail-closed:** Previously, a Redis outage silently disabled all rate limiting. The rate limiter now returns `429` when Redis is unreachable.
|
||||||
|
- **OpenAPI docs exposure:** `/docs`, `/redoc`, and `/openapi.json` are disabled by default. Set `DOCS_ENABLED=true` to re-enable (intended for development only).
|
||||||
|
|
||||||
|
### Low Fixes
|
||||||
|
- **Information disclosure:** `/api/config/auth` no longer leaks `tenant_id` and `client_id` when `auth_enabled=false`.
|
||||||
|
- **Webhook validation token:** Added length cap (1024 chars) and ASCII-only validation before echoing `validationToken`. Response now includes `X-Content-Type-Options: nosniff`.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/main.py` | CORS fix, security headers middleware, conditional OpenAPI docs |
|
||||||
|
| `backend/config.py` | Added `DOCS_ENABLED` setting |
|
||||||
|
| `backend/rate_limiter.py` | Fail-closed on Redis errors |
|
||||||
|
| `backend/routes/config.py` | Hide tenant/client IDs when auth disabled |
|
||||||
|
| `backend/routes/webhooks.py` | Validate validationToken before echo |
|
||||||
|
| `backend/tests/conftest.py` | Enhanced FakeRedis mock with `incr`/`expire` |
|
||||||
|
| `.env.example` | Documented `DOCS_ENABLED` |
|
||||||
|
| `VERSION` | Bumped to 1.7.12 |
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **80/80 pytest tests passing**
|
||||||
|
- Penetration test report: `PEN_TEST_REPORT_v1.7.11.md`
|
||||||
34
RELEASE_NOTES_v1.7.13.md
Normal file
34
RELEASE_NOTES_v1.7.13.md
Normal 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.11–1.7.12 releases included `'unsafe-eval'` in the CSP because the standard Alpine.js CDN build uses `new Function()` internally for reactive expression evaluation. The CSP build eliminates this requirement, further hardening the application against XSS and injection attacks.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
All existing Alpine.js directives (`x-data`, `x-init`, `x-show`, `x-text`, `x-for`, `x-if`, `x-model`, event handlers) continue to work unchanged. The CSP build uses a safe expression evaluator that produces identical behavior without `eval`/`new Function`.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/frontend/index.html` | Alpine.js src → `csp.min.js`; added `Alpine.start()` |
|
||||||
|
| `backend/main.py` | Removed `'unsafe-eval'` from `script-src` CSP |
|
||||||
|
| `VERSION` | Bumped to 1.7.13 |
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **80/80 pytest tests passing**
|
||||||
|
- Ruff lint/format clean
|
||||||
64
RELEASE_NOTES_v1.7.14.md
Normal file
64
RELEASE_NOTES_v1.7.14.md
Normal 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
|
||||||
99
RELEASE_NOTES_v1.7.7.md
Normal file
99
RELEASE_NOTES_v1.7.7.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# AOC v1.7.7 Release Notes
|
||||||
|
|
||||||
|
**Release date:** 2026-04-24
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Hardening
|
||||||
|
|
||||||
|
This release is a focused security patch addressing findings from an internal audit. All users running AOC in production are encouraged to upgrade.
|
||||||
|
|
||||||
|
### Webhook authentication (`/api/webhooks/graph`)
|
||||||
|
- **ClientState validation** — Notifications now require a matching `WEBHOOK_CLIENT_SECRET`. Set this in your `.env` to the same value used when creating Graph subscriptions.
|
||||||
|
- Rejects spoofed notification payloads with `401 Unauthorized`.
|
||||||
|
|
||||||
|
### Rate limiting
|
||||||
|
- **Redis-backed fixed-window rate limiting** is now enabled by default.
|
||||||
|
- Per-category limits:
|
||||||
|
- `/api/fetch-audit-logs` — 10 requests/hour
|
||||||
|
- `/api/ask` — 30 requests/minute
|
||||||
|
- `/api/events/bulk-tags` — 20 requests/minute
|
||||||
|
- All other endpoints — 120 requests/minute
|
||||||
|
- Returns `429 Too Many Requests` with a `Retry-After` header when exceeded.
|
||||||
|
|
||||||
|
### SSRF protection for LLM calls
|
||||||
|
- `LLM_BASE_URL` is now validated before every outbound request.
|
||||||
|
- Blocks non-HTTPS URLs, localhost, link-local addresses (`169.254.169.254`), and all private IP ranges.
|
||||||
|
|
||||||
|
### CORS enforcement
|
||||||
|
- Wildcard (`*`) origins are **automatically stripped** when `AUTH_ENABLED=true`.
|
||||||
|
- A startup warning is logged if an insecure CORS configuration is detected.
|
||||||
|
|
||||||
|
### Content Security Policy
|
||||||
|
- API and HTML responses now include a `Content-Security-Policy` header.
|
||||||
|
- Restricts script sources to self, CDN origins, and MSAL auth library.
|
||||||
|
|
||||||
|
### Audit trail integrity
|
||||||
|
- The audit middleware no longer parses JWT tokens without signature verification.
|
||||||
|
- Verified claims are now propagated safely via `contextvars`, eliminating audit log poisoning.
|
||||||
|
|
||||||
|
### Standalone MCP server
|
||||||
|
- Prints a prominent security warning on startup reminding operators that the stdio transport has no authentication layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Improvements
|
||||||
|
|
||||||
|
### Bulk tag cap
|
||||||
|
- `POST /api/events/bulk-tags` now refuses to update more than **10,000 events** in a single request.
|
||||||
|
- Returns `400` with guidance to narrow filters.
|
||||||
|
|
||||||
|
### Generic error responses
|
||||||
|
- Internal exception details are no longer leaked in HTTP 500/502 responses.
|
||||||
|
- Full stack traces remain in server-side logs.
|
||||||
|
|
||||||
|
### Alert rule schema
|
||||||
|
- `conditions` field now uses a strict Pydantic model (`AlertCondition`) instead of an unconstrained `list[dict]`.
|
||||||
|
- Prevents stored data pollution from malformed rule payloads.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
- MongoDB (`27017`) and Redis (`6379`) ports are no longer forwarded to the Docker host.
|
||||||
|
- Internal services are reachable only via the Docker network.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add to your `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required if you use Graph webhooks
|
||||||
|
WEBHOOK_CLIENT_SECRET=your-random-secret
|
||||||
|
|
||||||
|
# Optional: disable rate limiting (not recommended)
|
||||||
|
RATE_LIMIT_ENABLED=true
|
||||||
|
RATE_LIMIT_REQUESTS=120
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade notes
|
||||||
|
|
||||||
|
**No breaking changes.** Existing event data, tags, comments, and saved searches are preserved.
|
||||||
|
|
||||||
|
After pulling:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AOC_VERSION=v1.7.7
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker image
|
||||||
|
|
||||||
|
```
|
||||||
|
git.cqre.net/cqrenet/aoc-backend:v1.7.7
|
||||||
|
```
|
||||||
61
ROADMAP.md
61
ROADMAP.md
@@ -59,5 +59,64 @@ Goal: evolve from a polling dashboard into a full security operations tool.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 5: Intelligence ✅
|
||||||
|
Goal: add AI-powered analysis and external tool integration.
|
||||||
|
|
||||||
|
- [x] AI feature flag (`AI_FEATURES_ENABLED`) to gate LLM-dependent features
|
||||||
|
- [x] Natural language query endpoint (`/api/ask`) with intent extraction and smart sampling
|
||||||
|
- [x] MCP (Model Context Protocol) server for Claude Desktop / Cursor integration
|
||||||
|
- [x] Valkey caching for LLM responses and frequent queries
|
||||||
|
- [x] Async queue (arq) for LLM requests to prevent timeout/cost explosions at scale
|
||||||
|
- [ ] Advanced analytics dashboard (trending operations, anomaly detection)
|
||||||
|
|
||||||
## Completed in this PR
|
## Completed in this PR
|
||||||
All Phase 1 items were implemented in the latest changes.
|
All Phase 5 items marked done were implemented in v1.3.0–v1.5.0.
|
||||||
|
Redis caching + async queue implemented in v1.6.0, switched to Valkey.
|
||||||
|
UI polish (topbar, footer, clickable pills) in v1.6.1–v1.6.4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Row-level isolation: `tenant_id` field on every MongoDB document
|
||||||
|
- Each tenant has their own Microsoft Entra tenant + app registration credentials
|
||||||
|
- Auth: user's JWT `tid` claim maps to tenant config automatically
|
||||||
|
- Super-admin role for MSP staff to access all tenants
|
||||||
|
|
||||||
|
### Implementation phases
|
||||||
|
- **Phase 7.1** (2–3 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
|
||||||
|
- Multi-tenant: premium feature requiring a signed license key
|
||||||
|
- License key is a JWT with claims: `plan`, `max_tenants`, `exp`, `features`
|
||||||
|
- Offline license generation tool included
|
||||||
|
|
||||||
|
### Effort estimate
|
||||||
|
~7–9 days total. Deferred until SIEM export and alerting are battle-tested.
|
||||||
|
|||||||
321
THREAT_MODEL_v1.7.13.md
Normal file
321
THREAT_MODEL_v1.7.13.md
Normal 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,5 +1,9 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Bake the version into the image at build time
|
||||||
|
ARG VERSION=unknown
|
||||||
|
ENV VERSION=${VERSION}
|
||||||
|
|
||||||
# Security: run as non-root
|
# Security: run as non-root
|
||||||
RUN groupadd -r aoc && useradd -r -g aoc aoc
|
RUN groupadd -r aoc && useradd -r -g aoc aoc
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import contextvars
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -8,11 +9,16 @@ from config import (
|
|||||||
AUTH_CLIENT_ID,
|
AUTH_CLIENT_ID,
|
||||||
AUTH_ENABLED,
|
AUTH_ENABLED,
|
||||||
AUTH_TENANT_ID,
|
AUTH_TENANT_ID,
|
||||||
|
PRIVACY_SERVICE_ROLES,
|
||||||
|
PRIVACY_SERVICES,
|
||||||
)
|
)
|
||||||
from fastapi import Header, HTTPException
|
from fastapi import Header, HTTPException
|
||||||
from jwt import ExpiredSignatureError, InvalidTokenError, decode
|
from jwt import ExpiredSignatureError, InvalidTokenError, decode
|
||||||
from jwt.algorithms import RSAAlgorithm
|
from jwt.algorithms import RSAAlgorithm
|
||||||
|
|
||||||
|
# Thread-/task-local storage for verified auth claims (used by audit middleware)
|
||||||
|
_auth_context: contextvars.ContextVar[dict | None] = contextvars.ContextVar("auth_context", default=None)
|
||||||
|
|
||||||
JWKS_CACHE = {"exp": 0, "keys": []}
|
JWKS_CACHE = {"exp": 0, "keys": []}
|
||||||
logger = structlog.get_logger("aoc.auth")
|
logger = structlog.get_logger("aoc.auth")
|
||||||
|
|
||||||
@@ -82,9 +88,19 @@ def _decode_token(token: str, jwks):
|
|||||||
raise HTTPException(status_code=401, detail=f"Invalid token ({type(exc).__name__})") from None
|
raise HTTPException(status_code=401, detail=f"Invalid token ({type(exc).__name__})") from None
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_access_privacy_services(claims: dict) -> bool:
|
||||||
|
"""Check if the user has roles that grant access to privacy-sensitive services."""
|
||||||
|
if not PRIVACY_SERVICES or not PRIVACY_SERVICE_ROLES:
|
||||||
|
return True
|
||||||
|
user_roles = set(claims.get("roles", []) or claims.get("role", []) or [])
|
||||||
|
return bool(user_roles.intersection(PRIVACY_SERVICE_ROLES))
|
||||||
|
|
||||||
|
|
||||||
def require_auth(authorization: str | None = Header(None)):
|
def require_auth(authorization: str | None = Header(None)):
|
||||||
if not AUTH_ENABLED:
|
if not AUTH_ENABLED:
|
||||||
return {"sub": "anonymous"}
|
user = {"sub": "anonymous"}
|
||||||
|
_auth_context.set(user)
|
||||||
|
return user
|
||||||
|
|
||||||
if not authorization or not authorization.lower().startswith("bearer "):
|
if not authorization or not authorization.lower().startswith("bearer "):
|
||||||
raise HTTPException(status_code=401, detail="Missing bearer token")
|
raise HTTPException(status_code=401, detail="Missing bearer token")
|
||||||
@@ -96,4 +112,5 @@ def require_auth(authorization: str | None = Header(None)):
|
|||||||
if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS):
|
if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS):
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
_auth_context.set(claims)
|
||||||
return claims
|
return claims
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from secrets_manager import load_key_vault_secrets
|
||||||
|
|
||||||
|
# Pre-load Azure Key Vault secrets into os.environ before pydantic-settings reads them.
|
||||||
|
# This is a no-op if AZURE_KEY_VAULT_NAME is not set.
|
||||||
|
load_key_vault_secrets()
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -42,14 +48,53 @@ class Settings(BaseSettings):
|
|||||||
# Alerting
|
# Alerting
|
||||||
ALERTS_ENABLED: bool = False
|
ALERTS_ENABLED: bool = False
|
||||||
|
|
||||||
# LLM / Natural Language Query
|
# AI / Natural Language Query
|
||||||
|
AI_FEATURES_ENABLED: bool = True
|
||||||
LLM_API_KEY: str = ""
|
LLM_API_KEY: str = ""
|
||||||
LLM_BASE_URL: str = "https://api.openai.com/v1"
|
LLM_BASE_URL: str = "https://api.openai.com/v1"
|
||||||
LLM_MODEL: str = "gpt-4o-mini"
|
LLM_MODEL: str = "gpt-4o-mini"
|
||||||
LLM_MAX_EVENTS: int = 50
|
LLM_MAX_EVENTS: int = 200
|
||||||
LLM_TIMEOUT_SECONDS: int = 30
|
LLM_TIMEOUT_SECONDS: int = 30
|
||||||
LLM_API_VERSION: str = "" # e.g. 2025-01-01-preview for Azure OpenAI
|
LLM_API_VERSION: str = "" # e.g. 2025-01-01-preview for Azure OpenAI
|
||||||
|
|
||||||
|
# Privacy / access control
|
||||||
|
# Entire services can be hidden, or specific operations can be gated.
|
||||||
|
PRIVACY_SERVICES: str = "" # comma-separated, e.g. "Exchange,Teams"
|
||||||
|
PRIVACY_SENSITIVE_OPERATIONS: str = "" # comma-separated, e.g. "MailItemsAccessed,Search-Mailbox,Send"
|
||||||
|
PRIVACY_SERVICE_ROLES: str = "" # comma-separated, e.g. "SecurityAdministrator,ComplianceAdministrator"
|
||||||
|
|
||||||
|
# Redis (caching + async job queue)
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# UI defaults
|
||||||
|
DEFAULT_PAGE_SIZE: int = 24
|
||||||
|
|
||||||
|
# Alert notifications
|
||||||
|
ALERT_WEBHOOK_URL: str = ""
|
||||||
|
ALERT_WEBHOOK_FORMAT: str = "generic" # generic | slack | teams
|
||||||
|
ALERT_DEDUPE_MINUTES: int = 15
|
||||||
|
|
||||||
|
# Webhook security
|
||||||
|
WEBHOOK_CLIENT_SECRET: str = ""
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATE_LIMIT_ENABLED: bool = True
|
||||||
|
RATE_LIMIT_REQUESTS: int = 120
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS: int = 60
|
||||||
|
|
||||||
|
# Security / docs exposure
|
||||||
|
DOCS_ENABLED: bool = False
|
||||||
|
METRICS_ALLOWED_IPS: str = "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
|
||||||
|
# LLM endpoint restriction (comma-separated domains, e.g. "api.openai.com,*.openai.azure.com")
|
||||||
|
LLM_ALLOWED_DOMAINS: str = ""
|
||||||
|
|
||||||
|
# SIEM webhook restriction (comma-separated domains)
|
||||||
|
SIEM_ALLOWED_DOMAINS: str = ""
|
||||||
|
|
||||||
|
# Optional Azure Key Vault integration for secrets
|
||||||
|
AZURE_KEY_VAULT_NAME: str = ""
|
||||||
|
|
||||||
|
|
||||||
_settings = Settings()
|
_settings = Settings()
|
||||||
|
|
||||||
@@ -77,9 +122,35 @@ SIEM_ENABLED = _settings.SIEM_ENABLED
|
|||||||
SIEM_WEBHOOK_URL = _settings.SIEM_WEBHOOK_URL
|
SIEM_WEBHOOK_URL = _settings.SIEM_WEBHOOK_URL
|
||||||
ALERTS_ENABLED = _settings.ALERTS_ENABLED
|
ALERTS_ENABLED = _settings.ALERTS_ENABLED
|
||||||
|
|
||||||
|
AI_FEATURES_ENABLED = _settings.AI_FEATURES_ENABLED
|
||||||
LLM_API_KEY = _settings.LLM_API_KEY
|
LLM_API_KEY = _settings.LLM_API_KEY
|
||||||
LLM_BASE_URL = _settings.LLM_BASE_URL
|
LLM_BASE_URL = _settings.LLM_BASE_URL
|
||||||
LLM_MODEL = _settings.LLM_MODEL
|
LLM_MODEL = _settings.LLM_MODEL
|
||||||
LLM_MAX_EVENTS = _settings.LLM_MAX_EVENTS
|
LLM_MAX_EVENTS = _settings.LLM_MAX_EVENTS
|
||||||
LLM_TIMEOUT_SECONDS = _settings.LLM_TIMEOUT_SECONDS
|
LLM_TIMEOUT_SECONDS = _settings.LLM_TIMEOUT_SECONDS
|
||||||
LLM_API_VERSION = _settings.LLM_API_VERSION
|
LLM_API_VERSION = _settings.LLM_API_VERSION
|
||||||
|
|
||||||
|
PRIVACY_SERVICES = {s.strip() for s in _settings.PRIVACY_SERVICES.split(",") if s.strip()}
|
||||||
|
PRIVACY_SENSITIVE_OPERATIONS = {o.strip() for o in _settings.PRIVACY_SENSITIVE_OPERATIONS.split(",") if o.strip()}
|
||||||
|
PRIVACY_SERVICE_ROLES = {r.strip() for r in _settings.PRIVACY_SERVICE_ROLES.split(",") if r.strip()}
|
||||||
|
|
||||||
|
REDIS_URL = _settings.REDIS_URL
|
||||||
|
DEFAULT_PAGE_SIZE = _settings.DEFAULT_PAGE_SIZE
|
||||||
|
|
||||||
|
ALERT_WEBHOOK_URL = _settings.ALERT_WEBHOOK_URL
|
||||||
|
ALERT_WEBHOOK_FORMAT = _settings.ALERT_WEBHOOK_FORMAT
|
||||||
|
ALERT_DEDUPE_MINUTES = _settings.ALERT_DEDUPE_MINUTES
|
||||||
|
|
||||||
|
WEBHOOK_CLIENT_SECRET = _settings.WEBHOOK_CLIENT_SECRET
|
||||||
|
|
||||||
|
RATE_LIMIT_ENABLED = _settings.RATE_LIMIT_ENABLED
|
||||||
|
RATE_LIMIT_REQUESTS = _settings.RATE_LIMIT_REQUESTS
|
||||||
|
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
|
||||||
|
|||||||
@@ -7,9 +7,25 @@ from pymongo import ASCENDING, DESCENDING, TEXT, MongoClient
|
|||||||
client = MongoClient(MONGO_URI or "mongodb://localhost:27017")
|
client = MongoClient(MONGO_URI or "mongodb://localhost:27017")
|
||||||
db = client[DB_NAME]
|
db = client[DB_NAME]
|
||||||
events_collection = db["events"]
|
events_collection = db["events"]
|
||||||
|
saved_searches_collection = db["saved_searches"]
|
||||||
|
alerts_collection = db["alerts"]
|
||||||
logger = structlog.get_logger("aoc.database")
|
logger = structlog.get_logger("aoc.database")
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_alert_rules():
|
||||||
|
"""Remove duplicate alert_rules by name, keeping the oldest document."""
|
||||||
|
try:
|
||||||
|
pipeline = [
|
||||||
|
{"$sort": {"_id": ASCENDING}},
|
||||||
|
{"$group": {"_id": "$name", "first_id": {"$first": "$_id"}}},
|
||||||
|
]
|
||||||
|
seen = {doc["_id"]: doc["first_id"] for doc in db["alert_rules"].aggregate(pipeline)}
|
||||||
|
for name, keep_id in seen.items():
|
||||||
|
db["alert_rules"].delete_many({"name": name, "_id": {"$ne": keep_id}})
|
||||||
|
except Exception:
|
||||||
|
pass # Collection may not exist yet
|
||||||
|
|
||||||
|
|
||||||
def setup_indexes(max_retries: int = 5, delay: float = 2.0):
|
def setup_indexes(max_retries: int = 5, delay: float = 2.0):
|
||||||
"""Ensure MongoDB indexes exist. Retries on connection errors."""
|
"""Ensure MongoDB indexes exist. Retries on connection errors."""
|
||||||
from time import sleep
|
from time import sleep
|
||||||
@@ -20,6 +36,9 @@ def setup_indexes(max_retries: int = 5, delay: float = 2.0):
|
|||||||
events_collection.create_index([("timestamp", DESCENDING)])
|
events_collection.create_index([("timestamp", DESCENDING)])
|
||||||
events_collection.create_index([("service", ASCENDING), ("timestamp", DESCENDING)])
|
events_collection.create_index([("service", ASCENDING), ("timestamp", DESCENDING)])
|
||||||
events_collection.create_index("id")
|
events_collection.create_index("id")
|
||||||
|
saved_searches_collection.create_index([("created_by", ASCENDING), ("created_at", DESCENDING)])
|
||||||
|
_dedupe_alert_rules()
|
||||||
|
db["alert_rules"].create_index("name", unique=True)
|
||||||
events_collection.create_index(
|
events_collection.create_index(
|
||||||
[("actor_display", TEXT), ("raw_text", TEXT), ("operation", TEXT)],
|
[("actor_display", TEXT), ("raw_text", TEXT), ("operation", TEXT)],
|
||||||
name="text_search_index",
|
name="text_search_index",
|
||||||
|
|||||||
@@ -3,29 +3,64 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AOC Events</title>
|
<title>Admin Operations Center</title>
|
||||||
<link rel="stylesheet" href="/style.css?v=8" />
|
<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 defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" integrity="sha384-WPtu0YHhJ3arcykfnv1JgUffWDSKRnqnDeTpJUbOc2os2moEmLkIdaeR0trPN4be" crossorigin="anonymous"></script>
|
||||||
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" integrity="sha384-DUSOaqAzlZRiZxkDi8hL7hXJDZ+X39ZOAYV9ZDx44gUv9pozmcunJH02tjSFLPnW" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page" x-data="aocApp()" x-init="initApp()">
|
<div class="page" x-data="aocApp()" x-init="initApp()">
|
||||||
|
<nav class="topbar">
|
||||||
|
<div class="topbar__brand">
|
||||||
|
<span class="topbar__logo">🔍</span>
|
||||||
|
<span class="topbar__name">AOC</span>
|
||||||
|
<span class="version-badge" x-text="appVersion"></span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar__links">
|
||||||
|
<a :href="repoUrl" target="_blank" rel="noopener">Repository</a>
|
||||||
|
<a :href="docsUrl" target="_blank" rel="noopener">Docs</a>
|
||||||
|
</div>
|
||||||
|
<div class="topbar__meta">
|
||||||
|
<template x-if="account">
|
||||||
|
<div class="user-chip">
|
||||||
|
<div class="user-avatar" x-text="(account.name || account.username || '?').charAt(0).toUpperCase()"></div>
|
||||||
|
<div class="user-details">
|
||||||
|
<span class="user-name" x-text="account.name || account.username || ''"></span>
|
||||||
|
<span class="user-email" x-text="account.username || ''"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!account && authConfig?.auth_enabled">
|
||||||
|
<span class="login-hint">Not signed in</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="topbar__actions">
|
||||||
|
<button id="fetchBtn" class="ghost btn--compact" aria-label="Fetch latest audit logs" @click="fetchLogs()">Fetch</button>
|
||||||
|
<button id="refreshBtn" class="ghost btn--compact" aria-label="Refresh events" @click="loadEvents(currentCursor)">Refresh</button>
|
||||||
|
<button id="authBtn" class="ghost btn--compact" aria-label="Login" x-text="authBtnText" @click="toggleAuth()"></button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Admin Operations Center</p>
|
<p class="eyebrow">Admin Operations Center</p>
|
||||||
<h1>Directory Audit Explorer</h1>
|
<h1>Audit Log Explorer</h1>
|
||||||
<p class="lede">Filter Microsoft Entra audit events by user, app, time, action, and action type.</p>
|
<p class="lede">Search and review Microsoft audit events from Entra, Intune, Exchange, SharePoint, and Teams.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cta">
|
<div class="alert-summary" x-show="alertSummary.total_open > 0">
|
||||||
<button id="authBtn" class="ghost" aria-label="Login" x-text="authBtnText" @click="toggleAuth()"></button>
|
<div class="alert-badge alert-badge--high" x-show="alertSummary.high > 0" x-text="alertSummary.high"></div>
|
||||||
<button id="fetchBtn" aria-label="Fetch latest audit logs" @click="fetchLogs()">Fetch new</button>
|
<div class="alert-badge alert-badge--medium" x-show="alertSummary.medium > 0" x-text="alertSummary.medium"></div>
|
||||||
<button id="refreshBtn" aria-label="Refresh events" @click="loadEvents(currentCursor)">Refresh</button>
|
<div class="alert-badge alert-badge--low" x-show="alertSummary.low > 0" x-text="alertSummary.low"></div>
|
||||||
|
<span class="alert-label">open alerts</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h3>Source Health</h3>
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('sourceHealth')">
|
||||||
<div class="source-health">
|
<h3>Source Health</h3>
|
||||||
|
<span class="panel-toggle" :class="panelState.sourceHealth ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
|
<div x-show="panelState.sourceHealth">
|
||||||
<template x-for="src in sourceHealth" :key="src.source">
|
<template x-for="src in sourceHealth" :key="src.source">
|
||||||
<div class="health-card">
|
<div class="health-card">
|
||||||
<strong x-text="src.source"></strong>
|
<strong x-text="src.source"></strong>
|
||||||
@@ -39,50 +74,160 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h3>Ask a question</h3>
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('alerts')">
|
||||||
<form class="ask-form" @submit.prevent="askQuestion()">
|
<h3>Alerts</h3>
|
||||||
<div class="ask-row">
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
<input
|
<span x-text="`${alertSummary.total_open} open`" class="alert-open-count"></span>
|
||||||
type="text"
|
<span class="panel-toggle" :class="panelState.alerts ? 'panel-toggle--open' : ''">▸</span>
|
||||||
placeholder="What happened to device ABC123 in the last 3 days?"
|
|
||||||
x-model="askQuestionText"
|
|
||||||
class="ask-input"
|
|
||||||
/>
|
|
||||||
<button type="submit" :disabled="askLoading" x-text="askLoading ? 'Thinking…' : 'Ask'">Ask</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div x-show="hasActiveFilters()" class="ask-filter-hint">
|
</div>
|
||||||
<small>Respecting active filters: <span x-text="activeFilterSummary()"></span></small>
|
<div x-show="panelState.alerts">
|
||||||
</div>
|
<div class="alert-filters">
|
||||||
</form>
|
<select x-model="alertsFilter.status" @change="alertsPage = 1; loadAlerts()">
|
||||||
<template x-if="askAnswer">
|
<option value="">All statuses</option>
|
||||||
<div class="ask-result">
|
<option value="open">Open</option>
|
||||||
<div x-show="askLlmError" class="ask-error" x-text="askLlmError"></div>
|
<option value="acknowledged">Acknowledged</option>
|
||||||
<div class="ask-answer" x-html="askAnswerHtml"></div>
|
<option value="resolved">Resolved</option>
|
||||||
<template x-if="askEvents.length">
|
<option value="false_positive">False Positive</option>
|
||||||
<div class="ask-events">
|
</select>
|
||||||
<h4>Referenced events</h4>
|
<select x-model="alertsFilter.severity" @change="alertsPage = 1; loadAlerts()">
|
||||||
<template x-for="(evt, idx) in askEvents" :key="evt.id || idx">
|
<option value="">All severities</option>
|
||||||
<article class="event event--compact">
|
<option value="high">High</option>
|
||||||
<div class="event__meta">
|
<option value="medium">Medium</option>
|
||||||
<span class="pill" x-text="evt.display_category || evt.service || '—'"></span>
|
<option value="low">Low</option>
|
||||||
<span class="pill" :class="['success','succeeded','ok','passed'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'"></span>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<h3 x-text="evt.operation || '—'"></h3>
|
<div class="alerts-list" x-show="alerts.length > 0">
|
||||||
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
|
<template x-for="alert in alerts" :key="alert._id || alert.event_id">
|
||||||
<p class="event__detail"><strong>Actor:</strong> <span x-text="evt.actor_display || '—'"></span></p>
|
<div class="alert-card" :class="'alert-card--' + alert.severity">
|
||||||
<p class="event__detail"><strong>Target:</strong> <span x-text="Array.isArray(evt.target_displays) ? evt.target_displays.join(', ') : '—'"></span></p>
|
<div class="alert-card__meta">
|
||||||
<p class="event__detail"><strong>When:</strong> <span x-text="evt.timestamp ? new Date(evt.timestamp).toLocaleString() : '—'"></span></p>
|
<span class="pill" :class="alert.severity === 'high' ? 'pill--err' : (alert.severity === 'medium' ? 'pill--warn' : '')" x-text="alert.severity"></span>
|
||||||
</article>
|
<span class="pill" x-text="alert.status"></span>
|
||||||
</template>
|
<small x-text="new Date(alert.timestamp).toLocaleString()"></small>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<strong x-text="alert.rule_name"></strong>
|
||||||
<button type="button" class="ghost" @click="clearAsk()">Clear</button>
|
<p x-text="alert.message"></p>
|
||||||
</div>
|
<div class="alert-card__actions">
|
||||||
</template>
|
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'acknowledged')" x-show="alert.status === 'open'">Acknowledge</button>
|
||||||
|
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'resolved')" x-show="alert.status !== 'resolved' && alert.status !== 'false_positive'">Resolve</button>
|
||||||
|
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'false_positive')" x-show="alert.status !== 'false_positive'">False Positive</button>
|
||||||
|
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'open')" x-show="alert.status !== 'open'">Reopen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="alerts-empty" x-show="alerts.length === 0">
|
||||||
|
<p>No alerts match the current filters. Alerts appear here when rules trigger during event ingestion.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pagination" x-show="alertsTotal > 20">
|
||||||
|
<button type="button" :disabled="alertsPage === 1" @click="alertsPage--; loadAlerts()">Prev</button>
|
||||||
|
<span x-text="`Page ${alertsPage}`"></span>
|
||||||
|
<button type="button" :disabled="alertsPage * 20 >= alertsTotal" @click="alertsPage++; loadAlerts()">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('rules')">
|
||||||
|
<h3>Alert Rules</h3>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<button type="button" class="btn--compact" @click.stop="openRuleEditor()">+ Add rule</button>
|
||||||
|
<span class="panel-toggle" :class="panelState.rules ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="panelState.rules">
|
||||||
|
<div class="rules-list">
|
||||||
|
<template x-for="rule in rules" :key="rule.id">
|
||||||
|
<div class="rule-card" :class="rule.enabled ? '' : 'rule-card--disabled'">
|
||||||
|
<div class="rule-card__meta">
|
||||||
|
<span class="pill" :class="rule.severity === 'high' ? 'pill--err' : (rule.severity === 'medium' ? 'pill--warn' : '')" x-text="rule.severity"></span>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" :checked="rule.enabled" @change="toggleRule(rule.id, $event.target.checked)">
|
||||||
|
<span x-text="rule.enabled ? 'On' : 'Off'"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<strong x-text="rule.name"></strong>
|
||||||
|
<p x-text="rule.message"></p>
|
||||||
|
<div class="rule-card__conditions">
|
||||||
|
<template x-for="(cond, idx) in rule.conditions" :key="idx">
|
||||||
|
<span class="pill pill--tag" x-text="`${cond.field} ${cond.op} ${cond.value}`"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="rule-card__actions">
|
||||||
|
<button type="button" class="ghost btn--compact" @click="openRuleEditor(rule)">Edit</button>
|
||||||
|
<button type="button" class="ghost btn--compact" @click="deleteRule(rule.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="rules-empty" x-show="rules.length === 0">
|
||||||
|
<p>No custom rules yet. Pre-built admin-ops rules are active by default. Add your own rules to detect specific patterns.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ruleModal" class="modal hidden" role="dialog" aria-modal="true" :class="{ 'hidden': !ruleModalOpen }">
|
||||||
|
<div class="modal__content" style="max-width: 600px;">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h3 x-text="ruleEditId ? 'Edit Rule' : 'New Rule'"></h3>
|
||||||
|
<button type="button" class="ghost" @click="ruleModalOpen = false">Close</button>
|
||||||
|
</div>
|
||||||
|
<form class="rule-form" @submit.prevent="saveRule()">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" x-model="ruleEdit.name" placeholder="e.g. Failed CA Policy" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Severity
|
||||||
|
<select x-model="ruleEdit.severity">
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Message
|
||||||
|
<textarea x-model="ruleEdit.message" placeholder="What should the alert say?" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="rule-conditions">
|
||||||
|
<span>Conditions (all must match)</span>
|
||||||
|
<template x-for="(cond, idx) in ruleEdit.conditions" :key="idx">
|
||||||
|
<div class="condition-row">
|
||||||
|
<input type="text" x-model="cond.field" placeholder="field" list="ruleFieldOptions" required />
|
||||||
|
<select x-model="cond.op">
|
||||||
|
<option value="eq">equals</option>
|
||||||
|
<option value="neq">not equals</option>
|
||||||
|
<option value="contains">contains</option>
|
||||||
|
<option value="in">in list</option>
|
||||||
|
<option value="after_hours">after hours</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" x-model="cond.value" placeholder="value" :required="cond.op !== 'after_hours'" />
|
||||||
|
<button type="button" class="ghost btn--compact" @click="ruleEdit.conditions.splice(idx, 1)">−</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button type="button" class="ghost btn--compact" @click="ruleEdit.conditions.push({field:'', op:'eq', value:''})">+ Add condition</button>
|
||||||
|
</div>
|
||||||
|
<datalist id="ruleFieldOptions">
|
||||||
|
<option value="service"></option>
|
||||||
|
<option value="operation"></option>
|
||||||
|
<option value="result"></option>
|
||||||
|
<option value="actor_display"></option>
|
||||||
|
<option value="timestamp"></option>
|
||||||
|
</datalist>
|
||||||
|
<div class="rule-form__actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" class="ghost" @click="ruleModalOpen = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('filters')">
|
||||||
|
<h3>Filters</h3>
|
||||||
|
<span class="panel-toggle" :class="panelState.filters ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
|
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()" x-show="panelState.filters">
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<label>
|
<label>
|
||||||
User (name/UPN)
|
User (name/UPN)
|
||||||
@@ -155,26 +300,88 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Apply filters</button>
|
<button type="submit">Apply filters</button>
|
||||||
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||||
|
<button type="button" class="ghost" @click="saveCurrentFilters()">Save filters</button>
|
||||||
<button type="button" class="ghost" @click="bulkTagMatching()">Bulk tag matching</button>
|
<button type="button" class="ghost" @click="bulkTagMatching()">Bulk tag matching</button>
|
||||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-row" x-show="savedSearches.length">
|
||||||
|
<div class="saved-searches">
|
||||||
|
<span>Saved:</span>
|
||||||
|
<template x-for="ss in savedSearches" :key="ss.id">
|
||||||
|
<span class="pill pill--tag" style="cursor:pointer;" @click="applySavedSearch(ss)">
|
||||||
|
<span x-text="ss.name"></span>
|
||||||
|
<button type="button" class="link" style="margin-left:4px;" @click.stop="deleteSavedSearch(ss.id)">×</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" x-show="aiFeaturesEnabled">
|
||||||
<div class="panel-header">
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('ask')">
|
||||||
<h2>Events</h2>
|
<h3>Ask a question</h3>
|
||||||
<span id="count" x-text="countText"></span>
|
<span class="panel-toggle" :class="panelState.ask ? 'panel-toggle--open' : ''">▸</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status" aria-live="polite" x-text="statusText"></div>
|
<form class="ask-form" @submit.prevent="askQuestion()" x-show="panelState.ask">
|
||||||
|
<div class="ask-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="What happened to device ABC123 in the last 3 days?"
|
||||||
|
x-model="askQuestionText"
|
||||||
|
class="ask-input"
|
||||||
|
/>
|
||||||
|
<button type="submit" :disabled="askLoading" x-text="askLoading ? 'Thinking…' : 'Ask'">Ask</button>
|
||||||
|
</div>
|
||||||
|
<div x-show="hasActiveFilters()" class="ask-filter-hint">
|
||||||
|
<small>Respecting active filters: <span x-text="activeFilterSummary()"></span></small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<template x-if="askAnswer">
|
||||||
|
<div class="ask-result">
|
||||||
|
<div x-show="askLlmError" class="ask-error" x-text="askLlmError"></div>
|
||||||
|
<div class="ask-answer" x-html="askAnswerHtml"></div>
|
||||||
|
<template x-if="askEvents.length">
|
||||||
|
<div class="ask-events">
|
||||||
|
<h4>Referenced events</h4>
|
||||||
|
<template x-for="(evt, idx) in askEvents" :key="evt.id || idx">
|
||||||
|
<article class="event event--compact">
|
||||||
|
<div class="event__meta">
|
||||||
|
<span class="pill pill--clickable" x-text="evt.display_category || evt.service || '—'" @click="filterByService(evt.service || evt.display_category)" title="Filter by this service"></span>
|
||||||
|
<span class="pill pill--clickable" :class="['success','succeeded','ok','passed','true'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'" @click="filterByResult(evt.result)" title="Filter by this result"></span>
|
||||||
|
</div>
|
||||||
|
<h3 x-text="evt.operation || '—'"></h3>
|
||||||
|
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
|
||||||
|
<p class="event__detail"><strong>Actor:</strong> <span x-text="evt.actor_display || '—'"></span></p>
|
||||||
|
<p class="event__detail"><strong>Target:</strong> <span x-text="Array.isArray(evt.target_displays) ? evt.target_displays.join(', ') : '—'"></span></p>
|
||||||
|
<p class="event__detail"><strong>When:</strong> <span x-text="evt.timestamp ? new Date(evt.timestamp).toLocaleString() : '—'"></span></p>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button type="button" class="ghost" @click="clearAsk()">Clear</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('events')">
|
||||||
|
<h2>Events</h2>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<span id="count" x-text="countText"></span>
|
||||||
|
<span class="panel-toggle" :class="panelState.events ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="panelState.events">
|
||||||
|
<div id="status" class="status" aria-live="polite" x-text="statusText"></div>
|
||||||
<div id="events" class="events">
|
<div id="events" class="events">
|
||||||
<template x-for="(evt, idx) in events" :key="evt._id || evt.id || idx">
|
<template x-for="(evt, idx) in events" :key="evt._id || evt.id || idx">
|
||||||
<article class="event">
|
<article class="event">
|
||||||
<div class="event__meta">
|
<div class="event__meta">
|
||||||
<span class="pill" x-text="evt.display_category || evt.service || '—'"></span>
|
<span class="pill pill--clickable" x-text="evt.display_category || evt.service || '—'" @click="filterByService(evt.service || evt.display_category)" title="Filter by this service"></span>
|
||||||
<span class="pill" :class="['success','succeeded','ok','passed'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'"></span>
|
<span class="pill pill--clickable" :class="['success','succeeded','ok','passed','true'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'" @click="filterByResult(evt.result)" title="Filter by this result"></span>
|
||||||
</div>
|
</div>
|
||||||
<h3 x-text="evt.operation || '—'"></h3>
|
<h3 x-text="evt.operation || '—'"></h3>
|
||||||
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
|
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
|
||||||
@@ -208,17 +415,41 @@
|
|||||||
<span x-text="`Page ${cursorStack.length + 1}`"></span>
|
<span x-text="`Page ${cursorStack.length + 1}`"></span>
|
||||||
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
|
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
||||||
<div class="modal__content">
|
<div class="modal__content">
|
||||||
<div class="modal__header">
|
<div class="modal__header">
|
||||||
<h3 id="modalTitle">Raw Event</h3>
|
<h3 id="modalTitle">Raw Event</h3>
|
||||||
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
|
<div class="modal__actions">
|
||||||
|
<button type="button" class="ghost" @click="copyRawEvent()">Copy</button>
|
||||||
|
<button type="button" class="ghost" x-show="aiFeaturesEnabled" :disabled="modalExplainLoading" @click="explainEvent()" x-text="modalExplainLoading ? 'Explaining…' : 'Explain'">Explain</button>
|
||||||
|
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="modalExplanation || modalExplainError" class="modal__explanation">
|
||||||
|
<div x-show="modalExplainError" class="ask-error" x-text="modalExplainError"></div>
|
||||||
|
<div x-show="modalExplanation" class="ask-answer" x-html="_mdToHtml(modalExplanation)"></div>
|
||||||
</div>
|
</div>
|
||||||
<pre id="modalBody" x-text="modalBody"></pre>
|
<pre id="modalBody" x-text="modalBody"></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer__left">
|
||||||
|
<span class="footer__brand">Admin Operations Center</span>
|
||||||
|
<span class="footer__version" x-text="'v' + appVersion"></span>
|
||||||
|
</div>
|
||||||
|
<div class="footer__center">
|
||||||
|
<a :href="repoUrl + '/issues/new'" target="_blank" rel="noopener">🐛 Report an issue</a>
|
||||||
|
<a :href="repoUrl" target="_blank" rel="noopener">💻 Source code</a>
|
||||||
|
<a :href="docsUrl" target="_blank" rel="noopener">📖 Documentation</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer__right">
|
||||||
|
<span>Built with ❤️ by CQRE.NET</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -233,6 +464,10 @@
|
|||||||
currentCursor: null,
|
currentCursor: null,
|
||||||
modalOpen: false,
|
modalOpen: false,
|
||||||
modalBody: '',
|
modalBody: '',
|
||||||
|
modalEventId: '',
|
||||||
|
modalExplanation: '',
|
||||||
|
modalExplainLoading: false,
|
||||||
|
modalExplainError: '',
|
||||||
authBtnText: 'Login',
|
authBtnText: 'Login',
|
||||||
authConfig: null,
|
authConfig: null,
|
||||||
msalInstance: null,
|
msalInstance: null,
|
||||||
@@ -240,9 +475,24 @@
|
|||||||
accessToken: null,
|
accessToken: null,
|
||||||
authScopes: [],
|
authScopes: [],
|
||||||
filters: {
|
filters: {
|
||||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '',
|
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: [] },
|
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: '',
|
askQuestionText: '',
|
||||||
askLoading: false,
|
askLoading: false,
|
||||||
askAnswer: '',
|
askAnswer: '',
|
||||||
@@ -252,14 +502,70 @@
|
|||||||
askLlmError: '',
|
askLlmError: '',
|
||||||
|
|
||||||
async initApp() {
|
async initApp() {
|
||||||
|
await this.loadVersion();
|
||||||
await this.initAuth();
|
await this.initAuth();
|
||||||
|
this.loadSavedFilters();
|
||||||
|
this.loadPanelState();
|
||||||
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
||||||
await this.loadFilterOptions();
|
await this.loadFilterOptions();
|
||||||
|
await this.loadSavedSearches();
|
||||||
await this.loadSourceHealth();
|
await this.loadSourceHealth();
|
||||||
|
await this.loadAlertSummary();
|
||||||
|
await this.loadAlerts();
|
||||||
|
await this.loadRules();
|
||||||
await this.loadEvents();
|
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() {
|
authHeader() {
|
||||||
return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
|
return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
|
||||||
},
|
},
|
||||||
@@ -285,13 +591,46 @@
|
|||||||
async initAuth() {
|
async initAuth() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/config/auth');
|
const res = await fetch('/api/config/auth');
|
||||||
this.authConfig = await res.json();
|
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 {
|
} catch {
|
||||||
this.authConfig = { auth_enabled: false };
|
this.aiFeaturesEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.authConfig?.auth_enabled) {
|
if (!this.authConfig?.auth_enabled) {
|
||||||
this.authBtnText = '';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,8 +639,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = this.authConfig.tenant_id;
|
|
||||||
const clientId = this.authConfig.client_id;
|
|
||||||
const baseScope = this.authConfig.scope || "";
|
const baseScope = this.authConfig.scope || "";
|
||||||
this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
|
this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
|
||||||
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
||||||
@@ -412,6 +749,7 @@
|
|||||||
this.nextCursor = body.next_cursor || null;
|
this.nextCursor = body.next_cursor || null;
|
||||||
this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
|
this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
|
||||||
this.statusText = this.events.length ? '' : 'No events found for these filters.';
|
this.statusText = this.events.length ? '' : 'No events found for these filters.';
|
||||||
|
this.saveFilters();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.statusText = err.message || 'Failed to load events.';
|
this.statusText = err.message || 'Failed to load events.';
|
||||||
}
|
}
|
||||||
@@ -447,8 +785,18 @@
|
|||||||
this.options.services = (opts.services || []).slice(0, 200);
|
this.options.services = (opts.services || []).slice(0, 200);
|
||||||
this.options.operations = (opts.operations || []).slice(0, 200);
|
this.options.operations = (opts.operations || []).slice(0, 200);
|
||||||
this.options.results = (opts.results || []).slice(0, 200);
|
this.options.results = (opts.results || []).slice(0, 200);
|
||||||
if (!this.filters.selectedServices.length && this.options.services.length) {
|
|
||||||
|
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];
|
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 {}
|
} catch {}
|
||||||
},
|
},
|
||||||
@@ -461,6 +809,59 @@
|
|||||||
} catch {}
|
} 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() {
|
resetPagination() {
|
||||||
this.cursorStack = [];
|
this.cursorStack = [];
|
||||||
this.nextCursor = null;
|
this.nextCursor = null;
|
||||||
@@ -482,11 +883,137 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearFilters() {
|
clearFilters() {
|
||||||
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '' };
|
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '' };
|
||||||
|
this.saveFilters();
|
||||||
this.resetPagination();
|
this.resetPagination();
|
||||||
this.loadEvents();
|
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() {
|
async askQuestion() {
|
||||||
const q = this.askQuestionText.trim();
|
const q = this.askQuestionText.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
@@ -647,9 +1174,44 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
|
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;
|
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) {
|
async addTag(e, tag) {
|
||||||
if (!tag.trim()) return;
|
if (!tag.trim()) return;
|
||||||
const tags = [...(e.tags || []), tag.trim()];
|
const tags = [...(e.tags || []), tag.trim()];
|
||||||
@@ -712,5 +1274,6 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,7 +28,115 @@ body {
|
|||||||
.page {
|
.page {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 20px 60px;
|
padding: 0 20px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__logo {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__links a {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__links a:hover {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
color: #0b1220;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--compact {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
@@ -37,6 +145,7 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
@@ -165,6 +274,31 @@ input {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header--collapsible {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header--collapsible:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-toggle--open {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
#count {
|
#count {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -246,6 +380,27 @@ input {
|
|||||||
border-color: rgba(239, 68, 68, 0.5);
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pill--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill--clickable:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(125, 211, 252, 0.2);
|
||||||
|
background: rgba(125, 211, 252, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill--clickable.pill--ok:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.2);
|
||||||
|
background: rgba(34, 197, 94, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill--clickable.pill--warn:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.2);
|
||||||
|
background: rgba(249, 115, 22, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.event h3 {
|
.event h3 {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
@@ -364,6 +519,30 @@ input {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-searches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__explanation {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.modal pre {
|
.modal pre {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -433,6 +612,20 @@ input {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(125, 211, 252, 0.15);
|
||||||
|
border: 1px solid rgba(125, 211, 252, 0.3);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.ask-events {
|
.ask-events {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
@@ -470,7 +663,321 @@ input {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__brand {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__version {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(125, 211, 252, 0.1);
|
||||||
|
border: 1px solid rgba(125, 211, 252, 0.2);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__center {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__center a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__center a:hover {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__right {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert summary in hero */
|
||||||
|
.alert-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-badge {
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-badge--high {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-badge--medium {
|
||||||
|
background: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-badge--low {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-open-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-filters select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card--high {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card--medium {
|
||||||
|
border-left-color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card--low {
|
||||||
|
border-left-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card__meta small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card strong {
|
||||||
|
font-size: 14px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules management */
|
||||||
|
.rules-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card--disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card strong {
|
||||||
|
font-size: 14px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card__conditions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-form input,
|
||||||
|
.rule-form select,
|
||||||
|
.rule-form textarea {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-conditions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-row input,
|
||||||
|
.condition-row select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-form__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__links {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -484,4 +991,10 @@ input {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
backend/jobs.py
Normal file
117
backend/jobs.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""arq job functions for async LLM processing."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from arq.connections import RedisSettings
|
||||||
|
from config import REDIS_URL
|
||||||
|
|
||||||
|
logger = structlog.get_logger("aoc.jobs")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CACHE_TTL_ASK = 3600 # 1 hour
|
||||||
|
CACHE_TTL_EXPLAIN = 86400 # 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_cache_key(question: str, filters: dict, events: list) -> str:
|
||||||
|
payload = json.dumps({"q": question, "f": filters, "e": [e.get("id") for e in events]}, sort_keys=True)
|
||||||
|
return f"aoc:cache:ask:{hashlib.md5(payload.encode()).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _explain_cache_key(event_id: str) -> str:
|
||||||
|
return f"aoc:cache:explain:{event_id}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cached_ask(redis, question: str, filters: dict, events: list) -> dict | None:
|
||||||
|
key = _ask_cache_key(question, filters, events)
|
||||||
|
raw = await redis.get(key)
|
||||||
|
if raw:
|
||||||
|
return json.loads(raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_cached_ask(redis, question: str, filters: dict, events: list, result: dict):
|
||||||
|
key = _ask_cache_key(question, filters, events)
|
||||||
|
await redis.setex(key, CACHE_TTL_ASK, json.dumps(result, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cached_explain(redis, event_id: str) -> dict | None:
|
||||||
|
key = _explain_cache_key(event_id)
|
||||||
|
raw = await redis.get(key)
|
||||||
|
if raw:
|
||||||
|
return json.loads(raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_cached_explain(redis, event_id: str, result: dict):
|
||||||
|
key = _explain_cache_key(event_id)
|
||||||
|
await redis.setex(key, CACHE_TTL_EXPLAIN, json.dumps(result, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# arq job functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def process_ask_question(
|
||||||
|
ctx, question: str, filters: dict, events: list, total: int, excluded_services: list | None
|
||||||
|
):
|
||||||
|
"""Background job: call LLM for /api/ask and cache result."""
|
||||||
|
from routes.ask import _call_llm
|
||||||
|
|
||||||
|
redis = ctx["redis"]
|
||||||
|
try:
|
||||||
|
answer = await _call_llm(question, events, total=total, excluded_services=excluded_services)
|
||||||
|
result = {"status": "completed", "answer": answer, "llm_used": True, "llm_error": None}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Async ask LLM failed", error=str(exc))
|
||||||
|
result = {"status": "failed", "answer": "", "llm_used": False, "llm_error": str(exc)}
|
||||||
|
|
||||||
|
await set_cached_ask(redis, question, filters, events, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def process_explain_event(ctx, event_id: str, event: dict, related: list):
|
||||||
|
"""Background job: call LLM for /api/events/{id}/explain and cache result."""
|
||||||
|
from routes.ask import _explain_event
|
||||||
|
|
||||||
|
redis = ctx["redis"]
|
||||||
|
try:
|
||||||
|
explanation = await _explain_event(event, related)
|
||||||
|
result = {"status": "completed", "explanation": explanation, "llm_used": True, "llm_error": None}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Async explain LLM failed", error=str(exc))
|
||||||
|
result = {"status": "failed", "explanation": "", "llm_used": False, "llm_error": str(exc)}
|
||||||
|
|
||||||
|
await set_cached_explain(redis, event_id, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# arq worker configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def startup(ctx):
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
ctx["redis"] = Redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown(ctx):
|
||||||
|
await ctx["redis"].close()
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerSettings:
|
||||||
|
functions = [process_ask_question, process_explain_event]
|
||||||
|
redis_settings = RedisSettings.from_dsn(REDIS_URL)
|
||||||
|
on_startup = startup
|
||||||
|
on_shutdown = shutdown
|
||||||
|
max_jobs = 10
|
||||||
|
job_timeout = 120
|
||||||
|
keep_result = 3600
|
||||||
|
keep_result_forever = False
|
||||||
174
backend/main.py
174
backend/main.py
@@ -1,12 +1,24 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from audit_trail import log_action
|
from audit_trail import log_action
|
||||||
from config import CORS_ORIGINS, ENABLE_PERIODIC_FETCH, FETCH_INTERVAL_MINUTES
|
from config import (
|
||||||
|
AI_FEATURES_ENABLED,
|
||||||
|
AUTH_ALLOWED_GROUPS,
|
||||||
|
AUTH_ALLOWED_ROLES,
|
||||||
|
AUTH_ENABLED,
|
||||||
|
CORS_ORIGINS,
|
||||||
|
DOCS_ENABLED,
|
||||||
|
ENABLE_PERIODIC_FETCH,
|
||||||
|
FETCH_INTERVAL_MINUTES,
|
||||||
|
METRICS_ALLOWED_IPS,
|
||||||
|
)
|
||||||
from database import setup_indexes
|
from database import setup_indexes
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -14,13 +26,15 @@ from fastapi.responses import Response
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from metrics import observe_request, prometheus_metrics
|
from metrics import observe_request, prometheus_metrics
|
||||||
from middleware import CorrelationIdMiddleware
|
from middleware import CorrelationIdMiddleware
|
||||||
from routes.ask import router as ask_router
|
from routes.alerts import router as alerts_router
|
||||||
from routes.config import router as config_router
|
from routes.config import router as config_router
|
||||||
from routes.events import router as events_router
|
from routes.events import router as events_router
|
||||||
from routes.fetch import router as fetch_router
|
from routes.fetch import router as fetch_router
|
||||||
from routes.fetch import run_fetch
|
from routes.fetch import run_fetch
|
||||||
from routes.health import router as health_router
|
from routes.health import router as health_router
|
||||||
|
from routes.jobs import router as jobs_router
|
||||||
from routes.rules import router as rules_router
|
from routes.rules import router as rules_router
|
||||||
|
from routes.saved_searches import router as saved_searches_router
|
||||||
from routes.webhooks import router as webhooks_router
|
from routes.webhooks import router as webhooks_router
|
||||||
|
|
||||||
|
|
||||||
@@ -48,13 +62,28 @@ def configure_logging():
|
|||||||
configure_logging()
|
configure_logging()
|
||||||
logger = structlog.get_logger("aoc.fetcher")
|
logger = structlog.get_logger("aoc.fetcher")
|
||||||
|
|
||||||
app = FastAPI()
|
# Disable OpenAPI docs in production by default
|
||||||
|
app = FastAPI(
|
||||||
|
docs_url="/docs" if DOCS_ENABLED else None,
|
||||||
|
redoc_url="/redoc" if DOCS_ENABLED else None,
|
||||||
|
openapi_url="/openapi.json" if DOCS_ENABLED else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS: when auth is enabled, never allow credentials with wildcard origins
|
||||||
|
_effective_cors = CORS_ORIGINS
|
||||||
|
_cors_credentials = True
|
||||||
|
if AUTH_ENABLED and "*" in _effective_cors:
|
||||||
|
logger.warning(
|
||||||
|
"CORS wildcard (*) is insecure with AUTH_ENABLED=true and allow_credentials. "
|
||||||
|
"Disabling credentials. Set CORS_ORIGINS to your actual origin(s)."
|
||||||
|
)
|
||||||
|
_cors_credentials = False
|
||||||
|
|
||||||
app.add_middleware(CorrelationIdMiddleware)
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=CORS_ORIGINS,
|
allow_origins=_effective_cors,
|
||||||
allow_credentials=True,
|
allow_credentials=_cors_credentials,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -71,34 +100,58 @@ async def prometheus_middleware(request: Request, call_next):
|
|||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def cache_control_middleware(request: Request, call_next):
|
async def security_headers_middleware(request: Request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
# Prevent caching of HTML and API responses by default
|
# Prevent caching of HTML and API responses by default
|
||||||
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
|
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
|
||||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
response.headers["Pragma"] = "no-cache"
|
response.headers["Pragma"] = "no-cache"
|
||||||
response.headers["Expires"] = "0"
|
response.headers["Expires"] = "0"
|
||||||
|
# Basic CSP for the UI and API (allows MSAL auth flows)
|
||||||
|
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; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"connect-src 'self' https://login.microsoftonline.com; "
|
||||||
|
"frame-src 'self' https://login.microsoftonline.com; "
|
||||||
|
"form-action 'self' https://login.microsoftonline.com; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"font-src 'self' data:;"
|
||||||
|
)
|
||||||
|
# Additional security headers
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
response.headers["Permissions-Policy"] = (
|
||||||
|
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def rate_limit_middleware(request: Request, call_next):
|
||||||
|
"""Apply Redis-backed rate limiting before processing the request."""
|
||||||
|
# Exempt config and health endpoints from rate limiting
|
||||||
|
exempt_paths = {"/api/config/auth", "/api/config/features", "/health", "/metrics"}
|
||||||
|
if request.url.path.startswith("/api/") and request.url.path not in exempt_paths:
|
||||||
|
from rate_limiter import check_rate_limit
|
||||||
|
|
||||||
|
await check_rate_limit(request)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def audit_middleware(request: Request, call_next):
|
async def audit_middleware(request: Request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if request.url.path.startswith("/api/") and request.method in ("POST", "PATCH", "PUT", "DELETE"):
|
if request.url.path.startswith("/api/") and request.method in ("POST", "PATCH", "PUT", "DELETE"):
|
||||||
from auth import AUTH_ENABLED
|
|
||||||
|
|
||||||
user = "anonymous"
|
user = "anonymous"
|
||||||
if AUTH_ENABLED:
|
if AUTH_ENABLED:
|
||||||
auth_header = request.headers.get("authorization", "")
|
from auth import _auth_context
|
||||||
if auth_header.lower().startswith("bearer "):
|
|
||||||
try:
|
|
||||||
from jose import jwt
|
|
||||||
|
|
||||||
token = auth_header.split(" ", 1)[1]
|
claims = _auth_context.get(None)
|
||||||
claims = jwt.get_unverified_claims(token)
|
if isinstance(claims, dict):
|
||||||
user = claims.get("sub", "unknown")
|
user = claims.get("sub", "unknown")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log_action(
|
log_action(
|
||||||
action=request.method.lower(),
|
action=request.method.lower(),
|
||||||
resource=request.url.path,
|
resource=request.url.path,
|
||||||
@@ -113,8 +166,17 @@ app.include_router(events_router, prefix="/api")
|
|||||||
app.include_router(config_router, prefix="/api")
|
app.include_router(config_router, prefix="/api")
|
||||||
app.include_router(webhooks_router, prefix="/api")
|
app.include_router(webhooks_router, prefix="/api")
|
||||||
app.include_router(health_router, prefix="/api")
|
app.include_router(health_router, prefix="/api")
|
||||||
app.include_router(ask_router, prefix="/api")
|
if AI_FEATURES_ENABLED:
|
||||||
|
from routes.ask import router as ask_router
|
||||||
|
|
||||||
|
app.include_router(ask_router, prefix="/api")
|
||||||
|
from routes.mcp import mcp_asgi
|
||||||
|
|
||||||
|
app.mount("/mcp", mcp_asgi)
|
||||||
|
app.include_router(saved_searches_router, prefix="/api")
|
||||||
app.include_router(rules_router, prefix="/api")
|
app.include_router(rules_router, prefix="/api")
|
||||||
|
app.include_router(alerts_router, prefix="/api")
|
||||||
|
app.include_router(jobs_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -129,11 +191,66 @@ async def health_check():
|
|||||||
raise HTTPException(status_code=503, detail="Database unavailable") from exc
|
raise HTTPException(status_code=503, detail="Database unavailable") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Best-effort client IP: X-Forwarded-For first hop, or direct client host."""
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_metrics_allowed(ip: str) -> bool:
|
||||||
|
"""Check if IP is in the configured metrics allowlist."""
|
||||||
|
if not METRICS_ALLOWED_IPS:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
client_addr = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for network in METRICS_ALLOWED_IPS.split(","):
|
||||||
|
network = network.strip()
|
||||||
|
if not network:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if client_addr in ipaddress.ip_network(network, strict=False):
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.get("/metrics")
|
@app.get("/metrics")
|
||||||
async def metrics():
|
async def metrics(request: Request):
|
||||||
|
client_ip = _client_ip(request)
|
||||||
|
if not _is_metrics_allowed(client_ip):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
return Response(content=prometheus_metrics(), media_type="text/plain")
|
return Response(content=prometheus_metrics(), media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
async def version():
|
||||||
|
return {"version": os.environ.get("VERSION", "unknown")}
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def generic_exception_handler(request: Request, exc: Exception):
|
||||||
|
"""Return generic error messages for unhandled exceptions to avoid info leakage."""
|
||||||
|
if isinstance(exc, HTTPException):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"detail": exc.detail},
|
||||||
|
headers=getattr(exc, "headers", None) or {},
|
||||||
|
)
|
||||||
|
logger.error("Unhandled exception", path=request.url.path, error=str(exc))
|
||||||
|
return Response(
|
||||||
|
content='{"detail":"Internal server error"}',
|
||||||
|
status_code=500,
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
frontend_dir = Path(__file__).parent / "frontend"
|
frontend_dir = Path(__file__).parent / "frontend"
|
||||||
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
|
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
|
||||||
|
|
||||||
@@ -151,6 +268,22 @@ async def _periodic_fetch():
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def start_periodic_fetch():
|
async def start_periodic_fetch():
|
||||||
setup_indexes()
|
setup_indexes()
|
||||||
|
from rules import seed_default_rules
|
||||||
|
|
||||||
|
seed_default_rules()
|
||||||
|
logger.info(
|
||||||
|
"AOC startup",
|
||||||
|
version=os.environ.get("VERSION", "unknown"),
|
||||||
|
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 ENABLE_PERIODIC_FETCH:
|
if ENABLE_PERIODIC_FETCH:
|
||||||
app.state.fetch_task = asyncio.create_task(_periodic_fetch())
|
app.state.fetch_task = asyncio.create_task(_periodic_fetch())
|
||||||
|
|
||||||
@@ -162,3 +295,6 @@ async def stop_periodic_fetch():
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await task
|
await task
|
||||||
|
from redis_client import close_redis_connections
|
||||||
|
|
||||||
|
await close_redis_connections()
|
||||||
|
|||||||
187
backend/mcp_common.py
Normal file
187
backend/mcp_common.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""Shared MCP tool handlers used by both stdio and SSE transports."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from database import events_collection
|
||||||
|
from mcp.types import TextContent
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_search_events(arguments: dict) -> list[TextContent]:
|
||||||
|
days = arguments.get("days", 7)
|
||||||
|
limit = min(arguments.get("limit", 20), 100)
|
||||||
|
since = (datetime.now(UTC) - timedelta(days=days)).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
filters = [{"timestamp": {"$gte": since}}]
|
||||||
|
|
||||||
|
services = arguments.get("services")
|
||||||
|
if services:
|
||||||
|
filters.append({"service": {"$in": services}})
|
||||||
|
|
||||||
|
operation = arguments.get("operation")
|
||||||
|
if operation:
|
||||||
|
filters.append({"operation": {"$regex": operation, "$options": "i"}})
|
||||||
|
|
||||||
|
result = arguments.get("result")
|
||||||
|
if result:
|
||||||
|
filters.append({"result": {"$regex": result, "$options": "i"}})
|
||||||
|
|
||||||
|
entity = arguments.get("entity")
|
||||||
|
if entity:
|
||||||
|
entity_safe = entity.replace(".", "\\.").replace("(", "\\(").replace(")", "\\)")
|
||||||
|
filters.append(
|
||||||
|
{
|
||||||
|
"$or": [
|
||||||
|
{"target_displays": {"$elemMatch": {"$regex": entity_safe, "$options": "i"}}},
|
||||||
|
{"actor_display": {"$regex": entity_safe, "$options": "i"}},
|
||||||
|
{"actor_upn": {"$regex": entity_safe, "$options": "i"}},
|
||||||
|
{"raw_text": {"$regex": entity_safe, "$options": "i"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
query = {"$and": filters}
|
||||||
|
cursor = events_collection.find(query).sort("timestamp", -1).limit(limit)
|
||||||
|
events = list(cursor)
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
return [TextContent(type="text", text="No matching events found.")]
|
||||||
|
|
||||||
|
lines = [f"Found {len(events)} event(s):\n"]
|
||||||
|
for e in events:
|
||||||
|
ts = e.get("timestamp", "?")[:16].replace("T", " ")
|
||||||
|
svc = e.get("service", "?")
|
||||||
|
op = e.get("operation", "?")
|
||||||
|
actor = e.get("actor_display", "?")
|
||||||
|
result_str = e.get("result", "?")
|
||||||
|
lines.append(f"{ts} | {svc} | {op} | {actor} | {result_str}")
|
||||||
|
|
||||||
|
return [TextContent(type="text", text="\n".join(lines))]
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_event(arguments: dict) -> list[TextContent]:
|
||||||
|
event_id = arguments["event_id"]
|
||||||
|
event = events_collection.find_one({"id": event_id})
|
||||||
|
if not event:
|
||||||
|
return [TextContent(type="text", text=f"Event {event_id} not found.")]
|
||||||
|
event.pop("_id", None)
|
||||||
|
return [TextContent(type="text", text=json.dumps(event, indent=2, default=str))]
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_summary(arguments: dict) -> list[TextContent]:
|
||||||
|
days = arguments.get("days", 7)
|
||||||
|
since = (datetime.now(UTC) - timedelta(days=days)).isoformat().replace("+00:00", "Z")
|
||||||
|
query = {"timestamp": {"$gte": since}}
|
||||||
|
|
||||||
|
total = events_collection.count_documents(query)
|
||||||
|
if total == 0:
|
||||||
|
return [TextContent(type="text", text="No events in the specified period.")]
|
||||||
|
|
||||||
|
svc_pipeline = [
|
||||||
|
{"$match": query},
|
||||||
|
{"$group": {"_id": "$service", "count": {"$sum": 1}}},
|
||||||
|
{"$sort": {"count": -1}},
|
||||||
|
{"$limit": 10},
|
||||||
|
]
|
||||||
|
op_pipeline = [
|
||||||
|
{"$match": query},
|
||||||
|
{"$group": {"_id": "$operation", "count": {"$sum": 1}}},
|
||||||
|
{"$sort": {"count": -1}},
|
||||||
|
{"$limit": 10},
|
||||||
|
]
|
||||||
|
result_pipeline = [
|
||||||
|
{"$match": query},
|
||||||
|
{"$group": {"_id": "$result", "count": {"$sum": 1}}},
|
||||||
|
{"$sort": {"count": -1}},
|
||||||
|
]
|
||||||
|
actor_pipeline = [
|
||||||
|
{"$match": query},
|
||||||
|
{"$group": {"_id": "$actor_display", "count": {"$sum": 1}}},
|
||||||
|
{"$sort": {"count": -1}},
|
||||||
|
{"$limit": 10},
|
||||||
|
]
|
||||||
|
|
||||||
|
svc_counts = list(events_collection.aggregate(svc_pipeline))
|
||||||
|
op_counts = list(events_collection.aggregate(op_pipeline))
|
||||||
|
result_counts = list(events_collection.aggregate(result_pipeline))
|
||||||
|
actor_counts = list(events_collection.aggregate(actor_pipeline))
|
||||||
|
|
||||||
|
lines = [f"Summary for the last {days} days ({total} total events)\n"]
|
||||||
|
|
||||||
|
lines.append("By service:")
|
||||||
|
for row in svc_counts:
|
||||||
|
lines.append(f" {row['_id'] or 'Unknown'}: {row['count']}")
|
||||||
|
|
||||||
|
lines.append("\nBy action:")
|
||||||
|
for row in op_counts:
|
||||||
|
lines.append(f" {row['_id'] or 'Unknown'}: {row['count']}")
|
||||||
|
|
||||||
|
lines.append("\nBy result:")
|
||||||
|
for row in result_counts:
|
||||||
|
lines.append(f" {row['_id'] or 'Unknown'}: {row['count']}")
|
||||||
|
|
||||||
|
lines.append("\nTop actors:")
|
||||||
|
for row in actor_counts:
|
||||||
|
lines.append(f" {row['_id'] or 'Unknown'}: {row['count']}")
|
||||||
|
|
||||||
|
return [TextContent(type="text", text="\n".join(lines))]
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_ask(arguments: dict) -> list[TextContent]:
|
||||||
|
"""For now, returns recent events + guidance. In the future this could call the LLM backend."""
|
||||||
|
question = arguments["question"]
|
||||||
|
days = arguments.get("days", 7)
|
||||||
|
|
||||||
|
result = await handle_search_events({"entity": "", "days": days, "limit": 50})
|
||||||
|
base_text = result[0].text if result else ""
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"You asked: '{question}'\n\n"
|
||||||
|
f"Here are the most recent events from the last {days} days:\n\n"
|
||||||
|
f"{base_text}\n\n"
|
||||||
|
f"Tip: Use the 'search_events' tool with specific filters "
|
||||||
|
f"to narrow down the dataset before asking follow-up questions."
|
||||||
|
)
|
||||||
|
return [TextContent(type="text", text=text)]
|
||||||
|
|
||||||
|
|
||||||
|
# JSON schemas for tool definitions
|
||||||
|
SEARCH_EVENTS_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"entity": {"type": "string", "description": "Device name, user UPN, or email to search for"},
|
||||||
|
"services": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Filter by service (e.g. Intune, Directory, Exchange)",
|
||||||
|
},
|
||||||
|
"operation": {"type": "string", "description": "Filter by operation name"},
|
||||||
|
"result": {"type": "string", "description": "Filter by result (success, failure)"},
|
||||||
|
"days": {"type": "integer", "description": "Number of days to look back (default 7)"},
|
||||||
|
"limit": {"type": "integer", "description": "Max events to return (default 20)"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
GET_EVENT_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"event_id": {"type": "string", "description": "The event ID to retrieve"},
|
||||||
|
},
|
||||||
|
"required": ["event_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
GET_SUMMARY_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"days": {"type": "integer", "description": "Number of days to summarise (default 7)"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ASK_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"question": {"type": "string", "description": "Natural language question about audit logs"},
|
||||||
|
"days": {"type": "integer", "description": "Number of days to look back (default 7)"},
|
||||||
|
},
|
||||||
|
"required": ["question"],
|
||||||
|
}
|
||||||
97
backend/mcp_server.py
Normal file
97
backend/mcp_server.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
AOC MCP Server — stdio transport
|
||||||
|
|
||||||
|
Standalone MCP server for local use (Claude Desktop, Cursor, etc.).
|
||||||
|
For the HTTP/SSE version (production, behind auth), see routes/mcp.py.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python mcp_server.py
|
||||||
|
|
||||||
|
Claude Desktop config (~/.config/claude/claude_desktop_config.json):
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"aoc": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["/path/to/aoc/backend/mcp_server.py"],
|
||||||
|
"env": {"MONGO_URI": "mongodb://..."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure backend modules are importable when run standalone
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
from mcp.types import TextContent, Tool
|
||||||
|
from mcp_common import (
|
||||||
|
ASK_SCHEMA,
|
||||||
|
GET_EVENT_SCHEMA,
|
||||||
|
GET_SUMMARY_SCHEMA,
|
||||||
|
SEARCH_EVENTS_SCHEMA,
|
||||||
|
handle_ask,
|
||||||
|
handle_get_event,
|
||||||
|
handle_get_summary,
|
||||||
|
handle_search_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Security warning: this standalone stdio server has no authentication.
|
||||||
|
# Only run it in trusted environments (e.g. local Claude Desktop) and
|
||||||
|
# ensure the MongoDB connection uses authenticated credentials.
|
||||||
|
print("=" * 60, file=sys.stderr)
|
||||||
|
print("AOC MCP Server (stdio transport)", file=sys.stderr)
|
||||||
|
print("WARNING: No authentication layer. Only run in trusted", file=sys.stderr)
|
||||||
|
print("environments or behind a VPN. See AGENTS.md for details.", file=sys.stderr)
|
||||||
|
print("=" * 60, file=sys.stderr)
|
||||||
|
|
||||||
|
app = Server("aoc")
|
||||||
|
|
||||||
|
|
||||||
|
@app.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
return [
|
||||||
|
Tool(
|
||||||
|
name="search_events",
|
||||||
|
description="Search audit events by entity, service, operation, or result.",
|
||||||
|
inputSchema=SEARCH_EVENTS_SCHEMA,
|
||||||
|
),
|
||||||
|
Tool(name="get_event", description="Retrieve a single audit event by its ID.", inputSchema=GET_EVENT_SCHEMA),
|
||||||
|
Tool(
|
||||||
|
name="get_summary",
|
||||||
|
description="Get an aggregated summary of audit activity for the last N days.",
|
||||||
|
inputSchema=GET_SUMMARY_SCHEMA,
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="ask",
|
||||||
|
description="Ask a natural language question about audit logs. Returns a narrative answer.",
|
||||||
|
inputSchema=ASK_SCHEMA,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
|
if name == "search_events":
|
||||||
|
return await handle_search_events(arguments)
|
||||||
|
if name == "get_event":
|
||||||
|
return await handle_get_event(arguments)
|
||||||
|
if name == "get_summary":
|
||||||
|
return await handle_get_summary(arguments)
|
||||||
|
if name == "ask":
|
||||||
|
return await handle_ask(arguments)
|
||||||
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with stdio_server() as (read_stream, write_stream):
|
||||||
|
await app.run(read_stream, write_stream, app.create_initialization_options())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -63,12 +63,18 @@ class CommentAddRequest(BaseModel):
|
|||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class AlertCondition(BaseModel):
|
||||||
|
field: str
|
||||||
|
op: str # eq, neq, contains, in, after_hours
|
||||||
|
value: str | list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class AlertRuleResponse(BaseModel):
|
class AlertRuleResponse(BaseModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
name: str
|
name: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
severity: str
|
severity: str
|
||||||
conditions: list[dict]
|
conditions: list[AlertCondition]
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +88,7 @@ class AskRequest(BaseModel):
|
|||||||
end: str | None = None
|
end: str | None = None
|
||||||
include_tags: list[str] | None = None
|
include_tags: list[str] | None = None
|
||||||
exclude_tags: list[str] | None = None
|
exclude_tags: list[str] | None = None
|
||||||
|
async_mode: bool = False # enqueue async job instead of waiting
|
||||||
|
|
||||||
|
|
||||||
class AskEventRef(BaseModel):
|
class AskEventRef(BaseModel):
|
||||||
@@ -101,3 +108,4 @@ class AskResponse(BaseModel):
|
|||||||
query_info: dict
|
query_info: dict
|
||||||
llm_used: bool
|
llm_used: bool
|
||||||
llm_error: str | None = None
|
llm_error: str | None = None
|
||||||
|
job_id: str | None = None
|
||||||
|
|||||||
172
backend/notifications.py
Normal file
172
backend/notifications.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Pluggable notification channels for admin-ops alerts.
|
||||||
|
|
||||||
|
Supported channels:
|
||||||
|
- webhook: POST JSON to any URL (Slack, Teams, generic)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import structlog
|
||||||
|
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
logger = structlog.get_logger("aoc.notifications")
|
||||||
|
|
||||||
|
WEBHOOK_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||||
|
retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
def _post_webhook(url: str, payload: dict) -> requests.Response:
|
||||||
|
"""POST to webhook with retry on connection/timeout errors."""
|
||||||
|
return requests.post(url, json=payload, timeout=WEBHOOK_TIMEOUT, headers={"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
|
||||||
|
def _build_slack_payload(rule_name: str, severity: str, message: str, event: dict) -> dict:
|
||||||
|
"""Build a Slack-compatible block payload."""
|
||||||
|
color = {"high": "#ef4444", "medium": "#f97316", "low": "#3b82f6"}.get(severity, "#94a3b8")
|
||||||
|
ts = event.get("timestamp", "?")
|
||||||
|
op = event.get("operation", "unknown")
|
||||||
|
actor = event.get("actor_display", "unknown")
|
||||||
|
targets = ", ".join(event.get("target_displays", [])) or "—"
|
||||||
|
svc = event.get("service", "unknown")
|
||||||
|
return {
|
||||||
|
"text": f"[{severity.upper()}] {rule_name}: {message}",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"color": color,
|
||||||
|
"fields": [
|
||||||
|
{"title": "Rule", "value": rule_name, "short": True},
|
||||||
|
{"title": "Severity", "value": severity.upper(), "short": True},
|
||||||
|
{"title": "Service", "value": svc, "short": True},
|
||||||
|
{"title": "Action", "value": op, "short": True},
|
||||||
|
{"title": "Actor", "value": actor, "short": True},
|
||||||
|
{"title": "Target", "value": targets, "short": True},
|
||||||
|
{"title": "Time", "value": ts, "short": False},
|
||||||
|
],
|
||||||
|
"footer": "AOC Admin Operations Center",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_teams_payload(rule_name: str, severity: str, message: str, event: dict) -> dict:
|
||||||
|
"""Build a Microsoft Teams adaptive card payload."""
|
||||||
|
color = {"high": "Attention", "medium": "Warning", "low": "Good"}.get(severity, "Default")
|
||||||
|
ts = event.get("timestamp", "?")
|
||||||
|
op = event.get("operation", "unknown")
|
||||||
|
actor = event.get("actor_display", "unknown")
|
||||||
|
targets = ", ".join(event.get("target_displays", [])) or "—"
|
||||||
|
svc = event.get("service", "unknown")
|
||||||
|
return {
|
||||||
|
"type": "message",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||||
|
"content": {
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.4",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"🚨 {severity.upper()}: {rule_name}",
|
||||||
|
"weight": "Bolder",
|
||||||
|
"size": "Medium",
|
||||||
|
"color": color,
|
||||||
|
},
|
||||||
|
{"type": "TextBlock", "text": message, "wrap": True},
|
||||||
|
{
|
||||||
|
"type": "FactSet",
|
||||||
|
"facts": [
|
||||||
|
{"title": "Service:", "value": svc},
|
||||||
|
{"title": "Action:", "value": op},
|
||||||
|
{"title": "Actor:", "value": actor},
|
||||||
|
{"title": "Target:", "value": targets},
|
||||||
|
{"title": "Time:", "value": ts},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_generic_payload(rule_name: str, severity: str, message: str, event: dict) -> dict:
|
||||||
|
"""Build a generic JSON payload."""
|
||||||
|
return {
|
||||||
|
"alert": {
|
||||||
|
"rule_name": rule_name,
|
||||||
|
"severity": severity,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"id": event.get("id"),
|
||||||
|
"timestamp": event.get("timestamp"),
|
||||||
|
"service": event.get("service"),
|
||||||
|
"operation": event.get("operation"),
|
||||||
|
"actor_display": event.get("actor_display"),
|
||||||
|
"target_displays": event.get("target_displays"),
|
||||||
|
"result": event.get("result"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification(
|
||||||
|
webhook_url: str,
|
||||||
|
format_type: str,
|
||||||
|
rule_name: str,
|
||||||
|
severity: str,
|
||||||
|
message: str,
|
||||||
|
event: dict,
|
||||||
|
) -> bool:
|
||||||
|
"""Send an alert notification to the configured channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
webhook_url: URL to POST to.
|
||||||
|
format_type: "slack", "teams", or "generic".
|
||||||
|
rule_name: Name of the triggered rule.
|
||||||
|
severity: high, medium, or low.
|
||||||
|
message: Human-readable alert message.
|
||||||
|
event: The normalized event that triggered the alert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if delivery succeeded, False otherwise.
|
||||||
|
"""
|
||||||
|
if not webhook_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
builders = {
|
||||||
|
"slack": _build_slack_payload,
|
||||||
|
"teams": _build_teams_payload,
|
||||||
|
"generic": _build_generic_payload,
|
||||||
|
}
|
||||||
|
builder = builders.get(format_type, _build_generic_payload)
|
||||||
|
payload = builder(rule_name, severity, message, event)
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = _post_webhook(webhook_url, payload)
|
||||||
|
res.raise_for_status()
|
||||||
|
logger.info(
|
||||||
|
"Notification sent",
|
||||||
|
rule=rule_name,
|
||||||
|
severity=severity,
|
||||||
|
format=format_type,
|
||||||
|
status_code=res.status_code,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Notification failed after retries",
|
||||||
|
rule=rule_name,
|
||||||
|
severity=severity,
|
||||||
|
format=format_type,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return False
|
||||||
83
backend/rate_limiter.py
Normal file
83
backend/rate_limiter.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Simple Redis-backed fixed-window rate limiter."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from config import RATE_LIMIT_ENABLED, RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from redis_client import get_redis
|
||||||
|
|
||||||
|
logger = structlog.get_logger("aoc.rate_limit")
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitExceeded(HTTPException):
|
||||||
|
def __init__(self, retry_after: int):
|
||||||
|
super().__init__(
|
||||||
|
status_code=429,
|
||||||
|
detail="Rate limit exceeded. Please slow down.",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_identifier(request: Request) -> str:
|
||||||
|
"""Best-effort client identifier: authenticated sub, or X-Forwarded-For, or client host."""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user and isinstance(user, dict):
|
||||||
|
sub = user.get("sub")
|
||||||
|
if sub and sub != "anonymous":
|
||||||
|
return f"user:{sub}"
|
||||||
|
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return f"ip:{forwarded.split(',')[0].strip()}"
|
||||||
|
|
||||||
|
return f"ip:{request.client.host if request.client else 'unknown'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_path_category(path: str) -> str:
|
||||||
|
"""Bucket paths into rate-limit categories."""
|
||||||
|
if path.startswith("/api/fetch"):
|
||||||
|
return "fetch"
|
||||||
|
if path.startswith("/api/ask"):
|
||||||
|
return "ask"
|
||||||
|
if path.startswith("/api/events/bulk-tags"):
|
||||||
|
return "write"
|
||||||
|
return "default"
|
||||||
|
|
||||||
|
|
||||||
|
def _limit_for_category(category: str) -> tuple[int, int]:
|
||||||
|
"""Return (max_requests, window_seconds) for a category."""
|
||||||
|
if category == "fetch":
|
||||||
|
return (10, 3600) # 10 per hour
|
||||||
|
if category == "ask":
|
||||||
|
return (30, 60) # 30 per minute
|
||||||
|
if category == "write":
|
||||||
|
return (20, 60) # 20 per minute
|
||||||
|
return (RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_rate_limit(request: Request):
|
||||||
|
"""Raise RateLimitExceeded if the client has exceeded their quota."""
|
||||||
|
if not RATE_LIMIT_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
category = _get_path_category(request.url.path)
|
||||||
|
limit, window = _limit_for_category(category)
|
||||||
|
|
||||||
|
identifier = _get_identifier(request)
|
||||||
|
now = int(time.time())
|
||||||
|
window_key = now // window
|
||||||
|
redis_key = f"rate_limit:{identifier}:{category}:{window_key}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
redis = await get_redis()
|
||||||
|
count = await redis.incr(redis_key)
|
||||||
|
if count == 1:
|
||||||
|
await redis.expire(redis_key, window)
|
||||||
|
if count > limit:
|
||||||
|
raise RateLimitExceeded(retry_after=window - (now % window))
|
||||||
|
except RateLimitExceeded:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Rate limiter Redis error; failing closed", error=str(exc))
|
||||||
|
raise RateLimitExceeded(retry_after=60) from None
|
||||||
36
backend/redis_client.py
Normal file
36
backend/redis_client.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Async Redis client singleton for caching and job queue."""
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from arq import create_pool
|
||||||
|
from arq.connections import ArqRedis, RedisSettings
|
||||||
|
from config import REDIS_URL
|
||||||
|
|
||||||
|
_arq_pool: ArqRedis | None = None
|
||||||
|
_plain_redis: aioredis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_arq_pool() -> ArqRedis:
|
||||||
|
"""Return a shared arq pool (ArqRedis extends redis.asyncio.Redis)."""
|
||||||
|
global _arq_pool
|
||||||
|
if _arq_pool is None:
|
||||||
|
_arq_pool = await create_pool(RedisSettings.from_dsn(REDIS_URL))
|
||||||
|
return _arq_pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> aioredis.Redis:
|
||||||
|
"""Return a shared plain async Redis client."""
|
||||||
|
global _plain_redis
|
||||||
|
if _plain_redis is None:
|
||||||
|
_plain_redis = aioredis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
return _plain_redis
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis_connections():
|
||||||
|
"""Close all Redis connections (call on shutdown)."""
|
||||||
|
global _arq_pool, _plain_redis
|
||||||
|
if _arq_pool:
|
||||||
|
await _arq_pool.close()
|
||||||
|
_arq_pool = None
|
||||||
|
if _plain_redis:
|
||||||
|
await _plain_redis.close()
|
||||||
|
_plain_redis = None
|
||||||
@@ -13,3 +13,11 @@ tenacity
|
|||||||
prometheus-client
|
prometheus-client
|
||||||
httpx
|
httpx
|
||||||
gunicorn
|
gunicorn
|
||||||
|
mcp
|
||||||
|
redis
|
||||||
|
arq
|
||||||
|
|
||||||
|
# Optional: Azure Key Vault integration for secrets storage
|
||||||
|
# Uncomment if using AZURE_KEY_VAULT_NAME
|
||||||
|
# azure-identity
|
||||||
|
# azure-keyvault-secrets
|
||||||
|
|||||||
78
backend/routes/alerts.py
Normal file
78
backend/routes/alerts.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Alert management endpoints."""
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from bson import ObjectId
|
||||||
|
from database import alerts_collection
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
|
class AlertStatusUpdate(BaseModel):
|
||||||
|
status: str # open | acknowledged | resolved | false_positive
|
||||||
|
|
||||||
|
|
||||||
|
class AlertListResponse(BaseModel):
|
||||||
|
items: list[dict]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alerts", response_model=AlertListResponse)
|
||||||
|
def list_alerts(
|
||||||
|
status: str = Query(default="", description="Filter by status"),
|
||||||
|
severity: str = Query(default="", description="Filter by severity"),
|
||||||
|
rule_name: str = Query(default="", description="Filter by rule name"),
|
||||||
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
):
|
||||||
|
query = {}
|
||||||
|
if status:
|
||||||
|
query["status"] = status
|
||||||
|
if severity:
|
||||||
|
query["severity"] = severity
|
||||||
|
if rule_name:
|
||||||
|
query["rule_name"] = {"$regex": rule_name, "$options": "i"}
|
||||||
|
|
||||||
|
total = alerts_collection.count_documents(query)
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
cursor = alerts_collection.find(query, {"_id": 0}).sort("timestamp", -1).skip(skip).limit(page_size)
|
||||||
|
return {"items": list(cursor), "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/alerts/{alert_id}/status")
|
||||||
|
def update_alert_status(alert_id: str, body: AlertStatusUpdate):
|
||||||
|
result = alerts_collection.update_one(
|
||||||
|
{"_id": ObjectId(alert_id)},
|
||||||
|
{"$set": {"status": body.status}},
|
||||||
|
)
|
||||||
|
if result.matched_count == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
return {"updated": True, "status": body.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alerts/summary")
|
||||||
|
def alert_summary():
|
||||||
|
"""Return counts by status and severity for the dashboard."""
|
||||||
|
pipeline = [
|
||||||
|
{
|
||||||
|
"$group": {
|
||||||
|
"_id": {"status": "$status", "severity": "$severity"},
|
||||||
|
"count": {"$sum": 1},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
by_status_severity = list(alerts_collection.aggregate(pipeline))
|
||||||
|
|
||||||
|
total_open = alerts_collection.count_documents({"status": "open"})
|
||||||
|
total_acknowledged = alerts_collection.count_documents({"status": "acknowledged"})
|
||||||
|
total_resolved = alerts_collection.count_documents({"status": "resolved"})
|
||||||
|
total_false_positive = alerts_collection.count_documents({"status": "false_positive"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_open": total_open,
|
||||||
|
"total_acknowledged": total_acknowledged,
|
||||||
|
"total_resolved": total_resolved,
|
||||||
|
"total_false_positive": total_false_positive,
|
||||||
|
"by_status_severity": by_status_severity,
|
||||||
|
}
|
||||||
@@ -1,18 +1,154 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import structlog
|
import structlog
|
||||||
from auth import require_auth
|
from auth import require_auth, user_can_access_privacy_services
|
||||||
from config import LLM_API_KEY, LLM_API_VERSION, LLM_BASE_URL, LLM_MAX_EVENTS, LLM_MODEL, LLM_TIMEOUT_SECONDS
|
from config import (
|
||||||
|
LLM_ALLOWED_DOMAINS,
|
||||||
|
LLM_API_KEY,
|
||||||
|
LLM_API_VERSION,
|
||||||
|
LLM_BASE_URL,
|
||||||
|
LLM_MAX_EVENTS,
|
||||||
|
LLM_MODEL,
|
||||||
|
LLM_TIMEOUT_SECONDS,
|
||||||
|
PRIVACY_SENSITIVE_OPERATIONS,
|
||||||
|
PRIVACY_SERVICES,
|
||||||
|
)
|
||||||
from database import events_collection
|
from database import events_collection
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from jobs import get_cached_ask, get_cached_explain, set_cached_ask, set_cached_explain
|
||||||
from models.api import AskRequest, AskResponse
|
from models.api import AskRequest, AskResponse
|
||||||
|
from redis_client import get_arq_pool
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||||
logger = structlog.get_logger("aoc.ask")
|
logger = structlog.get_logger("aoc.ask")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Intent extraction — map question keywords to relevant audit services
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SERVICE_INTENTS = {
|
||||||
|
"intune": ["Intune"],
|
||||||
|
"device": ["Intune", "Device"],
|
||||||
|
"laptop": ["Intune", "Device"],
|
||||||
|
"mobile": ["Intune", "Device"],
|
||||||
|
"phone": ["Intune", "Device"],
|
||||||
|
"ipad": ["Intune", "Device"],
|
||||||
|
"app": ["Intune", "ApplicationManagement"],
|
||||||
|
"application": ["Intune", "ApplicationManagement"],
|
||||||
|
"policy": ["Intune", "Policy"],
|
||||||
|
"compliance": ["Intune", "Policy"],
|
||||||
|
"user": ["Directory", "UserManagement"],
|
||||||
|
"group": ["Directory", "GroupManagement"],
|
||||||
|
"role": ["Directory", "RoleManagement"],
|
||||||
|
"permission": ["Directory", "RoleManagement"],
|
||||||
|
"license": ["Directory", "License"],
|
||||||
|
"email": ["Exchange"],
|
||||||
|
"mailbox": ["Exchange"],
|
||||||
|
"mail": ["Exchange"],
|
||||||
|
"message": ["Exchange", "Teams"],
|
||||||
|
"file": ["SharePoint"],
|
||||||
|
"sharepoint": ["SharePoint"],
|
||||||
|
"site": ["SharePoint"],
|
||||||
|
"document": ["SharePoint"],
|
||||||
|
"team": ["Teams"],
|
||||||
|
"channel": ["Teams"],
|
||||||
|
"meeting": ["Teams"],
|
||||||
|
"call": ["Teams"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Services that are extremely noisy for typical admin questions.
|
||||||
|
# We exclude them by default on broad questions unless the user explicitly mentions them.
|
||||||
|
_NOISY_SERVICES = {"Exchange", "SharePoint", "Teams"}
|
||||||
|
|
||||||
|
# Services that are generally admin-relevant and kept by default.
|
||||||
|
_DEFAULT_ADMIN_SERVICES = {
|
||||||
|
"Directory",
|
||||||
|
"UserManagement",
|
||||||
|
"GroupManagement",
|
||||||
|
"RoleManagement",
|
||||||
|
"ApplicationManagement",
|
||||||
|
"Intune",
|
||||||
|
"Device",
|
||||||
|
"Policy",
|
||||||
|
"Teams",
|
||||||
|
"License",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_intent_services(question: str) -> tuple[list[str] | None, bool]:
|
||||||
|
"""
|
||||||
|
Extract relevant services from the question.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(services, is_explicit):
|
||||||
|
- services: list of service names to query, or None for default admin set
|
||||||
|
- is_explicit: True if the user explicitly mentioned a noisy service
|
||||||
|
"""
|
||||||
|
q_lower = question.lower()
|
||||||
|
tokens = set(re.findall(r"\b[a-z]+\b", q_lower))
|
||||||
|
|
||||||
|
matched_services = set()
|
||||||
|
for token, services in _SERVICE_INTENTS.items():
|
||||||
|
if token in tokens:
|
||||||
|
matched_services.update(services)
|
||||||
|
|
||||||
|
if matched_services:
|
||||||
|
# User asked something specific — return exactly what they asked for
|
||||||
|
is_explicit = not matched_services.isdisjoint(_NOISY_SERVICES)
|
||||||
|
return sorted(matched_services), is_explicit
|
||||||
|
|
||||||
|
# Broad question with no clear intent — default to admin-relevant services only
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Smart sampling — stratified by importance so the LLM sees signal, not noise
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _smart_sample(events: list[dict], max_events: int = 200) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Return a curated subset that preserves diversity and prioritises signal.
|
||||||
|
|
||||||
|
Tiers:
|
||||||
|
1. Failures (always valuable)
|
||||||
|
2. High-admin-value services (Intune, Device, Directory, etc.)
|
||||||
|
3. Everything else
|
||||||
|
"""
|
||||||
|
if len(events) <= max_events:
|
||||||
|
return events
|
||||||
|
|
||||||
|
high_value = {
|
||||||
|
"Directory",
|
||||||
|
"UserManagement",
|
||||||
|
"GroupManagement",
|
||||||
|
"RoleManagement",
|
||||||
|
"Intune",
|
||||||
|
"Device",
|
||||||
|
"Policy",
|
||||||
|
"ApplicationManagement",
|
||||||
|
}
|
||||||
|
|
||||||
|
failures = [e for e in events if str(e.get("result") or "").lower() in ("failure", "failed")]
|
||||||
|
high_val = [e for e in events if e.get("service") in high_value and e not in failures]
|
||||||
|
rest = [e for e in events if e not in failures and e not in high_val]
|
||||||
|
|
||||||
|
# Allocate slots: half to failures+high-value, half to rest (but never let rest dominate)
|
||||||
|
slots = max_events
|
||||||
|
failure_cap = min(len(failures), max(10, slots // 4))
|
||||||
|
high_cap = min(len(high_val), max(20, slots // 4))
|
||||||
|
rest_cap = slots - failure_cap - high_cap
|
||||||
|
|
||||||
|
sampled = failures[:failure_cap] + high_val[:high_cap] + rest[:rest_cap]
|
||||||
|
# Sort back to chronological order
|
||||||
|
sampled.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||||
|
return sampled
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Time-range extraction
|
# Time-range extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -168,22 +304,80 @@ def _build_event_query(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_SYSTEM_PROMPT = """You are an IT operations assistant. An administrator has asked a question about audit logs.
|
_SYSTEM_PROMPT = """You are an IT operations assistant. An administrator has asked a question about audit logs.
|
||||||
Your job is to read the list of audit events below and write a concise, plain-language answer.
|
Your job is to read the data below and write a concise, plain-language answer.
|
||||||
|
|
||||||
|
The input may be either:
|
||||||
|
- A small list of individual audit events (numbered Event #1, #2, etc.), or
|
||||||
|
- An aggregated overview with counts by service, action, result, and actor, plus sample events.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Assume the reader is a non-expert admin.
|
- Assume the reader is a non-expert admin.
|
||||||
- Group related events together and tell a coherent story.
|
- For aggregated overviews: summarise the scale, top patterns, and highlight anomalies or failures.
|
||||||
|
- For small event lists: group related events together and tell a coherent story.
|
||||||
- Highlight anything unusual, failed actions, or privilege escalations.
|
- Highlight anything unusual, failed actions, or privilege escalations.
|
||||||
- Reference specific event numbers (e.g., "Event #3") when making claims so the user can verify.
|
- Reference specific event numbers (e.g., "Event #3") when making claims so the user can verify.
|
||||||
|
- If the data is an aggregated subset of a larger result set, acknowledge the scale (e.g., "847 events occurred — the top pattern was...").
|
||||||
- If there are no events, say so clearly.
|
- If there are no events, say so clearly.
|
||||||
- Keep the answer under 300 words.
|
- Keep the answer under 300 words.
|
||||||
- Do not invent events that are not in the list.
|
- Do not invent events or patterns that are not supported by the data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _format_events_for_llm(events: list[dict]) -> str:
|
def _aggregate_counts(events: list[dict]) -> dict:
|
||||||
|
"""Build lightweight aggregation tables for large result sets."""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
svc_counts = Counter(e.get("service") or "Unknown" for e in events)
|
||||||
|
op_counts = Counter(e.get("operation") or "Unknown" for e in events)
|
||||||
|
result_counts = Counter(e.get("result") or "Unknown" for e in events)
|
||||||
|
actor_counts = Counter(e.get("actor_display") or "Unknown" for e in events)
|
||||||
|
return {
|
||||||
|
"services": svc_counts.most_common(10),
|
||||||
|
"operations": op_counts.most_common(10),
|
||||||
|
"results": result_counts.most_common(5),
|
||||||
|
"actors": actor_counts.most_common(10),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_events_for_llm(
|
||||||
|
events: list[dict], total: int | None = None, excluded_services: list[str] | None = None
|
||||||
|
) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
for i, e in enumerate(events, 1):
|
|
||||||
|
# If we have a large result set, send aggregation + samples instead of raw dump
|
||||||
|
if total is not None and total > len(events) and len(events) >= 50:
|
||||||
|
lines.append(f"Result set overview: {total} total events (showing a curated sample of {len(events)}).\n")
|
||||||
|
if excluded_services:
|
||||||
|
lines.append(f"Note: high-volume services excluded by default: {', '.join(excluded_services)}.\n")
|
||||||
|
agg = _aggregate_counts(events)
|
||||||
|
lines.append("Breakdown by service:")
|
||||||
|
for svc, cnt in agg["services"]:
|
||||||
|
lines.append(f" {svc}: {cnt}")
|
||||||
|
lines.append("\nBreakdown by action:")
|
||||||
|
for op, cnt in agg["operations"]:
|
||||||
|
lines.append(f" {op}: {cnt}")
|
||||||
|
lines.append("\nBreakdown by result:")
|
||||||
|
for res, cnt in agg["results"]:
|
||||||
|
lines.append(f" {res}: {cnt}")
|
||||||
|
lines.append("\nTop actors:")
|
||||||
|
for actor, cnt in agg["actors"]:
|
||||||
|
lines.append(f" {actor}: {cnt}")
|
||||||
|
# Include failures and a few recent samples
|
||||||
|
failures = [e for e in events if str(e.get("result") or "").lower() in ("failure", "failed")]
|
||||||
|
if failures:
|
||||||
|
lines.append(f"\nFailures ({len(failures)}):")
|
||||||
|
for e in failures[:10]:
|
||||||
|
ts = e.get("timestamp", "?")[:16].replace("T", " ")
|
||||||
|
op = e.get("operation", "unknown")
|
||||||
|
actor = e.get("actor_display", "unknown")
|
||||||
|
lines.append(f" {ts} — {op} by {actor}")
|
||||||
|
lines.append("\nMost recent sample events:")
|
||||||
|
else:
|
||||||
|
if total is not None and total > len(events):
|
||||||
|
lines.append(f"Showing {len(events)} of {total} total matching events (most recent first):\n")
|
||||||
|
|
||||||
|
# Always include the first N raw events as detail (up to 50)
|
||||||
|
for i, e in enumerate(events[:50], 1):
|
||||||
ts = e.get("timestamp") or "unknown time"
|
ts = e.get("timestamp") or "unknown time"
|
||||||
op = e.get("operation") or "unknown action"
|
op = e.get("operation") or "unknown action"
|
||||||
actor = e.get("actor_display") or "unknown actor"
|
actor = e.get("actor_display") or "unknown actor"
|
||||||
@@ -204,8 +398,37 @@ def _format_events_for_llm(events: list[dict]) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_llm_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("LLM_BASE_URL must use HTTPS")
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
if not hostname:
|
||||||
|
raise RuntimeError("LLM_BASE_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"LLM_BASE_URL hostname '{hostname}' is not allowed")
|
||||||
|
# Block link-local and private IP ranges
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
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"LLM_BASE_URL IP '{hostname}' is not allowed")
|
||||||
|
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:
|
def _build_chat_url(base_url: str, api_version: str) -> str:
|
||||||
"""Construct the chat completions URL, handling Azure OpenAI endpoints."""
|
|
||||||
base = base_url.rstrip("/")
|
base = base_url.rstrip("/")
|
||||||
url = base if base.endswith("/chat/completions") else f"{base}/chat/completions"
|
url = base if base.endswith("/chat/completions") else f"{base}/chat/completions"
|
||||||
if api_version:
|
if api_version:
|
||||||
@@ -213,11 +436,16 @@ def _build_chat_url(base_url: str, api_version: str) -> str:
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
async def _call_llm(question: str, events: list[dict]) -> str:
|
async def _call_llm(
|
||||||
|
question: str,
|
||||||
|
events: list[dict],
|
||||||
|
total: int | None = None,
|
||||||
|
excluded_services: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
if not LLM_API_KEY:
|
if not LLM_API_KEY:
|
||||||
raise RuntimeError("LLM_API_KEY not configured")
|
raise RuntimeError("LLM_API_KEY not configured")
|
||||||
|
|
||||||
context = _format_events_for_llm(events)
|
context = _format_events_for_llm(events, total=total, excluded_services=excluded_services)
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||||
{
|
{
|
||||||
@@ -226,6 +454,9 @@ async def _call_llm(question: str, events: list[dict]) -> str:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# SSRF guard: only allow known public HTTPS endpoints
|
||||||
|
_validate_llm_url(LLM_BASE_URL)
|
||||||
|
|
||||||
url = _build_chat_url(LLM_BASE_URL, LLM_API_VERSION)
|
url = _build_chat_url(LLM_BASE_URL, LLM_API_VERSION)
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -270,6 +501,209 @@ def _to_event_ref(e: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_EXPLAIN_SYSTEM_PROMPT = """You are a Microsoft 365 security and compliance expert.
|
||||||
|
An administrator needs help understanding an audit event.
|
||||||
|
|
||||||
|
Your task:
|
||||||
|
1. Explain what happened in plain language (1-2 sentences).
|
||||||
|
2. Identify who performed the action and what was the target.
|
||||||
|
3. Assess whether this is typical admin activity or something to investigate.
|
||||||
|
4. Highlight any security implications (privilege escalation, unusual actor, after-hours activity, etc.).
|
||||||
|
5. Suggest what the admin should do next, if anything.
|
||||||
|
|
||||||
|
Keep the answer under 200 words. Use bullet points for readability.
|
||||||
|
Do not invent facts that are not in the data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_GUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_guids(obj: dict | list | str) -> set[str]:
|
||||||
|
"""Recursively extract UUID-like strings from a JSON structure."""
|
||||||
|
guids = set()
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
if k.lower() in ("id", "groupid", "userid", "targetid") and isinstance(v, str) and _GUID_RE.match(v):
|
||||||
|
guids.add(v)
|
||||||
|
guids.update(_extract_guids(v))
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for item in obj:
|
||||||
|
guids.update(_extract_guids(item))
|
||||||
|
elif isinstance(obj, str) and _GUID_RE.match(obj):
|
||||||
|
guids.add(obj)
|
||||||
|
return guids
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_guids_for_event(event: dict) -> dict[str, str]:
|
||||||
|
"""Try to resolve GUIDs in an event to human-readable names via Graph API."""
|
||||||
|
raw = event.get("raw") or {}
|
||||||
|
guids = _extract_guids(raw)
|
||||||
|
# Also include any GUIDs in targetResources that might not have displayName
|
||||||
|
for tr in raw.get("targetResources") or []:
|
||||||
|
tid = tr.get("id")
|
||||||
|
if tid and _GUID_RE.match(tid):
|
||||||
|
guids.add(tid)
|
||||||
|
for tr in raw.get("modifiedProperties") or []:
|
||||||
|
for key in ("oldValue", "newValue"):
|
||||||
|
val = tr.get(key)
|
||||||
|
if val and _GUID_RE.match(val):
|
||||||
|
guids.add(val)
|
||||||
|
|
||||||
|
if not guids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from graph.auth import get_access_token
|
||||||
|
from graph.resolve import resolve_directory_object
|
||||||
|
|
||||||
|
token = await asyncio.to_thread(get_access_token)
|
||||||
|
cache: dict[str, dict] = {}
|
||||||
|
resolved = {}
|
||||||
|
for gid in guids:
|
||||||
|
result = await asyncio.to_thread(resolve_directory_object, gid, token, cache)
|
||||||
|
if result:
|
||||||
|
resolved[gid] = result["name"]
|
||||||
|
return resolved
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("GUID resolution failed", error=str(exc))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _explain_event(event: dict, related: list[dict]) -> str:
|
||||||
|
if not LLM_API_KEY:
|
||||||
|
raise RuntimeError("LLM_API_KEY not configured")
|
||||||
|
|
||||||
|
# Resolve GUIDs to names before sending to LLM
|
||||||
|
resolved = await _resolve_guids_for_event(event)
|
||||||
|
|
||||||
|
event_text = json.dumps(event, indent=2, default=str)
|
||||||
|
resolution_text = ""
|
||||||
|
if resolved:
|
||||||
|
resolution_text = "\nResolved GUIDs:\n"
|
||||||
|
for gid, name in resolved.items():
|
||||||
|
resolution_text += f" {gid} → {name}\n"
|
||||||
|
|
||||||
|
related_text = ""
|
||||||
|
if related:
|
||||||
|
related_text = "\n\nRelated events in the last 24 hours:\n"
|
||||||
|
for i, e in enumerate(related[:10], 1):
|
||||||
|
ts = e.get("timestamp", "?")[:16].replace("T", " ")
|
||||||
|
op = e.get("operation", "unknown")
|
||||||
|
actor = e.get("actor_display", "unknown")
|
||||||
|
targets = ", ".join(e.get("target_displays") or []) or "—"
|
||||||
|
result = e.get("result", "—")
|
||||||
|
related_text += f"{i}. {ts} — {op} by {actor} on {targets} ({result})\n"
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _EXPLAIN_SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"Audit event:\n{event_text}{resolution_text}{related_text}\n\nPlease explain this event.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_validate_llm_url(LLM_BASE_URL)
|
||||||
|
|
||||||
|
url = _build_chat_url(LLM_BASE_URL, LLM_API_VERSION)
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if "azure" in LLM_BASE_URL.lower() or "cognitiveservices" in LLM_BASE_URL.lower():
|
||||||
|
headers["api-key"] = LLM_API_KEY
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = f"Bearer {LLM_API_KEY}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": LLM_MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"max_completion_tokens": 600,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SECONDS) as client:
|
||||||
|
resp = await client.post(url, headers=headers, json=payload)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
body = resp.text
|
||||||
|
logger.error("LLM API error", status_code=resp.status_code, url=url, response_body=body)
|
||||||
|
raise RuntimeError(f"LLM API error {resp.status_code}: {body[:500]}")
|
||||||
|
data = resp.json()
|
||||||
|
return data["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/{event_id}/explain")
|
||||||
|
async def explain_event(event_id: str, user: dict = Depends(require_auth)):
|
||||||
|
event = events_collection.find_one({"id": event_id})
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.get("service") in PRIVACY_SERVICES or event.get("operation") in PRIVACY_SENSITIVE_OPERATIONS
|
||||||
|
) and not user_can_access_privacy_services(user):
|
||||||
|
raise HTTPException(status_code=403, detail="Access to this event is restricted")
|
||||||
|
|
||||||
|
event.pop("_id", None)
|
||||||
|
|
||||||
|
# Fetch related events for context (same actor or target in last 24h)
|
||||||
|
related = []
|
||||||
|
since = (datetime.now(UTC) - timedelta(hours=24)).isoformat().replace("+00:00", "Z")
|
||||||
|
actor = event.get("actor_upn") or event.get("actor_display")
|
||||||
|
target = event.get("target_displays", [None])[0] if event.get("target_displays") else None
|
||||||
|
|
||||||
|
or_filters = [{"timestamp": {"$gte": since}}, {"id": {"$ne": event_id}}]
|
||||||
|
if actor:
|
||||||
|
or_filters.append(
|
||||||
|
{
|
||||||
|
"$or": [
|
||||||
|
{"actor_upn": actor},
|
||||||
|
{"actor_display": actor},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if target:
|
||||||
|
or_filters.append({"target_displays": target})
|
||||||
|
|
||||||
|
if len(or_filters) > 2:
|
||||||
|
try:
|
||||||
|
rel_cursor = events_collection.find({"$and": or_filters}).sort("timestamp", -1).limit(10)
|
||||||
|
related = list(rel_cursor)
|
||||||
|
for r in related:
|
||||||
|
r.pop("_id", None)
|
||||||
|
r.pop("raw", None)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch related events", error=str(exc))
|
||||||
|
|
||||||
|
if not LLM_API_KEY:
|
||||||
|
return {
|
||||||
|
"explanation": "LLM is not configured. Set LLM_API_KEY in your environment to enable event explanations.",
|
||||||
|
"llm_used": False,
|
||||||
|
"llm_error": "LLM_API_KEY not configured",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
redis = await get_arq_pool()
|
||||||
|
cached = await get_cached_explain(redis, event_id)
|
||||||
|
if cached:
|
||||||
|
cached["related_count"] = len(related)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
try:
|
||||||
|
explanation = await _explain_event(event, related)
|
||||||
|
result = {
|
||||||
|
"explanation": explanation,
|
||||||
|
"llm_used": True,
|
||||||
|
"llm_error": None,
|
||||||
|
"related_count": len(related),
|
||||||
|
}
|
||||||
|
await set_cached_explain(redis, event_id, result)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Event explanation failed", error=str(exc))
|
||||||
|
return {
|
||||||
|
"explanation": "Unable to generate an explanation at this time. Please check the raw event details.",
|
||||||
|
"llm_used": False,
|
||||||
|
"llm_error": str(exc),
|
||||||
|
"related_count": len(related),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ask", response_model=AskResponse)
|
@router.post("/ask", response_model=AskResponse)
|
||||||
async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
||||||
question = body.question.strip()
|
question = body.question.strip()
|
||||||
@@ -278,6 +712,7 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
|||||||
|
|
||||||
start, end = _extract_time_range(question)
|
start, end = _extract_time_range(question)
|
||||||
entity = _extract_entity(question)
|
entity = _extract_entity(question)
|
||||||
|
intent_services, explicit_noisy = _extract_intent_services(question)
|
||||||
|
|
||||||
# Default to last 7 days if no time range detected
|
# Default to last 7 days if no time range detected
|
||||||
if not start:
|
if not start:
|
||||||
@@ -285,57 +720,157 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
|||||||
start = (now - timedelta(days=7)).isoformat().replace("+00:00", "Z")
|
start = (now - timedelta(days=7)).isoformat().replace("+00:00", "Z")
|
||||||
end = now.isoformat().replace("+00:00", "Z")
|
end = now.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Decide which services to query
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
excluded_services: list[str] = []
|
||||||
|
if body.services:
|
||||||
|
# User explicitly filtered via UI — respect that exactly
|
||||||
|
query_services = body.services
|
||||||
|
elif intent_services is not None:
|
||||||
|
# NL question implies specific services
|
||||||
|
query_services = intent_services
|
||||||
|
else:
|
||||||
|
# Broad question with no intent — exclude noisy services by default
|
||||||
|
query_services = sorted(_DEFAULT_ADMIN_SERVICES)
|
||||||
|
excluded_services = sorted(_NOISY_SERVICES)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Build and run query
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
privacy_excluded_services = [] if user_can_access_privacy_services(user) else list(PRIVACY_SERVICES)
|
||||||
|
privacy_excluded_ops = [] if user_can_access_privacy_services(user) else list(PRIVACY_SENSITIVE_OPERATIONS)
|
||||||
query = _build_event_query(
|
query = _build_event_query(
|
||||||
entity,
|
entity,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
services=body.services,
|
services=query_services,
|
||||||
actor=body.actor,
|
actor=body.actor,
|
||||||
operation=body.operation,
|
operation=body.operation,
|
||||||
result=body.result,
|
result=body.result,
|
||||||
include_tags=body.include_tags,
|
include_tags=body.include_tags,
|
||||||
exclude_tags=body.exclude_tags,
|
exclude_tags=body.exclude_tags,
|
||||||
)
|
)
|
||||||
|
extra_filters = []
|
||||||
|
if privacy_excluded_services:
|
||||||
|
extra_filters.append({"service": {"$nin": privacy_excluded_services}})
|
||||||
|
if privacy_excluded_ops:
|
||||||
|
extra_filters.append({"operation": {"$nin": privacy_excluded_ops}})
|
||||||
|
if extra_filters:
|
||||||
|
query["$and"] = query.get("$and", []) + extra_filters
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cursor = events_collection.find(query).sort([("timestamp", -1)]).limit(LLM_MAX_EVENTS)
|
total = events_collection.count_documents(query)
|
||||||
events = list(cursor)
|
# Fetch a generous window so we can apply smart sampling in Python
|
||||||
|
cursor = events_collection.find(query).sort([("timestamp", -1)]).limit(1000)
|
||||||
|
raw_events = list(cursor)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to query events for ask", error=str(exc))
|
logger.error("Failed to query events for ask", error=str(exc))
|
||||||
raise HTTPException(status_code=500, detail=f"Database query failed: {exc}") from exc
|
raise HTTPException(status_code=500, detail="Database query failed") from exc
|
||||||
|
|
||||||
for e in events:
|
for e in raw_events:
|
||||||
e["_id"] = str(e.get("_id", ""))
|
e["_id"] = str(e.get("_id", ""))
|
||||||
|
|
||||||
|
# Apply smart sampling (preserves failures, prioritises admin-relevant services)
|
||||||
|
events = _smart_sample(raw_events, max_events=LLM_MAX_EVENTS)
|
||||||
|
|
||||||
# If no events, return early
|
# If no events, return early
|
||||||
if not events:
|
if not events:
|
||||||
return AskResponse(
|
return AskResponse(
|
||||||
answer="I couldn't find any audit events matching your question. Try broadening the time range or checking the spelling of the device/user name.",
|
answer="I couldn't find any audit events matching your question. Try broadening the time range or checking the spelling of the device/user name.",
|
||||||
events=[],
|
events=[],
|
||||||
query_info={"entity": entity, "start": start, "end": end, "event_count": 0},
|
query_info={
|
||||||
|
"entity": entity,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"event_count": 0,
|
||||||
|
"total_matched": total,
|
||||||
|
"services_queried": query_services,
|
||||||
|
"excluded_services": excluded_services,
|
||||||
|
},
|
||||||
llm_used=False,
|
llm_used=False,
|
||||||
llm_error="LLM not used — no events found." if not LLM_API_KEY else None,
|
llm_error="LLM not used — no events found." if not LLM_API_KEY else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try LLM summarisation
|
# Try LLM summarisation (with caching + optional async)
|
||||||
answer = ""
|
answer = ""
|
||||||
llm_used = False
|
llm_used = False
|
||||||
llm_error = None
|
llm_error = None
|
||||||
if not LLM_API_KEY:
|
job_id = None
|
||||||
llm_error = "LLM_API_KEY is not configured. Set it in your .env to enable AI narrative summarisation."
|
|
||||||
|
filters_snapshot = {
|
||||||
|
"services": body.services,
|
||||||
|
"actor": body.actor,
|
||||||
|
"operation": body.operation,
|
||||||
|
"result": body.result,
|
||||||
|
"start": body.start,
|
||||||
|
"end": body.end,
|
||||||
|
"include_tags": body.include_tags,
|
||||||
|
"exclude_tags": body.exclude_tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
if LLM_API_KEY:
|
||||||
|
redis = await get_arq_pool()
|
||||||
|
cached = await get_cached_ask(redis, question, filters_snapshot, events)
|
||||||
|
if cached:
|
||||||
|
answer = cached.get("answer", "")
|
||||||
|
llm_used = cached.get("llm_used", False)
|
||||||
|
llm_error = cached.get("llm_error")
|
||||||
|
elif body.async_mode:
|
||||||
|
pool = await get_arq_pool()
|
||||||
|
job = await pool.enqueue_job(
|
||||||
|
"process_ask_question",
|
||||||
|
question,
|
||||||
|
filters_snapshot,
|
||||||
|
events,
|
||||||
|
total,
|
||||||
|
excluded_services,
|
||||||
|
)
|
||||||
|
job_id = job.job_id if job else None
|
||||||
|
return AskResponse(
|
||||||
|
answer="Your question is being processed. Poll /api/jobs/{job_id} for the result.",
|
||||||
|
events=[_to_event_ref(e) for e in events],
|
||||||
|
query_info={
|
||||||
|
"entity": entity,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"event_count": len(events),
|
||||||
|
"total_matched": total,
|
||||||
|
"services_queried": query_services,
|
||||||
|
"excluded_services": excluded_services,
|
||||||
|
},
|
||||||
|
llm_used=False,
|
||||||
|
llm_error=None,
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
answer = await _call_llm(question, events, total=total, excluded_services=excluded_services)
|
||||||
|
llm_used = True
|
||||||
|
await set_cached_ask(
|
||||||
|
redis,
|
||||||
|
question,
|
||||||
|
filters_snapshot,
|
||||||
|
events,
|
||||||
|
{
|
||||||
|
"answer": answer,
|
||||||
|
"llm_used": True,
|
||||||
|
"llm_error": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
llm_error = f"LLM call failed: {exc}"
|
||||||
|
logger.warning("LLM call failed, falling back to structured summary", error=str(exc))
|
||||||
else:
|
else:
|
||||||
try:
|
llm_error = "LLM_API_KEY is not configured. Set it in your .env to enable AI narrative summarisation."
|
||||||
answer = await _call_llm(question, events)
|
|
||||||
llm_used = True
|
|
||||||
except Exception as exc:
|
|
||||||
llm_error = f"LLM call failed: {exc}"
|
|
||||||
logger.warning("LLM call failed, falling back to structured summary", error=str(exc))
|
|
||||||
|
|
||||||
# Fallback: structured summary if LLM unavailable or failed
|
# Fallback: structured summary if LLM unavailable or failed
|
||||||
if not answer:
|
if not answer:
|
||||||
parts = [f"Found {len(events)} event(s)"]
|
parts = [f"Found {total} event(s)"]
|
||||||
if entity:
|
if entity:
|
||||||
parts.append(f"related to **{entity}**")
|
parts.append(f"related to **{entity}**")
|
||||||
|
if excluded_services:
|
||||||
|
parts.append(f"(excluding {', '.join(excluded_services)})")
|
||||||
parts.append(f"between {start[:10]} and {end[:10]}.\n")
|
parts.append(f"between {start[:10]} and {end[:10]}.\n")
|
||||||
|
|
||||||
for i, e in enumerate(events[:10], 1):
|
for i, e in enumerate(events[:10], 1):
|
||||||
@@ -359,8 +894,11 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
|||||||
"start": start,
|
"start": start,
|
||||||
"end": end,
|
"end": end,
|
||||||
"event_count": len(events),
|
"event_count": len(events),
|
||||||
"mongo_query": json.dumps(query, default=str),
|
"total_matched": total,
|
||||||
|
"services_queried": query_services,
|
||||||
|
"excluded_services": excluded_services,
|
||||||
},
|
},
|
||||||
llm_used=llm_used,
|
llm_used=llm_used,
|
||||||
llm_error=llm_error,
|
llm_error=llm_error,
|
||||||
|
job_id=job_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
|
import structlog
|
||||||
from config import (
|
from config import (
|
||||||
|
AI_FEATURES_ENABLED,
|
||||||
AUTH_CLIENT_ID,
|
AUTH_CLIENT_ID,
|
||||||
AUTH_ENABLED,
|
AUTH_ENABLED,
|
||||||
AUTH_SCOPE,
|
AUTH_SCOPE,
|
||||||
AUTH_TENANT_ID,
|
AUTH_TENANT_ID,
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = structlog.get_logger("aoc.config")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config/auth")
|
@router.get("/config/auth")
|
||||||
def auth_config():
|
def auth_config():
|
||||||
|
logger.debug("Auth config requested", auth_enabled=AUTH_ENABLED)
|
||||||
return {
|
return {
|
||||||
"auth_enabled": AUTH_ENABLED,
|
"auth_enabled": AUTH_ENABLED,
|
||||||
"tenant_id": AUTH_TENANT_ID,
|
"tenant_id": AUTH_TENANT_ID if AUTH_ENABLED else "",
|
||||||
"client_id": AUTH_CLIENT_ID,
|
"client_id": AUTH_CLIENT_ID if AUTH_ENABLED else "",
|
||||||
"scope": AUTH_SCOPE,
|
"scope": AUTH_SCOPE,
|
||||||
"redirect_uri": None, # frontend uses window.location.origin by default
|
"redirect_uri": None, # frontend uses window.location.origin by default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/features")
|
||||||
|
def features_config():
|
||||||
|
return {
|
||||||
|
"ai_features_enabled": AI_FEATURES_ENABLED,
|
||||||
|
"default_page_size": DEFAULT_PAGE_SIZE,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import re
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from audit_trail import log_action
|
from audit_trail import log_action
|
||||||
from auth import require_auth
|
from auth import require_auth, user_can_access_privacy_services
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
from config import PRIVACY_SENSITIVE_OPERATIONS, PRIVACY_SERVICES
|
||||||
from database import events_collection
|
from database import events_collection
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from models.api import (
|
from models.api import (
|
||||||
@@ -44,6 +45,7 @@ def _build_query(
|
|||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
include_tags: list[str] | None = None,
|
include_tags: list[str] | None = None,
|
||||||
exclude_tags: list[str] | None = None,
|
exclude_tags: list[str] | None = None,
|
||||||
|
exclude_operations: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
filters = []
|
filters = []
|
||||||
|
|
||||||
@@ -51,6 +53,8 @@ def _build_query(
|
|||||||
filters.append({"service": service})
|
filters.append({"service": service})
|
||||||
if services:
|
if services:
|
||||||
filters.append({"service": {"$in": services}})
|
filters.append({"service": {"$in": services}})
|
||||||
|
if exclude_operations:
|
||||||
|
filters.append({"operation": {"$nin": exclude_operations}})
|
||||||
if actor:
|
if actor:
|
||||||
actor_safe = re.escape(actor)
|
actor_safe = re.escape(actor)
|
||||||
filters.append(
|
filters.append(
|
||||||
@@ -125,6 +129,8 @@ def list_events(
|
|||||||
exclude_tags: list[str] | None = Query(default=None),
|
exclude_tags: list[str] | None = Query(default=None),
|
||||||
user: dict = Depends(require_auth),
|
user: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
|
privacy_excluded_services = [] if user_can_access_privacy_services(user) else list(PRIVACY_SERVICES)
|
||||||
|
privacy_excluded_ops = [] if user_can_access_privacy_services(user) else list(PRIVACY_SENSITIVE_OPERATIONS)
|
||||||
query = _build_query(
|
query = _build_query(
|
||||||
service=service,
|
service=service,
|
||||||
services=services,
|
services=services,
|
||||||
@@ -137,7 +143,13 @@ def list_events(
|
|||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
include_tags=include_tags,
|
include_tags=include_tags,
|
||||||
exclude_tags=exclude_tags,
|
exclude_tags=exclude_tags,
|
||||||
|
exclude_operations=privacy_excluded_ops,
|
||||||
)
|
)
|
||||||
|
if privacy_excluded_services:
|
||||||
|
query = query if query else {}
|
||||||
|
if "$and" not in query:
|
||||||
|
query = {"$and": [query]} if query else {"$and": []}
|
||||||
|
query["$and"].append({"service": {"$nin": privacy_excluded_services}})
|
||||||
|
|
||||||
safe_page_size = max(1, min(page_size, 500))
|
safe_page_size = max(1, min(page_size, 500))
|
||||||
|
|
||||||
@@ -146,7 +158,7 @@ def list_events(
|
|||||||
cursor_query = events_collection.find(query).sort([("timestamp", -1), ("_id", -1)]).limit(safe_page_size)
|
cursor_query = events_collection.find(query).sort([("timestamp", -1), ("_id", -1)]).limit(safe_page_size)
|
||||||
events = list(cursor_query)
|
events = list(cursor_query)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to query events: {exc}") from exc
|
raise HTTPException(status_code=500, detail="Failed to query events") from exc
|
||||||
|
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
if len(events) == safe_page_size:
|
if len(events) == safe_page_size:
|
||||||
@@ -202,6 +214,8 @@ def bulk_tags(
|
|||||||
exclude_tags: list[str] | None = Query(default=None),
|
exclude_tags: list[str] | None = Query(default=None),
|
||||||
user: dict = Depends(require_auth),
|
user: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
|
privacy_excluded_services = [] if user_can_access_privacy_services(user) else list(PRIVACY_SERVICES)
|
||||||
|
privacy_excluded_ops = [] if user_can_access_privacy_services(user) else list(PRIVACY_SENSITIVE_OPERATIONS)
|
||||||
query = _build_query(
|
query = _build_query(
|
||||||
service=service,
|
service=service,
|
||||||
services=services,
|
services=services,
|
||||||
@@ -213,7 +227,13 @@ def bulk_tags(
|
|||||||
search=search,
|
search=search,
|
||||||
include_tags=include_tags,
|
include_tags=include_tags,
|
||||||
exclude_tags=exclude_tags,
|
exclude_tags=exclude_tags,
|
||||||
|
exclude_operations=privacy_excluded_ops,
|
||||||
)
|
)
|
||||||
|
if privacy_excluded_services:
|
||||||
|
query = query if query else {}
|
||||||
|
if "$and" not in query:
|
||||||
|
query = {"$and": [query]} if query else {"$and": []}
|
||||||
|
query["$and"].append({"service": {"$nin": privacy_excluded_services}})
|
||||||
tags = [t.strip() for t in body.tags if t.strip()]
|
tags = [t.strip() for t in body.tags if t.strip()]
|
||||||
if not tags:
|
if not tags:
|
||||||
raise HTTPException(status_code=400, detail="No tags provided")
|
raise HTTPException(status_code=400, detail="No tags provided")
|
||||||
@@ -221,9 +241,17 @@ def bulk_tags(
|
|||||||
update = {"$set": {"tags": tags}} if body.mode == "replace" else {"$addToSet": {"tags": {"$each": tags}}}
|
update = {"$set": {"tags": tags}} if body.mode == "replace" else {"$addToSet": {"tags": {"$each": tags}}}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
matched = events_collection.count_documents(query, limit=10001)
|
||||||
|
if matched > 10000:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Bulk tag update matches too many events (>10000). Narrow your filters.",
|
||||||
|
)
|
||||||
result_obj = events_collection.update_many(query, update)
|
result_obj = events_collection.update_many(query, update)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update tags: {exc}") from exc
|
raise HTTPException(status_code=500, detail="Failed to update tags") from exc
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
"bulk_tags",
|
"bulk_tags",
|
||||||
@@ -235,7 +263,10 @@ def bulk_tags(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/filter-options", response_model=FilterOptionsResponse)
|
@router.get("/filter-options", response_model=FilterOptionsResponse)
|
||||||
def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
def filter_options(
|
||||||
|
limit: int = Query(default=200, ge=1, le=1000),
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
safe_limit = max(1, min(limit, 1000))
|
safe_limit = max(1, min(limit, 1000))
|
||||||
try:
|
try:
|
||||||
services = sorted(events_collection.distinct("service"))[:safe_limit]
|
services = sorted(events_collection.distinct("service"))[:safe_limit]
|
||||||
@@ -245,7 +276,11 @@ def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
|||||||
actor_upns = sorted([a for a in events_collection.distinct("actor_upn") if a])[:safe_limit]
|
actor_upns = sorted([a for a in events_collection.distinct("actor_upn") if a])[:safe_limit]
|
||||||
devices = sorted([a for a in events_collection.distinct("target_displays") if isinstance(a, str)])[:safe_limit]
|
devices = sorted([a for a in events_collection.distinct("target_displays") if isinstance(a, str)])[:safe_limit]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to load filter options: {exc}") from exc
|
raise HTTPException(status_code=500, detail="Failed to load filter options") from exc
|
||||||
|
|
||||||
|
if not user_can_access_privacy_services(user):
|
||||||
|
services = [s for s in services if s not in PRIVACY_SERVICES]
|
||||||
|
operations = [o for o in operations if o not in PRIVACY_SENSITIVE_OPERATIONS]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"services": services,
|
"services": services,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
|
import structlog
|
||||||
from audit_trail import log_action
|
from audit_trail import log_action
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from config import ALERTS_ENABLED
|
from config import ALERTS_ENABLED
|
||||||
@@ -15,6 +16,8 @@ from sources.intune_audit import fetch_intune_audit
|
|||||||
from sources.unified_audit import fetch_unified_audit
|
from sources.unified_audit import fetch_unified_audit
|
||||||
from watermark import get_watermark, set_watermark
|
from watermark import get_watermark, set_watermark
|
||||||
|
|
||||||
|
logger = structlog.get_logger("aoc.fetch")
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
@@ -85,5 +88,8 @@ def fetch_logs(
|
|||||||
user.get("sub", "anonymous"),
|
user.get("sub", "anonymous"),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
logger.error("Fetch failed", error=str(exc))
|
||||||
|
raise HTTPException(status_code=502, detail="Failed to fetch audit logs") from exc
|
||||||
|
|||||||
43
backend/routes/jobs.py
Normal file
43
backend/routes/jobs.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Job status endpoints for async LLM operations."""
|
||||||
|
|
||||||
|
from arq.jobs import Job, JobStatus
|
||||||
|
from auth import require_auth
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from redis_client import get_redis
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatusResponse(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
status: str # queued, in_progress, complete, not_found, deferred
|
||||||
|
result: dict | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}", response_model=JobStatusResponse)
|
||||||
|
async def get_job_status(job_id: str, user: dict = Depends(require_auth)):
|
||||||
|
"""Poll for the result of an async LLM job."""
|
||||||
|
redis = await get_redis()
|
||||||
|
job = Job(job_id, redis)
|
||||||
|
status = await job.status()
|
||||||
|
|
||||||
|
if status == JobStatus.not_found:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
result = None
|
||||||
|
error = None
|
||||||
|
if status == JobStatus.complete:
|
||||||
|
try:
|
||||||
|
result_data = await job.result(timeout=0)
|
||||||
|
result = result_data if isinstance(result_data, dict) else {"data": str(result_data)}
|
||||||
|
except Exception as exc:
|
||||||
|
error = str(exc)
|
||||||
|
|
||||||
|
return JobStatusResponse(
|
||||||
|
job_id=job_id,
|
||||||
|
status=status.value,
|
||||||
|
result=result,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
124
backend/routes/mcp.py
Normal file
124
backend/routes/mcp.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""MCP server over SSE (HTTP) transport, mounted inside FastAPI with OIDC auth."""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from auth import (
|
||||||
|
AUTH_ALLOWED_GROUPS,
|
||||||
|
AUTH_ALLOWED_ROLES,
|
||||||
|
AUTH_ENABLED,
|
||||||
|
_allowed,
|
||||||
|
_decode_token,
|
||||||
|
_get_jwks,
|
||||||
|
)
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.sse import SseServerTransport
|
||||||
|
from mcp.types import TextContent, Tool
|
||||||
|
from mcp_common import (
|
||||||
|
ASK_SCHEMA,
|
||||||
|
GET_EVENT_SCHEMA,
|
||||||
|
GET_SUMMARY_SCHEMA,
|
||||||
|
SEARCH_EVENTS_SCHEMA,
|
||||||
|
handle_ask,
|
||||||
|
handle_get_event,
|
||||||
|
handle_get_summary,
|
||||||
|
handle_search_events,
|
||||||
|
)
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
logger = structlog.get_logger("aoc.mcp")
|
||||||
|
|
||||||
|
mcp_app = Server("aoc")
|
||||||
|
transport = SseServerTransport("/messages/")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp_app.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
return [
|
||||||
|
Tool(
|
||||||
|
name="search_events",
|
||||||
|
description="Search audit events by entity, service, operation, or result.",
|
||||||
|
inputSchema=SEARCH_EVENTS_SCHEMA,
|
||||||
|
),
|
||||||
|
Tool(name="get_event", description="Retrieve a single audit event by its ID.", inputSchema=GET_EVENT_SCHEMA),
|
||||||
|
Tool(
|
||||||
|
name="get_summary",
|
||||||
|
description="Get an aggregated summary of audit activity for the last N days.",
|
||||||
|
inputSchema=GET_SUMMARY_SCHEMA,
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="ask",
|
||||||
|
description="Ask a natural language question about audit logs. Returns a narrative answer.",
|
||||||
|
inputSchema=ASK_SCHEMA,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp_app.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
|
if name == "search_events":
|
||||||
|
return await handle_search_events(arguments)
|
||||||
|
if name == "get_event":
|
||||||
|
return await handle_get_event(arguments)
|
||||||
|
if name == "get_summary":
|
||||||
|
return await handle_get_summary(arguments)
|
||||||
|
if name == "ask":
|
||||||
|
return await handle_ask(arguments)
|
||||||
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_auth(request: Request) -> dict | None:
|
||||||
|
"""Validate Bearer token. Returns claims dict or None on failure."""
|
||||||
|
if not AUTH_ENABLED:
|
||||||
|
return {"sub": "anonymous"}
|
||||||
|
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if not auth_header or not auth_header.lower().startswith("bearer "):
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = auth_header.split(" ", 1)[1]
|
||||||
|
try:
|
||||||
|
jwks = _get_jwks()
|
||||||
|
claims = _decode_token(token, jwks)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("MCP auth failed", error=str(exc))
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS):
|
||||||
|
logger.warning("MCP auth forbidden", sub=claims.get("sub"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
async def mcp_asgi(scope: dict, receive, send):
|
||||||
|
"""ASGI application for MCP over SSE, mounted under /mcp in FastAPI."""
|
||||||
|
if scope["type"] != "http":
|
||||||
|
return
|
||||||
|
|
||||||
|
request = Request(scope, receive)
|
||||||
|
|
||||||
|
# Auth check
|
||||||
|
claims = await _validate_auth(request)
|
||||||
|
if claims is None:
|
||||||
|
response = Response("Unauthorized", status_code=401)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
path = scope.get("path", "")
|
||||||
|
root_path = scope.get("root_path", "")
|
||||||
|
relative_path = path[len(root_path) :] if path.startswith(root_path) else path
|
||||||
|
method = scope.get("method", "")
|
||||||
|
|
||||||
|
if relative_path == "/sse" and method == "GET":
|
||||||
|
logger.info("MCP SSE connection established", sub=claims.get("sub", "unknown"))
|
||||||
|
async with transport.connect_sse(scope, receive, send) as (read_stream, write_stream):
|
||||||
|
await mcp_app.run(
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
mcp_app.create_initialization_options(),
|
||||||
|
)
|
||||||
|
elif relative_path == "/messages/" and method == "POST":
|
||||||
|
await transport.handle_post_message(scope, receive, send)
|
||||||
|
else:
|
||||||
|
response = Response("Not found", status_code=404)
|
||||||
|
await response(scope, receive, send)
|
||||||
60
backend/routes/saved_searches.py
Normal file
60
backend/routes/saved_searches.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""CRUD for saved filter searches (bookmarks)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from auth import require_auth
|
||||||
|
from database import saved_searches_collection
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||||
|
logger = structlog.get_logger("aoc.saved_searches")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_sub(user: dict) -> str:
|
||||||
|
return user.get("sub", "anonymous")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/saved-searches")
|
||||||
|
async def list_saved_searches(user: dict = Depends(require_auth)):
|
||||||
|
"""Return saved searches for the current user."""
|
||||||
|
sub = _user_sub(user)
|
||||||
|
cursor = saved_searches_collection.find({"created_by": sub}).sort("created_at", -1)
|
||||||
|
items = []
|
||||||
|
for doc in cursor:
|
||||||
|
doc["id"] = doc.pop("_id")
|
||||||
|
items.append(doc)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/saved-searches")
|
||||||
|
async def create_saved_search(body: dict, 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")
|
||||||
|
|
||||||
|
filters = body.get("filters") or {}
|
||||||
|
doc = {
|
||||||
|
"_id": str(uuid.uuid4()),
|
||||||
|
"name": name,
|
||||||
|
"filters": filters,
|
||||||
|
"created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||||
|
"created_by": _user_sub(user),
|
||||||
|
}
|
||||||
|
saved_searches_collection.insert_one(doc)
|
||||||
|
logger.info("Saved search created", name=name, user=doc["created_by"])
|
||||||
|
doc["id"] = doc.pop("_id")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/saved-searches/{search_id}")
|
||||||
|
async def delete_saved_search(search_id: str, user: dict = Depends(require_auth)):
|
||||||
|
"""Delete a saved search (only if owned by current user)."""
|
||||||
|
sub = _user_sub(user)
|
||||||
|
result = saved_searches_collection.delete_one({"_id": search_id, "created_by": sub})
|
||||||
|
if result.deleted_count == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Saved search not found")
|
||||||
|
logger.info("Saved search deleted", search_id=search_id, user=sub)
|
||||||
|
return {"status": "deleted"}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import structlog
|
import structlog
|
||||||
|
from config import WEBHOOK_CLIENT_SECRET
|
||||||
from fastapi import APIRouter, Request, Response
|
from fastapi import APIRouter, Request, Response
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -10,10 +11,21 @@ async def graph_webhook(request: Request):
|
|||||||
"""
|
"""
|
||||||
Receive Microsoft Graph change notifications.
|
Receive Microsoft Graph change notifications.
|
||||||
Handles the validation handshake by echoing validationToken.
|
Handles the validation handshake by echoing validationToken.
|
||||||
|
Validates clientState on notifications to prevent spoofing.
|
||||||
"""
|
"""
|
||||||
validation_token = request.query_params.get("validationToken")
|
validation_token = request.query_params.get("validationToken")
|
||||||
if validation_token:
|
if validation_token:
|
||||||
return Response(content=validation_token, media_type="text/plain")
|
# Microsoft sends validationToken as a query param during subscription creation.
|
||||||
|
# Echo it back as plain text to prove endpoint ownership.
|
||||||
|
# Validate to prevent content injection if endpoint is hit directly.
|
||||||
|
if len(validation_token) > 1024 or not validation_token.isascii():
|
||||||
|
logger.warning("Invalid validationToken rejected", length=len(validation_token))
|
||||||
|
return Response(status_code=400)
|
||||||
|
return Response(
|
||||||
|
content=validation_token,
|
||||||
|
media_type="text/plain",
|
||||||
|
headers={"X-Content-Type-Options": "nosniff"},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -21,12 +33,26 @@ async def graph_webhook(request: Request):
|
|||||||
logger.warning("Invalid webhook payload", error=str(exc))
|
logger.warning("Invalid webhook payload", error=str(exc))
|
||||||
return Response(status_code=400)
|
return Response(status_code=400)
|
||||||
|
|
||||||
for notification in body.get("value", []):
|
notifications = body.get("value", [])
|
||||||
|
if not isinstance(notifications, list):
|
||||||
|
logger.warning("Invalid webhook payload structure")
|
||||||
|
return Response(status_code=400)
|
||||||
|
|
||||||
|
for notification in notifications:
|
||||||
|
client_state = notification.get("clientState")
|
||||||
|
if WEBHOOK_CLIENT_SECRET and client_state != WEBHOOK_CLIENT_SECRET:
|
||||||
|
logger.warning(
|
||||||
|
"Graph webhook rejected: invalid clientState",
|
||||||
|
change_type=notification.get("changeType"),
|
||||||
|
resource=notification.get("resource"),
|
||||||
|
)
|
||||||
|
return Response(status_code=401)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Received Graph notification",
|
"Received Graph notification",
|
||||||
change_type=notification.get("changeType"),
|
change_type=notification.get("changeType"),
|
||||||
resource=notification.get("resource"),
|
resource=notification.get("resource"),
|
||||||
client_state=notification.get("clientState"),
|
client_state=client_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "accepted"}
|
return {"status": "accepted"}
|
||||||
|
|||||||
205
backend/rules.py
205
backend/rules.py
@@ -1,7 +1,18 @@
|
|||||||
from datetime import UTC, datetime
|
"""Rule-based alerting for admin operations.
|
||||||
|
|
||||||
|
Rules are evaluated during event ingestion. Triggered alerts are stored in MongoDB
|
||||||
|
and optionally forwarded to a notification channel (webhook, Slack, Teams).
|
||||||
|
|
||||||
|
Deduplication: the same rule firing for the same actor within ALERT_DEDUPE_MINUTES
|
||||||
|
produces only one alert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from config import ALERT_DEDUPE_MINUTES, ALERT_WEBHOOK_FORMAT, ALERT_WEBHOOK_URL
|
||||||
from database import db
|
from database import db
|
||||||
|
from pymongo import ASCENDING
|
||||||
|
|
||||||
logger = structlog.get_logger("aoc.rules")
|
logger = structlog.get_logger("aoc.rules")
|
||||||
rules_collection = db["alert_rules"]
|
rules_collection = db["alert_rules"]
|
||||||
@@ -18,6 +29,13 @@ def evaluate_event(event: dict) -> list[dict]:
|
|||||||
rules = load_rules()
|
rules = load_rules()
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
if _matches(rule, event):
|
if _matches(rule, event):
|
||||||
|
if _is_duplicate(rule, event):
|
||||||
|
logger.debug(
|
||||||
|
"Alert deduplicated",
|
||||||
|
rule=rule.get("name"),
|
||||||
|
event_id=event.get("id"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
triggered.append(rule)
|
triggered.append(rule)
|
||||||
_create_alert(rule, event)
|
_create_alert(rule, event)
|
||||||
return triggered
|
return triggered
|
||||||
@@ -50,6 +68,9 @@ def _matches(rule: dict, event: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
if op == "threshold_count":
|
||||||
|
# Threshold rules are evaluated at query time, not per-event
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +85,22 @@ def _get_nested(obj: dict, path: str):
|
|||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate(rule: dict, event: dict) -> bool:
|
||||||
|
"""Check if an alert for this rule + actor was recently created."""
|
||||||
|
if ALERT_DEDUPE_MINUTES <= 0:
|
||||||
|
return False
|
||||||
|
cutoff = (datetime.now(UTC) - timedelta(minutes=ALERT_DEDUPE_MINUTES)).isoformat()
|
||||||
|
actor = event.get("actor_display") or event.get("actor_upn") or "unknown"
|
||||||
|
query = {
|
||||||
|
"rule_id": str(rule.get("_id")),
|
||||||
|
"actor": actor,
|
||||||
|
"timestamp": {"$gte": cutoff},
|
||||||
|
}
|
||||||
|
return alerts_collection.count_documents(query, limit=1) > 0
|
||||||
|
|
||||||
|
|
||||||
def _create_alert(rule: dict, event: dict):
|
def _create_alert(rule: dict, event: dict):
|
||||||
|
actor = event.get("actor_display") or event.get("actor_upn") or "unknown"
|
||||||
alert = {
|
alert = {
|
||||||
"timestamp": datetime.now(UTC).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"rule_id": str(rule.get("_id")),
|
"rule_id": str(rule.get("_id")),
|
||||||
@@ -72,10 +108,177 @@ def _create_alert(rule: dict, event: dict):
|
|||||||
"severity": rule.get("severity", "medium"),
|
"severity": rule.get("severity", "medium"),
|
||||||
"event_id": event.get("id"),
|
"event_id": event.get("id"),
|
||||||
"event_dedupe_key": event.get("dedupe_key"),
|
"event_dedupe_key": event.get("dedupe_key"),
|
||||||
|
"actor": actor,
|
||||||
"message": rule.get("message", f"Rule '{rule.get('name')}' triggered"),
|
"message": rule.get("message", f"Rule '{rule.get('name')}' triggered"),
|
||||||
|
"status": "open", # open | acknowledged | resolved | false_positive
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
alerts_collection.insert_one(alert)
|
alerts_collection.insert_one(alert)
|
||||||
logger.info("Alert created", rule=rule.get("name"), event_id=event.get("id"))
|
logger.info("Alert created", rule=rule.get("name"), event_id=event.get("id"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to create alert", error=str(exc))
|
logger.warning("Failed to create alert", error=str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send notification
|
||||||
|
if ALERT_WEBHOOK_URL:
|
||||||
|
try:
|
||||||
|
from notifications import send_notification
|
||||||
|
|
||||||
|
send_notification(
|
||||||
|
webhook_url=ALERT_WEBHOOK_URL,
|
||||||
|
format_type=ALERT_WEBHOOK_FORMAT,
|
||||||
|
rule_name=rule.get("name", "Unnamed rule"),
|
||||||
|
severity=rule.get("severity", "medium"),
|
||||||
|
message=rule.get("message", ""),
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to send notification", error=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def seed_default_rules():
|
||||||
|
"""Upsert pre-built admin-ops rule templates. Safe for concurrent startup."""
|
||||||
|
# One-time cleanup: remove duplicates by name, keep the oldest (_id ascending)
|
||||||
|
pipeline = [
|
||||||
|
{"$sort": {"_id": ASCENDING}},
|
||||||
|
{"$group": {"_id": "$name", "first_id": {"$first": "$_id"}}},
|
||||||
|
]
|
||||||
|
seen = {doc["_id"]: doc["first_id"] for doc in rules_collection.aggregate(pipeline)}
|
||||||
|
for name, keep_id in seen.items():
|
||||||
|
rules_collection.delete_many({"name": name, "_id": {"$ne": keep_id}})
|
||||||
|
|
||||||
|
defaults = [
|
||||||
|
{
|
||||||
|
"name": "Failed Conditional Access",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "high",
|
||||||
|
"message": (
|
||||||
|
"A Conditional Access policy evaluation failed. "
|
||||||
|
"This may indicate a sign-in risk or policy misconfiguration."
|
||||||
|
),
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "eq", "value": "Directory"},
|
||||||
|
{"field": "operation", "op": "contains", "value": "ConditionalAccess"},
|
||||||
|
{"field": "result", "op": "neq", "value": "success"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "After-Hours Admin Activity",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "medium",
|
||||||
|
"message": "A privileged operation was performed outside business hours (9 AM – 5 PM).",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"field": "service",
|
||||||
|
"op": "in",
|
||||||
|
"value": ["Directory", "UserManagement", "GroupManagement", "RoleManagement"],
|
||||||
|
},
|
||||||
|
{"field": "timestamp", "op": "after_hours"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "New Application Registration",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "medium",
|
||||||
|
"message": (
|
||||||
|
"A new application was registered in Entra ID. Review for shadow IT or unauthorized integrations."
|
||||||
|
),
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "eq", "value": "ApplicationManagement"},
|
||||||
|
{"field": "operation", "op": "contains", "value": "Add application"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Admin Role Assignment",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "high",
|
||||||
|
"message": "A user was assigned an administrative role. Verify this was expected and authorized.",
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "eq", "value": "RoleManagement"},
|
||||||
|
{"field": "operation", "op": "contains", "value": "Add member to role"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "License Change",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "low",
|
||||||
|
"message": "A license was assigned or removed from a user. Monitor for unexpected cost changes.",
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "eq", "value": "License"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bulk User Deletion",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "high",
|
||||||
|
"message": (
|
||||||
|
"Multiple users were deleted in a short window. "
|
||||||
|
"This may indicate a compromised admin account or cleanup activity."
|
||||||
|
),
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "in", "value": ["Directory", "UserManagement"]},
|
||||||
|
{"field": "operation", "op": "contains", "value": "Delete user"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Device Compliance Failure",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "medium",
|
||||||
|
"message": (
|
||||||
|
"A device failed compliance evaluation. "
|
||||||
|
"It may no longer meet your organization's security requirements."
|
||||||
|
),
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "eq", "value": "Intune"},
|
||||||
|
{"field": "operation", "op": "contains", "value": "compliance"},
|
||||||
|
{"field": "result", "op": "neq", "value": "success"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Exchange Transport Rule Change",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "high",
|
||||||
|
"message": "An Exchange transport rule was modified. This could affect mail flow or security filtering.",
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "eq", "value": "Exchange"},
|
||||||
|
{"field": "operation", "op": "contains", "value": "Transport rule"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Service Principal Credential Added",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "high",
|
||||||
|
"message": "A new secret or certificate was added to a service principal. Verify this was expected.",
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "eq", "value": "ApplicationManagement"},
|
||||||
|
{"field": "operation", "op": "contains", "value": "Add service principal credentials"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "External Sharing Enabled",
|
||||||
|
"enabled": True,
|
||||||
|
"severity": "medium",
|
||||||
|
"message": (
|
||||||
|
"External sharing settings were modified on a SharePoint site or team. Review for data exposure risk."
|
||||||
|
),
|
||||||
|
"conditions": [
|
||||||
|
{"field": "service", "op": "in", "value": ["SharePoint", "Teams"]},
|
||||||
|
{"field": "operation", "op": "contains", "value": "Sharing"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
for rule in defaults:
|
||||||
|
try:
|
||||||
|
result = rules_collection.replace_one(
|
||||||
|
{"name": rule["name"]},
|
||||||
|
rule,
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
if result.upserted_id:
|
||||||
|
inserted += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to seed rule", rule=rule["name"], error=str(exc))
|
||||||
|
if inserted:
|
||||||
|
logger.info("Default admin-ops rules seeded", inserted=inserted, total=len(defaults))
|
||||||
|
|||||||
76
backend/secrets_manager.py
Normal file
76
backend/secrets_manager.py
Normal 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()),
|
||||||
|
)
|
||||||
@@ -1,15 +1,43 @@
|
|||||||
|
import ipaddress
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import structlog
|
import structlog
|
||||||
from config import SIEM_ENABLED, SIEM_WEBHOOK_URL
|
from config import SIEM_ALLOWED_DOMAINS, SIEM_ENABLED, SIEM_WEBHOOK_URL
|
||||||
|
|
||||||
logger = structlog.get_logger("aoc.siem")
|
logger = structlog.get_logger("aoc.siem")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_siem_url(url: str):
|
||||||
|
"""Prevent SSRF by rejecting internal/reserved addresses and enforcing domain allowlist."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
raise RuntimeError("SIEM_WEBHOOK_URL must use HTTPS")
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
if not hostname:
|
||||||
|
raise RuntimeError("SIEM_WEBHOOK_URL must have a valid hostname")
|
||||||
|
blocked = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "169.254.169.254"}
|
||||||
|
if hostname in blocked:
|
||||||
|
raise RuntimeError(f"SIEM_WEBHOOK_URL hostname '{hostname}' is not allowed")
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(hostname)
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
|
raise RuntimeError(f"SIEM_WEBHOOK_URL IP '{hostname}' is not allowed")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if SIEM_ALLOWED_DOMAINS:
|
||||||
|
allowed = any(hostname == d or (d.startswith("*.") and hostname.endswith(d[1:])) for d in SIEM_ALLOWED_DOMAINS)
|
||||||
|
if not allowed:
|
||||||
|
raise RuntimeError(f"SIEM_WEBHOOK_URL domain '{hostname}' is not in SIEM_ALLOWED_DOMAINS")
|
||||||
|
|
||||||
|
|
||||||
def forward_event(event: dict):
|
def forward_event(event: dict):
|
||||||
"""Forward a normalized event to the configured SIEM webhook."""
|
"""Forward a normalized event to the configured SIEM webhook."""
|
||||||
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
|
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
_validate_siem_url(SIEM_WEBHOOK_URL)
|
||||||
res = requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
|
res = requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
logger.debug("Event forwarded to SIEM", event_id=event.get("id"))
|
logger.debug("Event forwarded to SIEM", event_id=event.get("id"))
|
||||||
|
|||||||
@@ -22,14 +22,23 @@ def mock_watermarks_collection():
|
|||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
||||||
monkeypatch.setattr("database.events_collection", mock_events_collection)
|
monkeypatch.setattr("database.events_collection", mock_events_collection)
|
||||||
|
monkeypatch.setattr("database.saved_searches_collection", mock_events_collection)
|
||||||
monkeypatch.setattr("routes.fetch.events_collection", mock_events_collection)
|
monkeypatch.setattr("routes.fetch.events_collection", mock_events_collection)
|
||||||
monkeypatch.setattr("routes.events.events_collection", mock_events_collection)
|
monkeypatch.setattr("routes.events.events_collection", mock_events_collection)
|
||||||
monkeypatch.setattr("routes.ask.events_collection", mock_events_collection)
|
monkeypatch.setattr("routes.ask.events_collection", mock_events_collection)
|
||||||
|
monkeypatch.setattr("routes.saved_searches.saved_searches_collection", mock_events_collection)
|
||||||
monkeypatch.setattr("watermark.watermarks_collection", mock_watermarks_collection)
|
monkeypatch.setattr("watermark.watermarks_collection", mock_watermarks_collection)
|
||||||
monkeypatch.setattr("routes.health.watermarks_collection", mock_watermarks_collection)
|
monkeypatch.setattr("routes.health.watermarks_collection", mock_watermarks_collection)
|
||||||
monkeypatch.setattr("routes.fetch.get_watermark", lambda source: None)
|
monkeypatch.setattr("routes.fetch.get_watermark", lambda source: None)
|
||||||
monkeypatch.setattr("routes.fetch.set_watermark", lambda source, ts: None)
|
monkeypatch.setattr("routes.fetch.set_watermark", lambda source, ts: None)
|
||||||
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
||||||
|
monkeypatch.setattr("routes.mcp.AUTH_ENABLED", False)
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SERVICES", set())
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SENSITIVE_OPERATIONS", set())
|
||||||
|
monkeypatch.setattr("routes.events.PRIVACY_SERVICES", set())
|
||||||
|
monkeypatch.setattr("routes.events.PRIVACY_SENSITIVE_OPERATIONS", set())
|
||||||
|
monkeypatch.setattr("routes.ask.PRIVACY_SERVICES", set())
|
||||||
|
monkeypatch.setattr("routes.ask.PRIVACY_SENSITIVE_OPERATIONS", set())
|
||||||
monkeypatch.setattr("database.db.command", lambda cmd: {"ok": 1} if cmd == "ping" else {})
|
monkeypatch.setattr("database.db.command", lambda cmd: {"ok": 1} if cmd == "ping" else {})
|
||||||
|
|
||||||
# Mock audit trail and rules collections so tests don't wait on real MongoDB
|
# Mock audit trail and rules collections so tests don't wait on real MongoDB
|
||||||
@@ -40,6 +49,35 @@ def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
|||||||
monkeypatch.setattr("rules.rules_collection", audit_db["alert_rules"])
|
monkeypatch.setattr("rules.rules_collection", audit_db["alert_rules"])
|
||||||
monkeypatch.setattr("routes.rules.rules_collection", audit_db["alert_rules"])
|
monkeypatch.setattr("routes.rules.rules_collection", audit_db["alert_rules"])
|
||||||
|
|
||||||
|
# Mock Redis so tests don't require a running Redis server
|
||||||
|
class FakeRedis:
|
||||||
|
_store = {}
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
return self._store.get(key)
|
||||||
|
|
||||||
|
async def setex(self, key, ttl, value):
|
||||||
|
self._store[key] = value
|
||||||
|
|
||||||
|
async def incr(self, key):
|
||||||
|
self._store[key] = self._store.get(key, 0) + 1
|
||||||
|
return self._store[key]
|
||||||
|
|
||||||
|
async def expire(self, key, ttl):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def fake_get_arq_pool():
|
||||||
|
return FakeRedis()
|
||||||
|
|
||||||
|
async def fake_get_redis():
|
||||||
|
return FakeRedis()
|
||||||
|
|
||||||
|
monkeypatch.setattr("redis_client.get_arq_pool", fake_get_arq_pool)
|
||||||
|
monkeypatch.setattr("redis_client.get_redis", fake_get_redis)
|
||||||
|
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||||
|
monkeypatch.setattr("routes.jobs.get_redis", fake_get_redis)
|
||||||
|
monkeypatch.setattr("rate_limiter.get_redis", fake_get_redis)
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|||||||
@@ -1,6 +1,264 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_features(client):
|
||||||
|
response = client.get("/api/config/features")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "ai_features_enabled" in data
|
||||||
|
assert isinstance(data["ai_features_enabled"], bool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_disabled_when_ai_features_off():
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
code = """
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
import os
|
||||||
|
os.environ['AI_FEATURES_ENABLED'] = 'false'
|
||||||
|
|
||||||
|
# Re-import config with the env override
|
||||||
|
import importlib
|
||||||
|
import config
|
||||||
|
importlib.reload(config)
|
||||||
|
|
||||||
|
# Now import main; it will pick up the new AI_FEATURES_ENABLED
|
||||||
|
import main
|
||||||
|
ask_paths = [r.path for r in main.app.routes if hasattr(r, 'path') and 'ask' in r.path]
|
||||||
|
print('ASK_PATHS:', ask_paths)
|
||||||
|
assert len(ask_paths) == 0, f"Expected no ask routes, found: {ask_paths}"
|
||||||
|
print('OK')
|
||||||
|
"""
|
||||||
|
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=".")
|
||||||
|
assert result.returncode == 0, f"Subprocess failed: {result.stdout}\n{result.stderr}"
|
||||||
|
assert "OK" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_sse_mount_exists():
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
mcp_mounts = [r for r in app.routes if getattr(r, "path", "") == "/mcp"]
|
||||||
|
assert len(mcp_mounts) == 1, "MCP mount not found in app routes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_messages_no_session(client):
|
||||||
|
response = client.post("/mcp/messages/")
|
||||||
|
# MCP transport returns 400 when session_id is missing, 404 when session not found
|
||||||
|
assert response.status_code in (400, 404)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_sse_auth_required_when_enabled(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("routes.mcp.AUTH_ENABLED", True)
|
||||||
|
response = client.get("/mcp/sse")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_event_not_found(client):
|
||||||
|
response = client.post("/api/events/nonexistent/explain")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_event_no_llm_key(client, mock_events_collection, monkeypatch):
|
||||||
|
monkeypatch.setattr("routes.ask.LLM_API_KEY", "")
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-explain",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Add user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = client.post("/api/events/evt-explain/explain")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "explanation" in data
|
||||||
|
assert data["llm_used"] is False
|
||||||
|
assert "LLM_API_KEY" in (data.get("llm_error") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_event_with_llm_mock(client, mock_events_collection, monkeypatch):
|
||||||
|
monkeypatch.setattr("routes.ask.LLM_API_KEY", "test-key")
|
||||||
|
|
||||||
|
async def fake_explain(event, related):
|
||||||
|
return "This is a test explanation."
|
||||||
|
|
||||||
|
monkeypatch.setattr("routes.ask._explain_event", fake_explain)
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
async def get(self, key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def setex(self, key, ttl, value):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def fake_get_arq_pool():
|
||||||
|
return FakeRedis()
|
||||||
|
|
||||||
|
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||||
|
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-explain2",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Add user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = client.post("/api/events/evt-explain2/explain")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["explanation"] == "This is a test explanation."
|
||||||
|
assert data["llm_used"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_crud(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
||||||
|
|
||||||
|
# Create
|
||||||
|
response = client.post(
|
||||||
|
"/api/saved-searches", json={"name": "Test search", "filters": {"actor": "alice", "result": "success"}}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
created = response.json()
|
||||||
|
assert created["name"] == "Test search"
|
||||||
|
assert created["filters"]["actor"] == "alice"
|
||||||
|
search_id = created["id"]
|
||||||
|
|
||||||
|
# List
|
||||||
|
response2 = client.get("/api/saved-searches")
|
||||||
|
assert response2.status_code == 200
|
||||||
|
items = response2.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["name"] == "Test search"
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
response3 = client.delete(f"/api/saved-searches/{search_id}")
|
||||||
|
assert response3.status_code == 200
|
||||||
|
|
||||||
|
# List empty
|
||||||
|
response4 = client.get("/api/saved-searches")
|
||||||
|
assert response4.status_code == 200
|
||||||
|
assert len(response4.json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_delete_not_found(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
||||||
|
response = client.delete("/api/saved-searches/nonexistent")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_filtering_events_by_operation(client, mock_events_collection, monkeypatch):
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SENSITIVE_OPERATIONS", {"MailItemsAccessed", "Send"})
|
||||||
|
monkeypatch.setattr("routes.events.PRIVACY_SENSITIVE_OPERATIONS", {"MailItemsAccessed", "Send"})
|
||||||
|
monkeypatch.setattr("auth.PRIVACY_SERVICE_ROLES", {"SecurityAdmin"})
|
||||||
|
monkeypatch.setattr("auth.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
monkeypatch.setattr("routes.events.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-safe",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Add-MailboxPermission",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-priv",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Send",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Bob",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/events")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
ids = [e["id"] for e in data["items"]]
|
||||||
|
assert "evt-safe" in ids
|
||||||
|
assert "evt-priv" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_filter_options_shows_service_hides_ops(client, mock_events_collection, monkeypatch):
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SENSITIVE_OPERATIONS", {"MailItemsAccessed"})
|
||||||
|
monkeypatch.setattr("routes.events.PRIVACY_SENSITIVE_OPERATIONS", {"MailItemsAccessed"})
|
||||||
|
monkeypatch.setattr("auth.PRIVACY_SERVICE_ROLES", {"SecurityAdmin"})
|
||||||
|
monkeypatch.setattr("auth.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
monkeypatch.setattr("routes.events.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-1",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "MailItemsAccessed",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-2",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Add-MailboxPermission",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Bob",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/filter-options")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "Exchange" in data["services"]
|
||||||
|
assert "MailItemsAccessed" not in data["operations"]
|
||||||
|
assert "Add-MailboxPermission" in data["operations"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_explain_forbidden_by_operation(client, mock_events_collection, monkeypatch):
|
||||||
|
monkeypatch.setattr("config.PRIVACY_SENSITIVE_OPERATIONS", {"Send"})
|
||||||
|
monkeypatch.setattr("routes.ask.PRIVACY_SENSITIVE_OPERATIONS", {"Send"})
|
||||||
|
monkeypatch.setattr("auth.PRIVACY_SERVICE_ROLES", {"SecurityAdmin"})
|
||||||
|
monkeypatch.setattr("auth.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
monkeypatch.setattr("routes.ask.user_can_access_privacy_services", lambda claims: False)
|
||||||
|
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-send",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Send",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Bob",
|
||||||
|
"raw_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = client.post("/api/events/evt-send/explain")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_health(client):
|
def test_health(client):
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -10,7 +268,7 @@ def test_health(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_metrics(client):
|
def test_metrics(client):
|
||||||
response = client.get("/metrics")
|
response = client.get("/metrics", headers={"X-Forwarded-For": "127.0.0.1"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "aoc_request_duration_seconds" in response.text
|
assert "aoc_request_duration_seconds" in response.text
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import asyncio
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from jobs import set_cached_ask
|
||||||
from routes.ask import _build_event_query, _extract_entity, _extract_time_range
|
from routes.ask import _build_event_query, _extract_entity, _extract_time_range
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -236,7 +238,7 @@ class TestAskEndpoint:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fake_llm(question, events):
|
async def fake_llm(question, events, total=None, excluded_services=None):
|
||||||
return "The device had a failed wipe attempt."
|
return "The device had a failed wipe attempt."
|
||||||
|
|
||||||
monkeypatch.setattr("routes.ask.LLM_API_KEY", "fake-key")
|
monkeypatch.setattr("routes.ask.LLM_API_KEY", "fake-key")
|
||||||
@@ -265,7 +267,7 @@ class TestAskEndpoint:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def failing_llm(question, events):
|
async def failing_llm(question, events, total=None):
|
||||||
raise RuntimeError("LLM service down")
|
raise RuntimeError("LLM service down")
|
||||||
|
|
||||||
monkeypatch.setattr("routes.ask.LLM_API_KEY", "fake-key")
|
monkeypatch.setattr("routes.ask.LLM_API_KEY", "fake-key")
|
||||||
@@ -350,3 +352,131 @@ class TestAskEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["query_info"]["event_count"] == 1
|
assert data["query_info"]["event_count"] == 1
|
||||||
assert data["events"][0]["id"] == "evt-bob"
|
assert data["events"][0]["id"] == "evt-bob"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAskCaching:
|
||||||
|
def test_ask_cache_hit_returns_cached_answer(self, client, mock_events_collection, monkeypatch):
|
||||||
|
"""If the answer is cached, the LLM should not be called."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-cache",
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Add user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"target_displays": ["USER-001"],
|
||||||
|
"display_summary": "summary",
|
||||||
|
"raw_text": "raw",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
llm_called = False
|
||||||
|
|
||||||
|
async def fake_llm(question, events, total=None, excluded_services=None):
|
||||||
|
nonlocal llm_called
|
||||||
|
llm_called = True
|
||||||
|
return "This should NOT appear."
|
||||||
|
|
||||||
|
monkeypatch.setattr("routes.ask.LLM_API_KEY", "fake-key")
|
||||||
|
monkeypatch.setattr("routes.ask._call_llm", fake_llm)
|
||||||
|
|
||||||
|
# Pre-populate cache with a specific answer
|
||||||
|
class CachingFakeRedis:
|
||||||
|
def __init__(self):
|
||||||
|
self.store = {}
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
return self.store.get(key)
|
||||||
|
|
||||||
|
async def setex(self, key, ttl, value):
|
||||||
|
self.store[key] = value
|
||||||
|
|
||||||
|
redis = CachingFakeRedis()
|
||||||
|
# Seed cache with the exact filters the endpoint will generate
|
||||||
|
filters_snapshot = {
|
||||||
|
"services": None,
|
||||||
|
"actor": None,
|
||||||
|
"operation": None,
|
||||||
|
"result": None,
|
||||||
|
"start": None,
|
||||||
|
"end": None,
|
||||||
|
"include_tags": None,
|
||||||
|
"exclude_tags": None,
|
||||||
|
}
|
||||||
|
asyncio.run(
|
||||||
|
set_cached_ask(
|
||||||
|
redis,
|
||||||
|
"What happened to USER-001?",
|
||||||
|
filters_snapshot,
|
||||||
|
[{"id": "evt-cache"}],
|
||||||
|
{"answer": "Cached answer!", "llm_used": True, "llm_error": None},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_get_arq_pool():
|
||||||
|
return redis
|
||||||
|
|
||||||
|
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||||
|
|
||||||
|
response = client.post("/api/ask", json={"question": "What happened to USER-001?"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["answer"] == "Cached answer!"
|
||||||
|
assert data["llm_used"] is True
|
||||||
|
assert llm_called is False
|
||||||
|
|
||||||
|
def test_ask_async_mode_returns_job_id(self, client, mock_events_collection, monkeypatch):
|
||||||
|
"""Async mode should return immediately with a job_id."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-async",
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Add user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"target_displays": ["USER-001"],
|
||||||
|
"display_summary": "summary",
|
||||||
|
"raw_text": "raw",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("routes.ask.LLM_API_KEY", "fake-key")
|
||||||
|
|
||||||
|
# Mock arq pool to capture enqueue_job call
|
||||||
|
class FakeArqPool:
|
||||||
|
def __init__(self):
|
||||||
|
self.enqueued = []
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def setex(self, key, ttl, value):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def enqueue_job(self, func, *args, **kwargs):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
job = MagicMock()
|
||||||
|
job.job_id = "job-12345"
|
||||||
|
self.enqueued.append((func, args, kwargs))
|
||||||
|
return job
|
||||||
|
|
||||||
|
pool = FakeArqPool()
|
||||||
|
|
||||||
|
async def fake_get_arq_pool():
|
||||||
|
return pool
|
||||||
|
|
||||||
|
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||||
|
|
||||||
|
response = client.post("/api/ask", json={"question": "What happened to USER-001?", "async_mode": True})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["job_id"] == "job-12345"
|
||||||
|
assert data["llm_used"] is False
|
||||||
|
assert "being processed" in data["answer"]
|
||||||
|
assert len(pool.enqueued) == 1
|
||||||
|
assert pool.enqueued[0][0] == "process_ask_question"
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ def test_evaluate_event_creates_alert(monkeypatch):
|
|||||||
inserted["doc"] = doc
|
inserted["doc"] = doc
|
||||||
|
|
||||||
monkeypatch.setattr(alerts_collection, "insert_one", mock_insert)
|
monkeypatch.setattr(alerts_collection, "insert_one", mock_insert)
|
||||||
|
monkeypatch.setattr(alerts_collection, "count_documents", lambda *args, **kwargs: 0)
|
||||||
|
|
||||||
event = {"id": "e1", "operation": "Add user", "timestamp": datetime.now(UTC).isoformat(), "dedupe_key": "dk1"}
|
event = {"id": "e1", "operation": "Add user", "timestamp": datetime.now(UTC).isoformat(), "dedupe_key": "dk1"}
|
||||||
triggered = evaluate_event(event)
|
triggered = evaluate_event(event)
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
|
redis:
|
||||||
|
image: valkey/valkey:8-alpine
|
||||||
|
container_name: aoc-redis
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- aoc-internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:7
|
image: mongo:7
|
||||||
container_name: aoc-mongo
|
container_name: aoc-mongo
|
||||||
@@ -27,9 +42,12 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:27017/
|
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:27017/
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- aoc-internal
|
- aoc-internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -39,6 +57,24 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: git.cqre.net/cqrenet/aoc-backend:${AOC_VERSION:-latest}
|
||||||
|
container_name: aoc-worker
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:27017/
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
command: ["arq", "jobs.WorkerSettings"]
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- aoc-internal
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: aoc-nginx
|
container_name: aoc-nginx
|
||||||
@@ -58,6 +94,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo_data:
|
mongo_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
aoc-internal:
|
aoc-internal:
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
|
redis:
|
||||||
|
image: valkey/valkey:8-alpine
|
||||||
|
container_name: aoc-redis
|
||||||
|
restart: always
|
||||||
|
# Ports not exposed to host; backend and worker connect via Docker network
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:7
|
image: mongo:7
|
||||||
container_name: aoc-mongo
|
container_name: aoc-mongo
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
# Ports not exposed to host; backend and worker connect via Docker network
|
||||||
- "27017:27017"
|
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USERNAME}
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USERNAME}
|
||||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
|
||||||
@@ -14,17 +21,34 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
# For production, use the pre-built image instead:
|
# For production, use the pre-built image instead:
|
||||||
# image: git.cqre.net/cqrenet/aoc-backend:v1.1.0
|
# image: git.cqre.net/cqrenet/aoc-backend:v1.2.5
|
||||||
container_name: aoc-backend
|
container_name: aoc-backend
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:${MONGO_PORT}/
|
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:${MONGO_PORT}/
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongo
|
- mongo
|
||||||
|
- redis
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: ./backend
|
||||||
|
container_name: aoc-worker
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:${MONGO_PORT}/
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
command: ["arq", "jobs.WorkerSettings"]
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- mongo
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo_data:
|
mongo_data:
|
||||||
|
redis_data:
|
||||||
|
|||||||
Reference in New Issue
Block a user