Pipeline Examples

Reference pipelines showing where each security control belongs. Calibrate thresholds against your own backlog before enabling blocking behaviour.

GitLab CI

GitLab DevSecOps Pipeline

End-to-end pipeline covering code scanning, image scanning, SBOM generation, DAST and a manual production gate.

  1. Source
  2. SAST
  3. SCA
  4. Secret Scan
  5. Build
  6. Container Scan
  7. SBOM
  8. DAST
  9. Security Gate
  10. Deploy
GitLabDockerTrivySemgrep
yaml.gitlab-ci.yml
stages: [scan, build, verify, dast, deploy] variables:  IMAGE: $CI_REGISTRY_IMAGE/app .scan_defaults: &scan_defaults  stage: scan  allow_failure: false  interruptible: true sast:  <<: *scan_defaults  image: returntocorp/semgrep  script:    - semgrep --config auto --sarif --output semgrep.sarif .  artifacts:    reports: { sast: semgrep.sarif } secret_detection:  <<: *scan_defaults  image: zricethezav/gitleaks:latest  script:    - gitleaks detect --source . --redact --report-path gitleaks.json dependency_scan:  <<: *scan_defaults  image: aquasec/trivy:latest  script:    - trivy fs --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL . build_image:  stage: build  image: quay.io/buildah/stable  script:    - buildah bud -t "$IMAGE:$CI_COMMIT_SHA" .    - buildah push "$IMAGE:$CI_COMMIT_SHA" container_scan:  stage: verify  image: aquasec/trivy:latest  script:    - trivy image --exit-code 1 --ignore-unfixed --severity CRITICAL "$IMAGE:$CI_COMMIT_SHA" sbom:  stage: verify  image: anchore/syft:latest  script:    - syft "$IMAGE:$CI_COMMIT_SHA" -o cyclonedx-json=sbom.cdx.json  artifacts:    paths: [sbom.cdx.json]    expire_in: 400 days dast_baseline:  stage: dast  image: ghcr.io/zaproxy/zaproxy:stable  script:    - zap-baseline.py -t "$STAGING_URL" -r zap.html -I  artifacts:    paths: [zap.html] deploy_production:  stage: deploy  environment: { name: production }  when: manual  rules:    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH  script:    - ./deploy.sh "$IMAGE:$CI_COMMIT_SHA"
Cheap code scans run first so failures are fast. Gates use --ignore-unfixed so the pipeline blocks only on findings a team can act on. Calibrate thresholds against your existing backlog before enabling blocking behaviour.
  • Use protected variables for registry and deployment credentials.
  • Run scheduled full DAST scans separately from merge-request pipelines.
GitHub Actions

GitHub Actions Secure Build

Least-privilege workflow with digest-pinned actions, OIDC cloud access, image scanning and build provenance.

  1. Checkout
  2. SAST
  3. SCA
  4. Build
  5. Scan
  6. Attest
  7. Push
GitHubDockerSLSATrivy
yaml.github/workflows/release.yml
name: releaseon:  push:    branches: [main] permissions:  contents: read jobs:  secure-build:    runs-on: ubuntu-latest    permissions:      contents: read      id-token: write      packages: write      attestations: write    steps:      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1       - name: Static analysis        uses: returntocorp/semgrep-action@v1        with: { config: auto }       - name: Dependency scan        run: |          curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \            | sh -s -- -b /usr/local/bin          trivy fs --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL .       - name: Build image        id: build        run: |          IMAGE=ghcr.io/${{ github.repository }}          docker build -t "$IMAGE:${{ github.sha }}" .          echo "image=$IMAGE:${{ github.sha }}" >> "$GITHUB_OUTPUT"       - name: Scan image        run: trivy image --exit-code 1 --ignore-unfixed --severity CRITICAL "${{ steps.build.outputs.image }}"       - name: Push and capture digest        id: push        run: |          docker push "${{ steps.build.outputs.image }}"          echo "digest=$(docker inspect --format='{{index .RepoDigests 0}}' \            "${{ steps.build.outputs.image }}" | cut -d@ -f2)" >> "$GITHUB_OUTPUT"       - name: Generate build provenance        uses: actions/attest-build-provenance@v1        with:          subject-name: ghcr.io/${{ github.repository }}          subject-digest: ${{ steps.push.outputs.digest }}
Workflow-level permissions default to read-only and are widened per job. Provenance is generated by the platform rather than inside the build script, which is what makes it meaningful.
  • Pin third-party actions by commit SHA, not by tag.
  • Never expose secrets to workflows triggered by forked pull requests.
Jenkins

Jenkins Declarative Security Pipeline

Declarative Jenkinsfile with parallel security stages, credential binding and an approval step before deployment.

  1. Checkout
  2. Parallel scans
  3. Build
  4. Image scan
  5. Approval
  6. Deploy
JenkinsDockerTrivySonarQube
groovyJenkinsfile
pipeline {  agent { kubernetes { yamlFile 'ci/agent.yaml' } }  options { timeout(time: 45, unit: 'MINUTES') }   environment {    IMAGE = "registry.example.com/app"    TAG   = "${env.GIT_COMMIT.take(12)}"  }   stages {    stage('Security scans') {      parallel {        stage('SAST') {          steps { sh 'semgrep --config auto --error .' }        }        stage('Secrets') {          steps { sh 'gitleaks detect --source . --redact' }        }        stage('Dependencies') {          steps { sh 'trivy fs --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL .' }        }      }    }     stage('Build') {      steps { sh 'buildah bud -t "$IMAGE:$TAG" .' }    }     stage('Image scan') {      steps { sh 'trivy image --exit-code 1 --ignore-unfixed --severity CRITICAL "$IMAGE:$TAG"' }    }     stage('Approval') {      when { branch 'main' }      steps {        timeout(time: 8, unit: 'HOURS') {          input message: "Deploy ${IMAGE}:${TAG} to production?", submitter: 'release-managers'        }      }    }     stage('Deploy') {      when { branch 'main' }      steps {        withCredentials([string(credentialsId: 'kube-token', variable: 'KUBE_TOKEN')]) {          sh './deploy.sh "$IMAGE:$TAG"'        }      }    }  }   post {    always { archiveArtifacts artifacts: '**/*.sarif', allowEmptyArchive: true }  }}
Parallel scan stages keep feedback fast. Credentials are bound only inside the deploy step, and the approval step records the submitter for audit.
  • Use ephemeral Kubernetes agents so build state is not shared between jobs.
  • Restrict the approval submitter list to a named group.
GitLab CI + Ansible Automation Platform

Ansible Automation Pipeline

Validation, linting, security scanning, testing and approval before content reaches the automation controller.

  1. Git
  2. Validation
  3. Security Scan
  4. Ansible Lint
  5. Test
  6. Approval
  7. Automation Controller
  8. Deployment
AnsibleAAPGitLabRed Hat
yaml.gitlab-ci.yml (Ansible content)
stages: [validate, scan, test, deploy] syntax:  stage: validate  script:    - ansible-playbook --syntax-check playbooks/site.yml lint:  stage: validate  script:    - ansible-lint --profile production playbooks/ secrets:  stage: scan  script:    - gitleaks detect --source . --redact iac_scan:  stage: scan  script:    - kics scan -p . -t Ansible --report-formats json molecule:  stage: test  script:    - molecule test  rules:    - if: $CI_PIPELINE_SOURCE == "merge_request_event" launch_job_template:  stage: deploy  environment: { name: production }  when: manual  rules:    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH  script:    - >      curl -sS -X POST      -H "Authorization: Bearer $AAP_TOKEN"      -H "Content-Type: application/json"      -d '{"extra_vars":{"git_ref":"'"$CI_COMMIT_SHA"'"}}'      "$AAP_URL/api/v2/job_templates/$TEMPLATE_ID/launch/"
The pipeline never holds machine credentials: it launches a job template and the controller injects credentials at runtime. Controller RBAC decides which inventory the template may target.
  • Store the AAP token as a protected, masked CI variable scoped to the default branch.
  • Keep execution environment images scanned and version-pinned.
Argo CD

GitOps Deployment with Verification

Manifests reconciled from git with admission-time signature verification and policy enforcement.

  1. Manifest change
  2. Review
  3. Policy test
  4. Merge
  5. Reconcile
  6. Admission verify
  7. Running
KubernetesArgo CDKyvernoSigstore Cosign
yamlCI policy test + Argo CD application
# CI: test manifests against policy before mergepolicy_test:  stage: validate  image: openpolicyagent/conftest  script:    - conftest test --policy policies/ k8s/overlays/production ---apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: payments  namespace: argocdspec:  project: production  source:    repoURL: https://git.example.com/acme/deploy.git    targetRevision: main    path: k8s/overlays/production  destination:    server: https://kubernetes.default.svc    namespace: payments  syncPolicy:    automated: { prune: true, selfHeal: true }
selfHeal reverts manual cluster edits back to the declared state. Admission policies still apply, so an unverified image is rejected even if it is committed to git.
  • Give the reconciler namespace-scoped permissions where possible.
  • Document a break-glass procedure.
GitLab CI

Terraform Plan and Apply Pipeline

Separated plan and apply jobs with policy scanning of the plan and different credentials per phase.

  1. Validate
  2. Plan
  3. Policy scan
  4. Approval
  5. Apply
TerraformAWSCheckovGitLab
yaml.gitlab-ci.yml (Terraform)
stages: [validate, plan, policy, apply] image: hashicorp/terraform:1.9 validate:  stage: validate  script:    - terraform init -backend=false    - terraform validate    - terraform fmt -check -recursive plan:  stage: plan  script:    - terraform init    - terraform plan -out tfplan.binary    - terraform show -json tfplan.binary > tfplan.json  artifacts:    paths: [tfplan.binary, tfplan.json]    expire_in: 1 day policy:  stage: policy  image: bridgecrew/checkov  script:    - checkov -f tfplan.json --compact --soft-fail-on LOW apply:  stage: apply  environment: { name: production }  when: manual  rules:    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH  script:    - terraform init    - terraform apply -auto-approve tfplan.binary
The plan job uses read-only credentials; apply uses a separate, approval-gated credential. Plan artefacts expire quickly because they can contain sensitive values.
  • Never publish tfplan.json to a public artefact store.
  • Use OIDC federation instead of static cloud keys.
Harbor + GitLab CI

Container Promotion Pipeline

Images are built into a staging repository and promoted to production only after scanning and signing.

  1. Build
  2. Push staging
  3. Scan
  4. Sign
  5. Promote
  6. Deploy by digest
DockerHarborSigstore CosignTrivy
yamlPromotion job
promote:  stage: promote  image: alpine:3.20  script:    - apk add --no-cache skopeo cosign    - |      DIGEST=$(skopeo inspect --format '{{.Digest}}' \        "docker://$STAGING_REPO/app:$CI_COMMIT_SHA")    - cosign verify --certificate-identity-regexp "^https://git.example.com/acme/" \        --certificate-oidc-issuer "$OIDC_ISSUER" "$STAGING_REPO/app@$DIGEST"    - skopeo copy "docker://$STAGING_REPO/app@$DIGEST" \        "docker://$PROD_REPO/app@$DIGEST"  rules:    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Promotion copies by digest so the promoted artefact is bit-identical to the scanned one. Verification happens before the copy, not after.
  • Enable tag immutability on the production repository.
  • Keep continuous rescanning enabled in the registry.
GitHub Actions

SBOM and Release Attestation Pipeline

Generates an SBOM per release, signs it as an attestation and publishes it with the release artefacts.

  1. Build
  2. SBOM
  3. Sign
  4. Attest
  5. Publish
GitHubCycloneDXSPDXDocker
yamlSBOM job
sbom:  runs-on: ubuntu-latest  permissions: { contents: write, id-token: write, packages: read }  steps:    - uses: anchore/sbom-action@v0      with:        image: ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}        format: cyclonedx-json        output-file: sbom.cdx.json     - run: |        cosign attest --yes --type cyclonedx \          --predicate sbom.cdx.json \          "ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}"     - uses: softprops/action-gh-release@v2      with:        files: sbom.cdx.json
Generating the SBOM from the pushed digest guarantees it describes the artefact that will actually be deployed.
  • Retain SBOMs at least as long as the release is supported.
GitLab CI (scheduled)

Scheduled Security Scan Pipeline

Nightly deep scans that are too slow for merge requests: full DAST, cloud posture assessment and artefact rescanning.

  1. Schedule
  2. Full DAST
  3. Cloud posture
  4. Artifact rescan
  5. Report
GitLabAWSKubernetesTrivy
yamlScheduled pipeline
workflow:  rules:    - if: $CI_PIPELINE_SOURCE == "schedule" stages: [scan, report] full_dast:  stage: scan  image: ghcr.io/zaproxy/zaproxy:stable  script:    - zap-full-scan.py -t "$STAGING_URL" -r zap-full.html -I  artifacts: { paths: [zap-full.html] } cloud_posture:  stage: scan  image: toniblyx/prowler  script:    - prowler aws --severity critical high --output-formats json-ocsf rescan_artifacts:  stage: scan  image: aquasec/trivy  script:    - for tag in $(cat release-tags.txt); do trivy image --severity CRITICAL "$tag" || true; done publish_report:  stage: report  script:    - ./scripts/push-findings-to-tracker.sh
Rescanning already-released artefacts is how newly published advisories reach existing deployments rather than only future builds.
  • Only run full active DAST against a dedicated, resettable environment.
GitLab CI + OPA

Pre-production Verification Gate

A single policy job that evaluates all collected scan evidence and decides whether the release may be promoted.

  1. Collect evidence
  2. Evaluate policy
  3. Record decision
  4. Promote or block
GitLabKubernetesPython
yamlEvidence-based gate
release_gate:  stage: gate  image: openpolicyagent/opa:latest  script:    - >      opa eval --format pretty      --data policy/release.rego      --input evidence/release.json      "data.release.allow"    - >      opa eval --fail-defined      --data policy/release.rego      --input evidence/release.json      "data.release.deny[_]"  artifacts:    paths: [evidence/release.json]    expire_in: 400 days
The gate consumes an evidence document assembled from every earlier scan, so the decision and its inputs are both retained as audit evidence.
  • Version the policy alongside the application so decisions are reproducible.