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:
@@ -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.events.events_collection", mock_events_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.set_watermark", lambda source, ts: None)
|
||||
monkeypatch.setattr("auth.AUTH_ENABLED", False)
|
||||
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
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
@@ -40,7 +40,6 @@ def test_list_events_cursor_pagination(client, mock_events_collection):
|
||||
assert len(data["items"]) == 2
|
||||
assert data["next_cursor"] is not None
|
||||
|
||||
# Follow cursor
|
||||
response2 = client.get(f"/api/events?page_size=2&cursor={data['next_cursor']}")
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
@@ -130,3 +129,79 @@ def test_graph_webhook_notification(client):
|
||||
response = client.post("/api/webhooks/graph", json=payload)
|
||||
assert response.status_code == 200
|
||||
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"
|
||||
Reference in New Issue
Block a user