Read-only security audit toolkit for an AWS account, run weekly (Thursdays)
via GitHub Actions. This is the AWS counterpart to
gcp-security — same approach
(bash + CLI + jq, OIDC auth from CI, findings uploaded as build artifacts),
adapted to AWS services.
| Script | Checks |
|---|---|
01_mfa_audit.sh |
IAM users with console access but no MFA enrolled |
02_access_key_age_audit.sh |
IAM access keys older than STALE_DAYS (default 90) |
03_privileged_iam_audit.sh |
Users/roles/groups with AdministratorAccess or wildcard (Action:*/Resource:*) policies |
04_public_s3_audit.sh |
S3 buckets exposed via ACL, bucket policy, or disabled Block Public Access |
05_public_security_group_audit.sh |
Security groups open to 0.0.0.0/0 / ::/0, flagging sensitive ports (SSH, RDP, DB engines) |
06_public_rds_audit.sh |
Publicly accessible RDS instances and publicly shared snapshots |
07_public_ami_snapshot_audit.sh |
Public AMIs and public EBS snapshots |
08_stale_resources_audit.sh |
The "hasn't been used in 90 days" check — inactive IAM users/roles, unattached EBS volumes, unassociated Elastic IPs, long-stopped EC2 instances |
09_cloudtrail_suspicious_audit.sh |
Privilege-escalation-shaped events, root account usage, anti-forensics activity (disabled logging, deleted trails) in the last LOOKBACK_HOURS |
10_securityhub_guardduty_audit.sh |
Aggregates active CRITICAL/HIGH Security Hub and GuardDuty findings across regions |
11_account_baseline_audit.sh |
Account-level security baseline drift — IAM password policy, S3 account-wide Block Public Access, EBS encryption by default, multi-region CloudTrail with log file validation, AWS Config recording |
Each script prints ISSUE: ... lines for anything actionable, or a No issues found line if clean. The GitHub Actions workflow captures each
script's output to findings/<script-name>.txt, uploads them as a build
artifact (90-day retention), and emails a categorized findings summary every
run (see below) — so new findings and drift show up without anyone having
to remember to check.
The run's pass/fail status reflects whether it actually ran — not whether
anything was found. Findings are expected (e.g. this account's admin user
legitimately having AdministratorAccess), and are already surfaced via
email; a green run means the audits executed and the email sent, a red run
means one of those two things failed to happen.
- OIDC identity provider for
token.actions.githubusercontent.comadded in IAM. - IAM role
github-actions-aws-security-audittrusted only forrepo:jchan023@7191761/aws-security@1329980626:*(GitHub's newer OIDC claim format embeds immutable owner/repo IDs after each name, e.g.owner@ownerId/repo@repoId, to prevent takeover via repo rename/transfer — decode a token'ssubclaim in CI to see this if you ever need to re-verify it), with the AWS managedSecurityAuditpolicy attached (read-only across IAM, S3, EC2, RDS, CloudTrail, GuardDuty, Security Hub, etc. — no write access). - Role ARN is referenced directly in
.github/workflows/aws-security-audit.yml(role-to-assume). No AWS access keys are stored anywhere.
git init
git add .
git commit -m "Initial AWS security audit toolkit"
git branch -M main
git remote add origin https://github.com/jchan023/aws-security.git
git push -u origin main- It runs automatically every Thursday at 14:00 UTC.
- Or trigger it manually: GitHub repo → Actions tab → AWS Security Audit workflow → Run workflow.
Every run also emails a categorized findings summary so a run and its results are visible without opening GitHub. The body is split into five sections:
- New Findings —
ISSUE:lines not seen in any prior run - Existing Findings —
ISSUE:lines seen before, with the date first observed - Drift —
11_account_baseline_audit.sh's output specifically (account-level security settings, shown as-is every run rather than new/existing-tracked, same asgcp-security'sorg_policy_audit.shhandling — this is ongoing configuration posture, not a one-off event, so you want to see it every day it's still true, not just once) - Accepted Risk — findings matched against
accepted-findings.json(see below) — real findings that are deliberately not being acted on right now, with a written reason, kept visible rather than disappearing - All Clear — checks that came back clean
Not every real finding needs action immediately — e.g. Security Hub
recommending Inspector scanning for EC2 when this account has no EC2
instances is technically true but not worth enabling (it'd add ongoing
cost to scan nothing). Rather than letting these sit in New/Existing
forever (alert fatigue) or silently deleting them (no audit trail), add an
entry to accepted-findings.json:
{
"check": "10_securityhub_guardduty_audit",
"match": "Amazon Inspector EC2 scanning should be enabled",
"reason": "No EC2 instances exist in this account. Revisit once EC2 is actually used.",
"accepted_date": "2026-09-03"
}check must match the script name exactly (the findings/<check>.txt
filename minus .txt); match is a substring checked against each
ISSUE line for that check — keep it specific enough that it won't
accidentally swallow an unrelated finding from the same script, but not so
specific (e.g. including a region or resource ID) that a trivial change to
the underlying finding stops matching it. A file whose findings are all
accepted still doesn't count as All Clear — the decision stays visible in
the Accepted Risk section every run, not just once. This file is committed
to the repo (not gitignored) since these are deliberate, reviewable
decisions, unlike the findings-history cache below.
New-vs-existing tracking needs a history file that survives across runs.
That can't be committed to the repo (findings history — account IDs,
resource names, misconfiguration details — would then be public, since this
repo is), so it's kept in a GitHub Actions
cache
instead (.github/scripts/build_email_report.py
builds it). A finding that disappears and later comes back is treated as
new again, not resurrected with its old first-seen date. Only ISSUE
lines are tracked (both the ISSUE: ... and ISSUE [region]: ... formats
scripts use) — header lines (=== ... ===), section dividers
(--- ... ---), and informational NOTE: lines (e.g. a region where
GuardDuty/Security Hub isn't enabled) are filtered out, so they don't show
up as permanent phantom findings.
Set these two repo secrets to enable the email step (GitHub repo → Settings → Secrets and variables → Actions → New repository secret):
MAIL_USERNAME— a Gmail address to send fromMAIL_PASSWORD— a Gmail App Password for that address, not the account password (Google requires 2-Step Verification to be enabled to generate one)
The recipient address is hardcoded to jchan023@gmail.com in the workflow —
change it there if needed. Unlike findings (which no longer fail the run),
a failed email send does fail the job — that's the point of emailing
findings at all, so it needs to be a real failure signal.
Requires the AWS CLI (configured with credentials for the target account)
and jq.
chmod +x scripts/*.sh
STALE_DAYS=90 LOOKBACK_HOURS=24 bash scripts/08_stale_resources_audit.shOr run everything:
mkdir -p findings
for script in scripts/[0-9]*.sh; do
bash "$script" | tee "findings/$(basename "$script" .sh).txt"
done- Region scoping is intentionally split into two groups, because "skip a
region" means something different for each:
05_public_security_group_audit.sh,06–07, and08(partially) — security groups, RDS, AMIs/snapshots, stale resources — look for actual resources, so they always scan every enabled AWS region viaaws ec2 describe-regionsdirectly, unconditionally. Skipping a region here would mean silently missing a real misconfigured resource the moment you deploy somewhere new. They're already cheap and silent when a region has nothing in it, so full coverage costs nothing extra.10_securityhub_guardduty_audit.shand11_account_baseline_audit.sh(EBS encryption + AWS Config) check account/region settings, not resources — every enabled region always reports some status regardless of whether you use it, so scanning all ~17 is pure noise. These two useregions_to_scan()inscripts/lib.sh, which defaults to justAWS_DEFAULT_REGION. SetAWS_REGIONSto a space-separated list (e.g."us-east-1 us-west-2") to widen it once you operate in more than one region — these are still free read-only calls, so widening costs nothing but time.
10_securityhub_guardduty_audit.shonly reports findings in regions where Security Hub / GuardDuty are enabled; it notes (not flags) regions where they're off, since enabling them account/org-wide is a separate decision.- The
SecurityAuditmanaged policy is intentionally broad-but-read-only. If you want to scope it down further, most checks above only need IAM, S3, EC2, RDS, CloudTrail, GuardDuty, and Security Hub read permissions. - The workflow's "Run audit scripts" step runs with
set -euo pipefail, so a genuine bug in one script (not just findings — those always exit 0) aborts the remaining scripts for that run rather than silently continuing. The email/artifact still reflect whatever ran before the failure; the next day's run isn't affected.