An AWS account is the strongest isolation boundary available. Mature estates run many: production, staging, security/logging, shared services, sandbox — grouped into Organizational Units under an Organization.
Service Control Policies (SCPs) are guardrails applied at OU or account level. They never grant permission; they set the ceiling of what any principal in that account can do, including the root user. Canonical uses:
{
"Effect": "Deny",
"Action": ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail",
"guardduty:DeleteDetector", "config:DeleteConfigurationRecorder"],
"Resource": "*"
}
Also common: deny use of regions you do not operate in (shrinks attack surface and cost surprises), deny disabling of encryption defaults, and deny root-user actions outside break-glass.
Policy anatomy, with the parts that decide real outcomes:
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::finance-reports/*",
"Condition": {
"StringEquals": {"aws:PrincipalOrgID": "o-abc123"},
"Bool": {"aws:MultiFactorAuthPresent": "true"},
"IpAddress": {"aws:SourceIp": ["203.0.113.0/24"]}
}
}
Conditions are where security actually lives. aws:PrincipalOrgID stops confused-deputy access from outside your org; aws:MultiFactorAuthPresent gates sensitive actions; aws:SourceVpce restricts to a VPC endpoint. A policy with "Resource": "*" and no conditions is the thing you are looking for in a review.
arn:aws:s3:::bucket is the bucket itself (for ListBucket); arn:aws:s3:::bucket/* is the objects inside (for GetObject). Confusing the two is the most common cause of both broken and over-broad policies.A role has two policies: the trust policy (who may assume it) and the permission policy (what it can then do). Assuming it calls sts:AssumeRole and returns temporary credentials with an expiry.
# trust policy — the more security-critical of the two { "Principal": {"Federated": "arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com"}, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": {"StringLike": { "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"}} }
That pattern — GitHub Actions federating into AWS with no stored keys — is the modern replacement for CI access keys. Note the sub condition: without it, any GitHub repository in the world could assume your role. Missing or wildcarded trust conditions are a critical finding.
In CloudTrail, an assumed role appears as userIdentity.type = AssumedRole with an arn ending in the session name. Always set meaningful session names — it is the only way to attribute actions back to a human once federation is in play.
| Security group | Network ACL | |
|---|---|---|
| Attaches to | ENI (instance-level) | Subnet |
| State | Stateful — replies allowed automatically | Stateless — need both directions |
| Rules | Allow only | Allow and deny, numbered order |
| Best for | Everyday microsegmentation | Coarse blocks, e.g. blackhole an IP |
Good habit: security groups referencing other security groups rather than CIDRs — "the app tier may reach the database tier" survives IP changes and reads like intent.
VPC endpoints keep traffic to AWS services off the internet, and their policies can restrict which buckets are reachable from your VPC — a strong exfiltration control (aws:PrincipalOrgID on an S3 endpoint policy prevents copying data into an attacker's account from inside your network).
"Principal": "*" is the classic public exposure.Also: default encryption (SSE-S3 or SSE-KMS), versioning plus object lock for ransomware resilience, and CloudTrail data events on anything sensitive.
| Service | Catches | Limitation |
|---|---|---|
| GuardDuty | Credential exfiltration, crypto-mining, anomalous API use, malicious IPs | Detective only, some findings are noisy |
| Config | Resource configuration drift over time | Costs add up; needs rules defined |
| Security Hub | Aggregated findings + CIS/FSBP standards scoring | Aggregator, not a detector |
| IAM Access Analyzer | Resources shared outside the org; unused access | Only external sharing, not internal over-permission |
| Macie | Sensitive data discovery in S3 | Sampling cost model |
GuardDuty finding worth knowing by name: UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration — instance role credentials used from outside AWS. That is an SSRF or a stolen credential, and it is one of the highest-fidelity alerts in the platform. Route it straight to a page, not a queue.
Also worth memorising: sts:AssumeRole chains where a low-privilege role can assume a higher one because someone wrote a permissive trust policy, and ssm:SendCommand which is remote code execution on any managed instance.
s3:GetObject *; customer data is copied out via a pre-signed URL or to their own bucketiam:PassRole → new Lambda with an admin role → persistenceControls that break this chain, in order of value: IMDSv2 required (session-token-based, defeats basic SSRF), least-privilege instance roles scoped to specific buckets, VPC endpoint policy restricting S3 to your org, GuardDuty routed to a real pager, and SCPs denying IAM changes in production accounts.
# the fields that answer investigations eventTime, eventName, eventSource what was called userIdentity.type / .arn / .sessionContext who — follow the role chain sourceIPAddress, userAgent from where, with what tool requestParameters, responseElements the detail errorCode AccessDenied bursts = enumeration
# patterns to hunt userAgent CONTAINS "aws-cli" AND userIdentity.type = "AssumedRole" AND sourceIPAddress NOT IN (corporate/POP ranges) eventName IN (CreateAccessKey, AttachUserPolicy, PutUserPolicy, CreateLoginProfile, UpdateAssumeRolePolicy) eventName = ConsoleLogin AND additionalEventData.MFAUsed = "No" many errorCode = "AccessDenied" from one principal in a short window
principal, target and metadata.product_event_type populated from eventName. Learning these raw field names first makes the UDM mapping obvious rather than mysterious.Which single control would most reduce the impact of an SSRF vulnerability in an EC2-hosted application?