Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0eba09f0f | |||
| 91a4c6dccf | |||
| 196e1b7781 | |||
| 30dc75d0e5 | |||
| b45d9bb8a3 | |||
| 52f565b647 | |||
| 9774277bd0 | |||
| 4713b43afe | |||
| b86539399b | |||
| 86966bb57f | |||
| 3761aa6d74 | |||
| 6d00d7cf32 | |||
| de9ea45e1e | |||
| bade860fd4 | |||
| 9f4601c4d9 |
22
.gitea/workflows/release.yml
Normal file
22
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
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
|
||||||
|
run: docker build ./backend --tag git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: docker push git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
|
||||||
55
.github/workflows/release.yml
vendored
55
.github/workflows/release.yml
vendored
@@ -1,55 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}-backend
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
id: push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ./backend
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
|
||||||
uses: actions/attest-build-provenance@v1
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<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?v=7" />
|
<link rel="stylesheet" href="/style.css?v=8" />
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<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>
|
||||||
@@ -76,12 +76,20 @@
|
|||||||
To
|
To
|
||||||
<input name="end" type="datetime-local" x-model="filters.end" />
|
<input name="end" type="datetime-local" x-model="filters.end" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Include tags
|
||||||
|
<input name="includeTags" type="text" placeholder="backup, critical" x-model="filters.includeTags" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Exclude tags
|
||||||
|
<input name="excludeTags" type="text" placeholder="noise, auto" x-model="filters.excludeTags" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-row">
|
||||||
<label class="span-2">
|
<label class="span-2">
|
||||||
Search (raw/full-text)
|
Search (raw/full-text)
|
||||||
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
|
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
<div class="filter-row filter-row--tall">
|
|
||||||
<div class="filter-group span-2">
|
<div class="filter-group span-2">
|
||||||
<span>App / Service</span>
|
<span>App / Service</span>
|
||||||
<div class="multi-select">
|
<div class="multi-select">
|
||||||
@@ -104,6 +112,7 @@
|
|||||||
<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" @click="clearFilters()">Clear</button>
|
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||||
|
<button type="button" class="ghost" @click="bulkTagMatching()">Bulk tag matching</button>
|
||||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +197,7 @@
|
|||||||
accessToken: null,
|
accessToken: null,
|
||||||
authScopes: [],
|
authScopes: [],
|
||||||
filters: {
|
filters: {
|
||||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100,
|
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '',
|
||||||
},
|
},
|
||||||
options: { actors: [], services: [], operations: [], results: [] },
|
options: { actors: [], services: [], operations: [], results: [] },
|
||||||
|
|
||||||
@@ -320,6 +329,12 @@
|
|||||||
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
||||||
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
||||||
}
|
}
|
||||||
|
if (this.filters.includeTags) {
|
||||||
|
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.excludeTags) {
|
||||||
|
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
|
||||||
|
}
|
||||||
if (this.filters.start) {
|
if (this.filters.start) {
|
||||||
const d = new Date(this.filters.start);
|
const d = new Date(this.filters.start);
|
||||||
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
||||||
@@ -417,11 +432,53 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearFilters() {
|
clearFilters() {
|
||||||
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100 };
|
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '' };
|
||||||
this.resetPagination();
|
this.resetPagination();
|
||||||
this.loadEvents();
|
this.loadEvents();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async bulkTagMatching() {
|
||||||
|
const tag = prompt('Enter tag to apply to all matching events:');
|
||||||
|
if (!tag || !tag.trim()) return;
|
||||||
|
const mode = confirm('Click OK to REPLACE existing tags.\nClick Cancel to APPEND the new tag.') ? 'replace' : 'append';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
['actor', 'operation', 'result', 'search'].forEach((key) => {
|
||||||
|
const val = this.filters[key];
|
||||||
|
if (val) params.append(key, val);
|
||||||
|
});
|
||||||
|
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
||||||
|
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
||||||
|
}
|
||||||
|
if (this.filters.includeTags) {
|
||||||
|
this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
|
||||||
|
}
|
||||||
|
if (this.filters.excludeTags) {
|
||||||
|
this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
this.statusText = 'Applying bulk tag…';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/bulk-tags?${params.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||||
|
body: JSON.stringify({ tags: [tag.trim()], mode }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const body = await res.json();
|
||||||
|
this.statusText = `Tagged ${body.matched} events (${body.modified} modified).`;
|
||||||
|
await this.loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
this.statusText = err.message || 'Failed to apply bulk tag.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
displayActor(e) {
|
displayActor(e) {
|
||||||
const app = e.actor?.application || e.actor?.app;
|
const app = e.actor?.application || e.actor?.app;
|
||||||
if (app?.displayName) return app.displayName;
|
if (app?.displayName) return app.displayName;
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class TagsUpdateRequest(BaseModel):
|
|||||||
tags: list[str]
|
tags: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BulkTagsRequest(BaseModel):
|
||||||
|
tags: list[str]
|
||||||
|
mode: str = "append" # "append" or "replace"
|
||||||
|
|
||||||
|
|
||||||
class CommentAddRequest(BaseModel):
|
class CommentAddRequest(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 (
|
from models.api import (
|
||||||
|
BulkTagsRequest,
|
||||||
CommentAddRequest,
|
CommentAddRequest,
|
||||||
FilterOptionsResponse,
|
FilterOptionsResponse,
|
||||||
PaginatedEventResponse,
|
PaginatedEventResponse,
|
||||||
@@ -31,10 +32,9 @@ def _decode_cursor(cursor: str) -> tuple[str, str]:
|
|||||||
raise HTTPException(status_code=400, detail="Invalid cursor") from exc
|
raise HTTPException(status_code=400, detail="Invalid cursor") from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events", response_model=PaginatedEventResponse)
|
def _build_query(
|
||||||
def list_events(
|
|
||||||
service: str | None = None,
|
service: str | None = None,
|
||||||
services: list[str] | None = Query(default=None),
|
services: list[str] | None = None,
|
||||||
actor: str | None = None,
|
actor: str | None = None,
|
||||||
operation: str | None = None,
|
operation: str | None = None,
|
||||||
result: str | None = None,
|
result: str | None = None,
|
||||||
@@ -42,9 +42,9 @@ def list_events(
|
|||||||
end: str | None = None,
|
end: str | None = None,
|
||||||
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),
|
include_tags: list[str] | None = None,
|
||||||
user: dict = Depends(require_auth),
|
exclude_tags: list[str] | None = None,
|
||||||
):
|
) -> dict:
|
||||||
filters = []
|
filters = []
|
||||||
|
|
||||||
if service:
|
if service:
|
||||||
@@ -87,6 +87,10 @@ def list_events(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if include_tags:
|
||||||
|
filters.append({"tags": {"$all": include_tags}})
|
||||||
|
if exclude_tags:
|
||||||
|
filters.append({"tags": {"$not": {"$all": exclude_tags}}})
|
||||||
|
|
||||||
if cursor:
|
if cursor:
|
||||||
try:
|
try:
|
||||||
@@ -102,17 +106,44 @@ def list_events(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
query = {"$and": filters} if filters else {}
|
return {"$and": filters} if filters else {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events", response_model=PaginatedEventResponse)
|
||||||
|
def list_events(
|
||||||
|
service: str | None = None,
|
||||||
|
services: list[str] | None = Query(default=None),
|
||||||
|
actor: str | None = None,
|
||||||
|
operation: str | None = None,
|
||||||
|
result: str | None = None,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
page_size: int = Query(default=50, ge=1, le=500),
|
||||||
|
include_tags: list[str] | None = Query(default=None),
|
||||||
|
exclude_tags: list[str] | None = Query(default=None),
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
query = _build_query(
|
||||||
|
service=service,
|
||||||
|
services=services,
|
||||||
|
actor=actor,
|
||||||
|
operation=operation,
|
||||||
|
result=result,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
search=search,
|
||||||
|
cursor=cursor,
|
||||||
|
include_tags=include_tags,
|
||||||
|
exclude_tags=exclude_tags,
|
||||||
|
)
|
||||||
|
|
||||||
safe_page_size = max(1, min(page_size, 500))
|
safe_page_size = max(1, min(page_size, 500))
|
||||||
|
|
||||||
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
|
||||||
@@ -125,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,
|
||||||
@@ -138,6 +187,53 @@ def list_events(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/bulk-tags")
|
||||||
|
def bulk_tags(
|
||||||
|
body: BulkTagsRequest,
|
||||||
|
service: str | None = None,
|
||||||
|
services: list[str] | None = Query(default=None),
|
||||||
|
actor: str | None = None,
|
||||||
|
operation: str | None = None,
|
||||||
|
result: str | None = None,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
include_tags: list[str] | None = Query(default=None),
|
||||||
|
exclude_tags: list[str] | None = Query(default=None),
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
query = _build_query(
|
||||||
|
service=service,
|
||||||
|
services=services,
|
||||||
|
actor=actor,
|
||||||
|
operation=operation,
|
||||||
|
result=result,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
search=search,
|
||||||
|
include_tags=include_tags,
|
||||||
|
exclude_tags=exclude_tags,
|
||||||
|
)
|
||||||
|
tags = [t.strip() for t in body.tags if t.strip()]
|
||||||
|
if not tags:
|
||||||
|
raise HTTPException(status_code=400, detail="No tags provided")
|
||||||
|
|
||||||
|
update = {"$set": {"tags": tags}} if body.mode == "replace" else {"$addToSet": {"tags": {"$each": tags}}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result_obj = events_collection.update_many(query, update)
|
||||||
|
except Exception as 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"),
|
||||||
|
)
|
||||||
|
return {"matched": result_obj.matched_count, "modified": result_obj.modified_count}
|
||||||
|
|
||||||
|
|
||||||
@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)):
|
||||||
safe_limit = max(1, min(limit, 1000))
|
safe_limit = max(1, min(limit, 1000))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -241,3 +259,76 @@ def test_rules_crud(client):
|
|||||||
res5 = client.get("/api/rules")
|
res5 = client.get("/api/rules")
|
||||||
assert res5.status_code == 200
|
assert res5.status_code == 200
|
||||||
assert len(res5.json()) == 0
|
assert len(res5.json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_events_filter_by_include_tags(client, mock_events_collection):
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-tagged",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Add user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
"tags": ["backup", "auto"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-untagged",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Directory",
|
||||||
|
"operation": "Remove user",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Bob",
|
||||||
|
"raw_text": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = client.get("/api/events?include_tags=backup")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["id"] == "evt-tagged"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_tags_append(client, mock_events_collection):
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-bulk",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Update",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
"tags": ["existing"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "append"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["matched"] == 1
|
||||||
|
doc = mock_events_collection.find_one({"id": "evt-bulk"})
|
||||||
|
assert "backup" in doc["tags"]
|
||||||
|
assert "existing" in doc["tags"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_tags_replace(client, mock_events_collection):
|
||||||
|
mock_events_collection.insert_one(
|
||||||
|
{
|
||||||
|
"id": "evt-bulk2",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"service": "Exchange",
|
||||||
|
"operation": "Update",
|
||||||
|
"result": "success",
|
||||||
|
"actor_display": "Alice",
|
||||||
|
"raw_text": "",
|
||||||
|
"tags": ["old"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "replace"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
doc = mock_events_collection.find_one({"id": "evt-bulk2"})
|
||||||
|
assert doc["tags"] == ["backup"]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
# For local development you can switch back to: build: ./backend
|
# For local development you can switch back to: build: ./backend
|
||||||
image: ghcr.io/cqrenet/aoc-backend:v1.0.1
|
image: git.cqre.net/cqrenet/aoc-backend:v1.0.3
|
||||||
container_name: aoc-backend
|
container_name: aoc-backend
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
Reference in New Issue
Block a user