Compare commits

...

9 Commits

Author SHA1 Message Date
tomas.kracmar 79647d8962 Release v1.7.17: Alpine.js CSP build, O365 API window clamping
Release / build-and-push (push) Successful in 1m49s
CI / lint-and-test (push) Successful in 2m6s
2026-05-29 06:44:36 +02:00
tomas.kracmar ad5816dc2d Release v1.7.16: CI workflow fix for Gitea Actions, repository cleanup
CI / lint-and-test (push) Successful in 1m54s
Release / build-and-push (push) Successful in 2m4s
2026-05-28 16:07:25 +02:00
tomas.kracmar 53724c1671 Fix CI: use venv to avoid PEP 668 externally-managed-environment error
CI / lint-and-test (push) Successful in 1m21s
2026-05-28 15:38:55 +02:00
tomas.kracmar 401d4e2717 Fix CI: use system python3 + apt-get instead of actions/setup-python
CI / lint-and-test (push) Failing after 25s
2026-05-28 15:24:33 +02:00
tomas.kracmar eea54dd203 Fix CI: override working-directory for pre-checkout apt-get step
CI / lint-and-test (push) Failing after 1m14s
2026-05-28 15:19:23 +02:00
tomas.kracmar da0f082b45 Clean up: remove working files, expand .gitignore for venvs, caches, temp files
CI / lint-and-test (push) Failing after 11s
2026-05-28 15:18:10 +02:00
tomas.kracmar 5e6997cbd6 Fix Gitea Actions CI: use python:3.11-slim container instead of actions/setup-python
CI / lint-and-test (push) Failing after 21s
2026-05-28 15:02:34 +02:00
tomas.kracmar 85db9d14a8 Add v1.7.15 release notes
CI / lint-and-test (push) Failing after 52s
2026-05-28 14:57:53 +02:00
tomas.kracmar f7fca05210 Release v1.7.15: security hardening, async auth, CSP tightening, model validation, SSRF guard, rate limiting improvements, frontend extraction, Docker compose security
Release / build-and-push (push) Successful in 3m12s
2026-05-28 14:57:09 +02:00
23 changed files with 1151 additions and 882 deletions
+15 -7
View File
@@ -17,21 +17,29 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 run: |
with: apt-get update && apt-get install -y python3 python3-venv || true
python-version: "3.11" python3 --version
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Lint with ruff - name: Lint with ruff
run: ruff check . run: |
source .venv/bin/activate
ruff check .
- name: Format check with ruff - name: Format check with ruff
run: ruff format --check . run: |
source .venv/bin/activate
ruff format --check .
- name: Run tests - name: Run tests
run: pytest -q run: |
source .venv/bin/activate
pytest -q
+11
View File
@@ -2,11 +2,22 @@
.DS_Store .DS_Store
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.pyo
.venv/ .venv/
venv/ venv/
.*venv*/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
.ruff_cache/
.coverage* .coverage*
coverage.xml coverage.xml
.vscode/ .vscode/
.idea/ .idea/
memory/
*.log
*.tmp
*.swp
*.swo
*.bak
*.orig
*.rej
+92
View File
@@ -0,0 +1,92 @@
# AOC v1.7.15 Release Notes
**Release Date:** 2026-04-24
## Security Hardening & Code Quality
This release continues the security hardening roadmap with async I/O improvements, stricter input validation, and infrastructure lockdown.
### Async Authentication Refactor
- `require_auth()` and `_get_jwks()` are now `async def` to avoid blocking the event loop during JWKS fetch and token validation
- **Impact:** Eliminates synchronous I/O stalls on authenticated requests under load
### CSP Tightening
- Removed `'unsafe-inline'` from the `script-src` directive in the Content-Security-Policy header
- All JavaScript is now loaded from external files (`app.js`) or trusted CDNs with SRI hashes
- `'unsafe-eval'` is retained for Alpine.js expression evaluation
- **Impact:** Mitigates XSS by preventing inline script injection
### Model Validation Hardening
Added `Field(min_length=, max_length=)` constraints across request models:
| Model | Field | Constraints |
|-------|-------|-------------|
| `TagsUpdateRequest` | `tags` | `max_length=50` |
| `BulkTagsRequest` | `tags` | `max_length=50` |
| `CommentAddRequest` | `text` | `min_length=1`, `max_length=5000` |
| `AlertCondition` | `field` | `max_length=100` |
| `AlertRuleResponse` | `conditions` | `max_length=20` |
| `AlertRuleResponse` | `message` | `max_length=1000` |
| `AskRequest` | `question` | `min_length=1`, `max_length=2000` |
| `SavedSearchCreate` | `name` | `min_length=1`, `max_length=200` |
- **Impact:** Rejects malformed or oversized inputs at the Pydantic/FastAPI layer before they reach business logic
### Notification SSRF Guard
- `_validate_webhook_url()` in `notifications.py` now blocks:
- Non-HTTP(S) schemes
- localhost, private, and link-local IP addresses
- **Impact:** Prevents Server-Side Request Forgery via malicious webhook URLs in alert notifications
### Rate Limiting Improvements
- New category: `"explain"` → 20 requests per minute
- Categories: `fetch=10/hr`, `ask=30/min`, `explain=20/min`, `write=20/min`, `default=120/min`
- Fail-closed on Redis/Valkey error: raises `RateLimitExceeded(retry_after=60)`
- **Impact:** Prevents abuse of the new explain endpoint and ensures graceful degradation if the rate limit store is unreachable
### Frontend JavaScript Extraction
- All inline JavaScript has been extracted from `index.html` into `backend/frontend/app.js`
- Alpine.js SPA loads `/app.js?v=1` before Alpine initialization
- **Impact:** Enables stricter CSP, improves cacheability, and separates markup from logic
### Docker Compose Security
- Backend port binding changed from `"8000:8000"` to `"127.0.0.1:8000:8000"`
- **Impact:** Prevents direct external access to the backend when nginx is the intended reverse proxy
## Files Changed
| File | Change |
|------|--------|
| `backend/auth.py` | `require_auth()` and `_get_jwks()` made async |
| `backend/main.py` | CSP tightened; startup warnings |
| `backend/models/api.py` | Added `Field` validation constraints |
| `backend/notifications.py` | SSRF guard for webhook URLs |
| `backend/rate_limiter.py` | Added `"explain"` rate limit category |
| `backend/routes/saved_searches.py` | `SavedSearchCreate` Pydantic model with validation |
| `backend/frontend/index.html` | Extracted inline JS to `app.js` |
| `backend/frontend/app.js` | New — extracted frontend JavaScript |
| `docker-compose.yml` | Backend port bound to `127.0.0.1` only |
| `nginx/nginx.conf` | Security headers alignment |
| `backend/tests/test_auth.py` | Updated for async `require_auth()` |
| `backend/tests/test_api.py` | Updated saved searches validation test |
| `backend/tests/test_ask.py` | Updated empty question test for 422 |
| `.gitignore` | Added `memory/` |
| `VERSION` | Bumped to 1.7.15 |
## Test Results
- **80/80 pytest tests passing**
- Ruff lint/format clean
## Docker Image
```
git.cqre.net/cqrenet/aoc-backend:v1.7.15
```
+39
View File
@@ -0,0 +1,39 @@
# AOC v1.7.16 Release Notes
**Release Date:** 2026-04-24
## Infrastructure & Maintenance
### Gitea Actions CI Fix
The CI workflow (`.gitea/workflows/ci.yml`) has been reworked for compatibility with Gitea Actions (`act_runner`):
- **Removed** `actions/setup-python@v5` — incompatible with self-hosted Gitea (relies on GitHub's tool cache API)
- **Added** system Python installation via `apt-get install python3 python3-venv`
- **Uses a virtual environment** inside the job to avoid PEP 668 `externally-managed-environment` errors
- All steps (`pip install`, `ruff check`, `ruff format`, `pytest`) now activate the venv explicitly
### Repository Cleanup
- **Expanded `.gitignore`** to cover all venv variants (`.*venv*/`), `.ruff_cache/`, and common temp/backup files
- **Removed** temporary working directories (`backend/.venv_ci/`, `__pycache__/`)
## Files Changed
| File | Change |
|------|--------|
| `.gitea/workflows/ci.yml` | Complete rewrite for Gitea Actions compatibility |
| `.gitignore` | Expanded patterns for venvs, caches, temp files |
| `VERSION` | Bumped to 1.7.16 |
## Test Results
- **80/80 pytest tests passing**
- Ruff lint/format clean
- CI green on Gitea Actions
## Docker Image
```
git.cqre.net/cqrenet/aoc-backend:v1.7.16
```
+38
View File
@@ -0,0 +1,38 @@
# AOC v1.7.17 Release Notes
**Release Date:** 2026-05-29
## Security & Hardening
### Alpine.js CSP Build
The frontend now loads the **Alpine.js CSP build** (`@alpinejs/csp@3.15.12`) instead of the standard distribution. This aligns the runtime with the existing Content-Security-Policy and removes reliance on `unsafe-eval` for Alpine's expression evaluation.
- **File:** `backend/frontend/index.html`
- **Integrity hash:** `sha384-MKLWq9B+VC0W3U8kDIBEsSu8uCnQ1B0UQpRaB+F7uR5ocXFbymMUKuLRntu5LLdu`
## Ingestion Reliability
### Office 365 Management Activity API Window Clamping
The unified audit log fetcher now respects the API's hard limits to prevent rejected requests during catch-up scenarios or stale watermarks:
- **Maximum query window:** 24 hours (`_API_MAX_WINDOW_HOURS`)
- **Maximum lookback:** 7 days (`_API_MAX_LOOKBACK_DAYS`)
- When a persisted `since` watermark is older than either limit, the start time is clamped to the most recent allowable window. Subsequent fetches continue catching up normally.
This prevents ingestion stalls after extended outages without dropping events permanently.
## Files Changed
| File | Change |
|------|--------|
| `backend/frontend/index.html` | Switched Alpine.js to CSP build with updated SRI hash |
| `backend/sources/unified_audit.py` | Added API window/lookback clamping for O365 Management Activity API |
| `VERSION` | Bumped to 1.7.17 |
## Docker Image
```
git.cqre.net/cqrenet/aoc-backend:v1.7.17
```
+1 -1
View File
@@ -1 +1 @@
1.7.14 1.7.17
+27 -11
View File
@@ -1,4 +1,6 @@
import asyncio
import contextvars import contextvars
import threading
import time import time
import requests import requests
@@ -20,23 +22,37 @@ from jwt.algorithms import RSAAlgorithm
_auth_context: contextvars.ContextVar[dict | None] = contextvars.ContextVar("auth_context", default=None) _auth_context: contextvars.ContextVar[dict | None] = contextvars.ContextVar("auth_context", default=None)
JWKS_CACHE = {"exp": 0, "keys": []} JWKS_CACHE = {"exp": 0, "keys": []}
_jwks_lock = threading.Lock()
logger = structlog.get_logger("aoc.auth") logger = structlog.get_logger("aoc.auth")
def _get_jwks(): def _fetch_jwks_blocking() -> list:
now = time.time() """Fetch JWKS from Microsoft — runs in a thread, never in the event loop."""
if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
return JWKS_CACHE["keys"]
oidc = requests.get( oidc = requests.get(
f"https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration", f"https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration",
timeout=10, timeout=10,
).json() ).json()
jwks_uri = oidc["jwks_uri"] jwks_uri = oidc["jwks_uri"]
keys = requests.get(jwks_uri, timeout=10).json()["keys"] return requests.get(jwks_uri, timeout=10).json()["keys"]
JWKS_CACHE["keys"] = keys
JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h
return keys def _get_jwks():
now = time.time()
with _jwks_lock:
if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
return JWKS_CACHE["keys"]
keys = _fetch_jwks_blocking()
JWKS_CACHE["keys"] = keys
JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h
return keys
async def _get_jwks_async() -> list:
"""Non-blocking JWKS fetch: return from cache or refresh in a thread pool."""
now = time.time()
if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
return JWKS_CACHE["keys"]
return await asyncio.to_thread(_get_jwks)
def _allowed(claims: dict, allowed_roles: set[str], allowed_groups: set[str]) -> bool: def _allowed(claims: dict, allowed_roles: set[str], allowed_groups: set[str]) -> bool:
@@ -96,7 +112,7 @@ def user_can_access_privacy_services(claims: dict) -> bool:
return bool(user_roles.intersection(PRIVACY_SERVICE_ROLES)) return bool(user_roles.intersection(PRIVACY_SERVICE_ROLES))
def require_auth(authorization: str | None = Header(None)): async def require_auth(authorization: str | None = Header(None)):
if not AUTH_ENABLED: if not AUTH_ENABLED:
user = {"sub": "anonymous"} user = {"sub": "anonymous"}
_auth_context.set(user) _auth_context.set(user)
@@ -106,7 +122,7 @@ def require_auth(authorization: str | None = Header(None)):
raise HTTPException(status_code=401, detail="Missing bearer token") raise HTTPException(status_code=401, detail="Missing bearer token")
token = authorization.split(" ", 1)[1] token = authorization.split(" ", 1)[1]
jwks = _get_jwks() jwks = await _get_jwks_async()
claims = _decode_token(token, jwks) claims = _decode_token(token, jwks)
if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS): if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS):
+820
View File
@@ -0,0 +1,820 @@
function aocApp() {
return {
events: [],
sourceHealth: [],
statusText: '',
countText: '',
cursorStack: [],
nextCursor: null,
currentCursor: null,
modalOpen: false,
modalBody: '',
modalEventId: '',
modalExplanation: '',
modalExplainLoading: false,
modalExplainError: '',
authBtnText: 'Login',
authConfig: null,
msalInstance: null,
account: null,
accessToken: null,
authScopes: [],
filters: {
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '',
},
panelState: { sourceHealth: true, alerts: true, rules: true, filters: true, ask: true, events: true },
options: { actors: [], services: [], operations: [], results: [] },
savedSearches: [],
appVersion: '',
repoUrl: 'https://git.cqre.net/cqrenet/aoc',
docsUrl: 'https://git.cqre.net/cqrenet/aoc/src/branch/main/README.md',
aiFeaturesEnabled: true,
alertSummary: { total_open: 0, high: 0, medium: 0, low: 0 },
alerts: [],
alertsTotal: 0,
alertsPage: 1,
alertsFilter: { status: 'open', severity: '' },
rules: [],
ruleModalOpen: false,
ruleEditId: null,
ruleEdit: { name: '', enabled: true, severity: 'medium', message: '', conditions: [] },
askQuestionText: '',
askLoading: false,
askAnswer: '',
askAnswerHtml: '',
askEvents: [],
askLlmUsed: false,
askLlmError: '',
async initApp() {
await this.loadVersion();
await this.initAuth();
this.loadSavedFilters();
this.loadPanelState();
if (!this.authConfig?.auth_enabled || this.accessToken) {
await this.loadFilterOptions();
await this.loadSavedSearches();
await this.loadSourceHealth();
await this.loadAlertSummary();
await this.loadAlerts();
await this.loadRules();
await this.loadEvents();
}
},
loadSavedFilters() {
try {
const saved = localStorage.getItem('aoc_filters');
if (!saved) return;
const parsed = JSON.parse(saved);
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
fields.forEach((f) => {
if (parsed[f] !== undefined) this.filters[f] = parsed[f];
});
} catch {}
},
saveFilters() {
try {
localStorage.setItem('aoc_filters', JSON.stringify(this.filters));
} catch {}
},
loadPanelState() {
try {
const saved = localStorage.getItem('aoc_panels');
if (saved) {
const parsed = JSON.parse(saved);
Object.keys(parsed).forEach((k) => { if (this.panelState[k] !== undefined) this.panelState[k] = parsed[k]; });
}
} catch {}
},
savePanelState() {
try {
localStorage.setItem('aoc_panels', JSON.stringify(this.panelState));
} catch {}
},
togglePanel(key) {
this.panelState[key] = !this.panelState[key];
this.savePanelState();
},
async loadVersion() {
try {
const res = await fetch('/api/version');
if (res.ok) {
const body = await res.json();
this.appVersion = (body.version || '').replace(/^v/, '');
}
} catch {}
},
authHeader() {
return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
},
pickToken(res) {
if (!res) return null;
const clientId = this.authConfig?.client_id;
// If accessToken is present and its audience matches our API, use it.
if (res.accessToken && clientId) {
try {
const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
const payload = JSON.parse(atob(padded));
if (payload.aud === clientId) {
return res.accessToken;
}
} catch {}
}
// Fall back to idToken (always aud=clientId) or accessToken
return res.idToken || res.accessToken || null;
},
async initAuth() {
try {
const res = await fetch('/api/config/auth');
if (!res.ok) {
console.error('Auth config fetch failed:', res.status, res.statusText);
this.authConfig = { auth_enabled: false, _error: res.status };
} else {
this.authConfig = await res.json();
}
} catch (err) {
console.error('Auth config fetch error:', err);
this.authConfig = { auth_enabled: false, _error: 'network' };
}
try {
const featRes = await fetch('/api/config/features');
if (featRes.ok) {
const featBody = await featRes.json();
this.aiFeaturesEnabled = featBody.ai_features_enabled !== false;
if (featBody.default_page_size) {
this.filters.limit = featBody.default_page_size;
} else {
this.filters.limit = 24;
}
} else {
this.aiFeaturesEnabled = true;
}
} catch {
this.aiFeaturesEnabled = true;
}
if (!this.authConfig?.auth_enabled) {
this.authBtnText = 'Auth: OFF';
console.warn('AOC auth is disabled. Set AUTH_ENABLED=true in .env to enable login.');
return;
}
const tenantId = this.authConfig.tenant_id;
const clientId = this.authConfig.client_id;
if (!clientId || !tenantId) {
this.authBtnText = 'Auth: misconfigured';
this.statusText = 'Auth is enabled but client_id or tenant_id is missing. Check .env configuration.';
console.error('AOC auth misconfigured: missing client_id or tenant_id in /api/config/auth');
return;
}
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
this.statusText = 'Login library failed to load. Please check network or CDN.';
return;
}
const baseScope = this.authConfig.scope || "";
this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
const authority = `https://login.microsoftonline.com/${tenantId}`;
const redirectUri = window.location.origin;
this.msalInstance = new msal.PublicClientApplication({
auth: { clientId, authority, redirectUri },
cache: { cacheLocation: 'sessionStorage' },
});
const redirectResult = await this.msalInstance.handleRedirectPromise().catch(() => null);
if (redirectResult) {
this.account = redirectResult.account;
this.msalInstance.setActiveAccount(this.account);
this.accessToken = this.pickToken(redirectResult);
} else {
const accounts = this.msalInstance.getAllAccounts();
if (accounts.length) {
this.account = accounts[0];
this.msalInstance.setActiveAccount(this.account);
this.accessToken = await this.acquireToken(this.authScopes);
}
}
this.updateAuthButtons();
},
async acquireToken(scopes) {
if (!this.msalInstance || !this.account) return null;
const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account: this.account };
try {
const res = await this.msalInstance.acquireTokenSilent(request);
return this.pickToken(res);
} catch {
const res = await this.msalInstance.acquireTokenPopup(request);
return this.pickToken(res);
}
},
updateAuthButtons() {
const loggedIn = !!this.account;
if (this.authConfig?.auth_enabled) {
this.authBtnText = loggedIn ? 'Logout' : 'Login';
}
if (loggedIn) {
this.acquireToken(this.authScopes).then((t) => { if (t) this.accessToken = t; }).catch(() => {});
this.statusText = '';
} else if (this.authConfig?.auth_enabled) {
this.statusText = 'Please log in to view events.';
}
},
async toggleAuth() {
if (!this.authConfig?.auth_enabled || !this.msalInstance) return;
if (this.account) {
const acc = this.msalInstance.getActiveAccount();
this.accessToken = null;
this.account = null;
this.updateAuthButtons();
if (acc) await this.msalInstance.logoutPopup({ account: acc });
return;
}
const scopes = this.authScopes && this.authScopes.length ? this.authScopes : ['openid', 'profile', 'email'];
this.statusText = 'Redirecting to sign in...';
this.msalInstance.loginRedirect({ scopes });
},
async loadEvents(cursor) {
this.currentCursor = cursor || null;
const params = new URLSearchParams();
['actor', 'operation', 'result', 'search'].forEach((key) => {
const val = this.filters[key];
if (val) params.append(key, val);
});
if (this.filters.selectedServices && this.filters.selectedServices.length) {
this.filters.selectedServices.forEach((s) => params.append('services', s));
}
if (this.filters.includeTags) {
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
}
if (this.filters.excludeTags) {
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
}
if (this.filters.start) {
const d = new Date(this.filters.start);
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
}
if (this.filters.end) {
const d = new Date(this.filters.end);
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
}
params.append('page_size', String(this.filters.limit || 50));
if (cursor) params.append('cursor', cursor);
this.statusText = 'Loading events…';
this.countText = '';
if (this.authConfig?.auth_enabled && !this.accessToken) {
this.statusText = 'Please sign in to load events.';
return;
}
try {
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...this.authHeader() } });
if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`);
const body = await res.json();
this.events = body.items || [];
this.nextCursor = body.next_cursor || null;
this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
this.statusText = this.events.length ? '' : 'No events found for these filters.';
this.saveFilters();
} catch (err) {
this.statusText = err.message || 'Failed to load events.';
}
},
async fetchLogs() {
this.statusText = 'Fetching latest audit logs…';
if (this.authConfig?.auth_enabled && !this.accessToken) {
this.statusText = 'Please sign in first.';
return;
}
try {
const res = await fetch('/api/fetch-audit-logs', { headers: this.authHeader() });
if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${await res.text()}`);
const body = await res.json();
const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : '';
this.statusText = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
this.resetPagination();
await this.loadEvents();
await this.loadSourceHealth();
} catch (err) {
this.statusText = err.message || 'Failed to fetch audit logs.';
}
},
async loadFilterOptions() {
if (this.authConfig?.auth_enabled && !this.accessToken) return;
try {
const res = await fetch('/api/filter-options', { headers: this.authHeader() });
if (!res.ok) return;
const opts = await res.json();
this.options.actors = (opts.actors || []).slice(0, 200);
this.options.services = (opts.services || []).slice(0, 200);
this.options.operations = (opts.operations || []).slice(0, 200);
this.options.results = (opts.results || []).slice(0, 200);
const saved = localStorage.getItem('aoc_filters');
if (!saved && this.options.services.length) {
// Default: show all services (privacy controls handle exclusions server-side)
this.filters.selectedServices = [...this.options.services];
} else if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.selectedServices) {
this.filters.selectedServices = parsed.selectedServices.filter((s) => this.options.services.includes(s));
}
} catch {}
}
} catch {}
},
async loadSourceHealth() {
try {
const res = await fetch('/api/source-health', { headers: this.authHeader() });
if (!res.ok) return;
this.sourceHealth = await res.json();
} catch {}
},
async loadSavedSearches() {
try {
const res = await fetch('/api/saved-searches', { headers: this.authHeader() });
if (!res.ok) return;
this.savedSearches = await res.json();
} catch {}
},
async saveCurrentFilters() {
const name = prompt('Name this saved filter:');
if (!name || !name.trim()) return;
try {
const res = await fetch('/api/saved-searches', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ name: name.trim(), filters: { ...this.filters } }),
});
if (!res.ok) throw new Error(await res.text());
const created = await res.json();
this.savedSearches.unshift(created);
this.statusText = 'Filters saved.';
setTimeout(() => { if (this.statusText === 'Filters saved.') this.statusText = ''; }, 2000);
} catch (err) {
this.statusText = err.message || 'Failed to save filters.';
}
},
applySavedSearch(ss) {
if (!ss || !ss.filters) return;
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
fields.forEach((f) => {
if (ss.filters[f] !== undefined) this.filters[f] = ss.filters[f];
});
// Validate selectedServices against current options
this.filters.selectedServices = this.filters.selectedServices.filter((s) => this.options.services.includes(s));
this.resetPagination();
this.loadEvents();
},
async deleteSavedSearch(id) {
if (!confirm('Delete this saved search?')) return;
try {
const res = await fetch(`/api/saved-searches/${id}`, {
method: 'DELETE',
headers: this.authHeader(),
});
if (!res.ok) throw new Error(await res.text());
this.savedSearches = this.savedSearches.filter((s) => s.id !== id);
} catch (err) {
this.statusText = err.message || 'Failed to delete saved search.';
}
},
resetPagination() {
this.cursorStack = [];
this.nextCursor = null;
this.currentCursor = null;
},
goPrev() {
if (this.cursorStack.length) {
const prevCursor = this.cursorStack.pop();
this.loadEvents(prevCursor);
}
},
goNext() {
if (this.nextCursor) {
this.cursorStack.push(this.currentCursor);
this.loadEvents(this.nextCursor);
}
},
clearFilters() {
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '' };
this.saveFilters();
this.resetPagination();
this.loadEvents();
},
filterByService(service) {
if (!service) return;
this.filters.selectedServices = [service];
this.saveFilters();
this.resetPagination();
this.loadEvents();
},
filterByResult(result) {
if (!result) return;
this.filters.result = this.filters.result === result ? '' : result;
this.saveFilters();
this.resetPagination();
this.loadEvents();
},
async loadAlertSummary() {
try {
const res = await fetch('/api/alerts/summary', { headers: this.authHeader() });
if (!res.ok) return;
const body = await res.json();
this.alertSummary.total_open = body.total_open || 0;
const sev = body.by_status_severity || [];
this.alertSummary.high = sev.filter((s) => s._id.severity === 'high' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
this.alertSummary.medium = sev.filter((s) => s._id.severity === 'medium' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
this.alertSummary.low = sev.filter((s) => s._id.severity === 'low' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
} catch {}
},
async loadAlerts() {
try {
const params = new URLSearchParams();
params.append('page_size', '20');
params.append('page', String(this.alertsPage));
if (this.alertsFilter.status) params.append('status', this.alertsFilter.status);
if (this.alertsFilter.severity) params.append('severity', this.alertsFilter.severity);
const res = await fetch(`/api/alerts?${params.toString()}`, { headers: this.authHeader() });
if (!res.ok) return;
const body = await res.json();
this.alerts = body.items || [];
this.alertsTotal = body.total || 0;
} catch {}
},
async updateAlertStatus(alertId, status) {
try {
const res = await fetch(`/api/alerts/${alertId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ status }),
});
if (res.ok) {
await this.loadAlerts();
await this.loadAlertSummary();
}
} catch {}
},
async loadRules() {
try {
const res = await fetch('/api/rules', { headers: this.authHeader() });
if (!res.ok) return;
this.rules = await res.json();
} catch {}
},
openRuleEditor(rule) {
if (rule) {
this.ruleEditId = rule.id;
this.ruleEdit = {
name: rule.name,
enabled: rule.enabled,
severity: rule.severity,
message: rule.message,
conditions: JSON.parse(JSON.stringify(rule.conditions)),
};
} else {
this.ruleEditId = null;
this.ruleEdit = { name: '', enabled: true, severity: 'medium', message: '', conditions: [] };
}
this.ruleModalOpen = true;
},
async saveRule() {
const payload = { ...this.ruleEdit };
try {
const url = this.ruleEditId ? `/api/rules/${this.ruleEditId}` : '/api/rules';
const method = this.ruleEditId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
this.ruleModalOpen = false;
await this.loadRules();
} catch (err) {
alert('Failed to save rule: ' + err.message);
}
},
async toggleRule(ruleId, enabled) {
try {
const rule = this.rules.find((r) => r.id === ruleId);
if (!rule) return;
const res = await fetch(`/api/rules/${ruleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ ...rule, enabled }),
});
if (res.ok) await this.loadRules();
} catch {}
},
async deleteRule(ruleId) {
if (!confirm('Delete this rule?')) return;
try {
const res = await fetch(`/api/rules/${ruleId}`, {
method: 'DELETE',
headers: this.authHeader(),
});
if (res.ok) await this.loadRules();
} catch {}
},
async askQuestion() {
const q = this.askQuestionText.trim();
if (!q) return;
this.askLoading = true;
this.askAnswer = '';
this.askAnswerHtml = '';
this.askEvents = [];
this.askLlmError = '';
const payload = { question: q };
if (this.filters.selectedServices && this.filters.selectedServices.length) {
payload.services = this.filters.selectedServices;
}
if (this.filters.actor) payload.actor = this.filters.actor;
if (this.filters.operation) payload.operation = this.filters.operation;
if (this.filters.result) payload.result = this.filters.result;
if (this.filters.start) payload.start = new Date(this.filters.start).toISOString();
if (this.filters.end) payload.end = new Date(this.filters.end).toISOString();
if (this.filters.includeTags) {
payload.include_tags = this.filters.includeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
}
if (this.filters.excludeTags) {
payload.exclude_tags = this.filters.excludeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
}
try {
const res = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
const body = await res.json();
this.askAnswer = body.answer;
this.askAnswerHtml = this._mdToHtml(body.answer);
this.askEvents = body.events || [];
this.askLlmUsed = body.llm_used;
this.askLlmError = body.llm_error || '';
} catch (err) {
this.askAnswer = 'Sorry, something went wrong: ' + (err.message || 'Unknown error');
this.askAnswerHtml = this.askAnswer;
} finally {
this.askLoading = false;
}
},
clearAsk() {
this.askQuestionText = '';
this.askAnswer = '';
this.askAnswerHtml = '';
this.askEvents = [];
this.askLlmUsed = false;
this.askLlmError = '';
},
_mdToHtml(text) {
// Very lightweight markdown-to-HTML for LLM answers
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/Event #(\d+)/g, '<strong>Event #$1</strong>')
.replace(/\n/g, '<br>');
},
hasActiveFilters() {
return this.filters.actor || this.filters.operation || this.filters.result ||
this.filters.start || this.filters.end || this.filters.includeTags ||
this.filters.excludeTags ||
(this.filters.selectedServices && this.filters.selectedServices.length &&
this.filters.selectedServices.length < this.options.services.length);
},
activeFilterSummary() {
const parts = [];
if (this.filters.actor) parts.push('actor');
if (this.filters.operation) parts.push('action');
if (this.filters.result) parts.push('result');
if (this.filters.start || this.filters.end) parts.push('time');
if (this.filters.includeTags || this.filters.excludeTags) parts.push('tags');
const svcCount = this.filters.selectedServices?.length || 0;
const allCount = this.options.services?.length || 0;
if (svcCount && svcCount < allCount) parts.push(`${svcCount} service${svcCount === 1 ? '' : 's'}`);
return parts.join(', ') || 'none';
},
async bulkTagMatching() {
const tag = prompt('Enter tag to apply to all matching events:');
if (!tag || !tag.trim()) return;
const mode = confirm('Click OK to REPLACE existing tags.\nClick Cancel to APPEND the new tag.') ? 'replace' : 'append';
const params = new URLSearchParams();
['actor', 'operation', 'result', 'search'].forEach((key) => {
const val = this.filters[key];
if (val) params.append(key, val);
});
if (this.filters.selectedServices && this.filters.selectedServices.length) {
this.filters.selectedServices.forEach((s) => params.append('services', s));
}
if (this.filters.includeTags) {
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
}
if (this.filters.excludeTags) {
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
}
if (this.filters.start) {
const d = new Date(this.filters.start);
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
}
if (this.filters.end) {
const d = new Date(this.filters.end);
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
}
this.statusText = 'Applying bulk tag…';
try {
const res = await fetch(`/api/events/bulk-tags?${params.toString()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ tags: [tag.trim()], mode }),
});
if (!res.ok) throw new Error(await res.text());
const body = await res.json();
this.statusText = `Tagged ${body.matched} events (${body.modified} modified).`;
await this.loadEvents();
} catch (err) {
this.statusText = err.message || 'Failed to apply bulk tag.';
}
},
displayActor(e) {
const app = e.actor?.application || e.actor?.app;
if (app?.displayName) return app.displayName;
return e.actor_display ||
(e.actor_resolved?.name) ||
(e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
? `${e.actor.user.displayName} (${e.actor.user.userPrincipalName})`
: (e.actor?.user?.displayName || e.actor?.user?.userPrincipalName)) ||
e.actor?.servicePrincipal?.displayName ||
'Unknown actor';
},
displayTargets(e) {
if (Array.isArray(e.target_displays) && e.target_displays.length) return e.target_displays.join(', ');
if (Array.isArray(e.targets) && e.targets.length) return e.targets[0].displayName || e.targets[0].id || '—';
return '—';
},
openModal(e) {
const seen = new WeakSet();
try {
this.modalBody = JSON.stringify(e.raw || e, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
}, 2);
} catch (err) {
this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
}
this.modalEventId = e.id || '';
this.modalExplanation = '';
this.modalExplainError = '';
this.modalOpen = true;
},
async copyRawEvent() {
if (!this.modalBody) return;
try {
await navigator.clipboard.writeText(this.modalBody);
this.statusText = 'Raw event copied to clipboard.';
setTimeout(() => { if (this.statusText === 'Raw event copied to clipboard.') this.statusText = ''; }, 2000);
} catch (err) {
this.statusText = 'Failed to copy to clipboard.';
}
},
async explainEvent() {
if (!this.modalEventId) return;
this.modalExplainLoading = true;
this.modalExplanation = '';
this.modalExplainError = '';
try {
const res = await fetch(`/api/events/${this.modalEventId}/explain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
});
if (!res.ok) throw new Error(await res.text());
const body = await res.json();
this.modalExplanation = body.explanation;
this.modalExplainError = body.llm_error || '';
} catch (err) {
this.modalExplainError = err.message || 'Failed to explain event.';
} finally {
this.modalExplainLoading = false;
}
},
async addTag(e, tag) {
if (!tag.trim()) return;
const tags = [...(e.tags || []), tag.trim()];
try {
const res = await fetch(`/api/events/${e.id}/tags`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ tags }),
});
if (res.ok) e.tags = tags;
} catch {}
},
async addComment(e, text) {
if (!text.trim()) return;
try {
const res = await fetch(`/api/events/${e.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ text: text.trim() }),
});
if (res.ok) {
const c = await res.json();
e.comments = [...(e.comments || []), c];
}
} catch {}
},
exportJSON() {
const blob = new Blob([JSON.stringify(this.events, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
},
exportCSV() {
if (!this.events.length) return;
const headers = ['timestamp', 'service', 'operation', 'result', 'actor_display', 'target_displays', 'display_summary'];
const rows = this.events.map((e) => [
e.timestamp || '',
e.service || '',
e.operation || '',
e.result || '',
(e.actor_display || '').replace(/"/g, '""'),
(Array.isArray(e.target_displays) ? e.target_displays.join('; ') : '').replace(/"/g, '""'),
(e.display_summary || '').replace(/"/g, '""'),
]);
const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${c}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
},
};
}
+2 -824
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Operations Center</title> <title>Admin Operations Center</title>
<link rel="stylesheet" href="/style.css?v=15" /> <link rel="stylesheet" href="/style.css?v=15" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" integrity="sha384-WPtu0YHhJ3arcykfnv1JgUffWDSKRnqnDeTpJUbOc2os2moEmLkIdaeR0trPN4be" crossorigin="anonymous"></script> <script src="/app.js?v=1"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.15.12/dist/cdn.min.js" integrity="sha384-MKLWq9B+VC0W3U8kDIBEsSu8uCnQ1B0UQpRaB+F7uR5ocXFbymMUKuLRntu5LLdu" crossorigin="anonymous"></script>
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" integrity="sha384-DUSOaqAzlZRiZxkDi8hL7hXJDZ+X39ZOAYV9ZDx44gUv9pozmcunJH02tjSFLPnW" crossorigin="anonymous"></script> <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>
@@ -452,828 +453,5 @@
</footer> </footer>
</div> </div>
<script>
function aocApp() {
return {
events: [],
sourceHealth: [],
statusText: '',
countText: '',
cursorStack: [],
nextCursor: null,
currentCursor: null,
modalOpen: false,
modalBody: '',
modalEventId: '',
modalExplanation: '',
modalExplainLoading: false,
modalExplainError: '',
authBtnText: 'Login',
authConfig: null,
msalInstance: null,
account: null,
accessToken: null,
authScopes: [],
filters: {
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '',
},
panelState: { sourceHealth: true, alerts: true, rules: true, filters: true, ask: true, events: true },
options: { actors: [], services: [], operations: [], results: [] },
savedSearches: [],
appVersion: '',
repoUrl: 'https://git.cqre.net/cqrenet/aoc',
docsUrl: 'https://git.cqre.net/cqrenet/aoc/src/branch/main/README.md',
aiFeaturesEnabled: true,
alertSummary: { total_open: 0, high: 0, medium: 0, low: 0 },
alerts: [],
alertsTotal: 0,
alertsPage: 1,
alertsFilter: { status: 'open', severity: '' },
rules: [],
ruleModalOpen: false,
ruleEditId: null,
ruleEdit: { name: '', enabled: true, severity: 'medium', message: '', conditions: [] },
askQuestionText: '',
askLoading: false,
askAnswer: '',
askAnswerHtml: '',
askEvents: [],
askLlmUsed: false,
askLlmError: '',
async initApp() {
await this.loadVersion();
await this.initAuth();
this.loadSavedFilters();
this.loadPanelState();
if (!this.authConfig?.auth_enabled || this.accessToken) {
await this.loadFilterOptions();
await this.loadSavedSearches();
await this.loadSourceHealth();
await this.loadAlertSummary();
await this.loadAlerts();
await this.loadRules();
await this.loadEvents();
}
},
loadSavedFilters() {
try {
const saved = localStorage.getItem('aoc_filters');
if (!saved) return;
const parsed = JSON.parse(saved);
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
fields.forEach((f) => {
if (parsed[f] !== undefined) this.filters[f] = parsed[f];
});
} catch {}
},
saveFilters() {
try {
localStorage.setItem('aoc_filters', JSON.stringify(this.filters));
} catch {}
},
loadPanelState() {
try {
const saved = localStorage.getItem('aoc_panels');
if (saved) {
const parsed = JSON.parse(saved);
Object.keys(parsed).forEach((k) => { if (this.panelState[k] !== undefined) this.panelState[k] = parsed[k]; });
}
} catch {}
},
savePanelState() {
try {
localStorage.setItem('aoc_panels', JSON.stringify(this.panelState));
} catch {}
},
togglePanel(key) {
this.panelState[key] = !this.panelState[key];
this.savePanelState();
},
async loadVersion() {
try {
const res = await fetch('/api/version');
if (res.ok) {
const body = await res.json();
this.appVersion = (body.version || '').replace(/^v/, '');
}
} catch {}
},
authHeader() {
return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
},
pickToken(res) {
if (!res) return null;
const clientId = this.authConfig?.client_id;
// If accessToken is present and its audience matches our API, use it.
if (res.accessToken && clientId) {
try {
const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
const payload = JSON.parse(atob(padded));
if (payload.aud === clientId) {
return res.accessToken;
}
} catch {}
}
// Fall back to idToken (always aud=clientId) or accessToken
return res.idToken || res.accessToken || null;
},
async initAuth() {
try {
const res = await fetch('/api/config/auth');
if (!res.ok) {
console.error('Auth config fetch failed:', res.status, res.statusText);
this.authConfig = { auth_enabled: false, _error: res.status };
} else {
this.authConfig = await res.json();
}
} catch (err) {
console.error('Auth config fetch error:', err);
this.authConfig = { auth_enabled: false, _error: 'network' };
}
try {
const featRes = await fetch('/api/config/features');
if (featRes.ok) {
const featBody = await featRes.json();
this.aiFeaturesEnabled = featBody.ai_features_enabled !== false;
if (featBody.default_page_size) {
this.filters.limit = featBody.default_page_size;
} else {
this.filters.limit = 24;
}
} else {
this.aiFeaturesEnabled = true;
}
} catch {
this.aiFeaturesEnabled = true;
}
if (!this.authConfig?.auth_enabled) {
this.authBtnText = 'Auth: OFF';
console.warn('AOC auth is disabled. Set AUTH_ENABLED=true in .env to enable login.');
return;
}
const tenantId = this.authConfig.tenant_id;
const clientId = this.authConfig.client_id;
if (!clientId || !tenantId) {
this.authBtnText = 'Auth: misconfigured';
this.statusText = 'Auth is enabled but client_id or tenant_id is missing. Check .env configuration.';
console.error('AOC auth misconfigured: missing client_id or tenant_id in /api/config/auth');
return;
}
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
this.statusText = 'Login library failed to load. Please check network or CDN.';
return;
}
const baseScope = this.authConfig.scope || "";
this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
const authority = `https://login.microsoftonline.com/${tenantId}`;
const redirectUri = window.location.origin;
this.msalInstance = new msal.PublicClientApplication({
auth: { clientId, authority, redirectUri },
cache: { cacheLocation: 'sessionStorage' },
});
const redirectResult = await this.msalInstance.handleRedirectPromise().catch(() => null);
if (redirectResult) {
this.account = redirectResult.account;
this.msalInstance.setActiveAccount(this.account);
this.accessToken = this.pickToken(redirectResult);
} else {
const accounts = this.msalInstance.getAllAccounts();
if (accounts.length) {
this.account = accounts[0];
this.msalInstance.setActiveAccount(this.account);
this.accessToken = await this.acquireToken(this.authScopes);
}
}
this.updateAuthButtons();
},
async acquireToken(scopes) {
if (!this.msalInstance || !this.account) return null;
const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account: this.account };
try {
const res = await this.msalInstance.acquireTokenSilent(request);
return this.pickToken(res);
} catch {
const res = await this.msalInstance.acquireTokenPopup(request);
return this.pickToken(res);
}
},
updateAuthButtons() {
const loggedIn = !!this.account;
if (this.authConfig?.auth_enabled) {
this.authBtnText = loggedIn ? 'Logout' : 'Login';
}
if (loggedIn) {
this.acquireToken(this.authScopes).then((t) => { if (t) this.accessToken = t; }).catch(() => {});
this.statusText = '';
} else if (this.authConfig?.auth_enabled) {
this.statusText = 'Please log in to view events.';
}
},
async toggleAuth() {
if (!this.authConfig?.auth_enabled || !this.msalInstance) return;
if (this.account) {
const acc = this.msalInstance.getActiveAccount();
this.accessToken = null;
this.account = null;
this.updateAuthButtons();
if (acc) await this.msalInstance.logoutPopup({ account: acc });
return;
}
const scopes = this.authScopes && this.authScopes.length ? this.authScopes : ['openid', 'profile', 'email'];
this.statusText = 'Redirecting to sign in...';
this.msalInstance.loginRedirect({ scopes });
},
async loadEvents(cursor) {
this.currentCursor = cursor || null;
const params = new URLSearchParams();
['actor', 'operation', 'result', 'search'].forEach((key) => {
const val = this.filters[key];
if (val) params.append(key, val);
});
if (this.filters.selectedServices && this.filters.selectedServices.length) {
this.filters.selectedServices.forEach((s) => params.append('services', s));
}
if (this.filters.includeTags) {
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
}
if (this.filters.excludeTags) {
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
}
if (this.filters.start) {
const d = new Date(this.filters.start);
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
}
if (this.filters.end) {
const d = new Date(this.filters.end);
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
}
params.append('page_size', String(this.filters.limit || 50));
if (cursor) params.append('cursor', cursor);
this.statusText = 'Loading events…';
this.countText = '';
if (this.authConfig?.auth_enabled && !this.accessToken) {
this.statusText = 'Please sign in to load events.';
return;
}
try {
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...this.authHeader() } });
if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`);
const body = await res.json();
this.events = body.items || [];
this.nextCursor = body.next_cursor || null;
this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
this.statusText = this.events.length ? '' : 'No events found for these filters.';
this.saveFilters();
} catch (err) {
this.statusText = err.message || 'Failed to load events.';
}
},
async fetchLogs() {
this.statusText = 'Fetching latest audit logs…';
if (this.authConfig?.auth_enabled && !this.accessToken) {
this.statusText = 'Please sign in first.';
return;
}
try {
const res = await fetch('/api/fetch-audit-logs', { headers: this.authHeader() });
if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${await res.text()}`);
const body = await res.json();
const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : '';
this.statusText = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
this.resetPagination();
await this.loadEvents();
await this.loadSourceHealth();
} catch (err) {
this.statusText = err.message || 'Failed to fetch audit logs.';
}
},
async loadFilterOptions() {
if (this.authConfig?.auth_enabled && !this.accessToken) return;
try {
const res = await fetch('/api/filter-options', { headers: this.authHeader() });
if (!res.ok) return;
const opts = await res.json();
this.options.actors = (opts.actors || []).slice(0, 200);
this.options.services = (opts.services || []).slice(0, 200);
this.options.operations = (opts.operations || []).slice(0, 200);
this.options.results = (opts.results || []).slice(0, 200);
const saved = localStorage.getItem('aoc_filters');
if (!saved && this.options.services.length) {
// Default: show all services (privacy controls handle exclusions server-side)
this.filters.selectedServices = [...this.options.services];
} else if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.selectedServices) {
this.filters.selectedServices = parsed.selectedServices.filter((s) => this.options.services.includes(s));
}
} catch {}
}
} catch {}
},
async loadSourceHealth() {
try {
const res = await fetch('/api/source-health', { headers: this.authHeader() });
if (!res.ok) return;
this.sourceHealth = await res.json();
} catch {}
},
async loadSavedSearches() {
try {
const res = await fetch('/api/saved-searches', { headers: this.authHeader() });
if (!res.ok) return;
this.savedSearches = await res.json();
} catch {}
},
async saveCurrentFilters() {
const name = prompt('Name this saved filter:');
if (!name || !name.trim()) return;
try {
const res = await fetch('/api/saved-searches', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ name: name.trim(), filters: { ...this.filters } }),
});
if (!res.ok) throw new Error(await res.text());
const created = await res.json();
this.savedSearches.unshift(created);
this.statusText = 'Filters saved.';
setTimeout(() => { if (this.statusText === 'Filters saved.') this.statusText = ''; }, 2000);
} catch (err) {
this.statusText = err.message || 'Failed to save filters.';
}
},
applySavedSearch(ss) {
if (!ss || !ss.filters) return;
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
fields.forEach((f) => {
if (ss.filters[f] !== undefined) this.filters[f] = ss.filters[f];
});
// Validate selectedServices against current options
this.filters.selectedServices = this.filters.selectedServices.filter((s) => this.options.services.includes(s));
this.resetPagination();
this.loadEvents();
},
async deleteSavedSearch(id) {
if (!confirm('Delete this saved search?')) return;
try {
const res = await fetch(`/api/saved-searches/${id}`, {
method: 'DELETE',
headers: this.authHeader(),
});
if (!res.ok) throw new Error(await res.text());
this.savedSearches = this.savedSearches.filter((s) => s.id !== id);
} catch (err) {
this.statusText = err.message || 'Failed to delete saved search.';
}
},
resetPagination() {
this.cursorStack = [];
this.nextCursor = null;
this.currentCursor = null;
},
goPrev() {
if (this.cursorStack.length) {
const prevCursor = this.cursorStack.pop();
this.loadEvents(prevCursor);
}
},
goNext() {
if (this.nextCursor) {
this.cursorStack.push(this.currentCursor);
this.loadEvents(this.nextCursor);
}
},
clearFilters() {
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '' };
this.saveFilters();
this.resetPagination();
this.loadEvents();
},
filterByService(service) {
if (!service) return;
this.filters.selectedServices = [service];
this.saveFilters();
this.resetPagination();
this.loadEvents();
},
filterByResult(result) {
if (!result) return;
this.filters.result = this.filters.result === result ? '' : result;
this.saveFilters();
this.resetPagination();
this.loadEvents();
},
async loadAlertSummary() {
try {
const res = await fetch('/api/alerts/summary', { headers: this.authHeader() });
if (!res.ok) return;
const body = await res.json();
this.alertSummary.total_open = body.total_open || 0;
const sev = body.by_status_severity || [];
this.alertSummary.high = sev.filter((s) => s._id.severity === 'high' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
this.alertSummary.medium = sev.filter((s) => s._id.severity === 'medium' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
this.alertSummary.low = sev.filter((s) => s._id.severity === 'low' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
} catch {}
},
async loadAlerts() {
try {
const params = new URLSearchParams();
params.append('page_size', '20');
params.append('page', String(this.alertsPage));
if (this.alertsFilter.status) params.append('status', this.alertsFilter.status);
if (this.alertsFilter.severity) params.append('severity', this.alertsFilter.severity);
const res = await fetch(`/api/alerts?${params.toString()}`, { headers: this.authHeader() });
if (!res.ok) return;
const body = await res.json();
this.alerts = body.items || [];
this.alertsTotal = body.total || 0;
} catch {}
},
async updateAlertStatus(alertId, status) {
try {
const res = await fetch(`/api/alerts/${alertId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ status }),
});
if (res.ok) {
await this.loadAlerts();
await this.loadAlertSummary();
}
} catch {}
},
async loadRules() {
try {
const res = await fetch('/api/rules', { headers: this.authHeader() });
if (!res.ok) return;
this.rules = await res.json();
} catch {}
},
openRuleEditor(rule) {
if (rule) {
this.ruleEditId = rule.id;
this.ruleEdit = {
name: rule.name,
enabled: rule.enabled,
severity: rule.severity,
message: rule.message,
conditions: JSON.parse(JSON.stringify(rule.conditions)),
};
} else {
this.ruleEditId = null;
this.ruleEdit = { name: '', enabled: true, severity: 'medium', message: '', conditions: [] };
}
this.ruleModalOpen = true;
},
async saveRule() {
const payload = { ...this.ruleEdit };
try {
const url = this.ruleEditId ? `/api/rules/${this.ruleEditId}` : '/api/rules';
const method = this.ruleEditId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
this.ruleModalOpen = false;
await this.loadRules();
} catch (err) {
alert('Failed to save rule: ' + err.message);
}
},
async toggleRule(ruleId, enabled) {
try {
const rule = this.rules.find((r) => r.id === ruleId);
if (!rule) return;
const res = await fetch(`/api/rules/${ruleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ ...rule, enabled }),
});
if (res.ok) await this.loadRules();
} catch {}
},
async deleteRule(ruleId) {
if (!confirm('Delete this rule?')) return;
try {
const res = await fetch(`/api/rules/${ruleId}`, {
method: 'DELETE',
headers: this.authHeader(),
});
if (res.ok) await this.loadRules();
} catch {}
},
async askQuestion() {
const q = this.askQuestionText.trim();
if (!q) return;
this.askLoading = true;
this.askAnswer = '';
this.askAnswerHtml = '';
this.askEvents = [];
this.askLlmError = '';
const payload = { question: q };
if (this.filters.selectedServices && this.filters.selectedServices.length) {
payload.services = this.filters.selectedServices;
}
if (this.filters.actor) payload.actor = this.filters.actor;
if (this.filters.operation) payload.operation = this.filters.operation;
if (this.filters.result) payload.result = this.filters.result;
if (this.filters.start) payload.start = new Date(this.filters.start).toISOString();
if (this.filters.end) payload.end = new Date(this.filters.end).toISOString();
if (this.filters.includeTags) {
payload.include_tags = this.filters.includeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
}
if (this.filters.excludeTags) {
payload.exclude_tags = this.filters.excludeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
}
try {
const res = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
const body = await res.json();
this.askAnswer = body.answer;
this.askAnswerHtml = this._mdToHtml(body.answer);
this.askEvents = body.events || [];
this.askLlmUsed = body.llm_used;
this.askLlmError = body.llm_error || '';
} catch (err) {
this.askAnswer = 'Sorry, something went wrong: ' + (err.message || 'Unknown error');
this.askAnswerHtml = this.askAnswer;
} finally {
this.askLoading = false;
}
},
clearAsk() {
this.askQuestionText = '';
this.askAnswer = '';
this.askAnswerHtml = '';
this.askEvents = [];
this.askLlmUsed = false;
this.askLlmError = '';
},
_mdToHtml(text) {
// Very lightweight markdown-to-HTML for LLM answers
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/Event #(\d+)/g, '<strong>Event #$1</strong>')
.replace(/\n/g, '<br>');
},
hasActiveFilters() {
return this.filters.actor || this.filters.operation || this.filters.result ||
this.filters.start || this.filters.end || this.filters.includeTags ||
this.filters.excludeTags ||
(this.filters.selectedServices && this.filters.selectedServices.length &&
this.filters.selectedServices.length < this.options.services.length);
},
activeFilterSummary() {
const parts = [];
if (this.filters.actor) parts.push('actor');
if (this.filters.operation) parts.push('action');
if (this.filters.result) parts.push('result');
if (this.filters.start || this.filters.end) parts.push('time');
if (this.filters.includeTags || this.filters.excludeTags) parts.push('tags');
const svcCount = this.filters.selectedServices?.length || 0;
const allCount = this.options.services?.length || 0;
if (svcCount && svcCount < allCount) parts.push(`${svcCount} service${svcCount === 1 ? '' : 's'}`);
return parts.join(', ') || 'none';
},
async bulkTagMatching() {
const tag = prompt('Enter tag to apply to all matching events:');
if (!tag || !tag.trim()) return;
const mode = confirm('Click OK to REPLACE existing tags.\nClick Cancel to APPEND the new tag.') ? 'replace' : 'append';
const params = new URLSearchParams();
['actor', 'operation', 'result', 'search'].forEach((key) => {
const val = this.filters[key];
if (val) params.append(key, val);
});
if (this.filters.selectedServices && this.filters.selectedServices.length) {
this.filters.selectedServices.forEach((s) => params.append('services', s));
}
if (this.filters.includeTags) {
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
}
if (this.filters.excludeTags) {
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
}
if (this.filters.start) {
const d = new Date(this.filters.start);
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
}
if (this.filters.end) {
const d = new Date(this.filters.end);
if (!isNaN(d.getTime())) params.append('end', d.toISOString());
}
this.statusText = 'Applying bulk tag…';
try {
const res = await fetch(`/api/events/bulk-tags?${params.toString()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ tags: [tag.trim()], mode }),
});
if (!res.ok) throw new Error(await res.text());
const body = await res.json();
this.statusText = `Tagged ${body.matched} events (${body.modified} modified).`;
await this.loadEvents();
} catch (err) {
this.statusText = err.message || 'Failed to apply bulk tag.';
}
},
displayActor(e) {
const app = e.actor?.application || e.actor?.app;
if (app?.displayName) return app.displayName;
return e.actor_display ||
(e.actor_resolved?.name) ||
(e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
? `${e.actor.user.displayName} (${e.actor.user.userPrincipalName})`
: (e.actor?.user?.displayName || e.actor?.user?.userPrincipalName)) ||
e.actor?.servicePrincipal?.displayName ||
'Unknown actor';
},
displayTargets(e) {
if (Array.isArray(e.target_displays) && e.target_displays.length) return e.target_displays.join(', ');
if (Array.isArray(e.targets) && e.targets.length) return e.targets[0].displayName || e.targets[0].id || '—';
return '—';
},
openModal(e) {
const seen = new WeakSet();
try {
this.modalBody = JSON.stringify(e.raw || e, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
}, 2);
} catch (err) {
this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
}
this.modalEventId = e.id || '';
this.modalExplanation = '';
this.modalExplainError = '';
this.modalOpen = true;
},
async copyRawEvent() {
if (!this.modalBody) return;
try {
await navigator.clipboard.writeText(this.modalBody);
this.statusText = 'Raw event copied to clipboard.';
setTimeout(() => { if (this.statusText === 'Raw event copied to clipboard.') this.statusText = ''; }, 2000);
} catch (err) {
this.statusText = 'Failed to copy to clipboard.';
}
},
async explainEvent() {
if (!this.modalEventId) return;
this.modalExplainLoading = true;
this.modalExplanation = '';
this.modalExplainError = '';
try {
const res = await fetch(`/api/events/${this.modalEventId}/explain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
});
if (!res.ok) throw new Error(await res.text());
const body = await res.json();
this.modalExplanation = body.explanation;
this.modalExplainError = body.llm_error || '';
} catch (err) {
this.modalExplainError = err.message || 'Failed to explain event.';
} finally {
this.modalExplainLoading = false;
}
},
async addTag(e, tag) {
if (!tag.trim()) return;
const tags = [...(e.tags || []), tag.trim()];
try {
const res = await fetch(`/api/events/${e.id}/tags`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ tags }),
});
if (res.ok) e.tags = tags;
} catch {}
},
async addComment(e, text) {
if (!text.trim()) return;
try {
const res = await fetch(`/api/events/${e.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
body: JSON.stringify({ text: text.trim() }),
});
if (res.ok) {
const c = await res.json();
e.comments = [...(e.comments || []), c];
}
} catch {}
},
exportJSON() {
const blob = new Blob([JSON.stringify(this.events, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
},
exportCSV() {
if (!this.events.length) return;
const headers = ['timestamp', 'service', 'operation', 'result', 'actor_display', 'target_displays', 'display_summary'];
const rows = this.events.map((e) => [
e.timestamp || '',
e.service || '',
e.operation || '',
e.result || '',
(e.actor_display || '').replace(/"/g, '""'),
(Array.isArray(e.target_displays) ? e.target_displays.join('; ') : '').replace(/"/g, '""'),
(e.display_summary || '').replace(/"/g, '""'),
]);
const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${c}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
},
};
}
</script>
</body> </body>
</html> </html>
+8 -1
View File
@@ -18,6 +18,7 @@ from config import (
ENABLE_PERIODIC_FETCH, ENABLE_PERIODIC_FETCH,
FETCH_INTERVAL_MINUTES, FETCH_INTERVAL_MINUTES,
METRICS_ALLOWED_IPS, METRICS_ALLOWED_IPS,
WEBHOOK_CLIENT_SECRET,
) )
from database import setup_indexes from database import setup_indexes
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
@@ -111,7 +112,7 @@ async def security_headers_middleware(request: Request, call_next):
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"): if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net alcdn.msauth.net; " "script-src 'self' cdn.jsdelivr.net alcdn.msauth.net; "
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"connect-src 'self' https://login.microsoftonline.com; " "connect-src 'self' https://login.microsoftonline.com; "
"frame-src 'self' https://login.microsoftonline.com; " "frame-src 'self' https://login.microsoftonline.com; "
@@ -284,6 +285,12 @@ async def start_periodic_fetch():
"Any Entra user in the tenant can authenticate and access AOC. " "Any Entra user in the tenant can authenticate and access AOC. "
"Set AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS to restrict access." "Set AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS to restrict access."
) )
if not WEBHOOK_CLIENT_SECRET:
logger.warning(
"WEBHOOK_CLIENT_SECRET is not set. Graph webhook notifications will be accepted without "
"clientState validation, allowing any HTTP client to spoof Graph notifications. "
"Set WEBHOOK_CLIENT_SECRET to the clientState used when creating Graph subscriptions."
)
if ENABLE_PERIODIC_FETCH: if ENABLE_PERIODIC_FETCH:
app.state.fetch_task = asyncio.create_task(_periodic_fetch()) app.state.fetch_task = asyncio.create_task(_periodic_fetch())
+14 -12
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel, ConfigDict from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class EventItem(BaseModel): class EventItem(BaseModel):
@@ -51,35 +53,35 @@ class SourceHealthResponse(BaseModel):
class TagsUpdateRequest(BaseModel): class TagsUpdateRequest(BaseModel):
tags: list[str] tags: list[str] = Field(..., max_length=50)
class BulkTagsRequest(BaseModel): class BulkTagsRequest(BaseModel):
tags: list[str] tags: list[str] = Field(..., max_length=50)
mode: str = "append" # "append" or "replace" mode: Literal["append", "replace"] = "append"
class CommentAddRequest(BaseModel): class CommentAddRequest(BaseModel):
text: str text: str = Field(..., min_length=1, max_length=5000)
class AlertCondition(BaseModel): class AlertCondition(BaseModel):
field: str field: str = Field(..., max_length=100)
op: str # eq, neq, contains, in, after_hours op: Literal["eq", "neq", "contains", "in", "after_hours", "threshold_count"]
value: str | list[str] | None = None value: str | list[str] | None = None
class AlertRuleResponse(BaseModel): class AlertRuleResponse(BaseModel):
id: str | None = None id: str | None = None
name: str name: str = Field(..., max_length=200)
enabled: bool enabled: bool
severity: str severity: Literal["high", "medium", "low"]
conditions: list[AlertCondition] conditions: list[AlertCondition] = Field(..., max_length=20)
message: str message: str = Field(..., max_length=1000)
class AskRequest(BaseModel): class AskRequest(BaseModel):
question: str question: str = Field(..., min_length=1, max_length=2000)
services: list[str] | None = None services: list[str] | None = None
actor: str | None = None actor: str | None = None
operation: str | None = None operation: str | None = None
+28
View File
@@ -4,7 +4,9 @@ Supported channels:
- webhook: POST JSON to any URL (Slack, Teams, generic) - webhook: POST JSON to any URL (Slack, Teams, generic)
""" """
import ipaddress
from datetime import UTC, datetime from datetime import UTC, datetime
from urllib.parse import urlparse
import requests import requests
import structlog import structlog
@@ -15,6 +17,26 @@ logger = structlog.get_logger("aoc.notifications")
WEBHOOK_TIMEOUT = 15 WEBHOOK_TIMEOUT = 15
def _validate_webhook_url(url: str):
"""Prevent SSRF by rejecting internal/reserved addresses."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Webhook URL scheme '{parsed.scheme}' is not allowed")
hostname = (parsed.hostname or "").lower()
if not hostname:
raise ValueError("Webhook URL must have a valid hostname")
blocked = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "169.254.169.254"}
if hostname in blocked:
raise ValueError(f"Webhook URL hostname '{hostname}' is not allowed")
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError(f"Webhook URL IP '{hostname}' is not allowed")
except ValueError as exc:
if "not allowed" in str(exc):
raise
@retry( @retry(
stop=stop_after_attempt(3), stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10), wait=wait_exponential(multiplier=1, min=2, max=10),
@@ -142,6 +164,12 @@ def send_notification(
if not webhook_url: if not webhook_url:
return False return False
try:
_validate_webhook_url(webhook_url)
except ValueError as exc:
logger.warning("Notification blocked: invalid webhook URL", error=str(exc))
return False
builders = { builders = {
"slack": _build_slack_payload, "slack": _build_slack_payload,
"teams": _build_teams_payload, "teams": _build_teams_payload,
+4
View File
@@ -42,6 +42,8 @@ def _get_path_category(path: str) -> str:
return "ask" return "ask"
if path.startswith("/api/events/bulk-tags"): if path.startswith("/api/events/bulk-tags"):
return "write" return "write"
if "/explain" in path:
return "explain"
return "default" return "default"
@@ -51,6 +53,8 @@ def _limit_for_category(category: str) -> tuple[int, int]:
return (10, 3600) # 10 per hour return (10, 3600) # 10 per hour
if category == "ask": if category == "ask":
return (30, 60) # 30 per minute return (30, 60) # 30 per minute
if category == "explain":
return (20, 60) # 20 per minute — LLM + Graph API calls
if category == "write": if category == "write":
return (20, 60) # 20 per minute return (20, 60) # 20 per minute
return (RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS) return (RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
+5 -2
View File
@@ -1,5 +1,8 @@
"""Alert management endpoints.""" """Alert management endpoints."""
import re
from typing import Literal
from auth import require_auth from auth import require_auth
from bson import ObjectId from bson import ObjectId
from database import alerts_collection from database import alerts_collection
@@ -10,7 +13,7 @@ router = APIRouter(dependencies=[Depends(require_auth)])
class AlertStatusUpdate(BaseModel): class AlertStatusUpdate(BaseModel):
status: str # open | acknowledged | resolved | false_positive status: Literal["open", "acknowledged", "resolved", "false_positive"]
class AlertListResponse(BaseModel): class AlertListResponse(BaseModel):
@@ -32,7 +35,7 @@ def list_alerts(
if severity: if severity:
query["severity"] = severity query["severity"] = severity
if rule_name: if rule_name:
query["rule_name"] = {"$regex": rule_name, "$options": "i"} query["rule_name"] = {"$regex": re.escape(rule_name), "$options": "i"}
total = alerts_collection.count_documents(query) total = alerts_collection.count_documents(query)
skip = (page - 1) * page_size skip = (page - 1) * page_size
+4 -2
View File
@@ -75,12 +75,14 @@ def run_fetch(hours: int = 168):
@router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse) @router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse)
def fetch_logs( async def fetch_logs(
hours: int = Query(default=168, ge=1, le=720), hours: int = Query(default=168, ge=1, le=720),
user: dict = Depends(require_auth), user: dict = Depends(require_auth),
): ):
import asyncio
try: try:
result = run_fetch(hours=hours) result = await asyncio.to_thread(run_fetch, hours=hours)
log_action( log_action(
"fetch_audit_logs", "fetch_audit_logs",
"/api/fetch-audit-logs", "/api/fetch-audit-logs",
+20 -9
View File
@@ -7,10 +7,18 @@ import structlog
from auth import require_auth from auth import require_auth
from database import saved_searches_collection from database import saved_searches_collection
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
router = APIRouter(dependencies=[Depends(require_auth)]) router = APIRouter(dependencies=[Depends(require_auth)])
logger = structlog.get_logger("aoc.saved_searches") logger = structlog.get_logger("aoc.saved_searches")
MAX_SAVED_SEARCHES_PER_USER = 50
class SavedSearchCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
filters: dict = Field(default_factory=dict)
def _user_sub(user: dict) -> str: def _user_sub(user: dict) -> str:
return user.get("sub", "anonymous") return user.get("sub", "anonymous")
@@ -29,22 +37,25 @@ async def list_saved_searches(user: dict = Depends(require_auth)):
@router.post("/saved-searches") @router.post("/saved-searches")
async def create_saved_search(body: dict, user: dict = Depends(require_auth)): async def create_saved_search(body: SavedSearchCreate, user: dict = Depends(require_auth)):
"""Save the current filter set.""" """Save the current filter set."""
name = (body.get("name") or "").strip() sub = _user_sub(user)
if not name: existing = saved_searches_collection.count_documents({"created_by": sub})
raise HTTPException(status_code=400, detail="Name is required") if existing >= MAX_SAVED_SEARCHES_PER_USER:
raise HTTPException(
status_code=400,
detail=f"Maximum of {MAX_SAVED_SEARCHES_PER_USER} saved searches per user reached.",
)
filters = body.get("filters") or {}
doc = { doc = {
"_id": str(uuid.uuid4()), "_id": str(uuid.uuid4()),
"name": name, "name": body.name,
"filters": filters, "filters": body.filters,
"created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), "created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
"created_by": _user_sub(user), "created_by": sub,
} }
saved_searches_collection.insert_one(doc) saved_searches_collection.insert_one(doc)
logger.info("Saved search created", name=name, user=doc["created_by"]) logger.info("Saved search created", name=body.name, user=sub)
doc["id"] = doc.pop("_id") doc["id"] = doc.pop("_id")
return doc return doc
-1
View File
@@ -52,7 +52,6 @@ async def graph_webhook(request: Request):
"Received Graph notification", "Received Graph notification",
change_type=notification.get("changeType"), change_type=notification.get("changeType"),
resource=notification.get("resource"), resource=notification.get("resource"),
client_state=client_state,
) )
return {"status": "accepted"} return {"status": "accepted"}
+13 -1
View File
@@ -11,13 +11,25 @@ AUDIT_CONTENT_TYPES = {
} }
# Office 365 Management Activity API hard limits
_API_MAX_WINDOW_HOURS = 24
_API_MAX_LOOKBACK_DAYS = 7
def _time_window(hours: int, since: str | None = None): def _time_window(hours: int, since: str | None = None):
end = datetime.utcnow() end = datetime.utcnow()
earliest_allowed = end - timedelta(days=_API_MAX_LOOKBACK_DAYS)
max_window_start = end - timedelta(hours=_API_MAX_WINDOW_HOURS)
if since: if since:
# Office 365 API expects format without Z # Office 365 API expects format without Z
start = datetime.fromisoformat(since.replace("Z", "+00:00")).replace(tzinfo=None) start = datetime.fromisoformat(since.replace("Z", "+00:00")).replace(tzinfo=None)
# Clamp: the API rejects windows > 24 h or start times > 7 days in the past.
# If the watermark is stale (e.g. after a long outage), cap to the most recent
# 24-hour window so the API accepts the request; subsequent fetches catch up.
start = max(start, earliest_allowed, max_window_start)
else: else:
start = end - timedelta(hours=hours) start = max(end - timedelta(hours=min(hours, _API_MAX_WINDOW_HOURS)), earliest_allowed)
return start.strftime("%Y-%m-%dT%H:%M:%S"), end.strftime("%Y-%m-%dT%H:%M:%S") return start.strftime("%Y-%m-%dT%H:%M:%S"), end.strftime("%Y-%m-%dT%H:%M:%S")
+2 -2
View File
@@ -157,8 +157,8 @@ def test_saved_searches_delete_not_found(client, monkeypatch):
def test_saved_searches_create_validation(client, monkeypatch): def test_saved_searches_create_validation(client, monkeypatch):
monkeypatch.setattr("auth.AUTH_ENABLED", False) monkeypatch.setattr("auth.AUTH_ENABLED", False)
response = client.post("/api/saved-searches", json={"name": " ", "filters": {}}) response = client.post("/api/saved-searches", json={"name": "", "filters": {}})
assert response.status_code == 400 assert response.status_code == 422
def test_privacy_filtering_events_by_operation(client, mock_events_collection, monkeypatch): def test_privacy_filtering_events_by_operation(client, mock_events_collection, monkeypatch):
+1 -1
View File
@@ -141,7 +141,7 @@ class TestBuildEventQuery:
class TestAskEndpoint: class TestAskEndpoint:
def test_ask_empty_question(self, client): def test_ask_empty_question(self, client):
response = client.post("/api/ask", json={"question": ""}) response = client.post("/api/ask", json={"question": ""})
assert response.status_code == 400 assert response.status_code == 422
def test_ask_no_events(self, client): def test_ask_no_events(self, client):
response = client.post("/api/ask", json={"question": "What happened to device NONEXISTENT in the last 3 days?"}) response = client.post("/api/ask", json={"question": "What happened to device NONEXISTENT in the last 3 days?"})
+4 -3
View File
@@ -1,3 +1,4 @@
import asyncio
from unittest.mock import patch from unittest.mock import patch
import auth import auth
@@ -28,19 +29,19 @@ def test_allowed_by_group():
@patch("auth.AUTH_ENABLED", False) @patch("auth.AUTH_ENABLED", False)
def test_require_auth_disabled(): def test_require_auth_disabled():
claims = require_auth(None) claims = asyncio.run(require_auth(None))
assert claims["sub"] == "anonymous" assert claims["sub"] == "anonymous"
@patch("auth.AUTH_ENABLED", True) @patch("auth.AUTH_ENABLED", True)
def test_require_auth_missing_header(): def test_require_auth_missing_header():
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
require_auth(None) asyncio.run(require_auth(None))
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
@patch("auth.AUTH_ENABLED", True) @patch("auth.AUTH_ENABLED", True)
def test_require_auth_invalid_bearer(): def test_require_auth_invalid_bearer():
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
require_auth("Basic abc") asyncio.run(require_auth("Basic abc"))
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
+1 -1
View File
@@ -33,7 +33,7 @@ services:
- mongo - mongo
- redis - redis
ports: ports:
- "8000:8000" - "127.0.0.1:8000:8000"
worker: worker:
build: ./backend build: ./backend
+2 -4
View File
@@ -30,11 +30,9 @@ http {
gzip_comp_level 6; gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
# Security headers # Security headers most headers are set by the backend; only add non-duplicates here.
add_header X-Frame-Options "SAMEORIGIN" always; # X-XSS-Protection is kept for legacy browser compatibility.
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Upstream backend # Upstream backend
upstream aoc_backend { upstream aoc_backend {