diff --git a/.gitignore b/.gitignore index 3ff7af2..780f28b 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,5 @@ backend/detections/mitre_attack_stub.json frontend/tsconfig.tsbuildinfo # Local AI tool config — not shared to the repo -CLAUDE.md \ No newline at end of file +CLAUDE.md +frontend/next-env.d.ts diff --git a/backend/ai_assistant/assistant.py b/backend/ai_assistant/assistant.py index 9187c22..6ab11ec 100644 --- a/backend/ai_assistant/assistant.py +++ b/backend/ai_assistant/assistant.py @@ -17,6 +17,8 @@ record_mcp_monitor_event, ) from ai_assistant.skills import apply_local_skills +from ai_assistant.monitoring import record_skill_call +from ai_assistant.skill_library import read_skill from tickets.models import EventTicket, TicketWorkLog logger = logging.getLogger(__name__) @@ -50,6 +52,85 @@ def _json_dumps_safe(value: Any) -> str: return str(value) +def _normalize_ai_assistant(assistant: Any, ticket: EventTicket, raw_text: str = '') -> tuple[Dict[str, Any], Dict[str, str]]: + """Return the stable DTO consumed by the UI, regardless of model output quality.""" + candidate = assistant if isinstance(assistant, dict) else {} + raw = str(raw_text or '') + sources: Dict[str, str] = {} + + model_header = candidate.get('header') if isinstance(candidate.get('header'), dict) else {} + score = model_header.get('score') + if not isinstance(score, (int, float)) or score <= 0: + score_match = re.search(r'(?i)risk\s+score\s*[:=]\s*(\d+)', raw or str(getattr(ticket, 'alert_message', '') or '')) + score = int(score_match.group(1)) if score_match else getattr(ticket, 'event_risk_score', None) + sources['header.score'] = 'derived' if score_match else 'ticket' + else: + sources['header.score'] = 'model' + + risk_level = str(model_header.get('risk_level') or getattr(ticket, 'priority', '') or 'medium').lower() + if risk_level not in {'critical', 'high', 'medium', 'low', 'info'}: + risk_level = 'medium' + sources['header.risk_level'] = 'model' if model_header.get('risk_level') else 'ticket' + confidence = model_header.get('ai_confidence') + if confidence in (None, ''): + confidence = None + sources['header.ai_confidence'] = 'unavailable' + else: + sources['header.ai_confidence'] = 'model' + + header = { + **model_header, + 'risk_level': risk_level, + 'ai_confidence': confidence, + 'score': score, + 'summary_title': str(model_header.get('summary_title') or getattr(ticket, 'title', '') or 'SOC incident analysis'), + 'status': str(getattr(ticket, 'status', '') or 'new'), + 'platform': model_header.get('platform') or getattr(ticket, 'event_platform', '') or '', + 'source': model_header.get('source') or getattr(ticket, 'event_sources', '') or '', + } + sources['header.status'] = 'ticket' + + case_summary = candidate.get('case_summary') if isinstance(candidate.get('case_summary'), dict) else {} + summary = case_summary.get('incident_summary') or candidate.get('alert_explanation') or getattr(ticket, 'description', '') or getattr(ticket, 'title', '') or 'No summary available yet.' + sources['summary'] = 'model' if case_summary.get('incident_summary') or candidate.get('alert_explanation') else 'ticket' + + def normalize_tasks(value: Any) -> List[Dict[str, str]]: + if not isinstance(value, list): + return [] + result = [] + for item in value: + if isinstance(item, dict): + title = str(item.get('title') or '').strip() + detail = str(item.get('detail') or '').strip() + else: + title, detail = str(item).strip(), '' + if title or detail: + result.append({'title': title or detail, 'detail': detail}) + return result + + completed_tasks = normalize_tasks(candidate.get('completed_tasks')) + suggested_tasks = normalize_tasks(candidate.get('next_tasks')) + if not completed_tasks: + completed_tasks = [{'title': 'AI incident triage', 'detail': 'Generated an incident summary and extracted key indicators.'}] + sources['completed_tasks'] = 'system' + else: + sources['completed_tasks'] = 'model' + if suggested_tasks: + sources['suggested_tasks'] = 'model_or_extracted' + else: + sources['suggested_tasks'] = 'unavailable' + + normalized = { + **candidate, + 'header': header, + 'alert_explanation': str(summary), + 'risk_level_recommendation': candidate.get('risk_level_recommendation') if isinstance(candidate.get('risk_level_recommendation'), dict) else {'level': risk_level, 'rationale': 'Derived from available ticket and AI context.'}, + 'completed_tasks': completed_tasks, + 'next_tasks': suggested_tasks, + 'case_summary': {**case_summary, 'incident_summary': str(summary)}, + } + return normalized, sources + def _extract_iocs(text: str) -> Dict[str, List[str]]: if not text: return {"ips": [], "hashes": [], "users": [], "commands": []} @@ -201,6 +282,148 @@ def _build_prompt( ) +def _parse_structured_json(text: str) -> Dict[str, Any]: + """Parse the small JSON object requested for one assistant section.""" + value = str(text or '').strip() + if value.startswith('```'): + value = re.sub(r'^```(?:json)?\s*|\s*```$', '', value, flags=re.IGNORECASE | re.DOTALL).strip() + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def _parse_section_markdown(section: str, text: str) -> Dict[str, Any]: + """Extract only the requested section when a provider ignores JSON-only instructions.""" + body = str(text or '').strip() + + def field(name: str) -> str: + match = re.search(rf'(?im)^\s*\**{re.escape(name)}\**\s*:\s*(.+?)\s*$', body) + return re.sub(r'[*_`]', '', match.group(1)).strip() if match else '' + + def clean(value: str) -> str: + return re.sub(r'\s+', ' ', re.sub(r'[*_`]', '', value)).strip() + + def heading(name: str) -> str: + match = re.search( + rf'(?is)(?:^|\n)###\s+\**{re.escape(name)}\**\s*\n(.*?)(?=\n###\s|\Z)', + body, + ) + return (match.group(1) if match else '').strip() + + if section == 'header': + score_match = re.search(r'(?i)\brisk\s+score\s*:\s*(\d+)', body) + confidence_match = re.search(r'(?i)(?:\bconfidence\s*:\s*(\d{1,3})\s*%|(\d{1,3})\s*%\s*confidence)', body) + score = int(score_match.group(1)) if score_match else 0 + priority = (field('Priority') or 'medium').lower() + level = 'critical' if score >= 90 else 'high' if score >= 70 else priority + if level not in {'critical', 'high', 'medium', 'low', 'info'}: + level = 'medium' + return {'header': { + 'risk_level': level, + 'ai_confidence': f'{confidence_match.group(1) or confidence_match.group(2)}%' if confidence_match else '70%', + 'score': score, + 'summary_title': field('Title') or 'SOC incident analysis', + 'status': field('Status') or 'new', + 'platform': '', + 'source': '', + }} + + if section == 'summary': + summary = clean(heading('Summary')) or clean(body[:1200]) + return { + 'alert_explanation': summary, + 'risk_level_recommendation': { + 'level': 'high' if re.search(r'(?i)risk score\s*:\s*[7-9]\d|risk score\s*:\s*100', body) else 'medium', + 'rationale': 'Derived from the AI incident analysis.', + }, + 'case_summary': { + 'incident_summary': summary, + 'impact_assessment': 'Pending validation.', + 'root_cause': 'Under investigation.', + 'remediation_recommendations': [], + }, + } + + if section == 'tasks': + action_section = heading('Recommended Actions') or heading('Immediate Actions') + actions = [] + for line in action_section.splitlines(): + match = re.match(r'^\s*\d+\.\s+(.+?)\s*$', line) + if match: + title = clean(match.group(1)) + if title: + actions.append({'title': title, 'detail': 'Recommended follow-up action from the AI analysis.'}) + return { + 'completed_tasks': [ + {'title': 'AI incident triage', 'detail': 'Generated an incident summary and extracted key indicators.'} + ], + 'next_tasks': actions, + } + return {} + +def _generate_structured_sections(prompt: str, overrides: Dict[str, Any] | None = None) -> tuple[Dict[str, Any], List[str]]: + """Request each UI section independently so one long answer cannot erase others.""" + specs = [ + ( + 'header', + 'Return STRICT JSON only with exactly this shape: ' + '{"header":{"risk_level":"critical|high|medium|low|info",' + '"ai_confidence":"string","score":0,"summary_title":"string",' + '"status":"string","platform":"string","source":"string"}}', + ), + ( + 'summary', + 'Return STRICT JSON only with exactly this shape: ' + '{"alert_explanation":"string",' + '"risk_level_recommendation":{"level":"string","rationale":"string"},' + '"case_summary":{"incident_summary":"string","impact_assessment":"string",' + '"root_cause":"string","remediation_recommendations":["string"]}}', + ), + ( + 'tasks', + 'Return STRICT JSON only with exactly this shape: ' + '{"completed_tasks":[{"title":"string","detail":"string"}],' + '"next_tasks":[{"title":"string","detail":"string"}]}', + ), + ] + assistant: Dict[str, Any] = {} + raw_failures: List[str] = [] + for name, schema in specs: + section_prompt = ( + 'You are a SOC analyst. Generate only the requested section. ' + 'Do not use Markdown, commentary, or code fences.\n' + f'Requested section: {name}\n{schema}\n' + 'Use the following ticket context:\n' + f'{prompt}' + ) + response = _call_openai(section_prompt, overrides=overrides) + response_text = _extract_response_text(response) + parsed = _parse_structured_json(response_text) + fallback = _parse_section_markdown(name, response_text) + if parsed and fallback: + if name == 'header': + parsed_header = parsed.get('header') if isinstance(parsed.get('header'), dict) else {} + fallback_header = fallback.get('header') if isinstance(fallback.get('header'), dict) else {} + if not parsed_header.get('score') and fallback_header.get('score'): + parsed_header['score'] = fallback_header['score'] + if not parsed_header.get('ai_confidence') or parsed_header.get('ai_confidence') == '70%': + parsed_header['ai_confidence'] = fallback_header.get('ai_confidence', parsed_header.get('ai_confidence')) + parsed['header'] = {**fallback_header, **parsed_header} + if name == 'tasks': + if not isinstance(parsed.get('completed_tasks'), list) or not parsed.get('completed_tasks'): + parsed['completed_tasks'] = fallback.get('completed_tasks', []) + if not isinstance(parsed.get('next_tasks'), list) or not parsed.get('next_tasks'): + parsed['next_tasks'] = fallback.get('next_tasks', []) + elif not parsed: + parsed = fallback + if parsed: + assistant.update(parsed) + else: + raw_failures.append(f'{name}: {response_text[:4000]}') + return assistant, raw_failures + def _parse_responses_stream(res: requests.Response) -> Dict[str, Any]: text_buffer: List[str] = [] last_response: Dict[str, Any] | None = None @@ -398,6 +621,59 @@ def _decide_mcp_tool_and_args_by_ai( return None +def _select_skills_by_ai(ticket, alert_json, enabled_skills, overrides=None): + """Use the model to select relevant enabled skills from alert evidence.""" + if not enabled_skills: + return [] + allowed = [] + lookup = {} + for item in enabled_skills: + if isinstance(item, dict): + name = str(item.get("name") or item.get("route") or "").strip() + description = str(item.get("description") or item.get("summary") or "").strip() + else: + name, description = str(item).strip(), "" + if name: + allowed.append({"name": name, "description": description[:500]}) + lookup[name.lower()] = item + if isinstance(item, dict) and item.get("route"): + lookup[str(item["route"]).strip().lower()] = item + if not allowed: + return [] + evidence = { + "title": str(getattr(ticket, "title", "") or ""), + "description": str(getattr(ticket, "description", "") or ""), + "raw_message": getattr(ticket, "alert_message", "") or "", + "alert_json": alert_json, + } + selector_prompt = ( + 'You are a SOC skill router. Select only enabled skills materially relevant to the alert. ' + 'Return JSON only: {"skills":[{"name":"exact enabled name","reason":"short"}]}. ' + 'Return {"skills":[]} when none apply. Never invent names.\n' + + 'Enabled skills:\n' + json.dumps(allowed, ensure_ascii=False) + + '\nAlert evidence:\n' + json.dumps(evidence, ensure_ascii=False, default=str) + ) + try: + response = _call_openai(selector_prompt, overrides=overrides) + selected = _parse_structured_json(_extract_response_text(response)) + values = selected.get("skills") if isinstance(selected, dict) else [] + if isinstance(values, list): + result = [] + for value in values: + name = str(value.get("name") if isinstance(value, dict) else value).strip().lower() + if name in lookup and lookup[name] not in result: + result.append(lookup[name]) + return result + except Exception: + logger.exception("AI skill selection failed; using text fallback") + text = json.dumps(evidence, ensure_ascii=False, default=str).lower() + result = [] + for item in allowed: + tokens = [x for x in re.split(r"[-_\s]+", item["name"].lower()) if len(x) > 3] + if tokens and sum(x in text for x in tokens) >= max(1, len(tokens) // 2): + result.append(lookup[item["name"].lower()]) + return result + def generate_ai_assistant_output( ticket: EventTicket, alert_json: Any, @@ -549,15 +825,34 @@ def generate_ai_assistant_output( mcp_context=mcp_context, user_prompt=user_prompt, ) - response = _call_openai(prompt, overrides=overrides) - text = _extract_response_text(response) - - parsed = None - if text: - try: - parsed = json.loads(text) - except Exception: - parsed = None + enabled_skills = (overrides or {}).get("skills") if isinstance((overrides or {}).get("skills"), list) else [] + selected_skills = _select_skills_by_ai( + ticket=ticket, + alert_json=alert_json, + enabled_skills=enabled_skills, + overrides=overrides, + ) + skill_instructions = [] + selected_skill_names = [] + for item in selected_skills: + skill_name = str(item.get("name") or item.get("route") or "").strip() if isinstance(item, dict) else str(item).strip() + skill_doc = read_skill(skill_name) + if skill_doc and skill_doc.content: + selected_skill_names.append(skill_name) + skill_instructions.append(f"SKILL: {skill_doc.name}\n{skill_doc.content[:12000]}") + record_skill_call(skill_name, True) + elif skill_name: + record_skill_call(skill_name, False) + if skill_instructions: + prompt += "\n\nEnabled skill instructions (follow only when supported by ticket evidence):\n" + "\n\n".join(skill_instructions) + raw_failures: List[str] = [] + if user_prompt: + response = _call_openai(prompt, overrides=overrides) + text = _extract_response_text(response) + parsed = _parse_structured_json(text) + else: + parsed, raw_failures = _generate_structured_sections(prompt, overrides=overrides) + text = '\n\n'.join(raw_failures) parsed = apply_local_skills( assistant=parsed, @@ -565,6 +860,7 @@ def generate_ai_assistant_output( timeline=timeline, skills=(overrides or {}).get("skills"), ) + normalized_assistant, field_sources = _normalize_ai_assistant(parsed, ticket=ticket, raw_text=text) return { "model": (overrides or {}).get("model") or _get_setting("OPENAI_MODEL", "gpt-5.1-codex"), @@ -583,6 +879,9 @@ def generate_ai_assistant_output( "cmdb_assets": cmdb_assets, "observables": observables_payload, }, - "assistant": parsed, - "assistant_raw": text if parsed is None else None, + "assistant": normalized_assistant, + "skills_used": selected_skill_names, + "field_sources": field_sources, + "raw_response": text if text else None, + "assistant_raw": text if text else None, } diff --git a/backend/ai_assistant/chat_agent.py b/backend/ai_assistant/chat_agent.py index f6365fb..c97a4cb 100644 --- a/backend/ai_assistant/chat_agent.py +++ b/backend/ai_assistant/chat_agent.py @@ -1,4 +1,4 @@ -import json +import json import logging import re from dataclasses import dataclass @@ -317,6 +317,73 @@ def _parse_tool_arguments(raw: Any) -> Dict[str, Any]: return {} +def _chat_content(response: Dict[str, Any]) -> str: + choices = response.get("choices") if isinstance(response, dict) else [] + if not isinstance(choices, list) or not choices: + return "" + message = choices[0].get("message") or {} + return str(message.get("content") or "").strip() + + +def _preload_chat_skills(user_input: str, recommended_skills: List[str], overrides: Optional[Dict[str, Any]] = None) -> Tuple[List[str], List[str], List[Dict[str, Any]]]: + if not recommended_skills: + return [], [], [] + names = [str(name).strip() for name in recommended_skills if str(name).strip()] + list_exec_id = start_mcp_execution("list_skills", {}, source="internal") + try: + catalog = list_skills() + list_success = True + list_content = "\n".join(catalog) + except Exception as exc: + catalog = [] + list_success = False + list_content = f"Skill listing failed: {exc}" + finish_mcp_execution(list_exec_id, list_success, error="" if list_success else list_content) + update_mcp_stats("list_skills", list_success) + available = [name for name in names if name in catalog] if catalog else names + trace = [ + {"type": "model_call", "iteration": 0, "purpose": "skill_selection"}, + {"type": "tool_calls_detected", "iteration": 0, "count": 1}, + {"type": "tool_call", "iteration": 0, "tool": "list_skills", "source": "internal", "arguments": {}, "automatic": True, "execution_id": list_exec_id}, + {"type": "tool_result", "iteration": 0, "tool": "list_skills", "source": "internal", "success": list_success, "content": list_content, "automatic": True, "execution_id": list_exec_id}, + ] + router_prompt = ('You are a SOC skill router. Select only relevant skills from this list based on the user request. ' + 'Return JSON only: {"skills":["exact skill name"]}. Return {"skills":[]} if none apply. Never invent names.\n' + + 'Available skills: ' + json.dumps(available, ensure_ascii=False) + '\nUser request: ' + user_input) + selected_names = [] + try: + response = _call_openai_chat([{"role":"system","content":router_prompt},{"role":"user","content":user_input}], [], overrides=overrides) + text = _chat_content(response) + match = re.search(r'\{.*\}', text, flags=re.DOTALL) + payload = json.loads(match.group(0) if match else text) + values = payload.get("skills") if isinstance(payload, dict) else [] + if isinstance(values, list): + selected_names = [str(value).strip() for value in values if str(value).strip() in available] + except Exception: + logger.exception("Chat skill preselection failed") + instructions = [] + for name in selected_names: + read_exec_id = start_mcp_execution("read_skill", {"skill_name": name}, source="internal") + doc = read_skill(name) + if doc and doc.content: + finish_mcp_execution(read_exec_id, True) + update_mcp_stats("read_skill", True) + record_skill_call(name, True) + instructions.append(f"SKILL: {doc.name}\n{doc.content[:12000]}") + trace.extend([ + {"type": "tool_call", "iteration": 0, "tool": "read_skill", "source": "internal", "arguments": {"skill_name": name}, "automatic": True, "execution_id": read_exec_id}, + {"type": "tool_result", "iteration": 0, "tool": "read_skill", "source": "internal", "success": True, "content": doc.content[:2000], "automatic": True, "execution_id": read_exec_id}, + ]) + else: + finish_mcp_execution(read_exec_id, False, error=f"Skill not found: {name}") + update_mcp_stats("read_skill", False) + record_skill_call(name, False) + trace.extend([ + {"type": "tool_call", "iteration": 0, "tool": "read_skill", "source": "internal", "arguments": {"skill_name": name}, "automatic": True, "execution_id": read_exec_id}, + {"type": "tool_result", "iteration": 0, "tool": "read_skill", "source": "internal", "success": False, "content": f"Skill not found: {name}", "automatic": True, "execution_id": read_exec_id}, + ]) + return selected_names, instructions, trace + def run_chat_agent( user_input: str, history_messages: Optional[List[Dict[str, Any]]] = None, @@ -335,9 +402,12 @@ def run_chat_agent( "When responding to the user, include a short analysis summary in a second paragraph starting with " "\"AI thinking:\" (max 80 words)." ) + preloaded_names, preloaded_instructions, preload_trace = _preload_chat_skills(user_input, recommended_skills, overrides=overrides) if recommended_skills: skills_hint = ", ".join([f"`{s}`" for s in recommended_skills]) - system_prompt += f"\nRecommended skills: {skills_hint}. Use read_skill to load details when needed." + system_prompt += f"\nAvailable skills: {skills_hint}. You may still call read_skill for additional details." + if preloaded_instructions: + system_prompt += "\nSelected skill instructions (apply only when supported by evidence):\n" + "\n\n".join(preloaded_instructions) messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] for msg in history_messages: @@ -350,6 +420,11 @@ def run_chat_agent( internal_tools, internal_handlers = _internal_tools() external_tools, external_mapping = _external_tools(overrides=overrides) + if preloaded_names: + internal_tools = [ + tool for tool in internal_tools + if ((tool.get("function") or {}).get("name") != "read_skill") + ] tools = internal_tools + external_tools max_iter_value = None @@ -362,7 +437,7 @@ def run_chat_agent( except Exception: max_iterations = 6 - trace: List[Dict[str, Any]] = [] + trace: List[Dict[str, Any]] = list(preload_trace) iteration = 0 for _ in range(max_iterations): iteration += 1 @@ -436,6 +511,8 @@ def run_chat_agent( parts = content.split("AI thinking:", 1) content = parts[0].strip() summary = parts[1].strip() + if not content.strip(): + content = summary or "AI analysis completed, but no user-facing response was returned." trace.append({"type": "assistant_response", "iteration": iteration, "content": content}) if summary: trace.append({"type": "analysis_summary", "iteration": iteration, "content": summary}) diff --git a/backend/ai_assistant/skills.py b/backend/ai_assistant/skills.py index 782cf4e..547d9a4 100644 --- a/backend/ai_assistant/skills.py +++ b/backend/ai_assistant/skills.py @@ -47,13 +47,6 @@ def _apply_soc_ticket_triage( if not isinstance(assistant.get("next_tasks"), list): assistant["next_tasks"] = [] - if not assistant["next_tasks"]: - assistant["next_tasks"] = [ - {"title": "Validate affected scope", "detail": "Confirm impacted hosts/users from ticket context."}, - {"title": "Correlate recent logs", "detail": "Review recent timeline and related alerts for recurrence."}, - {"title": "Prepare containment plan", "detail": "Draft immediate containment actions for approval."}, - ] - if not isinstance(assistant.get("alert_explanation"), str): assistant["alert_explanation"] = "Initial triage completed using ticket context, timeline, and similar cases." @@ -86,26 +79,9 @@ def apply_local_skills( if not routes: return assistant - applied_routes: set[str] = set() if "ticket_triage" in routes or "soc-ticket-triage" in routes: _apply_soc_ticket_triage(assistant, ticket=ticket, timeline=timeline) - applied_routes.add("ticket_triage") - applied_routes.add("soc-ticket-triage") if "incident_summary" in routes or "incident-summary" in routes: _apply_incident_summary(assistant, ticket=ticket, timeline=timeline) - applied_routes.add("incident_summary") - applied_routes.add("incident-summary") - - if isinstance(skills, list): - for s in skills: - if not isinstance(s, dict): - continue - if s.get("enabled") is False: - continue - route = str(s.get("route") or s.get("name") or "").strip() - name = str(s.get("name") or route).strip() - if not name: - continue - if route in applied_routes: - record_skill_call(name, True) + return assistant diff --git a/backend/ai_assistant/views.py b/backend/ai_assistant/views.py index 9d23e7b..a662a06 100644 --- a/backend/ai_assistant/views.py +++ b/backend/ai_assistant/views.py @@ -253,9 +253,12 @@ def ai_chat(request): except Exception: pass + chat_input = str(data.get("message") or "") + if ticket: + chat_input += "\n\nCurrent case raw alert JSON:\n" + str(ticket.alert_message or "") try: result = run_chat_agent( - user_input=data.get("message"), + user_input=chat_input, history_messages=data.get("messages") or [], overrides=overrides, recommended_skills=recommended_skills, diff --git a/backend/skills/cloud-iam-anomaly/SKILL.md b/backend/skills/cloud-iam-anomaly/SKILL.md new file mode 100644 index 0000000..981b7df --- /dev/null +++ b/backend/skills/cloud-iam-anomaly/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Cloud IAM anomaly triage" +description: "Analyze suspicious cloud identity and access events, including role changes, new keys, unusual regions, and privilege escalation. Recommend evidence-preserving response actions." +--- +# Cloud IAM anomaly triage + +Analyze suspicious cloud identity and access events, including role changes, new keys, unusual regions, and privilege escalation. Recommend evidence-preserving response actions. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/data-exfiltration-detection/SKILL.md b/backend/skills/data-exfiltration-detection/SKILL.md new file mode 100644 index 0000000..d2ed834 --- /dev/null +++ b/backend/skills/data-exfiltration-detection/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Data exfiltration detection" +description: "Assess possible data movement using destinations, protocols, volume, files, and user context. Identify evidence gaps and recommend low-risk validation and containment actions." +--- +# Data exfiltration detection + +Assess possible data movement using destinations, protocols, volume, files, and user context. Identify evidence gaps and recommend low-risk validation and containment actions. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/dns-c2-investigation/SKILL.md b/backend/skills/dns-c2-investigation/SKILL.md new file mode 100644 index 0000000..4d89746 --- /dev/null +++ b/backend/skills/dns-c2-investigation/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "DNS command and control investigation" +description: "Analyze suspicious DNS behavior such as high entropy, beaconing, rare domains, unusual record types, and query volume. Distinguish indicators from confirmed C2 and recommend safe validation." +--- +# DNS command and control investigation + +Analyze suspicious DNS behavior such as high entropy, beaconing, rare domains, unusual record types, and query volume. Distinguish indicators from confirmed C2 and recommend safe validation. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/incident-containment-planning/SKILL.md b/backend/skills/incident-containment-planning/SKILL.md new file mode 100644 index 0000000..038fca8 --- /dev/null +++ b/backend/skills/incident-containment-planning/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Incident containment planning" +description: "Create a prioritized, reversible containment plan based only on observed evidence. Include owner, verification, rollback, and approval requirements; never perform destructive actions automatically." +--- +# Incident containment planning + +Create a prioritized, reversible containment plan based only on observed evidence. Include owner, verification, rollback, and approval requirements; never perform destructive actions automatically. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/insider-threat-assessment/SKILL.md b/backend/skills/insider-threat-assessment/SKILL.md new file mode 100644 index 0000000..680c1cf --- /dev/null +++ b/backend/skills/insider-threat-assessment/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Insider threat assessment" +description: "Assess possible insider risk from unusual access, downloads, privilege use, and policy violations. Maintain neutral language, minimize personal data, and recommend auditable investigative steps." +--- +# Insider threat assessment + +Assess possible insider risk from unusual access, downloads, privilege use, and policy violations. Maintain neutral language, minimize personal data, and recommend auditable investigative steps. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/malware-execution-analysis/SKILL.md b/backend/skills/malware-execution-analysis/SKILL.md new file mode 100644 index 0000000..42c3122 --- /dev/null +++ b/backend/skills/malware-execution-analysis/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Malware execution analysis" +description: "Analyze process execution, downloaded files, hashes, parent-child chains, persistence, and host impact. Separate observed facts from hypotheses and propose safe collection and containment steps." +--- +# Malware execution analysis + +Analyze process execution, downloaded files, hashes, parent-child chains, persistence, and host impact. Separate observed facts from hypotheses and propose safe collection and containment steps. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/phishing-triage/SKILL.md b/backend/skills/phishing-triage/SKILL.md new file mode 100644 index 0000000..9c6b98f --- /dev/null +++ b/backend/skills/phishing-triage/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Phishing email triage" +description: "Analyze suspicious email indicators, sender authentication, URLs, attachments, user impact, and recommended containment. Never claim a link or attachment is malicious without evidence. Return concise findings and follow-up tasks." +--- +# Phishing email triage + +Analyze suspicious email indicators, sender authentication, URLs, attachments, user impact, and recommended containment. Never claim a link or attachment is malicious without evidence. Return concise findings and follow-up tasks. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/ransomware-response/SKILL.md b/backend/skills/ransomware-response/SKILL.md new file mode 100644 index 0000000..4710152 --- /dev/null +++ b/backend/skills/ransomware-response/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Ransomware response" +description: "Triage ransomware indicators such as mass file changes, encryption processes, ransom notes, and lateral movement. Prioritize isolation, evidence preservation, recovery coordination, and safe next tasks." +--- +# Ransomware response + +Triage ransomware indicators such as mass file changes, encryption processes, ransom notes, and lateral movement. Prioritize isolation, evidence preservation, recovery coordination, and safe next tasks. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/suspicious-login-investigation/SKILL.md b/backend/skills/suspicious-login-investigation/SKILL.md new file mode 100644 index 0000000..985fc51 --- /dev/null +++ b/backend/skills/suspicious-login-investigation/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Suspicious login investigation" +description: "Investigate unusual authentication activity using source IP, account, time, geolocation, MFA, and privilege context. Recommend validation steps and avoid changing ticket status." +--- +# Suspicious login investigation + +Investigate unusual authentication activity using source IP, account, time, geolocation, MFA, and privilege context. Recommend validation steps and avoid changing ticket status. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/skills/vulnerability-prioritization/SKILL.md b/backend/skills/vulnerability-prioritization/SKILL.md new file mode 100644 index 0000000..e143ece --- /dev/null +++ b/backend/skills/vulnerability-prioritization/SKILL.md @@ -0,0 +1,13 @@ +--- +name: "Vulnerability prioritization" +description: "Prioritize vulnerabilities using asset criticality, exploitability, exposure, known exploitation, compensating controls, and business impact. Do not invent CVEs or affected versions." +--- +# Vulnerability prioritization + +Prioritize vulnerabilities using asset criticality, exploitability, exposure, known exploitation, compensating controls, and business impact. Do not invent CVEs or affected versions. + +Output requirements: +- Cite observed evidence from the ticket. +- Mark unknown values as unavailable. +- Return structured JSON fields when requested. +- Do not change ticket status or execute commands. \ No newline at end of file diff --git a/backend/tickets/views.py b/backend/tickets/views.py index 91f5303..a7a2879 100644 --- a/backend/tickets/views.py +++ b/backend/tickets/views.py @@ -668,6 +668,7 @@ def ai_assistant(self, request, ticket_number=None): ) try: assistant = result.get("assistant") if isinstance(result, dict) else None + assistant_raw = result.get("assistant_raw") if isinstance(result, dict) else None if assistant: completed = assistant.get("completed_tasks") if isinstance(assistant, dict) else None next_tasks = assistant.get("next_tasks") if isinstance(assistant, dict) else None @@ -715,6 +716,12 @@ def ai_assistant(self, request, ticket_number=None): log_entry="\n".join([l for l in log_lines if l]), created_by=request.user, ) + elif isinstance(assistant_raw, str) and assistant_raw.strip(): + TicketWorkLog.objects.create( + ticket=ticket, + log_entry=f"AI Raw Response:\n{assistant_raw.strip()}", + created_by=request.user, + ) except Exception: # Do not block API response on logging failure pass diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts deleted file mode 100644 index 9edff1c..0000000 --- a/frontend/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js index 8507825..ccc9a76 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -12,6 +12,7 @@ try { /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + allowedDevOrigins: ['192.168.31.66'], skipTrailingSlashRedirect: true, // Keep `next start` compatible in local/dev environments. // Set NEXT_OUTPUT_MODE=standalone only when you explicitly run standalone server. diff --git a/frontend/src/modules/aiAssistant/AiAssistantSettings.tsx b/frontend/src/modules/aiAssistant/AiAssistantSettings.tsx index 38ddb8f..45182dd 100644 --- a/frontend/src/modules/aiAssistant/AiAssistantSettings.tsx +++ b/frontend/src/modules/aiAssistant/AiAssistantSettings.tsx @@ -959,20 +959,6 @@ export default function AiAssistantSettings() { return Not Added; }, }, - { - title: 'Action', - key: 'action', - width: 140, - render: (_: any, row: any) => { - const cfg = skillConfigMap.get(row?.name); - const label = cfg ? (cfg.enabled ? 'Enabled' : 'Enable') : 'Add'; - return ( - - ); - }, - }, ]} /> diff --git a/frontend/src/modules/tickets/TicketsPage.tsx b/frontend/src/modules/tickets/TicketsPage.tsx index c2f989a..f757324 100644 --- a/frontend/src/modules/tickets/TicketsPage.tsx +++ b/frontend/src/modules/tickets/TicketsPage.tsx @@ -81,6 +81,11 @@ const TicketsPage: React.FC = ({ initialTicketNumber, onNavigate }) => { } }; + const refreshSlaWorkLogs = async (ticketNumber: string) => { + const timelineResp = await fetchSlaTicketTimeline(ticketNumber); + setSlaWorkLogs(Array.isArray(timelineResp?.work_logs) ? timelineResp.work_logs : []); + }; + const submitSlaStatus = async (statusOverride?: string) => { if (!slaDetail?.ticket_number) return; const nextStatus = statusOverride || slaStatus; @@ -180,6 +185,7 @@ const TicketsPage: React.FC = ({ initialTicketNumber, onNavigate }) => { onAddWorkLog={addWorkLog} onUploadWorkLogImage={uploadWorkLogImage} onRefresh={() => openSlaDetail(slaDetail.ticket_number)} + onRefreshWorkLogs={() => refreshSlaWorkLogs(slaDetail.ticket_number)} loading={slaLoading} /> )} diff --git a/frontend/src/modules/tickets/components/SlaTicketDetailView.tsx b/frontend/src/modules/tickets/components/SlaTicketDetailView.tsx index 54baa08..2f78763 100644 --- a/frontend/src/modules/tickets/components/SlaTicketDetailView.tsx +++ b/frontend/src/modules/tickets/components/SlaTicketDetailView.tsx @@ -17,6 +17,7 @@ type Props = { onAddWorkLog?: (logEntry: string) => void | Promise; onUploadWorkLogImage?: (file: File) => Promise; onRefresh: () => void; + onRefreshWorkLogs?: () => void | Promise; loading?: boolean; }; @@ -400,7 +401,7 @@ function WarRoomView({ } export default function SlaTicketDetailView(props: Props) { - const { ticket, attachments, workLogs, statusValue, notesValue, onStatusChange, onNotesChange, onSubmitStatus, onRefresh, loading } = props; + const { ticket, attachments, workLogs, statusValue, notesValue, onStatusChange, onNotesChange, onSubmitStatus, onRefresh, onRefreshWorkLogs, loading } = props; const [showEmpty, setShowEmpty] = useState(false); const [activeTab, setActiveTab] = useState('incident'); const [incidentTab, setIncidentTab] = useState('timeline'); @@ -409,6 +410,7 @@ export default function SlaTicketDetailView(props: Props) { const [handleLogs, setHandleLogs] = useState([]); const [aiLoading, setAiLoading] = useState(false); const [aiResult, setAiResult] = useState(null); + const [aiRawResponse, setAiRawResponse] = useState(''); const [aiError, setAiError] = useState(null); const [chatOpen, setChatOpen] = useState(false); const [chatSize, setChatSize] = useState({ width: 320, height: 440, right: 12, top: 88 }); @@ -477,6 +479,9 @@ export default function SlaTicketDetailView(props: Props) { }, [ticket.ticket_number, ticket.labels]); useEffect(() => { + setAiResult(null); + setAiRawResponse(''); + setAiError(null); setChatOpen(false); setChatMessages([]); setChatInput(''); @@ -643,7 +648,19 @@ export default function SlaTicketDetailView(props: Props) { related_logs: (workLogs || []).slice(0, 5).map((w) => w.log_entry), }; const res = await generateSlaTicketAiAssistant(ticket.ticket_number, payload); - setAiResult(res?.assistant || res); + const assistant = res?.assistant; + // Keep the structured cards intact when the model returns plain text. + // The plain response is rendered separately below as a raw/collapsible view. + if (assistant && typeof assistant === 'object' && !Array.isArray(assistant)) { + setAiResult(assistant); + } + const rawResponse = typeof res?.raw_response === 'string' ? res.raw_response : res?.assistant_raw; + setAiRawResponse(typeof rawResponse === 'string' ? fixMojibake(rawResponse) : ''); + try { + await onRefreshWorkLogs?.(); + } catch { + // Keep the successful AI result visible even if the Worklog refresh fails. + } message.success('AI assistant updated'); } catch (err: any) { const apiError = err?.response?.data?.error || err?.response?.data?.detail; @@ -986,12 +1003,25 @@ export default function SlaTicketDetailView(props: Props) { const incidentTitle = ticket.title || 'Incident'; const ownerName = ticket.current_assign_owner || ticket.assigned_user_username || 'Unassigned'; const riskScore = ticket.event_risk_score ?? '-'; - const summaryText = ticket.description - ? ticket.description - : [ - ticket.event_category ? `Category: ${ticket.event_category}` : '', - ticket.event_result ? `Result: ${ticket.event_result}` : '', - ].filter(Boolean).join(' | ') || 'No summary available yet.'; + const latestAiRawWorkLog = (workLogs || []) + .slice() + .reverse() + .map((w) => String(w?.log_entry || '')) + .find((entry) => entry.startsWith('AI Raw Response:\n')); + const persistedAiRaw = latestAiRawWorkLog + ? latestAiRawWorkLog.slice('AI Raw Response:\n'.length).trim() + : ''; + const structuredSummary = [ + aiResult?.case_summary?.incident_summary, + aiResult?.alert_explanation, + ticket.description, + [ + ticket.event_category ? `Category: ${ticket.event_category}` : '', + ticket.event_result ? `Result: ${ticket.event_result}` : '', + ].filter(Boolean).join(' | '), + ].find((value) => typeof value === 'string' && value.trim()); + const summaryText = String(structuredSummary || 'No summary available yet.'); + const rawSummaryText = aiRawResponse.trim() || persistedAiRaw; const timelineItems = useMemo(() => { const base = (workLogs || []).slice(); @@ -1388,7 +1418,7 @@ export default function SlaTicketDetailView(props: Props) {
Status: - {renderStatusTag(aiHeader?.status || ticket.status)} + {renderStatusTag(ticket.status)}
Owner: {ownerName}
Platform: {aiHeader?.platform || ticket.event_platform || '-'}
@@ -1397,7 +1427,17 @@ export default function SlaTicketDetailView(props: Props) { -
{summaryText}
+
+ {summaryText} +
+ {rawSummaryText ? ( +
+ Raw AI response +
+ {rawSummaryText} +
+
+ ) : null}
@@ -1666,7 +1706,7 @@ export default function SlaTicketDetailView(props: Props) { ); }) ) : ( - No suggested tasks + No AI-suggested tasks )}