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 | python audit_tui.py | 66 | python audit_tui.py |
| 67 | #+end_src | 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 | ** Contributing | 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 | # `aws_iam_users.sh` | 57 | # `aws_iam_users.sh` |
| 2 | 58 | ||
| 3 | *Note*: This example uses an account titled `cmc`, which has access provisioned to it through IAM. | 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 | PyYAML | 4 | PyYAML |
| 5 | pytest | 5 | pytest |
| 6 | requests | 6 | requests |
| 7 | boto3 | ||
| 7 | textual | 8 | textual |
| 8 | dash | 9 | dash |
| 9 | plotly | 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 | menu, collects connection details and check selection, then runs the existing | 4 | menu, collects connection details and check selection, then runs the existing |
| 5 | collectors with live progress. | 5 | collectors with live progress. |
| 6 | 6 | ||
| 7 | GitHub and GitLab are supported. Adding a platform is a matter of writing a | 7 | GitHub, GitLab, and AWS are supported. Adding a platform is a matter of writing |
| 8 | runner and a `Platform` descriptor in `tui/platforms.py` — the screens are | 8 | a runner and a `Platform` descriptor in `tui/platforms.py` — the screens are |
| 9 | platform-agnostic. | 9 | platform-agnostic. |
| 10 | 10 | ||
| 11 | ## Run it | 11 | ## Run it |
| @@ -26,8 +26,16 @@ export GITHUB_TOKEN=ghp_... # needs read:org and repo scopes | |||
| 26 | export GITLAB_GROUP=my-group | 26 | export GITLAB_GROUP=my-group |
| 27 | export GITLAB_TOKEN=glpat-... # needs read_api scope | 27 | export GITLAB_TOKEN=glpat-... # needs read_api scope |
| 28 | export GITLAB_URL=https://gitlab.example.com/api/v4 # self-hosted only | 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 | ## Walkthrough | 39 | ## Walkthrough |
| 32 | 40 | ||
| 33 | 1. **Platform** — choose GitHub or GitLab. | 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 | ## Output | 50 | ## Output |
| 43 | 51 | ||
| 44 | The TUI writes the same package the platform's `audit.py` produces: | 52 | The TUI writes the same package the platform's `audit.py` produces — |
| 45 | `<output>/github_audit_<org>_<date>/` or `<output>/gitlab_audit_<group>_<date>/`, | 53 | `github_audit_<org>_<date>/`, `gitlab_audit_<group>_<date>/`, or |
| 46 | one CSV per check plus a `summary.txt`. It reuses each platform's collectors and | 54 | `aws_audit_<profile>_<date>/` under the output directory — one CSV per check |
| 47 | CSV reporter unchanged — the TUI is only an interactive driver around them. | 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 | ## Keys | 58 | ## Keys |
| 50 | 59 | ||
tui/app.py +1 −1
| @@ -207,7 +207,7 @@ class RunScreen(Screen): | |||
| 207 | keys = self.app.selected_keys | 207 | keys = self.app.selected_keys |
| 208 | self.sub_title = f"{platform.label} · running" | 208 | self.sub_title = f"{platform.label} · running" |
| 209 | self.output_dir = platform.output_dir(settings) | 209 | self.output_dir = platform.output_dir(settings) |
| 210 | target = settings[platform.id_key] | 210 | target = platform.subject(settings) |
| 211 | self.query_one("#run-target", Static).update( | 211 | self.query_one("#run-target", Static).update( |
| 212 | f"Auditing [b]{target}[/] · {len(keys)} checks · → {self.output_dir}" | 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 | from collections.abc import Callable | 10 | from collections.abc import Callable |
| 11 | from dataclasses import dataclass, field | 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 | from tui.common import Check | 14 | from tui.common import Check |
| 15 | 15 | ||
| 16 | 16 | ||
| @@ -31,7 +31,9 @@ class Field: | |||
| 31 | class Platform: | 31 | class Platform: |
| 32 | key: str | 32 | key: str |
| 33 | label: str | 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 | fields: list[Field] | 37 | fields: list[Field] |
| 36 | checks: list[Check] | 38 | checks: list[Check] |
| 37 | default_selection: list[str] | 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 | GITHUB = Platform( | 99 | GITHUB = Platform( |
| 83 | key="github", | 100 | key="github", |
| 84 | label="GitHub", | 101 | label="GitHub", |
| 85 | id_key="org", | 102 | subject=lambda s: s["org"], |
| 86 | fields=[ | 103 | fields=[ |
| 87 | Field("org", "Organization", "my-org", required=True, env="GITHUB_ORG"), | 104 | Field("org", "Organization", "my-org", required=True, env="GITHUB_ORG"), |
| 88 | Field( | 105 | Field( |
| @@ -105,7 +122,7 @@ GITHUB = Platform( | |||
| 105 | GITLAB = Platform( | 122 | GITLAB = Platform( |
| 106 | key="gitlab", | 123 | key="gitlab", |
| 107 | label="GitLab", | 124 | label="GitLab", |
| 108 | id_key="group", | 125 | subject=lambda s: s["group"], |
| 109 | fields=[ | 126 | fields=[ |
| 110 | Field( | 127 | Field( |
| 111 | "group", | 128 | "group", |
| @@ -136,7 +153,33 @@ GITLAB = Platform( | |||
| 136 | run=_gitlab_run, | 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 | def prefill(f: Field) -> str: | 185 | def prefill(f: Field) -> str: |
tui/tests/test_app.py +40
| @@ -125,3 +125,43 @@ def test_gitlab_navigation(monkeypatch): | |||
| 125 | assert app.screen.query_one("#menu", Button).disabled is False | 125 | assert app.screen.query_one("#menu", Button).disabled is False |
| 126 | 126 | ||
| 127 | _run(scenario()) | 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 | ||