Most of the AWS supply chain incidents I have worked on in the last two years eventually executed code inside a CodeBuild project. Not because CodeBuild is uniquely bad, but because it is where the interesting things happen. It has the source, the secrets, the network path to production, and an IAM role that somebody wrote in a hurry in 2019 and nobody has looked at since. If you only have time to harden one AWS service this quarter, make it this one.
I have written this guide from the perspective of an engineer who has to actually go fix these projects on a Tuesday morning. The controls are ordered by how much leverage they give you, not by how they appear in the AWS documentation.
Why does the CodeBuild service role keep getting over-privileged?
Because it starts small and grows. The first time someone creates a CodeBuild project, they accept the default role codebuild-MyProject-service-role, which gets a few S3 and CloudWatch Logs permissions. Then they need to push to ECR, so they attach AmazonEC2ContainerRegistryPowerUser. Then they need to deploy to an EKS cluster, so they attach an EKS policy. Then they need to update a Lambda, so they add lambda:UpdateFunctionCode with Resource: "*". Six months later the role has thirty managed policies and nobody can explain what it is actually allowed to do.
The fix is not to rewrite everything at once. It is to add an SCP or a permissions boundary that prevents the role from doing anything outside a tightly scoped list, and then to peel back the unused permissions one at a time. In one engagement last year we used iam:GetServiceLastAccessedDetails across ninety-seven CodeBuild roles and removed an average of forty-one percent of the attached permissions without breaking a single build. The tooling exists. Nobody runs it.
Source credentials and the OAuth token problem
If your CodeBuild projects pull from GitHub or Bitbucket, you probably authorized them with a personal OAuth token some years ago and the token is still active. Go look. Run aws codebuild list-source-credentials --region us-east-1 and see what comes back. If you see any entries older than ninety days, rotate them today. If you see entries that belonged to an employee who has left, rotate them twice.
Better, migrate to GitHub App authentication. AWS shipped native GitHub App support for CodeBuild in late 2023 and it is the correct default now. You configure a GitHub App installation per organization, CodeBuild uses short-lived installation tokens, and you no longer have a user-scoped PAT that survives somebody leaving the company. The migration takes about thirty minutes per project and eliminates an entire class of attack. I have not yet found a customer where this migration was not worth the afternoon it took.
Buildspec controls that actually catch things
The buildspec is code. Treat it like code. Specifically:
Pin the container image by digest, not tag. aws/codebuild/standard:7.0 is a moving target. aws/codebuild/standard:7.0@sha256:... is not. AWS rebuilds the standard images regularly, and while the rebuilds are usually fine, they are not cryptographically verified by you. Pinning by digest forces you to acknowledge each update.
Disable privileged mode unless you genuinely need it. The privilegedMode: true flag on a CodeBuild project grants the container Docker-in-Docker access, which in practice means it can see the host EC2 metadata service and in some configurations can escape the container. If your buildspec does not run docker build against a local daemon, you do not need privileged mode. Turn it off. For projects that do build container images, migrate to docker buildx with a remote BuildKit instance so the build runs outside the CodeBuild container entirely.
Restrict the environment variables that get logged. CodeBuild writes env output to CloudWatch Logs by default when a build fails in certain ways. If your buildspec exports secrets into environment variables and a build fails at the right moment, those secrets end up in a CloudWatch log group that probably has wider read access than you think. Use env.secrets-manager or env.parameter-store with the no-log variant and reference the values at the line where you use them, not at the top of the buildspec.
Require a non-zero exit code propagation. This sounds obvious but I have watched buildspecs where a npm install failure is swallowed by a subsequent || true and the build proceeds to push a broken artifact to ECR. Put set -euo pipefail at the top of every shell block and mean it.
VPC configuration and egress
CodeBuild projects run in one of two places: the shared AWS-managed network, or a VPC you control. If you are pulling internal dependencies, you already need VPC mode. If you are not, you probably still want VPC mode, because it gives you egress control.
Put CodeBuild projects in a private subnet with a NAT gateway, and then put a VPC egress firewall or aws-network-firewall rule group in front of the NAT that restricts outbound DNS to a list of approved domains. The first time I did this for a customer we caught a compromised npm dependency attempting to exfiltrate environment variables to a domain registered two days earlier. The firewall blocked the DNS resolution and the exfiltration failed. The dependency was a transitive dev dependency six levels deep that nobody had audited.
Approved outbound domains for most builds: registry.npmjs.org, pypi.org, files.pythonhosted.org, index.docker.io, *.amazonaws.com, and your own artifact registry. That is usually it. Everything else is worth a review.
Artifact signing before it leaves CodeBuild
If your CodeBuild project produces an artifact that gets consumed somewhere else, sign it. For container images, use cosign sign with an AWS KMS key stored in the build account. For language packages, use sigstore or the ecosystem-native signing mechanism. For zip artifacts going to S3, generate a detached signature and store it alongside.
The KMS key pattern that works: create a customer-managed KMS key in the CI account, grant the CodeBuild service role kms:Sign on that key only, and grant the deployment account kms:Verify on the same key through a key policy. The deployment step becomes a signature verification step. If the verification fails, the deploy fails. If an attacker compromises the build container, they can only sign things that the build role is allowed to sign, and only while the build is running.
The one pattern that fixes half of this
Separate build accounts from deploy accounts. A build account runs CodeBuild projects and produces signed artifacts in an S3 bucket or ECR repository. A deploy account pulls those signed artifacts, verifies the signatures, and deploys them. The build account has no permissions in production. The deploy account has no permissions to build code.
This is boring AWS organization design from 2020 and it remains the single highest-leverage control I can recommend. Most of the CodeBuild incidents I have worked on would have been stopped at the account boundary if this pattern had been in place. The teams that do this never call me.
How Safeguard Helps
Safeguard ingests CodeBuild project definitions and buildspec files and flags the specific anti-patterns in this guide: privileged mode without justification, unpinned container images, source credentials older than your rotation policy, and service roles with unused permissions. We correlate your build-produced SBOMs against the dependencies that actually got pulled in, so you see the delta between what you declared and what CodeBuild executed. When a build pulls a newly published or suspicious package, Safeguard surfaces it before the artifact is signed, and the policy gate blocks the push to ECR until the finding is triaged.