feat: Admin Operations SIEM — alerts, notifications, pre-built rules
- Add pluggable notification system (webhook, Slack, Teams) with retry
- Add alert deduplication: same rule + actor within 15 min = one alert
- Add 10 pre-built admin-ops rule templates seeded on startup:
- Failed Conditional Access, After-Hours Admin Activity
- New Application Registration, Admin Role Assignment
- License Change, Bulk User Deletion
- Device Compliance Failure, Exchange Transport Rule Change
- Service Principal Credential Added, External Sharing Enabled
- Add /api/alerts, /api/alerts/{id}/status, /api/alerts/summary endpoints
- Add alert dashboard to frontend with status filters and ack/resolve buttons
- Add alert summary badge in hero header (high/medium/low counts)
- New env vars: ALERT_WEBHOOK_URL, ALERT_WEBHOOK_FORMAT, ALERT_DEDUPE_MINUTES
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Operations Center</title>
|
||||
<link rel="stylesheet" href="/style.css?v=12" />
|
||||
<link rel="stylesheet" href="/style.css?v=13" />
|
||||
<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>
|
||||
</head>
|
||||
@@ -47,6 +47,12 @@
|
||||
<h1>Audit Log Explorer</h1>
|
||||
<p class="lede">Search and review Microsoft audit events from Entra, Intune, Exchange, SharePoint, and Teams.</p>
|
||||
</div>
|
||||
<div class="alert-summary" x-show="alertSummary.total_open > 0">
|
||||
<div class="alert-badge alert-badge--high" x-show="alertSummary.high > 0" x-text="alertSummary.high"></div>
|
||||
<div class="alert-badge alert-badge--medium" x-show="alertSummary.medium > 0" x-text="alertSummary.medium"></div>
|
||||
<div class="alert-badge alert-badge--low" x-show="alertSummary.low > 0" x-text="alertSummary.low"></div>
|
||||
<span class="alert-label">open alerts</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
@@ -64,6 +70,52 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" x-show="alertSummary.total_open > 0 || alerts.length > 0">
|
||||
<div class="panel-header">
|
||||
<h3>Alerts</h3>
|
||||
<span x-text="`${alertSummary.total_open} open`" class="alert-open-count"></span>
|
||||
</div>
|
||||
<div class="alert-filters">
|
||||
<select x-model="alertsFilter.status" @change="alertsPage = 1; loadAlerts()">
|
||||
<option value="">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="acknowledged">Acknowledged</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="false_positive">False Positive</option>
|
||||
</select>
|
||||
<select x-model="alertsFilter.severity" @change="alertsPage = 1; loadAlerts()">
|
||||
<option value="">All severities</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="alerts-list">
|
||||
<template x-for="alert in alerts" :key="alert._id || alert.event_id">
|
||||
<div class="alert-card" :class="'alert-card--' + alert.severity">
|
||||
<div class="alert-card__meta">
|
||||
<span class="pill" :class="alert.severity === 'high' ? 'pill--err' : (alert.severity === 'medium' ? 'pill--warn' : '')" x-text="alert.severity"></span>
|
||||
<span class="pill" x-text="alert.status"></span>
|
||||
<small x-text="new Date(alert.timestamp).toLocaleString()"></small>
|
||||
</div>
|
||||
<strong x-text="alert.rule_name"></strong>
|
||||
<p x-text="alert.message"></p>
|
||||
<div class="alert-card__actions">
|
||||
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'acknowledged')" x-show="alert.status === 'open'">Acknowledge</button>
|
||||
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'resolved')" x-show="alert.status !== 'resolved' && alert.status !== 'false_positive'">Resolve</button>
|
||||
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'false_positive')" x-show="alert.status !== 'false_positive'">False Positive</button>
|
||||
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'open')" x-show="alert.status !== 'open'">Reopen</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="pagination" x-show="alertsTotal > 20">
|
||||
<button type="button" :disabled="alertsPage === 1" @click="alertsPage--; loadAlerts()">Prev</button>
|
||||
<span x-text="`Page ${alertsPage}`"></span>
|
||||
<button type="button" :disabled="alertsPage * 20 >= alertsTotal" @click="alertsPage++; loadAlerts()">Next</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
||||
<div class="filter-row">
|
||||
@@ -313,6 +365,11 @@
|
||||
repoUrl: 'https://git.cqre.net/cqrenet/aoc',
|
||||
docsUrl: 'https://git.cqre.net/cqrenet/aoc/src/branch/main/README.md',
|
||||
aiFeaturesEnabled: true,
|
||||
alertSummary: { total_open: 0, high: 0, medium: 0, low: 0 },
|
||||
alerts: [],
|
||||
alertsTotal: 0,
|
||||
alertsPage: 1,
|
||||
alertsFilter: { status: 'open', severity: '' },
|
||||
askQuestionText: '',
|
||||
askLoading: false,
|
||||
askAnswer: '',
|
||||
@@ -329,6 +386,8 @@
|
||||
await this.loadFilterOptions();
|
||||
await this.loadSavedSearches();
|
||||
await this.loadSourceHealth();
|
||||
await this.loadAlertSummary();
|
||||
await this.loadAlerts();
|
||||
await this.loadEvents();
|
||||
}
|
||||
},
|
||||
@@ -686,6 +745,48 @@
|
||||
this.loadEvents();
|
||||
},
|
||||
|
||||
async loadAlertSummary() {
|
||||
try {
|
||||
const res = await fetch('/api/alerts/summary', { headers: this.authHeader() });
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
this.alertSummary.total_open = body.total_open || 0;
|
||||
const sev = body.by_status_severity || [];
|
||||
this.alertSummary.high = sev.filter((s) => s._id.severity === 'high' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||
this.alertSummary.medium = sev.filter((s) => s._id.severity === 'medium' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||
this.alertSummary.low = sev.filter((s) => s._id.severity === 'low' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async loadAlerts() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page_size', '20');
|
||||
params.append('page', String(this.alertsPage));
|
||||
if (this.alertsFilter.status) params.append('status', this.alertsFilter.status);
|
||||
if (this.alertsFilter.severity) params.append('severity', this.alertsFilter.severity);
|
||||
const res = await fetch(`/api/alerts?${params.toString()}`, { headers: this.authHeader() });
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
this.alerts = body.items || [];
|
||||
this.alertsTotal = body.total || 0;
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async updateAlertStatus(alertId, status) {
|
||||
try {
|
||||
const res = await fetch(`/api/alerts/${alertId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.loadAlerts();
|
||||
await this.loadAlertSummary();
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async askQuestion() {
|
||||
const q = this.askQuestionText.trim();
|
||||
if (!q) return;
|
||||
|
||||
Reference in New Issue
Block a user