An AWS-native security/compliance audit framework. The audit logic, the foundation-model invocation, and the AWS API calls it makes all run inside the account boundary — no external service reaches in to inspect your infrastructure. For compliance-minded enterprises, that is a key objective — and it's exactly what this repo sets out to prove.
This is the evolution of aws-audit-mcp,
re-platformed onto Amazon Bedrock as the foundation-model layer instead of an
external API.
A running index, one row per *-audit-mcp server — grows every time a new
one is added. Each links to a per-check breakdown (what it checks, why it
matters, severity, an example finding, and remediation).
| Service | Checks | Docs |
|---|---|---|
| EC2 | 3 checks | ec2.md |
| IAM | 4 checks | iam.md |
| S3 | 4 checks | s3.md |
Strands Agent (on AgentCore Runtime)
- Foundation model: Claude via Bedrock
- MCP client (IAM/SigV4) -> AgentCore Gateway
v
AgentCore Gateway (MCP layer, AWS_IAM inbound auth)
- Exposes MCP server(s) as agent-callable tools
- Outbound auth: SigV4, signed as "execute-api"
v
API Gateway (HTTP API) + VPC Link <- HTTPS + SigV4 verification, both
v required by the Gateway target and
Internal ALB neither satisfied by a bare ALB
v
ECS Fargate
- ec2-audit-mcp (untagged instances, public IPs, permissive security groups)
- iam-audit-mcp (console users without MFA, stale/unused credentials, root risk)
- s3-audit-mcp (public buckets, public-access-block gaps, encryption, versioning)
- report-mcp (findings -> client-ready Markdown report)
| read-only IAM role, scoped per service
v
Target AWS account (the account being audited -- my own, for v1)
This is not what the original kickoff plan described. The first pass at this scaffold used a classic Bedrock Agent (the GA service, action groups backed by Lambda) and a bare ALB as the Gateway target. Both turned out to be wrong once checked against current AWS docs and the real Terraform provider schema:
- A classic Bedrock Agent's action group only supports a Lambda ARN or a custom-control executor — there's no way to point it at an AgentCore Gateway. Gateway is consumed by an MCP client (AgentCore Runtime, Strands, LangGraph, or a hand-rolled client), not by that resource. Swapped in a Strands Agent on AgentCore Runtime instead.
- An AgentCore Gateway's
mcp_servertarget endpoint must be HTTPS, and IAM/SigV4 outbound auth requires the target be fronted by something that natively verifies SigV4 (AgentCore Runtime, API Gateway, Lambda Function URLs all qualify; a bare ALB doesn't). Added API Gateway (HTTP API) + VPC Link in front of the ALB to get both for free, with no custom domain needed.
Every resource/attribute name in terraform/ below was checked against the
real provider schema (terraform providers schema -json against the
installed hashicorp/aws v6.51.0 and hashicorp/awscc v1.89.0 providers),
and terraform validate/terraform plan/terraform apply have all run
clean against my actual AWS account — see "Status" below.
Design principles, maintained as this grows:
- One MCP server per audited AWS service — never a monolith. Adding a new audit domain means standing up a new MCP server and registering it with the Gateway, not refactoring the existing ones.
report-mcpis its own server, decoupled from audit logic, so output format/destination can change independently.- Each MCP server's execution role is read-only and scoped only to the
service it audits.
ec2-audit-mcp's task role grants exactlyec2:DescribeInstancesandec2:DescribeSecurityGroups— seeterraform/iam.tf. - No MCP server ever accepts AWS credentials as a tool parameter. Every
server runs under its own IAM role (the Fargate task role) and uses
boto3's default credential chain. A caller — including the LLM itself —
cannot hand it different credentials than the ones it was deployed with.
(
aws-audit-mcp's originalaudit_ec2tool tookaccess_key_id/secret_access_keyas plaintext parameters; that pattern was dropped here as the first thing fixed when porting the logic over, since it directly contradicts the "nothing reaches in, nothing leaves" pitch.)
ec2-audit-mcp/ EC2 audit checks (ported from aws-audit-mcp), FastMCP server,
streamable-HTTP transport (network-reachable, not stdio --
required so AgentCore Gateway can call it)
iam-audit-mcp/ IAM audit checks (console users without MFA, stale/unused
credentials, root account risk), same FastMCP/streamable-
HTTP shape as ec2-audit-mcp
s3-audit-mcp/ S3 audit checks (public buckets, public-access-block gaps,
missing encryption/versioning), same FastMCP/streamable-
HTTP shape as ec2-audit-mcp. Buckets are regional like EC2
instances (unlike IAM) -- filtered by actual bucket
region, resolved via get_bucket_location.
report-mcp/ Findings -> Markdown report, persisted to S3 (storage.py)
in addition to being returned inline. HTML/PDF deferred
(v1 ships Markdown only, to keep the image lean -- see
report-mcp/main.py)
agent/ Strands Agent + FastAPI, implementing the AgentCore Runtime
HTTP protocol contract (POST /invocations, GET /ping on
0.0.0.0:8080). MCP client to the Gateway signs with this
Runtime's own IAM role via mcp-proxy-for-aws -- no audited-
account credentials ever touch this service either.
integrations/
security_hub/ AWS Security Hub ASFF export. asff_mapper.py maps the raw
{category: [...]} findings shape into ASFF; exporter/
is a separately-deployed Lambda subscribed to report-mcp's
AuditReportGenerated EventBridge event -- not a Gateway
tool, so the agent can't invoke it and whether a finding
reaches Security Hub never depends on an LLM decision.
Deployed and wired end-to-end; gated by
ENABLE_SECURITY_HUB_EXPORT (false by default -- no paid
Security Hub subscription on this account yet). See
"Status" and "Future expansions" below.
terraform/ ECR, ECS cluster/services/tasks (Fargate), internal ALB,
API Gateway HTTP API + VPC Link, AgentCore Gateway +
targets, AgentCore Runtime (awscc provider), an S3 bucket
for generated reports, the Security Hub exporter Lambda +
EventBridge rule, least-privilege IAM throughout. Remote
state in S3 with native locking -- see "Terraform state
backend" below.
ec2-audit-mcp, iam-audit-mcp, and s3-audit-mcp are all deployed and
verified end-to-end against my real AWS account: a real audit request
flows through every hop (Strands Agent on AgentCore Runtime → AgentCore
Gateway → API Gateway → internal ALB → ECS Fargate) for all three, and a
real combined Markdown report comes back. ec2-audit-mcp, iam-audit-mcp,
s3-audit-mcp, report-mcp, and agent all have passing local test
suites. Terraform state lives in S3 with native locking — see "Terraform
state backend" below.
Known gaps:
awscc_bedrockagentcore_runtime.agent_runtime_name's allowed character set is unconfirmed (currentlyreplace(var.project_name, "-", "_")as a guess) — hasn't caused a problem in practice yet, but isn't confirmed against AWS's actual validation rules either.- A second real end-to-end run (after redeploying
report-mcpwith the_all_findings()fix) surfaced a deeper issue:generate_reporttook a singleaudit_jsonstring, so producing one combined report required the agent to hand-merge multiple tool-call results into one JSON blob itself before calling it -- unreliable LLM behavior, not deterministic code. One run kept only EC2's raw data with a patched summary count; the next produced an empty/"CLEAN" report with no category data at all, even though all three tools had real findings. Fixed by changinggenerate_reportto acceptaudit_jsons: list[str](one entry per tool's raw, unmodified output) and merging them deterministically inreport.py's newmerge_findings()-- the summary is recomputed from the merged categories rather than trusted from the inputs, so it can't drift out of sync with what's actually rendered.agent/strands_agent.py'sSYSTEM_PROMPTwas also rewritten to name all three audit tools explicitly and tell the agent not to hand-merge JSON itself. Deployed and verified -- a real post-redeploy audit run produced a correctly-merged 4-finding report across EC2 and S3, persisted to S3 intact. - Rolling out that fix surfaced a general gotcha: the AgentCore Gateway
caches a target's tool schema at target-creation time and never
re-fetches it on a plain ECS redeploy. After
report-mcp's tool signature changed, the Gateway kept serving the old schema, so the agent got a validation error claiming the tool "requires a field that isn't exposed in its schema" -- the new code was deployed correctly, but the Gateway hadn't re-registered it. Fixed with the same-replaceapproach used for the Runtime's:latest-tag problem:terraform apply -replace=aws_bedrockagentcore_gateway_target.report_mcp. Any future change to an MCP tool's parameters needs this step, not just an ECS redeploy -- see "Deploying" below. - AWS Security Hub export is now wired and deployed, not yet activated
against live Security Hub -- one step further than the original
"designed, not yet activated" state.
report-mcppublishes anAuditReportGeneratedEventBridge event (merged findings + region, noaccount_id) after merging findings; a new exporter Lambda (integrations/security_hub/exporter/) subscribes via its own EventBridge rule, derivesaccount_idfrom its own execution context (zero extra IAM permissions), maps findings to ASFF via a new category→resource table inasff_mapper.py, and would callBatchImportFindingsif enabled. Confirmed against a real deployed invocation: a full audit ran end-to-end,report-mcppublished the event, and the exporter Lambda fired and no-op'd cleanly (ENABLE_SECURITY_HUB_EXPORTisfalse-- this account has no paid Security Hub subscription;aws securityhub describe-hubreturnsSubscriptionRequiredException). That same verification pass caught a real bug worth recording: the exporter'slogger.info()calls produced zero CloudWatch output on the first deployed invocation, even though the no-op path itself ran correctly.logging.basicConfig()no-ops in AWS Lambda's Python runtime -- the runtime pre-attaches its own handler to the root logger before the module loads, and that root logger defaults toWARNING, silently dropping everyINFOrecord. Fixed withlogger.setLevel(logging.INFO)on the named logger instead, redeployed, and re-verified: the no-op log line now appears as expected. A unit test couldn't have caught this -- it's a Lambda-runtime-specific environment quirk, not something pytest's logging capture reproduces -- which is exactly why this section exists.
- Port EC2 audit checks into
ec2-audit-mcp -
report-mcpstub producing a real Markdown report from findings - Terraform for Fargate + API Gateway/VPC Link + AgentCore Gateway + AgentCore Runtime + least-privilege IAM, schema-validated end to end
- Strands agent (
agent/) implementing the Runtime HTTP contract - First real deploy + end-to-end audit run against my own account
- Port IAM audit checks into
iam-audit-mcp - Port S3 audit checks into
s3-audit-mcp
Out of scope for v1: multi-account support, any UI beyond the generated report.
See the table above — per-check detail (what it checks, why it matters,
severity, an example finding, remediation) lives in docs/checks/, not
here, to avoid the same checks being described twice and drifting out of
sync.
cd ec2-audit-mcp && uv venv .venv -p 3.11 && uv pip install -p .venv -r requirements.txt
.venv/bin/python -m pytest tests/ -v
cd ../iam-audit-mcp && uv venv .venv -p 3.11 && uv pip install -p .venv -r requirements.txt
.venv/bin/python -m pytest tests/ -v
cd ../s3-audit-mcp && uv venv .venv -p 3.11 && uv pip install -p .venv -r requirements.txt
.venv/bin/python -m pytest tests/ -v
cd ../report-mcp && uv venv .venv -p 3.11 && uv pip install -p .venv -r requirements.txt
.venv/bin/python -m pytest tests/ -v
cd ../agent && uv venv .venv -p 3.11 && uv pip install -p .venv -r requirements.txt
.venv/bin/python -m pytest tests/ -vcd terraform
terraform init -backend=false
terraform validate
terraform plan -var="bedrock_model_id=<your model/inference-profile id>"Terraform state lives in S3, not locally — terraform/versions.tf's
backend "s3" block. Locking is native S3 conditional writes
(use_lockfile = true), which needs Terraform 1.10+ — no DynamoDB lock
table required. Same pattern as
daily-tech-brief-bedrock.
The bucket can't be created by the same config that uses it as a backend (chicken-and-egg), so it's bootstrapped out of band, once:
aws s3api create-bucket --bucket coresample-tfstate-<account_id> \
--region <region> --create-bucket-configuration LocationConstraint=<region>
aws s3api put-bucket-versioning --bucket coresample-tfstate-<account_id> \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket coresample-tfstate-<account_id> \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws s3api put-public-access-block --bucket coresample-tfstate-<account_id> \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=trueThen point terraform/versions.tf's backend "s3" block at that bucket
(bucket name, key, region — see the existing block for the deployed
instance's values, currently coresample-tfstate-293528978619 in
us-west-2). The IAM identity running terraform init/plan/apply needs
s3:CreateBucket/PutBucketVersioning/PutEncryptionConfiguration/
PutBucketPublicAccessBlock on the bucket for the one-time bootstrap, plus
ongoing s3:GetObject/PutObject/DeleteObject/ListBucket on the bucket
and its contents for every state read/write. Two of the bootstrap action
names are easy to get wrong: the IAM action for the PutBucketEncryption
API is s3:PutEncryptionConfiguration, and the IAM action for
PutPublicAccessBlock is s3:PutBucketPublicAccessBlock — neither matches
its API operation name exactly.
- Enable Bedrock model access for your target Claude model in the console
(Bedrock → Model access), then find the exact model/inference-profile ID:
Set it as
aws bedrock list-inference-profiles --region <region>bedrock_model_idin a gitignoredterraform.tfvars. - First apply is two-step, same chicken-and-egg as any container-image
resource: create the ECR repos first, build + push real images, then
apply everything else.
The AgentCore Gateway target for a brand-new MCP server can fail on this first
cd terraform terraform apply -target=aws_ecr_repository.ec2_audit_mcp -target=aws_ecr_repository.iam_audit_mcp -target=aws_ecr_repository.s3_audit_mcp -target=aws_ecr_repository.report_mcp -target=aws_ecr_repository.agent aws ecr get-login-password --region <region> | docker login --username AWS --password-stdin <account_id>.dkr.ecr.<region>.amazonaws.com docker build -t <ec2_audit_mcp_repo_url>:latest ../ec2-audit-mcp && docker push <ec2_audit_mcp_repo_url>:latest docker build -t <iam_audit_mcp_repo_url>:latest ../iam-audit-mcp && docker push <iam_audit_mcp_repo_url>:latest docker build -t <s3_audit_mcp_repo_url>:latest ../s3-audit-mcp && docker push <s3_audit_mcp_repo_url>:latest docker build -t <report_mcp_repo_url>:latest ../report-mcp && docker push <report_mcp_repo_url>:latest docker buildx build --platform linux/arm64 --provenance=false -t <agent_repo_url>:latest ../agent --push terraform applyapplywithFailed to connect and fetch tools from the provided MCP target server-- it tries to connect before the new ECS service has passed its ALB health check. Confirm the service reached steady state (aws ecs describe-services) and the target ishealthy(aws elbv2 describe-target-health), then re-runterraform apply-- everything else is already created, so it only retries the gateway target. - Invoke the agent once deployed (boto3
bedrock-agentcoreclient, or the AWS CLI'sbedrock-agentcorecommands) with a prompt like"audit us-west-2"and confirm a real Markdown report comes back. - If you ever change parameters on an existing MCP tool (rename, retype,
add/remove an argument) on an already-deployed server: a plain ECS
redeploy of the container is not enough. The AgentCore Gateway target
fetches and caches a target's tool schema once, at target-creation
time, and won't notice the underlying server's tool definitions changed.
Force it to re-register:
Symptom if you skip this: the agent reports a validation error claiming the tool needs a field that "isn't exposed in its schema," even though the new code is deployed correctly. Same root cause as the Runtime's
terraform apply -replace=aws_bedrockagentcore_gateway_target.<name>:latest-tag problem -- a resource referencing something Terraform doesn't see as changed -- same-replacefix.
iam-audit-mcppolicy-wildcard scan (v1.1) — flag directly-attached user policies containingAction: "*"/Resource: "*"(critical severity). Deferred out of the initialiam-audit-mcpround; the other four IAM checks shipped without it.- HTML/PDF report formats in
report-mcp(WeasyPrint, same approach asaws-audit-mcp) - Cross-account role assumption
- AWS Security Hub integration (ASFF export) —
integrations/security_hub/asff_mapper.pymaps audit findings into Security Hub's ASFF format forBatchImportFindings, so CoreSample findings could correlate alongside GuardDuty/Inspector/IAM Access Analyzer and flow into a connected SIEM via Security Hub's EventBridge events. Status: wired and deployed, not yet activated against live Security Hub.report-mcppublishes anAuditReportGeneratedEventBridge event after every report; a dedicated exporter Lambda (integrations/security_hub/exporter/) subscribes to it, maps findings to ASFF, and would callBatchImportFindingsif enabled — deployed and confirmed firing end-to-end against a real audit run (see "Status" above). The remaining gap is real-world activation, not code: this account has no paid Security Hub subscription, so the export path has never run against a live Security Hub instance to confirm the ASFF payload is actually accepted. SetENABLE_SECURITY_HUB_EXPORT=trueon the exporter Lambda once a real Security Hub subscription exists.
- Please see CONTRIBUTING for details, and CONTRIBUTORS for how AI tooling is used in this repo.
mainis protected — all changes go through a PR with a passingtestCI check (.github/workflows/test.yml). SeeCLAUDE.mdfor the full workflow.
Apache License 2.0 — see LICENSE.
