Peculiar Cloud · Sample deliverable
What a Cloud Security Review hands back
The honest answer to "what exactly do I get?" — shown, not described. Below is a condensed version of the four artifacts every review leaves in your repository.
The top attack path
Before the register, the picture: the single highest-ranked exposure, drawn as the path an attacker actually walks. The detail — and the fix — follow below.
The exposure register
The core document: every finding is an attack path — where an attacker enters, what identity they land on, what they reach — ranked by what we assess as most reachable and most damaging, not by scanner score. Five of the eight entries from the illustrative engagement:
## EXP-001 — public jump host can assume the CI deploy role [rank 1]
Path: internet → EC2 (sg inbound 22 from 0.0.0.0/0) → instance profile
→ sts:AssumeRole ci-deploy → s3:* on the prod data buckets
Why rank 1: two hops from unauthenticated to prod data. The CSPM rated
the security-group finding "Medium" in isolation — reachability and the
role's blast radius are what make it critical.
Fix shipped: fixes/exp-001-jump-host.tf
Residual: none observed after apply — see threat-model/ci-deploy.md
## EXP-002 — public bucket policy fronts an internal dataset [rank 2]
Path: internet → s3:GetObject (bucket policy allows "*") →
customer-derived analytics dataset (classified internal)
Why rank 2: one hop, no identity needed. The bucket was opened for a
one-off partner share; the policy never expired. The CSPM rated it
"Low" because the ACL — not the policy — was closed.
Fix shipped: fixes/exp-002-analytics-bucket.tf
Residual: partner share moves to presigned URLs — backlog #4
## EXP-003 — SSRF-reachable metadata on the reporting service [rank 3]
Path: authenticated user → PDF-render endpoint (fetches arbitrary
URLs) → IMDSv1 → instance role → dynamodb:* on three tables
Why rank 3: needs a paid account, but the endpoint accepts internal
URLs and the instance still answers IMDSv1.
Fix shipped: fixes/exp-003-imdsv2.tf
Residual: URL allow-list in the app itself — backlog #2
## EXP-004 — vendor role trusts an entire AWS account [rank 4]
Path: any principal in the vendor's account → sts:AssumeRole
vendor-integration → read on the billing exports
Why rank 4: needs vendor-side compromise first, but the trust policy
has no ExternalId and trusts the account root.
Fix shipped: fixes/exp-004-vendor-trust.tf
## EXP-005 — dormant IAM user, active key, admin policy [rank 5]
Path: leaked or cracked access key → IAM user deploy-legacy →
AdministratorAccess (attached 2023, unused 14 months)
Why rank 5: no public exposure today — but it is a standing skeleton
key with no MFA and no rotation.
Fix shipped: fixes/exp-005-deploy-legacy.tf The threat model
The register tells you what to fix; the threat model tells you why those paths existed — the trust boundaries of your environment and the identities whose compromise costs the most. An excerpt:
# Trust boundaries — reviewed environment (excerpt)
TB-1 internet → edge
CloudFront + ALB; only 443 terminates here. Two findings
bypassed this boundary entirely: the EXP-001 jump host and
the EXP-002 bucket policy.
TB-2 workload → identity
Every workload carries a role; the blast radius of a workload
compromise IS that role. Of 31 roles reviewed, 6 carried
wildcard actions — ranked in the register, top 3 fixed.
TB-3 account → account
12 cross-account trusts. 9 scoped correctly, 2 trusted a whole
account (EXP-004), 1 dormant (removed).
The identity that matters most: ci-deploy. It can write prod code
and read prod data, and three separate paths reached it (EXP-001,
EXP-003, EXP-005). Guarding it is worth more than closing any ten
scanner findings. The ranked backlog
Everything scoped but not implemented inside the fixed window, written so your team — or a follow-on engagement — can execute without us:
# Fix-it backlog — scoped, not yet implemented (excerpt)
#1 [high] Rotate all access keys older than 90 days; alarm on
IAM-user key use (3 users remain after EXP-005).
Evidence: threat-model/identities.md · Check: zero
active keys > 90d in credential report.
#2 [high] URL allow-list in the PDF-render service — closes the
application half of EXP-003.
Check: render endpoint refuses link-local + VPC CIDRs.
#3 [medium] IMDSv2 as account default via SCP once the two legacy
AMIs are rebuilt.
Check: DescribeInstances shows zero IMDSv1 responders.
#4 [medium] Partner analytics share → presigned URLs; retire the
EXP-002 CloudFront exception.
#5 [medium] Session policies on the shared read-only role used by
the data team (44 principals inherit it today). One complete fix, as it ships
Fixes arrive as pull requests in your repository, not recommendations in an appendix. The full EXP-001 fix:
# fixes/exp-001-jump-host.tf
# EXP-001: public jump host could assume the CI deploy role.
# SSM Session Manager replaces inbound SSH; the deploy role's trust
# and permissions shrink to what CI actually does.
resource "aws_security_group" "jump" {
name_prefix = "jump-host-"
description = "Jump host - no inbound; SSM via VPC endpoints only"
vpc_id = var.vpc_id
# No ingress blocks: Session Manager needs none.
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.vpc_cidr]
}
}
resource "aws_iam_role" "ci_deploy" {
name = "ci-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { AWS = var.ci_runner_role_arn }
Condition = {
StringEquals = { "sts:ExternalId" = var.ci_external_id }
}
}]
})
}
data "aws_iam_policy_document" "ci_deploy" {
statement {
sid = "ArtifactWriteOnly"
actions = [
"s3:PutObject",
"s3:GetObject",
"s3:ListBucket",
"s3:GetBucketLocation",
]
resources = [
"arn:aws:s3:::acme-ci-artifacts",
"arn:aws:s3:::acme-ci-artifacts/*",
"arn:aws:s3:::acme-deploy-manifests",
"arn:aws:s3:::acme-deploy-manifests/*",
]
}
}
resource "aws_iam_role_policy" "ci_deploy" {
name = "ci-deploy-scoped"
role = aws_iam_role.ci_deploy.id
policy = data.aws_iam_policy_document.ci_deploy.json
} What's not shown here
The full deliverable also includes the ADRs for any structural decision we made, the pull-request history your team reviewed as the fixes landed, and the live handover where we walk your engineers through all of it. The engagement ends when they can run it without us — the full engagement description is here. Also see the other sample: an edge-firewall security review from a real engagement →