Hardening Your Linux CI/CD Pipeline: DevSecOps Security from Runners to Production

A practical guide to hardening Linux CI/CD pipelines against supply chain attacks, runner compromises, and credential theft — covering ephemeral runners, rootless container builds, automated SAST/DAST/SCA scanning, Sigstore image signing, and OIDC-based secrets management with working code examples.

Linux CI/CD Hardening: DevSecOps 2026

Why Your CI/CD Pipeline Is the New Attack Surface

In March 2026, the TeamPCP threat group showed everyone just how devastating a CI/CD compromise can be. By exploiting a single misconfigured GitHub Actions workflow in Aqua Security's Trivy scanner, they replaced commit references behind 76 of 77 version tags — turning every pipeline that referenced those tags into an unwitting execution engine for malicious payloads. The cascade spread to Checkmarx KICS, LiteLLM, and dozens of npm packages within days, affecting an estimated 5,000 organizations and stealing over 300 GB of credentials.

And this wasn't an isolated incident.

Datadog's State of DevSecOps 2026 report confirms that CI/CD pipelines and GitHub Actions are prime targets for supply chain attacks. The Shai-Hulud worm, discovered in November 2025, turned self-hosted GitHub Actions runners into persistent backdoors that communicated entirely over trusted channels to github.com — completely invisible to traditional network defenses.

The message here is pretty clear: your CI/CD pipeline runs arbitrary code, holds production secrets, and deploys to live infrastructure. If you haven't hardened it with the same rigor you apply to production servers, you've got a blind spot that attackers are actively exploiting. So, let's walk through practical, Linux-focused hardening for every stage of your pipeline.

Securing Self-Hosted GitHub Actions Runners on Linux

Self-hosted runners give you control over hardware and network access, but they carry a fundamental risk: they don't run in ephemeral, clean VMs like GitHub-hosted runners do. A compromised workflow can persistently modify the runner environment, install backdoors, or exfiltrate cached credentials. I've personally seen environments where months-old runner state had accumulated enough cached tokens to access half a dozen production systems.

Use Ephemeral Runners with the --ephemeral Flag

The single most effective mitigation is making runners disposable. Configure your runner with the --ephemeral flag so it automatically unregisters after completing one job:

#!/bin/bash
# configure-ephemeral-runner.sh
# Creates a disposable runner that exits after one job

RUNNER_DIR="/opt/actions-runner"
GITHUB_ORG="your-org"
REPO="your-repo"

# Obtain a registration token via the API
REG_TOKEN=$(curl -s -X POST \
  -H "Authorization: token ${GITHUB_PAT}" \
  -H "Accept: application/vnd.github+json" \
  "https://api.github.com/repos/${GITHUB_ORG}/${REPO}/actions/runners/registration-token" \
  | jq -r .token)

cd "${RUNNER_DIR}"
./config.sh \
  --url "https://github.com/${GITHUB_ORG}/${REPO}" \
  --token "${REG_TOKEN}" \
  --ephemeral \
  --unattended \
  --name "ephemeral-$(hostname)-$(date +%s)" \
  --labels "linux,ephemeral,hardened"

./run.sh

Pair this with a systemd service that rebuilds the runner environment from a clean image after each job. This eliminates state leakage between workflow runs — and honestly, once you set it up, you'll wonder why you ever ran persistent runners.

Run as a Dedicated Non-Root User

Never run the runner service as root. Create a dedicated user with minimal permissions:

# Create dedicated runner user with no login shell
sudo useradd -m -s /usr/sbin/nologin github-runner
sudo mkdir -p /opt/actions-runner
sudo chown -R github-runner:github-runner /opt/actions-runner

# Install and configure the runner service
cd /opt/actions-runner
sudo -u github-runner ./config.sh --url https://github.com/your-org \
  --token "${REG_TOKEN}" --ephemeral --unattended

# Install and start the systemd service
sudo ./svc.sh install github-runner
sudo ./svc.sh start

Attackers specifically look for runners that set RUNNER_ALLOW_RUNASROOT=1. That flag exists only for containerized environments and should never appear on bare-metal or VM runners. If you see it in your config, remove it immediately.

Lock Down Network Egress with nftables

Runners need to reach github.com and your package registries — nothing else. Apply strict egress rules:

#!/usr/sbin/nft -f
# /etc/nftables.d/runner-egress.nft
# Restrict runner network access to essential endpoints only

table inet runner_firewall {
    set allowed_egress_v4 {
        type ipv4_addr
        flags interval
        # GitHub Actions service IPs (update from GitHub meta API)
        elements = {
            140.82.112.0/20,    # github.com
            185.199.108.0/22,   # GitHub assets
            13.107.42.0/24      # GitHub packages
        }
    }

    chain output {
        type filter hook output priority 0; policy drop;

        # Allow loopback
        oif "lo" accept

        # Allow established connections
        ct state established,related accept

        # Allow DNS resolution
        udp dport 53 accept
        tcp dport 53 accept

        # Allow HTTPS to GitHub and package registries only
        ip daddr @allowed_egress_v4 tcp dport 443 accept

        # Log and drop everything else
        log prefix "runner-egress-blocked: " drop
    }
}

Run curl -s https://api.github.com/meta | jq '.actions' to get the current list of GitHub Actions IP ranges and keep your allowlist updated. These ranges do change, so don't just set it and forget it.

Enable StepSecurity Harden-Runner

Add runtime monitoring to every workflow. Harden-Runner acts as an EDR for your CI/CD runner, detecting unexpected network calls, file modifications, and process activity:

# .github/workflows/build.yml
jobs:
  build:
    runs-on: self-hosted
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@v2
        with:
          egress-policy: audit  # Start with audit, move to block
          allowed-endpoints: >
            github.com:443
            api.github.com:443
            registry.npmjs.org:443
            pypi.org:443

      - name: Checkout
        uses: actions/checkout@v4

      # ... your build steps

Start with egress-policy: audit so you can see what your pipeline actually talks to, then switch to block once you've built your allowlist. Jumping straight to block mode will almost certainly break something.

Hardening GitLab CI Runners on Linux

GitLab runners share the same fundamental risks as GitHub Actions runners, with an additional concern: by default, jobs execute as root inside containers, and enabling privileged = true in the runner configuration gives job code full access to the host kernel. That's a terrifying default if you think about it.

Enforce Rootless Execution with Podman

Replace Docker with rootless Podman for your GitLab runner executor. Podman doesn't require a daemon running as root, and its user-namespace isolation maps container root to an unprivileged host user:

# Install Podman and configure rootless mode for the runner user
sudo apt install -y podman slirp4netns fuse-overlayfs
sudo useradd -m -s /bin/bash gitlab-runner

# Configure subuid/subgid ranges for rootless containers
sudo usermod --add-subuids 100000-165535 gitlab-runner
sudo usermod --add-subgids 100000-165535 gitlab-runner

# Set Podman as the OCI runtime for the runner
sudo -u gitlab-runner podman system migrate

Then configure the runner's config.toml to use Podman with strict security settings:

# /etc/gitlab-runner/config.toml
[[runners]]
  name = "hardened-linux-runner"
  url = "https://gitlab.example.com"
  executor = "docker"
  [runners.docker]
    image = "alpine:3.21"
    privileged = false
    # Map Docker socket to Podman
    host = "unix:///run/user/1001/podman/podman.sock"
    # Security options
    cap_drop = ["ALL"]
    security_opt = ["no-new-privileges:true"]
    # Restrict which images can be used
    allowed_images = [
      "alpine:3.*",
      "ubuntu:24.04",
      "registry.example.com/*"
    ]
    # Pull policy prevents using stale local images
    pull_policy = ["always"]

Rootless Container Image Builds with Buildah

Building container images inside CI is where most teams quietly re-introduce root privileges. With Buildah and the --isolation chroot flag, you can build images without requiring SYS_ADMIN capabilities:

# .gitlab-ci.yml — rootless image build
build_image:
  image: quay.io/buildah/stable:latest
  stage: build
  variables:
    STORAGE_DRIVER: overlay
    BUILDAH_FORMAT: docker
    BUILDAH_ISOLATION: chroot
  before_script:
    - buildah login -u "${CI_REGISTRY_USER}" -p "${CI_REGISTRY_PASSWORD}" "${CI_REGISTRY}"
  script:
    - buildah bud --isolation chroot
        -t "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
        -f Dockerfile .
    - buildah push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"

Add devices = ["/dev/fuse"] to your runner's config.toml Docker settings to enable FUSE overlay storage without requiring privileged mode.

Embedding Security Scanning in Every Pipeline Stage

A hardened pipeline isn't just about infrastructure — it also has to catch vulnerabilities in the code, dependencies, and configurations it processes. The key principle here is simple: fast checks early, strict checks late. Shift left what's cheap to run and cheap to fix, then enforce harder controls close to deployment.

Stage 1: Pre-Commit — Secret Scanning

Secrets in source code are the number one gift to attackers. Full stop. Install Gitleaks as a pre-commit hook so credentials never even enter your repository:

# Install gitleaks
curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_8.24.0_linux_x64.tar.gz \
  | sudo tar -xz -C /usr/local/bin gitleaks

# Configure as a pre-commit hook
cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/bash
gitleaks git --pre-commit --staged --verbose
if [ $? -ne 0 ]; then
    echo "ERROR: Secrets detected in staged changes. Commit blocked."
    exit 1
fi
HOOK
chmod +x .git/hooks/pre-commit

# For team-wide enforcement, use pre-commit framework
# .pre-commit-config.yaml
# repos:
#   - repo: https://github.com/gitleaks/gitleaks
#     rev: v8.24.0
#     hooks:
#       - id: gitleaks

Also add Gitleaks to your CI pipeline as a safety net — a pre-commit hook only works on machines where it's installed, and you can't guarantee that across every developer workstation:

# GitHub Actions secret scanning stage
secret-scan:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0
    - name: Run Gitleaks
      uses: gitleaks/gitleaks-action@v2
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Stage 2: Build — SAST and Dependency Scanning

Static analysis catches vulnerabilities in your own code, while software composition analysis (SCA) catches them in your dependencies. Use Semgrep for SAST and Trivy for SCA:

# GitHub Actions — SAST with Semgrep
sast:
  runs-on: ubuntu-latest
  container:
    image: semgrep/semgrep:latest
  steps:
    - uses: actions/checkout@v4
    - name: Run Semgrep SAST
      run: |
        semgrep scan \
          --config "p/default" \
          --config "p/owasp-top-ten" \
          --config "p/cwe-top-25" \
          --sarif --output semgrep-results.sarif \
          --error  # Fail on findings
    - name: Upload SARIF
      if: always()
      uses: github/codeql-action/upload-sarif@v3
      with:
        sarif_file: semgrep-results.sarif
# Dependency and container scanning with Trivy
dependency-scan:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Run Trivy filesystem scan
      run: |
        trivy fs . \
          --severity HIGH,CRITICAL \
          --exit-code 1 \
          --format sarif \
          --output trivy-fs.sarif
    - name: Scan container image
      run: |
        trivy image \
          --severity HIGH,CRITICAL \
          --exit-code 1 \
          --ignore-unfixed \
          "myapp:${{ github.sha }}"

Critical lesson from TeamPCP: Pin scanning tools to full commit SHAs, not version tags. Tags can be force-pushed; commit SHAs cannot. This is the difference between "we use Trivy" and "we use Trivy and we can prove it hasn't been tampered with":

# WRONG — vulnerable to tag manipulation
- uses: aquasecurity/[email protected]

# CORRECT — pinned to immutable commit SHA
- uses: aquasecurity/trivy-action@a1d0ae0b4710f57c60c1dcfaffc6b264e3461cd2

Stage 3: Test — DAST and IaC Scanning

Dynamic testing catches runtime vulnerabilities that static analysis simply can't see. Run OWASP ZAP against a staging deployment, and scan infrastructure-as-code with Checkov:

# DAST with OWASP ZAP — baseline scan against staging
dast:
  runs-on: ubuntu-latest
  needs: [deploy-staging]
  steps:
    - name: ZAP Baseline Scan
      uses: zaproxy/[email protected]
      with:
        target: "https://staging.example.com"
        rules_file_name: ".zap/rules.tsv"
        cmd_options: "-a -j"  # AJAX spider + JSON report
# IaC scanning with Checkov
iac-scan:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Run Checkov
      run: |
        pip install checkov
        checkov -d . \
          --framework terraform,kubernetes,dockerfile \
          --output sarif \
          --soft-fail-on LOW \
          --hard-fail-on HIGH,CRITICAL

Stage 4: Deploy — Image Signing and Admission Control

Never deploy an unsigned image. Period. Use Cosign from the Sigstore project to sign images in CI and verify them at admission:

# Sign the image after build
sign-image:
  runs-on: ubuntu-latest
  needs: [build]
  steps:
    - name: Sign with Cosign (keyless)
      run: |
        cosign sign --yes \
          --rekor-url https://rekor.sigstore.dev \
          "ghcr.io/your-org/myapp@${IMAGE_DIGEST}"

# Kubernetes admission policy — reject unsigned images
# kyverno-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences: ["ghcr.io/your-org/*"]
          attestors:
            - entries:
                - keyless:
                    issuer: "https://token.actions.githubusercontent.com"
                    subject: "https://github.com/your-org/*"

Supply Chain Hardening: Lessons from TeamPCP

The March 2026 TeamPCP campaign was a wake-up call that security tools themselves can become attack vectors. Here are the concrete defenses that incident taught us.

Pin All Actions and Dependencies to Immutable References

Git tags and version ranges are mutable — that's the core problem. The only truly immutable reference in Git is the commit SHA. For every third-party GitHub Action, npm package, or container base image your pipeline consumes, pin to a specific, audited hash:

# .github/workflows/secure-build.yml
steps:
  # Pinned to verified commit SHAs
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
  - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde8c81c89c3166c2  # v4.2.0
  - uses: docker/build-push-action@14487ce63c7a62a3fd1178696e674c8d7a72f2ee  # v6.15.0

Use StepSecurity Secure Repo or pinact to automatically resolve and pin Action references to their commit SHAs. Doing this manually across a large org is tedious (and error-prone), so automation is your friend here.

Generate and Verify SBOMs

A Software Bill of Materials (SBOM) lets you quickly determine whether a compromised package exists anywhere in your supply chain. Generate one for every build and attach it to your container image:

# Generate SBOM with Syft and attach to image with Cosign
syft "ghcr.io/your-org/myapp:${TAG}" -o spdx-json > sbom.spdx.json
cosign attest --yes \
  --predicate sbom.spdx.json \
  --type spdxjson \
  "ghcr.io/your-org/myapp@${IMAGE_DIGEST}"

When the next supply chain incident hits (and it will), having SBOMs attached to every image means you can search your entire fleet in minutes instead of days.

Restrict GITHUB_TOKEN Permissions

The TeamPCP attack exploited workflows that granted excessive write permissions to forked pull requests. Always set the minimum required permissions at the workflow level:

# .github/workflows/build.yml
permissions:
  contents: read       # Only read access to repository
  packages: write      # Write only if pushing to GHCR
  id-token: write      # Only if using keyless signing
  pull-requests: read  # Read-only for PR metadata

# NEVER use:
# permissions: write-all

Seriously, write-all is one of those defaults that looks convenient until it hands an attacker the keys to your repository. Don't do it.

Secrets Management in the Pipeline

Hardcoded secrets are the easiest win for attackers, and CI/CD environments are where leaked credentials do the most damage — they often have direct access to production infrastructure. Getting this right is probably the highest-impact thing you can do.

Use Short-Lived Credentials with OIDC

Instead of storing long-lived cloud credentials as CI/CD secrets, use OpenID Connect (OIDC) to obtain short-lived tokens directly from your cloud provider. This eliminates stored secrets entirely:

# GitHub Actions OIDC with AWS — no stored credentials
deploy:
  runs-on: ubuntu-latest
  permissions:
    id-token: write
    contents: read
  steps:
    - name: Configure AWS Credentials via OIDC
      uses: aws-actions/configure-aws-credentials@eba1c4c55a1d498b975d42da498e81bb76e04750
      with:
        role-to-assume: arn:aws:iam::123456789012:role/github-deploy
        aws-region: us-east-1
        # No access key or secret key needed
# AWS IAM trust policy for the OIDC role
{
  "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"
      }
    }
  }]
}

The Condition block is critical here — it restricts which repository and branch can assume the role, preventing forked repositories or feature branches from accessing production credentials. Skip this, and you've essentially given every fork write access to your AWS account.

Vault Integration for Dynamic Secrets

For secrets that can't use OIDC (database passwords, API keys for third-party services), use HashiCorp Vault with dynamic secret generation so credentials are created on-demand and automatically expire:

# GitLab CI with Vault dynamic database credentials
deploy:
  stage: deploy
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  secrets:
    DB_PASSWORD:
      vault: database/creds/deploy-role/password@secrets
      token: $VAULT_ID_TOKEN
  script:
    - echo "Credentials auto-expire after TTL"
    - ./deploy.sh --db-password "${DB_PASSWORD}"

Dynamic secrets mean that even if a credential leaks, it's probably already expired by the time an attacker tries to use it. That's a much better position than discovering a two-year-old API key sitting in your CI variables.

Pipeline Audit Logging and Monitoring

You can't defend what you can't see. Every pipeline execution should generate audit records that feed into your security monitoring stack.

Capture Pipeline Events with Auditd

On self-hosted runners, configure auditd to monitor critical paths where pipeline activity occurs:

# /etc/audit/rules.d/cicd-runner.rules
# Monitor runner working directory for unexpected modifications
-w /opt/actions-runner/_work -p wa -k cicd_workspace

# Monitor credential files
-w /opt/actions-runner/.credentials -p ra -k cicd_creds
-w /opt/actions-runner/.runner -p wa -k cicd_config

# Monitor for privilege escalation attempts
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid!=0 -k cicd_priv_esc

# Monitor network tool usage (potential exfiltration)
-a always,exit -F arch=b64 -S execve -F exe=/usr/bin/curl -k cicd_network
-a always,exit -F arch=b64 -S execve -F exe=/usr/bin/wget -k cicd_network

# Monitor container runtime invocations
-a always,exit -F arch=b64 -S execve -F exe=/usr/bin/docker -k cicd_container
-a always,exit -F arch=b64 -S execve -F exe=/usr/bin/podman -k cicd_container

Centralize Logs and Set Alerts

Forward runner audit logs to your SIEM. Here's an example using Fluent Bit to ship auditd logs to Elasticsearch or a compatible backend:

# /etc/fluent-bit/fluent-bit.conf
[INPUT]
    Name   tail
    Path   /var/log/audit/audit.log
    Tag    auditd.cicd
    Parser auditd

[FILTER]
    Name   grep
    Match  auditd.cicd
    Regex  key cicd_*

[OUTPUT]
    Name  es
    Match auditd.cicd
    Host  siem.example.com
    Port  9200
    Index cicd-audit
    tls   On

Set alerts for: unexpected network connections from the runner, privilege escalation (any process running as root when the runner user isn't root), modifications to runner configuration files, and job executions outside of business hours or from unrecognized repositories. These are all strong signals that something has gone wrong.

Putting It All Together: A Complete Hardened Pipeline

Here's a reference architecture that ties all the layers together into a coherent defense-in-depth strategy. It's not minimal — it's thorough on purpose:

# .github/workflows/hardened-pipeline.yml
name: Hardened CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Minimum permissions at workflow level
permissions:
  contents: read
  packages: write
  id-token: write
  security-events: write

jobs:
  # Stage 1: Secret scanning
  secrets:
    runs-on: ubuntu-latest
    steps:
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: audit
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # Stage 2: SAST + SCA
  analyze:
    runs-on: ubuntu-latest
    needs: [secrets]
    steps:
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: audit
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - name: SAST — Semgrep
        uses: semgrep/semgrep-action@v1
        with:
          config: "p/default p/owasp-top-ten"
      - name: SCA — Trivy filesystem
        run: |
          trivy fs . --severity HIGH,CRITICAL --exit-code 1

  # Stage 3: Build + Sign
  build:
    runs-on: ubuntu-latest
    needs: [analyze]
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: audit
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - name: Build and push image
        id: build
        run: |
          docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
          docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
      - name: Generate SBOM
        run: syft ghcr.io/${{ github.repository }}:${{ github.sha }} -o spdx-json > sbom.json
      - name: Sign image with Cosign
        run: |
          cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
          cosign attest --yes --predicate sbom.json --type spdxjson \
            ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}

  # Stage 4: Deploy with verification
  deploy:
    runs-on: ubuntu-latest
    needs: [build]
    environment: production
    steps:
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: block
          allowed-endpoints: >
            ghcr.io:443
            sts.amazonaws.com:443
      - name: Verify image signature
        run: |
          cosign verify \
            --certificate-oidc-issuer https://token.actions.githubusercontent.com \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/*" \
            ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}
      - name: Deploy to production
        run: |
          kubectl set image deployment/myapp \
            app=ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}

Hardening Checklist

Use this checklist to audit your current pipeline security posture. If you can check off even half of these, you're already ahead of most organizations:

  • Runners: Self-hosted runners use --ephemeral mode and run as a non-root dedicated user
  • Network: Runner egress is restricted to required endpoints only via nftables or security groups
  • Privileges: GitLab runners set privileged = false; container builds use rootless Podman or Buildah
  • Secrets: No long-lived credentials stored in CI/CD variables; OIDC used for cloud access; Gitleaks runs on every commit
  • Supply chain: All Actions and dependencies pinned to commit SHAs; SBOMs generated for every build; images signed with Cosign
  • Scanning: SAST (Semgrep), SCA (Trivy), and DAST (ZAP) run on every pull request; IaC scanned with Checkov
  • Permissions: GITHUB_TOKEN scoped to minimum required permissions; no write-all
  • Monitoring: Auditd rules on runner hosts; logs forwarded to SIEM; alerts for anomalous activity
  • Admission: Kubernetes admission policies reject unsigned or unverified images

Frequently Asked Questions

What is the difference between DevSecOps and just adding security scans to a CI/CD pipeline?

Adding a scanner is one tool in the toolbox. DevSecOps is an operational discipline that embeds security ownership into every stage of development and delivery — from threat modeling in design, to pre-commit hooks, to automated scanning, to runtime monitoring and incident response. A scanner finds vulnerabilities; DevSecOps ensures they get triaged, fixed within SLAs, and prevented from recurring. The cultural shift matters as much as the tooling, maybe even more.

Should I use GitHub-hosted runners or self-hosted runners for security-sensitive workloads?

GitHub-hosted runners are ephemeral and clean by default, making them inherently safer for most workloads. Choose self-hosted runners only when you need access to internal networks, specialized hardware, or must meet data residency requirements. If you do go the self-hosted route, apply all the hardening measures in this guide — ephemeral mode, non-root execution, network isolation, and runtime monitoring are non-negotiable.

How do I protect my pipeline from supply chain attacks like TeamPCP?

Three defenses are essential. First, pin every third-party GitHub Action to its full commit SHA — tags can be force-pushed silently. Second, set minimum GITHUB_TOKEN permissions at the workflow level and never grant write access to pull request workflows from forks. Third, monitor for unexpected network egress from your runners using tools like StepSecurity Harden-Runner. If a scanning tool suddenly starts making API calls to unknown endpoints, you want to know immediately.

How often should I update my security scanning tools and rules?

Vulnerability databases and scanning rules update frequently — Semgrep publishes new rules weekly, and Trivy's vulnerability database updates multiple times per day. Pin your scanning tools to a specific version (by commit SHA), test updates in a non-production pipeline branch first, and promote to production on a weekly cadence. Never auto-update scanning tools in production pipelines without review — as TeamPCP showed, even security tools can be compromised.

What is the minimum set of security scans every CI/CD pipeline should have?

At minimum, every pipeline should run: secret scanning (Gitleaks) to prevent credential leaks, dependency scanning (Trivy or Grype) to catch known CVEs in third-party libraries, and container image scanning if you build Docker images. Add SAST (Semgrep) as the next priority, followed by IaC scanning (Checkov) if you manage infrastructure as code. DAST (ZAP) is valuable but requires a running environment, so it typically runs only on staging deployments. Start with what you can actually act on — an ignored finding is worse than no scan at all.

About the Author Editorial Team

Our team of expert writers and editors.