feat: implement Phase 4 enhancements
Some checks failed
CI / lint-and-test (push) Has been cancelled
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
16
ROADMAP.md
16
ROADMAP.md
@@ -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
22
backend/audit_trail.py
Normal 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))
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}` } : {};
|
||||||
const params = new URLSearchParams();
|
},
|
||||||
const data = new FormData(form);
|
|
||||||
['actor', 'service', 'operation', 'result', 'search'].forEach((key) => {
|
|
||||||
const val = data.get(key)?.trim();
|
|
||||||
if (val) params.append(key, val);
|
|
||||||
});
|
|
||||||
const startIso = toIso(data.get('start'));
|
|
||||||
const endIso = toIso(data.get('end'));
|
|
||||||
if (startIso) params.append('start', startIso);
|
|
||||||
if (endIso) params.append('end', endIso);
|
|
||||||
|
|
||||||
const limit = data.get('limit');
|
pickToken(res) {
|
||||||
if (limit) {
|
return res ? (res.accessToken || res.idToken || null) : null;
|
||||||
pageSize = Number(limit);
|
},
|
||||||
params.append('page_size', limit);
|
|
||||||
} else {
|
|
||||||
params.append('page_size', pageSize);
|
|
||||||
}
|
|
||||||
if (cursor) {
|
|
||||||
params.append('cursor', cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
status.textContent = 'Loading events…';
|
async initAuth() {
|
||||||
eventsContainer.innerHTML = '';
|
try {
|
||||||
count.textContent = '';
|
const res = await fetch('/api/config/auth');
|
||||||
|
this.authConfig = await res.json();
|
||||||
|
} catch {
|
||||||
|
this.authConfig = { auth_enabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
if (authConfig?.auth_enabled && !accessToken) {
|
if (!this.authConfig?.auth_enabled) {
|
||||||
status.textContent = 'Please sign in to load events.';
|
this.authBtnText = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
|
||||||
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...authHeader() } });
|
this.statusText = 'Login library failed to load. Please check network or CDN.';
|
||||||
if (!res.ok) {
|
return;
|
||||||
const msg = await res.text();
|
}
|
||||||
throw new Error(`Request failed: ${res.status} ${msg}`);
|
|
||||||
}
|
|
||||||
const body = await res.json();
|
|
||||||
const events = body.items || [];
|
|
||||||
pageSize = body.page_size || pageSize;
|
|
||||||
nextCursor = body.next_cursor || null;
|
|
||||||
currentEvents = events;
|
|
||||||
renderEvents(events, body.total);
|
|
||||||
renderPagination();
|
|
||||||
status.textContent = events.length ? '' : 'No events found for these filters.';
|
|
||||||
} catch (err) {
|
|
||||||
status.textContent = err.message || 'Failed to load events.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLogs() {
|
const tenantId = this.authConfig.tenant_id;
|
||||||
status.textContent = 'Fetching latest audit logs…';
|
const clientId = this.authConfig.client_id;
|
||||||
if (authConfig?.auth_enabled && !accessToken) {
|
const baseScope = this.authConfig.scope || "";
|
||||||
status.textContent = 'Please sign in first.';
|
this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
|
||||||
return;
|
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
||||||
}
|
const redirectUri = window.location.origin;
|
||||||
try {
|
|
||||||
const res = await fetch('/api/fetch-audit-logs', { headers: authHeader() });
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = await res.text();
|
|
||||||
throw new Error(`Fetch failed: ${res.status} ${msg}`);
|
|
||||||
}
|
|
||||||
const body = await res.json();
|
|
||||||
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…`;
|
|
||||||
resetPagination();
|
|
||||||
await loadEvents();
|
|
||||||
} catch (err) {
|
|
||||||
status.textContent = err.message || 'Failed to fetch audit logs.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFilterOptions() {
|
this.msalInstance = new msal.PublicClientApplication({
|
||||||
if (authConfig?.auth_enabled && !accessToken) return;
|
auth: { clientId, authority, redirectUri },
|
||||||
try {
|
cache: { cacheLocation: 'sessionStorage' },
|
||||||
const res = await fetch('/api/filter-options', { headers: authHeader() });
|
});
|
||||||
if (!res.ok) return;
|
|
||||||
const opts = await res.json();
|
|
||||||
const setOptions = (el, values) => {
|
|
||||||
if (!el) return;
|
|
||||||
el.innerHTML = (values || []).slice(0, 200).map((v) => `<option value="${String(v)}"></option>`).join('');
|
|
||||||
};
|
|
||||||
setOptions(lists.actor, opts.actors);
|
|
||||||
setOptions(lists.service, opts.services);
|
|
||||||
setOptions(lists.operation, opts.operations);
|
|
||||||
setOptions(lists.result, opts.results);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEvents(events, total) {
|
const redirectResult = await this.msalInstance.handleRedirectPromise().catch(() => null);
|
||||||
const totalText = total >= 0 ? `${total} event${total === 1 ? '' : 's'}` : '';
|
if (redirectResult) {
|
||||||
count.textContent = totalText;
|
this.account = redirectResult.account;
|
||||||
eventsContainer.innerHTML = events
|
this.msalInstance.setActiveAccount(this.account);
|
||||||
.map((e, idx) => {
|
this.accessToken = this.pickToken(redirectResult);
|
||||||
const actor =
|
} else {
|
||||||
e.actor_display ||
|
const accounts = this.msalInstance.getAllAccounts();
|
||||||
e.actor_resolved?.name ||
|
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', 'service', 'operation', 'result', 'search'].forEach((key) => {
|
||||||
|
const val = this.filters[key];
|
||||||
|
if (val) params.append(key, val);
|
||||||
|
});
|
||||||
|
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.';
|
||||||
|
} 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);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSourceHealth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/source-health', { headers: this.authHeader() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
this.sourceHealth = await res.json();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
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');
|
openModal(e) {
|
||||||
});
|
this.modalBody = JSON.stringify(e.raw || e, null, 2);
|
||||||
});
|
this.modalOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPagination() {
|
|
||||||
const pagination = document.getElementById('pagination');
|
|
||||||
if (!pagination) return;
|
|
||||||
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() {
|
|
||||||
cursorStack = [];
|
|
||||||
nextCursor = null;
|
|
||||||
currentCursor = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function authHeader() {
|
|
||||||
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickToken = (res) => (res ? (res.accessToken || res.idToken || null) : null);
|
|
||||||
|
|
||||||
async function initAuth() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/config/auth');
|
|
||||||
authConfig = await res.json();
|
|
||||||
} catch {
|
|
||||||
authConfig = { auth_enabled: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const redirectResult = await msalInstance.handleRedirectPromise().catch(() => null);
|
|
||||||
if (redirectResult) {
|
|
||||||
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 {
|
|
||||||
const res = await msalInstance.acquireTokenSilent(request);
|
|
||||||
return pickToken(res);
|
|
||||||
} catch {
|
|
||||||
const res = await msalInstance.acquireTokenPopup(request);
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
closeModal.addEventListener('click', () => modal.classList.add('hidden'));
|
|
||||||
modal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === modal) modal.classList.add('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
resetPagination();
|
|
||||||
loadEvents();
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchBtn.addEventListener('click', () => fetchLogs());
|
|
||||||
refreshBtn.addEventListener('click', () => loadEvents(currentCursor));
|
|
||||||
|
|
||||||
clearBtn.addEventListener('click', () => {
|
|
||||||
form.reset();
|
|
||||||
resetPagination();
|
|
||||||
loadEvents();
|
|
||||||
});
|
|
||||||
|
|
||||||
initAuth();
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
30
backend/routes/health.py
Normal 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
42
backend/routes/rules.py
Normal 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
81
backend/rules.py
Normal 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
17
backend/siem.py
Normal 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))
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
49
backend/tests/test_rules.py
Normal file
49
backend/tests/test_rules.py
Normal 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"
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user