Initial Scrivas AWS discovery deliverables

Read-only cloud discovery of the Scrivas AWS Organization (o-qfj0pvhhv7)
to inform a proposal.

- scripts/: boto3 org assessment, member-account assessment, fast discovery
- findings/: self-contained HTML dashboards, written report, summary JSON,
  and the rendered Prowler benchmark report
- docs/full_discovery_plan.md: phased full-discovery plan
- index.html: landing page linking all reports
- Pipfile/.python-version: reproducible pipenv env (Python 3.12.11)

Large raw scans (OCSF JSON, CSV, compliance/) are git-ignored.
This commit is contained in:
Alvaro Del Valle
2026-08-19 14:50:09 -04:00
commit 3cce1fa61f
17 changed files with 67154 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Account Assessment for AWS Organizations
=========================================
Read-only discovery of an AWS Organization's governance posture.
Scope:
1. Trusted access services (org-enabled service principals)
2. Delegated administrators (+ delegated services per admin)
3. Trust policies (org resource policy + IAM role trust relationships)
The AWS profile is passed into the boto3 Session object (never hard-coded creds).
Usage:
python3 org_assessment.py [--profile PROFILE] [--out PATH.json]
Client: Scrivas | Engagement: cloud footprint discovery
"""
import argparse
import json
import os
import sys
from datetime import datetime, date, timezone
import boto3
from botocore.exceptions import ClientError, BotoCoreError
DEFAULT_PROFILE = "dasnuve-scrivas-louis-impersonation"
DEFAULT_OUT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"findings", "org_assessment_report.json",
)
# Known third-party vendor account IDs, for annotating cross-account trusts.
VENDOR_ACCOUNTS = {
"123311413059": "Intruder.io (external vulnerability scanning)",
"728997465891": "Secureframe (SOC 2 / compliance automation)",
}
def _default(o):
if isinstance(o, (datetime, date)):
return o.isoformat()
return str(o)
def dump(label, obj):
print(f"\n{'=' * 70}\n{label}\n{'=' * 70}")
print(json.dumps(obj, indent=2, default=_default))
def safe(fn, *args, **kwargs):
"""Call an API, returning (data, error_string)."""
try:
return fn(*args, **kwargs), None
except (ClientError, BotoCoreError) as e:
return None, str(e)
def paginate(client, op, key, **kwargs):
out = []
try:
for page in client.get_paginator(op).paginate(**kwargs):
out.extend(page.get(key, []))
except (ClientError, BotoCoreError) as e:
return out, str(e)
return out, None
def assess(session):
ident = session.client("sts").get_caller_identity()
org = session.client("organizations")
report = {
"generated": datetime.now(timezone.utc).isoformat(),
"caller": {"Account": ident["Account"], "Arn": ident["Arn"]},
}
# Organization overview
o, err = safe(org.describe_organization)
report["organization"] = o.get("Organization") if o else {"error": err}
# 1. Trusted access services
svcs, err = paginate(org, "list_aws_service_access_for_organization",
"EnabledServicePrincipals")
report["trusted_access_services"] = {"error": err} if err else svcs
# 2. Delegated administrators (+ their delegated services)
admins, err = paginate(org, "list_delegated_administrators",
"DelegatedAdministrators")
if err:
report["delegated_administrators"] = {"error": err}
else:
for a in admins:
svc, serr = paginate(org, "list_delegated_services_for_account",
"DelegatedServices", AccountId=a["Id"])
a["DelegatedServices"] = {"error": serr} if serr else svc
report["delegated_administrators"] = admins
# 3a. Trust policies: organization resource-based policy
rp, err = safe(org.describe_resource_policy)
if err:
report["org_resource_policy"] = {"error": err}
else:
content = rp["ResourcePolicy"]["Content"]
report["org_resource_policy"] = json.loads(content) if content else None
# 3b. Trust policies: IAM role assume-role (trust) policies
iam = session.client("iam")
roles, err = paginate(iam, "list_roles", "Roles")
if err:
report["iam_role_trust_policies"] = {"error": err}
else:
trust = []
for r in roles:
doc = r.get("AssumeRolePolicyDocument", {})
flat = json.dumps(doc)
kind = []
if '"Service"' in flat:
kind.append("service")
if '"AWS"' in flat:
kind.append("cross-account/aws")
if '"Federated"' in flat:
kind.append("federated")
statements = []
vendor = None
for stmt in doc.get("Statement", []):
principal = stmt.get("Principal", {})
aws_p = principal.get("AWS") if isinstance(principal, dict) else None
for acct, name in VENDOR_ACCOUNTS.items():
if aws_p and acct in json.dumps(aws_p):
vendor = name
statements.append({
"Effect": stmt.get("Effect"),
"Action": stmt.get("Action"),
"Principal": principal,
"Condition": stmt.get("Condition"),
})
trust.append({
"RoleName": r["RoleName"],
"Path": r.get("Path"),
"Arn": r["Arn"],
"Kind": kind,
"Vendor": vendor,
"Statements": statements,
})
report["iam_role_trust_policies"] = trust
return report
def summarize(report):
"""One-line-per-item human summary of the key governance signals."""
print(f"\n{'#' * 70}\n# SUMMARY\n{'#' * 70}")
org = report.get("organization", {})
print(f"Org: {org.get('Id')} | mgmt acct {org.get('MasterAccountId')} "
f"({org.get('MasterAccountEmail')}) | FeatureSet {org.get('FeatureSet')}")
svcs = report.get("trusted_access_services", [])
if isinstance(svcs, list):
print(f"Trusted access services: {len(svcs)} -> "
+ ", ".join(s["ServicePrincipal"] for s in svcs))
admins = report.get("delegated_administrators", [])
if isinstance(admins, list):
flag = " <-- GAP: none set" if not admins else ""
print(f"Delegated administrators: {len(admins)}{flag}")
roles = report.get("iam_role_trust_policies", [])
if isinstance(roles, list):
xacct = [r for r in roles if "cross-account/aws" in r["Kind"]]
fed = [r for r in roles if "federated" in r["Kind"]]
print(f"IAM roles: {len(roles)} total | {len(xacct)} cross-account | "
f"{len(fed)} federated")
for r in xacct:
tag = f" [{r['Vendor']}]" if r.get("Vendor") else ""
print(f" - {r['RoleName']}{tag}")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--profile", default=DEFAULT_PROFILE)
ap.add_argument("--out", default=DEFAULT_OUT)
ap.add_argument("--quiet", action="store_true",
help="skip verbose section dumps, print summary only")
args = ap.parse_args()
session = boto3.Session(profile_name=args.profile)
report = assess(session)
if not args.quiet:
dump("CALLER / ORG",
{"caller": report["caller"], "organization": report["organization"]})
dump("1. TRUSTED ACCESS SERVICES", report["trusted_access_services"])
dump("2. DELEGATED ADMINISTRATORS", report["delegated_administrators"])
dump("3a. ORG RESOURCE POLICY", report["org_resource_policy"])
dump("3b. IAM ROLE TRUST POLICIES", report["iam_role_trust_policies"])
summarize(report)
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as f:
json.dump(report, f, indent=2, default=_default)
print(f"\nFull JSON written to: {args.out}")
if __name__ == "__main__":
sys.exit(main())