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:
@@ -0,0 +1,157 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Scrivas — Fast-Track Discovery (hours, not days)
|
||||
================================================
|
||||
Read-only. Prioritizes the four highest-leverage phases:
|
||||
- Cost baseline (Cost Explorer, payer account)
|
||||
- Resource census (active regions only)
|
||||
- Security exposure (public S3, open SGs, GuardDuty/SecurityHub/CloudTrail)
|
||||
- IAM quick hits (users, MFA, key age, root usage, password policy)
|
||||
|
||||
Runs across the whole org: management account directly, members via assume-role.
|
||||
Profile is passed into the boto3 Session object.
|
||||
|
||||
Usage: python3 fast_discovery.py [--profile PROFILE]
|
||||
Output: findings/fast_discovery.json
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError, BotoCoreError
|
||||
|
||||
DEFAULT_PROFILE = "dasnuve-scrivas-louis-impersonation"
|
||||
FINDINGS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "findings")
|
||||
MEMBER_ROLE = "OrganizationAccountAccessRole"
|
||||
|
||||
|
||||
def _default(o):
|
||||
return o.isoformat() if isinstance(o, (datetime, date)) else str(o)
|
||||
|
||||
|
||||
def safe(fn, *a, **k):
|
||||
try:
|
||||
return fn(*a, **k), None
|
||||
except (ClientError, BotoCoreError) as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def pg(client, op, key, **k):
|
||||
out = []
|
||||
try:
|
||||
for page in client.get_paginator(op).paginate(**k):
|
||||
out.extend(page.get(key, []))
|
||||
except (ClientError, BotoCoreError) as e:
|
||||
return out, str(e)
|
||||
return out, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- regions
|
||||
def active_regions(session):
|
||||
"""Enabled regions that actually contain EC2 instances or EBS volumes,
|
||||
plus us-east-1 (global services). Keeps the scan fast."""
|
||||
ec2 = session.client("ec2", region_name="us-east-1")
|
||||
regs, err = safe(ec2.describe_regions, AllRegions=False)
|
||||
enabled = [r["RegionName"] for r in regs["Regions"]] if regs else ["us-east-1", "us-east-2"]
|
||||
active = set(["us-east-1"])
|
||||
for r in enabled:
|
||||
c = session.client("ec2", region_name=r)
|
||||
insts, _ = safe(c.describe_instances, MaxResults=5)
|
||||
if insts and any(res.get("Instances") for res in insts.get("Reservations", [])):
|
||||
active.add(r); continue
|
||||
vols, _ = safe(c.describe_volumes, MaxResults=5)
|
||||
if vols and vols.get("Volumes"):
|
||||
active.add(r)
|
||||
return sorted(active), enabled
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- IAM
|
||||
def iam_quickhits(session):
|
||||
iam = session.client("iam")
|
||||
out = {}
|
||||
users, err = pg(iam, "list_users", "Users")
|
||||
if err:
|
||||
return {"error": err}
|
||||
detail = []
|
||||
no_mfa = 0
|
||||
stale_keys = []
|
||||
for u in users:
|
||||
name = u["UserName"]
|
||||
mfa, _ = safe(iam.list_mfa_devices, UserName=name)
|
||||
has_mfa = bool(mfa and mfa.get("MFADevices"))
|
||||
if not has_mfa:
|
||||
no_mfa += 1
|
||||
keys, _ = safe(iam.list_access_keys, UserName=name)
|
||||
kinfo = []
|
||||
for k in (keys or {}).get("AccessKeyMetadata", []):
|
||||
age = (datetime.now(timezone.utc) - k["CreateDate"]).days
|
||||
lu, _ = safe(iam.get_access_key_last_used, AccessKeyId=k["AccessKeyId"])
|
||||
last = (lu or {}).get("AccessKeyLastUsed", {}).get("LastUsedDate")
|
||||
kinfo.append({"id": k["AccessKeyId"][-4:], "status": k["Status"],
|
||||
"age_days": age, "last_used": last})
|
||||
if age > 90:
|
||||
stale_keys.append(f"{name}:***{k['AccessKeyId'][-4:]} ({age}d)")
|
||||
detail.append({"user": name, "mfa": has_mfa, "keys": kinfo})
|
||||
pw, _ = safe(iam.get_account_password_policy)
|
||||
summ, _ = safe(iam.get_account_summary)
|
||||
out = {
|
||||
"user_count": len(users),
|
||||
"users_without_mfa": no_mfa,
|
||||
"stale_keys_over_90d": stale_keys,
|
||||
"password_policy": (pw or {}).get("PasswordPolicy", "none set"),
|
||||
"account_summary": (summ or {}).get("SummaryMap", {}),
|
||||
"users": detail,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- census + exposure
|
||||
def census_and_exposure(session, regions):
|
||||
census = {}
|
||||
exposure = {"public_buckets": [], "open_security_groups": [], "public_rds": []}
|
||||
|
||||
# S3 (global list, per-bucket public-access)
|
||||
s3 = session.client("s3")
|
||||
buckets, err = safe(s3.list_buckets)
|
||||
blist = (buckets or {}).get("Buckets", [])
|
||||
census["s3_buckets"] = len(blist)
|
||||
for b in blist:
|
||||
name = b["Name"]
|
||||
pab, _ = safe(s3.get_public_access_block, Bucket=name)
|
||||
cfg = (pab or {}).get("PublicAccessBlockConfiguration", {})
|
||||
blocked = all([cfg.get("BlockPublicAcls"), cfg.get("IgnorePublicAcls"),
|
||||
cfg.get("BlockPublicPolicy"), cfg.get("RestrictPublicBuckets")])
|
||||
if not blocked:
|
||||
exposure["public_buckets"].append(
|
||||
{"bucket": name, "public_access_block": cfg or "none"})
|
||||
|
||||
# per-region compute/db/net
|
||||
per_region = {}
|
||||
for r in regions:
|
||||
rr = {}
|
||||
ec2 = session.client("ec2", region_name=r)
|
||||
insts, _ = pg(ec2, "describe_instances", "Reservations")
|
||||
rr["ec2_instances"] = sum(len(x.get("Instances", [])) for x in insts)
|
||||
vols, _ = pg(ec2, "describe_volumes", "Volumes")
|
||||
rr["ebs_volumes"] = len(vols)
|
||||
rr["ebs_unattached"] = sum(1 for v in vols if not v.get("Attachments"))
|
||||
eips, _ = safe(ec2.describe_addresses)
|
||||
addrs = (eips or {}).get("Addresses", [])
|
||||
rr["eips"] = len(addrs)
|
||||
rr["eips_unassociated"] = sum(1 for a in addrs if not a.get("AssociationId"))
|
||||
sgs, _ = pg(ec2, "describe_security_groups", "SecurityGroups")
|
||||
for sg in sgs:
|
||||
for perm in sg.get("IpPermissions", []):
|
||||
for ipr in perm.get("IpRanges", []):
|
||||
if ipr.get("CidrIp") == "0.0.0.0/0":
|
||||
exposure["open_security_groups"].append({
|
||||
"region": r, "group": sg["GroupId"],
|
||||
"from_port": perm.get("FromPort"), "to_port": perm.get("ToPort"),
|
||||
"proto": perm.get("IpProtocol")})
|
||||
rds = session.client("rds", region_name=r)
|
||||
dbs, _ = pg(rds, "describe_db_instances", "DBInstances")
|
||||
rr["rds_instances"] = len(dbs)
|
||||
for d in dbs:
|
||||
if d.get("PubliclyAccessible"):
|
||||
exposure["public_rds"].append({"region": r, "db": d["DBInstanceIdentifier"]})
|
||||
lam = session.client("lambda", region_name=r)
|
||||
fns, _ = pg(lam, "list_functions", "Functions")
|
||||
rr["lambda_functions"] = len(fns)
|
||||
eks = session.client("eks", region_name=r)
|
||||
clusters, _ = pg(eks, "list_clusters", "clusters")
|
||||
rr["eks_clusters"] = len(clusters)
|
||||
rr["eks_cluster_names"] = clusters
|
||||
elbv2 = session.client("elbv2", region_name=r)
|
||||
lbs, _ = pg(elbv2, "describe_load_balancers", "LoadBalancers")
|
||||
rr["load_balancers"] = len(lbs)
|
||||
per_region[r] = {k: v for k, v in rr.items() if v not in (0, [], None)} or {"(empty)": True}
|
||||
census["by_region"] = per_region
|
||||
return census, exposure
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- security services
|
||||
def security_services(session, regions):
|
||||
out = {}
|
||||
# CloudTrail (any region lists org+shadow trails)
|
||||
ct = session.client("cloudtrail", region_name="us-east-1")
|
||||
trails, _ = safe(ct.describe_trails, includeShadowTrails=True)
|
||||
tl = (trails or {}).get("trailList", [])
|
||||
out["cloudtrail"] = [{"name": t["Name"], "multi_region": t.get("IsMultiRegionTrail"),
|
||||
"org_trail": t.get("IsOrganizationTrail"),
|
||||
"log_validation": t.get("LogFileValidationEnabled")} for t in tl]
|
||||
# GuardDuty / SecurityHub per active region
|
||||
gd_findings = {}
|
||||
shub = {}
|
||||
for r in regions:
|
||||
g = session.client("guardduty", region_name=r)
|
||||
dets, _ = safe(g.list_detectors)
|
||||
for d in (dets or {}).get("DetectorIds", []):
|
||||
stats, _ = safe(g.get_findings_statistics, DetectorId=d,
|
||||
FindingCriteria={}, FindingStatisticTypes=["COUNT_BY_SEVERITY"])
|
||||
gd_findings[r] = (stats or {}).get("FindingStatistics", {}).get("CountBySeverity", {})
|
||||
s = session.client("securityhub", region_name=r)
|
||||
desc, err = safe(s.describe_hub)
|
||||
shub[r] = "enabled" if desc else "not enabled"
|
||||
out["guardduty_findings_by_severity"] = gd_findings
|
||||
out["securityhub"] = shub
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- cost
|
||||
def cost_baseline(session):
|
||||
ce = session.client("ce", region_name="us-east-1")
|
||||
end = date.today().replace(day=1)
|
||||
start = (end - timedelta(days=185)).replace(day=1)
|
||||
res, err = safe(ce.get_cost_and_usage,
|
||||
TimePeriod={"Start": start.isoformat(), "End": end.isoformat()},
|
||||
Granularity="MONTHLY", Metrics=["UnblendedCost"],
|
||||
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}])
|
||||
if err:
|
||||
return {"error": err}
|
||||
months = []
|
||||
service_totals = {}
|
||||
for period in res["ResultsByTime"]:
|
||||
m = period["TimePeriod"]["Start"]
|
||||
total = 0.0
|
||||
for g in period["Groups"]:
|
||||
amt = float(g["Metrics"]["UnblendedCost"]["Amount"])
|
||||
svc = g["Keys"][0]
|
||||
service_totals[svc] = service_totals.get(svc, 0.0) + amt
|
||||
total += amt
|
||||
months.append({"month": m, "total": round(total, 2)})
|
||||
top = sorted(service_totals.items(), key=lambda x: -x[1])[:12]
|
||||
return {"monthly_total": months,
|
||||
"top_services_6mo": [{"service": s, "cost": round(v, 2)} for s, v in top]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- driver
|
||||
def assess_account(session, account_id, name, is_payer):
|
||||
print(f"\n=== {name} ({account_id}) ===")
|
||||
regions, enabled = active_regions(session)
|
||||
print(f" active regions: {regions} (of {len(enabled)} enabled)")
|
||||
census, exposure = census_and_exposure(session, regions)
|
||||
result = {
|
||||
"account_id": account_id, "name": name,
|
||||
"active_regions": regions, "enabled_region_count": len(enabled),
|
||||
"iam": iam_quickhits(session),
|
||||
"census": census,
|
||||
"exposure": exposure,
|
||||
"security_services": security_services(session, regions),
|
||||
}
|
||||
if is_payer:
|
||||
result["cost"] = cost_baseline(session)
|
||||
print(f" ec2/eks/rds/lambda scanned | public buckets: {len(exposure['public_buckets'])}"
|
||||
f" | open SGs: {len(exposure['open_security_groups'])}"
|
||||
f" | users w/o MFA: {result['iam'].get('users_without_mfa')}")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--profile", default=DEFAULT_PROFILE)
|
||||
args = ap.parse_args()
|
||||
|
||||
base = boto3.Session(profile_name=args.profile)
|
||||
payer = base.client("sts").get_caller_identity()["Account"]
|
||||
org = base.client("organizations")
|
||||
accounts, _ = pg(org, "list_accounts", "Accounts")
|
||||
|
||||
report = {"generated": datetime.now(timezone.utc).isoformat(),
|
||||
"payer_account": payer, "accounts": []}
|
||||
|
||||
for a in accounts:
|
||||
if a["Status"] != "ACTIVE":
|
||||
continue
|
||||
aid, name = a["Id"], a["Name"]
|
||||
if aid == payer:
|
||||
sess = base
|
||||
else:
|
||||
sts = base.client("sts")
|
||||
creds, err = safe(sts.assume_role,
|
||||
RoleArn=f"arn:aws:iam::{aid}:role/{MEMBER_ROLE}",
|
||||
RoleSessionName="scrivas-fast-discovery")
|
||||
if err:
|
||||
report["accounts"].append({"account_id": aid, "name": name,
|
||||
"error": f"assume-role failed: {err}"})
|
||||
continue
|
||||
c = creds["Credentials"]
|
||||
sess = boto3.Session(aws_access_key_id=c["AccessKeyId"],
|
||||
aws_secret_access_key=c["SecretAccessKey"],
|
||||
aws_session_token=c["SessionToken"])
|
||||
report["accounts"].append(assess_account(sess, aid, name, aid == payer))
|
||||
|
||||
out = os.path.join(FINDINGS, "fast_discovery.json")
|
||||
os.makedirs(FINDINGS, exist_ok=True)
|
||||
with open(out, "w") as f:
|
||||
json.dump(report, f, indent=2, default=_default)
|
||||
print(f"\nWritten to: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user