CI/CD Part 3 of 3 · DevSecOps Pipeline
Building a Secure CI/CD Pipeline with Jenkins
A declarative Jenkins pipeline with secrets scanning, SAST, image scanning and least-privilege credential handling — and the agent, plugin and Docker decisions that keep it that way.
On this page
Overview
Jenkins is still one of the most common CI servers in enterprises, and also one of the most common places to find a
plaintext credential, an over-privileged agent or a build container talking directly to the host Docker daemon. This
article builds a pipeline that treats security checks as first-class stages and closes the platform-level gaps that
a Jenkinsfile alone cannot.
The pipeline has four gates before an image can be pushed:
- Secrets scan with Gitleaks: stop credentials from ever reaching the registry or a log.
- SAST with Semgrep: catch dangerous code patterns in the change, not in production.
- Image scan with Trivy: block images with fixable HIGH/CRITICAL vulnerabilities.
- Controlled push: registry credentials are injected only for the push step and only on
main.
The three scanner invocations are the same ones used in the GitLab CI version of this
pipeline and in the tool articles, where they were run and their output
recorded. The Jenkinsfile itself was validated with the declarative pipeline linter of Jenkins 2.568.3 LTS
(POST /pipeline-model-converter/validate, with the Pipeline and Credentials Binding plugins installed); it was not
executed end to end on a controller with agents for this article, so treat the platform sections as a configuration
guide to verify on your own instance.
Architecture
- Jenkins controller. Receives the webhook, evaluates the
Jenkinsfile, schedules the build. It has zero executors, so no build step ever runs on it, and it holds the credentials store. - Ephemeral agent. A pod created for this build and deleted afterwards. It has the scanners installed, no Docker socket mounted, and only the credentials the current stage was given.
- Secrets scan. Gitleaks over the checkout. A finding exits 1 and stops the build before anything is built.
- SAST. Semgrep with
--error, so findings are a failed stage, not a warning in a log. - Image scan. Trivy against the freshly built image; only fixable HIGH and CRITICAL findings fail the gate.
- Pipeline stops. Any failed gate ends the build. The SARIF reports are archived so the reason is inspectable without re-running.
- Push. Only on
main, and only inside awithCredentialsblock, does the agent receive registry credentials, log in, push, and log out.
Secrets exist in two places: the controller’s credentials store (encrypted at rest, scoped to a folder) and, for the duration of the push stage, the agent’s environment. They never appear in the console log (masked), in the image (no build args), or in the checkout.
Prerequisites
- Jenkins 2.4xx LTS with the
Pipeline,Credentials BindingandGitplugins - An agent image or label that provides
docker(or BuildKit),gitleaks,semgrepandtrivy - A container registry and a username/password or token for it
- Repository webhook pointing at Jenkins (multibranch pipeline)
Implementation
Use a declarative pipeline stored in the repository. Declarative syntax is easier to review, works with the Script Security sandbox and keeps logic out of the controller.
pipeline {
agent { label 'linux-ephemeral' }
options {
timeout(time: 30, unit: 'MINUTES')
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '30'))
}
environment {
REGISTRY = 'registry.example.com'
IMAGE = "${REGISTRY}/platform/app"
TAG = "${env.GIT_COMMIT.take(12)}"
// Never put secrets here — environment {} values appear in the build environment.
}
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Secrets scan') {
steps {
sh '''
gitleaks git --redact --exit-code 1 \
--report-format sarif --report-path gitleaks.sarif .
'''
}
post { always { archiveArtifacts artifacts: 'gitleaks.sarif', allowEmptyArchive: true } }
}
stage('SAST') {
steps {
sh 'semgrep scan --config p/default --error --sarif --output semgrep.sarif'
}
post { always { archiveArtifacts artifacts: 'semgrep.sarif', allowEmptyArchive: true } }
}
stage('Build image') {
steps {
sh 'docker build --pull --no-cache=false -t "$IMAGE:$TAG" .'
}
}
stage('Image scan') {
steps {
sh '''
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--format table "$IMAGE:$TAG"
'''
}
}
stage('Push') {
when { branch 'main' }
steps {
withCredentials([usernamePassword(
credentialsId: 'registry-platform-push',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS'
)]) {
sh '''
echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin
docker push "$IMAGE:$TAG"
docker logout "$REGISTRY"
'''
}
}
}
}
post {
always { cleanWs(deleteDirs: true) }
}
}
A few details are deliberate:
- The
optionsblock uses only what the Pipeline plugin provides.timestamps()is a common addition, but it needs the Timestamper plugin; the linter rejects it on a minimal controller, which is a useful reminder that aJenkinsfileis only portable across controllers with the same plugin set. --redactin Gitleaks keeps the secret value out of the console log; the SARIF report still identifies the file and line.--errorin Semgrep turns findings into a non-zero exit code. Without it the stage always passes.--ignore-unfixedin Trivy makes the gate actionable: it fails only on vulnerabilities that have a patched version.--password-stdinavoids the password appearing inpsoutput or shell history on the agent.
Configuration
Credentials
Store registry, SCM and cloud credentials in the Jenkins credentials store, scoped to the folder that owns the pipeline. Reference them only through
withCredentials, which masks the values in the console output. Do not echo them, do not write them to files that get archived, and do not pass
them as docker build --build-arg values — build args are visible in image history.
Agents
Run builds on ephemeral agents. With the Kubernetes plugin each build gets a fresh pod that is deleted afterwards, so a compromised build cannot persist on the executor. The controller should have zero executors so nothing ever runs on it.
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile: { type: RuntimeDefault }
containers:
- name: tools
image: registry.example.com/platform/ci-tools:1.8.0 # pinned, scanned
command: ['sleep', 'infinity']
resources:
requests: { cpu: '500m', memory: '1Gi' }
limits: { cpu: '2', memory: '4Gi' }
Building images without the Docker socket
Mounting /var/run/docker.sock into the agent gives the build root on the node: anything that can run docker on
that socket can start a privileged container, mount the host filesystem and read every other build’s secrets. Prefer
a daemonless builder: BuildKit in rootless mode (buildctl against a buildkitd --rootless sidecar, or
docker buildx with a remote builder reached over mTLS) or Buildah. Kaniko was the usual answer for years; its
upstream repository was archived in 2025, so if you still use it, pin a maintained fork and plan the migration. If
you must use Docker-in-Docker, isolate it on dedicated nodes with a network policy and treat those nodes as
untrusted.
Verification
Trigger a build on a branch and confirm the gates behave:
# Confirm the secrets gate actually fails: add a token-shaped random value on a throwaway branch.
# (Amazon's documented example keys are allow-listed by Gitleaks and would pass.)
printf 'GITLAB_TOKEN=glpat-%s\n' "$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20)" > .env.test
git add .env.test && git commit -m "test: gitleaks gate" && git push -u origin test/gitleaks-gate
The job should stop at Secrets scan with exit code 1 and a redacted finding. Delete the branch afterwards. Repeat with a known-vulnerable base
image (for example an old debian:10 tag) to see the Trivy gate fail, then check that main is the only branch that reaches Push.
Security Considerations
- Script Security — keep the Groovy sandbox enabled. Approving arbitrary method signatures gives pipeline authors controller-level access.
- Least-privilege credentials — the push token should only be able to push to one repository. Use separate credentials for pull.
- Webhook authentication — require a shared secret or signature on incoming webhooks so anyone on the internet cannot trigger builds.
- Audit — install the Audit Trail plugin or ship the controller logs to your SIEM. Configuration changes to jobs and credentials are events.
- Supply chain — pin tool versions in the agent image and rebuild it on a schedule with the same scanning gates.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
gitleaks finds secrets in old commits | Full history is scanned on every run | Use --log-opts="HEAD~20..HEAD" on branches, full scan nightly; rotate real findings |
| Trivy stage is slow or rate-limited | Vulnerability DB downloaded on every build | Cache ~/.cache/trivy on the agent or mirror the DB with trivy image --download-db-only |
docker login fails with masked output | Wrong credential type (secret text vs username/password) | Match the withCredentials binding to the credential kind |
| Push stage runs on feature branches | when { branch } not matching multibranch naming | Verify env.BRANCH_NAME in a debug step; use when { branch pattern: 'main', comparator: 'EQUALS' } |
Production Recommendations
- Version and review the
Jenkinsfilelike application code; require approval from a platform owner for changes to security stages. - Run the full Gitleaks history scan and a Trivy re-scan of the last released image on a nightly schedule — new CVEs appear after the build.
- Publish SARIF reports to your code hosting platform so findings show up in merge requests.
- Add image signing (Cosign) after the push stage and verify signatures at admission in the cluster.
What happens on failure
Each gate fails the stage, and the post { always } block still runs cleanWs, so a failed build leaves nothing on
the agent. Registry credentials were never injected, so nothing can have been pushed. The archived SARIF reports are
attached to the build, which means a reviewer can see the finding without the console log and without re-running the
scanners. A failed build on main should page someone: the next merge does not un-break it.
Conclusion
The pipeline itself is short. What makes it secure is where it runs, what the agent can reach and how credentials are scoped. Start with the four gates, make them fail loudly on a test branch, then harden the platform around them.
References
Keep reading