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:
- Running Bandit in a GitLab CI job and preserving the JSON report.
- Building, testing, releasing, and deploying a Django app with Jenkins while pushing Docker images to a private registry.
- Executing Bandit in GitHub Actions, handling “job‑failed‑because‑vulnerabilities‑found” scenarios, and uploading results to DefectDojo.
- 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 GitLaballow_failurepattern; the pipeline continues while the stage is marked red.- Docker‑in‑Docker –
docker.buildcreates an image locally, thendocker.withRegistrypushes it securely using stored credentials. - Manual approval –
inputforces 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: - integrationis present, theprodjob will be skipped ifintegrationfails. - When the
requiresline is omitted (or you usetype: approvalfor a manual gate),prodruns regardless of the integration outcome.
Takeaway for learners
- Use
requires/needsto 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: alwaysin 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, setartifacts.when: always. - Jenkins – Use
catchErrorfor non‑blocking stages, push Docker images withdocker.withRegistry. - GitHub Actions – Add
continue-on-error: trueto 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!