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.
- aws_audit_checklist-0.1.0/LICENSE +21 -0
- aws_audit_checklist-0.1.0/PKG-INFO +90 -0
- aws_audit_checklist-0.1.0/README.md +75 -0
- aws_audit_checklist-0.1.0/aws_audit/__init__.py +5 -0
- aws_audit_checklist-0.1.0/aws_audit/checks.py +334 -0
- aws_audit_checklist-0.1.0/aws_audit/cli.py +154 -0
- aws_audit_checklist-0.1.0/aws_audit_checklist.egg-info/PKG-INFO +90 -0
- aws_audit_checklist-0.1.0/aws_audit_checklist.egg-info/SOURCES.txt +12 -0
- aws_audit_checklist-0.1.0/aws_audit_checklist.egg-info/dependency_links.txt +1 -0
- aws_audit_checklist-0.1.0/aws_audit_checklist.egg-info/entry_points.txt +2 -0
- aws_audit_checklist-0.1.0/aws_audit_checklist.egg-info/requires.txt +1 -0
- aws_audit_checklist-0.1.0/aws_audit_checklist.egg-info/top_level.txt +1 -0
- aws_audit_checklist-0.1.0/pyproject.toml +24 -0
- aws_audit_checklist-0.1.0/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
boto3>=1.26
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aws_audit
|
|
@@ -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"]
|