diff --git a/README.md b/README.md index 279fc4d..3807846 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@
-
+
@@ -34,6 +34,7 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- **RAG (Retrieval-Augmented Generation)**: automatically queries ADB vector store for relevant context before generating responses
- **MCP Tool Use (Function Calling)**: GenAI models can call tools from registered MCP servers during chat — supports both Cohere and Generic (OpenAI-style) function calling formats with automatic tool execution loop (max 5 iterations)
- **Chat Memory Compaction**: automatic summarization of older messages when conversation exceeds ~8000 tokens — keeps 6 recent messages intact and generates an LLM-based summary of older context, similar to Claude Code's context compression
+- **Multimodal Chat**: upload images (PNG/JPG/GIF/WebP), PDFs, and text files directly in the chat for AI analysis — supports up to 5 files per message via OCI GenAI `ImageContent` and `DocumentContent`
- **Thinking Indicator**: button disables and shows spinner + "Pensando..." while waiting for GenAI response
- 69 chat models + 11 embedding models across 6 providers: **Cohere**, **Meta**, **Google**, **OpenAI** (GPT-5.3/5.2/5.1/5/4.1/4o, Codex, Image, Audio, o1/o3/o4-mini, GPT-oss), **xAI** (Grok 4.1/4/3), **ProtectAI**
- OCID-based model resolution: catalog maps model IDs to OCI resource IDs per region for reliable API calls
@@ -74,7 +75,8 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- `cis_get_check` / `cis_get_remediation` — detailed findings and remediation guidance
- `cis_get_scan_status` / `cis_invalidate_cache` — session status and cache management
- **Per-section data collection**: each scan tool collects only the OCI data needed for that section, avoiding unnecessary API calls
-- **Session caching**: collected data is cached per config, so subsequent scans on different sections reuse shared prerequisites (compartments, identity domains)
+- **Region-specific scanning**: all scan tools accept optional `regions` parameter to target specific OCI regions (e.g., `["us-ashburn-1"]`) instead of scanning the entire tenancy
+- **Session caching**: collected data is cached per config+regions scope, so subsequent scans on different sections reuse shared prerequisites (compartments, identity domains)
- Based on Oracle's official `cis_reports.py` (6660 lines, 48 CIS + 11 OBP checks)
### 🔌 MCP Server Registry + Tool Discovery
@@ -418,6 +420,7 @@ oci-cis-agent/
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/chat` | Send message (with RAG + MCP tool use, accepts `use_tools` flag) |
+| POST | `/api/chat/upload` | Send message with file attachments (multipart, images/PDFs/text) |
| POST | `/api/reports/run` | Execute CIS report |
| GET | `/api/reports` | List reports |
| GET | `/api/reports/{id}/html` | View HTML report |
@@ -576,6 +579,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Version | Date | Changes |
|---------|------|---------|
+| **v1.9** | 2026-03 | Multimodal chat (image/PDF/text file upload with OCI GenAI ImageContent/DocumentContent), region-specific MCP scanning (`regions` param on all scan tools), orphaned report auto-detection on progress poll, nginx timeout increased to 15min, improved API error handling for non-JSON responses |
| **v1.8** | 2026-03 | CIS Engine auto-update from Oracle GitHub with automatic patch reapplication, version check UI card (admin), new `/api/cis-engine/*` endpoints, report file listing and individual download endpoints, reorganized Reports tab (execution history + status) and Downloads tab (file browser only with expandable cards per report), CIS Level description tooltip, persistent log expand during report generation |
| **v1.7** | 2026-03 | Oracle official CIS report engine (replaces lightweight checker), granular report parameters (Level, OBP, Raw Data, Redact), per-report file storage with category browser, tenancy filter in Downloads, individual file download |
| **v1.6** | 2026-03 | Granular CIS MCP server (12 per-section scan tools: IAM, Networking, Compute, Logging/Monitoring, Storage, Asset Management), chat memory compaction with LLM-based summarization, GenAI tool use loop fix (accumulated conversation), chat thinking indicator, auto-registered CIS MCP server |
diff --git a/backend/app.py b/backend/app.py
index 215ea69..93b4e97 100644
--- a/backend/app.py
+++ b/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"]):
diff --git a/backend/mcp_cis_server.py b/backend/mcp_cis_server.py
index 2b2d020..347d36a 100644
--- a/backend/mcp_cis_server.py
+++ b/backend/mcp_cis_server.py
@@ -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 = {
diff --git a/frontend/index.html b/frontend/index.html
index 07b0ca3..c632915 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -218,12 +218,14 @@ const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],g
chatPrompts:[],editingPrompt:null,trackingReportId:null,ociRegions:{},rptSelRegions:[],rptRegionsOpen:false,rptRegionFilter:'',
rptOciOpen:false,rptOciFilter:'',rptOciVal:'',rptRselOpen:false,rptRselFilter:'',rptRselVal:'',ociFormRegOpen:false,ociFormRegFilter:'',ociFormRegVal:'',
rptLevel:2,rptObp:false,rptRaw:false,rptRedact:false,reportFiles:{},dlExpandedRid:null,dlTenancyFilter:'',
- cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:''};
+ cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',chatFiles:[]};
const API='/api';
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
if(o.body&&!(o.body instanceof FormData)){h['Content-Type']='application/json';o.body=JSON.stringify(o.body)}
const r=await fetch(API+p,{...o,headers:h});if(r.status===401){S.user=null;S.token=null;localStorage.removeItem('t');R();throw new Error('Unauthorized')}
+ const ct=r.headers.get('content-type')||'';
+ if(!ct.includes('application/json')){const txt=await r.text();throw new Error(r.ok?'Resposta inesperada do servidor':`HTTP ${r.status}: ${txt.slice(0,120).replace(/<[^>]*>/g,'').trim()||'Erro do servidor'}`)}
const d=await r.json();if(!r.ok)throw new Error(d.detail||d.message||'Erro');return d;}
async function doLogin(u,pw,totp){const b={username:u,password:pw};if(totp)b.totp_code=totp;
const d=await $api('/auth/login',{method:'POST',body:b});if(d.mfa_required)return d;
@@ -320,7 +322,10 @@ ${S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).length
$1').replace(/\n/g,'