9 Commits

Author SHA1 Message Date
b0eba09f0f ci: suppress docker credential storage warning in release workflow
All checks were successful
CI / lint-and-test (push) Successful in 23s
Release / build-and-push (push) Successful in 21s
2026-04-17 16:10:09 +02:00
91a4c6dccf fix(ci): use REGISTRY_TOKEN secret for container registry auth
All checks were successful
CI / lint-and-test (push) Successful in 22s
Release / build-and-push (push) Successful in 49s
2026-04-17 16:04:31 +02:00
196e1b7781 fix(tests): use services query param for multi-service filter test
Some checks failed
CI / lint-and-test (push) Successful in 23s
Release / build-and-push (push) Failing after 22s
2026-04-17 15:57:48 +02:00
30dc75d0e5 ci: retrigger after database.py MONGO_URI fix
Some checks failed
CI / lint-and-test (push) Failing after 31s
2026-04-17 15:52:42 +02:00
b45d9bb8a3 fix(database): provide safe default MONGO_URI to prevent CI import crash
Some checks failed
CI / lint-and-test (push) Failing after 40s
- Avoid Empty host error when MONGO_URI is unset during test collection
2026-04-16 19:10:14 +02:00
52f565b647 style: apply ruff formatting to tests/test_rules.py
Some checks failed
CI / lint-and-test (push) Failing after 24s
2026-04-16 19:01:24 +02:00
9774277bd0 fix(tests): defer rules import in test_rules.py to avoid CI db init error
Some checks failed
CI / lint-and-test (push) Failing after 29s
2026-04-16 19:00:20 +02:00
4713b43afe style: apply ruff formatting to all backend files
Some checks failed
CI / lint-and-test (push) Failing after 38s
2026-04-16 18:58:41 +02:00
b86539399b fix(ci): resolve ruff SIM108 lint error and use github.token for registry login
Some checks failed
CI / lint-and-test (push) Failing after 22s
2026-04-16 18:55:52 +02:00
16 changed files with 267 additions and 197 deletions

View File

@@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Log in to Gitea Container Registry - name: Log in to Gitea Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login git.cqre.net -u ${{ gitea.actor }} --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.cqre.net -u ${{ github.actor }} --password-stdin 2>&1 | grep -v "WARNING! Your credentials are stored unencrypted"
- name: Build Docker image - name: Build Docker image
run: docker build ./backend --tag git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }} run: docker build ./backend --tag git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}

View File

@@ -4,7 +4,7 @@ import structlog
from config import DB_NAME, MONGO_URI, RETENTION_DAYS from config import DB_NAME, MONGO_URI, RETENTION_DAYS
from pymongo import ASCENDING, DESCENDING, TEXT, MongoClient from pymongo import ASCENDING, DESCENDING, TEXT, MongoClient
client = MongoClient(MONGO_URI) client = MongoClient(MONGO_URI or "mongodb://localhost:27017")
db = client[DB_NAME] db = client[DB_NAME]
events_collection = db["events"] events_collection = db["events"]
logger = structlog.get_logger("aoc.database") logger = structlog.get_logger("aoc.database")

View File

@@ -9,10 +9,7 @@ def fetch_audit_logs(hours: int = 24, since: str | None = None, max_pages: int =
"""Fetch paginated directory audit logs from Microsoft Graph and enrich with resolved names.""" """Fetch paginated directory audit logs from Microsoft Graph and enrich with resolved names."""
token = get_access_token() token = get_access_token()
start_time = since or (datetime.utcnow() - timedelta(hours=hours)).isoformat() + "Z" start_time = since or (datetime.utcnow() - timedelta(hours=hours)).isoformat() + "Z"
next_url = ( next_url = f"https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?$filter=activityDateTime ge {start_time}"
"https://graph.microsoft.com/v1.0/"
f"auditLogs/directoryAudits?$filter=activityDateTime ge {start_time}"
)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
events = [] events = []

View File

@@ -1,4 +1,3 @@
from utils.http import get_with_retry from utils.http import get_with_retry
@@ -48,7 +47,10 @@ def resolve_directory_object(object_id: str, token: str, cache: dict[str, dict])
probes = [ probes = [
("user", f"https://graph.microsoft.com/v1.0/users/{object_id}?$select=id,displayName,userPrincipalName,mail"), ("user", f"https://graph.microsoft.com/v1.0/users/{object_id}?$select=id,displayName,userPrincipalName,mail"),
("servicePrincipal", f"https://graph.microsoft.com/v1.0/servicePrincipals/{object_id}?$select=id,displayName,appId,appDisplayName"), (
"servicePrincipal",
f"https://graph.microsoft.com/v1.0/servicePrincipals/{object_id}?$select=id,displayName,appId,appDisplayName",
),
("group", f"https://graph.microsoft.com/v1.0/groups/{object_id}?$select=id,displayName,mail"), ("group", f"https://graph.microsoft.com/v1.0/groups/{object_id}?$select=id,displayName,mail"),
("device", f"https://graph.microsoft.com/v1.0/devices/{object_id}?$select=id,displayName"), ("device", f"https://graph.microsoft.com/v1.0/devices/{object_id}?$select=id,displayName"),
] ]
@@ -82,12 +84,7 @@ def resolve_service_principal_owners(sp_id: str, token: str, cache: dict[str, li
) )
payload = _request_json(url, token) payload = _request_json(url, token)
for owner in (payload or {}).get("value", []): for owner in (payload or {}).get("value", []):
name = ( name = owner.get("displayName") or owner.get("userPrincipalName") or owner.get("mail") or owner.get("id")
owner.get("displayName")
or owner.get("userPrincipalName")
or owner.get("mail")
or owner.get("id")
)
if name: if name:
owners.append(name) owners.append(name)

View File

@@ -85,12 +85,14 @@ async def audit_middleware(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
if request.url.path.startswith("/api/") and request.method in ("POST", "PATCH", "PUT", "DELETE"): if request.url.path.startswith("/api/") and request.method in ("POST", "PATCH", "PUT", "DELETE"):
from auth import AUTH_ENABLED from auth import AUTH_ENABLED
user = "anonymous" user = "anonymous"
if AUTH_ENABLED: if AUTH_ENABLED:
auth_header = request.headers.get("authorization", "") auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "): if auth_header.lower().startswith("bearer "):
try: try:
from jose import jwt from jose import jwt
token = auth_header.split(" ", 1)[1] token = auth_header.split(" ", 1)[1]
claims = jwt.get_unverified_claims(token) claims = jwt.get_unverified_claims(token)
user = claims.get("sub", "unknown") user = claims.get("sub", "unknown")
@@ -116,6 +118,7 @@ app.include_router(rules_router, prefix="/api")
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
from database import db from database import db
try: try:
db.command("ping") db.command("ping")
return {"status": "ok", "database": "connected"} return {"status": "ok", "database": "connected"}

View File

@@ -6,6 +6,7 @@ new display fields. Example:
python maintenance.py renormalize --limit 500 python maintenance.py renormalize --limit 500
""" """
import argparse import argparse
from database import events_collection from database import events_collection
@@ -53,7 +54,9 @@ def dedupe(limit: int = None, batch_size: int = 500) -> int:
""" """
Remove duplicate events based on dedupe_key. Keeps the first occurrence encountered. Remove duplicate events based on dedupe_key. Keeps the first occurrence encountered.
""" """
cursor = events_collection.find({}, projection={"_id": 1, "dedupe_key": 1, "raw": 1, "id": 1, "timestamp": 1}).sort("timestamp", 1) cursor = events_collection.find({}, projection={"_id": 1, "dedupe_key": 1, "raw": 1, "id": 1, "timestamp": 1}).sort(
"timestamp", 1
)
if limit: if limit:
cursor = cursor.limit(int(limit)) cursor = cursor.limit(int(limit))

View File

@@ -1,4 +1,3 @@
from prometheus_client import Counter, Histogram, generate_latest from prometheus_client import Counter, Histogram, generate_latest
REQUEST_DURATION = Histogram( REQUEST_DURATION = Histogram(

View File

@@ -75,10 +75,7 @@ def _target_types(targets: list) -> list:
types = [] types = []
for t in targets or []: for t in targets or []:
resolved = t.get("_resolved") or {} resolved = t.get("_resolved") or {}
t_type = ( t_type = resolved.get("type") or t.get("type")
resolved.get("type")
or t.get("type")
)
if t_type: if t_type:
types.append(t_type) types.append(t_type)
return types return types
@@ -101,7 +98,9 @@ def _display_summary(operation: str, target_labels: list, actor_label: str, targ
return " | ".join(pieces) return " | ".join(pieces)
def _render_summary(template: str, operation: str, actor: str, target: str, category: str, result: str, service: str) -> str: def _render_summary(
template: str, operation: str, actor: str, target: str, category: str, result: str, service: str
) -> str:
try: try:
return template.format( return template.format(
operation=operation or category or "Event", operation=operation or category or "Event",
@@ -177,13 +176,16 @@ def normalize_event(e):
else: else:
display_actor_value = actor_label display_actor_value = actor_label
dedupe_key = _make_dedupe_key(e, { dedupe_key = _make_dedupe_key(
"id": e.get("id"), e,
"timestamp": e.get("activityDateTime"), {
"service": e.get("category"), "id": e.get("id"),
"operation": e.get("activityDisplayName"), "timestamp": e.get("activityDateTime"),
"target_displays": target_labels, "service": e.get("category"),
}) "operation": e.get("activityDisplayName"),
"target_displays": target_labels,
},
)
return { return {
"id": e.get("id"), "id": e.get("id"),

View File

@@ -143,11 +143,7 @@ def list_events(
try: try:
total = events_collection.count_documents(query) if not cursor else -1 total = events_collection.count_documents(query) if not cursor else -1
cursor_query = ( cursor_query = events_collection.find(query).sort([("timestamp", -1), ("_id", -1)]).limit(safe_page_size)
events_collection.find(query)
.sort([("timestamp", -1), ("_id", -1)])
.limit(safe_page_size)
)
events = list(cursor_query) events = list(cursor_query)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to query events: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to query events: {exc}") from exc
@@ -160,10 +156,28 @@ 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 { log_action(
"service": service, "actor": actor, "operation": operation, "result": result, "list_events",
"start": start, "end": end, "search": search, "cursor": cursor, "page_size": page_size, "/api/events",
}.items() if v is not None}}, user.get("sub", "anonymous")) {
"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,
@@ -204,17 +218,19 @@ def bulk_tags(
if not tags: if not tags:
raise HTTPException(status_code=400, detail="No tags provided") raise HTTPException(status_code=400, detail="No tags provided")
if body.mode == "replace": update = {"$set": {"tags": tags}} if body.mode == "replace" else {"$addToSet": {"tags": {"$each": tags}}}
update = {"$set": {"tags": tags}}
else:
update = {"$addToSet": {"tags": {"$each": tags}}}
try: try:
result_obj = events_collection.update_many(query, update) result_obj = events_collection.update_many(query, update)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to update tags: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to update tags: {exc}") from exc
log_action("bulk_tags", "/api/events/bulk-tags", {"tags": tags, "mode": body.mode, "matched": result_obj.matched_count}, user.get("sub", "anonymous")) log_action(
"bulk_tags",
"/api/events/bulk-tags",
{"tags": tags, "mode": body.mode, "matched": result_obj.matched_count},
user.get("sub", "anonymous"),
)
return {"matched": result_obj.matched_count, "modified": result_obj.modified_count} return {"matched": result_obj.matched_count, "modified": result_obj.modified_count}

View File

@@ -54,11 +54,14 @@ def run_fetch(hours: int = 168):
if key: if key:
ops.append(UpdateOne({"dedupe_key": key}, {"$set": doc}, upsert=True)) ops.append(UpdateOne({"dedupe_key": key}, {"$set": doc}, upsert=True))
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: if ALERTS_ENABLED:
from rules import evaluate_event from rules import evaluate_event
for doc in normalized: for doc in normalized:
evaluate_event(doc) evaluate_event(doc)
@@ -75,7 +78,12 @@ def fetch_logs(
): ):
try: try:
result = 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")) log_action(
"fetch_audit_logs",
"/api/fetch-audit-logs",
{"hours": hours, "stored": result["stored_events"]},
user.get("sub", "anonymous"),
)
return result 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

View File

@@ -1,4 +1,3 @@
from auth import require_auth from auth import require_auth
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from models.api import SourceHealthResponse from models.api import SourceHealthResponse
@@ -19,17 +18,21 @@ def source_health():
status = doc.get("status") status = doc.get("status")
if not status: if not status:
status = "healthy" if doc.get("last_fetch_time") else "unknown" status = "healthy" if doc.get("last_fetch_time") else "unknown"
results.append({ results.append(
"source": source, {
"last_fetch_time": doc.get("last_fetch_time"), "source": source,
"last_attempt_time": doc.get("last_attempt_time"), "last_fetch_time": doc.get("last_fetch_time"),
"status": status, "last_attempt_time": doc.get("last_attempt_time"),
}) "status": status,
}
)
else: else:
results.append({ results.append(
"source": source, {
"last_fetch_time": None, "source": source,
"last_attempt_time": None, "last_fetch_time": None,
"status": "unknown", "last_attempt_time": None,
}) "status": "unknown",
}
)
return results return results

View File

@@ -11,10 +11,7 @@ def fetch_intune_audit(hours: int = 24, since: str | None = None, max_pages: int
""" """
token = get_access_token() token = get_access_token()
start_time = since or (datetime.utcnow() - timedelta(hours=hours)).isoformat() + "Z" start_time = since or (datetime.utcnow() - timedelta(hours=hours)).isoformat() + "Z"
url = ( url = f"https://graph.microsoft.com/v1.0/deviceManagement/auditEvents?$filter=activityDateTime ge {start_time}"
"https://graph.microsoft.com/v1.0/deviceManagement/auditEvents"
f"?$filter=activityDateTime ge {start_time}"
)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
events = [] events = []
@@ -69,7 +66,8 @@ def _normalize_intune(e: dict) -> dict:
"targetResources": [ "targetResources": [
{ {
"id": target.get("id"), "id": target.get("id"),
"displayName": target.get("displayName") or target.get("modifiedProperties", [{}])[0].get("displayName"), "displayName": target.get("displayName")
or target.get("modifiedProperties", [{}])[0].get("displayName"),
"type": target.get("type"), "type": target.get("type"),
} }
] ]

View File

@@ -25,15 +25,17 @@ def test_list_events_empty(client):
def test_list_events_cursor_pagination(client, mock_events_collection): def test_list_events_cursor_pagination(client, mock_events_collection):
for i in range(5): for i in range(5):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": f"evt-{i}", {
"timestamp": datetime.now(UTC).isoformat(), "id": f"evt-{i}",
"service": "Directory", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Add user", "service": "Directory",
"result": "success", "operation": "Add user",
"actor_display": f"Actor {i}", "result": "success",
"raw_text": "", "actor_display": f"Actor {i}",
}) "raw_text": "",
}
)
response = client.get("/api/events?page_size=2") response = client.get("/api/events?page_size=2")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -48,24 +50,28 @@ def test_list_events_cursor_pagination(client, mock_events_collection):
def test_list_events_filter_by_service(client, mock_events_collection): def test_list_events_filter_by_service(client, mock_events_collection):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": "evt-1", {
"timestamp": datetime.now(UTC).isoformat(), "id": "evt-1",
"service": "Exchange", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Update", "service": "Exchange",
"result": "success", "operation": "Update",
"actor_display": "Alice", "result": "success",
"raw_text": "", "actor_display": "Alice",
}) "raw_text": "",
mock_events_collection.insert_one({ }
"id": "evt-2", )
"timestamp": datetime.now(UTC).isoformat(), mock_events_collection.insert_one(
"service": "Directory", {
"operation": "Add", "id": "evt-2",
"result": "success", "timestamp": datetime.now(UTC).isoformat(),
"actor_display": "Bob", "service": "Directory",
"raw_text": "", "operation": "Add",
}) "result": "success",
"actor_display": "Bob",
"raw_text": "",
}
)
response = client.get("/api/events?service=Exchange") response = client.get("/api/events?service=Exchange")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -74,34 +80,40 @@ def test_list_events_filter_by_service(client, mock_events_collection):
def test_list_events_filter_by_services(client, mock_events_collection): def test_list_events_filter_by_services(client, mock_events_collection):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": "evt-1", {
"timestamp": datetime.now(UTC).isoformat(), "id": "evt-1",
"service": "Exchange", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Update", "service": "Exchange",
"result": "success", "operation": "Update",
"actor_display": "Alice", "result": "success",
"raw_text": "", "actor_display": "Alice",
}) "raw_text": "",
mock_events_collection.insert_one({ }
"id": "evt-2", )
"timestamp": datetime.now(UTC).isoformat(), mock_events_collection.insert_one(
"service": "Directory", {
"operation": "Add", "id": "evt-2",
"result": "success", "timestamp": datetime.now(UTC).isoformat(),
"actor_display": "Bob", "service": "Directory",
"raw_text": "", "operation": "Add",
}) "result": "success",
mock_events_collection.insert_one({ "actor_display": "Bob",
"id": "evt-3", "raw_text": "",
"timestamp": datetime.now(UTC).isoformat(), }
"service": "Teams", )
"operation": "Delete", mock_events_collection.insert_one(
"result": "success", {
"actor_display": "Charlie", "id": "evt-3",
"raw_text": "", "timestamp": datetime.now(UTC).isoformat(),
}) "service": "Teams",
response = client.get("/api/events?service=Exchange&service=Directory") "operation": "Delete",
"result": "success",
"actor_display": "Charlie",
"raw_text": "",
}
)
response = client.get("/api/events?services=Exchange&services=Directory")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert len(data["items"]) == 2 assert len(data["items"]) == 2
@@ -117,16 +129,18 @@ def test_list_events_page_size_validation(client):
def test_filter_options(client, mock_events_collection): def test_filter_options(client, mock_events_collection):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": "evt-1", {
"timestamp": datetime.now(UTC).isoformat(), "id": "evt-1",
"service": "Intune", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Assign", "service": "Intune",
"result": "failure", "operation": "Assign",
"actor_display": "Charlie", "result": "failure",
"actor_upn": "charlie@example.com", "actor_display": "Charlie",
"raw_text": "", "actor_upn": "charlie@example.com",
}) "raw_text": "",
}
)
response = client.get("/api/filter-options") response = client.get("/api/filter-options")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -168,15 +182,17 @@ def test_graph_webhook_notification(client):
def test_update_tags(client, mock_events_collection): def test_update_tags(client, mock_events_collection):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": "evt-tags", {
"timestamp": datetime.now(UTC).isoformat(), "id": "evt-tags",
"service": "Directory", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Add user", "service": "Directory",
"result": "success", "operation": "Add user",
"actor_display": "Alice", "result": "success",
"raw_text": "", "actor_display": "Alice",
}) "raw_text": "",
}
)
response = client.patch("/api/events/evt-tags/tags", json={"tags": ["investigating", "urgent"]}) response = client.patch("/api/events/evt-tags/tags", json={"tags": ["investigating", "urgent"]})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["tags"] == ["investigating", "urgent"] assert response.json()["tags"] == ["investigating", "urgent"]
@@ -185,15 +201,17 @@ def test_update_tags(client, mock_events_collection):
def test_add_comment(client, mock_events_collection): def test_add_comment(client, mock_events_collection):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": "evt-comment", {
"timestamp": datetime.now(UTC).isoformat(), "id": "evt-comment",
"service": "Directory", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Add user", "service": "Directory",
"result": "success", "operation": "Add user",
"actor_display": "Alice", "result": "success",
"raw_text": "", "actor_display": "Alice",
}) "raw_text": "",
}
)
response = client.post("/api/events/evt-comment/comments", json={"text": "Looks suspicious"}) response = client.post("/api/events/evt-comment/comments", json={"text": "Looks suspicious"})
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -244,26 +262,30 @@ def test_rules_crud(client):
def test_list_events_filter_by_include_tags(client, mock_events_collection): def test_list_events_filter_by_include_tags(client, mock_events_collection):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": "evt-tagged", {
"timestamp": datetime.now(UTC).isoformat(), "id": "evt-tagged",
"service": "Directory", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Add user", "service": "Directory",
"result": "success", "operation": "Add user",
"actor_display": "Alice", "result": "success",
"raw_text": "", "actor_display": "Alice",
"tags": ["backup", "auto"], "raw_text": "",
}) "tags": ["backup", "auto"],
mock_events_collection.insert_one({ }
"id": "evt-untagged", )
"timestamp": datetime.now(UTC).isoformat(), mock_events_collection.insert_one(
"service": "Directory", {
"operation": "Remove user", "id": "evt-untagged",
"result": "success", "timestamp": datetime.now(UTC).isoformat(),
"actor_display": "Bob", "service": "Directory",
"raw_text": "", "operation": "Remove user",
"tags": [], "result": "success",
}) "actor_display": "Bob",
"raw_text": "",
"tags": [],
}
)
response = client.get("/api/events?include_tags=backup") response = client.get("/api/events?include_tags=backup")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -272,16 +294,18 @@ def test_list_events_filter_by_include_tags(client, mock_events_collection):
def test_bulk_tags_append(client, mock_events_collection): def test_bulk_tags_append(client, mock_events_collection):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": "evt-bulk", {
"timestamp": datetime.now(UTC).isoformat(), "id": "evt-bulk",
"service": "Exchange", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Update", "service": "Exchange",
"result": "success", "operation": "Update",
"actor_display": "Alice", "result": "success",
"raw_text": "", "actor_display": "Alice",
"tags": ["existing"], "raw_text": "",
}) "tags": ["existing"],
}
)
response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "append"}) response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "append"})
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -292,16 +316,18 @@ def test_bulk_tags_append(client, mock_events_collection):
def test_bulk_tags_replace(client, mock_events_collection): def test_bulk_tags_replace(client, mock_events_collection):
mock_events_collection.insert_one({ mock_events_collection.insert_one(
"id": "evt-bulk2", {
"timestamp": datetime.now(UTC).isoformat(), "id": "evt-bulk2",
"service": "Exchange", "timestamp": datetime.now(UTC).isoformat(),
"operation": "Update", "service": "Exchange",
"result": "success", "operation": "Update",
"actor_display": "Alice", "result": "success",
"raw_text": "", "actor_display": "Alice",
"tags": ["old"], "raw_text": "",
}) "tags": ["old"],
}
)
response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "replace"}) response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "replace"})
assert response.status_code == 200 assert response.status_code == 200
doc = mock_events_collection.find_one({"id": "evt-bulk2"}) doc = mock_events_collection.find_one({"id": "evt-bulk2"})

View File

@@ -30,9 +30,7 @@ def test_normalize_event_basic():
"userPrincipalName": "alice@example.com", "userPrincipalName": "alice@example.com",
} }
}, },
"targetResources": [ "targetResources": [{"id": "t1", "displayName": "Bob", "type": "User"}],
{"id": "t1", "displayName": "Bob", "type": "User"}
],
} }
out = normalize_event(e) out = normalize_event(e)
assert out["id"] == "abc" assert out["id"] == "abc"

View File

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

View File

@@ -1,4 +1,3 @@
import requests import requests
import structlog import structlog
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
@@ -18,12 +17,16 @@ RETRY_CONFIG = {
@retry(**RETRY_CONFIG) @retry(**RETRY_CONFIG)
def get_with_retry(url: str, headers: dict | None = None, params: dict | None = None, timeout: float = 20) -> requests.Response: def get_with_retry(
url: str, headers: dict | None = None, params: dict | None = None, timeout: float = 20
) -> requests.Response:
res = requests.get(url, headers=headers, params=params, timeout=timeout) res = requests.get(url, headers=headers, params=params, timeout=timeout)
return res return res
@retry(**RETRY_CONFIG) @retry(**RETRY_CONFIG)
def post_with_retry(url: str, headers: dict | None = None, data: dict | None = None, params: dict | None = None, timeout: float = 15) -> requests.Response: def post_with_retry(
url: str, headers: dict | None = None, data: dict | None = None, params: dict | None = None, timeout: float = 15
) -> requests.Response:
res = requests.post(url, headers=headers, data=data, params=params, timeout=timeout) res = requests.post(url, headers=headers, data=data, params=params, timeout=timeout)
return res return res