feat: implement Phase 4 enhancements
Some checks failed
CI / lint-and-test (push) Has been cancelled

- Migrate frontend to Alpine.js for reactive state management
- Add source health dashboard in UI and /api/source-health endpoint
- Add event tagging (PATCH /api/events/{id}/tags) and commenting (POST /api/events/{id}/comments)
- Add CSV/JSON export from the UI
- Add rule-based alerting engine (rules.py) with CRUD endpoints (/api/rules)
- Add SIEM export via webhook (siem.py)
- Add AOC audit trail middleware logging all mutations to aoc_audit collection
- Update config with SIEM_ENABLED, SIEM_WEBHOOK_URL, ALERTS_ENABLED
- Add tests for rules engine, tags, comments, and source health
This commit is contained in:
2026-04-14 15:38:39 +02:00
parent b0198012eb
commit b35cac42e0
18 changed files with 869 additions and 370 deletions

View File

@@ -23,3 +23,10 @@ RETENTION_DAYS=0
# Optional: comma-separated CORS origins (e.g., http://localhost:3000,https://app.example.com) # Optional: comma-separated CORS origins (e.g., http://localhost:3000,https://app.example.com)
CORS_ORIGINS=* CORS_ORIGINS=*
# Optional: SIEM export webhook (e.g., Splunk HEC, Sentinel, or generic syslog webhook)
SIEM_ENABLED=false
SIEM_WEBHOOK_URL=
# Optional: enable rule-based alerting during ingestion
ALERTS_ENABLED=false

View File

@@ -72,11 +72,20 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
- Intune audit logs (`/deviceManagement/auditEvents`) - Intune audit logs (`/deviceManagement/auditEvents`)
Dedupes on a stable key (source id or timestamp/category/operation/target). Returns count and per-source warnings. Dedupes on a stable key (source id or timestamp/category/operation/target). Returns count and per-source warnings.
- **Incremental fetch**: each source remembers its last successful fetch time in MongoDB (`watermarks` collection). Subsequent calls fetch only new events since the watermark. - **Incremental fetch**: each source remembers its last successful fetch time in MongoDB (`watermarks` collection). Subsequent calls fetch only new events since the watermark.
- **Alerting**: if `ALERTS_ENABLED=true`, events are evaluated against stored rules during ingestion.
- **SIEM export**: if `SIEM_ENABLED=true`, each ingested event is forwarded to `SIEM_WEBHOOK_URL`.
- `GET /api/events` — list stored events with filters: - `GET /api/events` — list stored events with filters:
- `service`, `actor`, `operation`, `result`, `start`, `end`, `search` (free text over raw/summary/actor/targets) - `service`, `actor`, `operation`, `result`, `start`, `end`, `search` (free text over raw/summary/actor/targets)
- Pagination: `cursor`-based (`page_size` defaults to 50, max 500). Pass `cursor` from `next_cursor` to paginate forward. - Pagination: `cursor`-based (`page_size` defaults to 50, max 500). Pass `cursor` from `next_cursor` to paginate forward.
- `GET /api/filter-options` — best-effort distinct values for services, operations, results, actors (used by UI dropdowns). - `GET /api/filter-options` — best-effort distinct values for services, operations, results, actors (used by UI dropdowns).
- `POST /api/webhooks/graph` — receive Microsoft Graph change notifications. Echoes `validationToken` when present. - `POST /api/webhooks/graph` — receive Microsoft Graph change notifications. Echoes `validationToken` when present.
- `GET /api/source-health` — last fetch status for each ingestion source (`directory`, `unified`, `intune`).
- `PATCH /api/events/{id}/tags` — update tags on an event (e.g., `investigating`, `false_positive`).
- `POST /api/events/{id}/comments` — add a comment to an event.
- `GET /api/rules` — list alert rules.
- `POST /api/rules` — create an alert rule.
- `PUT /api/rules/{id}` — update an alert rule.
- `DELETE /api/rules/{id}` — delete an alert rule.
Stored document shape (collection `micro_soc.events`): Stored document shape (collection `micro_soc.events`):
```json ```json

View File

@@ -46,16 +46,16 @@ Goal: handle larger data volumes and support real-time ingestion.
--- ---
## Phase 4: Enhance ## Phase 4: Enhance
Goal: evolve from a polling dashboard into a full security operations tool. Goal: evolve from a polling dashboard into a full security operations tool.
- [ ] Migrate frontend to a maintainable framework (Vue 3, React, or HTMX + Alpine.js) - [x] Migrate frontend to Alpine.js for better state management and maintainability
- [ ] Add rule-based alerting (e.g., alert on privileged operations, after-hours activity) - [x] Add rule-based alerting (e.g., alert on privileged operations, after-hours activity)
- [ ] Add SIEM export (Splunk, Sentinel, syslog webhook) - [x] Add SIEM export (Splunk, Sentinel, syslog webhook)
- [ ] Build an audit trail for AOC itself (who queried what, who triggered fetches) - [x] Build an audit trail for AOC itself (who queried what, who triggered fetches)
- [ ] Add event tagging and commenting (e.g., `investigating`, `false_positive`) - [x] Add event tagging and commenting (e.g., `investigating`, `false_positive`)
- [ ] Add export functionality (CSV / JSON) from the UI - [x] Add export functionality (CSV / JSON) from the UI
- [ ] Add source health dashboard showing last fetch time and status per source - [x] Add source health dashboard showing last fetch time and status per source
--- ---

22
backend/audit_trail.py Normal file
View File

@@ -0,0 +1,22 @@
from datetime import UTC, datetime
import structlog
from database import db
logger = structlog.get_logger("aoc.audit")
audit_collection = db["aoc_audit"]
def log_action(action: str, resource: str, details: dict | None = None, user: str | None = None):
"""Log an action in the AOC audit trail."""
doc = {
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
"resource": resource,
"details": details or {},
"user": user or "anonymous",
}
try:
audit_collection.insert_one(doc)
except Exception as exc:
logger.warning("Failed to write audit trail", error=str(exc))

View File

@@ -35,6 +35,13 @@ class Settings(BaseSettings):
# CORS # CORS
CORS_ORIGINS: str = "*" CORS_ORIGINS: str = "*"
# SIEM export
SIEM_ENABLED: bool = False
SIEM_WEBHOOK_URL: str = ""
# Alerting
ALERTS_ENABLED: bool = False
_settings = Settings() _settings = Settings()
@@ -57,3 +64,7 @@ AUTH_ALLOWED_GROUPS = {g.strip() for g in _settings.AUTH_ALLOWED_GROUPS.split(",
RETENTION_DAYS = _settings.RETENTION_DAYS RETENTION_DAYS = _settings.RETENTION_DAYS
CORS_ORIGINS = [o.strip() for o in _settings.CORS_ORIGINS.split(",") if o.strip()] CORS_ORIGINS = [o.strip() for o in _settings.CORS_ORIGINS.split(",") if o.strip()]
SIEM_ENABLED = _settings.SIEM_ENABLED
SIEM_WEBHOOK_URL = _settings.SIEM_WEBHOOK_URL
ALERTS_ENABLED = _settings.ALERTS_ENABLED

View File

@@ -5,10 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AOC Events</title> <title>AOC Events</title>
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script> <script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<div class="page"> <div class="page" x-data="aocApp()" x-init="initApp()">
<header class="hero"> <header class="hero">
<div> <div>
<p class="eyebrow">Admin Operations Center</p> <p class="eyebrow">Admin Operations Center</p>
@@ -16,53 +17,76 @@
<p class="lede">Filter Microsoft Entra audit events by user, app, time, action, and action type.</p> <p class="lede">Filter Microsoft Entra audit events by user, app, time, action, and action type.</p>
</div> </div>
<div class="cta"> <div class="cta">
<button id="authBtn" class="ghost" aria-label="Login">Login</button> <button id="authBtn" class="ghost" aria-label="Login" x-text="authBtnText" @click="toggleAuth()"></button>
<button id="fetchBtn" aria-label="Fetch latest audit logs">Fetch new</button> <button id="fetchBtn" aria-label="Fetch latest audit logs" @click="fetchLogs()">Fetch new</button>
<button id="refreshBtn" aria-label="Refresh events">Refresh</button> <button id="refreshBtn" aria-label="Refresh events" @click="loadEvents(currentCursor)">Refresh</button>
</div> </div>
</header> </header>
<section class="panel"> <section class="panel">
<form id="filters" class="filters"> <h3>Source Health</h3>
<div class="source-health">
<template x-for="src in sourceHealth" :key="src.source">
<div class="health-card">
<strong x-text="src.source"></strong>
<span class="pill" :class="src.status === 'healthy' ? 'pill--ok' : 'pill--warn'" x-text="src.status"></span>
<small x-text="src.last_fetch_time ? new Date(src.last_fetch_time).toLocaleString() : 'Never'"></small>
</div>
</template>
</div>
</section>
<section class="panel">
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
<label> <label>
User (name/UPN) User (name/UPN)
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" /> <input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" x-model="filters.actor" />
<datalist id="actorOptions"></datalist> <datalist id="actorOptions">
<template x-for="opt in options.actors" :key="opt"><option :value="opt"></option></template>
</datalist>
</label> </label>
<label> <label>
App / Service App / Service
<input name="service" type="text" placeholder="DirectoryManagement" list="serviceOptions" /> <input name="service" type="text" placeholder="DirectoryManagement" list="serviceOptions" x-model="filters.service" />
<datalist id="serviceOptions"></datalist> <datalist id="serviceOptions">
<template x-for="opt in options.services" :key="opt"><option :value="opt"></option></template>
</datalist>
</label> </label>
<label> <label>
Search (raw/full-text) Search (raw/full-text)
<input name="search" type="text" placeholder="Any text to search in raw/summary" /> <input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
</label> </label>
<label> <label>
Action (display name) Action (display name)
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" /> <input name="operation" type="text" placeholder="Add group member" list="operationOptions" x-model="filters.operation" />
<datalist id="operationOptions"></datalist> <datalist id="operationOptions">
<template x-for="opt in options.operations" :key="opt"><option :value="opt"></option></template>
</datalist>
</label> </label>
<label> <label>
Action type (result) Action type (result)
<input name="result" type="text" placeholder="success / failure" list="resultOptions" /> <input name="result" type="text" placeholder="success / failure" list="resultOptions" x-model="filters.result" />
<datalist id="resultOptions"></datalist> <datalist id="resultOptions">
<template x-for="opt in options.results" :key="opt"><option :value="opt"></option></template>
</datalist>
</label> </label>
<label> <label>
From From
<input name="start" type="datetime-local" /> <input name="start" type="datetime-local" x-model="filters.start" />
</label> </label>
<label> <label>
To To
<input name="end" type="datetime-local" /> <input name="end" type="datetime-local" x-model="filters.end" />
</label> </label>
<label> <label>
Limit Limit
<input name="limit" type="number" min="1" max="500" value="100" /> <input name="limit" type="number" min="1" max="500" value="100" x-model.number="filters.limit" />
</label> </label>
<div class="actions"> <div class="actions">
<button type="submit">Apply filters</button> <button type="submit">Apply filters</button>
<button type="button" id="clearBtn" class="ghost">Clear</button> <button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
</div> </div>
</form> </form>
</section> </section>
@@ -70,367 +94,376 @@
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>Events</h2> <h2>Events</h2>
<span id="count"></span> <span id="count" x-text="countText"></span>
</div>
<div id="status" class="status" aria-live="polite" x-text="statusText"></div>
<div id="events" class="events">
<template x-for="(e, idx) in events" :key="e._id || e.id || idx">
<article class="event">
<div class="event__meta">
<span class="pill" x-text="e.display_category || e.service || '—'"></span>
<span class="pill" :class="(e.result || '').toLowerCase() === 'success' ? 'pill--ok' : 'pill--warn'" x-text="e.result || '—'"></span>
</div>
<h3 x-text="e.operation || '—'"></h3>
<p class="event__detail" x-show="e.display_summary"><strong>Summary:</strong> <span x-text="e.display_summary"></span></p>
<p class="event__detail"><strong x-text="e.display_actor_label || 'User'"></strong>: <span x-text="displayActor(e)"></span></p>
<p class="event__detail" x-show="e.actor_owner_names && e.actor_owner_names.length"><strong>App owners:</strong> <span x-text="(e.actor_owner_names || []).slice(0,3).join(', ')"></span></p>
<p class="event__detail"><strong>Target:</strong> <span x-text="displayTargets(e)"></span></p>
<p class="event__detail"><strong>When:</strong> <span x-text="e.timestamp ? new Date(e.timestamp).toLocaleString() : '—'"></span></p>
<div class="event__tags" x-show="e.tags && e.tags.length">
<template x-for="tag in (e.tags || [])" :key="tag">
<span class="pill pill--tag" x-text="tag"></span>
</template>
</div>
<div class="event__comments" x-show="e.comments && e.comments.length">
<template x-for="c in (e.comments || [])" :key="c.timestamp + c.text">
<p class="comment"><strong x-text="c.author"></strong>: <span x-text="c.text"></span> <small x-text="new Date(c.timestamp).toLocaleString()"></small></p>
</template>
</div>
<div class="event__actions">
<button class="ghost" @click="openModal(e)">View raw event</button>
<input type="text" placeholder="Add tag" @keydown.enter="addTag(e, $event.target.value); $event.target.value=''" />
<input type="text" placeholder="Add comment" @keydown.enter="addComment(e, $event.target.value); $event.target.value=''" />
</div>
</article>
</template>
</div>
<div id="pagination" class="pagination">
<button type="button" id="prevPage" :disabled="cursorStack.length === 0" @click="goPrev()">Prev</button>
<span x-text="`Page ${cursorStack.length + 1}`"></span>
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
</div> </div>
<div id="status" class="status" aria-live="polite"></div>
<div id="events" class="events"></div>
<div id="pagination" class="pagination"></div>
</section> </section>
</div> </div>
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle"> <div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
<div class="modal__content"> <div class="modal__content">
<div class="modal__header"> <div class="modal__header">
<h3 id="modalTitle">Raw Event</h3> <h3 id="modalTitle">Raw Event</h3>
<button type="button" id="closeModal" class="ghost">Close</button> <button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
</div> </div>
<pre id="modalBody"></pre> <pre id="modalBody" x-text="modalBody"></pre>
</div> </div>
</div> </div>
<script type="module"> <script>
const form = document.getElementById('filters'); function aocApp() {
const eventsContainer = document.getElementById('events'); return {
const status = document.getElementById('status'); events: [],
const count = document.getElementById('count'); sourceHealth: [],
const refreshBtn = document.getElementById('refreshBtn'); statusText: '',
const fetchBtn = document.getElementById('fetchBtn'); countText: '',
const clearBtn = document.getElementById('clearBtn'); cursorStack: [],
const authBtn = document.getElementById('authBtn'); nextCursor: null,
const modal = document.getElementById('modal'); currentCursor: null,
const modalBody = document.getElementById('modalBody'); modalOpen: false,
const closeModal = document.getElementById('closeModal'); modalBody: '',
let currentEvents = []; authBtnText: 'Login',
let pageSize = 50; authConfig: null,
let cursorStack = []; msalInstance: null,
let nextCursor = null; account: null,
let currentCursor = null; accessToken: null,
let authConfig = null; authScopes: [],
let msalInstance = null; filters: {
let account = null; actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100,
let accessToken = null; },
let authScopes = []; options: { actors: [], services: [], operations: [], results: [] },
const lists = {
actor: document.getElementById('actorOptions'),
service: document.getElementById('serviceOptions'),
operation: document.getElementById('operationOptions'),
result: document.getElementById('resultOptions'),
};
const toIso = (value) => { async initApp() {
if (!value) return ''; await this.initAuth();
const date = new Date(value); if (!this.authConfig?.auth_enabled || this.accessToken) {
return isNaN(date.getTime()) ? '' : date.toISOString(); await this.loadFilterOptions();
}; await this.loadSourceHealth();
await this.loadEvents();
}
},
async function loadEvents(cursor) { authHeader() {
currentCursor = cursor || null; return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
},
pickToken(res) {
return res ? (res.accessToken || res.idToken || null) : null;
},
async initAuth() {
try {
const res = await fetch('/api/config/auth');
this.authConfig = await res.json();
} catch {
this.authConfig = { auth_enabled: false };
}
if (!this.authConfig?.auth_enabled) {
this.authBtnText = '';
return;
}
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
this.statusText = 'Login library failed to load. Please check network or CDN.';
return;
}
const tenantId = this.authConfig.tenant_id;
const clientId = this.authConfig.client_id;
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(); const params = new URLSearchParams();
const data = new FormData(form);
['actor', 'service', 'operation', 'result', 'search'].forEach((key) => { ['actor', 'service', 'operation', 'result', 'search'].forEach((key) => {
const val = data.get(key)?.trim(); const val = this.filters[key];
if (val) params.append(key, val); if (val) params.append(key, val);
}); });
const startIso = toIso(data.get('start')); if (this.filters.start) {
const endIso = toIso(data.get('end')); const d = new Date(this.filters.start);
if (startIso) params.append('start', startIso); if (!isNaN(d.getTime())) params.append('start', d.toISOString());
if (endIso) params.append('end', endIso);
const limit = data.get('limit');
if (limit) {
pageSize = Number(limit);
params.append('page_size', limit);
} else {
params.append('page_size', pageSize);
} }
if (cursor) { if (this.filters.end) {
params.append('cursor', cursor); 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);
status.textContent = 'Loading events…'; this.statusText = 'Loading events…';
eventsContainer.innerHTML = ''; this.countText = '';
count.textContent = '';
if (authConfig?.auth_enabled && !accessToken) { if (this.authConfig?.auth_enabled && !this.accessToken) {
status.textContent = 'Please sign in to load events.'; this.statusText = 'Please sign in to load events.';
return; return;
} }
try { try {
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...authHeader() } }); const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...this.authHeader() } });
if (!res.ok) { if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`);
const msg = await res.text();
throw new Error(`Request failed: ${res.status} ${msg}`);
}
const body = await res.json(); const body = await res.json();
const events = body.items || []; this.events = body.items || [];
pageSize = body.page_size || pageSize; this.nextCursor = body.next_cursor || null;
nextCursor = body.next_cursor || null; this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
currentEvents = events; this.statusText = this.events.length ? '' : 'No events found for these filters.';
renderEvents(events, body.total);
renderPagination();
status.textContent = events.length ? '' : 'No events found for these filters.';
} catch (err) { } catch (err) {
status.textContent = err.message || 'Failed to load events.'; this.statusText = err.message || 'Failed to load events.';
}
} }
},
async function fetchLogs() { async fetchLogs() {
status.textContent = 'Fetching latest audit logs…'; this.statusText = 'Fetching latest audit logs…';
if (authConfig?.auth_enabled && !accessToken) { if (this.authConfig?.auth_enabled && !this.accessToken) {
status.textContent = 'Please sign in first.'; this.statusText = 'Please sign in first.';
return; return;
} }
try { try {
const res = await fetch('/api/fetch-audit-logs', { headers: authHeader() }); const res = await fetch('/api/fetch-audit-logs', { headers: this.authHeader() });
if (!res.ok) { if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${await res.text()}`);
const msg = await res.text();
throw new Error(`Fetch failed: ${res.status} ${msg}`);
}
const body = await res.json(); const body = await res.json();
const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : ''; const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : '';
status.textContent = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`; this.statusText = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
resetPagination(); this.resetPagination();
await loadEvents(); await this.loadEvents();
await this.loadSourceHealth();
} catch (err) { } catch (err) {
status.textContent = err.message || 'Failed to fetch audit logs.'; this.statusText = err.message || 'Failed to fetch audit logs.';
}
} }
},
async function loadFilterOptions() { async loadFilterOptions() {
if (authConfig?.auth_enabled && !accessToken) return; if (this.authConfig?.auth_enabled && !this.accessToken) return;
try { try {
const res = await fetch('/api/filter-options', { headers: authHeader() }); const res = await fetch('/api/filter-options', { headers: this.authHeader() });
if (!res.ok) return; if (!res.ok) return;
const opts = await res.json(); const opts = await res.json();
const setOptions = (el, values) => { this.options.actors = (opts.actors || []).slice(0, 200);
if (!el) return; this.options.services = (opts.services || []).slice(0, 200);
el.innerHTML = (values || []).slice(0, 200).map((v) => `<option value="${String(v)}"></option>`).join(''); this.options.operations = (opts.operations || []).slice(0, 200);
}; this.options.results = (opts.results || []).slice(0, 200);
setOptions(lists.actor, opts.actors); } catch {}
setOptions(lists.service, opts.services); },
setOptions(lists.operation, opts.operations);
setOptions(lists.result, opts.results);
} catch {
/* ignore */
}
}
function renderEvents(events, total) { async loadSourceHealth() {
const totalText = total >= 0 ? `${total} event${total === 1 ? '' : 's'}` : ''; try {
count.textContent = totalText; const res = await fetch('/api/source-health', { headers: this.authHeader() });
eventsContainer.innerHTML = events if (!res.ok) return;
.map((e, idx) => { this.sourceHealth = await res.json();
const actor = } catch {}
e.actor_display || },
e.actor_resolved?.name ||
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: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100 };
this.resetPagination();
this.loadEvents();
},
displayActor(e) {
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.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 || e.actor?.servicePrincipal?.displayName ||
'Unknown actor'; 'Unknown actor';
const owners = Array.isArray(e.actor_owner_names) && e.actor_owner_names.length },
? `Owners: ${e.actor_owner_names.slice(0, 3).join(', ')}`
: '';
const time = e.timestamp ? new Date(e.timestamp).toLocaleString() : '—';
const service = e.service || '—';
const operation = e.operation || '—';
const result = e.result || '—';
const category = e.display_category || service;
const targets = Array.isArray(e.target_displays) && e.target_displays.length
? e.target_displays.join(', ')
: (Array.isArray(e.targets) && e.targets.length
? (e.targets[0].displayName || e.targets[0].id || '—')
: '—');
const summary = e.display_summary || '';
const actorLabel = e.display_actor_label || 'User';
const actorValue = e.display_actor_value || actor;
return `
<article class="event">
<div class="event__meta">
<span class="pill">${category}</span>
<span class="pill ${result.toLowerCase() === 'success' ? 'pill--ok' : 'pill--warn'}">${result}</span>
</div>
<h3>${operation}</h3>
${summary ? `<p class="event__detail"><strong>Summary:</strong> ${summary}</p>` : ''}
<p class="event__detail"><strong>${actorLabel}:</strong> ${actorValue}</p>
${owners ? `<p class="event__detail"><strong>App owners:</strong> ${owners}</p>` : ''}
<p class="event__detail"><strong>Target:</strong> ${targets}</p>
<p class="event__detail"><strong>When:</strong> ${time}</p>
<p class="event__actions"><button class="ghost view-raw" data-idx="${idx}" type="button">View raw event</button></p>
</article>
`;
})
.join('');
eventsContainer.querySelectorAll('.view-raw').forEach((btn) => { displayTargets(e) {
btn.addEventListener('click', () => { if (Array.isArray(e.target_displays) && e.target_displays.length) return e.target_displays.join(', ');
const idx = Number(btn.dataset.idx); if (Array.isArray(e.targets) && e.targets.length) return e.targets[0].displayName || e.targets[0].id || '—';
const event = currentEvents[idx]; return '—';
const raw = event?.raw || event; },
modalBody.textContent = JSON.stringify(raw, null, 2);
modal.classList.remove('hidden');
});
});
}
function renderPagination() { openModal(e) {
const pagination = document.getElementById('pagination'); this.modalBody = JSON.stringify(e.raw || e, null, 2);
if (!pagination) return; this.modalOpen = true;
const hasPrev = cursorStack.length > 0; },
const hasNext = !!nextCursor;
const currentPageNum = cursorStack.length + 1;
pagination.innerHTML = `
<button type="button" id="prevPage" ${hasPrev ? '' : 'disabled'}>Prev</button>
<span>Page ${currentPageNum}</span>
<button type="button" id="nextPage" ${hasNext ? '' : 'disabled'}>Next</button>
`;
const prev = document.getElementById('prevPage');
const next = document.getElementById('nextPage');
if (prev) prev.addEventListener('click', () => {
if (cursorStack.length) {
const prevCursor = cursorStack.pop();
loadEvents(prevCursor);
}
});
if (next) next.addEventListener('click', () => {
if (nextCursor) {
cursorStack.push(currentCursor);
loadEvents(nextCursor);
}
});
}
function resetPagination() { async addTag(e, tag) {
cursorStack = []; if (!tag.trim()) return;
nextCursor = null; const tags = [...(e.tags || []), tag.trim()];
currentCursor = null;
}
function authHeader() {
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
}
const pickToken = (res) => (res ? (res.accessToken || res.idToken || null) : null);
async function initAuth() {
try { try {
const res = await fetch('/api/config/auth'); const res = await fetch(`/api/events/${e.id}/tags`, {
authConfig = await res.json(); method: 'PATCH',
} catch { headers: { 'Content-Type': 'application/json', ...this.authHeader() },
authConfig = { auth_enabled: false }; body: JSON.stringify({ tags }),
}
if (!authConfig?.auth_enabled) {
authBtn.classList.add('hidden');
return;
}
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
status.textContent = 'Login library failed to load. Please check network or CDN.';
return;
}
const tenantId = authConfig.tenant_id;
const clientId = authConfig.client_id;
const baseScope = authConfig.scope || "";
authScopes = Array.from(
new Set(
['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]
)
);
const authority = `https://login.microsoftonline.com/${tenantId}`;
const redirectUri = window.location.origin;
msalInstance = new msal.PublicClientApplication({
auth: { clientId, authority, redirectUri },
cache: { cacheLocation: 'sessionStorage' },
}); });
if (res.ok) e.tags = tags;
} catch {}
},
const redirectResult = await msalInstance.handleRedirectPromise().catch(() => null); async addComment(e, text) {
if (redirectResult) { if (!text.trim()) return;
account = redirectResult.account;
msalInstance.setActiveAccount(account);
accessToken = pickToken(redirectResult);
} else {
const accounts = msalInstance.getAllAccounts();
if (accounts.length) {
account = accounts[0];
msalInstance.setActiveAccount(account);
accessToken = await acquireToken(authScopes);
}
}
updateAuthButtons();
if (accessToken) {
await loadFilterOptions();
await loadEvents();
}
}
async function acquireToken(scopes) {
if (!msalInstance || !account) return null;
const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account };
try { try {
const res = await msalInstance.acquireTokenSilent(request); const res = await fetch(`/api/events/${e.id}/comments`, {
return pickToken(res); method: 'POST',
} catch { headers: { 'Content-Type': 'application/json', ...this.authHeader() },
const res = await msalInstance.acquireTokenPopup(request); body: JSON.stringify({ text: text.trim() }),
return pickToken(res);
}
}
function updateAuthButtons() {
const loggedIn = !!account;
if (authConfig?.auth_enabled) {
authBtn.textContent = loggedIn ? 'Logout' : 'Login';
}
if (loggedIn) {
acquireToken(authScopes).then((t) => { if (t) accessToken = t; }).catch(() => {});
status.textContent = '';
} else if (authConfig?.auth_enabled) {
status.textContent = 'Please log in to view events.';
}
}
authBtn.addEventListener('click', async () => {
if (!authConfig?.auth_enabled || !msalInstance) return;
if (account) {
const acc = msalInstance.getActiveAccount();
accessToken = null;
account = null;
updateAuthButtons();
if (acc) {
await msalInstance.logoutPopup({ account: acc });
}
return;
}
const scopes = authScopes && authScopes.length ? authScopes : ['openid', 'profile', 'email'];
status.textContent = 'Redirecting to sign in...';
msalInstance.loginRedirect({ scopes });
}); });
if (res.ok) {
const c = await res.json();
e.comments = [...(e.comments || []), c];
}
} catch {}
},
closeModal.addEventListener('click', () => modal.classList.add('hidden')); exportJSON() {
modal.addEventListener('click', (e) => { const blob = new Blob([JSON.stringify(this.events, null, 2)], { type: 'application/json' });
if (e.target === modal) modal.classList.add('hidden'); 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);
},
form.addEventListener('submit', (e) => { exportCSV() {
e.preventDefault(); if (!this.events.length) return;
resetPagination(); const headers = ['timestamp', 'service', 'operation', 'result', 'actor_display', 'target_displays', 'display_summary'];
loadEvents(); const rows = this.events.map((e) => [
}); e.timestamp || '',
e.service || '',
fetchBtn.addEventListener('click', () => fetchLogs()); e.operation || '',
refreshBtn.addEventListener('click', () => loadEvents(currentCursor)); e.result || '',
(e.actor_display || '').replace(/"/g, '""'),
clearBtn.addEventListener('click', () => { (Array.isArray(e.target_displays) ? e.target_displays.join('; ') : '').replace(/"/g, '""'),
form.reset(); (e.display_summary || '').replace(/"/g, '""'),
resetPagination(); ]);
loadEvents(); 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);
initAuth(); 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> </script>
</body> </body>
</html> </html>

View File

@@ -5,6 +5,7 @@ from contextlib import suppress
from pathlib import Path from pathlib import Path
import structlog import structlog
from audit_trail import log_action
from config import CORS_ORIGINS, ENABLE_PERIODIC_FETCH, FETCH_INTERVAL_MINUTES from config import CORS_ORIGINS, ENABLE_PERIODIC_FETCH, FETCH_INTERVAL_MINUTES
from database import setup_indexes from database import setup_indexes
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
@@ -17,6 +18,8 @@ from routes.config import router as config_router
from routes.events import router as events_router from routes.events import router as events_router
from routes.fetch import router as fetch_router from routes.fetch import router as fetch_router
from routes.fetch import run_fetch from routes.fetch import run_fetch
from routes.health import router as health_router
from routes.rules import router as rules_router
from routes.webhooks import router as webhooks_router from routes.webhooks import router as webhooks_router
@@ -66,10 +69,37 @@ async def prometheus_middleware(request: Request, call_next):
return response return response
@app.middleware("http")
async def audit_middleware(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/") and request.method in ("POST", "PATCH", "PUT", "DELETE"):
from auth import AUTH_ENABLED
user = "anonymous"
if AUTH_ENABLED:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
try:
from jose import jwt
token = auth_header.split(" ", 1)[1]
claims = jwt.get_unverified_claims(token)
user = claims.get("sub", "unknown")
except Exception:
pass
log_action(
action=request.method.lower(),
resource=request.url.path,
details={"status_code": response.status_code},
user=user,
)
return response
app.include_router(fetch_router, prefix="/api") app.include_router(fetch_router, prefix="/api")
app.include_router(events_router, prefix="/api") app.include_router(events_router, prefix="/api")
app.include_router(config_router, prefix="/api") app.include_router(config_router, prefix="/api")
app.include_router(webhooks_router, prefix="/api") app.include_router(webhooks_router, prefix="/api")
app.include_router(health_router, prefix="/api")
app.include_router(rules_router, prefix="/api")
@app.get("/health") @app.get("/health")

View File

@@ -1,4 +1,3 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -17,6 +16,8 @@ class EventItem(BaseModel):
targets: list[dict] | None = None targets: list[dict] | None = None
raw: dict | None = None raw: dict | None = None
raw_text: str | None = None raw_text: str | None = None
tags: list[str] | None = None
comments: list[dict] | None = None
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
@@ -40,3 +41,26 @@ class FilterOptionsResponse(BaseModel):
class FetchAuditLogsResponse(BaseModel): class FetchAuditLogsResponse(BaseModel):
stored_events: int stored_events: int
errors: list[str] errors: list[str]
class SourceHealthResponse(BaseModel):
source: str
last_fetch_time: str | None = None
status: str
class TagsUpdateRequest(BaseModel):
tags: list[str]
class CommentAddRequest(BaseModel):
text: str
class AlertRuleResponse(BaseModel):
id: str | None = None
name: str
enabled: bool
severity: str
conditions: list[dict]
message: str

View File

@@ -1,10 +1,18 @@
import base64 import base64
import re import re
from datetime import UTC, datetime
from audit_trail import log_action
from auth import require_auth from auth import require_auth
from bson import ObjectId
from database import events_collection from database import events_collection
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from models.api import FilterOptionsResponse, PaginatedEventResponse from models.api import (
CommentAddRequest,
FilterOptionsResponse,
PaginatedEventResponse,
TagsUpdateRequest,
)
router = APIRouter(dependencies=[Depends(require_auth)]) router = APIRouter(dependencies=[Depends(require_auth)])
@@ -34,6 +42,7 @@ def list_events(
search: str | None = None, search: str | None = None,
cursor: str | None = None, cursor: str | None = None,
page_size: int = Query(default=50, ge=1, le=500), page_size: int = Query(default=50, ge=1, le=500),
user: dict = Depends(require_auth),
): ):
filters = [] filters = []
@@ -85,7 +94,7 @@ def list_events(
{ {
"$or": [ "$or": [
{"timestamp": {"$lt": cursor_ts}}, {"timestamp": {"$lt": cursor_ts}},
{"timestamp": cursor_ts, "_id": {"$lt": cursor_oid}}, {"timestamp": cursor_ts, "_id": {"$lt": ObjectId(cursor_oid)}},
] ]
} }
) )
@@ -112,6 +121,12 @@ def list_events(
for e in events: for e in events:
e["_id"] = str(e["_id"]) e["_id"] = str(e["_id"])
log_action("list_events", "/api/events", {"filters": {k: v for k, v in {
"service": service, "actor": actor, "operation": operation, "result": result,
"start": start, "end": end, "search": search, "cursor": cursor, "page_size": page_size,
}.items() if v is not None}}, user.get("sub", "anonymous"))
return { return {
"items": events, "items": events,
"total": total, "total": total,
@@ -122,9 +137,6 @@ def list_events(
@router.get("/filter-options", response_model=FilterOptionsResponse) @router.get("/filter-options", response_model=FilterOptionsResponse)
def filter_options(limit: int = Query(default=200, ge=1, le=1000)): def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
"""
Provide distinct values for UI filters (best-effort, capped).
"""
safe_limit = max(1, min(limit, 1000)) safe_limit = max(1, min(limit, 1000))
try: try:
services = sorted(events_collection.distinct("service"))[:safe_limit] services = sorted(events_collection.distinct("service"))[:safe_limit]
@@ -144,3 +156,34 @@ def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
"actor_upns": actor_upns, "actor_upns": actor_upns,
"devices": devices, "devices": devices,
} }
@router.patch("/events/{event_id}/tags")
def update_tags(
event_id: str,
body: TagsUpdateRequest,
user: dict = Depends(require_auth),
):
result = events_collection.update_one({"id": event_id}, {"$set": {"tags": body.tags}})
if result.matched_count == 0:
raise HTTPException(status_code=404, detail="Event not found")
log_action("update_tags", f"/api/events/{event_id}/tags", {"tags": body.tags}, user.get("sub", "anonymous"))
return {"tags": body.tags}
@router.post("/events/{event_id}/comments")
def add_comment(
event_id: str,
body: CommentAddRequest,
user: dict = Depends(require_auth),
):
comment = {
"text": body.text,
"author": user.get("sub", "anonymous"),
"timestamp": datetime.now(UTC).isoformat(),
}
result = events_collection.update_one({"id": event_id}, {"$push": {"comments": comment}})
if result.matched_count == 0:
raise HTTPException(status_code=404, detail="Event not found")
log_action("add_comment", f"/api/events/{event_id}/comments", {"text": body.text}, user.get("sub", "anonymous"))
return comment

View File

@@ -1,6 +1,8 @@
import time import time
from audit_trail import log_action
from auth import require_auth from auth import require_auth
from config import ALERTS_ENABLED
from database import events_collection from database import events_collection
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from graph.audit_logs import fetch_audit_logs from graph.audit_logs import fetch_audit_logs
@@ -8,6 +10,7 @@ from metrics import track_fetch, track_fetch_duration, track_fetch_error
from models.api import FetchAuditLogsResponse from models.api import FetchAuditLogsResponse
from models.event_model import normalize_event from models.event_model import normalize_event
from pymongo import UpdateOne from pymongo import UpdateOne
from siem import forward_event
from sources.intune_audit import fetch_intune_audit from sources.intune_audit import fetch_intune_audit
from sources.unified_audit import fetch_unified_audit from sources.unified_audit import fetch_unified_audit
from watermark import get_watermark, set_watermark from watermark import get_watermark, set_watermark
@@ -52,12 +55,26 @@ def run_fetch(hours: int = 168):
else: else:
ops.append(UpdateOne({"id": doc.get("id"), "timestamp": doc.get("timestamp")}, {"$set": doc}, upsert=True)) ops.append(UpdateOne({"id": doc.get("id"), "timestamp": doc.get("timestamp")}, {"$set": doc}, upsert=True))
events_collection.bulk_write(ops, ordered=False) events_collection.bulk_write(ops, ordered=False)
if ALERTS_ENABLED:
from rules import evaluate_event
for doc in normalized:
evaluate_event(doc)
for doc in normalized:
forward_event(doc)
return {"stored_events": len(normalized), "errors": errors} return {"stored_events": len(normalized), "errors": errors}
@router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse) @router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse)
def fetch_logs(hours: int = Query(default=168, ge=1, le=720)): def fetch_logs(
hours: int = Query(default=168, ge=1, le=720),
user: dict = Depends(require_auth),
):
try: try:
return run_fetch(hours=hours) result = run_fetch(hours=hours)
log_action("fetch_audit_logs", "/api/fetch-audit-logs", {"hours": hours, "stored": result["stored_events"]}, user.get("sub", "anonymous"))
return result
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc

30
backend/routes/health.py Normal file
View File

@@ -0,0 +1,30 @@
from auth import require_auth
from fastapi import APIRouter, Depends
from models.api import SourceHealthResponse
from watermark import watermarks_collection
router = APIRouter(dependencies=[Depends(require_auth)])
SOURCES = ["directory", "unified", "intune"]
@router.get("/source-health", response_model=list[SourceHealthResponse])
def source_health():
"""Return the last known fetch status for each ingestion source."""
results = []
for source in SOURCES:
doc = watermarks_collection.find_one({"source": source})
if doc and doc.get("last_fetch_time"):
results.append({
"source": source,
"last_fetch_time": doc["last_fetch_time"],
"status": "healthy",
})
else:
results.append({
"source": source,
"last_fetch_time": None,
"status": "unknown",
})
return results

42
backend/routes/rules.py Normal file
View File

@@ -0,0 +1,42 @@
from auth import require_auth
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException
from models.api import AlertRuleResponse
from rules import rules_collection
router = APIRouter(dependencies=[Depends(require_auth)])
def _serialize(doc: dict) -> dict:
doc["id"] = str(doc.pop("_id"))
return doc
@router.get("/rules", response_model=list[AlertRuleResponse])
def list_rules():
return [_serialize(doc) for doc in rules_collection.find()]
@router.post("/rules", response_model=AlertRuleResponse)
def create_rule(rule: AlertRuleResponse):
payload = rule.model_dump(exclude={"id"})
result = rules_collection.insert_one(payload)
payload["id"] = str(result.inserted_id)
return payload
@router.put("/rules/{rule_id}", response_model=AlertRuleResponse)
def update_rule(rule_id: str, rule: AlertRuleResponse):
payload = rule.model_dump(exclude={"id"})
result = rules_collection.update_one({"_id": ObjectId(rule_id)}, {"$set": payload})
if result.matched_count == 0:
raise HTTPException(status_code=404, detail="Rule not found")
return {**payload, "id": rule_id}
@router.delete("/rules/{rule_id}")
def delete_rule(rule_id: str):
result = rules_collection.delete_one({"_id": ObjectId(rule_id)})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="Rule not found")
return {"deleted": True}

81
backend/rules.py Normal file
View File

@@ -0,0 +1,81 @@
from datetime import UTC, datetime
import structlog
from database import db
logger = structlog.get_logger("aoc.rules")
rules_collection = db["alert_rules"]
alerts_collection = db["alerts"]
def load_rules() -> list[dict]:
return list(rules_collection.find({"enabled": True}))
def evaluate_event(event: dict) -> list[dict]:
"""Evaluate a normalized event against stored alert rules."""
triggered = []
rules = load_rules()
for rule in rules:
if _matches(rule, event):
triggered.append(rule)
_create_alert(rule, event)
return triggered
def _matches(rule: dict, event: dict) -> bool:
conditions = rule.get("conditions", [])
if not conditions:
return False
for cond in conditions:
field = cond.get("field")
op = cond.get("op", "eq")
value = cond.get("value")
event_value = _get_nested(event, field)
if op == "eq" and event_value != value:
return False
if op == "neq" and event_value == value:
return False
if op == "contains" and (not isinstance(event_value, str) or value not in event_value):
return False
if op == "in" and event_value not in (value if isinstance(value, list) else [value]):
return False
if op == "after_hours":
try:
ts = datetime.fromisoformat(event.get("timestamp", "").replace("Z", "+00:00"))
hour = ts.hour
if 9 <= hour < 17:
return False
except Exception:
return False
return True
def _get_nested(obj: dict, path: str):
parts = path.split(".")
val = obj
for p in parts:
if isinstance(val, dict):
val = val.get(p)
else:
return None
return val
def _create_alert(rule: dict, event: dict):
alert = {
"timestamp": datetime.now(UTC).isoformat(),
"rule_id": str(rule.get("_id")),
"rule_name": rule.get("name", "Unnamed rule"),
"severity": rule.get("severity", "medium"),
"event_id": event.get("id"),
"event_dedupe_key": event.get("dedupe_key"),
"message": rule.get("message", f"Rule '{rule.get('name')}' triggered"),
}
try:
alerts_collection.insert_one(alert)
logger.info("Alert created", rule=rule.get("name"), event_id=event.get("id"))
except Exception as exc:
logger.warning("Failed to create alert", error=str(exc))

17
backend/siem.py Normal file
View File

@@ -0,0 +1,17 @@
import requests
import structlog
from config import SIEM_ENABLED, SIEM_WEBHOOK_URL
logger = structlog.get_logger("aoc.siem")
def forward_event(event: dict):
"""Forward a normalized event to the configured SIEM webhook."""
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
return
try:
res = requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
res.raise_for_status()
logger.debug("Event forwarded to SIEM", event_id=event.get("id"))
except Exception as exc:
logger.warning("SIEM forward failed", error=str(exc))

View File

@@ -25,11 +25,20 @@ def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
monkeypatch.setattr("routes.fetch.events_collection", mock_events_collection) monkeypatch.setattr("routes.fetch.events_collection", mock_events_collection)
monkeypatch.setattr("routes.events.events_collection", mock_events_collection) monkeypatch.setattr("routes.events.events_collection", mock_events_collection)
monkeypatch.setattr("watermark.watermarks_collection", mock_watermarks_collection) monkeypatch.setattr("watermark.watermarks_collection", mock_watermarks_collection)
monkeypatch.setattr("routes.health.watermarks_collection", mock_watermarks_collection)
monkeypatch.setattr("routes.fetch.get_watermark", lambda source: None) monkeypatch.setattr("routes.fetch.get_watermark", lambda source: None)
monkeypatch.setattr("routes.fetch.set_watermark", lambda source, ts: None) monkeypatch.setattr("routes.fetch.set_watermark", lambda source, ts: None)
monkeypatch.setattr("auth.AUTH_ENABLED", False) monkeypatch.setattr("auth.AUTH_ENABLED", False)
monkeypatch.setattr("database.db.command", lambda cmd: {"ok": 1} if cmd == "ping" else {}) monkeypatch.setattr("database.db.command", lambda cmd: {"ok": 1} if cmd == "ping" else {})
# Mock audit trail and rules collections so tests don't wait on real MongoDB
audit_client = mongomock.MongoClient()
audit_db = audit_client["micro_soc"]
monkeypatch.setattr("audit_trail.audit_collection", audit_db["aoc_audit"])
monkeypatch.setattr("rules.alerts_collection", audit_db["alerts"])
monkeypatch.setattr("rules.rules_collection", audit_db["alert_rules"])
monkeypatch.setattr("routes.rules.rules_collection", audit_db["alert_rules"])
from main import app from main import app
return TestClient(app) return TestClient(app)

View File

@@ -40,7 +40,6 @@ def test_list_events_cursor_pagination(client, mock_events_collection):
assert len(data["items"]) == 2 assert len(data["items"]) == 2
assert data["next_cursor"] is not None assert data["next_cursor"] is not None
# Follow cursor
response2 = client.get(f"/api/events?page_size=2&cursor={data['next_cursor']}") response2 = client.get(f"/api/events?page_size=2&cursor={data['next_cursor']}")
assert response2.status_code == 200 assert response2.status_code == 200
data2 = response2.json() data2 = response2.json()
@@ -130,3 +129,79 @@ def test_graph_webhook_notification(client):
response = client.post("/api/webhooks/graph", json=payload) response = client.post("/api/webhooks/graph", json=payload)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "accepted" assert response.json()["status"] == "accepted"
def test_update_tags(client, mock_events_collection):
mock_events_collection.insert_one({
"id": "evt-tags",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Directory",
"operation": "Add user",
"result": "success",
"actor_display": "Alice",
"raw_text": "",
})
response = client.patch("/api/events/evt-tags/tags", json={"tags": ["investigating", "urgent"]})
assert response.status_code == 200
assert response.json()["tags"] == ["investigating", "urgent"]
doc = mock_events_collection.find_one({"id": "evt-tags"})
assert doc["tags"] == ["investigating", "urgent"]
def test_add_comment(client, mock_events_collection):
mock_events_collection.insert_one({
"id": "evt-comment",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Directory",
"operation": "Add user",
"result": "success",
"actor_display": "Alice",
"raw_text": "",
})
response = client.post("/api/events/evt-comment/comments", json={"text": "Looks suspicious"})
assert response.status_code == 200
data = response.json()
assert data["text"] == "Looks suspicious"
doc = mock_events_collection.find_one({"id": "evt-comment"})
assert len(doc["comments"]) == 1
assert doc["comments"][0]["text"] == "Looks suspicious"
def test_source_health(client, mock_watermarks_collection):
mock_watermarks_collection.insert_one({"source": "directory", "last_fetch_time": "2024-01-01T00:00:00Z"})
response = client.get("/api/source-health")
assert response.status_code == 200
data = response.json()
directory = next((x for x in data if x["source"] == "directory"), None)
assert directory["status"] == "healthy"
assert directory["last_fetch_time"] == "2024-01-01T00:00:00Z"
def test_rules_crud(client):
rule = {
"name": "After-hours admin",
"enabled": True,
"severity": "high",
"conditions": [{"field": "operation", "op": "eq", "value": "Add user"}],
"message": "Admin action outside business hours",
}
res = client.post("/api/rules", json=rule)
assert res.status_code == 200
created = res.json()
assert created["name"] == "After-hours admin"
res2 = client.get("/api/rules")
assert res2.status_code == 200
assert len(res2.json()) == 1
updated = {**rule, "name": "After-hours admin updated"}
res3 = client.put(f"/api/rules/{created['id']}", json=updated)
assert res3.status_code == 200
assert res3.json()["name"] == "After-hours admin updated"
res4 = client.delete(f"/api/rules/{created['id']}")
assert res4.status_code == 200
res5 = client.get("/api/rules")
assert res5.status_code == 200
assert len(res5.json()) == 0

View File

@@ -0,0 +1,49 @@
from datetime import UTC, datetime
from rules import _matches, evaluate_event
def test_matches_equals():
rule = {"conditions": [{"field": "operation", "op": "eq", "value": "Add user"}]}
event = {"operation": "Add user", "timestamp": datetime.now(UTC).isoformat()}
assert _matches(rule, event) is True
def test_matches_not_equals():
rule = {"conditions": [{"field": "operation", "op": "neq", "value": "Delete user"}]}
event = {"operation": "Add user", "timestamp": datetime.now(UTC).isoformat()}
assert _matches(rule, event) is True
def test_matches_contains():
rule = {"conditions": [{"field": "actor_display", "op": "contains", "value": "Admin"}]}
event = {"actor_display": "Admin (admin@example.com)", "timestamp": datetime.now(UTC).isoformat()}
assert _matches(rule, event) is True
def test_matches_after_hours():
rule = {"conditions": [{"field": "timestamp", "op": "after_hours", "value": None}]}
event = {"timestamp": "2024-01-01T22:00:00Z"}
assert _matches(rule, event) is True
event2 = {"timestamp": "2024-01-01T10:00:00Z"}
assert _matches(rule, event2) is False
def test_evaluate_event_creates_alert(monkeypatch):
from rules import alerts_collection
monkeypatch.setattr("rules.load_rules", lambda: [
{"_id": "r1", "name": "Test rule", "enabled": True, "severity": "high", "conditions": [{"field": "operation", "op": "eq", "value": "Add user"}], "message": "Alert!"}
])
inserted = {}
def mock_insert(doc):
inserted["doc"] = doc
monkeypatch.setattr(alerts_collection, "insert_one", mock_insert)
event = {"id": "e1", "operation": "Add user", "timestamp": datetime.now(UTC).isoformat(), "dedupe_key": "dk1"}
triggered = evaluate_event(event)
assert len(triggered) == 1
assert inserted["doc"]["rule_name"] == "Test rule"

View File

@@ -4,7 +4,7 @@ line-length = 120
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"] select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"]
ignore = ["E501"] ignore = ["E501", "B008"]
[tool.ruff.lint.pydocstyle] [tool.ruff.lint.pydocstyle]
convention = "google" convention = "google"