diff --git a/.env.example b/.env.example index 42bb6f1..73460bb 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,5 @@ APP_SECRET=change-me-generate-a-secure-random-string JWT_EXPIRY_HOURS=12 PORT=8080 CORS_ORIGINS=http://localhost:8080 +TZ=America/Sao_Paulo OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True diff --git a/backend/app.py b/backend/app.py index e441761..d929f89 100644 --- a/backend/app.py +++ b/backend/app.py @@ -199,11 +199,12 @@ def init_db(): CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, first_name TEXT NOT NULL, - last_name TEXT NOT NULL, - username TEXT UNIQUE NOT NULL, + last_name TEXT NOT NULL, + username TEXT UNIQUE NOT NULL, email TEXT, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', mfa_secret TEXT, mfa_enabled INTEGER DEFAULT 0, + timezone TEXT DEFAULT 'America/Sao_Paulo', is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), last_login TEXT @@ -457,6 +458,11 @@ def init_db(): c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}") except sqlite3.OperationalError: pass + for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'"]: + try: + c.execute(f"ALTER TABLE users 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(): @@ -618,7 +624,7 @@ class TOTPVerify(BaseModel): class ChangePwReq(BaseModel): current_password: str; new_password: str class UserUpdateReq(BaseModel): - email: Optional[str] = None; role: Optional[str] = None; is_active: Optional[bool] = None + email: Optional[str] = None; role: Optional[str] = None; is_active: Optional[bool] = None; timezone: Optional[str] = None class ChatMsg(BaseModel): message: str; session_id: Optional[str] = None # Option A: saved preset @@ -688,7 +694,7 @@ async def login(req: LoginReq, request: Request): c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (u["id"],)) _audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None) return {"token":_make_token(u["id"],u["role"],sid), - "user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"])}} + "user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"]),"timezone":u.get("timezone","America/Sao_Paulo")}} @app.post("/api/auth/logout") async def logout_ep(u=Depends(current_user)): @@ -735,12 +741,12 @@ async def mfa_disable(user_id: str, adm=Depends(require("admin"))): # ── Users ───────────────────────────────────────────────────────────────────── @app.get("/api/users") async def list_users(u=Depends(require("admin"))): - with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,is_active,created_at,last_login FROM users").fetchall() + with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,timezone,is_active,created_at,last_login FROM users").fetchall() return [dict(r) for r in rows] @app.get("/api/users/me") async def me(u=Depends(current_user)): - return {k: u[k] for k in ("id","first_name","last_name","username","email","role","mfa_enabled")} + return {k: u[k] for k in ("id","first_name","last_name","username","email","role","mfa_enabled","timezone")} @app.put("/api/users/{uid}") async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))): @@ -750,6 +756,7 @@ async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin") if req.role not in ("admin","user","viewer"): raise HTTPException(400, "Role inválida") sets.append("role=?"); vals.append(req.role) if req.is_active is not None: sets.append("is_active=?"); vals.append(int(req.is_active)) + if req.timezone is not None: sets.append("timezone=?"); vals.append(req.timezone) if sets: vals.append(uid) with db() as c: c.execute(f"UPDATE users SET {','.join(sets)} WHERE id=?", vals) @@ -4729,7 +4736,7 @@ async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_ @app.get("/api/reports") async def list_reports(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), u=Depends(current_user)): with db() as c: - q = "SELECT id,user_id,tenancy_name,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports" + q = "SELECT id,user_id,tenancy_name,config_id,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports" rows = c.execute(q+" ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, skip)).fetchall() if u["role"]=="admin" \ else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?",(u["id"], limit, skip)).fetchall() return [dict(r) for r in rows] @@ -4926,52 +4933,110 @@ def _format_remediation_html(text: str) -> str: def esc(v): return (v or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + # Pre-process: join broken lines from PDF extraction + # PDF text wraps at column width, creating artificial line breaks mid-sentence + processed_lines = [] + _BLOCK_STARTERS = _re.compile( + r'^(?:\d+\.\s|From |From:|Note:|Remediation:|Audit:|Description:|Rationale:|' + r'oci |Allow |Define |for |do$|done$|\$ |query |where |#|' + r'Cloud Guard:|Using |Remediation |Default Value|Impact|References|' + r'Profile Applicability|Additional Information|CIS Controls)', + _re.IGNORECASE + ) + for line in text.split('\n'): + stripped = line.strip() + if not stripped: + processed_lines.append('') + continue + prev = processed_lines[-1] if processed_lines else '' + # Join if previous line doesn't end with sentence terminator AND + # current line is NOT a new block/section/command + should_join = ( + prev and + not prev.endswith(('.', ':', ';', ')', '}', '\\', '|', '"')) and + not _BLOCK_STARTERS.match(stripped) + ) + if should_join: + processed_lines[-1] = prev + ' ' + stripped + else: + processed_lines.append(stripped) + text = '\n'.join(processed_lines) + + # Code line detection patterns + _CODE_STARTERS = re.compile( + r'^(?:oci |Allow |Define |Endorse |Admit |terraform |curl |openssl |ssh |python|' + r'for |do$|done$|if |fi$|echo |export |eval |jq |grep |awk |sed |cat |' + r'\$ |#!|output=|[a-z_]+=\$|\{\{|query |where |bucket |' + r'select |filter=)', + re.IGNORECASE + ) + _CODE_CONTINUATIONS = re.compile( + r'^(?:--[a-z]|[|>]|\)$|\}|&&|\|\||resources\b|\.data|"query |' + r'IngressSecurityRules|EgressSecurityRules|tcpOptions|udpOptions|' + r'destinationPortRange|sourcePortRange|"region-|"\.region|' + r'publicAccessType|ObjectRead|ObjectReadWithoutList|' + r'\.source |\.direction |\.protocol |' + r'[a-z]+\.[a-z]+\.|2>/dev/null|\()', + re.IGNORECASE + ) + lines = text.split('\n') html_parts = [] in_code = False code_buf = [] + def flush_code(): + nonlocal in_code, code_buf + if code_buf: + html_parts.append('
' + esc('\n'.join(code_buf)) + '
') + code_buf = [] + in_code = False + for line in lines: stripped = line.strip() - # Detect CLI commands and policy statements - is_code = ( - stripped.startswith('oci ') or - stripped.startswith('Allow ') or - stripped.startswith('Define ') or - stripped.startswith('Endorse ') or - stripped.startswith('Admit ') or - stripped.startswith('terraform ') or - stripped.startswith('curl ') or - stripped.startswith('openssl ') or - stripped.startswith('ssh ') or - stripped.startswith('python') or - stripped.startswith('$ ') or - stripped.startswith('# ') and not stripped.startswith('# ') and len(stripped) < 5 or - (in_code and stripped and not stripped[0].isupper() and not stripped.startswith('http')) + if not stripped: + if in_code: + code_buf.append('') + continue + + is_code_start = bool(_CODE_STARTERS.match(stripped)) + is_code_cont = in_code and ( + bool(_CODE_CONTINUATIONS.match(stripped)) or + stripped.endswith('\\') or + stripped.endswith('|') or + stripped.endswith('{') or + (not stripped[0].isupper() and not _re.match(r'^\d+\.', stripped) and + not stripped.startswith('From ') and not stripped.startswith('To ') and + not stripped.startswith('Note') and not stripped.startswith('Ensure') and + not stripped.startswith('Cloud ') and not stripped.startswith('The ') and + not stripped.startswith('Navigate') and not stripped.startswith('Click') and + not stripped.startswith('Select') and not stripped.startswith('In the') and + not stripped.startswith('Go to') and not stripped.startswith('Open') and + not stripped.startswith('This ') and not stripped.startswith('For ') and + not stripped.startswith('If ') and not stripped.startswith('Are ') and + not stripped.startswith('Check ') and not stripped.startswith('Verify')) ) - if is_code: + if is_code_start or is_code_cont: if not in_code: in_code = True code_buf = [] code_buf.append(stripped) else: - if in_code and code_buf: - html_parts.append('
' + esc('\n'.join(code_buf)) + '
') - code_buf = [] - in_code = False - if stripped: - # Numbered steps + flush_code() + # "From Console:", "From CLI:", "From Command Line:" → bold header + header_match = _re.match(r'^(From\s+(?:Console|CLI|Command Line|the Console|the CLI|OCI Console|OCI CLI)[:\s]*)(.*)', stripped, _re.IGNORECASE) + if header_match: + rest = esc(header_match.group(2)) if header_match.group(2) else '' + html_parts.append(f'

{esc(header_match.group(1).strip())} {rest}

') + else: step_match = _re.match(r'^(\d+)\.\s+(.+)', stripped) if step_match: html_parts.append(f'
{step_match.group(1)}. {esc(step_match.group(2))}
') else: html_parts.append(f'

{esc(stripped)}

') - # Flush remaining code - if code_buf: - html_parts.append('
' + esc('\n'.join(code_buf)) + '
') - + flush_code() return '\n'.join(html_parts) @@ -5167,7 +5232,227 @@ def _build_findings_table(csv_path: str, max_rows: int = 10) -> str: return "" -def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: str, sections_data: dict, rid: str = "", file_id_map: dict = None, file_path_map: dict = None) -> str: +def _check_oci_services(config_id: str) -> list[dict]: + """Check status of OCI security services (VSS, Data Safe, Cloud Guard, Bastion, Security Zones, Vault).""" + import oci + results = [] + try: + oci_config = _get_oci_config(config_id) + tenancy_ocid = oci_config.get("tenancy", "") + # Use compartment_id from DB if available, otherwise root tenancy + with db() as c: + cfg = c.execute("SELECT compartment_id FROM oci_configs WHERE id=?", (config_id,)).fetchone() + # DB stores base64 — decode if needed + compartment_raw = (cfg["compartment_id"] or "") if cfg else "" + if compartment_raw and not compartment_raw.startswith("ocid"): + import base64 + try: + compartment_raw = base64.b64decode(compartment_raw).decode("utf-8") + except Exception: + pass + root_compartment = compartment_raw if compartment_raw.startswith("ocid") else tenancy_ocid + + def _safe_list(resp_data): + """Extract list from OCI response data (handles both .items and direct list).""" + if hasattr(resp_data, 'items'): + return resp_data.items + if isinstance(resp_data, list): + return resp_data + return [] + + # 1. Vulnerability Scanning Service (VSS) + try: + vss_client = _oci_client(oci.vulnerability_scanning.VulnerabilityScanningClient, config_id) + targets = _safe_list(vss_client.list_host_scan_targets(compartment_id=root_compartment).data) + if targets: + results.append({"service": "Vulnerability Scanning Service", "status": "OK", + "description": f"All instances in the compartment root and its subcompartments are scanned ({len(targets)} target(s))"}) + else: + results.append({"service": "Vulnerability Scanning Service", "status": "WARNING", + "description": "No VSS scan targets found in Tenant"}) + except Exception as e: + log.warning(f"OCI Services check - VSS: {e}") + results.append({"service": "Vulnerability Scanning Service", "status": "WARNING", + "description": "Unable to verify VSS status or service not enabled"}) + + # 2. Data Safe + try: + ds_client = _oci_client(oci.data_safe.DataSafeClient, config_id) + targets = _safe_list(ds_client.list_target_databases(compartment_id=root_compartment, lifecycle_state="ACTIVE").data) + if targets: + results.append({"service": "Data Safe", "status": "OK", + "description": f"{len(targets)} target database(s) registered in Data Safe"}) + else: + results.append({"service": "Data Safe", "status": "WARNING", + "description": "No target databases registered in Data Safe"}) + except Exception as e: + log.warning(f"OCI Services check - Data Safe: {e}") + results.append({"service": "Data Safe", "status": "WARNING", + "description": "Unable to verify Data Safe status or service not enabled"}) + + # 3. Cloud Guard (needs tenancy root) + recommendations + try: + cg_client = _oci_client(oci.cloud_guard.CloudGuardClient, config_id) + cg_config = cg_client.get_configuration(tenancy_ocid).data + cg_recommendations = [] + if cg_config.status == "ENABLED": + try: + recs = _safe_list(cg_client.list_recommendations( + compartment_id=tenancy_ocid, lifecycle_state="ACTIVE", + sort_by="riskLevel", sort_order="DESC", limit=50).data) + cg_recommendations = [{"name": r.name, "count": r.problem_count, "risk": r.risk_level} + for r in recs if r.problem_count and r.problem_count > 0] + except Exception as re: + log.warning(f"Cloud Guard recommendations fetch failed: {re}") + results.append({"service": "Cloud Guard", "status": "OK", + "description": "Cloud Guard service is enabled in Tenant", + "recommendations": cg_recommendations}) + else: + results.append({"service": "Cloud Guard", "status": "WARNING", + "description": "Cloud Guard service is not enabled in Tenant"}) + except Exception as e: + log.warning(f"OCI Services check - Cloud Guard: {e}") + results.append({"service": "Cloud Guard", "status": "WARNING", + "description": "Unable to verify Cloud Guard status"}) + + # 4. Bastion + try: + bastion_client = _oci_client(oci.bastion.BastionClient, config_id) + bastions = _safe_list(bastion_client.list_bastions(compartment_id=root_compartment).data) + if bastions: + results.append({"service": "Bastion", "status": "OK", + "description": f"Some OCI Bastion instances were found in Tenant ({len(bastions)})"}) + else: + results.append({"service": "Bastion", "status": "WARNING", + "description": "No OCI Bastion instances found in Tenant"}) + except Exception as e: + log.warning(f"OCI Services check - Bastion: {e}") + results.append({"service": "Bastion", "status": "WARNING", + "description": "Unable to verify Bastion status"}) + + # 5. Security Zones (needs tenancy root) + try: + cg_client_sz = _oci_client(oci.cloud_guard.CloudGuardClient, config_id) + sz = _safe_list(cg_client_sz.list_security_zones(compartment_id=tenancy_ocid).data) + if sz: + results.append({"service": "Security Zones", "status": "OK", + "description": f"Security Zones were created in Tenant ({len(sz)})"}) + else: + results.append({"service": "Security Zones", "status": "WARNING", + "description": "No Security Zones found in Tenant"}) + except Exception as e: + log.warning(f"OCI Services check - Security Zones: {e}") + results.append({"service": "Security Zones", "status": "WARNING", + "description": "Unable to verify Security Zones status"}) + + # 6. Vault + try: + vault_client = _oci_client(oci.key_management.KmsVaultClient, config_id) + vaults = _safe_list(vault_client.list_vaults(compartment_id=root_compartment).data) + active_vaults = [v for v in vaults if v.lifecycle_state == "ACTIVE"] + if active_vaults: + results.append({"service": "Vault", "status": "OK", + "description": f"Vault instances were found in Tenant ({len(active_vaults)} active)"}) + else: + results.append({"service": "Vault", "status": "WARNING", + "description": "No active Vault instances found in Tenant"}) + except Exception as e: + log.warning(f"OCI Services check - Vault: {e}") + results.append({"service": "Vault", "status": "WARNING", + "description": "Unable to verify Vault status"}) + + except Exception as e: + log.error(f"OCI Services check failed: {e}") + return results + + +# ── Static descriptions for OCI Services section ── +_OCI_SERVICE_DESCRIPTIONS = { + "Vulnerability Scanning Service": { + "title": "Vulnerability Scanning Service", + "description": ( + "Oracle Cloud Infrastructure Vulnerability Scanning Service (OCI VSS) is simple, prescriptive, and tightly integrated " + "with the OCI platform. VSS is available to all OCI customers that have paid accounts at no additional cost. The " + "scanning platform includes default plugins and engines for instance and container scanning. The service scans " + "installed packages and artifacts looking for the existence of known vulnerabilities. The service scans for open ports " + "on an instance and it reports on publicly and privately available open ports. These findings will highlight what ports " + "an attacker might use or what application could be shut down by the customer to reduce their attack surface. The " + "scanning agent also reviews the configuration of each instance against specific OS CIS benchmarks enabling " + "customers to see immediately what Operating System (OS) security hardening opportunities exist on their instances." + ), + "recommendation": "Create a recipe for scanning instances/containers and targets to use those recipes. Collect all results in Cloud Guard.", + }, + "Data Safe": { + "title": "Data Safe", + "description": ( + "An audit trail represents the collection of audit records from the target database such as " + "UNIFIED_AUDIT_TRAIL, which provides documentary evidence of the sequence of activities that happen. " + "A database audit trail is the source of audit records showing what has happened in the target database. When audit " + "data collection is enabled for the specified database audit trail in an audit trail resource, the audit records are " + "copied from the database's audit trail into Oracle Data Safe in near-real time. You can manage the audit records " + "volume in the target database using the auto purge feature." + ), + "recommendation": ( + "Start Oracle Data Safe audit trail for all Databases. " + "Take advantage of Activity Auditing Dashboard and Audit Insights." + ), + }, + "Cloud Guard": { + "title": "Cloud Guard", + "description": ( + "Misconfigured resources and insecure activity in the cloud represent one of the most difficult problems for security " + "professionals. Misconfigured cloud tenancies around cloud resources can present themselves in many ways; from " + "publicly accessible object storage buckets, unencrypted data storage, sensitive ports open to the internet. " + "Oracle Cloud Infrastructure Cloud Guard is a cloud security service that detects misconfigured resources and " + "insecure activities. Cloud Guard acts as a log and events aggregator that directly integrates with all major Oracle " + "Cloud Infrastructure services (Compute, Networking, Storage, etc.) providing actionable results. Cloud Guard offers " + "the flexibility to take action on security issues manually or automatically with conditional operators." + ), + "recommendation": "Based on its monitoring (aligned with CIS baselines), Cloud Guard provides in its console recommendations, some already highlighted in this document.", + }, + "Bastion": { + "title": "OCI Bastion", + "description": ( + "Provide restricted and time-limited secure access to resources that don't have public endpoints and require strict " + "resource access controls. Examples include compute instances, bare metal and virtual machines, MySQL, ATP, OKE, " + "and any other resource that allows Secure Shell Protocol (SSH) access. With Oracle Cloud Infrastructure (OCI) " + "Bastion service, customers can enable access to private hosts without deploying and maintaining a jump host. In " + "addition, customers gain improved security posture with identity-based permissions and a centralized, audited, and " + "time-bound SSH session. OCI Bastion removes the need for a public IP for bastion access, eliminating the hassle and " + "potential attack surface from remote access." + ), + "recommendation": "We recommend using Bastion to remediate findings from CIS Controls 2.1, 2.2, 2.3 and 2.4 in public subnets, if the permission is related to remote access use cases without restricted sources, VPN or Fast Connect.", + }, + "Security Zones": { + "title": "Security Zones", + "description": ( + "Security Zones let you be confident that your resources in Oracle Cloud Infrastructure, including Compute, " + "Networking, Object Storage, Block Volume and Database resources, comply with your security policies. " + "A security zone is associated with one or more compartments and a security zone recipe. When you create and " + "update resources in a security zone, Oracle Cloud Infrastructure validates these operations against the list of " + "policies that are defined in the security zone recipe. If any security zone policy is violated, then the operation is " + "denied. By default, a compartment and any subcompartments are in the same security zone, but you can also create " + "a different security zone for a subcompartment." + ), + "recommendation": "", + }, + "Vault": { + "title": "Vault", + "description": ( + "Vaults are logical entities where Vault service creates and durably stores vault keys and secrets. The type of vault " + "you have determines features and functionality such as degrees of storage isolation, access to management and " + "encryption, scalability, and the ability to back up. The type of vault you have also affects pricing. You cannot change " + "a vault's type after you create the vault. " + "The Vault service offers different vault types to accommodate your organization's needs and budget. All vault types " + "ensure the security and integrity of the encryption keys and secrets that vaults store. A virtual private vault is an " + "isolated partition on a hardware security module (HSM). Vaults otherwise share partitions on the HSM with other vaults." + ), + "recommendation": "", + }, +} + + +def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: str, sections_data: dict, rid: str = "", file_id_map: dict = None, file_path_map: dict = None, oci_services: list = None) -> str: """Build the full LAD A-Team CIS Compliance Report HTML.""" import re as _re from datetime import datetime as _dt @@ -5218,6 +5503,8 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: rec = r.get("Recommendation #", "") title = r.get("Title", "") level = (r.get("Level", "") or "").strip() + cis_v8 = (r.get("CIS v8", "") or "").strip().strip("[]'\"") + cccs = (r.get("CCCS Guard Rail", "") or "").strip().strip("[]'\"") rec_id = f"rec-{slug(sec_name)}-{slug(rec)}" is_compliant = r["__compliant"] findings = r.get("Findings", "").strip() @@ -5242,17 +5529,23 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: detail = f" ({findings_num} of {esc(total_items)} items)" result_html = f'
Result: Tenancy {esc(tenancy_name)}: Non-Compliant{detail}
' - # Description — preserve line breaks for readability + # Description — split into paragraphs on double newlines, justify each description_html = "" if not is_compliant and rag_desc: - desc_formatted = esc(rag_desc).replace('\n', '
') - description_html = f'

Description:

{desc_formatted}

' + paras = [p.strip() for p in rag_desc.split('\n\n') if p.strip()] + if not paras: + paras = [rag_desc.strip()] + desc_body = "".join(f'

{esc(p)}

' for p in paras) + description_html = f'

Description:

{desc_body}
' - # Rationale — preserve line breaks + # Rationale — split into paragraphs rationale_html = "" if not is_compliant and rag_rationale: - rat_formatted = esc(rag_rationale).replace('\n', '
') - rationale_html = f'

Rationale:

{rat_formatted}

' + paras = [p.strip() for p in rag_rationale.split('\n\n') if p.strip()] + if not paras: + paras = [rag_rationale.strip()] + rat_body = "".join(f'

{esc(p)}

' for p in paras) + rationale_html = f'

Rationale:

{rat_body}
' # Remediation — formatted with code snippets remediation_html = "" @@ -5271,7 +5564,7 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: # Findings file link + resource table findings_file_html = "" findings_table_html = "" - csv_filename = (r.get("Filename", "") or "").strip() + csv_filename = (r.get("Filename", "") or "").strip().strip('"').strip("'") if csv_filename and not is_compliant: if file_id_map: fid = file_id_map.get(csv_filename, "") @@ -5287,10 +5580,17 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: if level: lvl_cls = "level-1" if level == "1" else "level-2" level_badge = f'Level {esc(level)}' + refs_html = "" + ref_parts = [] + if cis_v8: ref_parts.append(f"CIS v8: {esc(cis_v8)}") + if cccs: ref_parts.append(f"CCCS: {esc(cccs)}") + if ref_parts: + refs_html = f'
{" | ".join(ref_parts)}
' sections_html += f'''

{esc(rec)} {esc(title)} {level_badge}

+ {refs_html} {bar_html} {result_html} {findings_table_html} @@ -5302,6 +5602,62 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: sections_html += '
' + # ── OCI Services section ── + oci_services_html = "" + if oci_services: + svc_rows = "" + for svc in oci_services: + st = svc["status"] + st_cls = "pass" if st == "OK" else "warn" + svc_rows += f'{esc(st)}{esc(svc["service"])}{esc(svc["description"])}' + + # Build service detail pages (each pair of services on a new page with ORACLE header) + svc_detail_pages = "" + svc_list = [s for s in oci_services if _OCI_SERVICE_DESCRIPTIONS.get(s["service"])] + for idx, svc in enumerate(svc_list): + svc_name = svc["service"] + info = _OCI_SERVICE_DESCRIPTIONS[svc_name] + # Start new page every 2 services (or first one) + if idx % 2 == 0: + if idx > 0: + svc_detail_pages += '' # close previous page div + svc_detail_pages += f'
' + svc_detail_pages += '
ORACLE
' + + svc_detail_pages += f'
' + svc_detail_pages += f'

{esc(info.get("title", svc_name))}

' + desc = info.get("description", "") + for para in desc.split("\n"): + para = para.strip() + if para: + svc_detail_pages += f'

{esc(para)}

' + rec = info.get("recommendation", "") + if rec: + svc_detail_pages += f'

Recommendation:

{esc(rec)}

' + cg_recs = svc.get("recommendations", []) + if cg_recs: + svc_detail_pages += '' + svc_detail_pages += '' + for cr in cg_recs: + svc_detail_pages += f'' + svc_detail_pages += '
RecommendationsTotal
{esc(cr["name"])}{cr["count"]}
' + svc_detail_pages += '
' + if svc_list: + svc_detail_pages += '
' # close last page div + + oci_services_html = f''' +
+
ORACLE
+

OCI Services

+

Recommendations:

+

The recommended services are already enabled. This way we recommend further development of their usage:

+ + + {svc_rows} +
STATUSSERVICEDescription
+
+ {svc_detail_pages}''' + # ── TOC ── toc_lines = '
CIS Security Assessment Summary
' toc_lines += '
CIS Benchmark Recommendation Summary
' @@ -5313,6 +5669,8 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: title = r.get("Title", "-") rec_id = f"rec-{slug(sec_name)}-{slug(rec)}" toc_lines += f'
{esc(rec)} {esc(title)}
' + if oci_services: + toc_lines += '
OCI Services
' DISCLAIMER_TEXT = "This document provides prescriptive guidance for establishing a secure baseline configuration for the Oracle Cloud Infrastructure environment based on the CIS Oracle Cloud Infrastructure Foundations Benchmark. The scope of this benchmark is to establish a base level of security for anyone utilizing the included Oracle Cloud Infrastructure services. The benchmark is, however, not an exhaustive list of all possible security configurations and architectures. You should take the benchmark as a starting point and do the required site-specific tailoring wherever needed and when it is prudent to do so.\n\nThis document is provided \"as is\" for informational purposes only, without warranties of any kind, whether express or implied. The findings and recommendations reflect the conditions observed at the time of the assessment and do not guarantee the prevention of future security incidents or compliance outcomes. This document does not form part of any Oracle license, service contract, or other agreement." @@ -5339,7 +5697,7 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: -OCI CIS Foundations Benchmark Assessment — {esc(tenancy_name)} +CIS Foundations Benchmark Assessment — {esc(tenancy_name)}