diff --git a/.gitignore b/.gitignore index 21b4c57..d400280 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ venv/ coverage.xml .vscode/ .idea/ +memory/ diff --git a/VERSION b/VERSION index 68ced4b..25eebeb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.14 +1.7.15 diff --git a/backend/auth.py b/backend/auth.py index 541c82e..303a6ce 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -1,4 +1,6 @@ +import asyncio import contextvars +import threading import time import requests @@ -20,23 +22,37 @@ from jwt.algorithms import RSAAlgorithm _auth_context: contextvars.ContextVar[dict | None] = contextvars.ContextVar("auth_context", default=None) JWKS_CACHE = {"exp": 0, "keys": []} +_jwks_lock = threading.Lock() logger = structlog.get_logger("aoc.auth") -def _get_jwks(): - now = time.time() - if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now: - return JWKS_CACHE["keys"] - +def _fetch_jwks_blocking() -> list: + """Fetch JWKS from Microsoft — runs in a thread, never in the event loop.""" oidc = requests.get( f"https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration", timeout=10, ).json() jwks_uri = oidc["jwks_uri"] - keys = requests.get(jwks_uri, timeout=10).json()["keys"] - JWKS_CACHE["keys"] = keys - JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h - return keys + return requests.get(jwks_uri, timeout=10).json()["keys"] + + +def _get_jwks(): + now = time.time() + with _jwks_lock: + if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now: + return JWKS_CACHE["keys"] + keys = _fetch_jwks_blocking() + JWKS_CACHE["keys"] = keys + JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h + return keys + + +async def _get_jwks_async() -> list: + """Non-blocking JWKS fetch: return from cache or refresh in a thread pool.""" + now = time.time() + if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now: + return JWKS_CACHE["keys"] + return await asyncio.to_thread(_get_jwks) def _allowed(claims: dict, allowed_roles: set[str], allowed_groups: set[str]) -> bool: @@ -96,7 +112,7 @@ def user_can_access_privacy_services(claims: dict) -> bool: return bool(user_roles.intersection(PRIVACY_SERVICE_ROLES)) -def require_auth(authorization: str | None = Header(None)): +async def require_auth(authorization: str | None = Header(None)): if not AUTH_ENABLED: user = {"sub": "anonymous"} _auth_context.set(user) @@ -106,7 +122,7 @@ def require_auth(authorization: str | None = Header(None)): raise HTTPException(status_code=401, detail="Missing bearer token") token = authorization.split(" ", 1)[1] - jwks = _get_jwks() + jwks = await _get_jwks_async() claims = _decode_token(token, jwks) if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS): diff --git a/backend/frontend/app.js b/backend/frontend/app.js new file mode 100644 index 0000000..0a4fd79 --- /dev/null +++ b/backend/frontend/app.js @@ -0,0 +1,820 @@ +function aocApp() { + return { + events: [], + sourceHealth: [], + statusText: '', + countText: '', + cursorStack: [], + nextCursor: null, + currentCursor: null, + modalOpen: false, + modalBody: '', + modalEventId: '', + modalExplanation: '', + modalExplainLoading: false, + modalExplainError: '', + authBtnText: 'Login', + authConfig: null, + msalInstance: null, + account: null, + accessToken: null, + authScopes: [], + filters: { + actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '', + }, + panelState: { sourceHealth: true, alerts: true, rules: true, filters: true, ask: true, events: true }, + options: { actors: [], services: [], operations: [], results: [] }, + savedSearches: [], + appVersion: '', + repoUrl: 'https://git.cqre.net/cqrenet/aoc', + docsUrl: 'https://git.cqre.net/cqrenet/aoc/src/branch/main/README.md', + aiFeaturesEnabled: true, + alertSummary: { total_open: 0, high: 0, medium: 0, low: 0 }, + alerts: [], + alertsTotal: 0, + alertsPage: 1, + alertsFilter: { status: 'open', severity: '' }, + rules: [], + ruleModalOpen: false, + ruleEditId: null, + ruleEdit: { name: '', enabled: true, severity: 'medium', message: '', conditions: [] }, + askQuestionText: '', + askLoading: false, + askAnswer: '', + askAnswerHtml: '', + askEvents: [], + askLlmUsed: false, + askLlmError: '', + + async initApp() { + await this.loadVersion(); + await this.initAuth(); + this.loadSavedFilters(); + this.loadPanelState(); + if (!this.authConfig?.auth_enabled || this.accessToken) { + await this.loadFilterOptions(); + await this.loadSavedSearches(); + await this.loadSourceHealth(); + await this.loadAlertSummary(); + await this.loadAlerts(); + await this.loadRules(); + await this.loadEvents(); + } + }, + + loadSavedFilters() { + try { + const saved = localStorage.getItem('aoc_filters'); + if (!saved) return; + const parsed = JSON.parse(saved); + const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags']; + fields.forEach((f) => { + if (parsed[f] !== undefined) this.filters[f] = parsed[f]; + }); + } catch {} + }, + + saveFilters() { + try { + localStorage.setItem('aoc_filters', JSON.stringify(this.filters)); + } catch {} + }, + + loadPanelState() { + try { + const saved = localStorage.getItem('aoc_panels'); + if (saved) { + const parsed = JSON.parse(saved); + Object.keys(parsed).forEach((k) => { if (this.panelState[k] !== undefined) this.panelState[k] = parsed[k]; }); + } + } catch {} + }, + + savePanelState() { + try { + localStorage.setItem('aoc_panels', JSON.stringify(this.panelState)); + } catch {} + }, + + togglePanel(key) { + this.panelState[key] = !this.panelState[key]; + this.savePanelState(); + }, + + async loadVersion() { + try { + const res = await fetch('/api/version'); + if (res.ok) { + const body = await res.json(); + this.appVersion = (body.version || '').replace(/^v/, ''); + } + } catch {} + }, + + authHeader() { + return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}; + }, + + pickToken(res) { + if (!res) return null; + const clientId = this.authConfig?.client_id; + // If accessToken is present and its audience matches our API, use it. + if (res.accessToken && clientId) { + try { + const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); + const payload = JSON.parse(atob(padded)); + if (payload.aud === clientId) { + return res.accessToken; + } + } catch {} + } + // Fall back to idToken (always aud=clientId) or accessToken + return res.idToken || res.accessToken || null; + }, + + async initAuth() { + try { + const res = await fetch('/api/config/auth'); + if (!res.ok) { + console.error('Auth config fetch failed:', res.status, res.statusText); + this.authConfig = { auth_enabled: false, _error: res.status }; + } else { + this.authConfig = await res.json(); + } + } catch (err) { + console.error('Auth config fetch error:', err); + this.authConfig = { auth_enabled: false, _error: 'network' }; + } + + try { + const featRes = await fetch('/api/config/features'); + if (featRes.ok) { + const featBody = await featRes.json(); + this.aiFeaturesEnabled = featBody.ai_features_enabled !== false; + if (featBody.default_page_size) { + this.filters.limit = featBody.default_page_size; + } else { + this.filters.limit = 24; + } + } else { + this.aiFeaturesEnabled = true; + } + } catch { + this.aiFeaturesEnabled = true; + } + + if (!this.authConfig?.auth_enabled) { + this.authBtnText = 'Auth: OFF'; + console.warn('AOC auth is disabled. Set AUTH_ENABLED=true in .env to enable login.'); + return; + } + + const tenantId = this.authConfig.tenant_id; + const clientId = this.authConfig.client_id; + if (!clientId || !tenantId) { + this.authBtnText = 'Auth: misconfigured'; + this.statusText = 'Auth is enabled but client_id or tenant_id is missing. Check .env configuration.'; + console.error('AOC auth misconfigured: missing client_id or tenant_id in /api/config/auth'); + return; + } + + if (typeof msal === 'undefined' || !msal.PublicClientApplication) { + this.statusText = 'Login library failed to load. Please check network or CDN.'; + return; + } + + const baseScope = this.authConfig.scope || ""; + this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)])); + const authority = `https://login.microsoftonline.com/${tenantId}`; + const redirectUri = window.location.origin; + + this.msalInstance = new msal.PublicClientApplication({ + auth: { clientId, authority, redirectUri }, + cache: { cacheLocation: 'sessionStorage' }, + }); + + const redirectResult = await this.msalInstance.handleRedirectPromise().catch(() => null); + if (redirectResult) { + this.account = redirectResult.account; + this.msalInstance.setActiveAccount(this.account); + this.accessToken = this.pickToken(redirectResult); + } else { + const accounts = this.msalInstance.getAllAccounts(); + if (accounts.length) { + this.account = accounts[0]; + this.msalInstance.setActiveAccount(this.account); + this.accessToken = await this.acquireToken(this.authScopes); + } + } + + this.updateAuthButtons(); + }, + + async acquireToken(scopes) { + if (!this.msalInstance || !this.account) return null; + const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account: this.account }; + try { + const res = await this.msalInstance.acquireTokenSilent(request); + return this.pickToken(res); + } catch { + const res = await this.msalInstance.acquireTokenPopup(request); + return this.pickToken(res); + } + }, + + updateAuthButtons() { + const loggedIn = !!this.account; + if (this.authConfig?.auth_enabled) { + this.authBtnText = loggedIn ? 'Logout' : 'Login'; + } + if (loggedIn) { + this.acquireToken(this.authScopes).then((t) => { if (t) this.accessToken = t; }).catch(() => {}); + this.statusText = ''; + } else if (this.authConfig?.auth_enabled) { + this.statusText = 'Please log in to view events.'; + } + }, + + async toggleAuth() { + if (!this.authConfig?.auth_enabled || !this.msalInstance) return; + if (this.account) { + const acc = this.msalInstance.getActiveAccount(); + this.accessToken = null; + this.account = null; + this.updateAuthButtons(); + if (acc) await this.msalInstance.logoutPopup({ account: acc }); + return; + } + const scopes = this.authScopes && this.authScopes.length ? this.authScopes : ['openid', 'profile', 'email']; + this.statusText = 'Redirecting to sign in...'; + this.msalInstance.loginRedirect({ scopes }); + }, + + async loadEvents(cursor) { + this.currentCursor = cursor || null; + const params = new URLSearchParams(); + ['actor', 'operation', 'result', 'search'].forEach((key) => { + const val = this.filters[key]; + if (val) params.append(key, val); + }); + if (this.filters.selectedServices && this.filters.selectedServices.length) { + this.filters.selectedServices.forEach((s) => params.append('services', s)); + } + if (this.filters.includeTags) { + this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t)); + } + if (this.filters.excludeTags) { + this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t)); + } + if (this.filters.start) { + const d = new Date(this.filters.start); + if (!isNaN(d.getTime())) params.append('start', d.toISOString()); + } + if (this.filters.end) { + const d = new Date(this.filters.end); + if (!isNaN(d.getTime())) params.append('end', d.toISOString()); + } + params.append('page_size', String(this.filters.limit || 50)); + if (cursor) params.append('cursor', cursor); + + this.statusText = 'Loading events…'; + this.countText = ''; + + if (this.authConfig?.auth_enabled && !this.accessToken) { + this.statusText = 'Please sign in to load events.'; + return; + } + + try { + const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...this.authHeader() } }); + if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`); + const body = await res.json(); + this.events = body.items || []; + this.nextCursor = body.next_cursor || null; + this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : ''; + this.statusText = this.events.length ? '' : 'No events found for these filters.'; + this.saveFilters(); + } catch (err) { + this.statusText = err.message || 'Failed to load events.'; + } + }, + + async fetchLogs() { + this.statusText = 'Fetching latest audit logs…'; + if (this.authConfig?.auth_enabled && !this.accessToken) { + this.statusText = 'Please sign in first.'; + return; + } + try { + const res = await fetch('/api/fetch-audit-logs', { headers: this.authHeader() }); + if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${await res.text()}`); + const body = await res.json(); + const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : ''; + this.statusText = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`; + this.resetPagination(); + await this.loadEvents(); + await this.loadSourceHealth(); + } catch (err) { + this.statusText = err.message || 'Failed to fetch audit logs.'; + } + }, + + async loadFilterOptions() { + if (this.authConfig?.auth_enabled && !this.accessToken) return; + try { + const res = await fetch('/api/filter-options', { headers: this.authHeader() }); + if (!res.ok) return; + const opts = await res.json(); + this.options.actors = (opts.actors || []).slice(0, 200); + this.options.services = (opts.services || []).slice(0, 200); + this.options.operations = (opts.operations || []).slice(0, 200); + this.options.results = (opts.results || []).slice(0, 200); + + const saved = localStorage.getItem('aoc_filters'); + if (!saved && this.options.services.length) { + // Default: show all services (privacy controls handle exclusions server-side) + this.filters.selectedServices = [...this.options.services]; + } else if (saved) { + try { + const parsed = JSON.parse(saved); + if (parsed.selectedServices) { + this.filters.selectedServices = parsed.selectedServices.filter((s) => this.options.services.includes(s)); + } + } catch {} + } + } catch {} + }, + + async loadSourceHealth() { + try { + const res = await fetch('/api/source-health', { headers: this.authHeader() }); + if (!res.ok) return; + this.sourceHealth = await res.json(); + } catch {} + }, + + async loadSavedSearches() { + try { + const res = await fetch('/api/saved-searches', { headers: this.authHeader() }); + if (!res.ok) return; + this.savedSearches = await res.json(); + } catch {} + }, + + async saveCurrentFilters() { + const name = prompt('Name this saved filter:'); + if (!name || !name.trim()) return; + try { + const res = await fetch('/api/saved-searches', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify({ name: name.trim(), filters: { ...this.filters } }), + }); + if (!res.ok) throw new Error(await res.text()); + const created = await res.json(); + this.savedSearches.unshift(created); + this.statusText = 'Filters saved.'; + setTimeout(() => { if (this.statusText === 'Filters saved.') this.statusText = ''; }, 2000); + } catch (err) { + this.statusText = err.message || 'Failed to save filters.'; + } + }, + + applySavedSearch(ss) { + if (!ss || !ss.filters) return; + const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags']; + fields.forEach((f) => { + if (ss.filters[f] !== undefined) this.filters[f] = ss.filters[f]; + }); + // Validate selectedServices against current options + this.filters.selectedServices = this.filters.selectedServices.filter((s) => this.options.services.includes(s)); + this.resetPagination(); + this.loadEvents(); + }, + + async deleteSavedSearch(id) { + if (!confirm('Delete this saved search?')) return; + try { + const res = await fetch(`/api/saved-searches/${id}`, { + method: 'DELETE', + headers: this.authHeader(), + }); + if (!res.ok) throw new Error(await res.text()); + this.savedSearches = this.savedSearches.filter((s) => s.id !== id); + } catch (err) { + this.statusText = err.message || 'Failed to delete saved search.'; + } + }, + + resetPagination() { + this.cursorStack = []; + this.nextCursor = null; + this.currentCursor = null; + }, + + goPrev() { + if (this.cursorStack.length) { + const prevCursor = this.cursorStack.pop(); + this.loadEvents(prevCursor); + } + }, + + goNext() { + if (this.nextCursor) { + this.cursorStack.push(this.currentCursor); + this.loadEvents(this.nextCursor); + } + }, + + clearFilters() { + this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '' }; + this.saveFilters(); + this.resetPagination(); + this.loadEvents(); + }, + + filterByService(service) { + if (!service) return; + this.filters.selectedServices = [service]; + this.saveFilters(); + this.resetPagination(); + this.loadEvents(); + }, + + filterByResult(result) { + if (!result) return; + this.filters.result = this.filters.result === result ? '' : result; + this.saveFilters(); + this.resetPagination(); + this.loadEvents(); + }, + + async loadAlertSummary() { + try { + const res = await fetch('/api/alerts/summary', { headers: this.authHeader() }); + if (!res.ok) return; + const body = await res.json(); + this.alertSummary.total_open = body.total_open || 0; + const sev = body.by_status_severity || []; + this.alertSummary.high = sev.filter((s) => s._id.severity === 'high' && s._id.status === 'open').reduce((a, b) => a + b.count, 0); + this.alertSummary.medium = sev.filter((s) => s._id.severity === 'medium' && s._id.status === 'open').reduce((a, b) => a + b.count, 0); + this.alertSummary.low = sev.filter((s) => s._id.severity === 'low' && s._id.status === 'open').reduce((a, b) => a + b.count, 0); + } catch {} + }, + + async loadAlerts() { + try { + const params = new URLSearchParams(); + params.append('page_size', '20'); + params.append('page', String(this.alertsPage)); + if (this.alertsFilter.status) params.append('status', this.alertsFilter.status); + if (this.alertsFilter.severity) params.append('severity', this.alertsFilter.severity); + const res = await fetch(`/api/alerts?${params.toString()}`, { headers: this.authHeader() }); + if (!res.ok) return; + const body = await res.json(); + this.alerts = body.items || []; + this.alertsTotal = body.total || 0; + } catch {} + }, + + async updateAlertStatus(alertId, status) { + try { + const res = await fetch(`/api/alerts/${alertId}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify({ status }), + }); + if (res.ok) { + await this.loadAlerts(); + await this.loadAlertSummary(); + } + } catch {} + }, + + async loadRules() { + try { + const res = await fetch('/api/rules', { headers: this.authHeader() }); + if (!res.ok) return; + this.rules = await res.json(); + } catch {} + }, + + openRuleEditor(rule) { + if (rule) { + this.ruleEditId = rule.id; + this.ruleEdit = { + name: rule.name, + enabled: rule.enabled, + severity: rule.severity, + message: rule.message, + conditions: JSON.parse(JSON.stringify(rule.conditions)), + }; + } else { + this.ruleEditId = null; + this.ruleEdit = { name: '', enabled: true, severity: 'medium', message: '', conditions: [] }; + } + this.ruleModalOpen = true; + }, + + async saveRule() { + const payload = { ...this.ruleEdit }; + try { + const url = this.ruleEditId ? `/api/rules/${this.ruleEditId}` : '/api/rules'; + const method = this.ruleEditId ? 'PUT' : 'POST'; + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(await res.text()); + this.ruleModalOpen = false; + await this.loadRules(); + } catch (err) { + alert('Failed to save rule: ' + err.message); + } + }, + + async toggleRule(ruleId, enabled) { + try { + const rule = this.rules.find((r) => r.id === ruleId); + if (!rule) return; + const res = await fetch(`/api/rules/${ruleId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify({ ...rule, enabled }), + }); + if (res.ok) await this.loadRules(); + } catch {} + }, + + async deleteRule(ruleId) { + if (!confirm('Delete this rule?')) return; + try { + const res = await fetch(`/api/rules/${ruleId}`, { + method: 'DELETE', + headers: this.authHeader(), + }); + if (res.ok) await this.loadRules(); + } catch {} + }, + + async askQuestion() { + const q = this.askQuestionText.trim(); + if (!q) return; + this.askLoading = true; + this.askAnswer = ''; + this.askAnswerHtml = ''; + this.askEvents = []; + this.askLlmError = ''; + + const payload = { question: q }; + if (this.filters.selectedServices && this.filters.selectedServices.length) { + payload.services = this.filters.selectedServices; + } + if (this.filters.actor) payload.actor = this.filters.actor; + if (this.filters.operation) payload.operation = this.filters.operation; + if (this.filters.result) payload.result = this.filters.result; + if (this.filters.start) payload.start = new Date(this.filters.start).toISOString(); + if (this.filters.end) payload.end = new Date(this.filters.end).toISOString(); + if (this.filters.includeTags) { + payload.include_tags = this.filters.includeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean); + } + if (this.filters.excludeTags) { + payload.exclude_tags = this.filters.excludeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean); + } + + try { + const res = await fetch('/api/ask', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(await res.text()); + const body = await res.json(); + this.askAnswer = body.answer; + this.askAnswerHtml = this._mdToHtml(body.answer); + this.askEvents = body.events || []; + this.askLlmUsed = body.llm_used; + this.askLlmError = body.llm_error || ''; + } catch (err) { + this.askAnswer = 'Sorry, something went wrong: ' + (err.message || 'Unknown error'); + this.askAnswerHtml = this.askAnswer; + } finally { + this.askLoading = false; + } + }, + + clearAsk() { + this.askQuestionText = ''; + this.askAnswer = ''; + this.askAnswerHtml = ''; + this.askEvents = []; + this.askLlmUsed = false; + this.askLlmError = ''; + }, + + _mdToHtml(text) { + // Very lightweight markdown-to-HTML for LLM answers + return text + .replace(/&/g, '&').replace(//g, '>') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/Event #(\d+)/g, 'Event #$1') + .replace(/\n/g, '
'); + }, + + 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); + }, + }; +} diff --git a/backend/frontend/index.html b/backend/frontend/index.html index 8b95074..d14f787 100644 --- a/backend/frontend/index.html +++ b/backend/frontend/index.html @@ -5,6 +5,7 @@ Admin Operations Center + @@ -452,828 +453,5 @@ - - diff --git a/backend/main.py b/backend/main.py index a980cae..3388423 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ from config import ( ENABLE_PERIODIC_FETCH, FETCH_INTERVAL_MINUTES, METRICS_ALLOWED_IPS, + WEBHOOK_CLIENT_SECRET, ) from database import setup_indexes 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"): response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net alcdn.msauth.net; " + "script-src 'self' cdn.jsdelivr.net alcdn.msauth.net; " "style-src 'self' 'unsafe-inline'; " "connect-src 'self' https://login.microsoftonline.com; " "frame-src 'self' https://login.microsoftonline.com; " @@ -284,6 +285,12 @@ async def start_periodic_fetch(): "Any Entra user in the tenant can authenticate and access AOC. " "Set AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS to restrict access." ) + if not WEBHOOK_CLIENT_SECRET: + logger.warning( + "WEBHOOK_CLIENT_SECRET is not set. Graph webhook notifications will be accepted without " + "clientState validation, allowing any HTTP client to spoof Graph notifications. " + "Set WEBHOOK_CLIENT_SECRET to the clientState used when creating Graph subscriptions." + ) if ENABLE_PERIODIC_FETCH: app.state.fetch_task = asyncio.create_task(_periodic_fetch()) diff --git a/backend/models/api.py b/backend/models/api.py index 4a10034..014f142 100644 --- a/backend/models/api.py +++ b/backend/models/api.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel, ConfigDict +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field class EventItem(BaseModel): @@ -51,35 +53,35 @@ class SourceHealthResponse(BaseModel): class TagsUpdateRequest(BaseModel): - tags: list[str] + tags: list[str] = Field(..., max_length=50) class BulkTagsRequest(BaseModel): - tags: list[str] - mode: str = "append" # "append" or "replace" + tags: list[str] = Field(..., max_length=50) + mode: Literal["append", "replace"] = "append" class CommentAddRequest(BaseModel): - text: str + text: str = Field(..., min_length=1, max_length=5000) class AlertCondition(BaseModel): - field: str - op: str # eq, neq, contains, in, after_hours + field: str = Field(..., max_length=100) + op: Literal["eq", "neq", "contains", "in", "after_hours", "threshold_count"] value: str | list[str] | None = None class AlertRuleResponse(BaseModel): id: str | None = None - name: str + name: str = Field(..., max_length=200) enabled: bool - severity: str - conditions: list[AlertCondition] - message: str + severity: Literal["high", "medium", "low"] + conditions: list[AlertCondition] = Field(..., max_length=20) + message: str = Field(..., max_length=1000) class AskRequest(BaseModel): - question: str + question: str = Field(..., min_length=1, max_length=2000) services: list[str] | None = None actor: str | None = None operation: str | None = None diff --git a/backend/notifications.py b/backend/notifications.py index a373a55..4290a2f 100644 --- a/backend/notifications.py +++ b/backend/notifications.py @@ -4,7 +4,9 @@ Supported channels: - webhook: POST JSON to any URL (Slack, Teams, generic) """ +import ipaddress from datetime import UTC, datetime +from urllib.parse import urlparse import requests import structlog @@ -15,6 +17,26 @@ logger = structlog.get_logger("aoc.notifications") WEBHOOK_TIMEOUT = 15 +def _validate_webhook_url(url: str): + """Prevent SSRF by rejecting internal/reserved addresses.""" + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Webhook URL scheme '{parsed.scheme}' is not allowed") + hostname = (parsed.hostname or "").lower() + if not hostname: + raise ValueError("Webhook URL must have a valid hostname") + blocked = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "169.254.169.254"} + if hostname in blocked: + raise ValueError(f"Webhook URL hostname '{hostname}' is not allowed") + try: + ip = ipaddress.ip_address(hostname) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise ValueError(f"Webhook URL IP '{hostname}' is not allowed") + except ValueError as exc: + if "not allowed" in str(exc): + raise + + @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), @@ -142,6 +164,12 @@ def send_notification( if not webhook_url: return False + try: + _validate_webhook_url(webhook_url) + except ValueError as exc: + logger.warning("Notification blocked: invalid webhook URL", error=str(exc)) + return False + builders = { "slack": _build_slack_payload, "teams": _build_teams_payload, diff --git a/backend/rate_limiter.py b/backend/rate_limiter.py index 1950248..df8b49d 100644 --- a/backend/rate_limiter.py +++ b/backend/rate_limiter.py @@ -42,6 +42,8 @@ def _get_path_category(path: str) -> str: return "ask" if path.startswith("/api/events/bulk-tags"): return "write" + if "/explain" in path: + return "explain" return "default" @@ -51,6 +53,8 @@ def _limit_for_category(category: str) -> tuple[int, int]: return (10, 3600) # 10 per hour if category == "ask": return (30, 60) # 30 per minute + if category == "explain": + return (20, 60) # 20 per minute — LLM + Graph API calls if category == "write": return (20, 60) # 20 per minute return (RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS) diff --git a/backend/routes/alerts.py b/backend/routes/alerts.py index 1a20829..b3463e8 100644 --- a/backend/routes/alerts.py +++ b/backend/routes/alerts.py @@ -1,5 +1,8 @@ """Alert management endpoints.""" +import re +from typing import Literal + from auth import require_auth from bson import ObjectId from database import alerts_collection @@ -10,7 +13,7 @@ router = APIRouter(dependencies=[Depends(require_auth)]) class AlertStatusUpdate(BaseModel): - status: str # open | acknowledged | resolved | false_positive + status: Literal["open", "acknowledged", "resolved", "false_positive"] class AlertListResponse(BaseModel): @@ -32,7 +35,7 @@ def list_alerts( if severity: query["severity"] = severity if rule_name: - query["rule_name"] = {"$regex": rule_name, "$options": "i"} + query["rule_name"] = {"$regex": re.escape(rule_name), "$options": "i"} total = alerts_collection.count_documents(query) skip = (page - 1) * page_size diff --git a/backend/routes/fetch.py b/backend/routes/fetch.py index 7f652bf..11c8725 100644 --- a/backend/routes/fetch.py +++ b/backend/routes/fetch.py @@ -75,12 +75,14 @@ def run_fetch(hours: int = 168): @router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse) -def fetch_logs( +async def fetch_logs( hours: int = Query(default=168, ge=1, le=720), user: dict = Depends(require_auth), ): + import asyncio + try: - result = run_fetch(hours=hours) + result = await asyncio.to_thread(run_fetch, hours=hours) log_action( "fetch_audit_logs", "/api/fetch-audit-logs", diff --git a/backend/routes/saved_searches.py b/backend/routes/saved_searches.py index 0ccf218..00543b1 100644 --- a/backend/routes/saved_searches.py +++ b/backend/routes/saved_searches.py @@ -7,10 +7,18 @@ import structlog from auth import require_auth from database import saved_searches_collection from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field router = APIRouter(dependencies=[Depends(require_auth)]) logger = structlog.get_logger("aoc.saved_searches") +MAX_SAVED_SEARCHES_PER_USER = 50 + + +class SavedSearchCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + filters: dict = Field(default_factory=dict) + def _user_sub(user: dict) -> str: return user.get("sub", "anonymous") @@ -29,22 +37,25 @@ async def list_saved_searches(user: dict = Depends(require_auth)): @router.post("/saved-searches") -async def create_saved_search(body: dict, user: dict = Depends(require_auth)): +async def create_saved_search(body: SavedSearchCreate, user: dict = Depends(require_auth)): """Save the current filter set.""" - name = (body.get("name") or "").strip() - if not name: - raise HTTPException(status_code=400, detail="Name is required") + sub = _user_sub(user) + existing = saved_searches_collection.count_documents({"created_by": sub}) + if existing >= MAX_SAVED_SEARCHES_PER_USER: + raise HTTPException( + status_code=400, + detail=f"Maximum of {MAX_SAVED_SEARCHES_PER_USER} saved searches per user reached.", + ) - filters = body.get("filters") or {} doc = { "_id": str(uuid.uuid4()), - "name": name, - "filters": filters, + "name": body.name, + "filters": body.filters, "created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), - "created_by": _user_sub(user), + "created_by": sub, } saved_searches_collection.insert_one(doc) - logger.info("Saved search created", name=name, user=doc["created_by"]) + logger.info("Saved search created", name=body.name, user=sub) doc["id"] = doc.pop("_id") return doc diff --git a/backend/routes/webhooks.py b/backend/routes/webhooks.py index dc3b59c..f1d883f 100644 --- a/backend/routes/webhooks.py +++ b/backend/routes/webhooks.py @@ -52,7 +52,6 @@ async def graph_webhook(request: Request): "Received Graph notification", change_type=notification.get("changeType"), resource=notification.get("resource"), - client_state=client_state, ) return {"status": "accepted"} diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8a88fc1..4aee998 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -157,8 +157,8 @@ def test_saved_searches_delete_not_found(client, monkeypatch): def test_saved_searches_create_validation(client, monkeypatch): monkeypatch.setattr("auth.AUTH_ENABLED", False) - response = client.post("/api/saved-searches", json={"name": " ", "filters": {}}) - assert response.status_code == 400 + response = client.post("/api/saved-searches", json={"name": "", "filters": {}}) + assert response.status_code == 422 def test_privacy_filtering_events_by_operation(client, mock_events_collection, monkeypatch): diff --git a/backend/tests/test_ask.py b/backend/tests/test_ask.py index b246ba8..7cb770b 100644 --- a/backend/tests/test_ask.py +++ b/backend/tests/test_ask.py @@ -141,7 +141,7 @@ class TestBuildEventQuery: class TestAskEndpoint: def test_ask_empty_question(self, client): response = client.post("/api/ask", json={"question": ""}) - assert response.status_code == 400 + assert response.status_code == 422 def test_ask_no_events(self, client): response = client.post("/api/ask", json={"question": "What happened to device NONEXISTENT in the last 3 days?"}) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index fe733ed..288f945 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import patch import auth @@ -28,19 +29,19 @@ def test_allowed_by_group(): @patch("auth.AUTH_ENABLED", False) def test_require_auth_disabled(): - claims = require_auth(None) + claims = asyncio.run(require_auth(None)) assert claims["sub"] == "anonymous" @patch("auth.AUTH_ENABLED", True) def test_require_auth_missing_header(): with pytest.raises(HTTPException) as exc_info: - require_auth(None) + asyncio.run(require_auth(None)) assert exc_info.value.status_code == 401 @patch("auth.AUTH_ENABLED", True) def test_require_auth_invalid_bearer(): with pytest.raises(HTTPException) as exc_info: - require_auth("Basic abc") + asyncio.run(require_auth("Basic abc")) assert exc_info.value.status_code == 401 diff --git a/docker-compose.yml b/docker-compose.yml index 1e2d3fb..594ac5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: - mongo - redis ports: - - "8000:8000" + - "127.0.0.1:8000:8000" worker: build: ./backend diff --git a/nginx/nginx.conf b/nginx/nginx.conf index a2b0e00..2ecc064 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -30,11 +30,9 @@ http { gzip_comp_level 6; gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; + # Security headers — most headers are set by the backend; only add non-duplicates here. + # X-XSS-Protection is kept for legacy browser compatibility. add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; # Upstream backend upstream aoc_backend {