Files
scrivas/scripts/assess_member_account.py
Alvaro Del Valle 3cce1fa61f 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.
2026-08-19 14:50:09 -04:00

158 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""
Member-Account Trust Assessment
================================
Extends the org assessment into a member account by assuming a cross-account
role from the management-account profile, then evaluating that account's
IAM role trust relationships and any delegated services it is aware of.
Delegated administrators & trusted-access services are ORG-LEVEL and can only
be read from the management account, so this focuses on what is meaningful
from inside a member account: IAM role trust policies (service / cross-account
/ federated) and account-level org context.
Usage:
python3 assess_member_account.py --account 547868853286 [--name Lazka]
[--role OrganizationAccountAccessRole] [--profile PROFILE]
"""
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"
FINDINGS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "findings")
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 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 assume(session, account_id, role_name, candidate_roles):
"""Try the primary role, then fall back through candidate role names."""
sts = session.client("sts")
tried = [role_name] + [r for r in candidate_roles if r != role_name]
errors = {}
for rn in tried:
arn = f"arn:aws:iam::{account_id}:role/{rn}"
try:
creds = sts.assume_role(
RoleArn=arn, RoleSessionName="scrivas-discovery")["Credentials"]
print(f"[+] Assumed {arn}")
return boto3.Session(
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
), rn, None
except (ClientError, BotoCoreError) as e:
errors[rn] = str(e)
print(f"[-] {rn}: {e}")
return None, None, errors
def assess_iam_trust(member_session):
iam = member_session.client("iam")
roles, err = paginate(iam, "list_roles", "Roles")
if err:
return {"error": err}
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")
vendor = None
for stmt in doc.get("Statement", []):
p = stmt.get("Principal", {})
aws_p = p.get("AWS") if isinstance(p, dict) else None
for acct, name in VENDOR_ACCOUNTS.items():
if aws_p and acct in json.dumps(aws_p):
vendor = name
trust.append({
"RoleName": r["RoleName"],
"Path": r.get("Path"),
"Kind": kind,
"Vendor": vendor,
"AssumeRolePolicyDocument": doc,
})
return trust
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--account", required=True)
ap.add_argument("--name", default="member")
ap.add_argument("--role", default="OrganizationAccountAccessRole")
ap.add_argument("--profile", default=DEFAULT_PROFILE)
args = ap.parse_args()
session = boto3.Session(profile_name=args.profile)
candidates = ["OrganizationAccountAccessRole", "AWSControlTowerExecution",
"OrganizationAccountAccessRole", "AdministratorAccess"]
member, role_used, err = assume(session, args.account, args.role, candidates)
result = {
"generated": datetime.now(timezone.utc).isoformat(),
"account_id": args.account,
"account_name": args.name,
}
if member is None:
result["access"] = {"assumed": False, "errors": err}
print("\n[!] Could not assume any cross-account role into "
f"{args.name} ({args.account}). This itself is a finding: no "
"management-account access path exists into the member account.")
else:
ident = member.client("sts").get_caller_identity()
result["access"] = {"assumed": True, "role_used": role_used,
"assumed_arn": ident["Arn"]}
result["iam_role_trust_policies"] = assess_iam_trust(member)
roles = result["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"\n[{args.name} / {args.account}] IAM roles: {len(roles)} | "
f"{len(xacct)} cross-account | {len(fed)} federated")
for r in xacct:
tag = f" [{r['Vendor']}]" if r.get("Vendor") else ""
print(f" - {r['RoleName']}{tag}")
out = os.path.join(FINDINGS_DIR, f"member_{args.name.lower()}_{args.account}.json")
os.makedirs(FINDINGS_DIR, exist_ok=True)
with open(out, "w") as f:
json.dump(result, f, indent=2, default=_default)
print(f"\nWritten to: {out}")
if __name__ == "__main__":
sys.exit(main())