Sachin Chaurasiya

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.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
9 min read
Difficulty
intermediate

Reviewed Tested with Jenkins 2.568.3 LTS (declarative linter), Gitleaks 8.30, Trivy 0.68

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:

  1. Secrets scan with Gitleaks: stop credentials from ever reaching the registry or a log.
  2. SAST with Semgrep: catch dangerous code patterns in the change, not in production.
  3. Image scan with Trivy: block images with fixable HIGH/CRITICAL vulnerabilities.
  4. 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

Diagram · Secure Jenkins pipeline flow
Secure Jenkins pipeline flowA developer pushes to Git. A webhook triggers the Jenkins controller, which schedules an ephemeral agent. On the agent, four stages run in order: a Gitleaks secrets scan, Semgrep SAST, an image build and a Trivy image scan. Any gate failing stops the pipeline and archives the report. Only on main does the push stage receive registry credentials and push the image to the registry.Runs on the ephemeral agentpushwebhookschedulecheckoutexit 1DeveloperGit repositoryGitLab / GitHubJenkinscontroller0 executors1Ephemeral agentpod · no docker.sock2Secrets scangitleaks3SASTsemgrep4Build imagebuildkit rootlessImage scantrivy5Pipeline stopsSARIF archived6Push imagemain only7Containerregistry

A developer pushes to Git. A webhook triggers the Jenkins controller, which schedules an ephemeral agent. On the agent, four stages run in order: a Gitleaks secrets scan, Semgrep SAST, an image build and a Trivy image scan. Any gate failing stops the pipeline and archives the report. Only on main does the push stage receive registry credentials and push the image to the registry.

  1. 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.
  2. 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.
  3. Secrets scan. Gitleaks over the checkout. A finding exits 1 and stops the build before anything is built.
  4. SAST. Semgrep with --error, so findings are a failed stage, not a warning in a log.
  5. Image scan. Trivy against the freshly built image; only fixable HIGH and CRITICAL findings fail the gate.
  6. Pipeline stops. Any failed gate ends the build. The SARIF reports are archived so the reason is inspectable without re-running.
  7. Push. Only on main, and only inside a withCredentials block, 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 Binding and Git plugins
  • An agent image or label that provides docker (or BuildKit), gitleaks, semgrep and trivy
  • 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 options block 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 a Jenkinsfile is only portable across controllers with the same plugin set.
  • --redact in Gitleaks keeps the secret value out of the console log; the SARIF report still identifies the file and line.
  • --error in Semgrep turns findings into a non-zero exit code. Without it the stage always passes.
  • --ignore-unfixed in Trivy makes the gate actionable: it fails only on vulnerabilities that have a patched version.
  • --password-stdin avoids the password appearing in ps output 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

SymptomLikely causeFix
gitleaks finds secrets in old commitsFull history is scanned on every runUse --log-opts="HEAD~20..HEAD" on branches, full scan nightly; rotate real findings
Trivy stage is slow or rate-limitedVulnerability DB downloaded on every buildCache ~/.cache/trivy on the agent or mirror the DB with trivy image --download-db-only
docker login fails with masked outputWrong credential type (secret text vs username/password)Match the withCredentials binding to the credential kind
Push stage runs on feature brancheswhen { branch } not matching multibranch namingVerify env.BRANCH_NAME in a debug step; use when { branch pattern: 'main', comparator: 'EQUALS' }

Production Recommendations

  • Version and review the Jenkinsfile like 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