feat: multimodal chat, region-specific MCP scanning, and resilience improvements
Add image/PDF/text file upload in chat via new /api/chat/upload endpoint with OCI GenAI ImageContent and DocumentContent support. Frontend shows attach button, file preview, and handles multipart upload seamlessly. Add optional regions parameter to all CIS MCP scan tools for targeting specific OCI regions instead of full tenancy. Session cache scoped by config+regions for concurrent multi-region scans. Add orphaned report auto-detection on progress poll (marks as failed if process not found). Increase nginx proxy timeout to 15 minutes. Improve $api error handling for non-JSON responses (504 HTML pages). Update README to v1.9.
This commit is contained in:
126
backend/app.py
126
backend/app.py
@@ -1013,7 +1013,8 @@ def _compact_history(session_id: str, user_id: str, genai_cfg: dict, history: li
|
||||
|
||||
def _call_genai(gc: dict, message: str, history: list = None, tools: list = None,
|
||||
tool_results_cohere: list = None,
|
||||
extra_messages: list = None) -> tuple:
|
||||
extra_messages: list = None,
|
||||
attachments: list = None) -> tuple:
|
||||
"""
|
||||
Call OCI Generative AI with optional tool use support.
|
||||
Returns (text, tool_calls, tool_calls_raw) tuple.
|
||||
@@ -1127,12 +1128,29 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
|
||||
msg.content = [content]
|
||||
messages.append(msg)
|
||||
|
||||
# Current user message
|
||||
content = oci.generative_ai_inference.models.TextContent()
|
||||
content.text = message
|
||||
# Current user message (with optional multimodal attachments)
|
||||
content_parts = []
|
||||
if attachments:
|
||||
for att in attachments:
|
||||
if att["type"] == "image":
|
||||
img_url = oci.generative_ai_inference.models.ImageUrl()
|
||||
img_url.url = att["data_uri"]
|
||||
img_url.detail = "AUTO"
|
||||
img_content = oci.generative_ai_inference.models.ImageContent()
|
||||
img_content.image_url = img_url
|
||||
content_parts.append(img_content)
|
||||
elif att["type"] == "document":
|
||||
doc_url = oci.generative_ai_inference.models.DocumentUrl()
|
||||
doc_url.url = att["data_uri"]
|
||||
doc_content = oci.generative_ai_inference.models.DocumentContent()
|
||||
doc_content.document_url = doc_url
|
||||
content_parts.append(doc_content)
|
||||
text_content = oci.generative_ai_inference.models.TextContent()
|
||||
text_content.text = message
|
||||
content_parts.append(text_content)
|
||||
user_message = oci.generative_ai_inference.models.Message()
|
||||
user_message.role = "USER"
|
||||
user_message.content = [content]
|
||||
user_message.content = content_parts
|
||||
messages.append(user_message)
|
||||
|
||||
# Append accumulated tool use loop messages (assistant+tool_calls → tool_results → ...)
|
||||
@@ -2096,6 +2114,12 @@ async def get_report_progress(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
# Detect orphaned "running" reports: status is running but no live process
|
||||
if r["status"] == "running" and rid not in _running_reports:
|
||||
with db() as c:
|
||||
c.execute("UPDATE reports SET status='failed', error_msg='Interrompido: processo não encontrado', completed_at=datetime('now') WHERE id=? AND status='running'", (rid,))
|
||||
log.warning(f"Report {rid} marked as failed: no live process found")
|
||||
return {**dict(r), "status": "failed", "error_msg": "Interrompido: processo não encontrado", "completed_at": datetime.utcnow().isoformat()}
|
||||
return dict(r)
|
||||
|
||||
@app.post("/api/reports/{rid}/cancel")
|
||||
@@ -2170,8 +2194,10 @@ async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)):
|
||||
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
|
||||
|
||||
# ── Chat Agent ────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/chat")
|
||||
async def chat(msg: ChatMsg, u=Depends(current_user)):
|
||||
# (endpoints defined below _chat_core, after _agent_respond)
|
||||
|
||||
async def _chat_core_impl(msg: ChatMsg, u, attachments: list = None):
|
||||
"""Internal chat implementation — called by both /api/chat and /api/chat/upload."""
|
||||
sid = msg.session_id or str(uuid.uuid4())
|
||||
with db() as c:
|
||||
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id) VALUES (?,?,?,?,?,?)",
|
||||
@@ -2284,7 +2310,7 @@ async def chat(msg: ChatMsg, u=Depends(current_user)):
|
||||
log.info(f"Post-compaction: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens")
|
||||
|
||||
hist = history[:-1] if len(history) > 1 else None
|
||||
resp_text, tool_calls, tool_calls_raw = _call_genai(cfg_dict, augmented_message, hist, tools=tool_defs)
|
||||
resp_text, tool_calls, tool_calls_raw = _call_genai(cfg_dict, augmented_message, hist, tools=tool_defs, attachments=attachments)
|
||||
|
||||
# Tool use loop (max 5 iterations)
|
||||
# Accumulate extra_messages so the model sees the full tool use conversation
|
||||
@@ -2360,6 +2386,90 @@ async def chat(msg: ChatMsg, u=Depends(current_user)):
|
||||
result["tools_used"] = [{"name": tr["name"], "result_preview": tr["content"][:200]} for tr in all_tool_results]
|
||||
return result
|
||||
|
||||
MAX_UPLOAD_FILES = 5
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"}
|
||||
DOC_MIMES = {"application/pdf"}
|
||||
|
||||
|
||||
@app.post("/api/chat/upload")
|
||||
async def chat_with_files(
|
||||
message: str = Form(""),
|
||||
session_id: Optional[str] = Form(None),
|
||||
genai_config_id: Optional[str] = Form(None),
|
||||
model_id: Optional[str] = Form(None),
|
||||
oci_config_id: Optional[str] = Form(None),
|
||||
genai_region: Optional[str] = Form(None),
|
||||
compartment_id: Optional[str] = Form(None),
|
||||
use_tools: Optional[bool] = Form(True),
|
||||
temperature: Optional[float] = Form(None),
|
||||
max_tokens: Optional[int] = Form(None),
|
||||
top_p: Optional[float] = Form(None),
|
||||
top_k: Optional[int] = Form(None),
|
||||
frequency_penalty: Optional[float] = Form(None),
|
||||
presence_penalty: Optional[float] = Form(None),
|
||||
files: List[UploadFile] = File(default=[]),
|
||||
u=Depends(current_user),
|
||||
):
|
||||
if len(files) > MAX_UPLOAD_FILES:
|
||||
raise HTTPException(400, f"Máximo {MAX_UPLOAD_FILES} arquivos por mensagem")
|
||||
|
||||
# Process uploaded files into attachments
|
||||
attachments = []
|
||||
file_names = []
|
||||
for f in files:
|
||||
data = await f.read()
|
||||
if len(data) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(400, f"Arquivo {f.filename} excede 10MB")
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
b64 = base64.b64encode(data).decode()
|
||||
data_uri = f"data:{mime};base64,{b64}"
|
||||
if mime in IMAGE_MIMES:
|
||||
attachments.append({"type": "image", "mime": mime, "data_uri": data_uri})
|
||||
elif mime in DOC_MIMES:
|
||||
attachments.append({"type": "document", "mime": mime, "data_uri": data_uri})
|
||||
else:
|
||||
# Text-based files: read content and append to message
|
||||
try:
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
text = data.decode("latin-1", errors="replace")
|
||||
message = f"{message}\n\n--- Conteúdo de {f.filename} ---\n{text[:50000]}"
|
||||
file_names.append(f.filename)
|
||||
|
||||
# Build a synthetic ChatMsg and delegate to the chat logic
|
||||
msg = ChatMsg(
|
||||
message=message or "Analise os arquivos anexados.",
|
||||
session_id=session_id,
|
||||
genai_config_id=genai_config_id,
|
||||
model_id=model_id,
|
||||
oci_config_id=oci_config_id,
|
||||
genai_region=genai_region,
|
||||
compartment_id=compartment_id,
|
||||
use_tools=use_tools if use_tools is not None else True,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
frequency_penalty=frequency_penalty,
|
||||
presence_penalty=presence_penalty,
|
||||
)
|
||||
|
||||
# Store attachments in request state for _call_genai
|
||||
result = await _chat_core_impl(msg, u, attachments=attachments if attachments else None)
|
||||
if file_names:
|
||||
# Save file reference in user message
|
||||
with db() as c:
|
||||
c.execute("UPDATE chat_messages SET content = content || ? WHERE session_id=? AND role='user' ORDER BY created_at DESC LIMIT 1",
|
||||
(f"\n[📎 {', '.join(file_names)}]", result["session_id"]))
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(msg: ChatMsg, u=Depends(current_user)):
|
||||
return await _chat_core_impl(msg, u)
|
||||
|
||||
|
||||
def _agent_respond(msg, user):
|
||||
m = msg.lower().strip()
|
||||
if any(k in m for k in ["status","health","como está","saúde"]):
|
||||
|
||||
@@ -25,10 +25,17 @@ from mcp.types import Tool, TextContent
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
server = Server("cis-compliance")
|
||||
|
||||
# config_id -> {checker, sections_collected: set, sections_analyzed: set, ...}
|
||||
# session_key -> {checker, sections_collected: set, sections_analyzed: set, ...}
|
||||
_sessions: dict = {}
|
||||
SESSION_TTL = 1800
|
||||
|
||||
|
||||
def _session_key(config_id: str, regions: list[str] | None = None) -> str:
|
||||
"""Build cache key that includes region filter so different scopes are cached separately."""
|
||||
if not regions:
|
||||
return config_id
|
||||
return f"{config_id}:{','.join(sorted(regions))}"
|
||||
|
||||
OCI_CONFIGS_DIR = os.environ.get("OCI_CONFIGS_DIR", "/data/oci_configs")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@@ -164,12 +171,13 @@ def _list_available_configs() -> list[dict]:
|
||||
|
||||
def _get_or_create_checker(config_id: str, regions: list[str] | None = None):
|
||||
"""Return cached session or create a new CIS_Report instance (fast — no data collection)."""
|
||||
key = _session_key(config_id, regions)
|
||||
now = time.time()
|
||||
if config_id in _sessions:
|
||||
s = _sessions[config_id]
|
||||
if key in _sessions:
|
||||
s = _sessions[key]
|
||||
if now - s["created_at"] < SESSION_TTL:
|
||||
return s
|
||||
del _sessions[config_id]
|
||||
del _sessions[key]
|
||||
|
||||
import cis_reports
|
||||
|
||||
@@ -197,9 +205,10 @@ def _get_or_create_checker(config_id: str, regions: list[str] | None = None):
|
||||
|
||||
session = {
|
||||
"checker": checker, "created_at": now, "config_id": config_id,
|
||||
"regions": regions, "session_key": key,
|
||||
"base_collected": False, "sections_collected": set(), "sections_analyzed": set(),
|
||||
}
|
||||
_sessions[config_id] = session
|
||||
_sessions[key] = session
|
||||
return session
|
||||
|
||||
|
||||
@@ -446,37 +455,44 @@ TOOLS = [
|
||||
description="Coleta dados IAM e executa checks CIS 1.1-1.17: políticas, usuários, MFA, API keys, passwords, dynamic groups. Rápido (~30s).",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
|
||||
}, "required": ["config_id"]}),
|
||||
Tool(name="cis_scan_networking",
|
||||
description="Coleta dados de rede e executa checks CIS 2.1-2.8: security lists, NSGs, VCNs, subnets, OIC, OAC, ADB.",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
|
||||
}, "required": ["config_id"]}),
|
||||
Tool(name="cis_scan_compute",
|
||||
description="Coleta instâncias Compute e executa checks CIS 3.1-3.3: metadata v2, secure boot, in-transit encryption. Rápido.",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
|
||||
}, "required": ["config_id"]}),
|
||||
Tool(name="cis_scan_logging_monitoring",
|
||||
description="Coleta dados de logging/monitoring e executa checks CIS 4.1-4.18: tags, notificações, events, Cloud Guard, KMS, logs.",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
|
||||
}, "required": ["config_id"]}),
|
||||
Tool(name="cis_scan_storage",
|
||||
description="Coleta dados de storage e executa checks CIS 5.1.1-5.3.1: buckets, block/boot volumes, file storage, CMK, versioning.",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
|
||||
}, "required": ["config_id"]}),
|
||||
Tool(name="cis_scan_asset_management",
|
||||
description="Coleta dados de assets e executa checks CIS 6.1-6.2: compartments, recursos no root.",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
|
||||
}, "required": ["config_id"]}),
|
||||
Tool(name="cis_get_check",
|
||||
description="Retorna findings detalhados de um check CIS individual por ID (ex: '1.7', '2.1'). Requer scan da seção correspondente.",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"check_id": {"type": "string", "description": "ID do check CIS (ex: '1.7')"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
|
||||
}, "required": ["config_id", "check_id"]}),
|
||||
Tool(name="cis_get_remediation",
|
||||
description="Orientação de remediação para um check CIS: descrição, passos, link docs Oracle. Estático, sem conexão OCI.",
|
||||
@@ -484,14 +500,16 @@ TOOLS = [
|
||||
"check_id": {"type": "string", "description": "ID do check CIS (ex: '1.7')"},
|
||||
}, "required": ["check_id"]}),
|
||||
Tool(name="cis_get_scan_status",
|
||||
description="Status da sessão: quais seções já foram coletadas/analisadas, TTL restante.",
|
||||
description="Status da sessão: quais seções já foram coletadas/analisadas, TTL restante, regiões filtradas.",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões da sessão a consultar. Vazio = sessão sem filtro."},
|
||||
}, "required": ["config_id"]}),
|
||||
Tool(name="cis_invalidate_cache",
|
||||
description="Limpa cache de sessão. Próximo scan coletará dados novamente.",
|
||||
inputSchema={"type": "object", "properties": {
|
||||
"config_id": {"type": "string", "description": "ID da config OCI"},
|
||||
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões da sessão a limpar. Vazio = sessão sem filtro."},
|
||||
}, "required": ["config_id"]}),
|
||||
]
|
||||
|
||||
@@ -515,16 +533,21 @@ def _handle_cis_list_checks(args):
|
||||
|
||||
def _handle_scan_section(args, section: str):
|
||||
config_id = args["config_id"]
|
||||
session = _get_or_create_checker(config_id)
|
||||
regions = args.get("regions") or None
|
||||
session = _get_or_create_checker(config_id, regions=regions)
|
||||
_analyze_section(session, section)
|
||||
return _section_summary(session["checker"], section)
|
||||
result = _section_summary(session["checker"], section)
|
||||
if regions:
|
||||
result["regions_filter"] = regions
|
||||
return result
|
||||
|
||||
def _handle_cis_get_check(args):
|
||||
config_id = args["config_id"]; check_id = args["check_id"]
|
||||
regions = args.get("regions") or None
|
||||
section = _check_id_to_section(check_id)
|
||||
if section == "unknown":
|
||||
return {"error": f"Check '{check_id}' não encontrado. Use cis_list_checks."}
|
||||
session = _get_or_create_checker(config_id)
|
||||
session = _get_or_create_checker(config_id, regions=regions)
|
||||
_analyze_section(session, section)
|
||||
benchmark = _get_benchmark(session["checker"])
|
||||
if check_id not in benchmark:
|
||||
@@ -549,12 +572,14 @@ def _handle_cis_get_remediation(args):
|
||||
|
||||
def _handle_cis_get_scan_status(args):
|
||||
config_id = args["config_id"]
|
||||
if config_id not in _sessions:
|
||||
return {"config_id": config_id, "status": "not_initialized", "sections_collected": [], "sections_analyzed": []}
|
||||
s = _sessions[config_id]
|
||||
regions = args.get("regions") or None
|
||||
key = _session_key(config_id, regions)
|
||||
if key not in _sessions:
|
||||
return {"config_id": config_id, "regions": regions, "status": "not_initialized", "sections_collected": [], "sections_analyzed": []}
|
||||
s = _sessions[key]
|
||||
elapsed = time.time() - s["created_at"]
|
||||
return {
|
||||
"config_id": config_id, "status": "active",
|
||||
"config_id": config_id, "regions": s.get("regions"), "status": "active",
|
||||
"base_collected": s["base_collected"],
|
||||
"sections_collected": list(s["sections_collected"]),
|
||||
"sections_analyzed": list(s["sections_analyzed"]),
|
||||
@@ -564,10 +589,12 @@ def _handle_cis_get_scan_status(args):
|
||||
|
||||
def _handle_cis_invalidate_cache(args):
|
||||
config_id = args["config_id"]
|
||||
if config_id in _sessions:
|
||||
del _sessions[config_id]
|
||||
return {"status": "cleared", "config_id": config_id}
|
||||
return {"status": "not_found", "config_id": config_id}
|
||||
regions = args.get("regions") or None
|
||||
key = _session_key(config_id, regions)
|
||||
if key in _sessions:
|
||||
del _sessions[key]
|
||||
return {"status": "cleared", "config_id": config_id, "regions": regions}
|
||||
return {"status": "not_found", "config_id": config_id, "regions": regions}
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
|
||||
Reference in New Issue
Block a user