Add MCP tool discovery (auto + manual), tool execution via MCP SDK, and GenAI function calling loop (Cohere + Generic formats) so the chat agent can invoke MCP server tools during conversations. - Add mcp SDK dependency - New endpoints: discover-tools, update tools - Modify _call_genai to support tools/tool_results and return (text, tool_calls) - Tool use loop in chat endpoint (max 5 iterations) - Frontend: tools management in MCP cards, chat toggle, tools_used display - Multi-table ADB vector search, enriched embeddings, preview chunks - Orphaned report cleanup on container restart - Nginx no-cache headers, searchable dropdowns, editable vector tables
1372 lines
77 KiB
Python
1372 lines
77 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
CIS OCI Foundations Benchmark 3.0 — Compliance Checker
|
|
|
|
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, 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","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 = {}
|
|
for cid, ck in findings.items():
|
|
sec = ck["section"]
|
|
sections.setdefault(sec, {"total":0,"passed":0,"failed":0})
|
|
sections[sec]["total"] += 1
|
|
if ck["status"] == "PASS": sections[sec]["passed"] += 1
|
|
elif ck["status"] == "FAIL": sections[sec]["failed"] += 1
|
|
tot = sum(s["total"] for s in sections.values())
|
|
pas = sum(s["passed"] for s in sections.values())
|
|
fai = sum(s["failed"] for s in sections.values())
|
|
sec_rows = "".join(f'<tr><td><strong>{s}</strong></td><td>{v["total"]}</td>'
|
|
f'<td style="color:#dc2626;font-weight:700">{v["failed"]}</td>'
|
|
f'<td style="color:#16a34a;font-weight:700">{v["passed"]}</td></tr>' for s,v in sorted(sections.items()))
|
|
det_rows = ""
|
|
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"
|
|
finding_details = ""
|
|
if ck.get("findings"):
|
|
items = "".join(f"<li>{f}</li>" for f in ck["findings"][:10])
|
|
if len(ck["findings"]) > 10:
|
|
items += f"<li>... and {len(ck['findings'])-10} more</li>"
|
|
finding_details = f'<br><ul style="margin:.25rem 0 0 1rem;font-size:.8rem;color:#4a5568">{items}</ul>'
|
|
det_rows += f'<tr><td>{ck["id"]}</td><td>{cid}</td><td>{ck["title"]}{finding_details}</td><td><span class="b b-{cls}">{st}</span></td><td>{ck["section"]}</td></tr>'
|
|
html = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Cloud Security Assessment - {tenancy}</title><style>
|
|
*{{margin:0;padding:0;box-sizing:border-box}}body{{font-family:'Segoe UI',system-ui,sans-serif;background:#f8f9fa;color:#1a1a2e;line-height:1.6}}
|
|
.c{{max-width:1200px;margin:0 auto;padding:2rem}}h1{{font-size:2.5rem;color:#1a1a2e;margin-bottom:.5rem}}
|
|
h2{{color:#2d3748;margin:2rem 0 1rem;padding-bottom:.5rem;border-bottom:2px solid #e2e8f0}}
|
|
.m{{color:#718096;margin-bottom:2rem}}.d{{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:1.5rem;margin:2rem 0}}
|
|
.d h3{{color:#c53030;margin-bottom:.5rem}}.d p{{font-size:.85rem;color:#4a5568}}
|
|
table{{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1);margin:1rem 0}}
|
|
th{{background:#1a365d;color:#fff;padding:.75rem 1rem;text-align:left;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em}}
|
|
td{{padding:.75rem 1rem;border-bottom:1px solid #e2e8f0}}tr:hover{{background:#f7fafc}}
|
|
.b{{padding:.25rem .75rem;border-radius:12px;font-size:.8rem;font-weight:600}}
|
|
.b-pass{{background:#c6f6d5;color:#22543d}}.b-fail{{background:#fed7d7;color:#9b2c2c}}.b-review{{background:#fefcbf;color:#744210}}
|
|
.sc{{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin:1.5rem 0}}
|
|
.sd{{background:#fff;border-radius:8px;padding:1.5rem;box-shadow:0 1px 3px rgba(0,0,0,.1);text-align:center}}
|
|
.sd .n{{font-size:2.5rem;font-weight:700}}.sd .l{{color:#718096;font-size:.9rem}}
|
|
.ft{{text-align:center;color:#a0aec0;margin-top:3rem;padding:1rem;border-top:1px solid #e2e8f0}}
|
|
</style></head><body><div class="c">
|
|
<h1>Cloud Security Assessment</h1>
|
|
<div class="m"><strong>Tenancy:</strong> {tenancy}<br>OCI CIS Foundations Benchmark 3.0<br><br>
|
|
{now.strftime('%B, %Y')}, Version [1.0]<br>Extract date: {now.isoformat()[:19]}</div>
|
|
<div class="d"><h3>Disclaimer</h3>
|
|
<p>This document, in any form, software or printed matter, contains proprietary information that is the exclusive property of Oracle.
|
|
Your access to and use of this confidential material is subject to the terms and conditions of your Oracle software license and service agreement,
|
|
which has been executed and with which you agree to comply.</p>
|
|
<p style="margin-top:.5rem">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.</p></div>
|
|
<h2>Findings Overview</h2><p style="color:#718096">Resumo por dominio (Section)</p>
|
|
<div class="sc"><div class="sd"><div class="n" style="color:#2d3748">{tot}</div><div class="l">Total Controls</div></div>
|
|
<div class="sd"><div class="n" style="color:#16a34a">{pas}</div><div class="l">Passed</div></div>
|
|
<div class="sd"><div class="n" style="color:#dc2626">{fai}</div><div class="l">Failed</div></div></div>
|
|
<table><thead><tr><th>Domains</th><th>Total Controls</th><th>Failed</th><th>Passed</th></tr></thead><tbody>{sec_rows}</tbody></table>
|
|
<p style="color:#718096;margin-top:.5rem">Total: {tot} · Passed: {pas} · Failed: {fai}</p>
|
|
<h2>Detailed Findings</h2>
|
|
<table><thead><tr><th>ID</th><th>Check #</th><th>Title</th><th>Status</th><th>Section</th></tr></thead><tbody>{det_rows}</tbody></table>
|
|
<div class="ft">Generated by OCI CIS AI Agent · CIS OCI Foundations Benchmark 3.0 · {now.strftime('%Y-%m-%d %H:%M:%S')}</div>
|
|
</div></body></html>"""
|
|
Path(output).write_text(html, encoding="utf-8")
|
|
|
|
|
|
# ─── 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}")
|
|
|
|
if not Path(args.config).exists():
|
|
print(f"[ERROR] Config not found: {args.config}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
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": 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"),
|
|
},
|
|
"findings": findings,
|
|
}
|
|
|
|
(out / "report.json").write_text(json.dumps(report, indent=2, default=str))
|
|
gen_html(findings, args.tenancy_name, str(out / "report.html"))
|
|
print("[CIS Runner] Reports generated successfully")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|