diff --git a/backend/app.py b/backend/app.py index 757cb66..f20a3a6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -36,6 +36,8 @@ VERSION = "1.1" for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]: d.mkdir(parents=True, exist_ok=True) +_running_reports: dict[str, asyncio.subprocess.Process] = {} # rid → subprocess + logging.basicConfig(level=logging.INFO) log = logging.getLogger("agent") @@ -163,6 +165,31 @@ GENAI_REGIONS = [ "me-jeddah-1","ap-singapore-1","ap-seoul-1","sa-vinhedo-1", ] +OCI_REGIONS = { + "Americas": [ + "us-ashburn-1","us-phoenix-1","us-chicago-1","us-sanjose-1","us-saltlake-2", + "ca-toronto-1","ca-montreal-1", + "sa-saopaulo-1","sa-vinhedo-1","sa-bogota-1","sa-santiago-1","sa-valparaiso-1", + "mx-queretaro-1","mx-monterrey-1", + ], + "Europe": [ + "eu-frankfurt-1","eu-amsterdam-1","eu-zurich-1","eu-madrid-1","eu-marseille-1", + "eu-milan-1","eu-paris-1","eu-stockholm-1","eu-jovanovac-1","eu-dcc-rome-1", + "uk-london-1","uk-cardiff-1", + "il-jerusalem-1", + ], + "Asia Pacific": [ + "ap-tokyo-1","ap-osaka-1","ap-seoul-1","ap-chuncheon-1", + "ap-mumbai-1","ap-hyderabad-1", + "ap-melbourne-1","ap-sydney-1", + "ap-singapore-1","ap-singapore-2", + ], + "Middle East & Africa": [ + "me-jeddah-1","me-abudhabi-1","me-dubai-1","me-riyadh-1", + "af-johannesburg-1", + ], +} + # ── Database ────────────────────────────────────────────────────────────────── @contextmanager def db(): @@ -222,6 +249,7 @@ def init_db(): frequency_penalty REAL DEFAULT 0, presence_penalty REAL DEFAULT 0, is_default INTEGER DEFAULT 0, + system_prompt TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (oci_config_id) REFERENCES oci_configs(id) ); @@ -229,7 +257,8 @@ def init_db(): id TEXT PRIMARY KEY, user_id TEXT NOT NULL, tenancy_name TEXT NOT NULL, config_id TEXT, mcp_server_id TEXT, - status TEXT DEFAULT 'pending', report_data TEXT, + status TEXT DEFAULT 'pending', progress TEXT DEFAULT '', + report_data TEXT, html_path TEXT, json_path TEXT, created_at TEXT DEFAULT (datetime('now')), completed_at TEXT, error_msg TEXT @@ -247,6 +276,16 @@ def init_db(): is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS adb_vector_tables ( + id TEXT PRIMARY KEY, + adb_config_id TEXT NOT NULL, + table_name TEXT NOT NULL, + description TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (adb_config_id) REFERENCES adb_vector_configs(id) ON DELETE CASCADE, + UNIQUE(adb_config_id, table_name) + ); CREATE TABLE IF NOT EXISTS mcp_servers ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, @@ -282,14 +321,49 @@ def init_db(): username TEXT, created_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS system_prompts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + agent TEXT NOT NULL DEFAULT 'chat', + content TEXT NOT NULL, + is_active INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); """) c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')") # ── Migrations ── + for col in ["system_prompt TEXT DEFAULT ''"]: + try: + c.execute(f"ALTER TABLE genai_configs ADD COLUMN {col}") + except sqlite3.OperationalError: + pass for col in ["genai_config_id TEXT", "embedding_model_id TEXT DEFAULT 'cohere.embed-v4.0'"]: try: c.execute(f"ALTER TABLE adb_vector_configs ADD COLUMN {col}") except sqlite3.OperationalError: pass + for col in ["progress TEXT DEFAULT ''"]: + try: + c.execute(f"ALTER TABLE reports ADD COLUMN {col}") + except sqlite3.OperationalError: + pass + # Migrate legacy table_name from adb_vector_configs into adb_vector_tables + try: + for cfg_row in c.execute("SELECT id, table_name FROM adb_vector_configs WHERE table_name IS NOT NULL AND table_name != ''").fetchall(): + if not c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=?", (cfg_row["id"], cfg_row["table_name"])).fetchone(): + c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)", + (str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente")) + except Exception: + pass + # Seed default system prompt if none exists for chat agent + if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone(): + c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)", + (str(uuid.uuid4()), "OCI CIS RAG Agent", "chat", RAG_DEFAULT_SYSTEM_PROMPT, 1)) adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone() if not adm: c.execute( @@ -314,6 +388,19 @@ def _make_token(uid,role,sid): return pyjwt.encode({"sub":uid,"role":role,"sid":sid,"exp":datetime.utcnow()+timedelta(hours=JWT_EXP_H),"iat":datetime.utcnow()},APP_SECRET,algorithm=JWT_ALG) def _enc(v): return base64.b64encode(v.encode()).decode() def _dec(v): return base64.b64decode(v.encode()).decode() +def _mask(v, show=6): + """Mask a sensitive value, showing only the last `show` characters.""" + if not v or len(v) <= show: + return "•" * 8 + return "•" * min(len(v) - show, 20) + v[-show:] +def _safe_dec(v): + """Decrypt a value, returning as-is if not encrypted (migration compat).""" + if not v: + return v + try: + return _dec(v) + except Exception: + return v # ── OCI SDK Client Helper ───────────────────────────────────────────────────── def _get_oci_config(oci_config_id: str) -> dict: @@ -369,6 +456,7 @@ class ChatMsg(BaseModel): temperature: Optional[float] = None; max_tokens: Optional[int] = None top_p: Optional[float] = None; top_k: Optional[int] = None frequency_penalty: Optional[float] = None; presence_penalty: Optional[float] = None + use_tools: Optional[bool] = True class RunReportReq(BaseModel): config_id: str; mcp_server_id: Optional[str] = None; regions: Optional[List[str]] = None class GenAIConfigReq(BaseModel): @@ -385,12 +473,12 @@ class ADBVectorReq(BaseModel): wallet_password: Optional[str] = None; table_name: str = "CIS_EMBEDDINGS"; use_mtls: bool = True genai_config_id: Optional[str] = None; embedding_model_id: str = "cohere.embed-v4.0" class IngestDocReq(BaseModel): - adb_config_id: str; documents: List[Dict[str, Any]] + adb_config_id: str; documents: List[Dict[str, Any]]; table_name: Optional[str] = None class MCPServerReq(BaseModel): name: str; description: Optional[str] = None; server_type: str = "stdio" command: Optional[str] = None; args: Optional[List[str]] = None env_vars: Optional[Dict[str,str]] = None; url: Optional[str] = None - module_path: Optional[str] = None; tools: Optional[List[str]] = None + module_path: Optional[str] = None; tools: Optional[List[dict]] = None linked_adb_id: Optional[str] = None # ── Auth endpoints ──────────────────────────────────────────────────────────── @@ -515,7 +603,7 @@ async def save_oci( cfg_file.chmod(0o600) with db() as c: c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id) VALUES (?,?,?,?,?,?,?,?,?)", - (cid, u["id"], tenancy_name, tenancy_ocid, user_ocid, fingerprint, region, str(kp), compartment_id or None)) + (cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None)) _audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name}") _config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region})", u["id"], u["username"]) return {"id": cid, "tenancy_name": tenancy_name, "region": region} @@ -526,7 +614,26 @@ async def list_oci(u=Depends(current_user)): cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,created_at" if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM oci_configs").fetchall() else: rows=c.execute(f"SELECT {cols} FROM oci_configs WHERE user_id=?",(u["id"],)).fetchall() - return [dict(r) for r in rows] + result = [] + for r in rows: + d = dict(r) + # Decrypt and mask sensitive fields for display + try: + d["tenancy_ocid"] = _mask(_dec(d["tenancy_ocid"])) + except Exception: + d["tenancy_ocid"] = _mask(d["tenancy_ocid"]) + try: + d["user_ocid"] = _mask(_dec(d["user_ocid"])) + except Exception: + d["user_ocid"] = _mask(d["user_ocid"]) + d["fingerprint"] = "•" * 20 # fingerprint fully masked + if d.get("compartment_id"): + try: + d["compartment_id"] = _mask(_dec(d["compartment_id"])) + except Exception: + d["compartment_id"] = _mask(d["compartment_id"]) + result.append(d) + return result @app.delete("/api/oci/configs/{cid}") async def del_oci(cid: str, u=Depends(require("admin","user"))): @@ -542,8 +649,8 @@ async def del_oci(cid: str, u=Depends(require("admin","user"))): @app.put("/api/oci/configs/{cid}") async def update_oci( cid: str, - tenancy_name: str = Form(...), tenancy_ocid: str = Form(...), - user_ocid: str = Form(...), fingerprint: str = Form(...), + tenancy_name: str = Form(...), tenancy_ocid: str = Form(""), + user_ocid: str = Form(""), fingerprint: str = Form(""), region: str = Form(...), compartment_id: str = Form(""), key_passphrase: str = Form(""), private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None), @@ -553,6 +660,11 @@ async def update_oci( existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone() if not existing: raise HTTPException(404) if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403) + # Keep existing encrypted values if not provided (empty = keep current) + tenancy_ocid = tenancy_ocid or _safe_dec(existing["tenancy_ocid"]) + user_ocid = user_ocid or _safe_dec(existing["user_ocid"]) + fingerprint = fingerprint or _safe_dec(existing["fingerprint"]) + compartment_id = compartment_id or _safe_dec(existing["compartment_id"]) or "" cdir = OCI_DIR / cid; cdir.mkdir(parents=True, exist_ok=True) kp = cdir / "oci_api_key.pem" if private_key and private_key.filename: @@ -571,7 +683,7 @@ async def update_oci( cfg_file.write_text(cfg_content); cfg_file.chmod(0o600) with db() as c: c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=? WHERE id=?", - (tenancy_name, tenancy_ocid, user_ocid, fingerprint, region, compartment_id or None, cid)) + (tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, cid)) _audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name}") _config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region})", u["id"], u["username"]) return {"id": cid, "tenancy_name": tenancy_name, "region": region} @@ -616,7 +728,7 @@ async def explore_compartments(cid: str, u=Depends(current_user)): identity = oci.identity.IdentityClient(config) with db() as c: cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone() - root = cfg["compartment_id"] or cfg["tenancy_ocid"] + root = _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"]) comps = identity.list_compartments(root, compartment_id_in_subtree=True).data return [{"id":cp.id,"name":cp.name,"lifecycle_state":cp.lifecycle_state,"description":cp.description or ""} for cp in comps if cp.lifecycle_state == "ACTIVE"] except Exception as e: @@ -630,7 +742,7 @@ async def explore_regions(cid: str, u=Depends(current_user)): identity = oci.identity.IdentityClient(config) with db() as c: cfg = c.execute("SELECT tenancy_ocid FROM oci_configs WHERE id=?", (cid,)).fetchone() - regions = identity.list_region_subscriptions(cfg["tenancy_ocid"]).data + regions = identity.list_region_subscriptions(_safe_dec(cfg["tenancy_ocid"])).data return [{"name":r.region_name,"key":r.region_key,"status":r.status,"is_home":r.is_home_region} for r in regions] except Exception as e: return {"error": str(e)[:500]} @@ -643,7 +755,7 @@ async def explore_vcns(cid: str, compartment_id: str = Query(None), u=Depends(cu vn = oci.core.VirtualNetworkClient(config) with db() as c: cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone() - comp = compartment_id or cfg["compartment_id"] or cfg["tenancy_ocid"] + comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"]) vcns = vn.list_vcns(comp).data return [{"id":v.id,"display_name":v.display_name,"cidr_blocks":v.cidr_blocks,"lifecycle_state":v.lifecycle_state} for v in vcns] except Exception as e: @@ -657,7 +769,7 @@ async def explore_instances(cid: str, compartment_id: str = Query(None), u=Depen compute = oci.core.ComputeClient(config) with db() as c: cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone() - comp = compartment_id or cfg["compartment_id"] or cfg["tenancy_ocid"] + comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"]) insts = compute.list_instances(comp).data return [{"id":i.id,"display_name":i.display_name,"shape":i.shape,"lifecycle_state":i.lifecycle_state,"region":i.region,"time_created":str(i.time_created)} for i in insts] except Exception as e: @@ -671,7 +783,7 @@ async def explore_databases(cid: str, compartment_id: str = Query(None), u=Depen db_client = oci.database.DatabaseClient(config) with db() as c: cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone() - comp = compartment_id or cfg["compartment_id"] or cfg["tenancy_ocid"] + comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"]) adbs = db_client.list_autonomous_databases(comp).data return [{"id":a.id,"display_name":a.display_name,"db_name":a.db_name,"lifecycle_state":a.lifecycle_state, "cpu_core_count":a.cpu_core_count,"data_storage_size_in_tbs":a.data_storage_size_in_tbs, @@ -688,7 +800,7 @@ async def explore_buckets(cid: str, compartment_id: str = Query(None), u=Depends namespace = os_client.get_namespace().data with db() as c: cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone() - comp = compartment_id or cfg["compartment_id"] or cfg["tenancy_ocid"] + comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"]) buckets = os_client.list_buckets(namespace, comp).data return [{"name":b.name,"namespace":b.namespace,"time_created":str(b.time_created)} for b in buckets] except Exception as e: @@ -697,7 +809,7 @@ async def explore_buckets(cid: str, compartment_id: str = Query(None), u=Depends # ── OCI GenAI Config & Chat ─────────────────────────────────────────────────── @app.get("/api/genai/models") async def list_genai_models(u=Depends(current_user)): - return {"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS} + return {"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS} @app.post("/api/genai/config") async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))): @@ -759,7 +871,7 @@ async def test_genai(gid: str, u=Depends(require("admin","user"))): if not gc: raise HTTPException(404) gname = gc["name"] try: - resp = _call_genai(dict(gc), "Say 'connection successful' in one short sentence.") + resp, _ = _call_genai(dict(gc), "Say 'connection successful' in one short sentence.") _config_log("genai", gid, gname, "success", "test", f"GenAI OK: {resp[:200]}", u["id"], u["username"]) return {"status":"success","message":"GenAI OK","response":resp[:300]} except Exception as e: @@ -767,10 +879,11 @@ async def test_genai(gid: str, u=Depends(require("admin","user"))): _config_log("genai", gid, gname, "error", "test", msg, u["id"], u["username"]) return {"status":"error","message":msg} -def _call_genai(gc: dict, message: str, history: list = None) -> str: +def _call_genai(gc: dict, message: str, history: list = None, tools: list = None, + tool_results_cohere: list = None, tool_results_generic: list = None) -> tuple: """ - Call OCI Generative AI using the exact SDK pattern from chat_demo.py. - Uses oci.generative_ai_inference with proper Message/TextContent objects. + Call OCI Generative AI with optional tool use support. + Returns (text, tool_calls) tuple. tool_calls is a list of dicts or None. """ import oci @@ -789,6 +902,9 @@ def _call_genai(gc: dict, message: str, history: list = None) -> str: timeout=(10, 240) ) + # System prompt + system_prompt = gc.get("system_prompt", "") + # Build ChatDetails chat_detail = oci.generative_ai_inference.models.ChatDetails() @@ -800,6 +916,8 @@ def _call_genai(gc: dict, message: str, history: list = None) -> str: # ── Cohere models (CohereChatRequest) ── chat_request = oci.generative_ai_inference.models.CohereChatRequest() chat_request.api_format = oci.generative_ai_inference.models.BaseChatRequest.API_FORMAT_COHERE + if system_prompt: + chat_request.preamble_override = system_prompt chat_request.message = message chat_request.max_tokens = int(gc.get("max_tokens", 6000)) chat_request.temperature = float(gc.get("temperature", 1)) @@ -815,6 +933,27 @@ def _call_genai(gc: dict, message: str, history: list = None) -> str: entry.message = h["content"] chat_history.append(entry) chat_request.chat_history = chat_history + # Tool use support for Cohere + if tools: + cohere_tools = [] + for t in tools: + props = t.get("input_schema", {}).get("properties", {}) + required = t.get("input_schema", {}).get("required", []) + param_defs = {} + for k, v in props.items(): + pd = oci.generative_ai_inference.models.CohereParameterDefinition() + pd.type = v.get("type", "str") + pd.description = v.get("description", "") + pd.is_required = k in required + param_defs[k] = pd + ct = oci.generative_ai_inference.models.CohereTool() + ct.name = t["name"] + ct.description = t.get("description", "") + ct.parameter_definitions = param_defs if param_defs else None + cohere_tools.append(ct) + chat_request.tools = cohere_tools + if tool_results_cohere: + chat_request.tool_results = tool_results_cohere else: # ── Generic format (Meta Llama, Google, xAI, OpenAI) ── chat_request = oci.generative_ai_inference.models.GenericChatRequest() @@ -837,6 +976,13 @@ def _call_genai(gc: dict, message: str, history: list = None) -> str: chat_request.top_p = float(gc.get("top_p", 0.95)) messages = [] + if system_prompt: + sys_content = oci.generative_ai_inference.models.TextContent() + sys_content.text = system_prompt + sys_msg = oci.generative_ai_inference.models.Message() + sys_msg.role = "SYSTEM" + sys_msg.content = [sys_content] + messages.append(sys_msg) if history: for h in history: content = oci.generative_ai_inference.models.TextContent() @@ -846,6 +992,16 @@ def _call_genai(gc: dict, message: str, history: list = None) -> str: msg.content = [content] messages.append(msg) + # Tool results from previous iteration (Generic format) + if tool_results_generic: + for tr in tool_results_generic: + tool_msg = oci.generative_ai_inference.models.ToolMessage() + tool_msg.tool_call_id = tr["tool_call_id"] + tool_content = oci.generative_ai_inference.models.TextContent() + tool_content.text = tr["content"] + tool_msg.content = [tool_content] + messages.append(tool_msg) + # Current user message content = oci.generative_ai_inference.models.TextContent() content.text = message @@ -856,6 +1012,17 @@ def _call_genai(gc: dict, message: str, history: list = None) -> str: chat_request.messages = messages + # Tool definitions for Generic format + if tools: + generic_tools = [] + for t in tools: + fd = oci.generative_ai_inference.models.FunctionDefinition() + fd.name = t["name"] + fd.description = t.get("description", "") + fd.parameters = t.get("input_schema") + generic_tools.append(fd) + chat_request.tools = generic_tools + # Serving mode - resolve OCID: explicit model_ocid > catalog ocid for region > short model_id model_ref = gc.get("model_ocid") or "" if not model_ref: @@ -878,31 +1045,94 @@ def _call_genai(gc: dict, message: str, history: list = None) -> str: # Execute chat_response = generative_ai_inference_client.chat(chat_detail) - # Extract text from response + # Extract text and tool_calls from response resp = chat_response.data.chat_response if api_format == "COHERE": - return resp.text if hasattr(resp, 'text') else str(resp) + text = resp.text if hasattr(resp, 'text') else "" + tool_calls = None + if hasattr(resp, 'tool_calls') and resp.tool_calls: + tool_calls = [] + for tc in resp.tool_calls: + tool_calls.append({ + "id": tc.name, # Cohere uses name as identifier + "name": tc.name, + "arguments": tc.parameters if isinstance(tc.parameters, dict) else {} + }) + return (text or "", tool_calls) else: + text = "" + tool_calls = None if hasattr(resp, 'choices') and resp.choices: choice = resp.choices[0] - if hasattr(choice, 'message') and choice.message and hasattr(choice.message, 'content'): - contents = choice.message.content - if contents and len(contents) > 0: - return contents[0].text - return str(resp) + if hasattr(choice, 'message') and choice.message: + # Check for tool calls + if hasattr(choice.message, 'tool_calls') and choice.message.tool_calls: + tool_calls = [] + for tc in choice.message.tool_calls: + args = {} + if hasattr(tc, 'arguments') and tc.arguments: + try: args = json.loads(tc.arguments) if isinstance(tc.arguments, str) else tc.arguments + except: args = {} + tool_calls.append({ + "id": tc.id if hasattr(tc, 'id') else tc.name, + "name": tc.name if hasattr(tc, 'name') else "", + "arguments": args + }) + # Extract text + if hasattr(choice.message, 'content') and choice.message.content: + contents = choice.message.content + if contents and len(contents) > 0: + text = contents[0].text if hasattr(contents[0], 'text') else "" + return (text or "", tool_calls) # ── RAG Helpers ─────────────────────────────────────────────────────────────── -RAG_SYSTEM_PROMPT = """You are the OCI CIS AI Agent, an expert assistant for Oracle Cloud Infrastructure security and compliance. - -You have been provided with relevant context documents retrieved from a knowledge base. Use this context to provide accurate, specific answers. If the context does not contain relevant information for the question, say so and answer based on your general knowledge. - -Always cite the source documents when using information from the context. - ---- RETRIEVED CONTEXT --- +RAG_CONTEXT_TEMPLATE = """--- CONTEXTO RECUPERADO (Vector Search) --- {context} ---- END CONTEXT --- +--- FIM DO CONTEXTO --- -User question: {question}""" +Pergunta do usuário: {question}""" + +RAG_DEFAULT_SYSTEM_PROMPT = """Você é um assistente RAG especializado em Oracle Cloud Infrastructure (OCI). + +### Escopo e restrições +- Responda **SOMENTE** perguntas relacionadas a Oracle Cloud (OCI), CIS Benchmark, CIS Report e itens de inventário OCI (IAM/AssetManagement/Networking/StorageBlock/FileStorageService/Objectstore/Compute/LoggingandMonitoring). +- Se a pergunta não for desse escopo, recuse educadamente e peça uma pergunta dentro do tema. + +### Tenancy (obrigatório) +1) Confirme a **TENANCY** alvo antes de responder, a menos que já esteja definida na conversa. +2) Se a tenancy ainda não estiver clara, pergunte uma única vez: "Qual tenancy devo considerar? (ex.: MinhaTenancy)" e aguarde a resposta. +3) Se o usuário trocar a tenancy, considere a nova tenancy como a atual. + +### Bases vetorizadas disponíveis (contexto recuperado automaticamente) +- **CIS_REPORT**: report coletado na tenancy (compliance, findings, critérios/auditoria, evidências). +- **CIS_RECOMMENDATIONS**: recomendações práticas de correção/remediação para itens não compliant. +- **Inventários OCI** (itens não compliance): inventidentityandaccess, inventassetmanagement, inventnetworking, inventstorageblock, inventfilestorageservice, inventobjectstorage, inventcomputeinstances, inventloggingandmonitoring. + +### Resolução de divergências +- Para **critérios, exigência e auditoria**, priorize documentos do **CIS_REPORT**. +- Para **passos de remediação (como corrigir)**, priorize documentos do **CIS_RECOMMENDATIONS**. +- Se houver conflito, declare: "Há divergência entre bases; para auditoria usei CIS_REPORT e para correção usei CIS_RECOMMENDATIONS", citando evidências. + +### Regras de fidelidade +- Responda **somente** com informações suportadas por evidências do contexto recuperado. +- Se não houver evidência suficiente: **"Não encontrei nas minhas bases"** e peça dados para refinar (Recommendation #, seção, recurso, OCID, etc.). +- **Não invente** páginas, comandos, políticas, valores ou números. +- Use somente **1–3 linhas** de evidência por item para manter respostas compactas. + +### Formato de resposta (compacto, porém completo) +- **Tenancy:** +- **Recomendação ** + - **Seção/Capítulo:** … + - **Nível/Tipo:** Manual/Automated (se disponível) + - **O que isso exige (CIS):** … + - **Como auditar (critério):** … + - **Como corrigir (remediação):** (somente se solicitado ou relevante) + - Passos (Console/OCI CLI **apenas se estiverem nas evidências**) + - Observações/alertas + - **Evidência (citação curta):** "…" + - **Fontes consultadas:** + - CIS_REPORT: + - CIS_RECOMMENDATIONS: (se usado)""" def _get_adb_connection(cfg: dict): """Create an oracledb connection from an adb_vector_configs row.""" @@ -915,32 +1145,6 @@ def _get_adb_connection(cfg: dict): params["wallet_password"] = _dec(cfg["wallet_password_enc"]) return oracledb.connect(**params) -def _ensure_embeddings_table(cfg: dict): - """Create the embeddings table in ADB if it doesn't exist.""" - table_name = cfg.get("table_name", "CIS_EMBEDDINGS") - emb_model = cfg.get("embedding_model_id", "cohere.embed-v4.0") - dims = EMBEDDING_MODELS.get(emb_model, {}).get("dims", 1024) - conn = _get_adb_connection(cfg) - try: - cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM user_tables WHERE table_name = :1", (table_name.upper(),)) - if cur.fetchone()[0] == 0: - cur.execute(f""" - CREATE TABLE {table_name} ( - ID VARCHAR2(100) PRIMARY KEY, - CONTENT CLOB, - EMBEDDING VECTOR({dims}, FLOAT64), - METADATA VARCHAR2(4000), - SOURCE VARCHAR2(500), - CREATED_AT TIMESTAMP DEFAULT SYSTIMESTAMP - ) - """) - conn.commit() - log.info(f"Created embeddings table: {table_name} (dims={dims})") - cur.close() - finally: - conn.close() - def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list: """Generate embedding using OCI GenAI embed endpoint.""" import oci @@ -964,10 +1168,10 @@ def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list: response = client.embed_text(embed_detail) return response.data.embeddings[0] -def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5) -> list: +def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None) -> list: """Search ADB vector store using cosine similarity. Returns top-K documents.""" import array - table_name = cfg.get("table_name", "CIS_EMBEDDINGS") + table_name = table_name or cfg.get("table_name", "CIS_EMBEDDINGS") conn = _get_adb_connection(cfg) try: cur = conn.cursor() @@ -1006,18 +1210,28 @@ def _build_rag_context(documents: list) -> str: parts.append(f"[Document {i} | Source: {source}]\n{content}") return "\n\n---\n\n".join(parts) -def _get_active_adb_config(user_id: str) -> dict | None: - """Get the first active ADB vector config with a linked GenAI config.""" +def _get_active_adb_configs(user_id: str) -> list[dict]: + """Get all active ADB vector configs with a linked GenAI config.""" with db() as c: - cfg = c.execute( - "SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC LIMIT 1", + rows = c.execute( + "SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC", (user_id,) - ).fetchone() - if not cfg: - cfg = c.execute( - "SELECT * FROM adb_vector_configs WHERE is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC LIMIT 1" - ).fetchone() - return dict(cfg) if cfg else None + ).fetchall() + if not rows: + rows = c.execute( + "SELECT * FROM adb_vector_configs WHERE is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC" + ).fetchall() + return [dict(r) for r in rows] + +def _get_tables_for_config(adb_config_id: str, active_only: bool = True) -> list[dict]: + """Get all registered vector tables for an ADB config.""" + with db() as c: + sql = "SELECT * FROM adb_vector_tables WHERE adb_config_id=?" + if active_only: + sql += " AND is_active=1" + sql += " ORDER BY created_at ASC" + rows = c.execute(sql, (adb_config_id,)).fetchall() + return [dict(r) for r in rows] # ── MCP Servers ─────────────────────────────────────────────────────────────── @app.post("/api/mcp/servers") @@ -1092,6 +1306,138 @@ async def link_mcp_adb(mid: str, adb_id: str = Query(...), u=Depends(require("ad with db() as c: c.execute("UPDATE mcp_servers SET linked_adb_id=? WHERE id=?", (adb_id, mid)) return {"ok": True, "mcp_id": mid, "linked_adb_id": adb_id} +@app.post("/api/mcp/servers/{mid}/discover-tools") +async def discover_mcp_tools(mid: str, u=Depends(require("admin","user"))): + """Connect to MCP server and discover available tools.""" + with db() as c: + row = c.execute("SELECT * FROM mcp_servers WHERE id=?", (mid,)).fetchone() + if not row: raise HTTPException(404) + mcp_srv = dict(row) + try: + discovered = await _discover_mcp_tools(mcp_srv) + except Exception as e: + raise HTTPException(500, f"Erro ao descobrir tools: {str(e)[:500]}") + # Merge with existing tools (keep manually added ones) + existing = [] + if mcp_srv.get("tools"): + try: existing = json.loads(mcp_srv["tools"]) if isinstance(mcp_srv["tools"], str) else mcp_srv["tools"] + except: pass + existing_names = {t["name"] for t in existing if isinstance(t, dict)} + for dt in discovered: + if dt["name"] not in existing_names: + existing.append(dt) + else: + # Update existing tool with discovered schema + for i, et in enumerate(existing): + if isinstance(et, dict) and et["name"] == dt["name"]: + existing[i] = dt + break + with db() as c: + c.execute("UPDATE mcp_servers SET tools=? WHERE id=?", (json.dumps(existing), mid)) + _audit(u["id"], u["username"], "discover_mcp_tools", mid, f"{len(discovered)} tools found") + return {"ok": True, "discovered": len(discovered), "total": len(existing), "tools": existing} + +@app.put("/api/mcp/servers/{mid}/tools") +async def update_mcp_tools(mid: str, req: dict, u=Depends(require("admin","user"))): + """Manually update tools for an MCP server.""" + with db() as c: + row = c.execute("SELECT id FROM mcp_servers WHERE id=?", (mid,)).fetchone() + if not row: raise HTTPException(404) + tools = req.get("tools", []) + if not isinstance(tools, list): raise HTTPException(400, "tools must be a list") + for t in tools: + if not isinstance(t, dict) or "name" not in t: + raise HTTPException(400, "Each tool must have a 'name' field") + with db() as c: + c.execute("UPDATE mcp_servers SET tools=? WHERE id=?", (json.dumps(tools), mid)) + return {"ok": True, "tools": tools} + +async def _discover_mcp_tools(mcp_srv: dict) -> list[dict]: + """Connect to MCP server and list available tools.""" + from mcp import ClientSession + if mcp_srv["server_type"] in ("stdio", "module"): + from mcp.client.stdio import stdio_client, StdioServerParameters + cmd = mcp_srv.get("command") or "python3" + args_raw = mcp_srv.get("args") + args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw or []) + env_raw = mcp_srv.get("env_vars") + env = json.loads(env_raw) if isinstance(env_raw, str) else (env_raw or None) + params = StdioServerParameters(command=cmd, args=args, env=env) + async with stdio_client(params) as streams: + async with ClientSession(*streams) as session: + await session.initialize() + result = await session.list_tools() + return [{"name": t.name, "description": t.description or "", + "input_schema": t.inputSchema if hasattr(t, 'inputSchema') else {}} for t in result.tools] + elif mcp_srv["server_type"] == "sse": + from mcp.client.sse import sse_client + async with sse_client(mcp_srv["url"]) as streams: + async with ClientSession(*streams) as session: + await session.initialize() + result = await session.list_tools() + return [{"name": t.name, "description": t.description or "", + "input_schema": t.inputSchema if hasattr(t, 'inputSchema') else {}} for t in result.tools] + return [] + +async def _execute_mcp_tool(mcp_srv: dict, tool_name: str, arguments: dict) -> str: + """Connect to MCP server and execute a specific tool.""" + from mcp import ClientSession + if mcp_srv["server_type"] in ("stdio", "module"): + from mcp.client.stdio import stdio_client, StdioServerParameters + cmd = mcp_srv.get("command") or "python3" + args_raw = mcp_srv.get("args") + args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw or []) + env_raw = mcp_srv.get("env_vars") + env = json.loads(env_raw) if isinstance(env_raw, str) else (env_raw or None) + params = StdioServerParameters(command=cmd, args=args, env=env) + async with stdio_client(params) as streams: + async with ClientSession(*streams) as session: + await session.initialize() + result = await session.call_tool(tool_name, arguments) + parts = [] + for c in result.content: + if hasattr(c, 'text'): + parts.append(c.text) + elif hasattr(c, 'data'): + parts.append(str(c.data)) + return "\n".join(parts) if parts else "Tool executed successfully (no output)" + elif mcp_srv["server_type"] == "sse": + from mcp.client.sse import sse_client + async with sse_client(mcp_srv["url"]) as streams: + async with ClientSession(*streams) as session: + await session.initialize() + result = await session.call_tool(tool_name, arguments) + parts = [] + for c in result.content: + if hasattr(c, 'text'): + parts.append(c.text) + elif hasattr(c, 'data'): + parts.append(str(c.data)) + return "\n".join(parts) if parts else "Tool executed successfully (no output)" + raise ValueError(f"Unsupported MCP server type: {mcp_srv['server_type']}") + +def _get_active_mcp_tools(user_id: str) -> list[dict]: + """Get all tools from active MCP servers for a user, with server reference.""" + with db() as c: + rows = c.execute( + "SELECT * FROM mcp_servers WHERE is_active=1 AND (user_id=? OR EXISTS (SELECT 1 FROM users WHERE id=? AND role='admin'))", + (user_id, user_id) + ).fetchall() + result = [] + for r in rows: + srv = dict(r) + tools_raw = srv.get("tools") + if not tools_raw: + continue + try: + tools = json.loads(tools_raw) if isinstance(tools_raw, str) else tools_raw + except: + continue + for t in tools: + if isinstance(t, dict) and t.get("name"): + result.append({"server": srv, "tool": t}) + return result + # ── ADB Vector DB Config ───────────────────────────────────────────────────── @app.post("/api/adb/parse-wallet") async def parse_wallet(wallet: UploadFile = File(...), u=Depends(require("admin","user"))): @@ -1171,7 +1517,12 @@ async def list_adb(u=Depends(current_user)): cols = "id,config_name,dsn,username,table_name,use_mtls,is_active,wallet_dir,genai_config_id,embedding_model_id,created_at" if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM adb_vector_configs").fetchall() else: rows=c.execute(f"SELECT {cols} FROM adb_vector_configs WHERE user_id=?",(u["id"],)).fetchall() - return [dict(r) for r in rows] + result = [] + for r in rows: + d = dict(r) + d["tables"] = _get_tables_for_config(d["id"], active_only=False) + result.append(d) + return result @app.post("/api/adb/test/{vid}") async def test_adb(vid: str, u=Depends(require("admin","user"))): @@ -1237,16 +1588,67 @@ async def update_adb( _config_log("adb", vid, config_name, "success", "save", f"Conexão atualizada: {config_name} ({dsn})", u["id"], u["username"]) return {"id": vid, "config_name": config_name} -@app.post("/api/adb/{vid}/ensure-table") -async def ensure_table(vid: str, u=Depends(require("admin","user"))): +import re as _re +_TABLE_NAME_RE = _re.compile(r'^[A-Z][A-Z0-9_]{0,127}$') + +@app.get("/api/adb/{vid}/tables") +async def list_adb_tables(vid: str, u=Depends(current_user)): + with db() as c: + cfg = c.execute("SELECT id FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() + if not cfg: raise HTTPException(404) + return _get_tables_for_config(vid, active_only=False) + +@app.post("/api/adb/{vid}/tables") +async def add_adb_table(vid: str, req: dict, u=Depends(require("admin","user"))): + table_name = req.get("table_name", "").strip().upper() + description = req.get("description", "").strip() + if not table_name: raise HTTPException(400, "table_name é obrigatório") + if not _TABLE_NAME_RE.match(table_name): raise HTTPException(400, "Nome da tabela inválido. Use letras maiúsculas, números e underscores.") with db() as c: cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() - if not cfg: raise HTTPException(404) + if not cfg: raise HTTPException(404, "ADB config not found") + tid = str(uuid.uuid4()) try: - _ensure_embeddings_table(dict(cfg)) - return {"ok": True, "table": cfg["table_name"]} - except Exception as e: - raise HTTPException(500, f"Falha ao criar tabela: {str(e)[:500]}") + with db() as c: + c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)", + (tid, vid, table_name, description)) + except sqlite3.IntegrityError: + raise HTTPException(409, f"Tabela '{table_name}' já registrada nesta conexão") + _config_log("adb", vid, cfg["config_name"], "success", "add_table", f"Tabela '{table_name}' registrada", u["id"], u["username"]) + return {"id": tid, "table_name": table_name} + +@app.delete("/api/adb/{vid}/tables/{tid}") +async def remove_adb_table(vid: str, tid: str, u=Depends(require("admin","user"))): + with db() as c: + row = c.execute("SELECT * FROM adb_vector_tables WHERE id=? AND adb_config_id=?", (tid, vid)).fetchone() + if not row: raise HTTPException(404, "Table entry not found") + with db() as c: + c.execute("DELETE FROM adb_vector_tables WHERE id=?", (tid,)) + return {"ok": True} + +@app.put("/api/adb/{vid}/tables/{tid}") +async def update_adb_table(vid: str, tid: str, req: dict, u=Depends(require("admin","user"))): + with db() as c: + row = c.execute("SELECT * FROM adb_vector_tables WHERE id=? AND adb_config_id=?", (tid, vid)).fetchone() + if not row: raise HTTPException(404, "Table entry not found") + sets, vals = [], [] + if "table_name" in req: + tn = req["table_name"].strip().upper() + if not tn: raise HTTPException(400, "table_name é obrigatório") + if not _TABLE_NAME_RE.match(tn): raise HTTPException(400, "Nome da tabela inválido") + sets.append("table_name=?"); vals.append(tn) + if "description" in req: + sets.append("description=?"); vals.append(req["description"]) + if "is_active" in req: + sets.append("is_active=?"); vals.append(int(req["is_active"])) + if not sets: return {"ok": True} + vals.append(tid) + try: + with db() as c: + c.execute(f"UPDATE adb_vector_tables SET {','.join(sets)} WHERE id=?", vals) + except sqlite3.IntegrityError: + raise HTTPException(409, "Tabela com este nome já existe nesta conexão") + return {"ok": True} @app.post("/api/adb/{vid}/ingest") async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=Depends(require("admin","user"))): @@ -1259,15 +1661,14 @@ async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=D with db() as c: gc = c.execute("SELECT * FROM genai_configs WHERE id=?", (cfg["genai_config_id"],)).fetchone() if not gc: raise HTTPException(400, "Linked GenAI config not found") - bg.add_task(_ingest_documents_task, cfg, dict(gc), req.documents, u["id"], u["username"]) + bg.add_task(_ingest_documents_task, cfg, dict(gc), req.documents, u["id"], u["username"], table_name=req.table_name) return {"ok": True, "message": f"Ingestão iniciada para {len(req.documents)} documentos", "config_id": vid} -def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: str, username: str): +def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: str, username: str, table_name: str = None): """Background task: embed and insert documents into ADB via OCI GenAI.""" import array emb_model = cfg.get("embedding_model_id", "cohere.embed-v4.0") - table_name = cfg.get("table_name", "CIS_EMBEDDINGS") - _ensure_embeddings_table(cfg) + table_name = table_name or cfg.get("table_name", "CIS_EMBEDDINGS") conn = _get_adb_connection(cfg) try: cur = conn.cursor() @@ -1304,6 +1705,15 @@ def _chunk_report_by_section(report_data: dict) -> list: findings = report_data.get("findings", {}) tenancy = report_data.get("tenancy", "unknown") generated_at = report_data.get("generated_at", "") + regions = report_data.get("regions", []) + compartments = report_data.get("compartments", []) + # Build context header for each chunk + ctx_parts = [f"Tenancy: {tenancy}"] + if regions: + ctx_parts.append(f"Regions: {', '.join(regions)}") + if compartments: + ctx_parts.append(f"Compartments: {', '.join(compartments[:50])}") + ctx_header = "\n".join(ctx_parts) sections = {} for cid, check in findings.items(): sec = check.get("section", "Other") @@ -1314,7 +1724,7 @@ def _chunk_report_by_section(report_data: dict) -> list: passed = sum(1 for c in checks if c.get("status") == "PASS") failed = sum(1 for c in checks if c.get("status") == "FAIL") review = sum(1 for c in checks if c.get("status") == "REVIEW") - lines = [f"Section: {section_name}", f"Total checks: {len(checks)}, Passed: {passed}, Failed: {failed}, Review: {review}", ""] + lines = [ctx_header, "", f"Section: {section_name}", f"Total checks: {len(checks)}, Passed: {passed}, Failed: {failed}, Review: {review}", ""] for c in checks: status = c.get("status", "REVIEW") lines.append(f"- [{c.get('id', '')}] {c.get('title', '')} — Status: {status}") @@ -1324,7 +1734,7 @@ def _chunk_report_by_section(report_data: dict) -> list: documents.append({ "content": "\n".join(lines), "source": f"CIS Report - {tenancy} - {generated_at}", - "metadata": f"section: {section_name}, total: {len(checks)}, passed: {passed}, failed: {failed}, review: {review}" + "metadata": f"tenancy: {tenancy}, section: {section_name}, total: {len(checks)}, passed: {passed}, failed: {failed}, review: {review}" }) return documents @@ -1358,6 +1768,23 @@ def _get_adb_and_genai(vid: str): if not gc: raise HTTPException(400, "Linked GenAI config not found") return cfg, dict(gc) +@app.get("/api/embeddings/preview/{rid}") +async def preview_report_chunks(rid: str, u=Depends(current_user)): + """Preview the chunks that will be generated from a CIS report before embedding.""" + with db() as c: + r = c.execute("SELECT report_data,tenancy_name FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone() + if not r: raise HTTPException(404, "Report not found or not completed") + report_data = r["report_data"] + if isinstance(report_data, str): + try: report_data = json.loads(report_data) + except: raise HTTPException(400, "Invalid report data") + documents = _chunk_report_by_section(report_data) + return {"tenancy": report_data.get("tenancy", "unknown"), + "regions": report_data.get("regions", []), + "compartments": report_data.get("compartments", []), + "total_chunks": len(documents), + "chunks": documents} + @app.post("/api/embeddings/report/{rid}") async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))): vid = req.get("adb_config_id") @@ -1372,12 +1799,13 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi documents = _chunk_report_by_section(report_data) if not documents: raise HTTPException(400, "No sections found in report") cfg, gc = _get_adb_and_genai(vid) - bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"]) + target_table = req.get("table_name") or None + bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"], table_name=target_table) _audit(u["id"], u["username"], "embed_report", rid, f"{len(documents)} sections") return {"ok": True, "message": f"Embedding de {len(documents)} seções iniciado", "sections": len(documents)} @app.post("/api/embeddings/upload") -async def embed_upload(adb_config_id: str = Form(...), file: UploadFile = File(...), bg: BackgroundTasks = None, u=Depends(require("admin","user"))): +async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""), file: UploadFile = File(...), bg: BackgroundTasks = None, u=Depends(require("admin","user"))): if not file.filename.lower().endswith(".txt"): raise HTTPException(400, "Only .txt files are supported") content = (await file.read()).decode("utf-8", errors="replace") @@ -1385,19 +1813,20 @@ async def embed_upload(adb_config_id: str = Form(...), file: UploadFile = File(. documents = _chunk_text_file(content, file.filename) if not documents: raise HTTPException(400, "No content chunks found") cfg, gc = _get_adb_and_genai(adb_config_id) - bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"]) + target_table = table_name.strip() or None + bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"], table_name=target_table) _audit(u["id"], u["username"], "embed_upload", file.filename, f"{len(documents)} chunks") return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "filename": file.filename} @app.get("/api/embeddings/{vid}/list") -async def list_embeddings(vid: str, limit: int = Query(50), offset: int = Query(0), u=Depends(current_user)): +async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Query(50), offset: int = Query(0), u=Depends(current_user)): with db() as c: cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() if not cfg: raise HTTPException(404) try: conn = _get_adb_connection(dict(cfg)) cur = conn.cursor() - table_name = cfg["table_name"] or "CIS_EMBEDDINGS" + table_name = table_name.strip() or cfg["table_name"] or "CIS_EMBEDDINGS" cur.execute(f"SELECT COUNT(*) FROM {table_name}") total = cur.fetchone()[0] cur.execute(f""" @@ -1415,14 +1844,14 @@ async def list_embeddings(vid: str, limit: int = Query(50), offset: int = Query( raise HTTPException(500, f"Erro ao listar embeddings: {str(e)[:500]}") @app.delete("/api/embeddings/{vid}/{doc_id}") -async def delete_embedding(vid: str, doc_id: str, u=Depends(require("admin","user"))): +async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u=Depends(require("admin","user"))): with db() as c: cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() if not cfg: raise HTTPException(404) try: conn = _get_adb_connection(dict(cfg)) cur = conn.cursor() - table_name = cfg["table_name"] or "CIS_EMBEDDINGS" + table_name = table_name.strip() or cfg["table_name"] or "CIS_EMBEDDINGS" cur.execute(f"DELETE FROM {table_name} WHERE ID = :1", [doc_id]) conn.commit() cur.close(); conn.close() @@ -1447,7 +1876,7 @@ async def _exec_report(rid, cfg, regions, mcp_server_id): rdir = REPORTS / rid; rdir.mkdir(parents=True, exist_ok=True) config_path = str(OCI_DIR / cfg["id"] / "config") try: - cmd = ["python3", "/app/cis_runner.py", "--config", config_path, + cmd = ["python3", "-u", "/app/cis_runner.py", "--config", config_path, "--output", str(rdir), "--tenancy-name", cfg["tenancy_name"]] if regions: cmd += ["--regions", ",".join(regions)] if mcp_server_id: @@ -1461,18 +1890,42 @@ async def _exec_report(rid, cfg, regions, mcp_server_id): cmd += ["--adb-dsn", adb_cfg["dsn"], "--adb-user", adb_cfg["username"]] if adb_cfg.get("wallet_dir"): cmd += ["--adb-wallet", adb_cfg["wallet_dir"]] proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) - stdout, stderr = await proc.communicate() + _running_reports[rid] = proc + # Read stdout line by line for real-time progress + progress_lines = [] + try: + while True: + line = await proc.stdout.readline() + if not line: + break + text = line.decode(errors="replace").strip() + if text: + progress_lines.append(text) + with db() as c: + c.execute("UPDATE reports SET progress=? WHERE id=?", + ("\n".join(progress_lines[-30:]), rid)) + await proc.wait() + finally: + _running_reports.pop(rid, None) + stderr_data = await proc.stderr.read() jp = rdir / "report.json"; hp = rdir / "report.html" + # Check if cancelled + with db() as c: + cur_status = c.execute("SELECT status FROM reports WHERE id=?", (rid,)).fetchone() + if cur_status and cur_status["status"] == "cancelled": + return # Already marked as cancelled by the cancel endpoint with db() as c: if proc.returncode == 0 and jp.exists(): - c.execute("UPDATE reports SET status='completed',report_data=?,html_path=?,json_path=?,completed_at=datetime('now') WHERE id=?", - (jp.read_text()[:500_000], str(hp) if hp.exists() else None, str(jp), rid)) + c.execute("UPDATE reports SET status='completed',progress=?,report_data=?,html_path=?,json_path=?,completed_at=datetime('now') WHERE id=?", + ("\n".join(progress_lines), jp.read_text()[:500_000], str(hp) if hp.exists() else None, str(jp), rid)) _config_log("oci", cfg["id"], cfg["tenancy_name"], "success", "report", f"Relatório concluído: {rid}") else: - err = (stderr.decode() if stderr else "Unknown")[:2000] - c.execute("UPDATE reports SET status='failed',error_msg=?,completed_at=datetime('now') WHERE id=?", (err, rid)) + err = (stderr_data.decode(errors="replace") if stderr_data else "Unknown")[:2000] + c.execute("UPDATE reports SET status='failed',progress=?,error_msg=?,completed_at=datetime('now') WHERE id=?", + ("\n".join(progress_lines), err, rid)) _config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", err) except Exception as e: + _running_reports.pop(rid, None) with db() as c: c.execute("UPDATE reports SET status='failed',error_msg=?,completed_at=datetime('now') WHERE id=?", (str(e)[:2000], rid)) _config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", str(e)[:2000]) @@ -1480,11 +1933,41 @@ async def _exec_report(rid, cfg, regions, mcp_server_id): @app.get("/api/reports") async def list_reports(u=Depends(current_user)): with db() as c: - q = "SELECT id,user_id,tenancy_name,status,mcp_server_id,created_at,completed_at,error_msg FROM reports" + q = "SELECT id,user_id,tenancy_name,status,progress,mcp_server_id,created_at,completed_at,error_msg FROM reports" rows = c.execute(q+" ORDER BY created_at DESC").fetchall() if u["role"]=="admin" \ else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall() return [dict(r) for r in rows] +@app.get("/api/reports/{rid}/progress") +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) + return dict(r) + +@app.post("/api/reports/{rid}/cancel") +async def cancel_report(rid: str, u=Depends(require("admin", "user"))): + with db() as c: + r = c.execute("SELECT status FROM reports WHERE id=?", (rid,)).fetchone() + if not r: raise HTTPException(404) + if r["status"] != "running": + raise HTTPException(400, "Relatório não está em execução") + proc = _running_reports.get(rid) + if proc: + try: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=5) + except asyncio.TimeoutError: + proc.kill() + except ProcessLookupError: + pass + _running_reports.pop(rid, None) + with db() as c: + c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelado pelo usuário',completed_at=datetime('now') WHERE id=?", (rid,)) + _audit(u["id"], u["username"], "cancel_report", rid) + return {"ok": True, "status": "cancelled"} + @app.get("/api/reports/{rid}") async def get_report(rid, u=Depends(current_user)): with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone() @@ -1538,7 +2021,7 @@ async def chat(msg: ChatMsg, u=Depends(current_user)): if not oci_row: raise HTTPException(400, "OCI config not found") region = msg.genai_region or oci_row["region"] - compartment = msg.compartment_id or oci_row.get("compartment_id") or "" + compartment = _safe_dec(oci_row["compartment_id"]) if oci_row["compartment_id"] else "" if not compartment: raise HTTPException(400, "compartment_id required") genai_cfg = { @@ -1565,35 +2048,121 @@ async def chat(msg: ChatMsg, u=Depends(current_user)): prev = c.execute("SELECT role,content FROM chat_messages WHERE session_id=? AND role IN ('user','assistant') ORDER BY created_at ASC", (sid,)).fetchall() history = [{"role":r["role"],"content":r["content"]} for r in prev] - # ── RAG: augment with vector context if ADB config is active ── + # ── RAG: augment with vector context from ALL active ADB configs ── rag_context = "" - adb_cfg = _get_active_adb_config(u["id"]) - if adb_cfg: - try: - with db() as c: - emb_genai = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone() - if emb_genai: - emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0") - query_embedding = _embed_text(msg.message, dict(emb_genai), emb_model) - documents = _vector_search(adb_cfg, query_embedding, top_k=5) - if documents: - rag_context = _build_rag_context(documents) - log.info(f"RAG: Retrieved {len(documents)} documents for query") - except Exception as e: - log.warning(f"RAG retrieval failed (non-fatal): {e}") + adb_cfgs = _get_active_adb_configs(u["id"]) + if adb_cfgs: + all_documents = [] + for adb_cfg in adb_cfgs: + try: + with db() as c: + emb_genai = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone() + if emb_genai: + emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0") + query_embedding = _embed_text(msg.message, dict(emb_genai), emb_model) + tables = _get_tables_for_config(adb_cfg["id"], active_only=True) + if not tables: + tables = [{"table_name": adb_cfg.get("table_name", "CIS_EMBEDDINGS")}] + for tbl in tables: + try: + documents = _vector_search(adb_cfg, query_embedding, top_k=5, table_name=tbl["table_name"]) + if documents: + for doc in documents: + doc["source"] = f"{doc.get('source', 'unknown')} [{tbl['table_name']}]" + all_documents.extend(documents) + log.info(f"RAG: Retrieved {len(documents)} docs from {tbl['table_name']}") + except Exception as te: + log.warning(f"RAG search failed for table {tbl['table_name']}: {te}") + except Exception as e: + log.warning(f"RAG retrieval failed for {adb_cfg.get('config_name','?')} (non-fatal): {e}") + if all_documents: + all_documents.sort(key=lambda d: d["distance"]) + rag_context = _build_rag_context(all_documents[:10]) - augmented_message = RAG_SYSTEM_PROMPT.format(context=rag_context, question=msg.message) if rag_context else msg.message - resp = _call_genai(dict(genai_cfg), augmented_message, history[:-1] if len(history) > 1 else None) + cfg_dict = dict(genai_cfg) + # Global system prompt from system_prompts table + with db() as c: + sp_row = c.execute("SELECT content FROM system_prompts WHERE agent='chat' AND is_active=1 LIMIT 1").fetchone() + global_prompt = sp_row["content"] if sp_row and sp_row["content"] else "" + # If RAG context found, wrap user message with context + if rag_context: + augmented_message = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=msg.message) + cfg_dict["system_prompt"] = global_prompt or RAG_DEFAULT_SYSTEM_PROMPT + else: + augmented_message = msg.message + if global_prompt: + cfg_dict["system_prompt"] = global_prompt + # Collect MCP tools if enabled + mcp_tools = [] + tool_defs = None + if msg.use_tools: + mcp_tools = _get_active_mcp_tools(u["id"]) + if mcp_tools: + tool_defs = [t["tool"] for t in mcp_tools] + log.info(f"Chat with {len(tool_defs)} MCP tools available") + + hist = history[:-1] if len(history) > 1 else None + resp_text, tool_calls = _call_genai(cfg_dict, augmented_message, hist, tools=tool_defs) + + # Tool use loop (max 5 iterations) + all_tool_results = [] + iterations = 0 + api_format = GENAI_MODELS.get(genai_cfg.get("model_id", ""), {}).get("api_format", "GENERIC") + while tool_calls and iterations < 5: + iterations += 1 + log.info(f"Tool use iteration {iterations}: {len(tool_calls)} tool call(s)") + iteration_results = [] + for tc in tool_calls: + mcp_match = next((m for m in mcp_tools if m["tool"]["name"] == tc["name"]), None) + if mcp_match: + try: + result = await _execute_mcp_tool(mcp_match["server"], tc["name"], tc["arguments"]) + log.info(f"Tool {tc['name']} executed successfully ({len(result)} chars)") + except Exception as te: + result = f"Erro ao executar tool {tc['name']}: {str(te)[:300]}" + log.warning(f"Tool {tc['name']} failed: {te}") + else: + result = f"Tool {tc['name']} não encontrada nos MCP servers ativos" + iteration_results.append({"tool_call_id": tc["id"], "name": tc["name"], "content": result}) + all_tool_results.extend(iteration_results) + + # Build tool results in the appropriate format and call again + if api_format == "COHERE": + import oci + cohere_results = [] + for tr in iteration_results: + tc_obj = oci.generative_ai_inference.models.CohereToolCall() + tc_obj.name = tr["name"] + tc_obj.parameters = {} + tr_obj = oci.generative_ai_inference.models.CohereToolResult() + tr_obj.call = tc_obj + tr_obj.outputs = [{"result": tr["content"]}] + cohere_results.append(tr_obj) + resp_text, tool_calls = _call_genai( + cfg_dict, augmented_message, hist, tools=tool_defs, + tool_results_cohere=cohere_results) + else: + generic_results = [{"tool_call_id": tr["tool_call_id"], "content": tr["content"]} for tr in iteration_results] + resp_text, tool_calls = _call_genai( + cfg_dict, augmented_message, hist, tools=tool_defs, + tool_results_generic=generic_results) + + resp = resp_text except Exception as e: resp = f"❌ Erro GenAI: {str(e)[:400]}" + all_tool_results = [] else: resp = _agent_respond(msg.message, u) + all_tool_results = [] mid = genai_cfg["model_id"] if genai_cfg else None with db() as c: c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id) VALUES (?,?,?,?,?,?)", (str(uuid.uuid4()), sid, u["id"], "assistant", resp, mid)) - return {"session_id": sid, "response": resp, "model_id": mid} + result = {"session_id": sid, "response": resp, "model_id": mid} + if all_tool_results: + result["tools_used"] = [{"name": tr["name"], "result_preview": tr["content"][:200]} for tr in all_tool_results] + return result def _agent_respond(msg, user): m = msg.lower().strip() @@ -1682,6 +2251,67 @@ async def clear_config_logs( return {"ok": True} # ── Health ──────────────────────────────────────────────────────────────────── +# ── App Settings ────────────────────────────────────────────────────────────── +@app.get("/api/settings/{key}") +async def get_setting(key: str, u=Depends(current_user)): + with db() as c: + row = c.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone() + return {"key": key, "value": row["value"] if row else ""} + +@app.put("/api/settings/{key}") +async def put_setting(key: str, body: dict, u=Depends(require("admin"))): + value = body.get("value", "") + with db() as c: + c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (key, value)) + return {"key": key, "value": value} + +# ── System Prompts ──────────────────────────────────────────────────────────── +@app.get("/api/prompts/{agent}") +async def list_prompts(agent: str, u=Depends(current_user)): + with db() as c: + rows = c.execute("SELECT * FROM system_prompts WHERE agent=? ORDER BY is_active DESC, created_at DESC", (agent,)).fetchall() + return [dict(r) for r in rows] + +@app.post("/api/prompts") +async def save_prompt(body: dict, u=Depends(require("admin"))): + agent = body.get("agent", "chat") + with db() as c: + count = c.execute("SELECT COUNT(*) FROM system_prompts WHERE agent=?", (agent,)).fetchone()[0] + if count >= 10: + raise HTTPException(400, "Limite de 10 prompts atingido. Exclua um antes de criar outro.") + pid = str(uuid.uuid4()) + name = body.get("name", "Sem nome") + content = body.get("content", "") + is_active = body.get("is_active", False) + with db() as c: + if is_active: + c.execute("UPDATE system_prompts SET is_active=0 WHERE agent=?", (agent,)) + c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)", + (pid, name, agent, content, int(is_active))) + return {"id": pid} + +@app.put("/api/prompts/{pid}") +async def update_prompt(pid: str, body: dict, u=Depends(require("admin"))): + with db() as c: + existing = c.execute("SELECT * FROM system_prompts WHERE id=?", (pid,)).fetchone() + if not existing: + raise HTTPException(404) + name = body.get("name", existing["name"]) + content = body.get("content", existing["content"]) + is_active = body.get("is_active", existing["is_active"]) + with db() as c: + if is_active: + c.execute("UPDATE system_prompts SET is_active=0 WHERE agent=?", (existing["agent"],)) + c.execute("UPDATE system_prompts SET name=?,content=?,is_active=? WHERE id=?", + (name, content, int(is_active), pid)) + return {"id": pid} + +@app.delete("/api/prompts/{pid}") +async def delete_prompt(pid: str, u=Depends(require("admin"))): + with db() as c: + c.execute("DELETE FROM system_prompts WHERE id=?", (pid,)) + return {"ok": True} + @app.get("/api/health") async def health(): return {"status":"ok","ts":datetime.utcnow().isoformat(),"version":VERSION} @@ -1689,6 +2319,11 @@ async def health(): @app.on_event("startup") async def startup(): init_db() + # Mark orphaned "running" reports as failed (e.g. after container restart) + with db() as c: + orphaned = c.execute("UPDATE reports SET status='failed', error_msg='Interrompido: container reiniciado' WHERE status='running'").rowcount + if orphaned: + log.warning(f"Marked {orphaned} orphaned running report(s) as failed") log.info(f"OCI CIS AI Agent v{VERSION} API started") if __name__ == "__main__": diff --git a/backend/cis_runner.py b/backend/cis_runner.py index 64c9256..6e5cf9f 100644 --- a/backend/cis_runner.py +++ b/backend/cis_runner.py @@ -1,70 +1,1242 @@ #!/usr/bin/env python3 """ -CIS Runner Stub — Generates placeholder reports. -Replace this with the actual MCP server integration later. +CIS OCI Foundations Benchmark 3.0 — Compliance Checker -Usage: python3 cis_runner.py --config /path/to/oci/config --output /path/to/dir --tenancy-name mytenancy [--regions r1,r2] +Performs all 48 CIS checks against an OCI tenancy (no OBP). +Generates report.json + report.html. + +Usage: + python3 cis_runner.py --config /path/to/oci/config --output /path/to/dir \ + --tenancy-name mytenancy [--regions r1,r2] [--level 1] """ -import argparse, json, datetime, sys +import argparse, json, datetime, sys, logging, time, re from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +import oci + +log = logging.getLogger("cis_runner") +logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(name)s: %(message)s") + +# ─── Check catalog ──────────────────────────────────────────────────────────── CHECKS = { - "1.1": {"id":"IAM-1","section":"Identity and Access Management","title":"Ensure service level admins are created to manage resources of particular service"}, - "1.2": {"id":"IAM-2","section":"Identity and Access Management","title":"Ensure permissions on all resources are given only to the tenancy administrator group"}, - "1.3": {"id":"IAM-3","section":"Identity and Access Management","title":"Ensure IAM administrators cannot update tenancy Administrators group"}, - "1.4": {"id":"IAM-4","section":"Identity and Access Management","title":"Ensure IAM password policy requires minimum length of 14 or greater"}, - "1.5": {"id":"IAM-5","section":"Identity and Access Management","title":"Ensure IAM password policy expires passwords within 365 days"}, - "1.6": {"id":"IAM-6","section":"Identity and Access Management","title":"Ensure IAM password policy prevents password reuse"}, - "1.7": {"id":"IAM-7","section":"Identity and Access Management","title":"Ensure MFA is enabled for all users with a console password"}, - "1.8": {"id":"IAM-8","section":"Identity and Access Management","title":"Ensure user API keys rotate within 90 days"}, - "1.9": {"id":"IAM-9","section":"Identity and Access Management","title":"Ensure user customer secret keys rotate within 90 days"}, - "1.10": {"id":"IAM-10","section":"Identity and Access Management","title":"Ensure user auth tokens rotate within 90 days"}, - "1.11": {"id":"IAM-11","section":"Identity and Access Management","title":"Ensure user IAM Database Passwords rotate within 90 days"}, - "1.12": {"id":"IAM-12","section":"Identity and Access Management","title":"Ensure API keys are not created for tenancy administrator users"}, - "1.13": {"id":"IAM-13","section":"Identity and Access Management","title":"Ensure all OCI IAM user accounts have a valid and current email address"}, - "1.14": {"id":"IAM-14","section":"Identity and Access Management","title":"Ensure Instance Principal authentication is used"}, - "1.15": {"id":"IAM-15","section":"Identity and Access Management","title":"Ensure storage service-level admins cannot delete resources they manage"}, - "1.16": {"id":"IAM-16","section":"Identity and Access Management","title":"Ensure OCI IAM credentials unused for 45 days or more are disabled"}, - "1.17": {"id":"IAM-17","section":"Identity and Access Management","title":"Ensure there is only one active API Key per user"}, - "2.1": {"id":"NTW-1","section":"Networking","title":"Ensure no security lists allow ingress from 0.0.0.0/0 to port 22"}, - "2.2": {"id":"NTW-2","section":"Networking","title":"Ensure no security lists allow ingress from 0.0.0.0/0 to port 3389"}, - "2.3": {"id":"NTW-3","section":"Networking","title":"Ensure no network security groups allow ingress from 0.0.0.0/0 to port 22"}, - "2.4": {"id":"NTW-4","section":"Networking","title":"Ensure no network security groups allow ingress from 0.0.0.0/0 to port 3389"}, - "2.5": {"id":"NTW-5","section":"Networking","title":"Ensure the default security list of every VCN restricts all traffic except ICMP"}, - "2.6": {"id":"NTW-6","section":"Networking","title":"Ensure Oracle Integration Cloud (OIC) access is restricted"}, - "2.7": {"id":"NTW-7","section":"Networking","title":"Ensure Oracle Analytics Cloud (OAC) access is restricted or deployed within a VCN"}, - "2.8": {"id":"NTW-8","section":"Networking","title":"Ensure Oracle Autonomous Shared Database access is restricted or deployed within a VCN"}, - "3.1": {"id":"COM-1","section":"Compute","title":"Ensure Compute Instance Legacy Metadata service endpoint is disabled"}, - "3.2": {"id":"COM-2","section":"Compute","title":"Ensure Secure Boot is enabled on Compute Instance"}, - "3.3": {"id":"COM-3","section":"Compute","title":"Ensure In-transit Encryption is enabled on Compute Instance"}, - "4.1": {"id":"LAM-1","section":"Logging and Monitoring","title":"Ensure default tags are used on resources"}, - "4.2": {"id":"LAM-2","section":"Logging and Monitoring","title":"Create at least one notification topic and subscription"}, - "4.3": {"id":"LAM-3","section":"Logging and Monitoring","title":"Ensure a notification is configured for Identity Provider changes"}, - "4.4": {"id":"LAM-4","section":"Logging and Monitoring","title":"Ensure a notification is configured for IdP group mapping changes"}, - "4.5": {"id":"LAM-5","section":"Logging and Monitoring","title":"Ensure a notification is configured for IAM group changes"}, - "4.6": {"id":"LAM-6","section":"Logging and Monitoring","title":"Ensure a notification is configured for IAM policy changes"}, - "4.7": {"id":"LAM-7","section":"Logging and Monitoring","title":"Ensure a notification is configured for user changes"}, - "4.8": {"id":"LAM-8","section":"Logging and Monitoring","title":"Ensure a notification is configured for VCN changes"}, - "4.9": {"id":"LAM-9","section":"Logging and Monitoring","title":"Ensure a notification is configured for changes to route tables"}, - "4.10": {"id":"LAM-10","section":"Logging and Monitoring","title":"Ensure a notification is configured for security list changes"}, - "4.11": {"id":"LAM-11","section":"Logging and Monitoring","title":"Ensure a notification is configured for network security group changes"}, - "4.12": {"id":"LAM-12","section":"Logging and Monitoring","title":"Ensure a notification is configured for changes to network gateways"}, - "4.13": {"id":"LAM-13","section":"Logging and Monitoring","title":"Ensure VCN flow logging is enabled for all subnets"}, - "4.14": {"id":"LAM-14","section":"Logging and Monitoring","title":"Ensure Cloud Guard is enabled in the root compartment"}, - "4.15": {"id":"LAM-15","section":"Logging and Monitoring","title":"Ensure a notification is configured for Cloud Guard problems detected"}, - "4.16": {"id":"LAM-16","section":"Logging and Monitoring","title":"Ensure customer created CMK is rotated at least annually"}, - "4.17": {"id":"LAM-17","section":"Logging and Monitoring","title":"Ensure write level Object Storage logging is enabled for all buckets"}, - "4.18": {"id":"LAM-18","section":"Logging and Monitoring","title":"Ensure a notification is configured for Local OCI User Authentication"}, - "5.1.1":{"id":"STO-1-1","section":"Storage - Object Storage","title":"Ensure no Object Storage buckets are publicly visible"}, - "5.1.2":{"id":"STO-1-2","section":"Storage - Object Storage","title":"Ensure Object Storage Buckets are encrypted with a CMK"}, - "5.1.3":{"id":"STO-1-3","section":"Storage - Object Storage","title":"Ensure Versioning is Enabled for Object Storage Buckets"}, - "5.2.1":{"id":"STO-2-1","section":"Storage - Block Volumes","title":"Ensure Block Volumes are encrypted with Customer Managed Keys"}, - "5.2.2":{"id":"STO-2-2","section":"Storage - Block Volumes","title":"Ensure Boot Volumes are encrypted with Customer Managed Key"}, - "5.3.1":{"id":"STO-3-1","section":"Storage - File Storage Service","title":"Ensure File Storage Systems are encrypted with Customer Managed Keys"}, - "6.1": {"id":"AM-1","section":"Asset Management","title":"Create at least one compartment in your tenancy"}, - "6.2": {"id":"AM-2","section":"Asset Management","title":"Ensure no resources are created in the root compartment"}, + "1.1": {"id":"IAM-1","section":"Identity and Access Management","title":"Ensure service level admins are created to manage resources of particular service","level":1}, + "1.2": {"id":"IAM-2","section":"Identity and Access Management","title":"Ensure permissions on all resources are given only to the tenancy administrator group","level":1}, + "1.3": {"id":"IAM-3","section":"Identity and Access Management","title":"Ensure IAM administrators cannot update tenancy Administrators group","level":1}, + "1.4": {"id":"IAM-4","section":"Identity and Access Management","title":"Ensure IAM password policy requires minimum length of 14 or greater","level":1}, + "1.5": {"id":"IAM-5","section":"Identity and Access Management","title":"Ensure IAM password policy expires passwords within 365 days","level":1}, + "1.6": {"id":"IAM-6","section":"Identity and Access Management","title":"Ensure IAM password policy prevents password reuse","level":1}, + "1.7": {"id":"IAM-7","section":"Identity and Access Management","title":"Ensure MFA is enabled for all users with a console password","level":1}, + "1.8": {"id":"IAM-8","section":"Identity and Access Management","title":"Ensure user API keys rotate within 90 days","level":1}, + "1.9": {"id":"IAM-9","section":"Identity and Access Management","title":"Ensure user customer secret keys rotate within 90 days","level":1}, + "1.10": {"id":"IAM-10","section":"Identity and Access Management","title":"Ensure user auth tokens rotate within 90 days","level":1}, + "1.11": {"id":"IAM-11","section":"Identity and Access Management","title":"Ensure user IAM Database Passwords rotate within 90 days","level":1}, + "1.12": {"id":"IAM-12","section":"Identity and Access Management","title":"Ensure API keys are not created for tenancy administrator users","level":1}, + "1.13": {"id":"IAM-13","section":"Identity and Access Management","title":"Ensure all OCI IAM user accounts have a valid and current email address","level":2}, + "1.14": {"id":"IAM-14","section":"Identity and Access Management","title":"Ensure Instance Principal authentication is used","level":1}, + "1.15": {"id":"IAM-15","section":"Identity and Access Management","title":"Ensure storage service-level admins cannot delete resources they manage","level":1}, + "1.16": {"id":"IAM-16","section":"Identity and Access Management","title":"Ensure OCI IAM credentials unused for 45 days or more are disabled","level":2}, + "1.17": {"id":"IAM-17","section":"Identity and Access Management","title":"Ensure there is only one active API Key per user","level":2}, + "2.1": {"id":"NTW-1","section":"Networking","title":"Ensure no security lists allow ingress from 0.0.0.0/0 to port 22","level":1}, + "2.2": {"id":"NTW-2","section":"Networking","title":"Ensure no security lists allow ingress from 0.0.0.0/0 to port 3389","level":1}, + "2.3": {"id":"NTW-3","section":"Networking","title":"Ensure no network security groups allow ingress from 0.0.0.0/0 to port 22","level":1}, + "2.4": {"id":"NTW-4","section":"Networking","title":"Ensure no network security groups allow ingress from 0.0.0.0/0 to port 3389","level":1}, + "2.5": {"id":"NTW-5","section":"Networking","title":"Ensure the default security list of every VCN restricts all traffic except ICMP","level":1}, + "2.6": {"id":"NTW-6","section":"Networking","title":"Ensure Oracle Integration Cloud (OIC) access is restricted","level":2}, + "2.7": {"id":"NTW-7","section":"Networking","title":"Ensure Oracle Analytics Cloud (OAC) access is restricted or deployed within a VCN","level":2}, + "2.8": {"id":"NTW-8","section":"Networking","title":"Ensure Oracle Autonomous Shared Database access is restricted or deployed within a VCN","level":2}, + "3.1": {"id":"COM-1","section":"Compute","title":"Ensure Compute Instance Legacy Metadata service endpoint is disabled","level":2}, + "3.2": {"id":"COM-2","section":"Compute","title":"Ensure Secure Boot is enabled on Compute Instance","level":2}, + "3.3": {"id":"COM-3","section":"Compute","title":"Ensure In-transit Encryption is enabled on Compute Instance","level":1}, + "4.1": {"id":"LAM-1","section":"Logging and Monitoring","title":"Ensure default tags are used on resources","level":1}, + "4.2": {"id":"LAM-2","section":"Logging and Monitoring","title":"Create at least one notification topic and subscription","level":1}, + "4.3": {"id":"LAM-3","section":"Logging and Monitoring","title":"Ensure a notification is configured for Identity Provider changes","level":1}, + "4.4": {"id":"LAM-4","section":"Logging and Monitoring","title":"Ensure a notification is configured for IdP group mapping changes","level":1}, + "4.5": {"id":"LAM-5","section":"Logging and Monitoring","title":"Ensure a notification is configured for IAM group changes","level":1}, + "4.6": {"id":"LAM-6","section":"Logging and Monitoring","title":"Ensure a notification is configured for IAM policy changes","level":1}, + "4.7": {"id":"LAM-7","section":"Logging and Monitoring","title":"Ensure a notification is configured for user changes","level":1}, + "4.8": {"id":"LAM-8","section":"Logging and Monitoring","title":"Ensure a notification is configured for VCN changes","level":1}, + "4.9": {"id":"LAM-9","section":"Logging and Monitoring","title":"Ensure a notification is configured for changes to route tables","level":1}, + "4.10": {"id":"LAM-10","section":"Logging and Monitoring","title":"Ensure a notification is configured for security list changes","level":1}, + "4.11": {"id":"LAM-11","section":"Logging and Monitoring","title":"Ensure a notification is configured for network security group changes","level":1}, + "4.12": {"id":"LAM-12","section":"Logging and Monitoring","title":"Ensure a notification is configured for changes to network gateways","level":1}, + "4.13": {"id":"LAM-13","section":"Logging and Monitoring","title":"Ensure VCN flow logging is enabled for all subnets","level":2}, + "4.14": {"id":"LAM-14","section":"Logging and Monitoring","title":"Ensure Cloud Guard is enabled in the root compartment","level":1}, + "4.15": {"id":"LAM-15","section":"Logging and Monitoring","title":"Ensure a notification is configured for Cloud Guard problems detected","level":2}, + "4.16": {"id":"LAM-16","section":"Logging and Monitoring","title":"Ensure customer created CMK is rotated at least annually","level":1}, + "4.17": {"id":"LAM-17","section":"Logging and Monitoring","title":"Ensure write level Object Storage logging is enabled for all buckets","level":2}, + "4.18": {"id":"LAM-18","section":"Logging and Monitoring","title":"Ensure a notification is configured for Local OCI User Authentication","level":1}, + "5.1.1":{"id":"STO-1-1","section":"Storage - Object Storage","title":"Ensure no Object Storage buckets are publicly visible","level":1}, + "5.1.2":{"id":"STO-1-2","section":"Storage - Object Storage","title":"Ensure Object Storage Buckets are encrypted with a CMK","level":2}, + "5.1.3":{"id":"STO-1-3","section":"Storage - Object Storage","title":"Ensure Versioning is Enabled for Object Storage Buckets","level":2}, + "5.2.1":{"id":"STO-2-1","section":"Storage - Block Volumes","title":"Ensure Block Volumes are encrypted with Customer Managed Keys","level":2}, + "5.2.2":{"id":"STO-2-2","section":"Storage - Block Volumes","title":"Ensure Boot Volumes are encrypted with Customer Managed Key","level":2}, + "5.3.1":{"id":"STO-3-1","section":"Storage - File Storage Service","title":"Ensure File Storage Systems are encrypted with Customer Managed Keys","level":2}, + "6.1": {"id":"AM-1","section":"Asset Management","title":"Create at least one compartment in your tenancy","level":1}, + "6.2": {"id":"AM-2","section":"Asset Management","title":"Ensure no resources are created in the root compartment","level":1}, } +# Event types required for monitoring checks 4.3-4.12, 4.15, 4.18 +CIS_MONITORING_EVENTS = { + "4.3": [ + "com.oraclecloud.identitycontrolplane.createidentityprovider", + "com.oraclecloud.identitycontrolplane.deleteidentityprovider", + "com.oraclecloud.identitycontrolplane.updateidentityprovider", + ], + "4.4": [ + "com.oraclecloud.identitycontrolplane.createidpgroupmapping", + "com.oraclecloud.identitycontrolplane.deleteidpgroupmapping", + "com.oraclecloud.identitycontrolplane.updateidpgroupmapping", + ], + "4.5": [ + "com.oraclecloud.identitycontrolplane.creategroup", + "com.oraclecloud.identitycontrolplane.deletegroup", + "com.oraclecloud.identitycontrolplane.updategroup", + ], + "4.6": [ + "com.oraclecloud.identitycontrolplane.createpolicy", + "com.oraclecloud.identitycontrolplane.deletepolicy", + "com.oraclecloud.identitycontrolplane.updatepolicy", + ], + "4.7": [ + "com.oraclecloud.identitycontrolplane.createuser", + "com.oraclecloud.identitycontrolplane.deleteuser", + "com.oraclecloud.identitycontrolplane.updateuser", + ], + "4.8": [ + "com.oraclecloud.virtualnetwork.createvcn", + "com.oraclecloud.virtualnetwork.deletevcn", + "com.oraclecloud.virtualnetwork.updatevcn", + ], + "4.9": [ + "com.oraclecloud.virtualnetwork.changeroutetablecompartment", + "com.oraclecloud.virtualnetwork.createroutetable", + "com.oraclecloud.virtualnetwork.deleteroutetable", + "com.oraclecloud.virtualnetwork.updateroutetable", + ], + "4.10": [ + "com.oraclecloud.virtualnetwork.changesecuritylistcompartment", + "com.oraclecloud.virtualnetwork.createsecuritylist", + "com.oraclecloud.virtualnetwork.deletesecuritylist", + "com.oraclecloud.virtualnetwork.updatesecuritylist", + ], + "4.11": [ + "com.oraclecloud.virtualnetwork.changenetworksecuritygroupcompartment", + "com.oraclecloud.virtualnetwork.createnetworksecuritygroup", + "com.oraclecloud.virtualnetwork.deletenetworksecuritygroup", + "com.oraclecloud.virtualnetwork.updatenetworksecuritygroup", + ], + "4.12": [ + "com.oraclecloud.virtualnetwork.createdrg", + "com.oraclecloud.virtualnetwork.deletedrg", + "com.oraclecloud.virtualnetwork.updatedrg", + "com.oraclecloud.virtualnetwork.createinternetgateway", + "com.oraclecloud.virtualnetwork.deleteinternetgateway", + "com.oraclecloud.virtualnetwork.updateinternetgateway", + "com.oraclecloud.virtualnetwork.createlocalpeering gateway", + "com.oraclecloud.virtualnetwork.deletelocalpeering gateway", + "com.oraclecloud.virtualnetwork.updatelocalpeering gateway", + ], + "4.15": [ + "com.oraclecloud.cloudguard.problemdetected", + ], + "4.18": [ + "com.oraclecloud.identitycontrolplane.createpolicy", + "com.oraclecloud.identitycontrolplane.deletepolicy", + "com.oraclecloud.identitycontrolplane.updatepolicy", + "com.oraclecloud.identitycontrolplane.createuser", + "com.oraclecloud.identitycontrolplane.deleteuser", + "com.oraclecloud.identitycontrolplane.updateuser", + "com.oraclecloud.identitycontrolplane.updateauthenticationpolicy", + ], +} + +# ─── CIS Compliance Checker ────────────────────────────────────────────────── +class CISComplianceChecker: + def __init__(self, config_path: str, filter_regions: list[str] | None = None, level: int | None = None): + self.config_path = config_path + self.filter_regions = [r.strip() for r in filter_regions] if filter_regions else None + self.level = level # None = all levels + self.config = oci.config.from_file(config_path, "DEFAULT") + oci.config.validate_config(self.config) + self.tenancy_id = self.config["tenancy"] + self.home_region = None + self.regions = [] + self.compartments = [] + # Collected data stores + self._policies = [] + self._users = [] + self._groups = [] + self._admin_group_id = None + self._admin_user_ids = set() + self._auth_policy = None + self._dynamic_groups = [] + self._tag_defaults = [] + self._user_api_keys = {} + self._user_secret_keys = {} + self._user_auth_tokens = {} + self._user_db_credentials = {} + self._user_mfa = {} + self._user_memberships = {} + # Regional data (keyed by region) + self._security_lists = [] + self._nsgs = [] + self._nsg_rules = {} + self._vcns = [] + self._subnets = [] + self._instances = [] + self._event_rules = [] + self._topics = [] + self._subscriptions = [] + self._log_groups = [] + self._logs = [] + self._cloud_guard_status = None + self._vaults = [] + self._keys = [] + self._buckets = [] + self._block_volumes = [] + self._boot_volumes = [] + self._file_systems = [] + self._oic_instances = [] + self._oac_instances = [] + self._adbs = [] + self._root_resources_count = 0 + # Results + self.findings = {} + for cid, ck in CHECKS.items(): + self.findings[cid] = {**ck, "status": "REVIEW", "findings": [], "total": []} + + # ── Helpers ──────────────────────────────────────────────────────────────── + def _safe(self, fn, *args, default=None, **kwargs): + """Call OCI SDK function with error handling and retry on 429.""" + for attempt in range(3): + try: + return fn(*args, **kwargs) + except oci.exceptions.ServiceError as e: + if e.status == 429: + wait = 2 ** attempt + log.warning(f"Rate limited, retrying in {wait}s...") + time.sleep(wait) + continue + if e.status in (401, 403): + log.warning(f"Auth/permission error ({e.status}): {e.message[:200]}") + return default + if e.status == 404: + return default + log.warning(f"OCI API error {e.status}: {e.message[:200]}") + return default + except oci.exceptions.ClientError as e: + log.warning(f"Client error: {e}") + return default + except Exception as e: + log.warning(f"Unexpected error: {e}") + return default + return default + + def _paginate(self, fn, **kwargs): + """Paginate through all results of an OCI list operation.""" + results = [] + resp = self._safe(fn, **kwargs) + if resp is None: + return results + results.extend(resp.data) + while resp.has_next_page: + resp = self._safe(fn, page=resp.next_page, **kwargs) + if resp is None: + break + results.extend(resp.data) + return results + + def _make_config(self, region: str) -> dict: + """Return OCI config dict targeting a specific region.""" + cfg = dict(self.config) + cfg["region"] = region + return cfg + + def _set_status(self, cid: str, passed: bool): + """Set check status to PASS or FAIL.""" + self.findings[cid]["status"] = "PASS" if passed else "FAIL" + + def _add_finding(self, cid: str, desc: str): + """Add a non-compliant resource finding.""" + self.findings[cid]["findings"].append(desc) + + def _add_total(self, cid: str, desc: str): + """Add an evaluated resource.""" + self.findings[cid]["total"].append(desc) + + def _should_skip(self, cid: str) -> bool: + """Check if a check should be skipped based on level filter.""" + if self.level is None: + return False + return CHECKS[cid]["level"] > self.level + + # ── Region Discovery ────────────────────────────────────────────────────── + def _discover_regions(self): + identity = oci.identity.IdentityClient(self.config) + tenancy = self._safe(identity.get_tenancy, self.tenancy_id) + if tenancy: + self.home_region_key = tenancy.data.home_region_key + subs = self._safe(identity.list_region_subscriptions, self.tenancy_id) + if not subs: + log.error("Cannot discover regions — using config region only") + self.regions = [self.config["region"]] + self.home_region = self.config["region"] + return + for r in subs.data: + if r.is_home_region: + self.home_region = r.region_name + if self.filter_regions: + if r.region_name in self.filter_regions: + self.regions.append(r.region_name) + else: + if r.status == "READY": + self.regions.append(r.region_name) + if not self.home_region: + self.home_region = self.config["region"] + if not self.regions: + self.regions = [self.home_region] + log.info(f"Home region: {self.home_region}") + log.info(f"Regions to scan: {', '.join(self.regions)}") + + # ── Compartment Discovery ───────────────────────────────────────────────── + def _discover_compartments(self): + identity = oci.identity.IdentityClient(self._make_config(self.home_region)) + comps = self._paginate(identity.list_compartments, + compartment_id=self.tenancy_id, + compartment_id_in_subtree=True, + access_level="ACCESSIBLE", + lifecycle_state="ACTIVE") + self.compartments = comps + # Include root compartment + root = type('obj', (object,), {'id': self.tenancy_id, 'name': 'root', 'lifecycle_state': 'ACTIVE'})() + self.compartments.insert(0, root) + log.info(f"Discovered {len(self.compartments)} compartments") + + def _comp_ids(self): + return [c.id for c in self.compartments] + + # ── IAM Data Collection ─────────────────────────────────────────────────── + def _collect_iam(self): + log.info("Collecting IAM data...") + identity = oci.identity.IdentityClient(self._make_config(self.home_region)) + # Policies across all compartments + for cid in self._comp_ids(): + pols = self._paginate(identity.list_policies, compartment_id=cid) + self._policies.extend(pols) + # Users + self._users = self._paginate(identity.list_users, compartment_id=self.tenancy_id) + # Groups + self._groups = self._paginate(identity.list_groups, compartment_id=self.tenancy_id) + # Find Administrators group + for g in self._groups: + if g.name == "Administrators": + self._admin_group_id = g.id + break + # Group memberships and user credentials + now = datetime.datetime.now(datetime.timezone.utc) + for u in self._users: + uid = u.id + self._user_api_keys[uid] = self._safe(identity.list_api_keys, uid, default=type('R', (), {'data': []})()).data + self._user_secret_keys[uid] = self._safe(identity.list_customer_secret_keys, uid, default=type('R', (), {'data': []})()).data + self._user_auth_tokens[uid] = self._safe(identity.list_auth_tokens, uid, default=type('R', (), {'data': []})()).data + try: + self._user_db_credentials[uid] = self._safe(identity.list_db_credentials, uid, default=type('R', (), {'data': []})()).data + except Exception: + self._user_db_credentials[uid] = [] + mfa_resp = self._safe(identity.list_mfa_totp_devices, uid, default=type('R', (), {'data': []})()) + self._user_mfa[uid] = mfa_resp.data if mfa_resp else [] + memberships = self._safe(identity.list_user_group_memberships, compartment_id=self.tenancy_id, user_id=uid, default=type('R', (), {'data': []})()) + self._user_memberships[uid] = memberships.data if memberships else [] + # Determine admin users + if self._admin_group_id: + for uid, memberships in self._user_memberships.items(): + for m in memberships: + if m.group_id == self._admin_group_id: + self._admin_user_ids.add(uid) + # Password policy + auth_policy = self._safe(identity.get_authentication_policy, self.tenancy_id) + self._auth_policy = auth_policy.data if auth_policy else None + # Dynamic groups + self._dynamic_groups = self._paginate(identity.list_dynamic_groups, compartment_id=self.tenancy_id) + # Tag defaults + self._tag_defaults = self._paginate(identity.list_tag_defaults, compartment_id=self.tenancy_id) + log.info(f"IAM: {len(self._policies)} policies, {len(self._users)} users, {len(self._groups)} groups") + + # ── Network Data Collection (per region) ────────────────────────────────── + def _collect_network(self, region: str): + log.info(f"Collecting network data for {region}...") + cfg = self._make_config(region) + vn = oci.core.VirtualNetworkClient(cfg) + sls, nsgs, vcns, subnets = [], [], [], [] + for cid in self._comp_ids(): + vcns.extend(self._paginate(vn.list_vcns, compartment_id=cid)) + sls.extend(self._paginate(vn.list_security_lists, compartment_id=cid)) + nsgs.extend(self._paginate(vn.list_network_security_groups, compartment_id=cid)) + subnets.extend(self._paginate(vn.list_subnets, compartment_id=cid)) + nsg_rules = {} + for nsg in nsgs: + rules = self._paginate(vn.list_network_security_group_security_rules, network_security_group_id=nsg.id) + nsg_rules[nsg.id] = rules + # OIC, OAC, ADB for checks 2.6-2.8 + oic_instances, oac_instances, adbs = [], [], [] + try: + oic_client = oci.integration.IntegrationInstanceClient(cfg) + for cid in self._comp_ids(): + oic_instances.extend(self._paginate(oic_client.list_integration_instances, compartment_id=cid)) + except Exception as e: + log.warning(f"OIC collection skipped: {e}") + try: + oac_client = oci.analytics.AnalyticsClient(cfg) + for cid in self._comp_ids(): + oac_instances.extend(self._paginate(oac_client.list_analytics_instances, compartment_id=cid)) + except Exception as e: + log.warning(f"OAC collection skipped: {e}") + try: + db_client = oci.database.DatabaseClient(cfg) + for cid in self._comp_ids(): + adbs.extend(self._paginate(db_client.list_autonomous_databases, compartment_id=cid)) + except Exception as e: + log.warning(f"ADB collection skipped: {e}") + return { + "security_lists": sls, "nsgs": nsgs, "nsg_rules": nsg_rules, + "vcns": vcns, "subnets": subnets, + "oic": oic_instances, "oac": oac_instances, "adbs": adbs, + } + + # ── Compute Data Collection (per region) ────────────────────────────────── + def _collect_compute(self, region: str): + log.info(f"Collecting compute data for {region}...") + cfg = self._make_config(region) + compute = oci.core.ComputeClient(cfg) + instances = [] + for cid in self._comp_ids(): + insts = self._paginate(compute.list_instances, compartment_id=cid) + instances.extend([i for i in insts if i.lifecycle_state == "RUNNING"]) + return instances + + # ── Monitoring Data Collection (per region) ─────────────────────────────── + def _collect_monitoring(self, region: str): + log.info(f"Collecting monitoring data for {region}...") + cfg = self._make_config(region) + events_client = oci.events.EventsClient(cfg) + ons_cp = oci.ons.NotificationControlPlaneClient(cfg) + ons_dp = oci.ons.NotificationDataPlaneClient(cfg) + logging_client = oci.logging.LoggingManagementClient(cfg) + rules, topics, subscriptions, log_groups_all, logs_all = [], [], [], [], [] + for cid in self._comp_ids(): + rules.extend(self._paginate(events_client.list_rules, compartment_id=cid)) + topics.extend(self._paginate(ons_cp.list_topics, compartment_id=cid)) + subscriptions.extend(self._paginate(ons_dp.list_subscriptions, compartment_id=cid)) + lgs = self._paginate(logging_client.list_log_groups, compartment_id=cid) + log_groups_all.extend(lgs) + for lg in lgs: + log_list = self._paginate(logging_client.list_logs, log_group_id=lg.id) + logs_all.extend(log_list) + # Vaults and keys + vaults, keys = [], [] + try: + kms_vault_client = oci.key_management.KmsVaultClient(cfg) + for cid in self._comp_ids(): + vs = self._paginate(kms_vault_client.list_vaults, compartment_id=cid) + for v in vs: + if v.lifecycle_state != "ACTIVE": + continue + vaults.append(v) + try: + kms_mgmt = oci.key_management.KmsManagementClient(cfg, service_endpoint=v.management_endpoint) + ks = self._paginate(kms_mgmt.list_keys, compartment_id=cid) + for k in ks: + if k.lifecycle_state == "ENABLED": + key_detail = self._safe(kms_mgmt.get_key, k.id) + if key_detail: + keys.append(key_detail.data) + except Exception as e: + log.warning(f"Key collection for vault {v.id}: {e}") + except Exception as e: + log.warning(f"Vault collection skipped: {e}") + return { + "event_rules": rules, "topics": topics, "subscriptions": subscriptions, + "log_groups": log_groups_all, "logs": logs_all, + "vaults": vaults, "keys": keys, + } + + # ── Storage Data Collection (per region) ────────────────────────────────── + def _collect_storage(self, region: str): + log.info(f"Collecting storage data for {region}...") + cfg = self._make_config(region) + os_client = oci.object_storage.ObjectStorageClient(cfg) + block_client = oci.core.BlockstorageClient(cfg) + buckets, block_volumes, boot_volumes, file_systems = [], [], [], [] + ns = self._safe(os_client.get_namespace) + namespace = ns.data if ns else None + if namespace: + for cid in self._comp_ids(): + bucket_list = self._paginate(os_client.list_buckets, namespace_name=namespace, compartment_id=cid) + for b in bucket_list: + detail = self._safe(os_client.get_bucket, namespace_name=namespace, bucket_name=b.name, + fields=["approximateCount", "approximateSize", "autoTiering"]) + if detail: + buckets.append(detail.data) + for cid in self._comp_ids(): + block_volumes.extend(self._paginate(block_client.list_volumes, compartment_id=cid)) + # Boot volumes and file systems need availability domains + identity = oci.identity.IdentityClient(cfg) + ads = self._safe(identity.list_availability_domains, compartment_id=self.tenancy_id) + ad_names = [ad.name for ad in (ads.data if ads else [])] + for cid in self._comp_ids(): + for ad in ad_names: + boot_volumes.extend(self._paginate(block_client.list_boot_volumes, + compartment_id=cid, availability_domain=ad)) + try: + fs_client = oci.file_storage.FileStorageClient(cfg) + for cid in self._comp_ids(): + for ad in ad_names: + file_systems.extend(self._paginate(fs_client.list_file_systems, + compartment_id=cid, availability_domain=ad)) + except Exception as e: + log.warning(f"File storage collection skipped: {e}") + return { + "buckets": buckets, "block_volumes": block_volumes, + "boot_volumes": boot_volumes, "file_systems": file_systems, + } + + # ── Cloud Guard (home region only) ──────────────────────────────────────── + def _collect_cloud_guard(self): + log.info("Collecting Cloud Guard status...") + cfg = self._make_config(self.home_region) + try: + cg = oci.cloud_guard.CloudGuardClient(cfg) + resp = self._safe(cg.get_configuration, compartment_id=self.tenancy_id) + self._cloud_guard_status = resp.data.status if resp else None + except Exception as e: + log.warning(f"Cloud Guard collection skipped: {e}") + + # ── Asset / Resource Search (home region) ───────────────────────────────── + def _collect_assets(self): + log.info("Collecting asset data...") + cfg = self._make_config(self.home_region) + try: + search_client = oci.resource_search.ResourceSearchClient(cfg) + query = f"query all resources where compartmentId = '{self.tenancy_id}'" + details = oci.resource_search.models.StructuredSearchDetails(query=query, type="Structured") + resp = self._safe(search_client.search_resources, details) + if resp: + # Exclude resource types that normally live in root + root_only_types = {"Compartment", "TagNamespace", "TagDefault", "Policy", "Group", + "DynamicGroup", "User", "IdentityProvider", "NetworkSource", + "AuthenticationPolicy", "Tenancy"} + non_root = [r for r in resp.data.items if r.resource_type not in root_only_types] + self._root_resources_count = len(non_root) + except Exception as e: + log.warning(f"Resource search skipped: {e}") + + # ═══════════════════════════════════════════════════════════════════════════ + # CIS CHECKS + # ═══════════════════════════════════════════════════════════════════════════ + + # ── IAM Policy Checks: 1.1, 1.2, 1.3, 1.15 ────────────────────────────── + def _check_iam_policies(self): + log.info("Running IAM policy checks (1.1, 1.2, 1.3, 1.15)...") + admin_group_name = "Administrators" + all_statements = [] + for p in self._policies: + if p.lifecycle_state != "ACTIVE": + continue + for stmt in (p.statements or []): + all_statements.append(stmt.lower().strip()) + + # 1.1 — Service-level admins exist + if not self._should_skip("1.1"): + service_keywords = ["virtual-network-family", "object-family", "instance-family", + "volume-family", "database-family", "file-family", "cluster-family"] + has_service_admin = False + for stmt in all_statements: + if "manage" in stmt or "use" in stmt: + for kw in service_keywords: + if kw in stmt and "administrators" not in stmt.split("group")[1] if "group" in stmt else True: + has_service_admin = True + break + self._set_status("1.1", has_service_admin) + if not has_service_admin: + self._add_finding("1.1", "No service-level admin groups found — only tenancy-wide Administrators") + + # 1.2 — Only Administrators has manage all-resources + if not self._should_skip("1.2"): + violating = [] + pattern = re.compile(r"allow\s+group\s+(\S+)\s+to\s+manage\s+all-resources\s+in\s+tenancy") + for stmt in all_statements: + m = pattern.search(stmt) + if m: + group_name = m.group(1).strip("'\"") + if group_name.lower() != admin_group_name.lower(): + violating.append(group_name) + self._set_status("1.2", len(violating) == 0) + for g in violating: + self._add_finding("1.2", f"Group '{g}' has 'manage all-resources in tenancy'") + + # 1.3 — IAM admins cannot update Administrators group + if not self._should_skip("1.3"): + can_modify_admins = False + for stmt in all_statements: + if ("manage" in stmt and ("users" in stmt or "groups" in stmt) and + "tenancy" in stmt and "administrators" not in stmt.split("group")[0] if "group" in stmt else True): + if "where" not in stmt: + can_modify_admins = True + self._set_status("1.3", not can_modify_admins) + if can_modify_admins: + self._add_finding("1.3", "Non-admin groups may modify Administrators group (no where clause restriction)") + + # 1.15 — Storage admins cannot delete resources + if not self._should_skip("1.15"): + can_delete = False + storage_resources = ["object-family", "volume-family", "file-family", + "buckets", "objects", "volumes", "boot-volumes", "file-systems"] + for stmt in all_statements: + if "manage" in stmt: + for sr in storage_resources: + if sr in stmt and "where" not in stmt: + can_delete = True + self._add_finding("1.15", f"Policy allows unrestricted manage on '{sr}' without delete restriction") + self._set_status("1.15", not can_delete) + + # ── Password Policy Checks: 1.4, 1.5, 1.6 ─────────────────────────────── + def _check_password_policies(self): + log.info("Running password policy checks (1.4, 1.5, 1.6)...") + pp = self._auth_policy.password_policy if self._auth_policy else None + if not pp: + for cid in ["1.4", "1.5", "1.6"]: + if not self._should_skip(cid): + self.findings[cid]["status"] = "REVIEW" + self._add_finding(cid, "Could not retrieve authentication policy") + return + + # 1.4 — Min length >= 14 + if not self._should_skip("1.4"): + min_len = getattr(pp, "minimum_password_length", None) + if min_len is None: + min_len = getattr(pp, "minimum_password_length_in_characters", 0) + passed = min_len is not None and min_len >= 14 + self._set_status("1.4", passed) + self._add_total("1.4", f"Minimum password length: {min_len}") + if not passed: + self._add_finding("1.4", f"Password minimum length is {min_len}, should be >= 14") + + # 1.5 — Passwords expire + if not self._should_skip("1.5"): + # OCI uses is_password_expires_in_applicable or password_expire_warning_in_days + pass_expires = getattr(pp, "is_password_expires_in_applicable", None) + if pass_expires is None: + # Check for numeric expiry field + pass_expires = getattr(pp, "password_expiry_in_days", None) is not None + self._set_status("1.5", bool(pass_expires)) + if not pass_expires: + self._add_finding("1.5", "Password expiration is not enabled") + + # 1.6 — Prevent reuse + if not self._should_skip("1.6"): + num_previous = getattr(pp, "num_previous_passwords_to_restrict", 0) or 0 + passed = num_previous > 0 + self._set_status("1.6", passed) + if not passed: + self._add_finding("1.6", "Password reuse prevention is not configured") + + # ── User Checks: 1.7-1.13, 1.16, 1.17 ─────────────────────────────────── + def _check_users(self): + log.info("Running user checks (1.7-1.13, 1.16, 1.17)...") + now = datetime.datetime.now(datetime.timezone.utc) + rotation_days = 90 + inactive_days = 45 + + for u in self._users: + uid = u.id + name = u.name + active = u.lifecycle_state == "ACTIVE" + self._add_total("1.7", name) + + # 1.7 — MFA for console users + if not self._should_skip("1.7") and active: + has_console = getattr(u, "can_use_console_password", None) + if has_console is None: + has_console = getattr(u, 'capabilities', None) + if has_console: + has_console = getattr(has_console, 'can_use_console_password', False) + is_mfa = getattr(u, "is_mfa_activated", False) + if has_console and not is_mfa: + self._add_finding("1.7", f"User '{name}' has console password but MFA not activated") + + # 1.8 — API key rotation + if not self._should_skip("1.8") and active: + for key in self._user_api_keys.get(uid, []): + if getattr(key, 'lifecycle_state', 'ACTIVE') != 'ACTIVE': + continue + self._add_total("1.8", f"{name}/{key.fingerprint}") + created = key.time_created + if created and (now - created).days > rotation_days: + self._add_finding("1.8", f"User '{name}' API key {key.fingerprint} is {(now - created).days} days old") + + # 1.9 — Secret key rotation + if not self._should_skip("1.9") and active: + for key in self._user_secret_keys.get(uid, []): + if getattr(key, 'lifecycle_state', 'ACTIVE') != 'ACTIVE': + continue + self._add_total("1.9", f"{name}/{key.id}") + created = key.time_created + if created and (now - created).days > rotation_days: + self._add_finding("1.9", f"User '{name}' secret key is {(now - created).days} days old") + + # 1.10 — Auth token rotation + if not self._should_skip("1.10") and active: + for tok in self._user_auth_tokens.get(uid, []): + if getattr(tok, 'lifecycle_state', 'ACTIVE') != 'ACTIVE': + continue + self._add_total("1.10", f"{name}/{tok.id}") + created = tok.time_created + if created and (now - created).days > rotation_days: + self._add_finding("1.10", f"User '{name}' auth token is {(now - created).days} days old") + + # 1.11 — DB credentials rotation + if not self._should_skip("1.11") and active: + for cred in self._user_db_credentials.get(uid, []): + if getattr(cred, 'lifecycle_state', 'ACTIVE') != 'ACTIVE': + continue + self._add_total("1.11", f"{name}/{cred.id}") + created = cred.time_created + if created and (now - created).days > rotation_days: + self._add_finding("1.11", f"User '{name}' DB credential is {(now - created).days} days old") + + # 1.12 — No API keys for admin users + if not self._should_skip("1.12") and uid in self._admin_user_ids: + active_keys = [k for k in self._user_api_keys.get(uid, []) + if getattr(k, 'lifecycle_state', 'ACTIVE') == 'ACTIVE'] + self._add_total("1.12", name) + if active_keys: + self._add_finding("1.12", f"Admin user '{name}' has {len(active_keys)} active API key(s)") + + # 1.13 — Valid email + if not self._should_skip("1.13") and active: + email = getattr(u, "email", None) + self._add_total("1.13", name) + if not email or "@" not in email: + self._add_finding("1.13", f"User '{name}' has no valid email address") + + # 1.16 — Inactive credentials disabled + if not self._should_skip("1.16") and active: + last_login = getattr(u, "last_successful_login_time", None) + ref_time = last_login or u.time_created + if ref_time and (now - ref_time).days > inactive_days: + self._add_total("1.16", name) + self._add_finding("1.16", f"User '{name}' inactive for {(now - ref_time).days} days but still ACTIVE") + + # 1.17 — Max 1 active API key + if not self._should_skip("1.17") and active: + active_keys = [k for k in self._user_api_keys.get(uid, []) + if getattr(k, 'lifecycle_state', 'ACTIVE') == 'ACTIVE'] + self._add_total("1.17", name) + if len(active_keys) > 1: + self._add_finding("1.17", f"User '{name}' has {len(active_keys)} active API keys") + + # Set statuses + for cid in ["1.7", "1.8", "1.9", "1.10", "1.11", "1.12", "1.13", "1.16", "1.17"]: + if not self._should_skip(cid): + self._set_status(cid, len(self.findings[cid]["findings"]) == 0) + + # ── Dynamic Groups Check: 1.14 ─────────────────────────────────────────── + def _check_dynamic_groups(self): + log.info("Running dynamic groups check (1.14)...") + if self._should_skip("1.14"): + return + has_instance_principal = False + for dg in self._dynamic_groups: + if dg.lifecycle_state != "ACTIVE": + continue + rules = getattr(dg, "matching_rule", "") or "" + if "instance.compartment.id" in rules.lower() or "instance.id" in rules.lower(): + has_instance_principal = True + break + self._set_status("1.14", has_instance_principal) + self._add_total("1.14", f"{len(self._dynamic_groups)} dynamic groups found") + if not has_instance_principal: + self._add_finding("1.14", "No dynamic group found using Instance Principal matching rules") + + # ── Network Checks: 2.1-2.8 ────────────────────────────────────────────── + def _check_network(self): + log.info("Running network checks (2.1-2.8)...") + + def _port_in_range(port_range, port): + if port_range is None: + return True # All ports + return port_range.min <= port <= port_range.max + + def _is_open_source(source): + return source in ("0.0.0.0/0", "::/0") + + # 2.1, 2.2 — Security Lists + for sl in self._security_lists: + sl_name = f"{sl.display_name} ({sl.id})" + for rule in (sl.ingress_security_rules or []): + if not _is_open_source(getattr(rule, "source", "")): + continue + proto = str(getattr(rule, "protocol", "")) + if proto == "6": # TCP + tcp_opts = getattr(rule, "tcp_options", None) + dst_range = getattr(tcp_opts, "destination_port_range", None) if tcp_opts else None + if not self._should_skip("2.1"): + self._add_total("2.1", sl_name) + if _port_in_range(dst_range, 22): + self._add_finding("2.1", f"SL '{sl.display_name}' allows 0.0.0.0/0 to port 22") + if not self._should_skip("2.2"): + self._add_total("2.2", sl_name) + if _port_in_range(dst_range, 3389): + self._add_finding("2.2", f"SL '{sl.display_name}' allows 0.0.0.0/0 to port 3389") + elif proto == "all": + if not self._should_skip("2.1"): + self._add_finding("2.1", f"SL '{sl.display_name}' allows ALL protocols from 0.0.0.0/0") + if not self._should_skip("2.2"): + self._add_finding("2.2", f"SL '{sl.display_name}' allows ALL protocols from 0.0.0.0/0") + + # 2.3, 2.4 — NSG rules + for nsg in self._nsgs: + nsg_name = f"{nsg.display_name} ({nsg.id})" + for rule in self._nsg_rules.get(nsg.id, []): + if getattr(rule, "direction", "") != "INGRESS": + continue + if not _is_open_source(getattr(rule, "source", "")): + continue + proto = str(getattr(rule, "protocol", "")) + if proto == "6": + tcp_opts = getattr(rule, "tcp_options", None) + dst_range = getattr(tcp_opts, "destination_port_range", None) if tcp_opts else None + if not self._should_skip("2.3"): + self._add_total("2.3", nsg_name) + if _port_in_range(dst_range, 22): + self._add_finding("2.3", f"NSG '{nsg.display_name}' allows 0.0.0.0/0 to port 22") + if not self._should_skip("2.4"): + self._add_total("2.4", nsg_name) + if _port_in_range(dst_range, 3389): + self._add_finding("2.4", f"NSG '{nsg.display_name}' allows 0.0.0.0/0 to port 3389") + elif proto == "all": + if not self._should_skip("2.3"): + self._add_finding("2.3", f"NSG '{nsg.display_name}' allows ALL protocols from 0.0.0.0/0") + if not self._should_skip("2.4"): + self._add_finding("2.4", f"NSG '{nsg.display_name}' allows ALL protocols from 0.0.0.0/0") + + # 2.5 — Default SL restricts all except ICMP + if not self._should_skip("2.5"): + for vcn in self._vcns: + default_sl_id = getattr(vcn, "default_security_list_id", None) + if not default_sl_id: + continue + default_sl = next((sl for sl in self._security_lists if sl.id == default_sl_id), None) + if not default_sl: + continue + self._add_total("2.5", f"{vcn.display_name} default SL") + for rule in (default_sl.ingress_security_rules or []): + source = getattr(rule, "source", "") + proto = str(getattr(rule, "protocol", "")) + if _is_open_source(source) and proto != "1": # 1 = ICMP + self._add_finding("2.5", f"VCN '{vcn.display_name}' default SL allows non-ICMP from 0.0.0.0/0 (proto={proto})") + + # Set statuses for 2.1-2.5 + for cid in ["2.1", "2.2", "2.3", "2.4", "2.5"]: + if not self._should_skip(cid): + self._set_status(cid, len(self.findings[cid]["findings"]) == 0) + + # 2.6 — OIC access restricted + if not self._should_skip("2.6"): + for oic in self._oic_instances: + self._add_total("2.6", oic.display_name) + net_ep = getattr(oic, "network_endpoint_details", None) + if net_ep: + ep_type = getattr(net_ep, "network_endpoint_type", "PUBLIC") + if ep_type == "PUBLIC": + allowlist = getattr(net_ep, "allowlisted_http_ips", None) + if not allowlist: + self._add_finding("2.6", f"OIC '{oic.display_name}' has public access without IP allowlist") + self._set_status("2.6", len(self.findings["2.6"]["findings"]) == 0) + + # 2.7 — OAC access restricted + if not self._should_skip("2.7"): + for oac in self._oac_instances: + self._add_total("2.7", oac.name) + net_ep = getattr(oac, "network_endpoint_details", None) + if net_ep: + ep_type = getattr(net_ep, "network_endpoint_type", "PUBLIC") + if ep_type == "PUBLIC": + allowlist = getattr(net_ep, "whitelisted_ips", None) or getattr(net_ep, "allowlisted_ips", None) + if not allowlist: + self._add_finding("2.7", f"OAC '{oac.name}' has public access without restrictions") + self._set_status("2.7", len(self.findings["2.7"]["findings"]) == 0) + + # 2.8 — ADB access restricted + if not self._should_skip("2.8"): + for adb in self._adbs: + if adb.lifecycle_state not in ("AVAILABLE", "PROVISIONING"): + continue + self._add_total("2.8", adb.display_name) + has_acl = getattr(adb, "is_access_control_enabled", False) + has_subnet = getattr(adb, "subnet_id", None) + wl_ips = getattr(adb, "whitelisted_ips", None) or [] + if not has_subnet and not has_acl and not wl_ips: + self._add_finding("2.8", f"ADB '{adb.display_name}' has unrestricted public access") + self._set_status("2.8", len(self.findings["2.8"]["findings"]) == 0) + + # ── Compute Checks: 3.1-3.3 ────────────────────────────────────────────── + def _check_compute(self): + log.info("Running compute checks (3.1-3.3)...") + for inst in self._instances: + name = f"{inst.display_name} ({inst.id[-12:]})" + + # 3.1 — Legacy IMDS disabled + if not self._should_skip("3.1"): + self._add_total("3.1", name) + inst_opts = getattr(inst, "instance_options", None) + legacy_disabled = getattr(inst_opts, "are_legacy_imds_endpoints_disabled", False) if inst_opts else False + if not legacy_disabled: + self._add_finding("3.1", f"Instance '{inst.display_name}' has legacy IMDS enabled") + + # 3.2 — Secure Boot + if not self._should_skip("3.2"): + self._add_total("3.2", name) + pc = getattr(inst, "platform_config", None) + secure_boot = getattr(pc, "is_secure_boot_enabled", False) if pc else False + if not secure_boot: + self._add_finding("3.2", f"Instance '{inst.display_name}' does not have Secure Boot enabled") + + # 3.3 — In-transit encryption + if not self._should_skip("3.3"): + self._add_total("3.3", name) + lo = getattr(inst, "launch_options", None) + in_transit = getattr(lo, "is_pv_encryption_in_transit_enabled", False) if lo else False + if not in_transit: + self._add_finding("3.3", f"Instance '{inst.display_name}' does not have in-transit encryption") + + for cid in ["3.1", "3.2", "3.3"]: + if not self._should_skip(cid): + self._set_status(cid, len(self.findings[cid]["findings"]) == 0) + + # ── Logging & Monitoring Checks: 4.1-4.18 ──────────────────────────────── + def _check_monitoring(self): + log.info("Running monitoring checks (4.1-4.18)...") + + # 4.1 — Default tags + if not self._should_skip("4.1"): + self._add_total("4.1", f"{len(self._tag_defaults)} tag defaults found") + self._set_status("4.1", len(self._tag_defaults) > 0) + if not self._tag_defaults: + self._add_finding("4.1", "No default tags configured in the tenancy") + + # 4.2 — At least one topic with subscription + if not self._should_skip("4.2"): + active_topics = [t for t in self._topics if getattr(t, "lifecycle_state", "") == "ACTIVE"] + active_subs = [s for s in self._subscriptions if getattr(s, "lifecycle_state", "") == "ACTIVE"] + has_topic_with_sub = False + topic_ids_with_subs = {s.topic_id for s in active_subs} + for t in active_topics: + if t.topic_id in topic_ids_with_subs: + has_topic_with_sub = True + break + self._add_total("4.2", f"{len(active_topics)} topics, {len(active_subs)} subscriptions") + self._set_status("4.2", has_topic_with_sub) + if not has_topic_with_sub: + self._add_finding("4.2", "No notification topic with an active subscription found") + + # 4.3-4.12, 4.15, 4.18 — Event rule notifications + active_rules = [r for r in self._event_rules if getattr(r, "lifecycle_state", "") == "ACTIVE" and getattr(r, "is_enabled", False)] + for check_id, required_events in CIS_MONITORING_EVENTS.items(): + if self._should_skip(check_id): + continue + found = False + for rule in active_rules: + condition = getattr(rule, "condition", "") or "" + try: + cond_json = json.loads(condition) + event_types = [] + if isinstance(cond_json, dict): + event_types = cond_json.get("eventType", []) + if not event_types: + # Handle nested conditions + data = cond_json.get("data", {}) + if isinstance(data, dict): + event_types = data.get("eventType", []) + except (json.JSONDecodeError, TypeError): + continue + event_types_lower = [e.lower() for e in event_types] + all_matched = all(req.lower() in event_types_lower for req in required_events) + if all_matched: + # Verify rule has ONS action + actions = getattr(rule, "actions", None) + if actions: + action_list = getattr(actions, "actions", []) + for a in action_list: + if getattr(a, "action_type", "") in ("ONS", "FAAS", "OSS"): + found = True + break + if found: + break + self._add_total(check_id, f"Checked {len(active_rules)} active event rules") + self._set_status(check_id, found) + if not found: + self._add_finding(check_id, f"No event rule found covering required events for {check_id}") + + # 4.13 — VCN flow logging for all subnets + if not self._should_skip("4.13"): + logged_resources = set() + for lg in self._logs: + config = getattr(lg, "configuration", None) + if not config: + continue + source = getattr(config, "source", None) + if not source: + continue + service = getattr(source, "service", "") + resource = getattr(source, "resource", "") + if service == "flowlogs" and resource: + logged_resources.add(resource) + for subnet in self._subnets: + self._add_total("4.13", f"{subnet.display_name}") + if subnet.id not in logged_resources: + self._add_finding("4.13", f"Subnet '{subnet.display_name}' ({subnet.id[-12:]}) has no VCN flow logging") + self._set_status("4.13", len(self.findings["4.13"]["findings"]) == 0) + + # 4.14 — Cloud Guard enabled + if not self._should_skip("4.14"): + enabled = self._cloud_guard_status == "ENABLED" + self._add_total("4.14", f"Cloud Guard status: {self._cloud_guard_status or 'UNKNOWN'}") + self._set_status("4.14", enabled) + if not enabled: + self._add_finding("4.14", f"Cloud Guard is not enabled (status: {self._cloud_guard_status or 'UNKNOWN'})") + + # 4.16 — CMK rotation (365 days) + if not self._should_skip("4.16"): + now = datetime.datetime.now(datetime.timezone.utc) + for key in self._keys: + key_name = getattr(key, "display_name", key.id) + self._add_total("4.16", key_name) + # Check key versions for rotation + current_version = getattr(key, "current_key_version", None) + time_created = getattr(key, "time_created", None) + # Get key version time + kv_time = None + key_versions = getattr(key, "key_versions", None) + if current_version: + # Use current key version's time if available + kv_time = getattr(current_version, "time_created", None) if hasattr(current_version, "time_created") else None + if kv_time is None: + kv_time = time_created + if kv_time and (now - kv_time).days > 365: + self._add_finding("4.16", f"Key '{key_name}' not rotated in {(now - kv_time).days} days") + self._set_status("4.16", len(self.findings["4.16"]["findings"]) == 0) + + # 4.17 — Write level Object Storage logging + if not self._should_skip("4.17"): + logged_buckets = set() + for lg in self._logs: + config = getattr(lg, "configuration", None) + if not config: + continue + source = getattr(config, "source", None) + if not source: + continue + service = getattr(source, "service", "") + resource = getattr(source, "resource", "") + category = getattr(source, "category", "") + if service == "objectstorage" and category == "write": + logged_buckets.add(resource) + for b in self._buckets: + self._add_total("4.17", b.name) + if b.name not in logged_buckets: + self._add_finding("4.17", f"Bucket '{b.name}' has no write-level logging enabled") + self._set_status("4.17", len(self.findings["4.17"]["findings"]) == 0) + + # ── Storage Checks: 5.1.1-5.3.1 ────────────────────────────────────────── + def _check_storage(self): + log.info("Running storage checks (5.x)...") + + # 5.1.1 — No public buckets + if not self._should_skip("5.1.1"): + for b in self._buckets: + self._add_total("5.1.1", b.name) + pa = getattr(b, "public_access_type", "NoPublicAccess") + if pa != "NoPublicAccess": + self._add_finding("5.1.1", f"Bucket '{b.name}' is publicly visible ({pa})") + self._set_status("5.1.1", len(self.findings["5.1.1"]["findings"]) == 0) + + # 5.1.2 — Bucket CMK + if not self._should_skip("5.1.2"): + for b in self._buckets: + self._add_total("5.1.2", b.name) + if not getattr(b, "kms_key_id", None): + self._add_finding("5.1.2", f"Bucket '{b.name}' not encrypted with CMK") + self._set_status("5.1.2", len(self.findings["5.1.2"]["findings"]) == 0) + + # 5.1.3 — Bucket versioning + if not self._should_skip("5.1.3"): + for b in self._buckets: + self._add_total("5.1.3", b.name) + versioning = getattr(b, "versioning", "Disabled") + if versioning != "Enabled": + self._add_finding("5.1.3", f"Bucket '{b.name}' versioning not enabled ({versioning})") + self._set_status("5.1.3", len(self.findings["5.1.3"]["findings"]) == 0) + + # 5.2.1 — Block Volume CMK + if not self._should_skip("5.2.1"): + for bv in self._block_volumes: + if bv.lifecycle_state != "AVAILABLE": + continue + self._add_total("5.2.1", bv.display_name) + if not getattr(bv, "kms_key_id", None): + self._add_finding("5.2.1", f"Block volume '{bv.display_name}' not encrypted with CMK") + self._set_status("5.2.1", len(self.findings["5.2.1"]["findings"]) == 0) + + # 5.2.2 — Boot Volume CMK + if not self._should_skip("5.2.2"): + for bv in self._boot_volumes: + if bv.lifecycle_state != "AVAILABLE": + continue + self._add_total("5.2.2", bv.display_name) + if not getattr(bv, "kms_key_id", None): + self._add_finding("5.2.2", f"Boot volume '{bv.display_name}' not encrypted with CMK") + self._set_status("5.2.2", len(self.findings["5.2.2"]["findings"]) == 0) + + # 5.3.1 — File Storage CMK + if not self._should_skip("5.3.1"): + for fs in self._file_systems: + if fs.lifecycle_state != "ACTIVE": + continue + self._add_total("5.3.1", fs.display_name) + if not getattr(fs, "kms_key_id", None): + self._add_finding("5.3.1", f"File system '{fs.display_name}' not encrypted with CMK") + self._set_status("5.3.1", len(self.findings["5.3.1"]["findings"]) == 0) + + # ── Asset Checks: 6.1, 6.2 ─────────────────────────────────────────────── + def _check_assets(self): + log.info("Running asset checks (6.1, 6.2)...") + + # 6.1 — At least one compartment + if not self._should_skip("6.1"): + non_root = [c for c in self.compartments if c.id != self.tenancy_id] + self._add_total("6.1", f"{len(non_root)} compartments (excluding root)") + self._set_status("6.1", len(non_root) > 0) + if not non_root: + self._add_finding("6.1", "No compartments created — all resources are in the root compartment") + + # 6.2 — No resources in root + if not self._should_skip("6.2"): + self._add_total("6.2", f"{self._root_resources_count} non-IAM resources in root compartment") + self._set_status("6.2", self._root_resources_count == 0) + if self._root_resources_count > 0: + self._add_finding("6.2", f"{self._root_resources_count} resource(s) found in root compartment") + + # ═══════════════════════════════════════════════════════════════════════════ + # ORCHESTRATOR + # ═══════════════════════════════════════════════════════════════════════════ + def _step(self, n, total, msg): + """Print a numbered progress step (flushed for real-time capture).""" + print(f"[{n}/{total}] {msg}", flush=True) + + def run_all(self) -> dict: + """Run all data collection and CIS checks, return findings dict.""" + T = 12 # total steps + + self._step(1, T, "Discovering regions...") + self._discover_regions() + + self._step(2, T, "Discovering compartments...") + self._discover_compartments() + + # Home-region-only collections + self._step(3, T, "Collecting IAM data (policies, users, groups)...") + try: + self._collect_iam() + except Exception as e: + log.error(f"IAM collection failed: {e}") + for cid in [f"1.{i}" for i in range(1, 18)]: + if cid in self.findings: + self.findings[cid]["status"] = "REVIEW" + self._add_finding(cid, f"IAM data collection failed: {e}") + + self._step(4, T, "Collecting Cloud Guard status...") + self._collect_cloud_guard() + + self._step(5, T, "Collecting asset data...") + self._collect_assets() + + # Per-region collections in parallel + self._step(6, T, f"Collecting regional data ({len(self.regions)} regions: {', '.join(self.regions)})...") + + def _collect_region(region): + print(f" Scanning {region}...", flush=True) + net = self._collect_network(region) + comp = self._collect_compute(region) + mon = self._collect_monitoring(region) + stor = self._collect_storage(region) + print(f" {region} done.", flush=True) + return region, net, comp, mon, stor + + with ThreadPoolExecutor(max_workers=min(3, len(self.regions))) as pool: + futures = {pool.submit(_collect_region, r): r for r in self.regions} + for future in as_completed(futures): + r = futures[future] + try: + _, net, comp, mon, stor = future.result() + self._security_lists.extend(net["security_lists"]) + self._nsgs.extend(net["nsgs"]) + self._nsg_rules.update(net["nsg_rules"]) + self._vcns.extend(net["vcns"]) + self._subnets.extend(net["subnets"]) + self._oic_instances.extend(net["oic"]) + self._oac_instances.extend(net["oac"]) + self._adbs.extend(net["adbs"]) + self._instances.extend(comp) + self._event_rules.extend(mon["event_rules"]) + self._topics.extend(mon["topics"]) + self._subscriptions.extend(mon["subscriptions"]) + self._log_groups.extend(mon["log_groups"]) + self._logs.extend(mon["logs"]) + self._vaults.extend(mon["vaults"]) + self._keys.extend(mon["keys"]) + self._buckets.extend(stor["buckets"]) + self._block_volumes.extend(stor["block_volumes"]) + self._boot_volumes.extend(stor["boot_volumes"]) + self._file_systems.extend(stor["file_systems"]) + except Exception as e: + log.error(f"Region {r} collection failed: {e}") + + # Run all checks + self._step(7, T, "Running IAM checks (1.1-1.17)...") + check_methods = [ + (self._check_iam_policies, ["1.1", "1.2", "1.3", "1.15"], None), + (self._check_password_policies, ["1.4", "1.5", "1.6"], None), + (self._check_users, ["1.7", "1.8", "1.9", "1.10", "1.11", "1.12", "1.13", "1.16", "1.17"], None), + (self._check_dynamic_groups, ["1.14"], None), + (self._check_network, ["2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8"], + (8, T, "Running network checks (2.1-2.8)...")), + (self._check_compute, ["3.1", "3.2", "3.3"], + (9, T, "Running compute checks (3.1-3.3)...")), + (self._check_monitoring, ["4.1", "4.2", "4.3", "4.4", "4.5", "4.6", "4.7", "4.8", "4.9", "4.10", "4.11", "4.12", "4.13", "4.14", "4.15", "4.16", "4.17", "4.18"], + (10, T, "Running monitoring checks (4.1-4.18)...")), + (self._check_storage, ["5.1.1", "5.1.2", "5.1.3", "5.2.1", "5.2.2", "5.3.1"], + (11, T, "Running storage checks (5.x)...")), + (self._check_assets, ["6.1", "6.2"], + (12, T, "Running asset management checks (6.1-6.2)...")), + ] + for method, check_ids, step_info in check_methods: + if step_info: + self._step(*step_info) + try: + method() + except Exception as e: + log.error(f"{method.__name__} failed: {e}") + for cid in check_ids: + if self.findings[cid]["status"] == "REVIEW": + self._add_finding(cid, f"Check failed: {e}") + + print("[DONE] All checks completed.", flush=True) + return self.findings + + +# ─── HTML Report Generator ─────────────────────────────────────────────────── def gen_html(findings, tenancy, output): now = datetime.datetime.now() sections = {} @@ -84,7 +1256,13 @@ def gen_html(findings, tenancy, output): for cid in sorted(findings.keys(), key=lambda x: [int(p) if p.isdigit() else p for p in x.replace('.',' ').split()]): ck = findings[cid]; st = ck["status"] cls = "pass" if st=="PASS" else "fail" if st=="FAIL" else "review" - det_rows += f'{ck["id"]}{cid}{ck["title"]}{st}{ck["section"]}' + finding_details = "" + if ck.get("findings"): + items = "".join(f"
  • {f}
  • " for f in ck["findings"][:10]) + if len(ck["findings"]) > 10: + items += f"
  • ... and {len(ck['findings'])-10} more
  • " + finding_details = f'
      {items}
    ' + det_rows += f'{ck["id"]}{cid}{ck["title"]}{finding_details}{st}{ck["section"]}' html = f""" Cloud Security Assessment - {tenancy}

    Cloud Security Assessment

    -
    Tenancy: {tenancy}
    Tenancy – Cloud Infrastructure

    +
    Tenancy: {tenancy}
    OCI CIS Foundations Benchmark 3.0

    {now.strftime('%B, %Y')}, Version [1.0]
    Extract date: {now.isoformat()[:19]}

    Disclaimer

    This document, in any form, software or printed matter, contains proprietary information that is the exclusive property of Oracle. @@ -111,7 +1289,7 @@ Your access to and use of this confidential material is subject to the terms and which has been executed and with which you agree to comply.

    This document is for informational purposes only and is intended solely to assist you in planning for the implementation and upgrade of the product features described. It is not a commitment to deliver any material, code, or functionality.

    -

    Findings Overview

    Resumo por domínio (Section)

    +

    Findings Overview

    Resumo por dominio (Section)

    {tot}
    Total Controls
    {pas}
    Passed
    {fai}
    Failed
    @@ -123,40 +1301,63 @@ upgrade of the product features described. It is not a commitment to deliver any
    """ Path(output).write_text(html, encoding="utf-8") -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--config", required=True) - ap.add_argument("--output", required=True) - ap.add_argument("--tenancy-name", default="Tenancy") - ap.add_argument("--regions", default=None) - args = ap.parse_args() - out = Path(args.output); out.mkdir(parents=True, exist_ok=True) - # TODO: Replace with MCP server call - # For now generate a stub report showing all checks as REVIEW +# ─── CLI Entry Point ───────────────────────────────────────────────────────── +def main(): + ap = argparse.ArgumentParser(description="CIS OCI Foundations Benchmark 3.0 Compliance Checker") + ap.add_argument("--config", required=True, help="Path to OCI config file") + ap.add_argument("--output", required=True, help="Output directory for reports") + ap.add_argument("--tenancy-name", default="Tenancy", help="Tenancy display name") + ap.add_argument("--regions", default=None, help="Comma-separated regions to scan") + ap.add_argument("--level", type=int, default=None, choices=[1, 2], help="CIS level filter (1 or 2)") + # Compatibility args (accepted but unused for now) + ap.add_argument("--mcp-module", default=None, help="(reserved)") + ap.add_argument("--adb-dsn", default=None, help="(reserved)") + ap.add_argument("--adb-user", default=None, help="(reserved)") + ap.add_argument("--adb-wallet", default=None, help="(reserved)") + args = ap.parse_args() + + out = Path(args.output) + out.mkdir(parents=True, exist_ok=True) + print(f"[CIS Runner] Config: {args.config}") print(f"[CIS Runner] Output: {args.output}") print(f"[CIS Runner] Tenancy: {args.tenancy_name}") - # Check if OCI config file exists if not Path(args.config).exists(): print(f"[ERROR] Config not found: {args.config}", file=sys.stderr) sys.exit(1) - findings = {} - for cid, ck in CHECKS.items(): - findings[cid] = {**ck, "status": "REVIEW", "findings": [], "total": []} + filter_regions = args.regions.split(",") if args.regions else None + + try: + checker = CISComplianceChecker(args.config, filter_regions, args.level) + findings = checker.run_all() + except Exception as e: + log.error(f"Fatal error: {e}") + # Generate partial report with all REVIEW + findings = {} + for cid, ck in CHECKS.items(): + findings[cid] = {**ck, "status": "REVIEW", "findings": [f"Execution error: {e}"], "total": []} + + # Extract compartment names for report metadata + compartment_names = [] + try: + compartment_names = [c.name for c in checker.compartments] if hasattr(checker, 'compartments') else [] + except Exception: + pass report = { "tenancy": args.tenancy_name, "generated_at": datetime.datetime.now().isoformat(), "benchmark": "CIS OCI Foundations Benchmark 3.0", - "regions": args.regions.split(",") if args.regions else ["all"], + "regions": filter_regions or ["all"], + "compartments": compartment_names, "summary": { "total": len(findings), - "passed": sum(1 for f in findings.values() if f["status"]=="PASS"), - "failed": sum(1 for f in findings.values() if f["status"]=="FAIL"), - "review": sum(1 for f in findings.values() if f["status"]=="REVIEW"), + "passed": sum(1 for f in findings.values() if f["status"] == "PASS"), + "failed": sum(1 for f in findings.values() if f["status"] == "FAIL"), + "review": sum(1 for f in findings.values() if f["status"] == "REVIEW"), }, "findings": findings, } @@ -165,5 +1366,6 @@ def main(): gen_html(findings, args.tenancy_name, str(out / "report.html")) print("[CIS Runner] Reports generated successfully") + if __name__ == "__main__": main() diff --git a/backend/requirements.txt b/backend/requirements.txt index 39b29ed..0ff293e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ PyJWT==2.9.0 python-dotenv==1.0.1 oci==2.133.0 oracledb==2.4.1 +mcp>=1.0.0 diff --git a/frontend/index.html b/frontend/index.html index c7a1a49..efdf5ea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -214,7 +214,9 @@ const LOGO_R=`
    🤖

    Inicie uma conversa com o agente.

    Selecione um modelo ou use o agente local.

    ' + const ms=S.msgs.length===0?'
    🤖

    Inicie uma conversa com o agente.

    Selecione um modelo para começar.

    ' :S.msgs.map(m=>`
    ${fm(m.c)}
    `).join(''); // Build custom dropdown items // Skip non-chat models: Codex (completions only), Image, Audio, Guard, ProtectAI, GPT-oss, Pro (Responses API only) @@ -284,11 +287,11 @@ function rChat(){ 'openai.gpt-oss-120b','openai.gpt-oss-20b']); const provs={};for(const[mid,info]of Object.entries(S.models)){if(skip.has(mid))continue;const p=info.provider||'other';if(!provs[p])provs[p]=[];provs[p].push({id:mid,name:info.name})} const provOrder=['openai','google','meta','xai']; - let ddItems=`
    Agente Local (sem IA)
    `; + let ddItems=''; if(S.genaiCfg.length){ddItems+=`
    Configs Salvas
    `;S.genaiCfg.forEach(g=>{const mi=S.models[g.model_id];ddItems+=`
    ${g.name||mi?.name||g.model_id} (${g.genai_region})
    `})} provOrder.filter(p=>provs[p]).forEach(p=>{ddItems+=`
    ${p.charAt(0).toUpperCase()+p.slice(1)}
    `;provs[p].forEach(m=>{ddItems+=`
    ${m.name}
    `})}); // Current label - let curLabel='Agente Local (sem IA)'; + let curLabel='Selecione um modelo...'; if(S.chatModel.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===S.chatModel.slice(4));const mi=g?S.models[g.model_id]:null;curLabel=g?(g.name||mi?.name||g.model_id)+' ('+g.genai_region+')':'Config...'} else if(S.chatModel){const mi=S.models[S.chatModel];curLabel=mi?mi.name:S.chatModel} const isDirect=S.chatModel&&!S.chatModel.startsWith('cfg:'); @@ -307,7 +310,9 @@ function rChat(){
    -`:''; +
    + +${S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).length?`(${S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).reduce((a,m)=>a+m.tools.length,0)} tools disponíveis)`:''}
    `:''; return`
    ${curLabel}
    ${ddItems}
    ${ociSel}${gearBtn}${ragBadge}
    @@ -323,13 +328,24 @@ function filterModels(q){const list=document.getElementById('mdl');const items=l items.forEach(el=>{if(el.classList.contains('grp')){if(lastGrp)lastGrp.style.display=grpHasVisible?'':'none';lastGrp=el;grpHasVisible=false} else{const show=!q||el.textContent.toLowerCase().includes(ql);el.style.display=show?'':'none';if(show)grpHasVisible=true}}); if(lastGrp)lastGrp.style.display=grpHasVisible?'':'none'} -document.addEventListener('click',e=>{const dd=document.getElementById('mdd');if(dd&&dd.classList.contains('open')&&!e.target.closest('.mdrop'))dd.classList.remove('open')}) +document.addEventListener('click',e=>{const dd=document.getElementById('mdd');if(dd&&dd.classList.contains('open')&&!e.target.closest('.mdrop'))dd.classList.remove('open'); + if(!e.target.closest('.dd')){let changed=false;if(S.rptOciOpen){S.rptOciOpen=false;changed=true}if(S.rptRselOpen){S.rptRselOpen=false;changed=true}if(S.ociFormRegOpen){S.ociFormRegOpen=false;changed=true}if(S.rptRegionsOpen){S.rptRegionsOpen=false;changed=true}if(changed)R()}}) function chatOciChanged(){S.chatOci=document.getElementById('coci').value;const c=S.ociCfg.find(x=>x.id===S.chatOci);if(c){S.chatRegion=c.region;S.chatCompartment=c.compartment_id||''}} function toggleChatParams(){S.chatParamsOpen=!S.chatParamsOpen;R()} +function editPrompt(id){S.editingPrompt=id;R()} +async function savePrompt(){const name=document.getElementById('spname').value;const content=document.getElementById('gsysp').value; + if(!name||!content)return sm('spm','Preencha nome e conteúdo do prompt.','e'); + try{if(S.editingPrompt){await $api('/prompts/'+S.editingPrompt,{method:'PUT',body:{name,content}})} + else{await $api('/prompts',{method:'POST',body:{agent:'chat',name,content,is_active:!S.chatPrompts.length}})} + S.editingPrompt=null;S.chatPrompts=await $api('/prompts/chat');sm('spm','✅ Prompt salvo!','s');R()}catch(e){sm('spm',e.message,'e')}} +async function activatePrompt(id){try{await $api('/prompts/'+id,{method:'PUT',body:{is_active:true}});S.chatPrompts=await $api('/prompts/chat');R()}catch(e){sm('spm',e.message,'e')}} +async function delPrompt(id){if(!confirm('Excluir prompt?'))return;try{await $api('/prompts/'+id,{method:'DELETE'});S.editingPrompt=null;S.chatPrompts=await $api('/prompts/chat');R()}catch(e){sm('spm',e.message,'e')}} function fm(t){return t.replace(/\*\*(.*?)\*\*/g,'$1').replace(/`(.*?)`/g,'$1').replace(/\n/g,'
    ')} -async function sChat(){const el=document.getElementById('chi');const m=el.value.trim();if(!m)return;el.value=''; +async function sChat(){const el=document.getElementById('chi');const m=el.value.trim();if(!m)return; + if(!S.chatModel){S.msgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar uma mensagem.'});R();return} + el.value=''; S.msgs.push({r:'user',c:m});R();scCh(); - try{const body={message:m,session_id:S.sid}; + try{const body={message:m,session_id:S.sid,use_tools:S.chatUseTools}; if(S.chatModel.startsWith('cfg:')){ body.genai_config_id=S.chatModel.slice(4); body.temperature=S.chatParams.temperature;body.max_tokens=S.chatParams.max_tokens;body.top_p=S.chatParams.top_p; @@ -339,7 +355,10 @@ async function sChat(){const el=document.getElementById('chi');const m=el.value. body.model_id=S.chatModel;body.oci_config_id=S.chatOci;body.genai_region=S.chatRegion;body.compartment_id=S.chatCompartment; body.temperature=S.chatParams.temperature;body.max_tokens=S.chatParams.max_tokens;body.top_p=S.chatParams.top_p; body.top_k=S.chatParams.top_k;body.frequency_penalty=S.chatParams.frequency_penalty;body.presence_penalty=S.chatParams.presence_penalty} - const d=await $api('/chat',{method:'POST',body});S.sid=d.session_id;S.msgs.push({r:'assistant',c:d.response});R();scCh()} + const d=await $api('/chat',{method:'POST',body});S.sid=d.session_id; + let resp=d.response; + if(d.tools_used&&d.tools_used.length){resp+='\n\n🔧 **Tools utilizadas:** '+d.tools_used.map(t=>t.name).join(', ')} + S.msgs.push({r:'assistant',c:resp});R();scCh()} catch(e){S.msgs.push({r:'assistant',c:'❌ Erro: '+e.message});R()}} function scCh(){setTimeout(()=>{const e=document.getElementById('chm');if(e)e.scrollTop=e.scrollHeight},50)} async function clrChat(){if(S.sid)try{await $api('/chat/'+S.sid,{method:'DELETE'})}catch(e){}S.msgs=[];S.sid=null;R()} @@ -367,25 +386,101 @@ ${keys.filter(k=>!['display_name','name','db_name','lifecycle_state'].includes(k /* ── Report ── */ function rReport(){const comp=S.reports.filter(r=>r.status==='completed'); - if(!comp.length)return`
    📊 Compliance Reports
    📄

    Nenhum relatório gerado ainda.

    ${rRunRpt()}`; + const running=S.reports.filter(r=>r.status==='running'); + let progressHtml=''; + if(S.trackingReportId||running.length){ + if(!S.trackingReportId&&running.length)S.trackingReportId=running[0].id; + const rr=running.find(r=>r.id===S.trackingReportId); + const lines=(rr?.progress||'').split('\n').filter(Boolean); + const last=lines[lines.length-1]||'Iniciando...'; + const m=last.match(/\[(\d+)\/(\d+)\]/);const pct=m?Math.round((parseInt(m[1])/parseInt(m[2]))*100):0; + progressHtml=`
    ${rProgressCard('running',last,pct,lines,S.trackingReportId)}
    `; + if(!_rptPollTimer)startRptPoll()} + if(!comp.length)return`${progressHtml}
    📊 Compliance Reports
    📄

    Nenhum relatório gerado ainda.

    ${rRunRpt()}`; const lt=comp[0]; - return`
    Report HTML — ${lt.tenancy_name} -
    -${rRunRpt()}`} + const selRpt=S.rptRselVal?comp.find(r=>r.id===S.rptRselVal):lt; + if(!S.rptRselVal)S.rptRselVal=lt.id; + const rselF=S.rptRselFilter.toLowerCase(); + const rselLabel=selRpt?`${selRpt.tenancy_name} — ${selRpt.created_at}`:'Selecione relatório...'; + let rselDdItems=comp.filter(r=>!rselF||r.tenancy_name.toLowerCase().includes(rselF)||r.created_at.includes(rselF)).map(r=>`
    ${r.tenancy_name} — ${r.created_at}
    `).join(''); + return`${progressHtml}
    Report HTML — ${(selRpt||lt).tenancy_name} +
    +${rselLabel}
    +${S.rptRselOpen?`
    +
    +${rselDdItems||'
    Nenhum relatório encontrado
    '} +
    `:''} +
    +${rRunRpt()}`} function rRunRpt(){if(S.user?.role==='viewer')return''; + const selTags=S.rptSelRegions.map(r=>`${r}×`).join(' '); + const f=S.rptRegionFilter.toLowerCase(); + let ddItems=''; + for(const[grp,regs] of Object.entries(S.ociRegions)){ + const filtered=regs.filter(r=>!S.rptSelRegions.includes(r)&&(!f||r.includes(f))); + if(!filtered.length)continue; + ddItems+=`
    ${grp}
    `; + filtered.forEach(r=>{ddItems+=`
    ${r}
    `})} + const ociF=S.rptOciFilter.toLowerCase(); + const selOci=S.ociCfg.find(c=>c.id===S.rptOciVal); + const ociLabel=selOci?`${selOci.tenancy_name} (${selOci.region})`:'Selecione config OCI...'; + let ociDdItems=S.ociCfg.filter(c=>!ociF||c.tenancy_name.toLowerCase().includes(ociF)||c.region.toLowerCase().includes(ociF)).map(c=>`
    ${c.tenancy_name} (${c.region})
    `).join(''); return`
    🚀 Executar Relatório
    -
    +
    +
    +${ociLabel}
    +${S.rptOciOpen?`
    +
    +${ociDdItems||'
    Nenhuma credencial encontrada
    '} +
    `:''} +
    -
    +
    +
    +${S.rptSelRegions.length?selTags:'Todas as regiões'}
    +${S.rptRegionsOpen?`
    +
    +${ddItems||'
    Nenhuma região encontrada
    '} +
    `:''} +
    `} function ldRpt(id){const f=document.getElementById('rfr');if(f)f.src=API+'/reports/'+id+'/html'} -async function runRpt(){const c=document.getElementById('rc').value;if(!c)return alert('Selecione config OCI'); - const rg=document.getElementById('rr').value.split(',').map(r=>r.trim()).filter(Boolean); +function rptPickRegion(r){S.rptSelRegions.push(r);S.rptRegionFilter='';R()} +function rptRemoveRegion(r){S.rptSelRegions=S.rptSelRegions.filter(x=>x!==r);R()} +function rptPickOci(id){S.rptOciVal=id;S.rptOciOpen=false;S.rptOciFilter='';R()} +function rptPickRsel(id){S.rptRselVal=id;S.rptRselOpen=false;S.rptRselFilter='';const f=document.getElementById('rfr');if(f)f.src=API+'/reports/'+id+'/html';R()} +async function runRpt(){if(S.reports.some(r=>r.status==='running'))return alert('Já existe um relatório em execução. Aguarde ou cancele antes de iniciar outro.'); + const c=S.rptOciVal;if(!c)return alert('Selecione config OCI'); + const rg=S.rptSelRegions; const mcp=document.getElementById('rmcp').value||null; + S.rptRegionsOpen=false; try{const d=await $api('/reports/run',{method:'POST',body:{config_id:c,mcp_server_id:mcp,regions:rg.length?rg:null}}); - alert('Relatório iniciado! ID: '+d.report_id);S.reports=await $api('/reports');R()}catch(e){alert('Erro: '+e.message)}} + S.trackingReportId=d.report_id;S.rptSelRegions=[];S.rptOciVal='';S.reports=await $api('/reports');R();startRptPoll()}catch(e){alert('Erro: '+e.message)}} +let _rptPollTimer=null; +function startRptPoll(){stopRptPoll();_rptPollTimer=setInterval(pollRptProgress,3000)} +function stopRptPoll(){if(_rptPollTimer){clearInterval(_rptPollTimer);_rptPollTimer=null}} +async function pollRptProgress(){if(!S.trackingReportId)return stopRptPoll(); + try{const p=await $api('/reports/'+S.trackingReportId+'/progress'); + const el=document.getElementById('rpt-progress'); + if(el){const lines=(p.progress||'').split('\n').filter(Boolean); + const last=lines[lines.length-1]||'Iniciando...'; + const m=last.match(/\[(\d+)\/(\d+)\]/);const pct=m?Math.round((parseInt(m[1])/parseInt(m[2]))*100):0; + el.innerHTML=rProgressCard(p.status,last,pct,lines,S.trackingReportId)} + if(p.status==='completed'||p.status==='failed'||p.status==='cancelled'){stopRptPoll();S.trackingReportId=null;S.reports=await $api('/reports');R()}}catch(e){}} +async function cancelRpt(rid){if(!confirm('Cancelar este relatório?'))return; + try{await $api('/reports/'+rid+'/cancel',{method:'POST'});stopRptPoll();S.trackingReportId=null;S.reports=await $api('/reports');R()}catch(e){alert('Erro: '+e.message)}} +function rProgressCard(status,lastStep,pct,lines,rid){ + if(status==='completed')return'
    Relatório concluído!
    '; + if(status==='failed')return'
    Relatório falhou

    '+lastStep+'

    '; + if(status==='cancelled')return'
    Relatório cancelado
    '; + const cancelBtn=rid?` `:''; + return`
    +
    Gerando relatório...${cancelBtn}${pct}%
    +
    +

    ${lastStep}

    +${lines.length>1?`
    Ver log completo
    ${lines.join('\n')}
    `:''} +
    `} /* ── Downloads ── */ function rDl(){return`
    @@ -393,53 +488,74 @@ function rDl(){return`
    🔄 Atualizar
    ${S.reports.map(r=>` - + -`).join('')}
    TenancyStatusCriadoConcluídoAções
    ${r.tenancy_name}${r.status}${r.status==='cancelled'?'cancelado':r.status}${r.status==='running'&&r.progress?`
    ${(r.progress||'').split('\\n').filter(Boolean).pop()||''}
    `:''}
    ${r.created_at||'—'}${r.completed_at||'—'}${r.status==='completed'?` `:''}
    +${r.status==='completed'?` `:r.status==='running'?``:''}`).join('')} ${!S.reports.length?'
    📂

    Nenhum relatório.

    ':''}
    `} function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f,'_blank')} async function refreshDl(){S.reports=await $api('/reports');R()} /* ── Edit helpers ── */ -function editCfg(type,id){S.editing={type,id};R(); +function editCfg(type,id){S.editing={type,id};if(type==='oci'){const c=S.ociCfg.find(x=>x.id===id);S.ociFormRegVal=c?c.region:'';S.ociFormRegFilter=''}R(); const form=document.querySelector('.cd:has(#obtn), .cd:has(#gbtn), .cd:has(#abtn), .cd:has(#mcbtn)');if(form)form.scrollIntoView({behavior:'smooth',block:'start'})} -function cancelEdit(){S.editing=null;R()} +function cancelEdit(){S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';R()} /* ── OCI Config ── */ +function rOciRegionDd(eo){ + if(eo&&!S.ociFormRegVal)S.ociFormRegVal=eo.region||''; + const val=S.ociFormRegVal;const f=S.ociFormRegFilter.toLowerCase(); + let ddItems=''; + for(const[grp,regs] of Object.entries(S.ociRegions)){ + const filtered=regs.filter(r=>!f||r.includes(f)); + if(!filtered.length)continue; + ddItems+=`
    ${grp}
    `; + filtered.forEach(r=>{ddItems+=`
    ${r}
    `})} + return`
    +${val||'Selecione a região...'}
    +${S.ociFormRegOpen?`
    +
    +${ddItems||'
    Nenhuma região encontrada
    '} +
    `:''}
    `} +function ociFormPickReg(r){S.ociFormRegVal=r;S.ociFormRegOpen=false;S.ociFormRegFilter='';R()} function rOci(){const eo=S.editing?.type==='oci'?S.ociCfg.find(c=>c.id===S.editing.id):null; return`
    ☁️ Credenciais Registradas
    - -${S.ociCfg.map(c=>` - +
    TenancyRegionCompartmentCriadoAções
    ${c.tenancy_name}${c.region}${c.compartment_id?(c.compartment_id.slice(0,22)+'…'):'—'}${c.created_at}
    +${S.ociCfg.map(c=>` +`).join('')}
    TenancyRegionDetalhesAções
    ${c.tenancy_name}
    ${c.created_at}
    ${c.region}🔑 ••••••••••••••••••••
    🏢 ${c.tenancy_ocid}
    👤 ${c.user_ocid}
    📦 ${c.compartment_id||'—'}
    ${!S.ociCfg.length?'

    Nenhuma credencial registrada.

    ':''}
    ${eo?'✏️ Editar Credencial':'➕ Nova Credencial'}
    ${eo?'Editando: '+eo.tenancy_name+'':'Adicione as credenciais da sua conta OCI para executar reports e acessar serviços.'}
    -
    -
    -
    -
    -
    +
    ${eo?'
    Preencha para alterar. Atual: '+eo.user_ocid+'
    ':''}
    +
    ${eo?'
    Preencha para alterar. Atual: '+eo.fingerprint+'
    ':''}
    +
    ${eo?'
    Preencha para alterar. Atual: '+eo.tenancy_ocid+'
    ':''}
    +
    ${rOciRegionDd(eo)}
    +
    ${eo?'
    Preencha para alterar. Atual: '+(eo.compartment_id||'—')+'
    ':''}
    ${eo?'
    Deixe vazio para manter a chave atual
    ':''}
    Apenas se a chave privada for protegida por senha
    ${eo?'':''}
    `+rConfigLogs('oci')} -async function sOci(){const fd=new FormData();fd.append('tenancy_name',document.getElementById('otn').value);fd.append('tenancy_ocid',document.getElementById('oto').value); +async function sOci(){const tn=document.getElementById('otn').value; + if(!tn)return sm('om','Preencha o Tenancy Name','e'); + const fd=new FormData();fd.append('tenancy_name',tn);fd.append('tenancy_ocid',document.getElementById('oto').value); fd.append('user_ocid',document.getElementById('ouo').value);fd.append('fingerprint',document.getElementById('ofp').value); - fd.append('region',document.getElementById('org').value);fd.append('compartment_id',document.getElementById('ocp').value); + fd.append('region',S.ociFormRegVal||'');fd.append('compartment_id',document.getElementById('ocp').value); fd.append('key_passphrase',document.getElementById('okp').value||''); const sk=document.getElementById('osk').files[0]; const isEdit=S.editing?.type==='oci'; - if(!isEdit&&!sk)return sm('om','Selecione a chave privada','e'); + if(!isEdit){ + if(!document.getElementById('oto').value||!document.getElementById('ouo').value||!document.getElementById('ofp').value)return sm('om','Preencha OCID Tenancy, OCID User e Fingerprint','e'); + if(!sk)return sm('om','Selecione a chave privada','e'); + } if(sk)fd.append('private_key',sk); const pk=document.getElementById('opk').files[0];if(pk)fd.append('public_key',pk); const btn=document.getElementById('obtn');btn.disabled=true;btn.textContent='Salvando...';sm('om',' Salvando credencial...','i'); try{const url=isEdit?'/oci/configs/'+S.editing.id:'/oci/config';const method=isEdit?'PUT':'POST'; - await $api(url,{method,body:fd,headers:{}});S.editing=null;S.ociCfg=await $api('/oci/configs');sm('om','✅ Credencial '+(isEdit?'atualizada':'salva')+' com sucesso!','s');R()}catch(e){sm('om',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Credencial'}} + await $api(url,{method,body:fd,headers:{}});S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';S.ociCfg=await $api('/oci/configs');sm('om','✅ Credencial '+(isEdit?'atualizada':'salva')+' com sucesso!','s');R()}catch(e){sm('om',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Credencial'}} async function tOci(id){try{const d=await $api('/oci/test/'+id,{method:'POST'});sm('ocm',d.status==='success'?'✅ Conexão OK!':'❌ '+d.message,d.status==='success'?'s':'e');refreshCLogs('oci')}catch(e){sm('ocm',e.message,'e')}} async function dOci(id){if(!confirm('Excluir credencial?'))return;await $api('/oci/configs/'+id,{method:'DELETE'});S.ociCfg=await $api('/oci/configs');R()} @@ -454,6 +570,16 @@ ${S.genaiCfg.map(g=>{const mi=S.models[g.model_id]||{};return`${g.genai_region} `}).join('')} ${!S.genaiCfg.length?'

    Nenhum modelo configurado.

    ':''}
    +
    💬 System Prompts — Chat Agent
    +
    Prompts de sistema para o Chat Agent. O prompt ativo é enviado como instrução do sistema em todas as mensagens. Máximo 10 prompts. Outros agents (ex.: provisionamento) terão seus próprios prompts.
    + +${(S.chatPrompts||[]).map(p=>` + +`).join('')}
    NomeStatusAções
    ${p.name}${p.is_active?'Ativo':'Inativo'} ${!p.is_active?``:''}
    +${!(S.chatPrompts||[]).length?'

    Nenhum prompt configurado.

    ':''} +
    + +
    ${S.editingPrompt?'':''}
    ${eg?'✏️ Editar Modelo GenAI':'➕ Novo Modelo GenAI'}
    ${eg?'Editando: '+eg.name+'':'Adicione modelos personalizados que não estão no catálogo do Chat Agent, ou crie presets com credenciais específicas.'}
    @@ -508,13 +634,29 @@ ${m.command?`
    Comando: ${m.url?`
    URL: ${m.url}
    `:''} ${m.module_path?`
    Módulo: ${m.module_path}
    `:''}
    ADB: ${adb?`${adb.config_name}`:'Nenhum'}
    +${(()=>{const tools=Array.isArray(m.tools)?m.tools:[];return tools.length?` +
    +
    🔧 Tools (${tools.length})
    +${tools.map((t,i)=>`
    +
    ${typeof t==='string'?t:t.name}${typeof t==='object'&&t.description?` — ${t.description}`:''} +${typeof t==='object'&&t.input_schema&&t.input_schema.properties?`
    Params: ${Object.keys(t.input_schema.properties).join(', ')}
    `:''}
    +
    `).join('')} +
    `:`
    Nenhuma tool registrada
    `})()}
    + + -
    `}).join('') +
    +`}).join('') :'

    Nenhum MCP server registrado.

    '}
    ${em?'✏️ Editar Server':'➕ Registrar Novo Server'}
    ${em?'Editando: '+em.name+'':'Configure um novo servidor MCP. Os campos se ajustam ao tipo selecionado.'}
    @@ -556,16 +698,33 @@ async function uMcp(id){const fd=new FormData();const f=document.getElementById( async function lnkMcp(id){const aid=document.getElementById('mlnk_'+id)?.value||''; const btn=document.getElementById('mlb_'+id);btn.disabled=true;btn.textContent='Vinculando...'; try{await $api('/mcp/servers/'+id+'/link-adb?adb_id='+aid,{method:'PUT'});S.mcpSvr=await $api('/mcp/servers');sm('mcpm_'+id,'✅ '+(aid?'Vinculado ao ADB!':'ADB desvinculado!'),'s');R()}catch(e){sm('mcpm_'+id,e.message,'e');btn.disabled=false;btn.textContent='Vincular ADB'}} +async function discoverMcpTools(mid){const btn=document.getElementById('mdb_'+mid);btn.disabled=true;btn.textContent='Descobrindo...'; + sm('mcpm_'+mid,' Conectando ao MCP server para descobrir tools...','i'); + try{const d=await $api('/mcp/servers/'+mid+'/discover-tools',{method:'POST'});S.mcpSvr=await $api('/mcp/servers'); + sm('mcpm_'+mid,`✅ ${d.discovered} tool(s) descoberta(s), ${d.total} total`,'s');R()}catch(e){sm('mcpm_'+mid,'❌ '+e.message,'e');btn.disabled=false;btn.textContent='🔍 Descobrir Tools'}} +function toggleAddTool(mid){const el=document.getElementById('mctf_'+mid);el.style.display=el.style.display==='none'?'':'none'} +async function addMcpTool(mid){const name=document.getElementById('mctn_'+mid)?.value?.trim();const desc=document.getElementById('mctd_'+mid)?.value?.trim()||''; + const schemaStr=document.getElementById('mcts_'+mid)?.value?.trim(); + if(!name)return sm('mcpm_'+mid,'Nome da tool é obrigatório','e'); + let schema={};if(schemaStr)try{schema=JSON.parse(schemaStr)}catch(e){return sm('mcpm_'+mid,'Input Schema JSON inválido','e')} + const srv=S.mcpSvr.find(m=>m.id===mid);const existing=Array.isArray(srv?.tools)?srv.tools:[]; + existing.push({name,description:desc,input_schema:schema}); + try{await $api('/mcp/servers/'+mid+'/tools',{method:'PUT',body:{tools:existing}});S.mcpSvr=await $api('/mcp/servers'); + sm('mcpm_'+mid,'✅ Tool adicionada!','s');R()}catch(e){sm('mcpm_'+mid,e.message,'e')}} +async function removeMcpTool(mid,idx){const srv=S.mcpSvr.find(m=>m.id===mid);const tools=Array.isArray(srv?.tools)?[...srv.tools]:[]; + if(idx<0||idx>=tools.length)return;const name=typeof tools[idx]==='string'?tools[idx]:tools[idx].name; + if(!confirm(`Remover tool "${name}"?`))return;tools.splice(idx,1); + try{await $api('/mcp/servers/'+mid+'/tools',{method:'PUT',body:{tools}});S.mcpSvr=await $api('/mcp/servers');R()}catch(e){sm('mcpm_'+mid,e.message,'e')}} /* ── ADB Vector ── */ function rADB(){const ea=S.editing?.type==='adb'?S.adbCfg.find(c=>c.id===S.editing.id):null; return`
    🗄️ Conexões Registradas
    - -${S.adbCfg.map(c=>{const em=S.embModels[c.embedding_model_id];return` - +
    NomeDSNUserTabelamTLSWalletEmbedAções
    ${c.config_name}${c.dsn}${c.username}${c.table_name}
    +${S.adbCfg.map(c=>{const em=S.embModels[c.embedding_model_id];const tbls=c.tables||[];return` + -`}).join('')}
    NomeDSNUserTabelasmTLSWalletEmbedAções
    ${c.config_name}${c.dsn}${c.username}${tbls.length?tbls.map(t=>''+t.table_name+'').join(' '):''} ${c.use_mtls?'✅':'—'}${c.wallet_dir?'✅':'❌'} ${c.genai_config_id?''+(em?em.name:c.embedding_model_id)+'':''}
    + `}).join('')} ${!S.adbCfg.length?'

    Nenhuma conexão ADB registrada.

    ':''}
    ${ea?'✏️ Editar Conexão':'➕ Nova Conexão'}
    ${ea?'Editando: '+ea.config_name+'':'Conexão via python-oracledb Thin mode com Wallet (mTLS) para Oracle Autonomous Database. Envie o Wallet ZIP para preencher o DSN automaticamente.'}
    @@ -577,12 +736,25 @@ ${!S.adbCfg.length?'

    Nenhuma conexão

    -
    Credenciais OCI usadas para gerar embeddings via GenAI.
    Modelo OCI GenAI para gerar vetores.
    ${ea?'':''}
    +${ea?`
    📋 Tabelas de Vetores — ${ea.config_name}
    +
    Tabelas no ADB com dados vetorizados. A busca RAG consultará todas as tabelas ativas.
    +${(ea.tables||[]).length?` +${(ea.tables||[]).map(t=>` + + + + +`).join('')}
    TabelaDescriçãoAtivaAções
    `:'

    Nenhuma tabela registrada.

    '} +
    +
    +
    + +
    `:''} ${S.adbCfg.length?`
    📤 Atualizar Wallet
    @@ -600,7 +772,7 @@ async function sAdb(){const btn=document.getElementById('abtn');btn.disabled=tru try{const fd=new FormData();fd.append('config_name',document.getElementById('an').value); fd.append('dsn',document.getElementById('adsn').value);fd.append('username',document.getElementById('auser').value); fd.append('password',document.getElementById('apw').value);fd.append('wallet_password',document.getElementById('awpw').value||''); - fd.append('table_name',document.getElementById('atbl').value);fd.append('use_mtls','true'); + fd.append('use_mtls','true'); fd.append('genai_config_id',document.getElementById('agcfg').value||''); fd.append('embedding_model_id',document.getElementById('aemb').value||'cohere.embed-v4.0'); const wf=document.getElementById('awf').files[0];if(wf)fd.append('wallet',wf); @@ -613,31 +785,48 @@ async function uWallet(){const fd=new FormData();const f=document.getElementById fd.append('wallet',f);const id=document.getElementById('awsel').value;if(!id)return sm('wm','Selecione uma conexão ADB.','e'); const btn=document.getElementById('wbtn');btn.disabled=true;btn.textContent='Enviando...';sm('wm',' Enviando wallet...','i'); try{const d=await $api('/adb/'+id+'/upload-wallet',{method:'POST',body:fd,headers:{}});S.adbCfg=await $api('/adb/configs');sm('wm','✅ Wallet enviada! Arquivos: '+d.files.join(', '),'s');R()}catch(e){sm('wm',e.message,'e');btn.disabled=false;btn.textContent='Upload Wallet'}} -async function ensureTable(id){try{const d=await $api('/adb/'+id+'/ensure-table',{method:'POST'});sm('am','✅ Tabela criada/verificada: '+d.table,'s')}catch(e){sm('am','Erro: '+e.message,'e')}} +async function addTable(vid){const name=document.getElementById('newTblName').value.trim();if(!name)return sm('tbm','Digite o nome da tabela.','e'); + const desc=document.getElementById('newTblDesc').value.trim(); + try{await $api('/adb/'+vid+'/tables',{method:'POST',body:{table_name:name,description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela '+name+' registrada!','s');R()}catch(e){sm('tbm',e.message,'e')}} +async function saveTable(vid,tid){const name=document.getElementById('tn_'+tid)?.value.trim();const desc=document.getElementById('td_'+tid)?.value.trim(); + if(!name)return sm('tbm','Nome da tabela é obrigatório.','e'); + try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{table_name:name.toUpperCase(),description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela atualizada.','s');R()}catch(e){sm('tbm',e.message,'e')}} +async function toggleTable(vid,tid,val){try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{is_active:val}});S.adbCfg=await $api('/adb/configs');R()}catch(e){sm('tbm',e.message,'e')}} +async function removeTable(vid,tid){if(!confirm('Excluir esta tabela do registro?'))return; + try{await $api('/adb/'+vid+'/tables/'+tid,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela removida.','s');R()}catch(e){sm('tbm',e.message,'e')}} /* ── Embeddings ── */ +function tblOpts(vid){const cfg=S.adbCfg.find(c=>c.id===vid);if(!cfg||!(cfg.tables||[]).length)return''; + return cfg.tables.filter(t=>t.is_active).map(t=>'').join('')} function rEmbeddings(){ const adbOpts=S.adbCfg.filter(c=>c.genai_config_id).map(c=>'').join(''); const rptOpts=S.reports.filter(r=>r.status==='completed').map(r=>'').join(''); if(!S.adbCfg.filter(c=>c.genai_config_id).length)return`
    🧬

    Nenhuma conexão ADB com GenAI configurada.

    Configure em ADB Vector vinculando uma Config GenAI.

    `; + const firstVid=S.adbCfg.filter(c=>c.genai_config_id)[0]?.id||''; return`
    📋 Embeddings Existentes
    -
    Consulte os documentos armazenados na tabela de embeddings do ADB.
    -
    +
    Consulte os documentos armazenados nas tabelas de embeddings do ADB.
    +
    +
    📊 Embed Relatório CIS
    -
    Gere embeddings a partir de relatórios CIS. Cada seção (IAM, Networking, etc.) gera um embedding separado.
    -
    +
    Visualize os chunks gerados antes de criar os embeddings. Cada seção do relatório gera um chunk com tenancy, regiões e compartments.
    +
    +
    -
    +
    +
    +
    📄 Upload de Arquivo
    Faça upload de um arquivo .txt para gerar embeddings automaticamente (chunking por parágrafos).
    -
    +
    +
    `} async function loadEmbs(){const vid=document.getElementById('elsel').value;if(!vid)return; - try{const d=await $api('/embeddings/'+vid+'/list'); + const tbl=document.getElementById('eltbl')?.value||''; + try{const d=await $api('/embeddings/'+vid+'/list'+(tbl?'?table_name='+encodeURIComponent(tbl):'')); if(!d.documents.length){document.getElementById('emb-list').innerHTML='

    Nenhum embedding encontrado.

    ';return} document.getElementById('emb-list').innerHTML=''+ d.documents.map(e=>'
    IDSourceMetadataDataAções
    '+e.id.substring(0,8)+'...'+ @@ -646,19 +835,45 @@ async function loadEmbs(){const vid=document.getElementById('elsel').value;if(!v '
    Total: '+d.total+' documentos
    '} catch(e){document.getElementById('emb-list').innerHTML='
    Erro: '+e.message+'
    '}} +async function previewChunks(){const rid=document.getElementById('errpt').value; + if(!rid)return sm('erm','Selecione um relatório.','e'); + const btn=document.getElementById('epbtn');btn.disabled=true;btn.textContent='Carregando...'; + try{const d=await $api('/embeddings/preview/'+rid); + let html='
    '; + html+='
    '; + html+='
    '+d.tenancy+''; + if(d.regions?.length)html+=' Regiões: '+d.regions.join(', ')+''; + html+='
    '+d.total_chunks+' chunks
    '; + if(d.compartments?.length)html+='
    Compartments: '+d.compartments.join(', ')+'
    '; + d.chunks.forEach((c,i)=>{ + const preview=c.content.length>300?c.content.substring(0,300)+'...':c.content; + html+='
    '; + html+=''; + html+='#'+(i+1)+' '+c.source+''; + html+=''+c.metadata+''; + html+='
    '+preview+'
    '; + html+='
    '}); + html+='
    '; + document.getElementById('chunk-preview').innerHTML=html; + document.getElementById('erbtn').style.display=''; + sm('erm','✅ '+d.total_chunks+' chunks prontos para embedding.','s'); + }catch(e){sm('erm',e.message,'e')}finally{btn.disabled=false;btn.textContent='👁 Visualizar Chunks'}} async function embedReport(){const vid=document.getElementById('ersel').value;const rid=document.getElementById('errpt').value; if(!vid||!rid)return sm('erm','Selecione conexão ADB e relatório.','e'); + const tbl=document.getElementById('ertbl')?.value||''; const btn=document.getElementById('erbtn');btn.disabled=true;btn.textContent='Gerando...';sm('erm',' Gerando embeddings do relatório...','i'); - try{const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid}});sm('erm','✅ '+d.message,'s');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}catch(e){sm('erm',e.message,'e');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}} + try{const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,table_name:tbl||undefined}});sm('erm','✅ '+d.message,'s');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}catch(e){sm('erm',e.message,'e');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}} async function embedFile(){const vid=document.getElementById('efsel').value;const f=document.getElementById('eff').files[0]; if(!vid||!f)return sm('efm','Selecione conexão ADB e arquivo.','e'); - const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid); + const tbl=document.getElementById('eftbl')?.value||''; + const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);if(tbl)fd.append('table_name',tbl); const btn=document.getElementById('efbtn');btn.disabled=true;btn.textContent='Enviando...';sm('efm',' Enviando e gerando embeddings...','i'); try{const d=await $api('/embeddings/upload',{method:'POST',body:fd,headers:{}});sm('efm','✅ '+d.message,'s');btn.disabled=false;btn.textContent='Upload e Gerar Embeddings'}catch(e){sm('efm',e.message,'e');btn.disabled=false;btn.textContent='Upload e Gerar Embeddings'}} async function delEmb(vid,docId){if(!confirm('Excluir este embedding?'))return; - try{await $api('/embeddings/'+vid+'/'+docId,{method:'DELETE'});loadEmbs()}catch(e){alert('Erro: '+e.message)}} + const tbl=document.getElementById('eltbl')?.value||''; + try{await $api('/embeddings/'+vid+'/'+docId+(tbl?'?table_name='+encodeURIComponent(tbl):''),{method:'DELETE'});loadEmbs()}catch(e){alert('Erro: '+e.message)}} /* ── Users ── */ function rUsers(){return`
    👥 Gerenciamento de Usuários
    diff --git a/nginx/default.conf b/nginx/default.conf index 5ed984e..4558e5b 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -8,6 +8,9 @@ server { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires 0; } location /api/ {