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.

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
--ephemeralmode 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_TOKENscoped to minimum required permissions; nowrite-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.

