Securing CI/CD Pipelines
Your CI/CD pipeline has more access to production infrastructure than most engineers on your team. It holds cloud credentials, can push container images, deploy to Kubernetes clusters, and modify DNS records. A compromised pipeline is a full-blown breach. This article covers practical hardening measures for GitHub Actions, starting with the highest-impact changes.
1. Replace Long-Lived Credentials with OIDC Federation
The single most impactful security improvement is eliminating static AWS access keys from your CI environment. GitHub Actions supports OpenID Connect (OIDC) federation, which lets your workflow assume an IAM role using a short-lived, automatically rotated JWT -- no secrets stored anywhere.
Setting Up the IAM OIDC Provider
# terraform/modules/github-oidc/main.tf
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Lock to specific repo and branch
"token.actions.githubusercontent.com:sub" = "repo:myorg/myapp:ref:refs/heads/main"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "deploy" {
role = aws_iam_role.github_actions.name
policy_arn = aws_iam_policy.deploy_permissions.arn
}Using OIDC in the Workflow
# .github/workflows/deploy.yaml
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-west-1
# No access keys needed -- OIDC handles authenticationThe condition repo:myorg/myapp:ref:refs/heads/main ensures that only workflows triggered from the main branch of a specific repository can assume this role. A forked repo or a feature branch cannot.
2. Least-Privilege IAM Policies
Your deploy role should have exactly the permissions it needs and nothing more. Start with zero permissions and add them as the pipeline fails:
# Example: minimal ECR + EKS deploy permissions
resource "aws_iam_policy" "deploy_permissions" {
name = "github-actions-deploy-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ECRPushPull"
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
Resource = "arn:aws:ecr:eu-west-1:123456789012:repository/myapp"
},
{
Sid = "EKSAccess"
Effect = "Allow"
Action = [
"eks:DescribeCluster",
"eks:ListClusters"
]
Resource = "arn:aws:eks:eu-west-1:123456789012:cluster/production"
}
]
})
}3. Container Image Signing with Cosign
Signing your container images creates a cryptographic proof of origin. Kubernetes admission controllers (like Kyverno or Sigstore's policy-controller) can then enforce that only signed images are deployed.
# Keyless signing with cosign + GitHub OIDC
- name: Sign container image
env:
COSIGN_EXPERIMENTAL: "1"
run: |
cosign sign --yes \
--oidc-issuer https://token.actions.githubusercontent.com \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}With keyless signing, there are no private keys to manage. Cosign uses the GitHub OIDC token to obtain a short-lived certificate from Sigstore's Fulcio CA, signs the image, and uploads the signature to the Rekor transparency log. Anyone can verify the signature using the public transparency log.
Enforcing Signed Images in Kubernetes
# kyverno-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: check-cosign-signature
match:
resources:
kinds: ["Pod"]
verifyImages:
- imageReferences:
- "123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp:*"
attestors:
- entries:
- keyless:
subject: "https://github.com/myorg/myapp/.github/workflows/*"
issuer: "https://token.actions.githubusercontent.com"4. Supply Chain Security with SLSA
SLSA (Supply-chain Levels for Software Artifacts) is a framework for ensuring the integrity of your build process. At SLSA Level 3, the build is fully defined by source code, uses a hardened build platform, and produces a signed provenance attestation.
# Generate SLSA provenance for container images
- uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
digest: ${{ steps.build.outputs.digest }}
secrets:
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GITHUB_TOKEN }}5. Workflow Hardening Checklist
Beyond OIDC and signing, apply these hardening measures to every workflow:
- Pin actions by SHA, not by tag. Tags are mutable:
uses: actions/checkout@b4ffde65f4 - Set minimal permissions at the workflow level. Default to
permissions: {}and add only what is needed per job. - Disable fork PR workflows for repos with deployment credentials. Use
pull_request_targetonly when absolutely necessary. - Use environment protection rules. Require manual approval for production deployments.
- Audit third-party actions. Periodically review every action in your workflows. Use
actions/dependency-review-actionto catch vulnerable dependencies. - Enable branch protection with required reviews, status checks, and signed commits.
6. Detecting Compromised Workflows
Set up monitoring to catch anomalous CI behavior:
- Alert on workflows that run outside business hours or from unexpected branches.
- Monitor for new OIDC role assumptions in CloudTrail.
- Track which actions are used across your org with
gh api /orgs/{org}/actions/permissions. - Use GitHub's audit log API to detect changes to workflow files, secrets, and environment configurations.
Key Takeaways
- OIDC federation eliminates the highest-risk secret in your CI: static cloud credentials.
- Signing images with cosign creates an auditable chain of trust from source to deployment.
- SLSA provenance attestations prove your artifacts were built from the expected source, on the expected platform.
- Defense in depth: even with OIDC, apply least-privilege, pin actions, and monitor for anomalies.