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
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
- 2. Pipeline Architecture Security
- 3. Secrets Management
- 4. Runner and Build Environment Hardening
- 5. Static Application Security Testing (SAST)
- 6. Dependency and Composition Analysis
- 7. Dynamic Application Security Testing (DAST)
- 8. Container Security Scanning
- 9. Artifact Integrity and Signing
- 10. Infrastructure as Code Security
- 11. Monitoring, Alerting, and Audit
- 12. Reference Pipeline Configurations
- 13. Hardening Checklist
- 14. References and Resources
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
| Vector | Description | Example |
|---|---|---|
| Poisoned pipeline execution | Attacker modifies pipeline definition in a pull request | Malicious run step exfiltrates secrets via curl |
| Dependency confusion | Internal package name claimed on public registry | Build pulls attacker’s package instead of internal one |
| Compromised third-party action/orb | Upstream action is hijacked or typosquatted | actions/checkout replaced with action/checkout |
| Secret exfiltration | Pipeline secrets extracted via environment variables | echo $AWS_SECRET_ACCESS_KEY \| base64 in build logs |
| Runner persistence | Attacker plants backdoor on non-ephemeral runner | Cron job on self-hosted runner survives between builds |
| Artifact tampering | Build output modified after compilation, before deployment | Binary 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
maincan 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 Type | Examples | Risk Level |
|---|---|---|
| Cloud credentials | AWS keys, GCP service accounts, Azure SPN | Critical |
| Registry tokens | Docker Hub, GHCR, ECR push tokens | High |
| Signing keys | GPG keys, cosign keys, code signing certificates | Critical |
| API keys | Deployment services, notification webhooks | Medium-High |
| Database credentials | Migration runners, seed scripts | High |
| Tokens | GitHub PATs, npm publish tokens | High |
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_maskin 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
| Aspect | Managed (GitHub/GitLab SaaS) | Self-Hosted |
|---|---|---|
| Isolation | Strong (ephemeral VMs) | You are responsible |
| Persistence | None between jobs | Risk of cross-job contamination |
| Network access | Public internet only | Can reach internal systems |
| Cost | Per-minute billing | Infrastructure cost |
| Compliance | Provider’s certifications | Your 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 ciwill fail ifnode_modulesdoesn’t matchpackage-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
| Tool | Languages | License | Strengths |
|---|---|---|---|
| Semgrep | 30+ languages | LGPL-2.1 (OSS) / Commercial | Custom rules, low false positives, fast |
| CodeQL | C/C++, C#, Go, Java, JS/TS, Python, Ruby, Swift | Free for public repos | Deep dataflow analysis, GitHub-native |
| Bandit | Python | Apache-2.0 | Python-specific, low overhead |
| Gosec | Go | Apache-2.0 | Go-specific, AST analysis |
| Brakeman | Ruby on Rails | MIT | Rails-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:
- New findings are reviewed within one sprint
- True positives get tickets in your issue tracker
- False positives are suppressed with a comment explaining why
- 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 inapt-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=DL3008and document the reason.
Common Hadolint rules to enforce:
| Rule | Description |
|---|---|
DL3002 | Last USER should not be root |
DL3003 | Use WORKDIR to switch directories |
DL3006 | Always tag the version of an image explicitly |
DL3018 | Pin versions in apk add |
DL3025 | Use JSON notation for CMD and ENTRYPOINT |
DL4006 | Set 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:
| Level | Requirements |
|---|---|
| Build L0 | No guarantees (unversioned scripted build) |
| Build L1 | Provenance exists, build process is documented and automated |
| Build L2 | Hosted build service, authenticated provenance generated by the service |
| Build L3 | Hardened 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:
- Contain: Disable the compromised workflow or runner immediately
- Revoke: Rotate all secrets accessible to the affected pipeline
- Audit: Review workflow run logs, git history for pipeline files, and audit logs for the time window
- Assess: Determine which artifacts were produced by compromised runs
- Notify: Alert downstream consumers of potentially tainted artifacts
- Rebuild: Rebuild and re-sign all affected artifacts from verified clean source
- 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
- SLSA (Supply-chain Levels for Software Artifacts)
- NIST SP 800-218: Secure Software Development Framework
- OWASP CI/CD Security Top 10
- CIS Software Supply Chain Security Guide
Tools Referenced
- Semgrep – SAST with custom rule support
- CodeQL – Deep semantic code analysis
- Trivy – Vulnerability scanner for containers, filesystems, IaC
- Grype – Vulnerability scanner for container images
- Syft – SBOM generation
- Cosign – Container image signing
- Gitleaks – Secret detection in git repositories
- OWASP ZAP – DAST web application scanner
- Nuclei – Template-based vulnerability scanner
- Checkov – IaC static analysis
- OPA (Open Policy Agent) – Policy-as-code engine
- Hadolint – Dockerfile linter
- Kyverno – Kubernetes policy engine
- Actions Runner Controller – Kubernetes-native GitHub Actions runners