← Back to list

Exporting OCI Cloud Guard Findings to Excel for Compliance Reporting Using Python SDK

Stop manually screenshotting the console. Here’s how to build a Python tool that pulls every Cloud Guard security finding from your OCI…

Tokishi · 2026-04-12 10:11 · 2 claps · 6.3 min read
#oci #python #automation #cloud-computing #cloud-security
Open on Medium ↗
Wiki topics: GEN · Genomics & Sequencing

Exporting OCI Cloud Guard Findings to Excel for Compliance Reporting Using Python SDK

Stop manually screenshotting the console. Here’s how to build a Python tool that pulls every Cloud Guard security finding from your OCI tenancy and exports it into a formatted, audit-ready Excel workbook — automatically.

What We Are Building

If you have worked with OCI, you know Cloud Guard is a powerful security posture management service. It scans your tenancy continuously and flags misconfigurations, suspicious activity, and policy violations as Problems.

But here is the gap that frustrates every cloud security engineer: Cloud Guard shows findings inside the console. Your compliance team, your auditors, and your manager want an Excel file they can sort, filter, annotate, and email. Not a browser tab.

This project closes that gap. By the end of this blog, you will have a working Python tool that:

  • Fetches all Cloud Guard problems across your tenancy using pagination
  • Pulls all detector recipe rules and their enabled/disabled status
  • Captures the responder activity audit trail (who fixed what, and when)
  • Exports everything into a 6-sheet, color-coded Excel workbook
  • Includes a mock data generator so you can test it without any OCI credentials

Project Structure

Before writing a single line of code, here is how the project is organized:

cloudguard-exporter/
├── cloudguard_exporter.py    # Main script — OCI API + Excel builder
├── generate_mock_report.py   # Demo mode — no OCI credentials needed
├── requirements.txt
├── output/                   # Reports saved here
└── utils/
    ├── helpers.py            # Date formatting, risk labels, truncation
    └── logger.py             # Structured console logger

Each file has a single responsibility. The main script handles only OCI API calls and workbook assembly. All formatting utilities live in helpers.py. This separation makes the helpers independently testable and makes the OCI layer easy to swap out or mock.

Prerequisites

Install the two dependencies:

pip install oci openpyxl

OCI config — you need a valid ~/.oci/config. If you have already used the OCI CLI, this is done. If not:

bash

oci setup config

IAM policy — add these statements at the tenancy level so your user can read Cloud Guard data:

Allow group SecurityAuditors to inspect cloud-guard-problems in tenancy
Allow group SecurityAuditors to read cloud-guard-detector-recipes in tenancy
Allow group SecurityAuditors to inspect cloud-guard-responder-activities in tenancy
Allow group SecurityAuditors to inspect compartments in tenancy

No OCI access yet? Run python generate_mock_report.py — it builds a fully realistic report with 45 sample findings using zero OCI credentials. Great for testing and for sharing screenshots in your blog.

Step-by-Step Code Walkthrough

Step 1 — Setting up OCI clients

The script creates two SDK clients: one for Cloud Guard and one for Identity (to resolve compartment names).

def build_clients(profile: str, region: str | None):
    config = oci.config.from_file(profile_name=profile)
    if region:
        config["region"] = region
    # Always validate before making any API calls
    oci.config.validate_config(config)
    cg_client = oci.cloud_guard.CloudGuardClient(config)
    id_client  = oci.identity.IdentityClient(config)
    return config, cg_client, id_client

Notice we call oci.config.validate_config() before touching any API. This catches missing fields, wrong key file paths, or invalid regions early — before you waste time on a broken API call.

Step 2 — Fetching problems with pagination

Cloud Guard can return hundreds of problems. The OCI API paginates results in pages of up to 100. Instead of manually looping with next_page tokens, we use the SDK's built-in pagination helper:

def fetch_problems(cg_client, compartment_id: str, status_filter: str | None) -> list[dict]:
    kwargs = {
        "compartment_id": compartment_id,
        "compartment_id_in_subtree": True,   # scan all child compartments
        "limit": 100,
    }
    if status_filter:
        kwargs["lifecycle_state"] = status_filter
    response = oci.pagination.list_call_get_all_results(
        cg_client.list_problems,
        **kwargs
    )
    problems = []
    for p in response.data:
        problems.append({
            "risk_level":       safe_str(p.risk_level),
            "resource_name":    safe_str(p.resource_name),
            "resource_type":    safe_str(p.resource_type),
            "status":           safe_str(p.lifecycle_state),
            "detected_at":      format_datetime(p.time_first_detected),
            "last_detected_at": format_datetime(p.time_last_detected),
            "recommendation":   safe_str(getattr(p, "recommendation", "")),
        })
    return problems

Key insight: Setting compartment_id_in_subtree=True means one call scans your root compartment and all child compartments recursively. Without this flag, you see only the top-level compartment.

Step 3 — The safe_str pattern (the most important habit)

OCI API responses return None for optional fields. Writing p.resource_name directly crashes with a TypeError the moment any field is missing. The safe_str helper protects every single field access:

# utils/helpers.py
def safe_str(val) -> str:
    if val is None:
        return ""
    return str(val)
def format_datetime(dt) -> str:
    if dt is None:
        return "N/A"
    try:
        return dt.strftime("%Y-%m-%d %H:%M UTC")
    except Exception:
        return str(dt)
def truncate(text: str, max_len: int) -> str:
    if not text:
        return ""
    text = str(text).strip()
    if len(text) <= max_len:
        return text
    return text[:max_len - 3] + "..."

This is the most important defensive habit when working with the OCI Python SDK. A single unguarded .attribute access on a None response can crash the entire script mid-run.

Step 4 — Fetching detector rules (two-level pagination)

Detector rules live inside recipes. You need to fetch all recipes first, then loop through each recipe to get its rules — two levels of pagination:

def fetch_detector_rules(cg_client, compartment_id: str) -> list[dict]:
    recipes = oci.pagination.list_call_get_all_results(
        cg_client.list_detector_recipes,
        compartment_id=compartment_id
    ).data
    rules = []
    for recipe in recipes:
        recipe_rules = oci.pagination.list_call_get_all_results(
            cg_client.list_detector_recipe_detector_rules,
            detector_recipe_id=recipe.id
        ).data
        for r in recipe_rules:
            rules.append({
                "recipe_name":     safe_str(recipe.display_name),
                "rule_id":         safe_str(r.detector_rule_id),
                "display_name":    safe_str(r.display_name),
                "risk_level":      safe_str(r.risk_level),
                "lifecycle_state": safe_str(r.lifecycle_state),
                "detector":        safe_str(r.detector),
                "recommendation":  safe_str(getattr(r, "recommendation", "")),
            })
    return rules

Step 5 — Building the Excel workbook

The workbook is assembled using openpyxl. The most reusable pattern in the whole project is apply_data_cell — a single function that handles alternating row colors and automatic risk-level color coding:

python

RISK_COLOR_MAP = {
    "CRITICAL": ("E74C3C", "FFFFFF"),   # red background, white text
    "HIGH":     ("E67E22", "FFFFFF"),   # orange
    "MEDIUM":   ("F1C40F", "1A1A1A"),   # yellow, dark text
    "LOW":      ("2ECC71", "FFFFFF"),   # green
}
def apply_data_cell(ws, row, col, value, alt_row=False,
                    risk=None, bold=False, center=False):
    cell = ws.cell(row=row, column=col, value=value)
    cell.border    = THIN_BORDER
    cell.font      = Font(size=9, bold=bold)
    cell.alignment = Alignment(
        vertical="center",
        wrap_text=True,
        horizontal="center" if center else "left"
    )
    if risk and risk.upper() in RISK_COLOR_MAP:
        bg, fg = RISK_COLOR_MAP[risk.upper()]
        cell.fill = PatternFill("solid", fgColor=bg)
        cell.font = Font(color=fg, size=9, bold=True)
    elif alt_row:
        cell.fill = PatternFill("solid", fgColor="EAF2FF")
    return cell

The problems sheet uses this on every row:

for row_idx, p in enumerate(data, start=2):
    alt  = row_idx % 2 == 0
    risk = p["risk_level"].upper()
    # Risk column — automatically colored red/orange/yellow/green
    apply_data_cell(ws, row_idx, 1, risk, risk=risk, bold=True, center=True)
    # All other columns — alternating light blue rows
    apply_data_cell(ws, row_idx, 2, p["resource_name"],  alt_row=alt)
    apply_data_cell(ws, row_idx, 3, p["resource_type"],  alt_row=alt)
    apply_data_cell(ws, row_idx, 4, p["status"],         alt_row=alt, center=True)
    apply_data_cell(ws, row_idx, 5, p["detected_at"],    alt_row=alt, center=True)
    apply_data_cell(ws, row_idx, 6, p["recommendation"], alt_row=alt)
# Enable auto-filter so the reader can sort and filter in Excel
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}1"

Step 6 — The “Critical & High” filtered sheet (zero duplicated code)

The Critical & High sheet is just the All Problems sheet with a filter applied. We pass a filter_fn argument — no need to duplicate anything:

# In main():
build_problems_sheet(wb, problems, "All Problems")
build_problems_sheet(
    wb, problems, "Critical & High",
    filter_fn=lambda p: p["risk_level"].upper() in ("CRITICAL", "HIGH")
)
# The function signature:
def build_problems_sheet(wb, problems, sheet_name, filter_fn=None):
    data = [p for p in problems if (filter_fn is None or filter_fn(p))]
    # ... rest of the sheet builder

This is a clean pattern for any report that needs multiple filtered views of the same data.

Step 7 — Charts on the summary sheet

The summary dashboard gets a bar chart (risk breakdown) and a pie chart (status breakdown) using openpyxl’s built-in chart support:

python

from openpyxl.chart import BarChart, Reference, PieChart
# Bar chart — problems by risk level
bar = BarChart()
bar.type   = "col"
bar.title  = "Problems by risk level"
bar.style  = 10
bar.width  = 18
bar.height = 12
data = Reference(ws, min_col=2, min_row=1, max_row=6)
cats = Reference(ws, min_col=1, min_row=2, max_row=6)
bar.add_data(data, titles_from_data=True)
bar.set_categories(cats)
ws.add_chart(bar, "A10")   # Anchor the chart at cell A10

The Reference rows point to the risk count table you wrote just above the chart — the chart reads its own data from the sheet, which keeps everything in sync automatically.

Running the Tool

Demo mode — no OCI credentials needed:

python generate_mock_report.py

Output:

[2026-03-25 10:15:42] INFO  Generating mock Cloud Guard data...
[2026-03-25 10:15:42] INFO  Building Excel workbook...
[2026-03-25 10:15:42] INFO  Sheet 'All Problems' written with 45 rows.
[2026-03-25 10:15:42] INFO  Sheet 'Critical & High' written with 12 rows.
[2026-03-25 10:15:42] INFO  Detector Rules sheet written with 15 rows.
[2026-03-25 10:15:42] INFO  Responder Activity sheet written with 5 rows.
Report saved to: output/cloudguard_mock_report_20260325_101542.xlsx

Against your real tenancy:

python cloudguard_exporter.py \
    --compartment-id ocid1.tenancy.oc1..YOUR_TENANCY_OCID \
    --profile DEFAULT \
    --region ap-mumbai-1

Export only open findings:

bash

python cloudguard_exporter.py \
    --compartment-id ocid1.tenancy.oc1..YOUR_TENANCY_OCID \
    --status OPEN

What Is in the Output Report

SheetContentsCoverMetadata, totals by risk and status, sheet indexSummary DashboardRisk bar chart, status pie chart, top affected resource typesAll ProblemsFull list, color-coded by risk, with auto-filterCritical & HighFiltered urgent findings only — ready for immediate triageDetector RulesAll recipe rules, risk levels, enabled/disabled stateResponder ActivityAudit trail of all remediation and notification actions

Key Takeaways

Working through this project teaches five habits that apply to any OCI Python SDK automation:

1. Always use oci.pagination.list_call_get_all_results() instead of manual pagination loops. It handles next_page tokens, retries, and edge cases automatically.

2. Set compartment_id_in_subtree=True whenever you want full tenancy visibility. Without it, child compartments are invisible to your script.

3. Wrap every API field access in safe_str() or an equivalent guard. OCI response objects return None for optional fields, and one unguarded access silently breaks everything downstream.

4. Catch oci.exceptions.ServiceError specifically, not bare Exception. It gives you the HTTP status code and the OCI error message, which makes debugging 10x faster.

5. Separate OCI logic from formatting logic. The main script knows nothing about Excel. The sheet builders know nothing about OCI. Both sides become independently testable.


메타데이터
post_id
e1d4c831fcf1
slug
exporting-oci-cloud-guard-findings-to-excel-for-compliance-reporting-using-python-sdk-e1d4c831fcf1
url
https://medium.com/@tokishi/exporting-oci-cloud-guard-findings-to-excel-for-compliance-reporting-using-python-sdk-e1d4c831fcf1
canonical_url
https://medium.com/@tokishi/exporting-oci-cloud-guard-findings-to-excel-for-compliance-reporting-using-python-sdk-e1d4c831fcf1
author_url
https://medium.com/@tokishi
status
ok
fetched_at
2026-06-24 11:06:28