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
+157
View File
@@ -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())
+294
View File
@@ -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())
+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())