Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ The CLI supports multiple input modes for both AWS and Azure.
| Interactive (manual credentials) | `python main.py aws` |
| AWS CLI profile | `python main.py aws --profile PROFILE` |
| JSON config file | `python main.py aws --config config.json` |
| Terraform/OpenTofu state file | `python main.py aws --tfstate infra.tfstate` |
| Non-interactive (env vars, CI) | `python main.py aws --non-interactive` |

**Azure**
Expand All @@ -99,6 +100,7 @@ The CLI supports multiple input modes for both AWS and Azure.
| Interactive (service principal) | `python main.py azure` |
| Azure CLI session | `python main.py azure --cli` |
| JSON config file | `python main.py azure --config config.json` |
| Terraform/OpenTofu state file | `python main.py azure --tfstate infra.tfstate` |
| Non-interactive (env vars, CI) | `python main.py azure --non-interactive` |

See the [configuration reference](https://cloudexit.escapecloud.io/config/config-schema.html) for required permissions and config file format.
Expand All @@ -116,6 +118,23 @@ python main.py aws --profile PROFILE --egress

See the [egress reference](https://cloudexit.escapecloud.io/egress/overview.html) for details.

## Infrastructure-as-Code State Scan (alpha)

Instead of connecting to a cloud account, cloudexit can build the assessment from a local **Terraform / OpenTofu state file** — no credentials, no API calls, nothing leaves your machine.

```bash
python main.py aws --tfstate infra.tfstate
python main.py azure --tfstate infra.tfstate
```

If your state lives in a remote backend (S3, Azure Storage, Terraform Cloud, …), export it first:

```bash
terraform state pull > infra.tfstate
```

See the [infrastructure-as-code reference](https://cloudexit.escapecloud.io/tfstate/overview.html) for details.

## CI/CD

cloudexit runs headlessly in CI pipelines via `--non-interactive` and environment variables. A ready-made GitHub Action is available:
Expand Down
30 changes: 29 additions & 1 deletion core/engine.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# core/engine.py
import json
import logging
import os
import boto3
Expand All @@ -14,6 +15,7 @@
from .utils_aws import build_aws_resource_inventory, build_aws_cost_inventory
from .utils_azure import build_azure_resource_inventory, build_azure_cost_inventory
from .utils_db import connect, load_data
from .utils_tfstate import build_tfstate_resource_inventory
from .utils_report import (
generate_html_report,
generate_pdf_report,
Expand Down Expand Up @@ -256,7 +258,16 @@ def create_resource_inventory(

try:

if cloud_service_provider == 1: # Azure
if provider_details.get("tfstatePath"): # Terraform / OpenTofu state file
coverage = build_tfstate_resource_inventory(
cloud_service_provider, provider_details, report_path, raw_data_path
)
return {
"success": True,
"logs": "Resource inventory created successfully.",
"coverage": coverage,
}
elif cloud_service_provider == 1: # Azure
build_azure_resource_inventory(
cloud_service_provider, provider_details, report_path, raw_data_path
)
Expand Down Expand Up @@ -497,6 +508,17 @@ def perform_risk_assessment(
return {"success": False, "logs": str(e)}


def _load_tfstate_scope(raw_data_path: str) -> dict[str, Any] | None:
# A missing or unreadable manifest must not fail report generation.
manifest_path = os.path.join(raw_data_path, "tfstate_manifest.json")
try:
with open(manifest_path, "r", encoding="utf-8") as manifest_file:
return json.load(manifest_file).get("scope")
except (OSError, json.JSONDecodeError) as e:
logger.warning("Could not read the tfstate manifest for report scope: %s", e)
return None


# Stage 6
def generate_report(
cloud_service_provider: int,
Expand Down Expand Up @@ -568,6 +590,11 @@ def generate_report(
)

# Generate PDF report
tfstate_scope = (
_load_tfstate_scope(raw_data_path)
if provider_details.get("tfstatePath")
else None
)
reports["PDF"] = generate_pdf_report(
provider_details,
report_path,
Expand All @@ -581,6 +608,7 @@ def generate_report(
alternatives,
alternative_technologies,
exit_strategy,
tfstate_scope=tfstate_scope,
)

# Generate JSON report
Expand Down
80 changes: 76 additions & 4 deletions core/utils_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,15 +279,84 @@ def _build_summary_section(metadata, styles, content_style):
return content


def _build_scope_section(metadata, provider_details, styles, content_style):
# Cap on how many subscriptions / resource groups / regions a scope row lists
# before it summarises the remainder. The full list stays in the manifest.
MAX_SCOPE_VALUES = 3


def _shorten(value: str, head: int = 4, tail: int = 4) -> str:
if not isinstance(value, str):
return "N/A"
if len(value) <= head + tail + 1:
return value
return f"{value[:head]}…{value[-tail:]}"


def _join_scope_values(values) -> str:
if not values:
return "Not determinable from state"
shown = list(values)[:MAX_SCOPE_VALUES]
remaining = len(values) - len(shown)
text = ", ".join(shown)
return f"{text}, +{remaining} more" if remaining > 0 else text


def _build_tfstate_scope_rows(metadata, scope):
rows = []

file_name = scope.get("file") or "N/A"
sha256 = scope.get("sha256")
if sha256:
file_name = f"{file_name} (SHA-256 {_shorten(sha256)})"
rows.append(["File", file_name])

lineage = scope.get("lineage")
serial = scope.get("serial")
if lineage and serial is not None:
rows.append(["State", f"{lineage} (Serial: {serial})"])
elif lineage:
rows.append(["State", lineage])
elif serial is not None:
rows.append(["State", f"Serial: {serial}"])

if metadata["cloud_service_provider"] == 1: # Azure
rows.append(["Subscription", _join_scope_values(scope.get("subscriptions"))])
rows.append(
["Resource Group(s)", _join_scope_values(scope.get("resource_groups"))]
)
else: # AWS
rows.append(["Region(s)", _join_scope_values(scope.get("locations"))])

return rows


def _build_scope_section(
metadata, provider_details, styles, content_style, tfstate_scope=None
):
"""Page 1: Scope of Assessment table."""
content = []
content.append(Paragraph("Scope of Assessment", styles["Heading2"]))
content.append(Paragraph("Defined scope of assessment:", content_style))

tfstate_path = provider_details.get("tfstatePath")
if tfstate_path:
content.append(
Paragraph(
"Assessed from Terraform state. Resources not managed by "
"Terraform are not included.",
content_style,
)
)
else:
content.append(Paragraph("Defined scope of assessment:", content_style))

scope_data = [["Name", "Value"]]

if metadata["cloud_service_provider"] == 1: # Azure
if tfstate_path:
# Fall back to the little we know from the config when the manifest is
# unavailable, rather than rendering credential rows that are all N/A.
scope = tfstate_scope or {"file": os.path.basename(tfstate_path)}
scope_data.extend(_build_tfstate_scope_rows(metadata, scope))
elif metadata["cloud_service_provider"] == 1: # Azure
scope_data.extend(
[
["Tenant ID", provider_details.get("tenantId", "N/A")],
Expand Down Expand Up @@ -708,6 +777,7 @@ def generate_pdf_report(
alternatives: list[dict[str, Any]],
alternative_technologies: list[dict[str, Any]],
exit_strategy: int,
tfstate_scope: dict[str, Any] | None = None,
) -> str:
pdf_path = os.path.join(report_path, "report.pdf")

Expand All @@ -728,7 +798,9 @@ def header_footer(canvas, doc):

content = []
content += _build_summary_section(metadata, styles, content_style)
content += _build_scope_section(metadata, provider_details, styles, content_style)
content += _build_scope_section(
metadata, provider_details, styles, content_style, tfstate_scope
)
content += _build_cost_section(cost_data, styles, content_style)
content += _build_risk_section(
risk_data,
Expand Down
Loading