Home Technical Support Integrating Bandit and Other Security Scans into CI/CD Pipelines (GitLab, Jenkins, GitHub Actions & CircleCI)

Integrating Bandit and Other Security Scans into CI/CD Pipelines (GitLab, Jenkins, GitHub Actions & CircleCI)

Last updated on Jan 06, 2026

Integrating Bandit and Other Security Scans into CI/CD Pipelines (GitLab, Jenkins, GitHub Actions & CircleCI)

Security‑as‑code is a core pillar of DevSecOps. Whether you use GitLab CI, Jenkins, GitHub Actions, or CircleCI, you can run static‑application‑security‑testing (SAST) tools such as Bandit and automatically publish the findings to downstream systems (e.g., DefectDojo).
This article walks through the most common pitfalls and shows you reliable patterns for:

  1. Running Bandit in a GitLab CI job and preserving the JSON report.
  2. Building, testing, releasing, and deploying a Django app with Jenkins while pushing Docker images to a private registry.
  3. Executing Bandit in GitHub Actions, handling “job‑failed‑because‑vulnerabilities‑found” scenarios, and uploading results to DefectDojo.
  4. Understanding why a failing integration step does not stop other independent jobs in CircleCI.

1. GitLab CI – Keep the Bandit Report Even When the Scan Fails

The problem

When when: on_fail (or when: fail) is used under the docker run step, the job stops on a non‑zero exit code and no artifact is saved. Removing the condition lets the job finish, but the pipeline aborts on the first failure.

The solution – Use when: always and separate the scan from the artifact step

# .gitlab-ci.yml
sast:
  image: docker:latest          # optional – you can also use the default runner image
  services:
    - docker:dind               # needed for Docker-in‑Docker
  stage: test
  script:
    # 1️⃣ Run Bandit – always succeed (ignore exit code)
    - docker run --rm -v "$(pwd)":/src hysnsec/bandit \
        -r /src -f json -o /src/bandit-output.json || true
  artifacts:
    when: always                # always upload, even if the job is marked failed
    paths:
      - bandit-output.json
    expire_in: 1 week

Key points

Element Why it matters
`docker run …
artifacts.when: always Guarantees the JSON file is uploaded even when the job is later marked failed.
script vs. separate run steps In GitLab CI a single script block runs sequentially; you don’t need an extra run step for ls -al unless you want debugging output.

2. Jenkins – Full CI/CD Flow with Docker Registry & DefectDojo

Below is a declarative pipeline that builds a Django project, runs unit tests, pushes a Docker image to a private GitLab registry, and finally deploys to production after a manual approval.

pipeline {
    agent any
    environment {
        REGISTRY_URL   = "gitlab-registry-JnOHfNpe.lab.practical-devsecops.training"
        REGISTRY_CREDS = "registry-auth"
    }

    stages {
        stage('Build') {
            agent { docker { image 'python:3.6' args '-u root' } }
            steps {
                sh '''
                    python3 -m venv env
                    . env/bin/activate
                    pip install -r requirements.txt
                    python manage.py check
                '''
            }
        }

        stage('Test') {
            agent { docker { image 'python:3.6' args '-u root' } }
            steps {
                sh '''
                    . env/bin/activate
                    pip install -r requirements.txt
                    python manage.py test taskManager
                '''
            }
        }

        stage('Release') {
            steps {
                script {
                    def image = docker.build("${REGISTRY_URL}/root/django-nv:${BUILD_NUMBER}")
                    docker.withRegistry("https://${REGISTRY_URL}", REGISTRY_CREDS) {
                        image.push()
                    }
                }
            }
        }

        stage('Integration') {
            steps {
                // Do not fail the whole pipeline – just mark this stage as failed
                catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
                    echo "This is an integration step"
                    sh 'exit 1'      // simulated failure
                }
            }
        }

        stage('Prod') {
            when { tag "release-*" }
            steps {
                input message: 'Deploy to production?', ok: 'Deploy'
                withCredentials([
                    string(credentialsId: 'prod-server',    variable: 'DEPLOYMENT_SERVER'),
                    string(credentialsId: 'docker_username', variable: 'DOCKER_USERNAME'),
                    string(credentialsId: 'docker_password', variable: 'DOCKER_PASSWORD')
                ]) {
                    sh """
                        ssh -o StrictHostKeyChecking=no root@${DEPLOYMENT_SERVER} '
                          docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD} ${REGISTRY_URL}
                          docker rm -f django.nv || true
                          docker pull ${REGISTRY_URL}/root/django-nv:${BUILD_NUMBER}
                          docker run -d --name django.nv -p 8000:8000 ${REGISTRY_URL}/root/django-nv:${BUILD_NUMBER}
                        '
                    """
                }
            }
        }
    }

    post {
        failure { updateGitlabCommitStatus name: env.STAGE_NAME, state: 'failed' }
        success { updateGitlabCommitStatus name: env.STAGE_NAME, state: 'success' }
        always {
            cleanWs(deleteDirs: true, disableDeferredWipeout: true)
        }
    }
}

Highlights for learners

  • catchError – mirrors the GitLab allow_failure pattern; the pipeline continues while the stage is marked red.
  • Docker‑in‑Dockerdocker.build creates an image locally, then docker.withRegistry pushes it securely using stored credentials.
  • Manual approvalinput forces a human gate before production deployment.

3. GitHub Actions – Running Bandit, Ignoring Vulnerability‑Induced Failures, and Sending Results to DefectDojo

Why the job fails

Bandit exits with a non‑zero status when it discovers security issues. GitHub Actions treats any non‑zero exit code as a failed step, which stops downstream steps unless you explicitly tell the runner to ignore the error.

Updated workflow snippet

name: SAST – Bandit Scan

on:
  push:
    branches: [ main ]

jobs:
  bandit-scan:
    runs-on: ubuntu-latest
    steps:
      # 1️⃣ Checkout the repo
      - uses: actions/checkout@v2

      # 2️⃣ Run Bandit – continue even if vulnerabilities are found
      - name: Run Bandit
        run: |
          docker run --rm -v "$(pwd)":/src hysnsec/bandit \
            -r /src -f json -o /src/bandit-output.json
        continue-on-error: true   # <‑‑ crucial

      # 3️⃣ Upload the JSON report as an artifact (always run)
      - name: Upload Bandit report
        uses: actions/upload-artifact@v2
        with:
          name: bandit-report
          path: bandit-output.json
        if: always()               # keep the artifact even on failure

      # 4️⃣ Install Python (required by the upload script)
      - name: Set up Python
        uses: actions/setup-python@v2
        with:
          python-version: '3.6'

      # 5️⃣ Push findings to DefectDojo
      - name: Send results to DefectDojo
        run: |
          python upload-results.py \
            --host ${{ secrets.DOJO_HOST }} \
            --api_key ${{ secrets.DOJO_API_TOKEN }} \
            --engagement_id 1 \
            --product_id 1 \
            --lead_id 1 \
            --environment "Production" \
            --result_file bandit-output.json \
            --scanner "Bandit Scan"
        continue-on-error: true   # optional – you may want the pipeline to succeed regardless

Explanation of key directives

Directive Purpose
continue-on-error: true (step level) Prevents a non‑zero exit from aborting the job. The step is marked yellow (failed but allowed).
if: always() Guarantees the artifact upload runs even when the previous step is marked failed.
setup-python Required only because the upload script is a Python program; you can replace it with any runtime you need.

4. CircleCI – Why Independent Jobs Keep Running After a Failure

The observation

In the “integration” job we deliberately run exit 1. The UI shows the job as failed, yet the subsequent “prod” job still executes.

The underlying rule

CircleCI treats each job as an isolated unit of work.
A failure in one job does not automatically cancel downstream jobs unless you create an explicit dependency using the requires keyword (or the newer needs syntax).

Example workflow showing the effect

version: 2.1
jobs:
  build:
    docker: [{image: python:3.6}]
    steps: [checkout, run: echo "building"]

  integration:
    docker: [{image: python:3.6}]
    steps:
      - run: echo "integration step"
      - run: exit 1   # intentional failure

  prod:
    docker: [{image: python:3.6}]
    steps: [run: echo "deploy to prod"]

workflows:
  version: 2
  pipeline:
    jobs:
      - build
      - integration:
          requires:
            - build
      - prod:
          requires:
            - integration   # <‑‑ comment this line to make prod independent
  • When requires: - integration is present, the prod job will be skipped if integration fails.
  • When the requires line is omitted (or you use type: approval for a manual gate), prod runs regardless of the integration outcome.

Takeaway for learners

  • Use requires/needs to model true dependencies.
  • If you want a “best‑effort” job that should always run (e.g., cleanup, reporting), deliberately omit the dependency or add when: always in GitLab‑style pipelines.

Common Questions & Tips

Question Quick Answer
How do I keep a job’s artifacts when the scan fails? In GitLab use artifacts.when: always. In GitHub Actions use if: always() on the upload step and continue-on-error: true on the scan step.
Can I make a Jenkins stage “optional” like GitLab’s allow_failure? Yes – wrap the steps in catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE').
What if I want the pipeline to stop on the first security failure? Remove continue-on-error / `
Do I need Docker‑in‑Docker for Bandit in GitLab? Only when the runner itself isn’t a Docker image that already contains Bandit. Using docker run with the host’s Docker socket (-v /var/run/docker.sock:/var/run/docker.sock) is another option.
How do I pass secrets to a Docker container in CI? Mount them as environment variables (-e VAR=value) or use the CI platform’s secret‑store (GitLab CI variables, Jenkins credentials, GitHub Actions secrets).

TL;DR Checklist

  • GitLab CI – Run Bandit with || true, set artifacts.when: always.
  • Jenkins – Use catchError for non‑blocking stages, push Docker images with docker.withRegistry.
  • GitHub Actions – Add continue-on-error: true to the Bandit step; always upload the artifact.
  • CircleCI – Define explicit job dependencies (requires/needs) if a failure should block downstream jobs.

By applying these patterns, you’ll have reliable security‑testing pipelines that never lose evidence, continue when appropriate, and fail fast when you need a hard stop. Happy securing!