From e34888108346c0fba385b26d5f9686b68e3ed4d9 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Wed, 22 Apr 2026 14:12:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Admin=20Operations=20SIEM=20=E2=80=94?= =?UTF-8?q?=20alerts,=20notifications,=20pre-built=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 6 ++ backend/config.py | 9 ++ backend/database.py | 1 + backend/frontend/index.html | 103 +++++++++++++++++++- backend/frontend/style.css | 118 ++++++++++++++++++++++ backend/main.py | 5 + backend/notifications.py | 172 ++++++++++++++++++++++++++++++++ backend/routes/alerts.py | 78 +++++++++++++++ backend/rules.py | 189 +++++++++++++++++++++++++++++++++++- backend/tests/test_rules.py | 1 + 10 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 backend/notifications.py create mode 100644 backend/routes/alerts.py diff --git a/.env.example b/.env.example index 958e5c7..8b6d0d9 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,12 @@ REDIS_URL=redis://localhost:6379/0 # UI default page size (number of events shown per page) DEFAULT_PAGE_SIZE=24 +# Alert notifications (optional) +# Send triggered admin-ops alerts to a webhook (Slack, Teams, or generic) +ALERT_WEBHOOK_URL= +ALERT_WEBHOOK_FORMAT=generic # generic | slack | teams +ALERT_DEDUPE_MINUTES=15 + # Optional: privacy / access control # Hide entire services from users without PRIVACY_SERVICE_ROLES # PRIVACY_SERVICES=Exchange,Teams diff --git a/backend/config.py b/backend/config.py index 4966a26..74c17e7 100644 --- a/backend/config.py +++ b/backend/config.py @@ -63,6 +63,11 @@ class Settings(BaseSettings): # UI defaults DEFAULT_PAGE_SIZE: int = 24 + # Alert notifications + ALERT_WEBHOOK_URL: str = "" + ALERT_WEBHOOK_FORMAT: str = "generic" # generic | slack | teams + ALERT_DEDUPE_MINUTES: int = 15 + _settings = Settings() @@ -104,3 +109,7 @@ PRIVACY_SERVICE_ROLES = {r.strip() for r in _settings.PRIVACY_SERVICE_ROLES.spli REDIS_URL = _settings.REDIS_URL DEFAULT_PAGE_SIZE = _settings.DEFAULT_PAGE_SIZE + +ALERT_WEBHOOK_URL = _settings.ALERT_WEBHOOK_URL +ALERT_WEBHOOK_FORMAT = _settings.ALERT_WEBHOOK_FORMAT +ALERT_DEDUPE_MINUTES = _settings.ALERT_DEDUPE_MINUTES diff --git a/backend/database.py b/backend/database.py index 003358b..65b25ea 100644 --- a/backend/database.py +++ b/backend/database.py @@ -8,6 +8,7 @@ client = MongoClient(MONGO_URI or "mongodb://localhost:27017") db = client[DB_NAME] events_collection = db["events"] saved_searches_collection = db["saved_searches"] +alerts_collection = db["alerts"] logger = structlog.get_logger("aoc.database") diff --git a/backend/frontend/index.html b/backend/frontend/index.html index 706a16d..369a319 100644 --- a/backend/frontend/index.html +++ b/backend/frontend/index.html @@ -4,7 +4,7 @@ Admin Operations Center - + @@ -47,6 +47,12 @@

Audit Log Explorer

Search and review Microsoft audit events from Entra, Intune, Exchange, SharePoint, and Teams.

+
+
+
+
+ open alerts +
@@ -64,6 +70,52 @@
+
+
+

Alerts

+ +
+
+ + +
+
+ +
+ +
+
@@ -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; diff --git a/backend/frontend/style.css b/backend/frontend/style.css index ebb07b1..aa27eba 100644 --- a/backend/frontend/style.css +++ b/backend/frontend/style.css @@ -691,6 +691,124 @@ input { font-size: 12px; } +/* Alert summary in hero */ +.alert-summary { + display: flex; + align-items: center; + gap: 6px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 14px; +} + +.alert-badge { + min-width: 22px; + height: 22px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: #0b1220; +} + +.alert-badge--high { + background: #ef4444; +} + +.alert-badge--medium { + background: #f97316; +} + +.alert-badge--low { + background: #3b82f6; +} + +.alert-label { + font-size: 12px; + color: var(--muted); +} + +.alert-open-count { + font-size: 13px; + color: var(--muted); +} + +.alert-filters { + display: flex; + gap: 10px; + margin-bottom: 12px; +} + +.alert-filters select { + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + color: var(--text); + font-size: 13px; +} + +.alerts-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.alert-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.02); + border-left: 3px solid transparent; +} + +.alert-card--high { + border-left-color: #ef4444; +} + +.alert-card--medium { + border-left-color: #f97316; +} + +.alert-card--low { + border-left-color: #3b82f6; +} + +.alert-card__meta { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.alert-card__meta small { + color: var(--muted); + font-size: 12px; +} + +.alert-card strong { + font-size: 14px; + display: block; + margin-bottom: 4px; +} + +.alert-card p { + margin: 0 0 10px; + font-size: 13px; + color: var(--muted); + line-height: 1.45; +} + +.alert-card__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + @media (max-width: 640px) { .topbar { flex-direction: column; diff --git a/backend/main.py b/backend/main.py index 8236e1e..36deafb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from metrics import observe_request, prometheus_metrics from middleware import CorrelationIdMiddleware +from routes.alerts import router as alerts_router from routes.config import router as config_router from routes.events import router as events_router from routes.fetch import router as fetch_router @@ -123,6 +124,7 @@ if AI_FEATURES_ENABLED: app.mount("/mcp", mcp_asgi) app.include_router(saved_searches_router, prefix="/api") app.include_router(rules_router, prefix="/api") +app.include_router(alerts_router, prefix="/api") app.include_router(jobs_router, prefix="/api") @@ -167,6 +169,9 @@ async def _periodic_fetch(): @app.on_event("startup") async def start_periodic_fetch(): setup_indexes() + from rules import seed_default_rules + + seed_default_rules() if ENABLE_PERIODIC_FETCH: app.state.fetch_task = asyncio.create_task(_periodic_fetch()) diff --git a/backend/notifications.py b/backend/notifications.py new file mode 100644 index 0000000..a373a55 --- /dev/null +++ b/backend/notifications.py @@ -0,0 +1,172 @@ +"""Pluggable notification channels for admin-ops alerts. + +Supported channels: +- webhook: POST JSON to any URL (Slack, Teams, generic) +""" + +from datetime import UTC, datetime + +import requests +import structlog +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential + +logger = structlog.get_logger("aoc.notifications") + +WEBHOOK_TIMEOUT = 15 + + +@retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=10), + retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)), + reraise=True, +) +def _post_webhook(url: str, payload: dict) -> requests.Response: + """POST to webhook with retry on connection/timeout errors.""" + return requests.post(url, json=payload, timeout=WEBHOOK_TIMEOUT, headers={"Content-Type": "application/json"}) + + +def _build_slack_payload(rule_name: str, severity: str, message: str, event: dict) -> dict: + """Build a Slack-compatible block payload.""" + color = {"high": "#ef4444", "medium": "#f97316", "low": "#3b82f6"}.get(severity, "#94a3b8") + ts = event.get("timestamp", "?") + op = event.get("operation", "unknown") + actor = event.get("actor_display", "unknown") + targets = ", ".join(event.get("target_displays", [])) or "—" + svc = event.get("service", "unknown") + return { + "text": f"[{severity.upper()}] {rule_name}: {message}", + "attachments": [ + { + "color": color, + "fields": [ + {"title": "Rule", "value": rule_name, "short": True}, + {"title": "Severity", "value": severity.upper(), "short": True}, + {"title": "Service", "value": svc, "short": True}, + {"title": "Action", "value": op, "short": True}, + {"title": "Actor", "value": actor, "short": True}, + {"title": "Target", "value": targets, "short": True}, + {"title": "Time", "value": ts, "short": False}, + ], + "footer": "AOC Admin Operations Center", + } + ], + } + + +def _build_teams_payload(rule_name: str, severity: str, message: str, event: dict) -> dict: + """Build a Microsoft Teams adaptive card payload.""" + color = {"high": "Attention", "medium": "Warning", "low": "Good"}.get(severity, "Default") + ts = event.get("timestamp", "?") + op = event.get("operation", "unknown") + actor = event.get("actor_display", "unknown") + targets = ", ".join(event.get("target_displays", [])) or "—" + svc = event.get("service", "unknown") + return { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + { + "type": "TextBlock", + "text": f"🚨 {severity.upper()}: {rule_name}", + "weight": "Bolder", + "size": "Medium", + "color": color, + }, + {"type": "TextBlock", "text": message, "wrap": True}, + { + "type": "FactSet", + "facts": [ + {"title": "Service:", "value": svc}, + {"title": "Action:", "value": op}, + {"title": "Actor:", "value": actor}, + {"title": "Target:", "value": targets}, + {"title": "Time:", "value": ts}, + ], + }, + ], + }, + } + ], + } + + +def _build_generic_payload(rule_name: str, severity: str, message: str, event: dict) -> dict: + """Build a generic JSON payload.""" + return { + "alert": { + "rule_name": rule_name, + "severity": severity, + "message": message, + "timestamp": datetime.now(UTC).isoformat(), + }, + "event": { + "id": event.get("id"), + "timestamp": event.get("timestamp"), + "service": event.get("service"), + "operation": event.get("operation"), + "actor_display": event.get("actor_display"), + "target_displays": event.get("target_displays"), + "result": event.get("result"), + }, + } + + +def send_notification( + webhook_url: str, + format_type: str, + rule_name: str, + severity: str, + message: str, + event: dict, +) -> bool: + """Send an alert notification to the configured channel. + + Args: + webhook_url: URL to POST to. + format_type: "slack", "teams", or "generic". + rule_name: Name of the triggered rule. + severity: high, medium, or low. + message: Human-readable alert message. + event: The normalized event that triggered the alert. + + Returns: + True if delivery succeeded, False otherwise. + """ + if not webhook_url: + return False + + builders = { + "slack": _build_slack_payload, + "teams": _build_teams_payload, + "generic": _build_generic_payload, + } + builder = builders.get(format_type, _build_generic_payload) + payload = builder(rule_name, severity, message, event) + + try: + res = _post_webhook(webhook_url, payload) + res.raise_for_status() + logger.info( + "Notification sent", + rule=rule_name, + severity=severity, + format=format_type, + status_code=res.status_code, + ) + return True + except Exception as exc: + logger.warning( + "Notification failed after retries", + rule=rule_name, + severity=severity, + format=format_type, + error=str(exc), + ) + return False diff --git a/backend/routes/alerts.py b/backend/routes/alerts.py new file mode 100644 index 0000000..1a20829 --- /dev/null +++ b/backend/routes/alerts.py @@ -0,0 +1,78 @@ +"""Alert management endpoints.""" + +from auth import require_auth +from bson import ObjectId +from database import alerts_collection +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter(dependencies=[Depends(require_auth)]) + + +class AlertStatusUpdate(BaseModel): + status: str # open | acknowledged | resolved | false_positive + + +class AlertListResponse(BaseModel): + items: list[dict] + total: int + + +@router.get("/alerts", response_model=AlertListResponse) +def list_alerts( + status: str = Query(default="", description="Filter by status"), + severity: str = Query(default="", description="Filter by severity"), + rule_name: str = Query(default="", description="Filter by rule name"), + page_size: int = Query(default=50, ge=1, le=200), + page: int = Query(default=1, ge=1), +): + query = {} + if status: + query["status"] = status + if severity: + query["severity"] = severity + if rule_name: + query["rule_name"] = {"$regex": rule_name, "$options": "i"} + + total = alerts_collection.count_documents(query) + skip = (page - 1) * page_size + cursor = alerts_collection.find(query, {"_id": 0}).sort("timestamp", -1).skip(skip).limit(page_size) + return {"items": list(cursor), "total": total} + + +@router.patch("/alerts/{alert_id}/status") +def update_alert_status(alert_id: str, body: AlertStatusUpdate): + result = alerts_collection.update_one( + {"_id": ObjectId(alert_id)}, + {"$set": {"status": body.status}}, + ) + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Alert not found") + return {"updated": True, "status": body.status} + + +@router.get("/alerts/summary") +def alert_summary(): + """Return counts by status and severity for the dashboard.""" + pipeline = [ + { + "$group": { + "_id": {"status": "$status", "severity": "$severity"}, + "count": {"$sum": 1}, + } + } + ] + by_status_severity = list(alerts_collection.aggregate(pipeline)) + + total_open = alerts_collection.count_documents({"status": "open"}) + total_acknowledged = alerts_collection.count_documents({"status": "acknowledged"}) + total_resolved = alerts_collection.count_documents({"status": "resolved"}) + total_false_positive = alerts_collection.count_documents({"status": "false_positive"}) + + return { + "total_open": total_open, + "total_acknowledged": total_acknowledged, + "total_resolved": total_resolved, + "total_false_positive": total_false_positive, + "by_status_severity": by_status_severity, + } diff --git a/backend/rules.py b/backend/rules.py index e50927e..edaeca8 100644 --- a/backend/rules.py +++ b/backend/rules.py @@ -1,6 +1,16 @@ -from datetime import UTC, datetime +"""Rule-based alerting for admin operations. + +Rules are evaluated during event ingestion. Triggered alerts are stored in MongoDB +and optionally forwarded to a notification channel (webhook, Slack, Teams). + +Deduplication: the same rule firing for the same actor within ALERT_DEDUPE_MINUTES +produces only one alert. +""" + +from datetime import UTC, datetime, timedelta import structlog +from config import ALERT_DEDUPE_MINUTES, ALERT_WEBHOOK_FORMAT, ALERT_WEBHOOK_URL from database import db logger = structlog.get_logger("aoc.rules") @@ -18,6 +28,13 @@ def evaluate_event(event: dict) -> list[dict]: rules = load_rules() for rule in rules: if _matches(rule, event): + if _is_duplicate(rule, event): + logger.debug( + "Alert deduplicated", + rule=rule.get("name"), + event_id=event.get("id"), + ) + continue triggered.append(rule) _create_alert(rule, event) return triggered @@ -50,6 +67,9 @@ def _matches(rule: dict, event: dict) -> bool: return False except Exception: return False + if op == "threshold_count": + # Threshold rules are evaluated at query time, not per-event + return False return True @@ -64,7 +84,22 @@ def _get_nested(obj: dict, path: str): return val +def _is_duplicate(rule: dict, event: dict) -> bool: + """Check if an alert for this rule + actor was recently created.""" + if ALERT_DEDUPE_MINUTES <= 0: + return False + cutoff = (datetime.now(UTC) - timedelta(minutes=ALERT_DEDUPE_MINUTES)).isoformat() + actor = event.get("actor_display") or event.get("actor_upn") or "unknown" + query = { + "rule_id": str(rule.get("_id")), + "actor": actor, + "timestamp": {"$gte": cutoff}, + } + return alerts_collection.count_documents(query, limit=1) > 0 + + def _create_alert(rule: dict, event: dict): + actor = event.get("actor_display") or event.get("actor_upn") or "unknown" alert = { "timestamp": datetime.now(UTC).isoformat(), "rule_id": str(rule.get("_id")), @@ -72,10 +107,162 @@ def _create_alert(rule: dict, event: dict): "severity": rule.get("severity", "medium"), "event_id": event.get("id"), "event_dedupe_key": event.get("dedupe_key"), + "actor": actor, "message": rule.get("message", f"Rule '{rule.get('name')}' triggered"), + "status": "open", # open | acknowledged | resolved | false_positive } 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)) + return + + # Send notification + if ALERT_WEBHOOK_URL: + try: + from notifications import send_notification + + send_notification( + webhook_url=ALERT_WEBHOOK_URL, + format_type=ALERT_WEBHOOK_FORMAT, + rule_name=rule.get("name", "Unnamed rule"), + severity=rule.get("severity", "medium"), + message=rule.get("message", ""), + event=event, + ) + except Exception as exc: + logger.warning("Failed to send notification", error=str(exc)) + + +def seed_default_rules(): + """Insert pre-built admin-ops rule templates if the collection is empty.""" + if rules_collection.count_documents({}) > 0: + return + + defaults = [ + { + "name": "Failed Conditional Access", + "enabled": True, + "severity": "high", + "message": ( + "A Conditional Access policy evaluation failed. " + "This may indicate a sign-in risk or policy misconfiguration." + ), + "conditions": [ + {"field": "service", "op": "eq", "value": "Directory"}, + {"field": "operation", "op": "contains", "value": "ConditionalAccess"}, + {"field": "result", "op": "neq", "value": "success"}, + ], + }, + { + "name": "After-Hours Admin Activity", + "enabled": True, + "severity": "medium", + "message": "A privileged operation was performed outside business hours (9 AM – 5 PM).", + "conditions": [ + { + "field": "service", + "op": "in", + "value": ["Directory", "UserManagement", "GroupManagement", "RoleManagement"], + }, + {"field": "timestamp", "op": "after_hours"}, + ], + }, + { + "name": "New Application Registration", + "enabled": True, + "severity": "medium", + "message": ( + "A new application was registered in Entra ID. Review for shadow IT or unauthorized integrations." + ), + "conditions": [ + {"field": "service", "op": "eq", "value": "ApplicationManagement"}, + {"field": "operation", "op": "contains", "value": "Add application"}, + ], + }, + { + "name": "Admin Role Assignment", + "enabled": True, + "severity": "high", + "message": "A user was assigned an administrative role. Verify this was expected and authorized.", + "conditions": [ + {"field": "service", "op": "eq", "value": "RoleManagement"}, + {"field": "operation", "op": "contains", "value": "Add member to role"}, + ], + }, + { + "name": "License Change", + "enabled": True, + "severity": "low", + "message": "A license was assigned or removed from a user. Monitor for unexpected cost changes.", + "conditions": [ + {"field": "service", "op": "eq", "value": "License"}, + ], + }, + { + "name": "Bulk User Deletion", + "enabled": True, + "severity": "high", + "message": ( + "Multiple users were deleted in a short window. " + "This may indicate a compromised admin account or cleanup activity." + ), + "conditions": [ + {"field": "service", "op": "in", "value": ["Directory", "UserManagement"]}, + {"field": "operation", "op": "contains", "value": "Delete user"}, + ], + }, + { + "name": "Device Compliance Failure", + "enabled": True, + "severity": "medium", + "message": ( + "A device failed compliance evaluation. " + "It may no longer meet your organization's security requirements." + ), + "conditions": [ + {"field": "service", "op": "eq", "value": "Intune"}, + {"field": "operation", "op": "contains", "value": "compliance"}, + {"field": "result", "op": "neq", "value": "success"}, + ], + }, + { + "name": "Exchange Transport Rule Change", + "enabled": True, + "severity": "high", + "message": "An Exchange transport rule was modified. This could affect mail flow or security filtering.", + "conditions": [ + {"field": "service", "op": "eq", "value": "Exchange"}, + {"field": "operation", "op": "contains", "value": "Transport rule"}, + ], + }, + { + "name": "Service Principal Credential Added", + "enabled": True, + "severity": "high", + "message": "A new secret or certificate was added to a service principal. Verify this was expected.", + "conditions": [ + {"field": "service", "op": "eq", "value": "ApplicationManagement"}, + {"field": "operation", "op": "contains", "value": "Add service principal credentials"}, + ], + }, + { + "name": "External Sharing Enabled", + "enabled": True, + "severity": "medium", + "message": ( + "External sharing settings were modified on a SharePoint site or team. Review for data exposure risk." + ), + "conditions": [ + {"field": "service", "op": "in", "value": ["SharePoint", "Teams"]}, + {"field": "operation", "op": "contains", "value": "Sharing"}, + ], + }, + ] + + try: + rules_collection.insert_many(defaults) + logger.info("Default admin-ops rules seeded", count=len(defaults)) + except Exception as exc: + logger.warning("Failed to seed default rules", error=str(exc)) diff --git a/backend/tests/test_rules.py b/backend/tests/test_rules.py index cc5f816..43f5acd 100644 --- a/backend/tests/test_rules.py +++ b/backend/tests/test_rules.py @@ -59,6 +59,7 @@ def test_evaluate_event_creates_alert(monkeypatch): inserted["doc"] = doc monkeypatch.setattr(alerts_collection, "insert_one", mock_insert) + monkeypatch.setattr(alerts_collection, "count_documents", lambda *args, **kwargs: 0) event = {"id": "e1", "operation": "Add user", "timestamp": datetime.now(UTC).isoformat(), "dedupe_key": "dk1"} triggered = evaluate_event(event)