Skip to main content
Post

CI/CD Pipeline Security: Hardening Your Software Delivery Chain

Comprehensive guide to securing CI/CD pipelines: secret management, runner hardening, SAST/DAST integration, artifact signing, supply chain integrity, and pipeline-as-code security patterns.

CI/CD Pipeline Security: Hardening Your Software Delivery Chain

CI/CD Pipeline Security

A practitioner’s guide to hardening software delivery pipelines against real-world attack vectors.

CI/CD pipelines are high-value targets. They hold credentials to production systems, execute arbitrary code, and produce the artifacts your customers run. A compromised pipeline means a compromised product. This guide covers the attack surface of modern CI/CD systems and the controls that reduce it.

Table of Contents

1. The CI/CD Attack Surface

1.1 Why Pipelines Are Targets

CI/CD systems occupy a privileged position in the software delivery lifecycle. They typically have:

  • Credentials to production infrastructure (cloud providers, container registries, databases)
  • Write access to source code repositories (merge bots, auto-formatting, changelog updates)
  • Authority to publish artifacts (package registries, container registries, CDNs)
  • Access to signing keys (code signing certificates, GPG keys)
  • Network access to internal systems (staging environments, internal APIs)

An attacker who compromises a pipeline can inject malicious code into every build that pipeline produces, affecting all downstream consumers. This makes CI/CD an attractive target for supply chain attacks.

1.2 Common Attack Vectors

VectorDescriptionExample
Poisoned pipeline executionAttacker modifies pipeline definition in a pull requestMalicious run step exfiltrates secrets via curl
Dependency confusionInternal package name claimed on public registryBuild pulls attacker’s package instead of internal one
Compromised third-party action/orbUpstream action is hijacked or typosquattedactions/checkout replaced with action/checkout
Secret exfiltrationPipeline secrets extracted via environment variablesecho $AWS_SECRET_ACCESS_KEY \| base64 in build logs
Runner persistenceAttacker plants backdoor on non-ephemeral runnerCron job on self-hosted runner survives between builds
Artifact tamperingBuild output modified after compilation, before deploymentBinary replaced in artifact storage between build and deploy stages

1.3 Real-World Incidents

These incidents illustrate why pipeline security is not theoretical:

  • SolarWinds (2020): Attackers injected malicious code into the Orion build system, compromising over 18,000 organizations through tainted software updates.
  • Codecov (2021): A modified bash uploader script in the CI pipeline exfiltrated environment variables (including CI secrets) from customer builds for over two months.
  • ua-parser-js (2021): Compromised npm maintainer credentials led to malicious versions of a package downloaded millions of times per week being published through the normal CI/CD flow.
  • GitHub Actions tj-actions/changed-files (2025): A compromised GitHub Action exposed CI/CD secrets in build logs across thousands of repositories relying on the popular action.

2. Pipeline Architecture Security

2.1 Pipeline-as-Code Principles

Store pipeline definitions in version control alongside application code. This provides auditability, review requirements, and rollback capability.

Enforce review on pipeline changes. Treat .github/workflows/, .gitlab-ci.yml, Jenkinsfile, and similar files with the same rigor as production code. Use CODEOWNERS to require security team approval:

1
2
3
4
# .github/CODEOWNERS
/.github/workflows/  @security-team
/Dockerfile          @security-team
/docker-compose*.yml @security-team

Pin action and image versions to SHA digests, not mutable tags:

1
2
3
4
5
# Bad: mutable tag, can be hijacked
- uses: actions/checkout@v4

# Good: pinned to immutable commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Tools like Dependabot or Renovate can automate SHA pinning and update PRs when new versions are available.

Avoid pull_request_target unless you understand the risks. The pull_request_target trigger runs the workflow definition from the base branch but provides a GITHUB_TOKEN with write permissions and access to repository secrets. If the workflow checks out the PR head (actions/checkout with ref: ${{ github.event.pull_request.head.sha }}), an attacker’s fork can execute arbitrary code with those elevated permissions:

1
2
3
4
5
6
7
8
9
10
# DANGEROUS: pull_request_target + PR head checkout = secret exposure
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # Attacker-controlled code
      - run: npm test  # Runs attacker's code with access to secrets

If you must use pull_request_target, never check out the PR head code and never pass PR-controlled inputs to shell commands. For building and testing PRs from forks, use the pull_request trigger, which runs the fork’s workflow with read-only permissions and no secret access.

2.2 Branch Protection and Merge Controls

Branch protection rules are the first line of defense against unauthorized code reaching your pipeline:

GitHub branch protection:

1
2
3
4
5
6
7
8
9
10
11
12
# Configure via GitHub CLI
gh api repos/{owner}/{repo}/branches/main/protection -X PUT \
  -f "required_status_checks[strict]=true" \
  -f "required_status_checks[contexts][]=security-scan" \
  -f "required_status_checks[contexts][]=test" \
  -f "enforce_admins=true" \
  -f "required_pull_request_reviews[required_approving_review_count]=2" \
  -f "required_pull_request_reviews[dismiss_stale_reviews]=true" \
  -f "required_pull_request_reviews[require_code_owner_reviews]=true" \
  -f "restrictions=null" \
  -F "allow_force_pushes=false" \
  -F "allow_deletions=false"

Key settings:

  • Require pull request reviews before merging (minimum 2 for production branches)
  • Dismiss stale reviews when new commits are pushed
  • Require status checks to pass (security scans, tests, linting)
  • Require signed commits to verify author identity
  • Disable force pushes to prevent history rewriting
  • Enforce rules for admins so no one can bypass protections

2.3 Environment Isolation

Separate pipeline environments with distinct credentials and approval gates:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# GitHub Actions environment protection
name: Deploy Production
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Configure AWS via OIDC
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
        with:
          # Use OIDC federation (see section 2.4) instead of static keys
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1

      - name: Deploy
        run: |
          ./deploy.sh

Configure environment protection rules:

  • Required reviewers for production deployments
  • Wait timers to allow cancellation of suspicious deployments
  • Deployment branch restrictions (only main can deploy to production)
  • Separate secrets per environment (dev, staging, production)

2.4 Least Privilege for Pipeline Identities

Restrict the default GITHUB_TOKEN permissions to read-only and grant write access only where needed:

1
2
3
4
5
6
7
8
9
10
11
12
# Set restrictive defaults at the workflow level
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write  # Only if publishing to GHCR
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

For cloud providers, use short-lived federated credentials instead of long-lived API keys:

1
2
3
4
5
6
7
8
9
10
11
12
13
# GitHub Actions OIDC with AWS
jobs:
  deploy:
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
          # No static credentials needed

Set up the corresponding IAM trust policy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

This ensures only the main branch of the specified repository can assume the deploy role, and credentials expire automatically after the job completes.

3. Secrets Management

3.1 Types of Pipeline Secrets

Secret TypeExamplesRisk Level
Cloud credentialsAWS keys, GCP service accounts, Azure SPNCritical
Registry tokensDocker Hub, GHCR, ECR push tokensHigh
Signing keysGPG keys, cosign keys, code signing certificatesCritical
API keysDeployment services, notification webhooksMedium-High
Database credentialsMigration runners, seed scriptsHigh
TokensGitHub PATs, npm publish tokensHigh

3.2 Native Secret Stores

GitHub Actions Secrets:

1
2
3
4
5
6
7
8
9
# Organization-level secrets (shared across repos)
# Repository-level secrets (specific to one repo)
# Environment-level secrets (scoped to deployment target)

steps:
  - name: Deploy
    env:
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
    run: ./migrate.sh

Best practices for native secrets:

  • Use environment-scoped secrets wherever possible to limit blast radius
  • Prefer organization secrets with repository access policies over duplicating secrets
  • Audit secret access via the organization audit log

GitLab CI/CD Variables:

1
2
3
4
5
6
7
8
9
10
11
# In .gitlab-ci.yml
deploy:
  stage: deploy
  script:
    - ./deploy.sh
  variables:
    DEPLOY_TOKEN: $DEPLOY_TOKEN
  environment:
    name: production
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Configure variables with:

  • Protected flag (only available on protected branches/tags)
  • Masked flag (hidden in job logs)
  • Environment scope to limit which environments can access them

3.3 External Vault Integration

For organizations managing many secrets or requiring centralized auditing, integrate with a dedicated secrets manager.

HashiCorp Vault with GitHub Actions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Import Secrets from Vault
        uses: hashicorp/vault-action@130d1f5f4fe645bb6c83e4225c04d64cfb62de6e
        with:
          url: https://vault.example.com
          method: jwt
          role: github-actions
          secrets: |
            secret/data/production/db password | DB_PASSWORD ;
            secret/data/production/api key | API_KEY

      - name: Use secrets
        run: |
          # Secrets are now available as environment variables
          ./deploy.sh

Vault JWT auth configuration for GitHub Actions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Enable JWT auth method
vault auth enable jwt

# Configure GitHub OIDC as identity provider
vault write auth/jwt/config \
  bound_issuer="https://token.actions.githubusercontent.com" \
  oidc_discovery_url="https://token.actions.githubusercontent.com"

# Create role with branch restrictions
vault write auth/jwt/role/github-actions \
  bound_audiences="https://github.com/your-org" \
  bound_claims_type="glob" \
  bound_claims='{"repository":"your-org/*","ref":"refs/heads/main"}' \
  user_claim="repository" \
  role_type="jwt" \
  policies="deploy-policy" \
  ttl="10m" \
  max_ttl="15m"

3.4 Secret Rotation and Auditing

Automate credential rotation on a defined schedule:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Example: GitHub Actions workflow for rotating deploy keys
name: Rotate Deploy Credentials
on:
  schedule:
    - cron: '0 6 1 * *'  # First of each month at 06:00 UTC
  workflow_dispatch:       # Allow manual trigger

jobs:
  rotate:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - name: Authenticate to AWS
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
        with:
          role-to-assume: arn:aws:iam::123456789012:role/key-rotation
          aws-region: us-east-1

      - name: Rotate IAM access key
        run: |
          # Create new key
          NEW_KEY=$(aws iam create-access-key --user-name deploy-bot --output json)
          NEW_KEY_ID=$(echo "$NEW_KEY" | jq -r '.AccessKey.AccessKeyId')
          NEW_SECRET=$(echo "$NEW_KEY" | jq -r '.AccessKey.SecretAccessKey')

          # Update GitHub secret via API
          gh secret set AWS_ACCESS_KEY_ID --body "$NEW_KEY_ID"
          gh secret set AWS_SECRET_ACCESS_KEY --body "$NEW_SECRET"

          # Deactivate old key (delete after 24h grace period)
          OLD_KEYS=$(aws iam list-access-keys --user-name deploy-bot \
            --query "AccessKeyMetadata[?AccessKeyId!='${NEW_KEY_ID}'].AccessKeyId" \
            --output text)
          for KEY in $OLD_KEYS; do
            aws iam update-access-key --user-name deploy-bot \
              --access-key-id "$KEY" --status Inactive
          done
        env:
          GH_TOKEN: ${{ secrets.GH_ADMIN_TOKEN }}

3.5 Preventing Secret Leakage

Secrets leak through build logs, error messages, and artifacts. Implement multiple layers of defense:

Pre-commit scanning with Gitleaks:

1
2
3
4
5
6
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks

Pipeline-level scanning:

1
2
3
4
5
6
7
8
9
10
11
12
13
# GitHub Actions
jobs:
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
        with:
          fetch-depth: 0

      - name: Run Gitleaks
        uses: gitleaks/gitleaks-action@cb7149a9b57195b609c63e8518d2c6056677d2d0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Additional leak prevention:

  • Enable GitHub push protection and secret scanning at the organization level
  • Use add_mask in GitHub Actions to dynamically mask values that appear in logs
  • Never echo environment variables in debug steps
  • Audit artifact uploads to ensure secrets are not bundled into build outputs
  • Configure log retention policies to limit exposure windows
1
2
3
4
5
6
# Mask dynamic values in GitHub Actions
- name: Fetch and mask token
  run: |
    TOKEN=$(curl -s https://auth.internal/token)
    echo "::add-mask::${TOKEN}"
    echo "DEPLOY_TOKEN=${TOKEN}" >> "$GITHUB_ENV"

4. Runner and Build Environment Hardening

4.1 Self-Hosted vs. Managed Runners

AspectManaged (GitHub/GitLab SaaS)Self-Hosted
IsolationStrong (ephemeral VMs)You are responsible
PersistenceNone between jobsRisk of cross-job contamination
Network accessPublic internet onlyCan reach internal systems
CostPer-minute billingInfrastructure cost
ComplianceProvider’s certificationsYour certifications

Recommendation: Use managed runners for public repositories and general builds. Reserve self-hosted runners for workloads that require internal network access or specialized hardware, and treat them as high-trust infrastructure.

4.2 Ephemeral Build Environments

Self-hosted runners should be ephemeral. Each job gets a fresh environment that is destroyed after completion, preventing persistence attacks.

GitHub Actions ephemeral runner with --ephemeral flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env bash
# launch-runner.sh - Launches a single-use GitHub Actions runner
set -euo pipefail

RUNNER_TOKEN=$(gh api \
  -X POST "repos/${REPO}/actions/runners/registration-token" \
  --jq '.token')

docker run --rm \
  --name "runner-$(date +%s)" \
  --security-opt no-new-privileges \
  --cap-drop ALL \
  --memory 4g \
  --cpus 2 \
  -e RUNNER_TOKEN="$RUNNER_TOKEN" \
  -e RUNNER_REPOSITORY="https://github.com/${REPO}" \
  -e RUNNER_EPHEMERAL=true \
  ghcr.io/your-org/custom-runner:latest

Auto-scaling with Kubernetes using Actions Runner Controller (ARC):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# runner-scale-set.yaml
apiVersion: actions.github.com/v1alpha1
kind: AutoscalingRunnerSet
metadata:
  name: production-runners
  namespace: actions-runners
spec:
  githubConfigUrl: "https://github.com/your-org"
  githubConfigSecret: github-config
  maxRunners: 20
  minRunners: 0
  template:
    spec:
      containers:
        - name: runner
          image: ghcr.io/actions/actions-runner:2.321.0  # Pin to specific version
          resources:
            limits:
              memory: "4Gi"
              cpu: "2"
            requests:
              memory: "2Gi"
              cpu: "1"
          securityContext:
            runAsNonRoot: true
            runAsUser: 1001
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
      securityContext:
        seccompProfile:
          type: RuntimeDefault

4.3 Runner Isolation Strategies

For self-hosted runners, implement defense-in-depth isolation:

1
2
3
4
5
6
7
8
9
10
11
12
13
+-----------------------------------------------------+
|  Host OS (minimal, hardened, auto-patched)           |
|  +-----------------------------------------------+  |
|  |  VM / Container Runtime                       |  |
|  |  +------------------+ +------------------+    |  |
|  |  | Runner Instance  | | Runner Instance  |    |  |
|  |  | (ephemeral)      | | (ephemeral)      |    |  |
|  |  | - no-new-privs   | | - no-new-privs   |    |  |
|  |  | - cap-drop ALL   | | - cap-drop ALL   |    |  |
|  |  | - read-only root | | - read-only root |    |  |
|  |  +------------------+ +------------------+    |  |
|  +-----------------------------------------------+  |
+-----------------------------------------------------+

Key isolation controls:

  • Dedicated runner groups per security tier (never share production runners with PR builds)
  • Separate runner infrastructure for public fork PRs (no access to secrets)
  • No Docker socket mounting (-v /var/run/docker.sock) unless using rootless Docker or sysbox
  • Read-only root filesystem with explicit writable mounts for workspace and temp directories
  • seccomp and AppArmor profiles to restrict system calls

4.4 Network Controls for Runners

Restrict runner network access to only what the build requires:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# iptables rules for a self-hosted runner host
# Allow DNS resolution
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT

# Allow HTTPS to package registries and GitHub
iptables -A OUTPUT -p tcp --dport 443 -d api.github.com -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -d ghcr.io -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -d registry.npmjs.org -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -d rubygems.org -j ACCEPT

# Allow access to internal artifact store
iptables -A OUTPUT -p tcp --dport 443 -d artifacts.internal.example.com -j ACCEPT

# Drop everything else
iptables -A OUTPUT -j DROP

For Kubernetes-based runners, use NetworkPolicy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: runner-egress
  namespace: actions-runners
spec:
  podSelector:
    matchLabels:
      app: github-runner
  policyTypes:
    - Egress
  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
      ports:
        - port: 443
          protocol: TCP
        - port: 53
          protocol: UDP
    - to:
        - namespaceSelector:
            matchLabels:
              name: artifact-store
      ports:
        - port: 8443
          protocol: TCP

4.5 Cache Poisoning Prevention

Pipeline caches (npm, pip, Docker layers, compiled artifacts) are shared across workflow runs and can be poisoned. An attacker who controls a cache entry can inject malicious dependencies or build outputs into future builds.

Key mitigations:

  • Scope caches to branches. GitHub Actions caches created by a PR branch cannot be read by the base branch, but the reverse is true. This limits but does not eliminate risk.
  • Use lock file hashes as cache keys so caches are invalidated when dependencies change:
1
2
3
4
5
6
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-
  • Never cache executable artifacts that will be deployed. Caches should only hold intermediate build dependencies, not final outputs.
  • Verify integrity after restoring caches where possible (e.g., npm ci will fail if node_modules doesn’t match package-lock.json).
  • Disable caching for security-critical workflows (signing, deployment) to ensure clean builds.

5. Static Application Security Testing (SAST)

5.1 SAST Tool Selection

ToolLanguagesLicenseStrengths
Semgrep30+ languagesLGPL-2.1 (OSS) / CommercialCustom rules, low false positives, fast
CodeQLC/C++, C#, Go, Java, JS/TS, Python, Ruby, SwiftFree for public reposDeep dataflow analysis, GitHub-native
BanditPythonApache-2.0Python-specific, low overhead
GosecGoApache-2.0Go-specific, AST analysis
BrakemanRuby on RailsMITRails-specific, no configuration needed

5.2 GitHub Actions SAST Integration

Semgrep scanning:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
jobs:
  sast:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Run Semgrep
        uses: semgrep/semgrep-action@713efdd345ed38e07810a4de6781a7f25e1e9117
        with:
          config: >-
            p/default
            p/owasp-top-ten
            p/cwe-top-25
          generateSarif: true

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        with:
          sarif_file: semgrep.sarif

CodeQL analysis:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
jobs:
  codeql:
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: read
      security-events: write
    strategy:
      matrix:
        language: ['javascript', 'python']
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Initialize CodeQL
        uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        with:
          languages: ${{ matrix.language }}
          queries: security-extended

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        with:
          category: "/language:${{ matrix.language }}"

5.3 GitLab CI SAST Integration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# .gitlab-ci.yml
include:
  - template: Security/SAST.gitlab-ci.yml

sast:
  stage: test
  variables:
    SAST_EXCLUDED_ANALYZERS: "spotbugs"
    SEARCH_MAX_DEPTH: 10
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# Custom Semgrep rules alongside GitLab SAST
semgrep-custom:
  stage: test
  image: semgrep/semgrep:latest
  script:
    - semgrep scan --config .semgrep/ --sarif -o semgrep-results.sarif
  artifacts:
    reports:
      sast: semgrep-results.sarif
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

5.4 Managing False Positives

SAST tools generate false positives. Manage them systematically to avoid alert fatigue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# .semgrep/settings.yml
# Ignore specific findings with inline comments or central config
rules:
  - id: custom-sql-injection
    patterns:
      - pattern: |
          cursor.execute($QUERY)
      - pattern-not: |
          cursor.execute($QUERY, $PARAMS)
    message: "Use parameterized queries"
    severity: ERROR

# Suppress known false positives
# In code: # nosemgrep: rule-id
# In config: use paths.exclude

Establish a triage workflow:

  1. New findings are reviewed within one sprint
  2. True positives get tickets in your issue tracker
  3. False positives are suppressed with a comment explaining why
  4. Suppressions are audited periodically to catch stale entries

6. Dependency and Composition Analysis

6.1 Software Composition Analysis (SCA)

Scan dependencies for known vulnerabilities on every pull request:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# GitHub Actions with Trivy for dependency scanning
jobs:
  dependency-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Run Trivy filesystem scan
        uses: aquasecurity/trivy-action@18f2510ee396bbf400402c6e5f6f022029f7c1a0
        with:
          scan-type: 'fs'
          scan-ref: '.'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'

      - name: Upload results
        uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        with:
          sarif_file: trivy-results.sarif

Dependency review for pull requests (blocks merging PRs that introduce vulnerable dependencies):

1
2
3
4
5
6
7
8
9
10
11
jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Dependency Review
        uses: actions/dependency-review-action@4901385134134e04cec5fbe5ddfe3b2c5bd5d976
        with:
          fail-on-severity: high
          deny-licenses: GPL-3.0, AGPL-3.0

6.2 SBOM Generation

Generate a Software Bill of Materials for every release to support vulnerability tracking and compliance:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
jobs:
  sbom:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write  # Required for keyless cosign attestation
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Install Cosign
        uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da

      - name: Generate SBOM with Syft
        uses: anchore/sbom-action@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0
        with:
          format: spdx-json
          output-file: sbom.spdx.json
          artifact-name: sbom

      - name: Attest SBOM to container image
        run: |
          cosign attest --yes --predicate sbom.spdx.json \
            --type spdxjson \
            ghcr.io/your-org/your-app:${{ github.sha }}

6.3 License Compliance

Track dependency licenses to avoid legal and compliance issues:

1
2
3
4
5
6
7
8
# Using Trivy for license scanning
- name: License check
  run: |
    trivy fs --scanners license \
      --severity UNKNOWN,HIGH,CRITICAL \
      --license-full \
      --format table \
      .

6.4 Dependency Pinning and Lock Files

Ensure reproducible builds and prevent dependency substitution:

1
2
3
4
5
6
7
8
9
10
11
# Node.js: always commit package-lock.json and use ci
npm ci  # Not npm install

# Python: pin with hashes
pip install --require-hashes -r requirements.txt

# Go: verify checksums
go mod verify

# Ruby: commit Gemfile.lock
bundle install --frozen

Generate pinned requirements with hashes for Python:

1
pip-compile --generate-hashes requirements.in -o requirements.txt

7. Dynamic Application Security Testing (DAST)

7.1 DAST in the Pipeline

DAST tests the running application, catching vulnerabilities that static analysis misses (authentication flaws, misconfigurations, runtime injection). Run DAST against ephemeral preview environments to avoid testing in production:

1
PR opened -> Build -> Deploy to ephemeral env -> DAST scan -> Report -> Tear down env

7.2 ZAP Automation Framework

OWASP ZAP provides a CI-friendly automation framework:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# zap-automation.yaml
env:
  contexts:
    - name: "target-app"
      urls:
        - "https://preview-pr-123.example.com"
      includePaths:
        - "https://preview-pr-123\\.example\\.com.*"
      excludePaths:
        - ".*\\.js$"
        - ".*\\.css$"
  parameters:
    failOnError: true
    failOnWarning: false
    progressToStdout: true

jobs:
  - type: spider
    parameters:
      maxDuration: 5
      maxDepth: 5
  - type: passiveScan-wait
    parameters:
      maxDuration: 10
  - type: activeScan
    parameters:
      maxRuleDurationInMins: 5
      maxScanDurationInMins: 30
    policyDefinition:
      rules:
        - id: 40012  # XSS Reflected
          strength: high
          threshold: medium
        - id: 40014  # XSS Persistent
          strength: high
          threshold: medium
        - id: 90019  # Server Side Code Injection
          strength: high
          threshold: low
  - type: report
    parameters:
      template: "sarif-json"
      reportDir: "/zap/reports"
      reportFile: "zap-results.sarif"

GitHub Actions integration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
jobs:
  dast:
    runs-on: ubuntu-latest
    needs: deploy-preview
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: ZAP Automation Scan
        uses: zaproxy/action-automation@fa96c2a867bfb8092f85b1d8a1fcf1ce38a7e45f
        with:
          autorun: zap-automation.yaml
          issue_title: "DAST Findings: ${{ github.event.pull_request.title }}"

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        with:
          sarif_file: reports/zap-results.sarif

7.3 Nuclei for Known Vulnerability Scanning

Nuclei uses community-maintained templates to detect known vulnerabilities, misconfigurations, and exposed services:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
jobs:
  nuclei-scan:
    runs-on: ubuntu-latest
    needs: deploy-preview
    steps:
      - name: Run Nuclei
        uses: projectdiscovery/nuclei-action@5e39b17f047c7c6a2b3f05c26c8f1eeb1629f6d7
        with:
          target: https://preview-pr-${{ github.event.number }}.example.com
          templates: |
            cves/
            misconfiguration/
            exposures/
          severity: critical,high,medium
          sarif-export: nuclei-results.sarif

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        with:
          sarif_file: nuclei-results.sarif

8. Container Security Scanning

8.1 Image Vulnerability Scanning

Scan container images before they reach a registry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
jobs:
  container-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Build image
        run: docker build -t app:${{ github.sha }} .

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@18f2510ee396bbf400402c6e5f6f022029f7c1a0
        with:
          image-ref: 'app:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-image.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'  # Fail the pipeline on findings

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        with:
          sarif_file: trivy-image.sarif

Grype as an alternative scanner:

1
2
3
4
5
6
7
- name: Scan with Grype
  uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e
  with:
    image: 'app:${{ github.sha }}'
    fail-build: true
    severity-cutoff: high
    output-format: sarif

8.2 Dockerfile Linting

Catch misconfigurations before build time:

1
2
3
4
5
- name: Lint Dockerfile
  uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf
  with:
    dockerfile: Dockerfile
    failure-threshold: warning

Note: Avoid globally ignoring security-relevant rules like DL3008 (pin versions in apt-get install). Unpinned packages can introduce supply chain risk when base images rebuild with different dependency versions. If pinning is impractical for certain packages, suppress per-line with # hadolint ignore=DL3008 and document the reason.

Common Hadolint rules to enforce:

RuleDescription
DL3002Last USER should not be root
DL3003Use WORKDIR to switch directories
DL3006Always tag the version of an image explicitly
DL3018Pin versions in apk add
DL3025Use JSON notation for CMD and ENTRYPOINT
DL4006Set the SHELL option -o pipefail

8.3 Runtime Policy Enforcement

Use admission controllers to prevent insecure images from being deployed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Kyverno policy: require signed images and vulnerability scans
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signature
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/your-org/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/your-org/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev

9. Artifact Integrity and Signing

9.1 Why Artifact Signing Matters

Without signing, an attacker who gains write access to your artifact storage (container registry, package registry, blob store) can replace legitimate artifacts with backdoored versions. Signing creates a verifiable chain of custody from build to deployment.

9.2 Sigstore and Cosign

Sigstore provides keyless signing that ties artifact signatures to CI/CD identity via OIDC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write  # Required for keyless signing
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Install Cosign
        uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da

      - name: Login to GHCR
        uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75
        with:
          push: true
          tags: ghcr.io/your-org/app:${{ github.sha }}

      - name: Sign the image
        run: |
          cosign sign --yes \
            ghcr.io/your-org/app@${{ steps.build.outputs.digest }}

Verify signatures before deployment:

1
2
3
4
5
# Verify that the image was signed in the expected GitHub Actions workflow
cosign verify \
  --certificate-identity "https://github.com/your-org/your-repo/.github/workflows/build.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/your-org/app:latest

9.3 SLSA Framework Compliance

SLSA (Supply-chain Levels for Software Artifacts) defines build levels for supply chain integrity. The current SLSA v1.0 specification uses Build Levels L0 through L3:

LevelRequirements
Build L0No guarantees (unversioned scripted build)
Build L1Provenance exists, build process is documented and automated
Build L2Hosted build service, authenticated provenance generated by the service
Build L3Hardened build platform, provenance is non-falsifiable, isolated builds

Higher levels provide stronger guarantees. Most organizations should target Build L2 (hosted CI with authenticated provenance) as a practical starting point and work toward Build L3 (hardened platform, non-falsifiable provenance) for critical artifacts.

9.4 Provenance Attestation

Generate SLSA provenance to prove where and how an artifact was built:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Build and push
        id: build
        uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75
        with:
          push: true
          tags: ghcr.io/your-org/app:${{ github.sha }}

      - name: Generate SLSA provenance
        uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018
        with:
          subject-name: ghcr.io/your-org/app
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

This attaches a signed provenance statement to the image, allowing consumers to verify:

  • Which repository the artifact was built from
  • Which workflow built it
  • Which commit triggered the build
  • That the build ran on GitHub-hosted infrastructure

10. Infrastructure as Code Security

10.1 IaC Scanning Tools

Scan Terraform, CloudFormation, Kubernetes manifests, and Helm charts for misconfigurations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
jobs:
  iac-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Scan with Checkov
        uses: bridgecrewio/checkov-action@aa78bad59f6e18b06d6b0c46c911a929dad3fa6a
        with:
          directory: terraform/
          framework: terraform
          output_format: sarif
          output_file_path: checkov.sarif
          soft_fail: false

      - name: Scan Kubernetes manifests with Trivy
        uses: aquasecurity/trivy-action@18f2510ee396bbf400402c6e5f6f022029f7c1a0
        with:
          scan-type: 'config'
          scan-ref: 'k8s/'
          format: 'sarif'
          output: 'trivy-config.sarif'
          severity: 'CRITICAL,HIGH'

Common IaC misconfigurations caught by scanning:

  • S3 buckets with public access
  • Security groups with unrestricted ingress (0.0.0.0/0)
  • Unencrypted databases and storage volumes
  • IAM policies with wildcard permissions
  • Kubernetes pods running as root
  • Missing network policies

10.2 Policy-as-Code with OPA

Open Policy Agent (OPA) enables custom policy enforcement across IaC and pipeline configurations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# policy/terraform.rego
package terraform

deny[msg] {
  resource := input.resource.aws_s3_bucket[name]
  not resource.server_side_encryption_configuration
  msg := sprintf("S3 bucket '%s' must have encryption enabled", [name])
}

deny[msg] {
  resource := input.resource.aws_security_group[name]
  some i
  rule := resource.ingress[i]
  rule.cidr_blocks[_] == "0.0.0.0/0"
  rule.from_port == 0
  rule.to_port == 65535
  msg := sprintf("Security group '%s' has unrestricted ingress", [name])
}

deny[msg] {
  resource := input.resource.aws_db_instance[name]
  resource.publicly_accessible == true
  msg := sprintf("RDS instance '%s' must not be publicly accessible", [name])
}

Integrate OPA into the pipeline:

1
2
3
4
5
6
7
8
9
10
11
12
13
- name: Evaluate Terraform plan against OPA policies
  run: |
    terraform plan -out=tfplan
    terraform show -json tfplan > tfplan.json
    opa eval --data policy/ --input tfplan.json \
      --format pretty 'data.terraform.deny[msg]'
    # Fail if any policies are violated
    VIOLATIONS=$(opa eval --data policy/ --input tfplan.json \
      --format json 'data.terraform.deny' | jq '.result[0].expressions[0].value | length')
    if [ "$VIOLATIONS" -gt 0 ]; then
      echo "Policy violations found"
      exit 1
    fi

10.3 Drift Detection

Detect when live infrastructure diverges from its IaC definition:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
name: Drift Detection
on:
  schedule:
    - cron: '0 8 * * 1-5'  # Weekdays at 08:00 UTC

jobs:
  detect-drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Terraform plan (detect only)
        run: |
          set -o pipefail
          terraform init
          terraform plan -detailed-exitcode -out=drift.tfplan 2>&1 | tee plan-output.txt || EXIT_CODE=$?
          if [ "${EXIT_CODE:-0}" -eq 2 ]; then
            echo "DRIFT_DETECTED=true" >> "$GITHUB_ENV"
          elif [ "${EXIT_CODE:-0}" -eq 1 ]; then
            echo "Terraform plan failed"
            exit 1
          fi

      - name: Notify on drift
        if: env.DRIFT_DETECTED == 'true'
        run: |
          # Post to Slack, create issue, or send email
          gh issue create \
            --title "Infrastructure drift detected $(date +%F)" \
            --body "$(cat plan-output.txt)" \
            --label "drift,infrastructure"

11. Monitoring, Alerting, and Audit

11.1 Pipeline Audit Logging

Maintain a complete audit trail of pipeline activity:

GitHub: Enable the organization audit log and stream it to your SIEM:

1
2
3
4
5
6
# Query recent workflow events
gh api orgs/{org}/audit-log \
  --method GET \
  -f phrase='action:workflows' \
  -f per_page=100 \
  --jq '.[] | {timestamp: .created_at, actor: .actor, action: .action, repo: .repo}'

Key events to monitor:

  • Workflow file modifications (.github/workflows/*)
  • Secret creation, update, and deletion
  • Runner registration and deregistration
  • Environment protection rule changes
  • Branch protection rule modifications
  • New deploy key or PAT creation

11.2 Anomaly Detection

Build alerts for suspicious pipeline behavior:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Example: alert on unusual workflow patterns
name: Pipeline Anomaly Check
on:
  workflow_run:
    workflows: ["*"]
    types: [completed]

jobs:
  check-anomalies:
    runs-on: ubuntu-latest
    steps:
      - name: Check for anomalies
        run: |
          WORKFLOW="${{ github.event.workflow_run.name }}"
          ACTOR="${{ github.event.workflow_run.actor.login }}"
          BRANCH="${{ github.event.workflow_run.head_branch }}"
          CONCLUSION="${{ github.event.workflow_run.conclusion }}"

          # Alert if workflow ran on unexpected branch
          if [[ "$WORKFLOW" == "Deploy Production" && "$BRANCH" != "main" ]]; then
            echo "ALERT: Production deploy from non-main branch: $BRANCH by $ACTOR"
            # Send alert to security channel
          fi

          # Alert if workflow was manually triggered outside business hours
          HOUR=$(date -u +%H)
          if [[ "${{ github.event.workflow_run.event }}" == "workflow_dispatch" ]]; then
            if [[ "$HOUR" -lt 8 || "$HOUR" -gt 20 ]]; then
              echo "ALERT: Manual workflow trigger at unusual hour by $ACTOR"
            fi
          fi

11.3 Incident Response for Pipeline Compromise

If you suspect a pipeline has been compromised, follow this response procedure:

  1. Contain: Disable the compromised workflow or runner immediately
  2. Revoke: Rotate all secrets accessible to the affected pipeline
  3. Audit: Review workflow run logs, git history for pipeline files, and audit logs for the time window
  4. Assess: Determine which artifacts were produced by compromised runs
  5. Notify: Alert downstream consumers of potentially tainted artifacts
  6. Rebuild: Rebuild and re-sign all affected artifacts from verified clean source
  7. Harden: Address the root cause and implement additional controls
1
2
3
4
5
6
7
8
# Emergency: disable all workflows in a repository
gh api repos/{owner}/{repo}/actions/permissions -X PUT \
  -f "enabled=false"

# List all workflow runs in the suspicious time window
gh run list --repo {owner}/{repo} \
  --created "2025-05-01..2025-05-15" \
  --json databaseId,name,conclusion,actor,headBranch,createdAt

12. Reference Pipeline Configurations

12.1 GitHub Actions Security Pipeline

A complete security-integrated CI pipeline:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
name: Security CI
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  # Secret scanning
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@cb7149a9b57195b609c63e8518d2c6056677d2d0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # Static analysis
  sast:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - uses: semgrep/semgrep-action@713efdd345ed38e07810a4de6781a7f25e1e9117
        with:
          config: p/default p/owasp-top-ten
          generateSarif: true
      - uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        if: always()
        with:
          sarif_file: semgrep.sarif

  # Dependency scanning
  dependency-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - uses: aquasecurity/trivy-action@18f2510ee396bbf400402c6e5f6f022029f7c1a0
        with:
          scan-type: 'fs'
          format: 'sarif'
          output: 'trivy-fs.sarif'
          severity: 'CRITICAL,HIGH'
      - uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        if: always()
        with:
          sarif_file: trivy-fs.sarif

  # IaC scanning
  iac-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - uses: aquasecurity/trivy-action@18f2510ee396bbf400402c6e5f6f022029f7c1a0
        with:
          scan-type: 'config'
          format: 'sarif'
          output: 'trivy-config.sarif'
          severity: 'CRITICAL,HIGH'
      - uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        if: always()
        with:
          sarif_file: trivy-config.sarif

  # Container scanning
  container-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    needs: [sast]  # Build only after static checks pass
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - run: docker build -t app:${{ github.sha }} .
      - uses: aquasecurity/trivy-action@18f2510ee396bbf400402c6e5f6f022029f7c1a0
        with:
          image-ref: 'app:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-image.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'
      - uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d
        if: always()
        with:
          sarif_file: trivy-image.sarif

  # Build and sign (only on main)
  build-sign-publish:
    if: github.ref == 'refs/heads/main'
    needs: [secret-scan, sast, dependency-scan, iac-scan, container-scan]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da
      - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push
        id: build
        uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
      - name: Sign image
        run: cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
      - name: Attest provenance
        uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018
        with:
          subject-name: ghcr.io/${{ github.repository }}
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

12.2 GitLab CI Security Pipeline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
stages:
  - scan
  - build
  - test
  - sign
  - deploy

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

# Include GitLab security templates
include:
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Dependency-Scanning.gitlab-ci.yml
  - template: Security/Secret-Detection.gitlab-ci.yml
  - template: Security/Container-Scanning.gitlab-ci.yml

# Secret scanning
secret-detection:
  stage: scan
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# SAST
sast:
  stage: scan
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# Dependency scanning
dependency_scanning:
  stage: scan
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# IaC scanning
iac-scan:
  stage: scan
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy config --severity HIGH,CRITICAL --exit-code 1 .
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# Build container
build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# Container scanning (uses built image)
container_scanning:
  stage: test
  needs: [build]
  variables:
    CS_IMAGE: $DOCKER_IMAGE
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# DAST against review environment
dast:
  stage: test
  needs: [build]
  image:
    name: ghcr.io/zaproxy/zaproxy:stable
    entrypoint: [""]
  script:
    - zap-baseline.py -t $REVIEW_APP_URL -r zap-report.html -J zap-report.json
  artifacts:
    paths:
      - zap-report.html
      - zap-report.json
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  allow_failure: true

# Sign artifact
sign:
  stage: sign
  needs: [build, container_scanning]
  image: bitnami/cosign:latest
  script:
    - cosign sign --yes $DOCKER_IMAGE
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# Deploy with verification
deploy:
  stage: deploy
  needs: [sign]
  script:
    - >-
      cosign verify
      --certificate-identity "$CI_PROJECT_URL//.gitlab-ci.yml@refs/heads/$CI_DEFAULT_BRANCH"
      --certificate-oidc-issuer "$CI_SERVER_URL"
      $DOCKER_IMAGE
    - ./deploy.sh $DOCKER_IMAGE
  environment:
    name: production
    url: https://app.example.com
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      when: manual

13. Hardening Checklist

Use this checklist to audit your pipeline security posture:

Source Control

  • Branch protection enforced on main/production branches
  • Required pull request reviews (minimum 2 reviewers)
  • Signed commits required
  • Force pushes disabled
  • CODEOWNERS file covers pipeline configuration files
  • Stale reviews dismissed on new pushes

Secrets

  • No hardcoded secrets in pipeline definitions or source code
  • Secrets scoped to minimum required environment
  • OIDC/workload identity federation used instead of long-lived credentials
  • Secret scanning enabled (pre-commit and in-pipeline)
  • Secret rotation automated on a defined schedule
  • Push protection enabled for secret patterns

Runners

  • Runners are ephemeral (destroyed after each job)
  • Self-hosted runners isolated by security tier
  • No Docker socket mounted in runner containers
  • Runner containers run as non-root with dropped capabilities
  • Network egress restricted to required destinations
  • Fork PR builds do not have access to secrets

Scanning

  • SAST runs on every pull request
  • Dependency/SCA scanning blocks merges on critical findings
  • Container images scanned before registry push
  • IaC templates scanned for misconfigurations
  • DAST runs against preview/staging environments
  • Findings flow to a central security dashboard (SARIF)

Artifacts

  • Container images signed with Sigstore/cosign
  • Build provenance attestation attached to artifacts
  • Deployment verifies signatures before running artifacts
  • SBOMs generated for every release
  • Admission controllers enforce signature requirements in Kubernetes

Supply Chain

  • Third-party actions/orbs pinned to SHA digests
  • Dependency lock files committed and used in CI (npm ci, --frozen)
  • Dependabot or Renovate configured for automated dependency updates
  • Internal package namespaces claimed on public registries (dependency confusion prevention)

Monitoring

  • Audit logs streamed to SIEM
  • Alerts configured for pipeline configuration changes
  • Alerts configured for off-hours manual workflow triggers
  • Incident response plan covers pipeline compromise scenarios
  • Workflow runs in suspicious time windows are reviewed

14. References and Resources

Standards and Frameworks

Tools Referenced

Further Reading

This post is licensed under CC BY 4.0 by the author.