aws-audit-checklist 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Gomez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: aws-audit-checklist
3
+ Version: 0.1.0
4
+ Summary: A free, read-only AWS security audit CLI — the 30-point checklist a fractional CTO runs on client accounts.
5
+ Author-email: David Gomez <contactme@itsdavidg.co>
6
+ License: MIT
7
+ Project-URL: Homepage, https://services.itsdavidg.co
8
+ Project-URL: Source, https://github.com/davidgomezbravo/aws-audit
9
+ Keywords: aws,security,audit,cloud,devops,iam,s3,checklist,finops
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: boto3>=1.26
14
+ Dynamic: license-file
15
+
16
+ # aws-audit — the AWS security checklist I run on client accounts
17
+
18
+ A free, **read-only** AWS security & cost audit CLI. Point it at an account and it runs the
19
+ same 30-point checklist a fractional CTO uses before a paid audit, then prints a prioritized
20
+ findings report. **It only makes `Describe`/`List`/`Get` calls — nothing is ever created,
21
+ modified, or deleted, and no data leaves your machine.**
22
+
23
+ ```
24
+ $ aws-audit
25
+
26
+ AWS Security Audit · aws-audit
27
+ Account 123456789012 · regions: us-east-1
28
+ ────────────────────────────────────────────────────────────────
29
+
30
+ CRITICAL [IAM-1] Root account MFA — FAIL
31
+ Root account does NOT have MFA enabled.
32
+ fix: Enable a hardware or virtual MFA device on the root user and stop using root.
33
+
34
+ HIGH [IAM-2] Long-lived IAM access keys — FAIL
35
+ 2 active access key(s) older than 90 days.
36
+ affected: deploy-bot:7Q4A (412d), ci-user:9F1C (203d)
37
+ fix: Rotate or delete keys older than 90 days; prefer IAM Identity Center (SSO).
38
+ ...
39
+ 1 fail · 3 warn · 18 pass
40
+
41
+ Want this done for you — and the issues fixed? → https://services.itsdavidg.co
42
+ ```
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pipx install aws-audit-checklist # recommended
48
+ # or
49
+ pip install aws-audit-checklist
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ aws-audit # uses your default AWS credential chain + region
56
+ aws-audit --profile myprofile # a named profile
57
+ aws-audit --all-regions # run regional checks in every enabled region
58
+ aws-audit --markdown report.md # export a Markdown report
59
+ aws-audit --json # machine-readable output
60
+ aws-audit --strict # exit non-zero if anything FAILs (CI gate)
61
+ ```
62
+
63
+ You only need **read-only** credentials. A built-in AWS managed policy like
64
+ `SecurityAudit` or `ReadOnlyAccess` is more than enough. Checks you lack permission for are
65
+ reported as "could-not-check" rather than failing the run.
66
+
67
+ ## What it checks (30-point checklist)
68
+
69
+ | Area | Examples |
70
+ |------|----------|
71
+ | **Identity (IAM)** | root MFA, access keys > 90 days, console users without MFA, password policy |
72
+ | **Network** | security groups exposing SSH/RDP to `0.0.0.0/0` |
73
+ | **Data** | S3 public buckets & default encryption, EBS encryption-by-default, RDS public/encrypted/backups |
74
+ | **Logging** | multi-region CloudTrail, GuardDuty, AWS Config |
75
+ | **Cost signals** | unattached EBS volumes, unused Elastic IPs |
76
+
77
+ The full human-readable checklist (with the items not yet automated — incident runbooks,
78
+ multi-AZ, IaC, off-account backups) is here:
79
+ **[the 30-point AWS Security Checklist PDF](https://services.itsdavidg.co/#checklist)**.
80
+
81
+ ## Why this exists
82
+
83
+ I'm David Gomez — I do fractional-CTO work and run AWS security/cost audits. I kept running
84
+ this same checklist by hand on every account, so I open-sourced the automatable parts. If you
85
+ want the whole thing done for you — including the manual items and actually *fixing* what it
86
+ finds, with a guarantee — that's my [AWS Complete Security Audit](https://services.itsdavidg.co).
87
+
88
+ ## License
89
+
90
+ MIT. Use it freely. No warranty — it's a helper, not a substitute for a real review.
@@ -0,0 +1,75 @@
1
+ # aws-audit — the AWS security checklist I run on client accounts
2
+
3
+ A free, **read-only** AWS security & cost audit CLI. Point it at an account and it runs the
4
+ same 30-point checklist a fractional CTO uses before a paid audit, then prints a prioritized
5
+ findings report. **It only makes `Describe`/`List`/`Get` calls — nothing is ever created,
6
+ modified, or deleted, and no data leaves your machine.**
7
+
8
+ ```
9
+ $ aws-audit
10
+
11
+ AWS Security Audit · aws-audit
12
+ Account 123456789012 · regions: us-east-1
13
+ ────────────────────────────────────────────────────────────────
14
+
15
+ CRITICAL [IAM-1] Root account MFA — FAIL
16
+ Root account does NOT have MFA enabled.
17
+ fix: Enable a hardware or virtual MFA device on the root user and stop using root.
18
+
19
+ HIGH [IAM-2] Long-lived IAM access keys — FAIL
20
+ 2 active access key(s) older than 90 days.
21
+ affected: deploy-bot:7Q4A (412d), ci-user:9F1C (203d)
22
+ fix: Rotate or delete keys older than 90 days; prefer IAM Identity Center (SSO).
23
+ ...
24
+ 1 fail · 3 warn · 18 pass
25
+
26
+ Want this done for you — and the issues fixed? → https://services.itsdavidg.co
27
+ ```
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pipx install aws-audit-checklist # recommended
33
+ # or
34
+ pip install aws-audit-checklist
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ aws-audit # uses your default AWS credential chain + region
41
+ aws-audit --profile myprofile # a named profile
42
+ aws-audit --all-regions # run regional checks in every enabled region
43
+ aws-audit --markdown report.md # export a Markdown report
44
+ aws-audit --json # machine-readable output
45
+ aws-audit --strict # exit non-zero if anything FAILs (CI gate)
46
+ ```
47
+
48
+ You only need **read-only** credentials. A built-in AWS managed policy like
49
+ `SecurityAudit` or `ReadOnlyAccess` is more than enough. Checks you lack permission for are
50
+ reported as "could-not-check" rather than failing the run.
51
+
52
+ ## What it checks (30-point checklist)
53
+
54
+ | Area | Examples |
55
+ |------|----------|
56
+ | **Identity (IAM)** | root MFA, access keys > 90 days, console users without MFA, password policy |
57
+ | **Network** | security groups exposing SSH/RDP to `0.0.0.0/0` |
58
+ | **Data** | S3 public buckets & default encryption, EBS encryption-by-default, RDS public/encrypted/backups |
59
+ | **Logging** | multi-region CloudTrail, GuardDuty, AWS Config |
60
+ | **Cost signals** | unattached EBS volumes, unused Elastic IPs |
61
+
62
+ The full human-readable checklist (with the items not yet automated — incident runbooks,
63
+ multi-AZ, IaC, off-account backups) is here:
64
+ **[the 30-point AWS Security Checklist PDF](https://services.itsdavidg.co/#checklist)**.
65
+
66
+ ## Why this exists
67
+
68
+ I'm David Gomez — I do fractional-CTO work and run AWS security/cost audits. I kept running
69
+ this same checklist by hand on every account, so I open-sourced the automatable parts. If you
70
+ want the whole thing done for you — including the manual items and actually *fixing* what it
71
+ finds, with a guarantee — that's my [AWS Complete Security Audit](https://services.itsdavidg.co).
72
+
73
+ ## License
74
+
75
+ MIT. Use it freely. No warranty — it's a helper, not a substitute for a real review.
@@ -0,0 +1,5 @@
1
+ """aws-audit — a free, read-only AWS security audit CLI.
2
+
3
+ https://services.itsdavidg.co
4
+ """
5
+ __version__ = "0.1.0"
@@ -0,0 +1,334 @@
1
+ """Read-only AWS security checks mapped to the 30-point audit checklist.
2
+
3
+ Every check makes only Describe/List/Get calls. Nothing is created, modified, or deleted.
4
+ Each check returns a list of Finding objects; missing permissions degrade to an ERROR
5
+ finding rather than crashing the run.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ from dataclasses import dataclass, field
11
+ from typing import Callable
12
+
13
+ import boto3
14
+ from botocore.exceptions import BotoCoreError, ClientError
15
+
16
+ CRITICAL, HIGH, MEDIUM, LOW, INFO = "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"
17
+ PASS, FAIL, WARN, ERROR = "PASS", "FAIL", "WARN", "ERROR"
18
+
19
+ SEV_ORDER = {CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, INFO: 4}
20
+
21
+
22
+ @dataclass
23
+ class Finding:
24
+ check_id: str
25
+ title: str
26
+ severity: str
27
+ status: str
28
+ detail: str = ""
29
+ resources: list[str] = field(default_factory=list)
30
+ remediation: str = ""
31
+
32
+
33
+ def _now() -> dt.datetime:
34
+ return dt.datetime.now(dt.timezone.utc)
35
+
36
+
37
+ def _days_old(d: dt.datetime) -> int:
38
+ if d.tzinfo is None:
39
+ d = d.replace(tzinfo=dt.timezone.utc)
40
+ return (_now() - d).days
41
+
42
+
43
+ def _err(check_id: str, title: str, severity: str, exc: Exception) -> Finding:
44
+ msg = getattr(exc, "response", {}).get("Error", {}).get("Code", str(exc)) if isinstance(exc, ClientError) else str(exc)
45
+ return Finding(check_id, title, severity, ERROR,
46
+ detail=f"Could not evaluate (likely missing read permission): {msg}")
47
+
48
+
49
+ # ---------------------------------------------------------------- IAM (global)
50
+
51
+ def check_root_mfa(session: boto3.Session) -> list[Finding]:
52
+ title = "Root account MFA"
53
+ try:
54
+ summary = session.client("iam").get_account_summary()["SummaryMap"]
55
+ if summary.get("AccountMFAEnabled", 0) == 1:
56
+ return [Finding("IAM-1", title, CRITICAL, PASS, "Root account has MFA enabled.")]
57
+ return [Finding("IAM-1", title, CRITICAL, FAIL,
58
+ "Root account does NOT have MFA enabled.",
59
+ remediation="Enable a hardware or virtual MFA device on the root user and stop using root for daily work.")]
60
+ except (ClientError, BotoCoreError) as e:
61
+ return [_err("IAM-1", title, CRITICAL, e)]
62
+
63
+
64
+ def check_old_access_keys(session: boto3.Session) -> list[Finding]:
65
+ title = "Long-lived IAM access keys"
66
+ out: list[Finding] = []
67
+ try:
68
+ iam = session.client("iam")
69
+ stale: list[str] = []
70
+ for page in iam.get_paginator("list_users").paginate():
71
+ for user in page["Users"]:
72
+ for key in iam.list_access_keys(UserName=user["UserName"]).get("AccessKeyMetadata", []):
73
+ age = _days_old(key["CreateDate"])
74
+ if key["Status"] == "Active" and age > 90:
75
+ stale.append(f'{user["UserName"]}:{key["AccessKeyId"][-4:]} ({age}d)')
76
+ if stale:
77
+ out.append(Finding("IAM-2", title, HIGH, FAIL,
78
+ f"{len(stale)} active access key(s) older than 90 days.",
79
+ resources=stale,
80
+ remediation="Rotate or delete keys older than 90 days; prefer IAM Identity Center (SSO) over long-lived keys."))
81
+ else:
82
+ out.append(Finding("IAM-2", title, HIGH, PASS, "No active access keys older than 90 days."))
83
+ except (ClientError, BotoCoreError) as e:
84
+ out.append(_err("IAM-2", title, HIGH, e))
85
+ return out
86
+
87
+
88
+ def check_users_without_mfa(session: boto3.Session) -> list[Finding]:
89
+ title = "Console users without MFA"
90
+ try:
91
+ iam = session.client("iam")
92
+ no_mfa: list[str] = []
93
+ for page in iam.get_paginator("list_users").paginate():
94
+ for user in page["Users"]:
95
+ name = user["UserName"]
96
+ try:
97
+ iam.get_login_profile(UserName=name) # raises if no console password
98
+ except ClientError as e:
99
+ if e.response["Error"]["Code"] == "NoSuchEntity":
100
+ continue
101
+ raise
102
+ if not iam.list_mfa_devices(UserName=name).get("MFADevices"):
103
+ no_mfa.append(name)
104
+ if no_mfa:
105
+ return [Finding("IAM-3", title, HIGH, FAIL,
106
+ f"{len(no_mfa)} user(s) with console access but no MFA.",
107
+ resources=no_mfa,
108
+ remediation="Require MFA for every human with console access (or move them to SSO).")]
109
+ return [Finding("IAM-3", title, HIGH, PASS, "All console users have MFA (or none have console access).")]
110
+ except (ClientError, BotoCoreError) as e:
111
+ return [_err("IAM-3", title, HIGH, e)]
112
+
113
+
114
+ def check_password_policy(session: boto3.Session) -> list[Finding]:
115
+ title = "IAM password policy"
116
+ try:
117
+ pol = session.client("iam").get_account_password_policy()["PasswordPolicy"]
118
+ weak = []
119
+ if pol.get("MinimumPasswordLength", 0) < 14:
120
+ weak.append("min length < 14")
121
+ if not pol.get("RequireSymbols"): weak.append("no symbols required")
122
+ if not pol.get("RequireNumbers"): weak.append("no numbers required")
123
+ if weak:
124
+ return [Finding("IAM-4", title, MEDIUM, WARN, "Weak password policy: " + ", ".join(weak),
125
+ remediation="Set minimum length >= 14 and require symbols/numbers, or use SSO.")]
126
+ return [Finding("IAM-4", title, MEDIUM, PASS, "Password policy meets baseline.")]
127
+ except ClientError as e:
128
+ if e.response["Error"]["Code"] == "NoSuchEntity":
129
+ return [Finding("IAM-4", title, MEDIUM, FAIL, "No account password policy is set.",
130
+ remediation="Set an IAM account password policy, or standardize on SSO.")]
131
+ return [_err("IAM-4", title, MEDIUM, e)]
132
+ except BotoCoreError as e:
133
+ return [_err("IAM-4", title, MEDIUM, e)]
134
+
135
+
136
+ # ---------------------------------------------------------------- S3 (global-ish)
137
+
138
+ def check_s3_account_public_block(session: boto3.Session, account_id: str) -> list[Finding]:
139
+ title = "S3 account-level Block Public Access"
140
+ try:
141
+ cfg = session.client("s3control").get_public_access_block(AccountId=account_id)["PublicAccessBlockConfiguration"]
142
+ if all(cfg.get(k) for k in ("BlockPublicAcls", "IgnorePublicAcls", "BlockPublicPolicy", "RestrictPublicBuckets")):
143
+ return [Finding("S3-1", title, HIGH, PASS, "Account-level S3 Block Public Access is fully ON.")]
144
+ return [Finding("S3-1", title, HIGH, FAIL, f"Account-level Block Public Access is partial: {cfg}",
145
+ remediation="Turn on all four S3 Block Public Access settings at the account level.")]
146
+ except ClientError as e:
147
+ if e.response["Error"]["Code"] in ("NoSuchPublicAccessBlockConfiguration", "NoSuchConfiguration"):
148
+ return [Finding("S3-1", title, HIGH, FAIL, "No account-level Block Public Access configuration.",
149
+ remediation="Enable account-level S3 Block Public Access.")]
150
+ return [_err("S3-1", title, HIGH, e)]
151
+ except BotoCoreError as e:
152
+ return [_err("S3-1", title, HIGH, e)]
153
+
154
+
155
+ def check_s3_buckets(session: boto3.Session) -> list[Finding]:
156
+ out: list[Finding] = []
157
+ try:
158
+ s3 = session.client("s3")
159
+ buckets = s3.list_buckets().get("Buckets", [])
160
+ public, unencrypted = [], []
161
+ for b in buckets:
162
+ name = b["Name"]
163
+ try:
164
+ status = s3.get_bucket_policy_status(Bucket=name)["PolicyStatus"]["IsPublic"]
165
+ if status:
166
+ public.append(name)
167
+ except ClientError:
168
+ pass
169
+ try:
170
+ s3.get_bucket_encryption(Bucket=name)
171
+ except ClientError as e:
172
+ if e.response["Error"]["Code"] == "ServerSideEncryptionConfigurationNotFoundError":
173
+ unencrypted.append(name)
174
+ if public:
175
+ out.append(Finding("S3-2", "Public S3 buckets", CRITICAL, FAIL,
176
+ f"{len(public)} bucket policy(ies) evaluate as public.", resources=public,
177
+ remediation="Review bucket policies/ACLs; make public only what is intentionally public."))
178
+ else:
179
+ out.append(Finding("S3-2", "Public S3 buckets", CRITICAL, PASS, "No buckets evaluate as public by policy."))
180
+ if unencrypted:
181
+ out.append(Finding("S3-3", "S3 default encryption", MEDIUM, WARN,
182
+ f"{len(unencrypted)} bucket(s) without default encryption.", resources=unencrypted,
183
+ remediation="Enable default encryption (SSE-S3 or SSE-KMS) on every bucket."))
184
+ else:
185
+ out.append(Finding("S3-3", "S3 default encryption", MEDIUM, PASS, "All buckets have default encryption."))
186
+ except (ClientError, BotoCoreError) as e:
187
+ out.append(_err("S3-2", "S3 buckets", CRITICAL, e))
188
+ return out
189
+
190
+
191
+ # ---------------------------------------------------------------- EC2 / VPC (regional)
192
+
193
+ def check_open_security_groups(session: boto3.Session, region: str) -> list[Finding]:
194
+ title = "Security groups open to the internet"
195
+ risky_ports = {22: "SSH", 3389: "RDP"}
196
+ try:
197
+ ec2 = session.client("ec2", region_name=region)
198
+ offenders: list[str] = []
199
+ for page in ec2.get_paginator("describe_security_groups").paginate():
200
+ for sg in page["SecurityGroups"]:
201
+ for perm in sg.get("IpPermissions", []):
202
+ open_v4 = any(r.get("CidrIp") == "0.0.0.0/0" for r in perm.get("IpRanges", []))
203
+ open_v6 = any(r.get("CidrIpv6") == "::/0" for r in perm.get("Ipv6Ranges", []))
204
+ if not (open_v4 or open_v6):
205
+ continue
206
+ frm, to = perm.get("FromPort"), perm.get("ToPort")
207
+ for port, label in risky_ports.items():
208
+ if frm is None or (frm <= port <= to):
209
+ offenders.append(f'{sg["GroupId"]} ({label} {port}) [{region}]')
210
+ if offenders:
211
+ return [Finding("NET-1", title, CRITICAL, FAIL,
212
+ f"{len(offenders)} security group rule(s) expose SSH/RDP to the world in {region}.",
213
+ resources=sorted(set(offenders)),
214
+ remediation="Restrict 22/3389 to known IPs or a bastion/SSM; never 0.0.0.0/0.")]
215
+ return [Finding("NET-1", title, CRITICAL, PASS, f"No SSH/RDP exposed to 0.0.0.0/0 in {region}.")]
216
+ except (ClientError, BotoCoreError) as e:
217
+ return [_err("NET-1", title, CRITICAL, e)]
218
+
219
+
220
+ def check_ebs_encryption_default(session: boto3.Session, region: str) -> list[Finding]:
221
+ title = "EBS encryption by default"
222
+ try:
223
+ ec2 = session.client("ec2", region_name=region)
224
+ if ec2.get_ebs_encryption_by_default()["EbsEncryptionByDefault"]:
225
+ return [Finding("DATA-1", title, MEDIUM, PASS, f"EBS encryption-by-default is ON in {region}.")]
226
+ return [Finding("DATA-1", title, MEDIUM, FAIL, f"EBS encryption-by-default is OFF in {region}.",
227
+ remediation="Enable EBS encryption by default per region.")]
228
+ except (ClientError, BotoCoreError) as e:
229
+ return [_err("DATA-1", title, MEDIUM, e)]
230
+
231
+
232
+ def check_unattached_resources(session: boto3.Session, region: str) -> list[Finding]:
233
+ out: list[Finding] = []
234
+ try:
235
+ ec2 = session.client("ec2", region_name=region)
236
+ vols = [v["VolumeId"] for p in ec2.get_paginator("describe_volumes").paginate(
237
+ Filters=[{"Name": "status", "Values": ["available"]}]) for v in p["Volumes"]]
238
+ eips = [a.get("PublicIp", a.get("AllocationId")) for a in ec2.describe_addresses().get("Addresses", [])
239
+ if "AssociationId" not in a]
240
+ if vols:
241
+ out.append(Finding("COST-1", "Unattached EBS volumes", LOW, WARN,
242
+ f"{len(vols)} unattached EBS volume(s) in {region} (paying for nothing).",
243
+ resources=vols, remediation="Snapshot if needed, then delete unattached volumes."))
244
+ else:
245
+ out.append(Finding("COST-1", "Unattached EBS volumes", LOW, PASS, f"No unattached EBS volumes in {region}."))
246
+ if eips:
247
+ out.append(Finding("COST-2", "Unused Elastic IPs", LOW, WARN,
248
+ f"{len(eips)} unassociated Elastic IP(s) in {region} (billed hourly).",
249
+ resources=[str(x) for x in eips], remediation="Release Elastic IPs you aren't using."))
250
+ except (ClientError, BotoCoreError) as e:
251
+ out.append(_err("COST-1", "Unattached resources", LOW, e))
252
+ return out
253
+
254
+
255
+ # ---------------------------------------------------------------- RDS (regional)
256
+
257
+ def check_rds(session: boto3.Session, region: str) -> list[Finding]:
258
+ out: list[Finding] = []
259
+ try:
260
+ rds = session.client("rds", region_name=region)
261
+ public, unenc, nobackup = [], [], []
262
+ for page in rds.get_paginator("describe_db_instances").paginate():
263
+ for db in page["DBInstances"]:
264
+ ident = db["DBInstanceIdentifier"]
265
+ if db.get("PubliclyAccessible"):
266
+ public.append(ident)
267
+ if not db.get("StorageEncrypted"):
268
+ unenc.append(ident)
269
+ if db.get("BackupRetentionPeriod", 0) == 0:
270
+ nobackup.append(ident)
271
+ if public:
272
+ out.append(Finding("DATA-2", "Publicly accessible RDS", CRITICAL, FAIL,
273
+ f"{len(public)} RDS instance(s) are publicly accessible in {region}.",
274
+ resources=public, remediation="Set PubliclyAccessible=false; reach DBs via private networking."))
275
+ if unenc:
276
+ out.append(Finding("DATA-3", "Unencrypted RDS", HIGH, FAIL,
277
+ f"{len(unenc)} RDS instance(s) without storage encryption in {region}.",
278
+ resources=unenc, remediation="Encryption must be set at creation; restore from an encrypted snapshot."))
279
+ if nobackup:
280
+ out.append(Finding("DATA-4", "RDS backups disabled", HIGH, FAIL,
281
+ f"{len(nobackup)} RDS instance(s) with backups disabled in {region}.",
282
+ resources=nobackup, remediation="Set a backup retention period >= 7 days and test a restore."))
283
+ if not (public or unenc or nobackup):
284
+ out.append(Finding("DATA-2", "RDS posture", HIGH, PASS, f"No public/unencrypted/un-backed-up RDS in {region}."))
285
+ except (ClientError, BotoCoreError) as e:
286
+ out.append(_err("DATA-2", "RDS posture", HIGH, e))
287
+ return out
288
+
289
+
290
+ # ---------------------------------------------------------------- Detection (regional)
291
+
292
+ def check_cloudtrail(session: boto3.Session, region: str) -> list[Finding]:
293
+ title = "CloudTrail multi-region logging"
294
+ try:
295
+ trails = session.client("cloudtrail", region_name=region).describe_trails(includeShadowTrails=True)["trailList"]
296
+ if any(t.get("IsMultiRegionTrail") for t in trails):
297
+ return [Finding("LOG-1", title, HIGH, PASS, "A multi-region CloudTrail exists.")]
298
+ if trails:
299
+ return [Finding("LOG-1", title, HIGH, WARN, "CloudTrail exists but none are multi-region.",
300
+ remediation="Enable a multi-region trail so activity in every region is logged.")]
301
+ return [Finding("LOG-1", title, HIGH, FAIL, "No CloudTrail found.",
302
+ remediation="Enable a multi-region CloudTrail logging to a locked-down S3 bucket.")]
303
+ except (ClientError, BotoCoreError) as e:
304
+ return [_err("LOG-1", title, HIGH, e)]
305
+
306
+
307
+ def check_guardduty(session: boto3.Session, region: str) -> list[Finding]:
308
+ title = "GuardDuty threat detection"
309
+ try:
310
+ if session.client("guardduty", region_name=region).list_detectors().get("DetectorIds"):
311
+ return [Finding("LOG-2", title, MEDIUM, PASS, f"GuardDuty is enabled in {region}.")]
312
+ return [Finding("LOG-2", title, MEDIUM, FAIL, f"GuardDuty is not enabled in {region}.",
313
+ remediation="Enable GuardDuty in every active region.")]
314
+ except (ClientError, BotoCoreError) as e:
315
+ return [_err("LOG-2", title, MEDIUM, e)]
316
+
317
+
318
+ def check_config(session: boto3.Session, region: str) -> list[Finding]:
319
+ title = "AWS Config recording"
320
+ try:
321
+ recs = session.client("config", region_name=region).describe_configuration_recorders().get("ConfigurationRecorders", [])
322
+ if recs:
323
+ return [Finding("LOG-3", title, LOW, PASS, f"AWS Config recorder present in {region}.")]
324
+ return [Finding("LOG-3", title, LOW, WARN, f"No AWS Config recorder in {region}.",
325
+ remediation="Enable AWS Config to track resource configuration changes.")]
326
+ except (ClientError, BotoCoreError) as e:
327
+ return [_err("LOG-3", title, LOW, e)]
328
+
329
+
330
+ GLOBAL_CHECKS: list[Callable] = [check_root_mfa, check_old_access_keys, check_users_without_mfa, check_password_policy]
331
+ REGIONAL_CHECKS: list[Callable] = [
332
+ check_open_security_groups, check_ebs_encryption_default, check_unattached_resources,
333
+ check_rds, check_cloudtrail, check_guardduty, check_config,
334
+ ]
@@ -0,0 +1,154 @@
1
+ """aws-audit — a free, read-only AWS security audit CLI.
2
+
3
+ Runs a 30-point security & cost checklist against an AWS account using only
4
+ read-only API calls, and prints a prioritized findings report.
5
+
6
+ Built by David Gomez (Fractional CTO & AWS DevOps). Want it done for you and the
7
+ issues fixed? https://services.itsdavidg.co
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+
15
+ import boto3
16
+
17
+ from . import checks as C
18
+ from .checks import SEV_ORDER, FAIL, WARN, PASS, ERROR
19
+
20
+ CTA = "Want this done for you — and the issues fixed? → https://services.itsdavidg.co"
21
+
22
+ _COLORS = {
23
+ "CRITICAL": "\033[97;41m", "HIGH": "\033[91m", "MEDIUM": "\033[93m",
24
+ "LOW": "\033[94m", "INFO": "\033[90m",
25
+ FAIL: "\033[91m", WARN: "\033[93m", PASS: "\033[92m", ERROR: "\033[90m",
26
+ "bold": "\033[1m", "dim": "\033[2m", "reset": "\033[0m",
27
+ }
28
+
29
+
30
+ def _c(text: str, key: str, use_color: bool) -> str:
31
+ if not use_color:
32
+ return text
33
+ return f"{_COLORS.get(key, '')}{text}{_COLORS['reset']}"
34
+
35
+
36
+ def run_checks(session: boto3.Session, account_id: str, regions: list[str]):
37
+ findings = []
38
+ for fn in C.GLOBAL_CHECKS:
39
+ findings.extend(fn(session))
40
+ findings.extend(C.check_s3_account_public_block(session, account_id))
41
+ findings.extend(C.check_s3_buckets(session))
42
+ for region in regions:
43
+ for fn in C.REGIONAL_CHECKS:
44
+ findings.extend(fn(session, region))
45
+ return findings
46
+
47
+
48
+ def print_report(findings, account_id, regions, use_color):
49
+ fails = [f for f in findings if f.status == FAIL]
50
+ warns = [f for f in findings if f.status == WARN]
51
+ passes = [f for f in findings if f.status == PASS]
52
+ errors = [f for f in findings if f.status == ERROR]
53
+
54
+ print()
55
+ print(_c(" AWS Security Audit", "bold", use_color) + _c(" · aws-audit", "dim", use_color))
56
+ print(_c(f" Account {account_id} · regions: {', '.join(regions)}", "dim", use_color))
57
+ print(" " + "─" * 64)
58
+
59
+ order = sorted(fails + warns, key=lambda f: (SEV_ORDER.get(f.severity, 9), f.check_id))
60
+ if not order:
61
+ print(_c(" ✓ No failed or warning checks. Nice.", PASS, use_color))
62
+ for f in order:
63
+ tag = _c(f" {f.severity} ", f.severity, use_color)
64
+ st = _c(f.status, f.status, use_color)
65
+ print(f"\n {tag} [{f.check_id}] {_c(f.title, 'bold', use_color)} — {st}")
66
+ print(f" {f.detail}")
67
+ if f.resources:
68
+ shown = ", ".join(f.resources[:8]) + (f" … (+{len(f.resources) - 8} more)" if len(f.resources) > 8 else "")
69
+ print(_c(f" affected: {shown}", "dim", use_color))
70
+ if f.remediation:
71
+ print(_c(f" fix: {f.remediation}", "dim", use_color))
72
+
73
+ print("\n " + "─" * 64)
74
+ summary = (f" {_c(str(len(fails)) + ' fail', FAIL, use_color)} · "
75
+ f"{_c(str(len(warns)) + ' warn', WARN, use_color)} · "
76
+ f"{_c(str(len(passes)) + ' pass', PASS, use_color)}")
77
+ if errors:
78
+ summary += f" · {_c(str(len(errors)) + ' could-not-check', ERROR, use_color)}"
79
+ print(summary)
80
+ if errors:
81
+ print(_c(" (could-not-check = the credentials used lack that read permission)", "dim", use_color))
82
+ print()
83
+ print(_c(" " + CTA, "bold", use_color))
84
+ print()
85
+
86
+
87
+ def to_markdown(findings, account_id, regions) -> str:
88
+ lines = [f"# AWS Security Audit — account {account_id}",
89
+ f"_Regions: {', '.join(regions)}_ · generated by [aws-audit](https://services.itsdavidg.co)\n"]
90
+ by_status = {}
91
+ for f in findings:
92
+ by_status.setdefault(f.status, []).append(f)
93
+ for status in (FAIL, WARN, PASS, ERROR):
94
+ items = sorted(by_status.get(status, []), key=lambda f: (SEV_ORDER.get(f.severity, 9), f.check_id))
95
+ if not items:
96
+ continue
97
+ lines.append(f"\n## {status} ({len(items)})\n")
98
+ for f in items:
99
+ lines.append(f"- **[{f.severity}] {f.title}** (`{f.check_id}`) — {f.detail}")
100
+ if f.resources:
101
+ lines.append(f" - affected: {', '.join(f.resources[:20])}")
102
+ if f.remediation:
103
+ lines.append(f" - fix: {f.remediation}")
104
+ lines.append(f"\n---\n\n**{CTA}**\n")
105
+ return "\n".join(lines)
106
+
107
+
108
+ def main(argv=None) -> int:
109
+ p = argparse.ArgumentParser(prog="aws-audit", description="Read-only AWS security audit (30-point checklist).")
110
+ p.add_argument("--profile", help="AWS profile name (default: default credential chain)")
111
+ p.add_argument("--region", help="Region for regional checks (default: session region or us-east-1)")
112
+ p.add_argument("--all-regions", action="store_true", help="Run regional checks in every enabled region (slower)")
113
+ p.add_argument("--json", dest="as_json", action="store_true", help="Output findings as JSON")
114
+ p.add_argument("--markdown", metavar="FILE", help="Write a Markdown report to FILE")
115
+ p.add_argument("--no-color", action="store_true", help="Disable ANSI colors")
116
+ p.add_argument("--strict", action="store_true", help="Exit non-zero if any check FAILs (for CI)")
117
+ args = p.parse_args(argv)
118
+
119
+ use_color = (not args.no_color) and sys.stdout.isatty()
120
+ try:
121
+ session = boto3.Session(profile_name=args.profile) if args.profile else boto3.Session()
122
+ account_id = session.client("sts").get_caller_identity()["Account"]
123
+ except Exception as e: # noqa: BLE001
124
+ print(f"error: could not establish an AWS session: {e}", file=sys.stderr)
125
+ return 2
126
+
127
+ base_region = args.region or session.region_name or "us-east-1"
128
+ if args.all_regions:
129
+ try:
130
+ regions = [r["RegionName"] for r in session.client("ec2", region_name=base_region)
131
+ .describe_regions(Filters=[{"Name": "opt-in-status", "Values": ["opt-in-not-required", "opted-in"]}])["Regions"]]
132
+ except Exception: # noqa: BLE001
133
+ regions = [base_region]
134
+ else:
135
+ regions = [base_region]
136
+
137
+ findings = run_checks(session, account_id, regions)
138
+
139
+ if args.markdown:
140
+ with open(args.markdown, "w", encoding="utf-8") as fh:
141
+ fh.write(to_markdown(findings, account_id, regions))
142
+ print(f"wrote {args.markdown}")
143
+ if args.as_json:
144
+ print(json.dumps([f.__dict__ for f in findings], indent=2))
145
+ else:
146
+ print_report(findings, account_id, regions, use_color)
147
+
148
+ if args.strict and any(f.status == FAIL for f in findings):
149
+ return 1
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: aws-audit-checklist
3
+ Version: 0.1.0
4
+ Summary: A free, read-only AWS security audit CLI — the 30-point checklist a fractional CTO runs on client accounts.
5
+ Author-email: David Gomez <contactme@itsdavidg.co>
6
+ License: MIT
7
+ Project-URL: Homepage, https://services.itsdavidg.co
8
+ Project-URL: Source, https://github.com/davidgomezbravo/aws-audit
9
+ Keywords: aws,security,audit,cloud,devops,iam,s3,checklist,finops
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: boto3>=1.26
14
+ Dynamic: license-file
15
+
16
+ # aws-audit — the AWS security checklist I run on client accounts
17
+
18
+ A free, **read-only** AWS security & cost audit CLI. Point it at an account and it runs the
19
+ same 30-point checklist a fractional CTO uses before a paid audit, then prints a prioritized
20
+ findings report. **It only makes `Describe`/`List`/`Get` calls — nothing is ever created,
21
+ modified, or deleted, and no data leaves your machine.**
22
+
23
+ ```
24
+ $ aws-audit
25
+
26
+ AWS Security Audit · aws-audit
27
+ Account 123456789012 · regions: us-east-1
28
+ ────────────────────────────────────────────────────────────────
29
+
30
+ CRITICAL [IAM-1] Root account MFA — FAIL
31
+ Root account does NOT have MFA enabled.
32
+ fix: Enable a hardware or virtual MFA device on the root user and stop using root.
33
+
34
+ HIGH [IAM-2] Long-lived IAM access keys — FAIL
35
+ 2 active access key(s) older than 90 days.
36
+ affected: deploy-bot:7Q4A (412d), ci-user:9F1C (203d)
37
+ fix: Rotate or delete keys older than 90 days; prefer IAM Identity Center (SSO).
38
+ ...
39
+ 1 fail · 3 warn · 18 pass
40
+
41
+ Want this done for you — and the issues fixed? → https://services.itsdavidg.co
42
+ ```
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pipx install aws-audit-checklist # recommended
48
+ # or
49
+ pip install aws-audit-checklist
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ aws-audit # uses your default AWS credential chain + region
56
+ aws-audit --profile myprofile # a named profile
57
+ aws-audit --all-regions # run regional checks in every enabled region
58
+ aws-audit --markdown report.md # export a Markdown report
59
+ aws-audit --json # machine-readable output
60
+ aws-audit --strict # exit non-zero if anything FAILs (CI gate)
61
+ ```
62
+
63
+ You only need **read-only** credentials. A built-in AWS managed policy like
64
+ `SecurityAudit` or `ReadOnlyAccess` is more than enough. Checks you lack permission for are
65
+ reported as "could-not-check" rather than failing the run.
66
+
67
+ ## What it checks (30-point checklist)
68
+
69
+ | Area | Examples |
70
+ |------|----------|
71
+ | **Identity (IAM)** | root MFA, access keys > 90 days, console users without MFA, password policy |
72
+ | **Network** | security groups exposing SSH/RDP to `0.0.0.0/0` |
73
+ | **Data** | S3 public buckets & default encryption, EBS encryption-by-default, RDS public/encrypted/backups |
74
+ | **Logging** | multi-region CloudTrail, GuardDuty, AWS Config |
75
+ | **Cost signals** | unattached EBS volumes, unused Elastic IPs |
76
+
77
+ The full human-readable checklist (with the items not yet automated — incident runbooks,
78
+ multi-AZ, IaC, off-account backups) is here:
79
+ **[the 30-point AWS Security Checklist PDF](https://services.itsdavidg.co/#checklist)**.
80
+
81
+ ## Why this exists
82
+
83
+ I'm David Gomez — I do fractional-CTO work and run AWS security/cost audits. I kept running
84
+ this same checklist by hand on every account, so I open-sourced the automatable parts. If you
85
+ want the whole thing done for you — including the manual items and actually *fixing* what it
86
+ finds, with a guarantee — that's my [AWS Complete Security Audit](https://services.itsdavidg.co).
87
+
88
+ ## License
89
+
90
+ MIT. Use it freely. No warranty — it's a helper, not a substitute for a real review.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ aws_audit/__init__.py
5
+ aws_audit/checks.py
6
+ aws_audit/cli.py
7
+ aws_audit_checklist.egg-info/PKG-INFO
8
+ aws_audit_checklist.egg-info/SOURCES.txt
9
+ aws_audit_checklist.egg-info/dependency_links.txt
10
+ aws_audit_checklist.egg-info/entry_points.txt
11
+ aws_audit_checklist.egg-info/requires.txt
12
+ aws_audit_checklist.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aws-audit = aws_audit.cli:main
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aws-audit-checklist"
7
+ version = "0.1.0"
8
+ description = "A free, read-only AWS security audit CLI — the 30-point checklist a fractional CTO runs on client accounts."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "David Gomez", email = "contactme@itsdavidg.co" }]
12
+ keywords = ["aws", "security", "audit", "cloud", "devops", "iam", "s3", "checklist", "finops"]
13
+ requires-python = ">=3.9"
14
+ dependencies = ["boto3>=1.26"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://services.itsdavidg.co"
18
+ Source = "https://github.com/davidgomezbravo/aws-audit"
19
+
20
+ [project.scripts]
21
+ aws-audit = "aws_audit.cli:main"
22
+
23
+ [tool.setuptools]
24
+ packages = ["aws_audit"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+