feat: copy raw event and AI explain in modal
All checks were successful
CI / lint-and-test (push) Successful in 32s
All checks were successful
CI / lint-and-test (push) Successful in 32s
- Add POST /api/events/{id}/explain endpoint that fetches event + related events
and asks the LLM for a plain-language explanation with security context
- Add 'Copy' button to raw event modal (uses navigator.clipboard)
- Add 'Explain' button to raw event modal (only when AI_FEATURES_ENABLED)
- Show explanation in modal with markdown rendering
- Add CSS for modal actions and explanation panel
- Add tests for explain endpoint (404, no LLM key, mocked LLM success)
This commit is contained in:
@@ -456,6 +456,131 @@ def _to_event_ref(e: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_EXPLAIN_SYSTEM_PROMPT = """You are a Microsoft 365 security and compliance expert.
|
||||
An administrator needs help understanding an audit event.
|
||||
|
||||
Your task:
|
||||
1. Explain what happened in plain language (1-2 sentences).
|
||||
2. Identify who performed the action and what was the target.
|
||||
3. Assess whether this is typical admin activity or something to investigate.
|
||||
4. Highlight any security implications (privilege escalation, unusual actor, after-hours activity, etc.).
|
||||
5. Suggest what the admin should do next, if anything.
|
||||
|
||||
Keep the answer under 200 words. Use bullet points for readability.
|
||||
Do not invent facts that are not in the data.
|
||||
"""
|
||||
|
||||
|
||||
async def _explain_event(event: dict, related: list[dict]) -> str:
|
||||
if not LLM_API_KEY:
|
||||
raise RuntimeError("LLM_API_KEY not configured")
|
||||
|
||||
event_text = json.dumps(event, indent=2, default=str)
|
||||
|
||||
related_text = ""
|
||||
if related:
|
||||
related_text = "\n\nRelated events in the last 24 hours:\n"
|
||||
for i, e in enumerate(related[:10], 1):
|
||||
ts = e.get("timestamp", "?")[:16].replace("T", " ")
|
||||
op = e.get("operation", "unknown")
|
||||
actor = e.get("actor_display", "unknown")
|
||||
targets = ", ".join(e.get("target_displays") or []) or "—"
|
||||
result = e.get("result", "—")
|
||||
related_text += f"{i}. {ts} — {op} by {actor} on {targets} ({result})\n"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _EXPLAIN_SYSTEM_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Audit event:\n{event_text}{related_text}\n\nPlease explain this event.",
|
||||
},
|
||||
]
|
||||
|
||||
url = _build_chat_url(LLM_BASE_URL, LLM_API_VERSION)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if "azure" in LLM_BASE_URL.lower() or "cognitiveservices" in LLM_BASE_URL.lower():
|
||||
headers["api-key"] = LLM_API_KEY
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {LLM_API_KEY}"
|
||||
|
||||
payload = {
|
||||
"model": LLM_MODEL,
|
||||
"messages": messages,
|
||||
"max_completion_tokens": 600,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
body = resp.text
|
||||
logger.error("LLM API error", status_code=resp.status_code, url=url, response_body=body)
|
||||
raise RuntimeError(f"LLM API error {resp.status_code}: {body[:500]}")
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/explain")
|
||||
async def explain_event(event_id: str, user: dict = Depends(require_auth)):
|
||||
event = events_collection.find_one({"id": event_id})
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
event.pop("_id", None)
|
||||
|
||||
# Fetch related events for context (same actor or target in last 24h)
|
||||
related = []
|
||||
since = (datetime.now(UTC) - timedelta(hours=24)).isoformat().replace("+00:00", "Z")
|
||||
actor = event.get("actor_upn") or event.get("actor_display")
|
||||
target = event.get("target_displays", [None])[0] if event.get("target_displays") else None
|
||||
|
||||
or_filters = [{"timestamp": {"$gte": since}}, {"id": {"$ne": event_id}}]
|
||||
if actor:
|
||||
or_filters.append(
|
||||
{
|
||||
"$or": [
|
||||
{"actor_upn": actor},
|
||||
{"actor_display": actor},
|
||||
]
|
||||
}
|
||||
)
|
||||
if target:
|
||||
or_filters.append({"target_displays": target})
|
||||
|
||||
if len(or_filters) > 2:
|
||||
try:
|
||||
rel_cursor = events_collection.find({"$and": or_filters}).sort("timestamp", -1).limit(10)
|
||||
related = list(rel_cursor)
|
||||
for r in related:
|
||||
r.pop("_id", None)
|
||||
r.pop("raw", None)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch related events", error=str(exc))
|
||||
|
||||
if not LLM_API_KEY:
|
||||
return {
|
||||
"explanation": "LLM is not configured. Set LLM_API_KEY in your environment to enable event explanations.",
|
||||
"llm_used": False,
|
||||
"llm_error": "LLM_API_KEY not configured",
|
||||
}
|
||||
|
||||
try:
|
||||
explanation = await _explain_event(event, related)
|
||||
return {
|
||||
"explanation": explanation,
|
||||
"llm_used": True,
|
||||
"llm_error": None,
|
||||
"related_count": len(related),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Event explanation failed", error=str(exc))
|
||||
return {
|
||||
"explanation": "Unable to generate an explanation at this time. Please check the raw event details.",
|
||||
"llm_used": False,
|
||||
"llm_error": str(exc),
|
||||
"related_count": len(related),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ask", response_model=AskResponse)
|
||||
async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
||||
question = body.question.strip()
|
||||
|
||||
Reference in New Issue
Block a user