Commit f12763587f
Unsigned
Layout: unified · split
README.org +1 −1
| @@ -17,7 +17,7 @@ connection details, choose which checks to run, and watch live progress. | ||
| 17 | 17 | |
| 18 | 18 | | Directory | Description | |
| 19 | 19 | |----------------------+------------------------------------------------------------------------------| |
| 20 | | =applications/aws/= | AWS IAM users, password policy, and S3 bucket analysis | | |
| 20 | | =applications/aws/= | AWS IAM users, account/root security, password policy, S3 public access, open security groups, CloudTrail, Config, SSO | | |
| 21 | 21 | | =applications/github/= | GitHub admin enumeration, org security settings, webhooks, deploy keys, secret-scanning/Dependabot alerts, audit log, branch protections, commits | |
| 22 | 22 | | =applications/gitlab/= | GitLab group/project members, branch protections, approvals, pipelines, audit events | |
| 23 | 23 | | =databases/mongo/= | MongoDB admin enumeration | |
applications/aws/README.md +11 −2
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | > **NOTE**: Authentication uses the standard AWS credential chain (environment |
| 2 | 2 | > variables, shared config/credentials, SSO profiles, instance roles). This tool |
| 3 | 3 | > never handles access keys directly. Read-only permissions are enough — IAM |
| 4 | > `Get*`/`List*`, S3 `s3:GetBucket*` + `s3:ListAllMyBuckets`, and for the SSO | |
| 4 | > `Get*`/`List*`, S3 `s3:GetBucket*` + `s3:ListAllMyBuckets`, EC2 | |
| 5 | > `ec2:DescribeRegions`/`DescribeSecurityGroups`, `cloudtrail:DescribeTrails` + | |
| 6 | > `GetTrailStatus`, `config:DescribeConfigurationRecorders*`, and for the SSO | |
| 5 | 7 | > check `sso:List*`/`sso:Describe*`, `identitystore:Describe*`, and |
| 6 | > `organizations:ListAccounts`. | |
| 8 | > `organizations:ListAccounts`. The SecurityAudit managed policy covers these. | |
| 7 | 9 | |
| 8 | 10 | --- |
| 9 | 11 | |
| @@ -42,10 +44,17 @@ Creates a directory: `<out>/aws_audit_<profile>_<YYYY-MM-DD>/` | ||
| 42 | 44 | |---|---| |
| 43 | 45 | | `iam_users.csv` | IAM users with MFA status, access-key count/age, console password, last use | |
| 44 | 46 | | `password_policy.csv` | Account IAM password policy (length, complexity, rotation, reuse) | |
| 47 | | `account_security.csv` | Account summary — root MFA, root access keys, and resource counts | | |
| 45 | 48 | | `s3_public_access.csv` | Per-bucket Public Access Block, policy public status, and ACL public exposure | |
| 49 | | `open_security_groups.csv` | Security-group ingress rules open to `0.0.0.0/0` or `::/0`, across all regions | | |
| 50 | | `cloudtrail.csv` | CloudTrail trails — logging status, multi-region, log-file validation | | |
| 51 | | `config_recorders.csv` | AWS Config recording status per region (gaps are flagged) | | |
| 46 | 52 | | `sso_assignments.csv` | IAM Identity Center permission-set assignments per account (Identity Center + Organizations) | |
| 47 | 53 | | `summary.txt` | Row counts per section | |
| 48 | 54 | |
| 55 | `open_security_groups.csv` and `config_recorders.csv` scan every enabled region, | |
| 56 | so they take longer on accounts with many regions. | |
| 57 | ||
| 49 | 58 | Checks that aren't available (no password policy, no Identity Center instance, |
| 50 | 59 | missing permissions) are skipped with a warning; the rest still run. |
| 51 | 60 | |
applications/aws/audit.py +9 −1
| @@ -31,7 +31,7 @@ import sys | ||
| 31 | 31 | from datetime import date |
| 32 | 32 | |
| 33 | 33 | import config |
| 34 | from collectors import iam, s3, sso | |
| 34 | from collectors import iam, monitoring, s3, security_groups, sso | |
| 35 | 35 | from reporters import csv_reporter |
| 36 | 36 | |
| 37 | 37 | |
| @@ -86,7 +86,15 @@ def run(): | ||
| 86 | 86 | |
| 87 | 87 | collect("IAM users", iam.iam_users, "iam_users.csv") |
| 88 | 88 | collect("Password policy", iam.password_policy, "password_policy.csv") |
| 89 | collect("Account security", iam.account_security, "account_security.csv") | |
| 89 | 90 | collect("S3 public access", s3.s3_public_access, "s3_public_access.csv") |
| 91 | collect( | |
| 92 | "Open security groups", | |
| 93 | security_groups.security_groups, | |
| 94 | "open_security_groups.csv", | |
| 95 | ) | |
| 96 | collect("CloudTrail", monitoring.cloudtrail, "cloudtrail.csv") | |
| 97 | collect("AWS Config recorders", monitoring.config_recorders, "config_recorders.csv") | |
| 90 | 98 | collect("SSO assignments", sso.sso_assignments, "sso_assignments.csv") |
| 91 | 99 | |
| 92 | 100 | print() |
applications/aws/collectors/api.py +6
| @@ -35,3 +35,9 @@ def account_id(cfg): | ||
| 35 | 35 | return cfg["session"].client("sts").get_caller_identity()["Account"] |
| 36 | 36 | except Exception: |
| 37 | 37 | return "" |
| 38 | ||
| 39 | ||
| 40 | def enabled_regions(cfg): | |
| 41 | """Return the region names enabled for the account (for region-scoped checks).""" | |
| 42 | ec2 = cfg["session"].client("ec2", region_name=cfg.get("region") or "us-east-1") | |
| 43 | return [r["RegionName"] for r in ec2.describe_regions().get("Regions", [])] | |
applications/aws/collectors/iam.py +25 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | """ |
| 2 | Collect IAM user hygiene and the account password policy. | |
| 2 | Collect IAM user hygiene, the account password policy, and account-level | |
| 3 | security summary (root MFA, root access keys). | |
| 3 | 4 | """ |
| 4 | 5 | |
| 5 | 6 | import sys |
| @@ -8,6 +9,29 @@ from datetime import datetime, timezone | ||
| 8 | 9 | from botocore.exceptions import ClientError |
| 9 | 10 | |
| 10 | 11 | |
| 12 | def account_security(cfg): | |
| 13 | """ | |
| 14 | One row of account-level security signals from the IAM account summary: | |
| 15 | whether the root user has MFA and access keys, plus resource counts. | |
| 16 | """ | |
| 17 | iam = cfg["session"].client("iam") | |
| 18 | s = iam.get_account_summary()["SummaryMap"] | |
| 19 | return [ | |
| 20 | { | |
| 21 | "root_mfa_enabled": bool(s.get("AccountMFAEnabled", 0)), | |
| 22 | "root_access_keys_present": bool(s.get("AccountAccessKeysPresent", 0)), | |
| 23 | "root_signing_certs_present": bool( | |
| 24 | s.get("AccountSigningCertificatesPresent", 0) | |
| 25 | ), | |
| 26 | "mfa_devices": s.get("MFADevices", 0), | |
| 27 | "users": s.get("Users", 0), | |
| 28 | "groups": s.get("Groups", 0), | |
| 29 | "roles": s.get("Roles", 0), | |
| 30 | "policies": s.get("Policies", 0), | |
| 31 | } | |
| 32 | ] | |
| 33 | ||
| 34 | ||
| 11 | 35 | def iam_users(cfg): |
| 12 | 36 | """ |
| 13 | 37 | One row per IAM user: MFA status, access-key count and oldest key age, |
applications/aws/collectors/monitoring.py added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | """ | |
| 2 | Collect audit-logging posture: CloudTrail trails and AWS Config recorders. | |
| 3 | """ | |
| 4 | ||
| 5 | import sys | |
| 6 | ||
| 7 | from botocore.exceptions import ClientError | |
| 8 | ||
| 9 | from .api import enabled_regions | |
| 10 | ||
| 11 | ||
| 12 | def cloudtrail(cfg): | |
| 13 | """ | |
| 14 | One row per CloudTrail trail: whether it is logging, multi-region, and has | |
| 15 | log-file validation. An empty result means no trails are configured. | |
| 16 | """ | |
| 17 | region = cfg.get("region") or "us-east-1" | |
| 18 | ct = cfg["session"].client("cloudtrail", region_name=region) | |
| 19 | rows = [] | |
| 20 | for trail in ct.describe_trails(includeShadowTrails=False).get("trailList", []): | |
| 21 | try: | |
| 22 | status = ct.get_trail_status(Name=trail["TrailARN"]) | |
| 23 | except ClientError: | |
| 24 | status = {} | |
| 25 | rows.append( | |
| 26 | { | |
| 27 | "name": trail.get("Name", ""), | |
| 28 | "home_region": trail.get("HomeRegion", ""), | |
| 29 | "multi_region": trail.get("IsMultiRegionTrail"), | |
| 30 | "log_file_validation": trail.get("LogFileValidationEnabled"), | |
| 31 | "is_logging": status.get("IsLogging"), | |
| 32 | "s3_bucket": trail.get("S3BucketName", ""), | |
| 33 | } | |
| 34 | ) | |
| 35 | return rows | |
| 36 | ||
| 37 | ||
| 38 | def config_recorders(cfg): | |
| 39 | """ | |
| 40 | One row per region: whether AWS Config is recording. Regions with no | |
| 41 | recorder are reported so gaps are visible. | |
| 42 | """ | |
| 43 | session = cfg["session"] | |
| 44 | rows = [] | |
| 45 | for region in enabled_regions(cfg): | |
| 46 | try: | |
| 47 | cc = session.client("config", region_name=region) | |
| 48 | recorders = cc.describe_configuration_recorders().get( | |
| 49 | "ConfigurationRecorders", [] | |
| 50 | ) | |
| 51 | statuses = { | |
| 52 | s["name"]: s | |
| 53 | for s in cc.describe_configuration_recorder_status().get( | |
| 54 | "ConfigurationRecordersStatus", [] | |
| 55 | ) | |
| 56 | } | |
| 57 | except ClientError as e: | |
| 58 | print(f" Skipping {region}: config returned {e}", file=sys.stderr) | |
| 59 | continue | |
| 60 | ||
| 61 | if not recorders: | |
| 62 | rows.append( | |
| 63 | { | |
| 64 | "region": region, | |
| 65 | "recorder": "(none)", | |
| 66 | "recording": False, | |
| 67 | "last_status": "", | |
| 68 | } | |
| 69 | ) | |
| 70 | continue | |
| 71 | for r in recorders: | |
| 72 | st = statuses.get(r["name"], {}) | |
| 73 | rows.append( | |
| 74 | { | |
| 75 | "region": region, | |
| 76 | "recorder": r["name"], | |
| 77 | "recording": st.get("recording"), | |
| 78 | "last_status": st.get("lastStatus", ""), | |
| 79 | } | |
| 80 | ) | |
| 81 | return rows | |
applications/aws/collectors/security_groups.py added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | """ | |
| 2 | Collect security-group ingress rules open to the internet, across all regions. | |
| 3 | ||
| 4 | Only rules allowing 0.0.0.0/0 or ::/0 are reported — one row per open rule. | |
| 5 | """ | |
| 6 | ||
| 7 | import sys | |
| 8 | ||
| 9 | from botocore.exceptions import ClientError | |
| 10 | ||
| 11 | from .api import enabled_regions | |
| 12 | ||
| 13 | OPEN_V4 = "0.0.0.0/0" | |
| 14 | OPEN_V6 = "::/0" | |
| 15 | ||
| 16 | ||
| 17 | def security_groups(cfg): | |
| 18 | session = cfg["session"] | |
| 19 | rows = [] | |
| 20 | for region in enabled_regions(cfg): | |
| 21 | try: | |
| 22 | ec2 = session.client("ec2", region_name=region) | |
| 23 | groups = _all_groups(ec2) | |
| 24 | except ClientError as e: | |
| 25 | print(f" Skipping {region}: ec2 returned {e}", file=sys.stderr) | |
| 26 | continue | |
| 27 | for sg in groups: | |
| 28 | rows.extend(_open_rules(region, sg)) | |
| 29 | return rows | |
| 30 | ||
| 31 | ||
| 32 | def _all_groups(ec2): | |
| 33 | groups = [] | |
| 34 | for page in ec2.get_paginator("describe_security_groups").paginate(): | |
| 35 | groups.extend(page.get("SecurityGroups", [])) | |
| 36 | return groups | |
| 37 | ||
| 38 | ||
| 39 | def _open_rules(region, sg): | |
| 40 | rows = [] | |
| 41 | for perm in sg.get("IpPermissions", []): | |
| 42 | open_to = [ | |
| 43 | r["CidrIp"] for r in perm.get("IpRanges", []) if r.get("CidrIp") == OPEN_V4 | |
| 44 | ] | |
| 45 | open_to += [ | |
| 46 | r["CidrIpv6"] | |
| 47 | for r in perm.get("Ipv6Ranges", []) | |
| 48 | if r.get("CidrIpv6") == OPEN_V6 | |
| 49 | ] | |
| 50 | if not open_to: | |
| 51 | continue | |
| 52 | rows.append( | |
| 53 | { | |
| 54 | "region": region, | |
| 55 | "group_id": sg.get("GroupId", ""), | |
| 56 | "group_name": sg.get("GroupName", ""), | |
| 57 | "protocol": perm.get("IpProtocol", ""), | |
| 58 | "from_port": perm.get("FromPort", "all"), | |
| 59 | "to_port": perm.get("ToPort", "all"), | |
| 60 | "open_to": ", ".join(open_to), | |
| 61 | } | |
| 62 | ) | |
| 63 | return rows | |
tui/aws_runner.py +29 −1
| @@ -21,7 +21,14 @@ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||
| 21 | 21 | if _REPO_ROOT not in sys.path: |
| 22 | 22 | sys.path.insert(0, _REPO_ROOT) |
| 23 | 23 | |
| 24 | from applications.aws.collectors import api, iam, s3, sso | |
| 24 | from applications.aws.collectors import ( | |
| 25 | api, | |
| 26 | iam, | |
| 27 | monitoring, | |
| 28 | s3, | |
| 29 | security_groups, | |
| 30 | sso, | |
| 31 | ) | |
| 25 | 32 | from applications.aws.reporters import csv_reporter |
| 26 | 33 | |
| 27 | 34 | # --- Check registry --------------------------------------------------------- |
| @@ -34,12 +41,33 @@ CHECKS: list[Check] = [ | ||
| 34 | 41 | iam.password_policy, |
| 35 | 42 | "password_policy.csv", |
| 36 | 43 | ), |
| 44 | Check( | |
| 45 | "account_security", | |
| 46 | "Account security (root MFA)", | |
| 47 | iam.account_security, | |
| 48 | "account_security.csv", | |
| 49 | ), | |
| 37 | 50 | Check( |
| 38 | 51 | "s3_public_access", |
| 39 | 52 | "S3 public access", |
| 40 | 53 | s3.s3_public_access, |
| 41 | 54 | "s3_public_access.csv", |
| 42 | 55 | ), |
| 56 | Check( | |
| 57 | "security_groups", | |
| 58 | "Open security groups", | |
| 59 | security_groups.security_groups, | |
| 60 | "open_security_groups.csv", | |
| 61 | note="scans all regions", | |
| 62 | ), | |
| 63 | Check("cloudtrail", "CloudTrail", monitoring.cloudtrail, "cloudtrail.csv"), | |
| 64 | Check( | |
| 65 | "config_recorders", | |
| 66 | "AWS Config recorders", | |
| 67 | monitoring.config_recorders, | |
| 68 | "config_recorders.csv", | |
| 69 | note="scans all regions", | |
| 70 | ), | |
| 43 | 71 | Check( |
| 44 | 72 | "sso_assignments", |
| 45 | 73 | "SSO assignments", |
tui/tests/test_aws_collectors.py added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | """Unit tests for the new AWS security collectors, mocking boto3 clients.""" | |
| 2 | ||
| 3 | from unittest.mock import MagicMock | |
| 4 | ||
| 5 | from applications.aws.collectors import iam as aws_iam | |
| 6 | from applications.aws.collectors import monitoring | |
| 7 | from applications.aws.collectors import security_groups as sg | |
| 8 | ||
| 9 | ||
| 10 | class FakeSession: | |
| 11 | """Dispatch .client(service, region_name=...) to preconfigured mocks.""" | |
| 12 | ||
| 13 | def __init__(self, clients): | |
| 14 | self._clients = clients | |
| 15 | ||
| 16 | def client(self, service, region_name=None): | |
| 17 | return self._clients[service] | |
| 18 | ||
| 19 | ||
| 20 | def _cfg(clients): | |
| 21 | return {"session": FakeSession(clients), "region": "us-east-1"} | |
| 22 | ||
| 23 | ||
| 24 | # --- account_security ------------------------------------------------------- | |
| 25 | ||
| 26 | ||
| 27 | def test_account_security(): | |
| 28 | iam = MagicMock() | |
| 29 | iam.get_account_summary.return_value = { | |
| 30 | "SummaryMap": { | |
| 31 | "AccountMFAEnabled": 1, | |
| 32 | "AccountAccessKeysPresent": 0, | |
| 33 | "Users": 5, | |
| 34 | "Roles": 12, | |
| 35 | } | |
| 36 | } | |
| 37 | rows = aws_iam.account_security(_cfg({"iam": iam})) | |
| 38 | assert rows[0]["root_mfa_enabled"] is True | |
| 39 | assert rows[0]["root_access_keys_present"] is False | |
| 40 | assert rows[0]["users"] == 5 | |
| 41 | assert rows[0]["roles"] == 12 | |
| 42 | ||
| 43 | ||
| 44 | # --- cloudtrail ------------------------------------------------------------- | |
| 45 | ||
| 46 | ||
| 47 | def test_cloudtrail(): | |
| 48 | ct = MagicMock() | |
| 49 | ct.describe_trails.return_value = { | |
| 50 | "trailList": [ | |
| 51 | { | |
| 52 | "Name": "org-trail", | |
| 53 | "TrailARN": "arn:aws:cloudtrail:...:trail/org-trail", | |
| 54 | "HomeRegion": "us-east-1", | |
| 55 | "IsMultiRegionTrail": True, | |
| 56 | "LogFileValidationEnabled": True, | |
| 57 | "S3BucketName": "logs", | |
| 58 | } | |
| 59 | ] | |
| 60 | } | |
| 61 | ct.get_trail_status.return_value = {"IsLogging": True} | |
| 62 | rows = monitoring.cloudtrail(_cfg({"cloudtrail": ct})) | |
| 63 | assert rows[0]["is_logging"] is True | |
| 64 | assert rows[0]["multi_region"] is True | |
| 65 | assert rows[0]["s3_bucket"] == "logs" | |
| 66 | ||
| 67 | ||
| 68 | # --- config_recorders ------------------------------------------------------- | |
| 69 | ||
| 70 | ||
| 71 | def _ec2_one_region(): | |
| 72 | ec2 = MagicMock() | |
| 73 | ec2.describe_regions.return_value = {"Regions": [{"RegionName": "us-east-1"}]} | |
| 74 | return ec2 | |
| 75 | ||
| 76 | ||
| 77 | def test_config_recorders_recording(): | |
| 78 | config = MagicMock() | |
| 79 | config.describe_configuration_recorders.return_value = { | |
| 80 | "ConfigurationRecorders": [{"name": "default"}] | |
| 81 | } | |
| 82 | config.describe_configuration_recorder_status.return_value = { | |
| 83 | "ConfigurationRecordersStatus": [ | |
| 84 | {"name": "default", "recording": True, "lastStatus": "SUCCESS"} | |
| 85 | ] | |
| 86 | } | |
| 87 | rows = monitoring.config_recorders( | |
| 88 | _cfg({"ec2": _ec2_one_region(), "config": config}) | |
| 89 | ) | |
| 90 | assert rows == [ | |
| 91 | { | |
| 92 | "region": "us-east-1", | |
| 93 | "recorder": "default", | |
| 94 | "recording": True, | |
| 95 | "last_status": "SUCCESS", | |
| 96 | } | |
| 97 | ] | |
| 98 | ||
| 99 | ||
| 100 | def test_config_recorders_reports_gap(): | |
| 101 | config = MagicMock() | |
| 102 | config.describe_configuration_recorders.return_value = { | |
| 103 | "ConfigurationRecorders": [] | |
| 104 | } | |
| 105 | config.describe_configuration_recorder_status.return_value = { | |
| 106 | "ConfigurationRecordersStatus": [] | |
| 107 | } | |
| 108 | rows = monitoring.config_recorders( | |
| 109 | _cfg({"ec2": _ec2_one_region(), "config": config}) | |
| 110 | ) | |
| 111 | assert rows[0]["recorder"] == "(none)" | |
| 112 | assert rows[0]["recording"] is False | |
| 113 | ||
| 114 | ||
| 115 | # --- security_groups -------------------------------------------------------- | |
| 116 | ||
| 117 | ||
| 118 | def _ec2_with_groups(groups): | |
| 119 | ec2 = _ec2_one_region() | |
| 120 | paginator = MagicMock() | |
| 121 | paginator.paginate.return_value = [{"SecurityGroups": groups}] | |
| 122 | ec2.get_paginator.return_value = paginator | |
| 123 | return ec2 | |
| 124 | ||
| 125 | ||
| 126 | def test_security_groups_flags_open_ingress(): | |
| 127 | groups = [ | |
| 128 | { | |
| 129 | "GroupId": "sg-1", | |
| 130 | "GroupName": "web", | |
| 131 | "IpPermissions": [ | |
| 132 | { | |
| 133 | "IpProtocol": "tcp", | |
| 134 | "FromPort": 22, | |
| 135 | "ToPort": 22, | |
| 136 | "IpRanges": [{"CidrIp": "0.0.0.0/0"}], | |
| 137 | "Ipv6Ranges": [], | |
| 138 | } | |
| 139 | ], | |
| 140 | } | |
| 141 | ] | |
| 142 | rows = sg.security_groups(_cfg({"ec2": _ec2_with_groups(groups)})) | |
| 143 | assert len(rows) == 1 | |
| 144 | assert rows[0]["group_id"] == "sg-1" | |
| 145 | assert rows[0]["from_port"] == 22 | |
| 146 | assert rows[0]["open_to"] == "0.0.0.0/0" | |
| 147 | ||
| 148 | ||
| 149 | def test_security_groups_ignores_scoped_ingress(): | |
| 150 | groups = [ | |
| 151 | { | |
| 152 | "GroupId": "sg-2", | |
| 153 | "GroupName": "internal", | |
| 154 | "IpPermissions": [ | |
| 155 | { | |
| 156 | "IpProtocol": "tcp", | |
| 157 | "FromPort": 5432, | |
| 158 | "ToPort": 5432, | |
| 159 | "IpRanges": [{"CidrIp": "10.0.0.0/8"}], | |
| 160 | "Ipv6Ranges": [], | |
| 161 | } | |
| 162 | ], | |
| 163 | } | |
| 164 | ] | |
| 165 | rows = sg.security_groups(_cfg({"ec2": _ec2_with_groups(groups)})) | |
| 166 | assert rows == [] | |