Commit ecaef9b176
Unsigned
Layout: unified · split
README.org +1 −1
| @@ -66,7 +66,7 @@ To pick a platform and be walked through an audit interactively: | ||
| 66 | 66 | python audit_tui.py |
| 67 | 67 | #+end_src |
| 68 | 68 | |
| 69 | See =tui/README.md= for details. GitHub and GitLab are supported. | |
| 69 | See =tui/README.md= for details. GitHub, GitLab, and AWS are supported. | |
| 70 | 70 | |
| 71 | 71 | ** Contributing |
| 72 | 72 | |
applications/aws/README.md +56
| @@ -1,3 +1,59 @@ | ||
| 1 | > **NOTE**: Authentication uses the standard AWS credential chain (environment | |
| 2 | > variables, shared config/credentials, SSO profiles, instance roles). This tool | |
| 3 | > never handles access keys directly. Read-only permissions are enough — IAM | |
| 4 | > `Get*`/`List*`, S3 `s3:GetBucket*` + `s3:ListAllMyBuckets`, and for the SSO | |
| 5 | > check `sso:List*`/`sso:Describe*`, `identitystore:Describe*`, and | |
| 6 | > `organizations:ListAccounts`. | |
| 7 | ||
| 8 | --- | |
| 9 | ||
| 10 | # `audit.py` — Unified AWS Audit Tool | |
| 11 | ||
| 12 | Runs all collectors against the account reachable with your active AWS | |
| 13 | credentials and writes a timestamped audit package to disk. This is the tool the | |
| 14 | interactive TUI (`audit_tui.py`) drives. | |
| 15 | ||
| 16 | ## Setup | |
| 17 | ||
| 18 | ```bash | |
| 19 | export AWS_PROFILE=my-profile # optional; else the default chain | |
| 20 | export AWS_DEFAULT_REGION=us-east-1 # optional | |
| 21 | export AWS_AUDIT_ACCOUNT=my-account # optional; only for the SSO check | |
| 22 | ``` | |
| 23 | ||
| 24 | ## Usage | |
| 25 | ||
| 26 | ```bash | |
| 27 | # Basic run — uses the active credentials / default profile | |
| 28 | python audit.py | |
| 29 | ||
| 30 | # Named profile and region, custom output directory | |
| 31 | python audit.py --profile my-profile --region us-east-1 --out ./output | |
| 32 | ||
| 33 | # SSO assignments for a specific account in the organization | |
| 34 | python audit.py --account my-account | |
| 35 | ``` | |
| 36 | ||
| 37 | ## Output | |
| 38 | ||
| 39 | Creates a directory: `<out>/aws_audit_<profile>_<YYYY-MM-DD>/` | |
| 40 | ||
| 41 | | File | Contents | | |
| 42 | |---|---| | |
| 43 | | `iam_users.csv` | IAM users with MFA status, access-key count/age, console password, last use | | |
| 44 | | `password_policy.csv` | Account IAM password policy (length, complexity, rotation, reuse) | | |
| 45 | | `s3_public_access.csv` | Per-bucket Public Access Block, policy public status, and ACL public exposure | | |
| 46 | | `sso_assignments.csv` | IAM Identity Center permission-set assignments per account (Identity Center + Organizations) | | |
| 47 | | `summary.txt` | Row counts per section | | |
| 48 | ||
| 49 | Checks that aren't available (no password policy, no Identity Center instance, | |
| 50 | missing permissions) are skipped with a warning; the rest still run. | |
| 51 | ||
| 52 | The shell scripts below remain for CloudShell or CLI-only environments where | |
| 53 | Python and boto3 aren't set up. | |
| 54 | ||
| 55 | --- | |
| 56 | ||
| 1 | 57 | # `aws_iam_users.sh` |
| 2 | 58 | |
| 3 | 59 | *Note*: This example uses an account titled `cmc`, which has access provisioned to it through IAM. |
applications/aws/__init__.py added
applications/aws/audit.py added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | """ | |
| 2 | AWS audit CLI. | |
| 3 | ||
| 4 | Runs all collectors against the account reachable with the active AWS | |
| 5 | credentials and writes a timestamped audit package to an output directory. | |
| 6 | ||
| 7 | Credentials come from the standard AWS chain (environment variables, shared | |
| 8 | config/credentials, SSO profiles, instance roles) — no access keys are passed | |
| 9 | to this tool. | |
| 10 | ||
| 11 | Usage: | |
| 12 | export AWS_PROFILE=my-profile # optional | |
| 13 | export AWS_DEFAULT_REGION=us-east-1 # optional | |
| 14 | ||
| 15 | python audit.py | |
| 16 | python audit.py --profile my-profile --region us-east-1 | |
| 17 | python audit.py --account my-account --out ./output | |
| 18 | ||
| 19 | Output: | |
| 20 | <out>/aws_audit_<profile>_<date>/ | |
| 21 | iam_users.csv | |
| 22 | password_policy.csv | |
| 23 | s3_public_access.csv | |
| 24 | sso_assignments.csv | |
| 25 | summary.txt | |
| 26 | """ | |
| 27 | ||
| 28 | import argparse | |
| 29 | import os | |
| 30 | import sys | |
| 31 | from datetime import date | |
| 32 | ||
| 33 | import config | |
| 34 | from collectors import iam, s3, sso | |
| 35 | from reporters import csv_reporter | |
| 36 | ||
| 37 | ||
| 38 | def parse_args(): | |
| 39 | parser = argparse.ArgumentParser( | |
| 40 | description="Generate an AWS audit package for the current account." | |
| 41 | ) | |
| 42 | parser.add_argument( | |
| 43 | "--profile", help="AWS named profile. Overrides AWS_PROFILE env var." | |
| 44 | ) | |
| 45 | parser.add_argument( | |
| 46 | "--region", help="AWS region. Overrides AWS_DEFAULT_REGION env var." | |
| 47 | ) | |
| 48 | parser.add_argument( | |
| 49 | "--account", | |
| 50 | help="Account name for the SSO assignments check. " | |
| 51 | "Overrides AWS_AUDIT_ACCOUNT. Defaults to the current account.", | |
| 52 | ) | |
| 53 | parser.add_argument( | |
| 54 | "--out", | |
| 55 | default="./output", | |
| 56 | help="Directory to write the audit package into. Default: ./output", | |
| 57 | ) | |
| 58 | return parser.parse_args() | |
| 59 | ||
| 60 | ||
| 61 | def run(): | |
| 62 | args = parse_args() | |
| 63 | cfg = config.load(args.profile, args.region, args.account) | |
| 64 | subject = cfg.get("profile") or "default" | |
| 65 | ||
| 66 | output_dir = os.path.join( | |
| 67 | args.out, f"aws_audit_{subject}_{date.today().isoformat()}" | |
| 68 | ) | |
| 69 | ||
| 70 | print(f"AWS Audit — profile: {subject}") | |
| 71 | print(f"Output directory: {output_dir}") | |
| 72 | print() | |
| 73 | ||
| 74 | sections = [] | |
| 75 | ||
| 76 | def collect(label, fn, filename): | |
| 77 | print(f"Collecting: {label}...") | |
| 78 | try: | |
| 79 | rows = fn(cfg) | |
| 80 | except Exception as e: | |
| 81 | print(f" Error: {e}", file=sys.stderr) | |
| 82 | rows = [] | |
| 83 | csv_reporter.write(output_dir, filename, rows) | |
| 84 | sections.append((label, len(rows))) | |
| 85 | return rows | |
| 86 | ||
| 87 | collect("IAM users", iam.iam_users, "iam_users.csv") | |
| 88 | collect("Password policy", iam.password_policy, "password_policy.csv") | |
| 89 | collect("S3 public access", s3.s3_public_access, "s3_public_access.csv") | |
| 90 | collect("SSO assignments", sso.sso_assignments, "sso_assignments.csv") | |
| 91 | ||
| 92 | print() | |
| 93 | csv_reporter.write_summary(output_dir, subject, sections) | |
| 94 | print() | |
| 95 | print("Done.") | |
| 96 | ||
| 97 | ||
| 98 | if __name__ == "__main__": | |
| 99 | run() | |
applications/aws/collectors/__init__.py added
applications/aws/collectors/api.py added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | """Shared AWS session helpers. | |
| 2 | ||
| 3 | Authentication uses the standard boto3 credential chain (environment variables, | |
| 4 | shared config/credentials files, SSO profiles, instance roles). No access keys | |
| 5 | are ever passed in or stored by this tool. | |
| 6 | """ | |
| 7 | ||
| 8 | import boto3 | |
| 9 | ||
| 10 | ||
| 11 | def build_cfg(profile="", region="", account=""): | |
| 12 | """Build the config dict the collectors expect. | |
| 13 | ||
| 14 | ``profile`` and ``region`` are optional; when empty, boto3's default | |
| 15 | resolution applies. ``account`` is an optional account name used only by the | |
| 16 | SSO assignments collector. | |
| 17 | """ | |
| 18 | kwargs = {} | |
| 19 | if profile: | |
| 20 | kwargs["profile_name"] = profile | |
| 21 | if region: | |
| 22 | kwargs["region_name"] = region | |
| 23 | session = boto3.Session(**kwargs) | |
| 24 | return { | |
| 25 | "session": session, | |
| 26 | "profile": profile, | |
| 27 | "region": region, | |
| 28 | "account": account, | |
| 29 | } | |
| 30 | ||
| 31 | ||
| 32 | def account_id(cfg): | |
| 33 | """Return the AWS account ID for the active credentials, or '' on failure.""" | |
| 34 | try: | |
| 35 | return cfg["session"].client("sts").get_caller_identity()["Account"] | |
| 36 | except Exception: | |
| 37 | return "" | |
applications/aws/collectors/iam.py added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | """ | |
| 2 | Collect IAM user hygiene and the account password policy. | |
| 3 | """ | |
| 4 | ||
| 5 | import sys | |
| 6 | from datetime import datetime, timezone | |
| 7 | ||
| 8 | from botocore.exceptions import ClientError | |
| 9 | ||
| 10 | ||
| 11 | def iam_users(cfg): | |
| 12 | """ | |
| 13 | One row per IAM user: MFA status, access-key count and oldest key age, | |
| 14 | whether a console password is set, and last password use. | |
| 15 | """ | |
| 16 | iam = cfg["session"].client("iam") | |
| 17 | now = datetime.now(timezone.utc) | |
| 18 | rows = [] | |
| 19 | ||
| 20 | for page in iam.get_paginator("list_users").paginate(): | |
| 21 | for u in page["Users"]: | |
| 22 | name = u["UserName"] | |
| 23 | mfa = iam.list_mfa_devices(UserName=name).get("MFADevices", []) | |
| 24 | keys = iam.list_access_keys(UserName=name).get("AccessKeyMetadata", []) | |
| 25 | key_ages = [(now - k["CreateDate"]).days for k in keys] | |
| 26 | ||
| 27 | try: | |
| 28 | iam.get_login_profile(UserName=name) | |
| 29 | console = True | |
| 30 | except ClientError as e: | |
| 31 | if e.response["Error"]["Code"] == "NoSuchEntity": | |
| 32 | console = False | |
| 33 | else: | |
| 34 | raise | |
| 35 | ||
| 36 | last_used = u.get("PasswordLastUsed") | |
| 37 | rows.append( | |
| 38 | { | |
| 39 | "user": name, | |
| 40 | "mfa_enabled": bool(mfa), | |
| 41 | "access_keys": len(keys), | |
| 42 | "oldest_key_age_days": max(key_ages) if key_ages else "", | |
| 43 | "console_password": console, | |
| 44 | "password_last_used": last_used.isoformat() if last_used else "", | |
| 45 | "created": u["CreateDate"].isoformat() | |
| 46 | if u.get("CreateDate") | |
| 47 | else "", | |
| 48 | } | |
| 49 | ) | |
| 50 | return rows | |
| 51 | ||
| 52 | ||
| 53 | def password_policy(cfg): | |
| 54 | """ | |
| 55 | One row describing the account IAM password policy. Returns an empty list | |
| 56 | with a warning if no policy is set. | |
| 57 | """ | |
| 58 | iam = cfg["session"].client("iam") | |
| 59 | try: | |
| 60 | p = iam.get_account_password_policy()["PasswordPolicy"] | |
| 61 | except ClientError as e: | |
| 62 | if e.response["Error"]["Code"] == "NoSuchEntity": | |
| 63 | print( | |
| 64 | "Warning: no IAM password policy is set for this account -- skipping.", | |
| 65 | file=sys.stderr, | |
| 66 | ) | |
| 67 | return [] | |
| 68 | raise | |
| 69 | ||
| 70 | return [ | |
| 71 | { | |
| 72 | "minimum_length": p.get("MinimumPasswordLength"), | |
| 73 | "require_symbols": p.get("RequireSymbols"), | |
| 74 | "require_numbers": p.get("RequireNumbers"), | |
| 75 | "require_uppercase": p.get("RequireUppercaseCharacters"), | |
| 76 | "require_lowercase": p.get("RequireLowercaseCharacters"), | |
| 77 | "allow_users_to_change": p.get("AllowUsersToChangePassword"), | |
| 78 | "max_age_days": p.get("MaxPasswordAge", "N/A"), | |
| 79 | "reuse_prevention": p.get("PasswordReusePrevention", "N/A"), | |
| 80 | "hard_expiry": p.get("HardExpiry", False), | |
| 81 | } | |
| 82 | ] | |
applications/aws/collectors/s3.py added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | """ | |
| 2 | Collect S3 bucket public-access exposure. | |
| 3 | ||
| 4 | For each bucket, reports the Public Access Block state, whether S3 considers the | |
| 5 | bucket policy public, and whether the ACL grants access to AllUsers. | |
| 6 | """ | |
| 7 | ||
| 8 | from botocore.exceptions import ClientError | |
| 9 | ||
| 10 | ALL_USERS = "http://acs.amazonaws.com/groups/global/AllUsers" | |
| 11 | ||
| 12 | ||
| 13 | def s3_public_access(cfg): | |
| 14 | s3 = cfg["session"].client("s3") | |
| 15 | rows = [] | |
| 16 | for b in s3.list_buckets().get("Buckets", []): | |
| 17 | name = b["Name"] | |
| 18 | rows.append( | |
| 19 | { | |
| 20 | "bucket": name, | |
| 21 | "region": _bucket_region(s3, name), | |
| 22 | "public_access_block": _pab_status(s3, name), | |
| 23 | "policy_public": _policy_public(s3, name), | |
| 24 | "acl_public": _acl_public(s3, name), | |
| 25 | } | |
| 26 | ) | |
| 27 | return rows | |
| 28 | ||
| 29 | ||
| 30 | def _bucket_region(s3, name): | |
| 31 | try: | |
| 32 | loc = s3.get_bucket_location(Bucket=name).get("LocationConstraint") | |
| 33 | return loc or "us-east-1" | |
| 34 | except ClientError: | |
| 35 | return "unknown" | |
| 36 | ||
| 37 | ||
| 38 | def _pab_status(s3, name): | |
| 39 | try: | |
| 40 | pab = s3.get_public_access_block(Bucket=name)["PublicAccessBlockConfiguration"] | |
| 41 | except ClientError as e: | |
| 42 | if e.response["Error"]["Code"] == "NoSuchPublicAccessBlockConfiguration": | |
| 43 | return "MISSING" | |
| 44 | return "error" | |
| 45 | all_on = all( | |
| 46 | [ | |
| 47 | pab.get("BlockPublicAcls"), | |
| 48 | pab.get("IgnorePublicAcls"), | |
| 49 | pab.get("BlockPublicPolicy"), | |
| 50 | pab.get("RestrictPublicBuckets"), | |
| 51 | ] | |
| 52 | ) | |
| 53 | return "fully-restricted" if all_on else "partial" | |
| 54 | ||
| 55 | ||
| 56 | def _policy_public(s3, name): | |
| 57 | try: | |
| 58 | return s3.get_bucket_policy_status(Bucket=name)["PolicyStatus"]["IsPublic"] | |
| 59 | except ClientError as e: | |
| 60 | if e.response["Error"]["Code"] == "NoSuchBucketPolicy": | |
| 61 | return False | |
| 62 | return "error" | |
| 63 | ||
| 64 | ||
| 65 | def _acl_public(s3, name): | |
| 66 | try: | |
| 67 | grants = s3.get_bucket_acl(Bucket=name).get("Grants", []) | |
| 68 | except ClientError: | |
| 69 | return "error" | |
| 70 | return any(g.get("Grantee", {}).get("URI") == ALL_USERS for g in grants) | |
applications/aws/collectors/sso.py added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | """ | |
| 2 | Collect IAM Identity Center (SSO) permission-set assignments for an account. | |
| 3 | ||
| 4 | Requires AWS Organizations + IAM Identity Center. Returns an empty list with a | |
| 5 | warning if no Identity Center instance is available or the target account can't | |
| 6 | be resolved. When no account name is configured, the current account is used. | |
| 7 | """ | |
| 8 | ||
| 9 | import sys | |
| 10 | ||
| 11 | from botocore.exceptions import ClientError | |
| 12 | ||
| 13 | from . import api | |
| 14 | ||
| 15 | ||
| 16 | def sso_assignments(cfg): | |
| 17 | session = cfg["session"] | |
| 18 | sso = session.client("sso-admin") | |
| 19 | ||
| 20 | instances = sso.list_instances().get("Instances", []) | |
| 21 | if not instances: | |
| 22 | print( | |
| 23 | "Warning: no IAM Identity Center instance found -- skipping.", | |
| 24 | file=sys.stderr, | |
| 25 | ) | |
| 26 | return [] | |
| 27 | instance_arn = instances[0]["InstanceArn"] | |
| 28 | identity_store_id = instances[0]["IdentityStoreId"] | |
| 29 | ||
| 30 | account = _resolve_account(cfg) | |
| 31 | if not account: | |
| 32 | return [] | |
| 33 | ||
| 34 | ids = session.client("identitystore") | |
| 35 | ps_cache = {} | |
| 36 | name_cache = {} | |
| 37 | rows = [] | |
| 38 | ||
| 39 | for ps_arn in _provisioned_permission_sets(sso, instance_arn, account): | |
| 40 | assignments = _account_assignments(sso, instance_arn, account, ps_arn) | |
| 41 | if not assignments: | |
| 42 | continue | |
| 43 | ps = _permission_set(sso, instance_arn, ps_arn, ps_cache) | |
| 44 | for a in assignments: | |
| 45 | rows.append( | |
| 46 | { | |
| 47 | "principal": _principal_name(ids, identity_store_id, a, name_cache), | |
| 48 | "type": a["PrincipalType"], | |
| 49 | "permission_set": ps["name"], | |
| 50 | "managed_policies": ps["managed"], | |
| 51 | "inline_policy": ps["inline"], | |
| 52 | } | |
| 53 | ) | |
| 54 | return rows | |
| 55 | ||
| 56 | ||
| 57 | def _resolve_account(cfg): | |
| 58 | """Resolve the target account ID from the configured account name, or fall | |
| 59 | back to the current account when no name is given.""" | |
| 60 | name = cfg.get("account", "") | |
| 61 | if not name: | |
| 62 | return api.account_id(cfg) | |
| 63 | ||
| 64 | orgs = cfg["session"].client("organizations") | |
| 65 | try: | |
| 66 | for page in orgs.get_paginator("list_accounts").paginate(): | |
| 67 | for acct in page["Accounts"]: | |
| 68 | if acct["Name"] == name and acct["Status"] == "ACTIVE": | |
| 69 | return acct["Id"] | |
| 70 | except ClientError: | |
| 71 | print( | |
| 72 | "Warning: could not list organization accounts (needs the management " | |
| 73 | "account) -- skipping SSO assignments.", | |
| 74 | file=sys.stderr, | |
| 75 | ) | |
| 76 | return "" | |
| 77 | ||
| 78 | print(f"Warning: no active account named '{name}' -- skipping.", file=sys.stderr) | |
| 79 | return "" | |
| 80 | ||
| 81 | ||
| 82 | def _provisioned_permission_sets(sso, instance_arn, account): | |
| 83 | arns = [] | |
| 84 | paginator = sso.get_paginator("list_permission_sets_provisioned_to_account") | |
| 85 | for page in paginator.paginate(InstanceArn=instance_arn, AccountId=account): | |
| 86 | arns.extend(page.get("PermissionSets", [])) | |
| 87 | return arns | |
| 88 | ||
| 89 | ||
| 90 | def _account_assignments(sso, instance_arn, account, ps_arn): | |
| 91 | out = [] | |
| 92 | paginator = sso.get_paginator("list_account_assignments") | |
| 93 | for page in paginator.paginate( | |
| 94 | InstanceArn=instance_arn, AccountId=account, PermissionSetArn=ps_arn | |
| 95 | ): | |
| 96 | out.extend(page.get("AccountAssignments", [])) | |
| 97 | return out | |
| 98 | ||
| 99 | ||
| 100 | def _permission_set(sso, instance_arn, ps_arn, cache): | |
| 101 | if ps_arn in cache: | |
| 102 | return cache[ps_arn] | |
| 103 | name = sso.describe_permission_set( | |
| 104 | InstanceArn=instance_arn, PermissionSetArn=ps_arn | |
| 105 | )["PermissionSet"]["Name"] | |
| 106 | managed = [ | |
| 107 | m["Arn"] | |
| 108 | for m in sso.list_managed_policies_in_permission_set( | |
| 109 | InstanceArn=instance_arn, PermissionSetArn=ps_arn | |
| 110 | ).get("AttachedManagedPolicies", []) | |
| 111 | ] | |
| 112 | inline = sso.get_inline_policy_for_permission_set( | |
| 113 | InstanceArn=instance_arn, PermissionSetArn=ps_arn | |
| 114 | ).get("InlinePolicy", "") | |
| 115 | cache[ps_arn] = { | |
| 116 | "name": name, | |
| 117 | "managed": ", ".join(managed) or "(none)", | |
| 118 | "inline": "yes" if inline else "no", | |
| 119 | } | |
| 120 | return cache[ps_arn] | |
| 121 | ||
| 122 | ||
| 123 | def _principal_name(ids, store_id, assignment, cache): | |
| 124 | pid = assignment["PrincipalId"] | |
| 125 | if pid in cache: | |
| 126 | return cache[pid] | |
| 127 | ptype = assignment["PrincipalType"] | |
| 128 | name = pid | |
| 129 | try: | |
| 130 | if ptype == "USER": | |
| 131 | name = ids.describe_user(IdentityStoreId=store_id, UserId=pid).get( | |
| 132 | "UserName", pid | |
| 133 | ) | |
| 134 | elif ptype == "GROUP": | |
| 135 | name = ids.describe_group(IdentityStoreId=store_id, GroupId=pid).get( | |
| 136 | "DisplayName", pid | |
| 137 | ) | |
| 138 | except ClientError: | |
| 139 | pass | |
| 140 | cache[pid] = name | |
| 141 | return name | |
applications/aws/config.py added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | """ | |
| 2 | Configuration loader for the AWS audit tool. | |
| 3 | ||
| 4 | Reads AWS_PROFILE, AWS_DEFAULT_REGION, and AWS_AUDIT_ACCOUNT from the | |
| 5 | environment. Credentials themselves come from the standard boto3 credential | |
| 6 | chain — this tool never handles access keys directly. | |
| 7 | ||
| 8 | Usage: | |
| 9 | export AWS_PROFILE=my-profile # optional; else default chain | |
| 10 | export AWS_DEFAULT_REGION=us-east-1 # optional | |
| 11 | export AWS_AUDIT_ACCOUNT=my-account # optional; only for SSO assignments | |
| 12 | """ | |
| 13 | ||
| 14 | import os | |
| 15 | ||
| 16 | from collectors.api import build_cfg | |
| 17 | ||
| 18 | ||
| 19 | def load(profile_override=None, region_override=None, account_override=None): | |
| 20 | """Return a config dict. AWS needs no required token to validate here; | |
| 21 | missing or invalid credentials surface at call time.""" | |
| 22 | profile = profile_override or os.environ.get("AWS_PROFILE", "").strip() | |
| 23 | region = region_override or os.environ.get("AWS_DEFAULT_REGION", "").strip() | |
| 24 | account = account_override or os.environ.get("AWS_AUDIT_ACCOUNT", "").strip() | |
| 25 | return build_cfg(profile, region, account) | |
applications/aws/reporters/__init__.py added
applications/aws/reporters/csv_reporter.py added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | """CSV reporter: writes one CSV file per data section into an output directory.""" | |
| 2 | ||
| 3 | import csv | |
| 4 | import os | |
| 5 | ||
| 6 | ||
| 7 | def write(output_dir, filename, rows): | |
| 8 | """ | |
| 9 | Write a list of dicts to a CSV file in output_dir. | |
| 10 | Skips writing if rows is empty, but logs the skip. | |
| 11 | """ | |
| 12 | if not rows: | |
| 13 | print(f" {filename}: no data, skipping") | |
| 14 | return | |
| 15 | ||
| 16 | os.makedirs(output_dir, exist_ok=True) | |
| 17 | path = os.path.join(output_dir, filename) | |
| 18 | ||
| 19 | with open(path, "w", newline="", encoding="utf-8") as f: | |
| 20 | writer = csv.DictWriter(f, fieldnames=rows[0].keys()) | |
| 21 | writer.writeheader() | |
| 22 | writer.writerows(rows) | |
| 23 | ||
| 24 | print(f" {filename}: {len(rows)} rows -> {path}") | |
| 25 | ||
| 26 | ||
| 27 | def write_summary(output_dir, subject, sections): | |
| 28 | """ | |
| 29 | Write a plain-text summary file listing section names and row counts. | |
| 30 | sections: list of (label, row_count) tuples | |
| 31 | """ | |
| 32 | os.makedirs(output_dir, exist_ok=True) | |
| 33 | path = os.path.join(output_dir, "summary.txt") | |
| 34 | lines = [ | |
| 35 | "AWS Audit Package", | |
| 36 | f"Profile: {subject}", | |
| 37 | "", | |
| 38 | "Section Rows", | |
| 39 | f"{'─' * 40}", | |
| 40 | ] | |
| 41 | for label, count in sections: | |
| 42 | lines.append(f"{label:<35}{count}") | |
| 43 | ||
| 44 | with open(path, "w", encoding="utf-8") as f: | |
| 45 | f.write("\n".join(lines) + "\n") | |
| 46 | ||
| 47 | print(f" summary.txt -> {path}") | |
requirements.txt +1
| @@ -4,6 +4,7 @@ xlrd | ||
| 4 | 4 | PyYAML |
| 5 | 5 | pytest |
| 6 | 6 | requests |
| 7 | boto3 | |
| 7 | 8 | textual |
| 8 | 9 | dash |
| 9 | 10 | plotly |
tui/README.md +15 −6
| @@ -4,8 +4,8 @@ A terminal UI that walks you through running an audit. It presents a platform | ||
| 4 | 4 | menu, collects connection details and check selection, then runs the existing |
| 5 | 5 | collectors with live progress. |
| 6 | 6 | |
| 7 | GitHub and GitLab are supported. Adding a platform is a matter of writing a | |
| 8 | runner and a `Platform` descriptor in `tui/platforms.py` — the screens are | |
| 7 | GitHub, GitLab, and AWS are supported. Adding a platform is a matter of writing | |
| 8 | a runner and a `Platform` descriptor in `tui/platforms.py` — the screens are | |
| 9 | 9 | platform-agnostic. |
| 10 | 10 | |
| 11 | 11 | ## Run it |
| @@ -26,8 +26,16 @@ export GITHUB_TOKEN=ghp_... # needs read:org and repo scopes | ||
| 26 | 26 | export GITLAB_GROUP=my-group |
| 27 | 27 | export GITLAB_TOKEN=glpat-... # needs read_api scope |
| 28 | 28 | export GITLAB_URL=https://gitlab.example.com/api/v4 # self-hosted only |
| 29 | ||
| 30 | # AWS (credentials come from the standard AWS chain, not a form field) | |
| 31 | export AWS_PROFILE=my-profile | |
| 32 | export AWS_DEFAULT_REGION=us-east-1 | |
| 33 | export AWS_AUDIT_ACCOUNT=my-account # optional; only for the SSO check | |
| 29 | 34 | ``` |
| 30 | 35 | |
| 36 | AWS never asks for an access key in the UI — it uses your configured profile / | |
| 37 | credential chain (env vars, `~/.aws`, SSO). Read-only permissions are enough. | |
| 38 | ||
| 31 | 39 | ## Walkthrough |
| 32 | 40 | |
| 33 | 41 | 1. **Platform** — choose GitHub or GitLab. |
| @@ -41,10 +49,11 @@ export GITLAB_URL=https://gitlab.example.com/api/v4 # self-hosted only | ||
| 41 | 49 | |
| 42 | 50 | ## Output |
| 43 | 51 | |
| 44 | The TUI writes the same package the platform's `audit.py` produces: | |
| 45 | `<output>/github_audit_<org>_<date>/` or `<output>/gitlab_audit_<group>_<date>/`, | |
| 46 | one CSV per check plus a `summary.txt`. It reuses each platform's collectors and | |
| 47 | CSV reporter unchanged — the TUI is only an interactive driver around them. | |
| 52 | The TUI writes the same package the platform's `audit.py` produces — | |
| 53 | `github_audit_<org>_<date>/`, `gitlab_audit_<group>_<date>/`, or | |
| 54 | `aws_audit_<profile>_<date>/` under the output directory — one CSV per check | |
| 55 | plus a `summary.txt`. It reuses each platform's collectors and CSV reporter | |
| 56 | unchanged; the TUI is only an interactive driver around them. | |
| 48 | 57 | |
| 49 | 58 | ## Keys |
| 50 | 59 | |
tui/app.py +1 −1
| @@ -207,7 +207,7 @@ class RunScreen(Screen): | ||
| 207 | 207 | keys = self.app.selected_keys |
| 208 | 208 | self.sub_title = f"{platform.label} · running" |
| 209 | 209 | self.output_dir = platform.output_dir(settings) |
| 210 | target = settings[platform.id_key] | |
| 210 | target = platform.subject(settings) | |
| 211 | 211 | self.query_one("#run-target", Static).update( |
| 212 | 212 | f"Auditing [b]{target}[/] · {len(keys)} checks · → {self.output_dir}" |
| 213 | 213 | ) |
tui/aws_runner.py added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | """ | |
| 2 | Drive the AWS audit collectors from the TUI. | |
| 3 | ||
| 4 | Reuses the collectors and CSV reporter under ``applications/aws`` unchanged. | |
| 5 | Mirrors the other runners: a ``CHECKS`` registry plus ``run_audit`` that writes | |
| 6 | the same package ``applications/aws/audit.py`` produces and reports progress | |
| 7 | through a callback. | |
| 8 | ||
| 9 | AWS collectors take a single config dict (a boto3 session plus region/account); | |
| 10 | there is no per-item cache, so every check is called as ``fn(cfg)``. | |
| 11 | """ | |
| 12 | ||
| 13 | import os | |
| 14 | import sys | |
| 15 | from collections.abc import Iterable | |
| 16 | from datetime import date | |
| 17 | ||
| 18 | from tui.common import Check, ProgressCallback, ProgressEvent | |
| 19 | ||
| 20 | _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 21 | if _REPO_ROOT not in sys.path: | |
| 22 | sys.path.insert(0, _REPO_ROOT) | |
| 23 | ||
| 24 | from applications.aws.collectors import api, iam, s3, sso | |
| 25 | from applications.aws.reporters import csv_reporter | |
| 26 | ||
| 27 | # --- Check registry --------------------------------------------------------- | |
| 28 | ||
| 29 | CHECKS: list[Check] = [ | |
| 30 | Check("iam_users", "IAM users", iam.iam_users, "iam_users.csv"), | |
| 31 | Check( | |
| 32 | "password_policy", | |
| 33 | "Password policy", | |
| 34 | iam.password_policy, | |
| 35 | "password_policy.csv", | |
| 36 | ), | |
| 37 | Check( | |
| 38 | "s3_public_access", | |
| 39 | "S3 public access", | |
| 40 | s3.s3_public_access, | |
| 41 | "s3_public_access.csv", | |
| 42 | ), | |
| 43 | Check( | |
| 44 | "sso_assignments", | |
| 45 | "SSO assignments", | |
| 46 | sso.sso_assignments, | |
| 47 | "sso_assignments.csv", | |
| 48 | note="requires Identity Center + Organizations", | |
| 49 | ), | |
| 50 | ] | |
| 51 | ||
| 52 | DEFAULT_SELECTION = [c.key for c in CHECKS if c.key != "sso_assignments"] | |
| 53 | ||
| 54 | ||
| 55 | # --- Output helper ---------------------------------------------------------- | |
| 56 | ||
| 57 | ||
| 58 | def default_output_dir(out: str, profile: str) -> str: | |
| 59 | """Match the folder naming used by applications/aws/audit.py.""" | |
| 60 | subject = profile or "default" | |
| 61 | return os.path.join(out, f"aws_audit_{subject}_{date.today().isoformat()}") | |
| 62 | ||
| 63 | ||
| 64 | # --- Runner ----------------------------------------------------------------- | |
| 65 | ||
| 66 | ||
| 67 | def run_audit( | |
| 68 | *, | |
| 69 | profile: str, | |
| 70 | region: str, | |
| 71 | account: str, | |
| 72 | output_dir: str, | |
| 73 | selected_keys: Iterable[str], | |
| 74 | on_event: ProgressCallback, | |
| 75 | ) -> list[tuple[str, int]]: | |
| 76 | """ | |
| 77 | Run the selected checks and write the audit package to ``output_dir``. | |
| 78 | ||
| 79 | A collector that raises is reported as an error and recorded with a count | |
| 80 | of 0, so one bad check never aborts the whole run. If the AWS session itself | |
| 81 | can't be built (e.g. an unknown profile), that is reported and the run ends | |
| 82 | cleanly. | |
| 83 | """ | |
| 84 | subject = profile or "default" | |
| 85 | selected = set(selected_keys) | |
| 86 | checks = [c for c in CHECKS if c.key in selected] | |
| 87 | ||
| 88 | try: | |
| 89 | cfg = api.build_cfg(profile, region, account) | |
| 90 | except Exception as e: | |
| 91 | on_event(ProgressEvent("error", "AWS session", message=str(e))) | |
| 92 | csv_reporter.write_summary(output_dir, subject, []) | |
| 93 | on_event(ProgressEvent("summary", output_dir, count=0)) | |
| 94 | return [] | |
| 95 | ||
| 96 | sections: list[tuple[str, int]] = [] | |
| 97 | for c in checks: | |
| 98 | on_event(ProgressEvent("start", c.label)) | |
| 99 | try: | |
| 100 | rows = c.fn(cfg) | |
| 101 | except Exception as e: | |
| 102 | on_event(ProgressEvent("error", c.label, message=str(e))) | |
| 103 | sections.append((c.label, 0)) | |
| 104 | continue | |
| 105 | ||
| 106 | csv_reporter.write(output_dir, c.filename, rows) | |
| 107 | sections.append((c.label, len(rows))) | |
| 108 | on_event(ProgressEvent("done", c.label, count=len(rows))) | |
| 109 | ||
| 110 | csv_reporter.write_summary(output_dir, subject, sections) | |
| 111 | total = sum(n for _, n in sections) | |
| 112 | on_event(ProgressEvent("summary", output_dir, count=total)) | |
| 113 | return sections | |
tui/platforms.py +48 −5
| @@ -10,7 +10,7 @@ import os | ||
| 10 | 10 | from collections.abc import Callable |
| 11 | 11 | from dataclasses import dataclass, field |
| 12 | 12 | |
| 13 | from tui import github_runner, gitlab_runner | |
| 13 | from tui import aws_runner, github_runner, gitlab_runner | |
| 14 | 14 | from tui.common import Check |
| 15 | 15 | |
| 16 | 16 | |
| @@ -31,7 +31,9 @@ class Field: | ||
| 31 | 31 | class Platform: |
| 32 | 32 | key: str |
| 33 | 33 | label: str |
| 34 | id_key: str # which field is the audit subject (org / group) | |
| 34 | subject: Callable[ | |
| 35 | [dict], str | |
| 36 | ] # (settings) -> audit subject shown on the run screen | |
| 35 | 37 | fields: list[Field] |
| 36 | 38 | checks: list[Check] |
| 37 | 39 | default_selection: list[str] |
| @@ -79,10 +81,25 @@ def _gitlab_run(s: dict, output_dir, selected_keys, on_event): | ||
| 79 | 81 | ) |
| 80 | 82 | |
| 81 | 83 | |
| 84 | def _aws_output_dir(s: dict) -> str: | |
| 85 | return aws_runner.default_output_dir(s["out"], s["profile"]) | |
| 86 | ||
| 87 | ||
| 88 | def _aws_run(s: dict, output_dir, selected_keys, on_event): | |
| 89 | return aws_runner.run_audit( | |
| 90 | profile=s["profile"], | |
| 91 | region=s["region"], | |
| 92 | account=s["account"], | |
| 93 | output_dir=output_dir, | |
| 94 | selected_keys=selected_keys, | |
| 95 | on_event=on_event, | |
| 96 | ) | |
| 97 | ||
| 98 | ||
| 82 | 99 | GITHUB = Platform( |
| 83 | 100 | key="github", |
| 84 | 101 | label="GitHub", |
| 85 | id_key="org", | |
| 102 | subject=lambda s: s["org"], | |
| 86 | 103 | fields=[ |
| 87 | 104 | Field("org", "Organization", "my-org", required=True, env="GITHUB_ORG"), |
| 88 | 105 | Field( |
| @@ -105,7 +122,7 @@ GITHUB = Platform( | ||
| 105 | 122 | GITLAB = Platform( |
| 106 | 123 | key="gitlab", |
| 107 | 124 | label="GitLab", |
| 108 | id_key="group", | |
| 125 | subject=lambda s: s["group"], | |
| 109 | 126 | fields=[ |
| 110 | 127 | Field( |
| 111 | 128 | "group", |
| @@ -136,7 +153,33 @@ GITLAB = Platform( | ||
| 136 | 153 | run=_gitlab_run, |
| 137 | 154 | ) |
| 138 | 155 | |
| 139 | PLATFORMS = [GITHUB, GITLAB] | |
| 156 | AWS = Platform( | |
| 157 | key="aws", | |
| 158 | label="AWS", | |
| 159 | subject=lambda s: s["profile"] or "default", | |
| 160 | fields=[ | |
| 161 | Field( | |
| 162 | "profile", | |
| 163 | "AWS profile", | |
| 164 | "default chain, or a named / SSO profile", | |
| 165 | env="AWS_PROFILE", | |
| 166 | ), | |
| 167 | Field("region", "Region", "e.g. us-east-1", env="AWS_DEFAULT_REGION"), | |
| 168 | Field( | |
| 169 | "account", | |
| 170 | "Account name (SSO check only)", | |
| 171 | "optional; defaults to current account", | |
| 172 | env="AWS_AUDIT_ACCOUNT", | |
| 173 | ), | |
| 174 | Field("out", "Output directory", default="./output"), | |
| 175 | ], | |
| 176 | checks=aws_runner.CHECKS, | |
| 177 | default_selection=aws_runner.DEFAULT_SELECTION, | |
| 178 | output_dir=_aws_output_dir, | |
| 179 | run=_aws_run, | |
| 180 | ) | |
| 181 | ||
| 182 | PLATFORMS = [GITHUB, GITLAB, AWS] | |
| 140 | 183 | |
| 141 | 184 | |
| 142 | 185 | def prefill(f: Field) -> str: |
tui/tests/test_app.py +40
| @@ -125,3 +125,43 @@ def test_gitlab_navigation(monkeypatch): | ||
| 125 | 125 | assert app.screen.query_one("#menu", Button).disabled is False |
| 126 | 126 | |
| 127 | 127 | _run(scenario()) |
| 128 | ||
| 129 | ||
| 130 | def test_aws_navigation(monkeypatch): | |
| 131 | for var in ("AWS_PROFILE", "AWS_DEFAULT_REGION", "AWS_AUDIT_ACCOUNT"): | |
| 132 | monkeypatch.delenv(var, raising=False) | |
| 133 | ||
| 134 | def fake_run_audit( | |
| 135 | *, profile, region, account, output_dir, selected_keys, on_event | |
| 136 | ): | |
| 137 | on_event(gh.ProgressEvent("done", "IAM users", count=5)) | |
| 138 | on_event(gh.ProgressEvent("summary", output_dir, count=5)) | |
| 139 | return [("IAM users", 5)] | |
| 140 | ||
| 141 | monkeypatch.setattr("tui.aws_runner.run_audit", fake_run_audit) | |
| 142 | ||
| 143 | async def scenario(): | |
| 144 | app = AuditApp() | |
| 145 | async with app.run_test(size=(120, 40)) as pilot: | |
| 146 | await pilot.pause() | |
| 147 | await pilot.click("#aws") | |
| 148 | await pilot.pause() | |
| 149 | assert isinstance(app.screen, ConfigScreen) | |
| 150 | ||
| 151 | # AWS has no required fields — continue with defaults (default chain). | |
| 152 | await pilot.click("#continue") | |
| 153 | await pilot.pause() | |
| 154 | assert isinstance(app.screen, ChecksScreen) | |
| 155 | assert app.settings["profile"] == "" | |
| 156 | ||
| 157 | await pilot.click("#run") | |
| 158 | await pilot.pause() | |
| 159 | assert isinstance(app.screen, RunScreen) | |
| 160 | # Empty profile renders as "default" in the folder name. | |
| 161 | assert "aws_audit_default" in app.screen.output_dir | |
| 162 | ||
| 163 | await app.workers.wait_for_complete() | |
| 164 | await pilot.pause() | |
| 165 | assert app.screen.query_one("#menu", Button).disabled is False | |
| 166 | ||
| 167 | _run(scenario()) | |
tui/tests/test_aws_runner.py added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | """Tests for the TUI's AWS audit orchestration. | |
| 2 | ||
| 3 | Stub the boto3 session and the collectors, then verify run_audit's wiring: | |
| 4 | each check is called with the config, per-check errors don't abort the run, a | |
| 5 | failed session build is reported cleanly, and the CSV package is written. | |
| 6 | """ | |
| 7 | ||
| 8 | import csv | |
| 9 | import os | |
| 10 | ||
| 11 | import pytest | |
| 12 | ||
| 13 | from tui import aws_runner as r | |
| 14 | ||
| 15 | ||
| 16 | @pytest.fixture | |
| 17 | def fake_checks(monkeypatch): | |
| 18 | calls = {} | |
| 19 | ||
| 20 | def users_fn(cfg): | |
| 21 | calls["users"] = cfg | |
| 22 | return [{"user": "alice"}, {"user": "bob"}] | |
| 23 | ||
| 24 | def policy_fn(cfg): | |
| 25 | calls["policy"] = cfg | |
| 26 | return [{"minimum_length": 14}] | |
| 27 | ||
| 28 | def boom_fn(cfg): | |
| 29 | raise RuntimeError("kaboom") | |
| 30 | ||
| 31 | checks = [ | |
| 32 | r.Check("users", "Users", users_fn, "users.csv"), | |
| 33 | r.Check("policy", "Policy", policy_fn, "policy.csv"), | |
| 34 | r.Check("boom", "Boom", boom_fn, "boom.csv"), | |
| 35 | ] | |
| 36 | monkeypatch.setattr(r, "CHECKS", checks) | |
| 37 | ||
| 38 | # Replace build_cfg so no real boto3 session is created. | |
| 39 | monkeypatch.setattr( | |
| 40 | r.api, | |
| 41 | "build_cfg", | |
| 42 | lambda profile, region, account: { | |
| 43 | "session": "SESSION", | |
| 44 | "profile": profile, | |
| 45 | "region": region, | |
| 46 | "account": account, | |
| 47 | }, | |
| 48 | ) | |
| 49 | return calls | |
| 50 | ||
| 51 | ||
| 52 | def run(tmp_path, keys, profile="", region="us-east-1", account=""): | |
| 53 | events = [] | |
| 54 | sections = r.run_audit( | |
| 55 | profile=profile, | |
| 56 | region=region, | |
| 57 | account=account, | |
| 58 | output_dir=str(tmp_path), | |
| 59 | selected_keys=keys, | |
| 60 | on_event=events.append, | |
| 61 | ) | |
| 62 | return events, sections | |
| 63 | ||
| 64 | ||
| 65 | def test_dispatch_and_files(tmp_path, fake_checks): | |
| 66 | calls = fake_checks | |
| 67 | run(tmp_path, ["users", "policy"], region="eu-west-1") | |
| 68 | ||
| 69 | # Each collector received the config dict. | |
| 70 | assert calls["users"]["region"] == "eu-west-1" | |
| 71 | assert calls["policy"]["session"] == "SESSION" | |
| 72 | ||
| 73 | for name in ("users.csv", "policy.csv", "summary.txt"): | |
| 74 | assert os.path.exists(tmp_path / name), name | |
| 75 | ||
| 76 | with open(tmp_path / "users.csv", newline="") as f: | |
| 77 | assert len(list(csv.DictReader(f))) == 2 | |
| 78 | ||
| 79 | ||
| 80 | def test_failing_check_does_not_abort_run(tmp_path, fake_checks): | |
| 81 | events, sections = run(tmp_path, ["boom", "users"]) | |
| 82 | ||
| 83 | kinds = [(e.kind, e.label) for e in events] | |
| 84 | assert ("error", "Boom") in kinds | |
| 85 | assert ("done", "Users") in kinds | |
| 86 | ||
| 87 | labels = dict(sections) | |
| 88 | assert labels["Boom"] == 0 | |
| 89 | assert labels["Users"] == 2 | |
| 90 | ||
| 91 | ||
| 92 | def test_session_build_failure_is_reported(tmp_path, fake_checks, monkeypatch): | |
| 93 | def boom_cfg(profile, region, account): | |
| 94 | raise RuntimeError("ProfileNotFound") | |
| 95 | ||
| 96 | monkeypatch.setattr(r.api, "build_cfg", boom_cfg) | |
| 97 | ||
| 98 | events, sections = run(tmp_path, ["users"], profile="ghost") | |
| 99 | ||
| 100 | kinds = [(e.kind, e.label) for e in events] | |
| 101 | assert ("error", "AWS session") in kinds | |
| 102 | # Run still ends with a summary and writes the (empty) package. | |
| 103 | summary = [e for e in events if e.kind == "summary"] | |
| 104 | assert summary and summary[0].count == 0 | |
| 105 | assert sections == [] | |
| 106 | assert os.path.exists(tmp_path / "summary.txt") | |
| 107 | ||
| 108 | ||
| 109 | def test_subject_defaults_to_default(tmp_path, fake_checks): | |
| 110 | run(tmp_path, ["users"], profile="") | |
| 111 | # An empty profile is folder-named "default". | |
| 112 | assert r.default_output_dir("./out", "") == r.default_output_dir("./out", "default") | |
| 113 | ||
| 114 | ||
| 115 | def test_sso_off_by_default(): | |
| 116 | assert "sso_assignments" not in r.DEFAULT_SELECTION | |
| 117 | assert "iam_users" in r.DEFAULT_SELECTION | |