Skip to content

blog

DevSecOps: Scan Container Images with Trivy

Containers images ship software. They also ship vulnerabilities. Outdated base images, exposed secrets, misconfigured image instructions. Trivy scans container images for CVEs, misconfigurations, secrets, and license issues in one command.

TL;DR: Install Trivy, scan your container images with trivy image, enable additional scanners, and add it to your CI pipeline to catch vulnerabilities before deployment.


Install Trivy

# Ubuntu/Debian
sudo apt install apt-transport-https ca-certificates curl gnupg
curl -fsSL https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt update
sudo apt install trivy

Verify:

trivy --version

Scan a container image

Basic usage: scan a public image for vulnerabilities and secrets:

trivy image python:3.4-alpine

Vulnerability and secret scanning are both enabled by default. The output groups findings by OS package type, then gets into specific packages:

Report Summary

┌────────────────────────────────────────────────────────────────────────────┬────────────┬─────────────────┬─────────┐
│                                   Target                                   │    Type    │ Vulnerabilities │ Secrets │
├────────────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ python:3.4-alpine (alpine 3.9.2)                                           │   alpine   │       37        │    -    │
├────────────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
...

python:3.4-alpine (alpine 3.9.2)

Total: 37 (UNKNOWN: 0, LOW: 4, MEDIUM: 16, HIGH: 13, CRITICAL: 4)

┌──────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬──────────────────────────────────────────────────────────────┐
│   Library    │ Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                            Title                             │
├──────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤
│ expat        │ CVE-2018-20843 │ HIGH     │ fixed  │ 2.2.6-r0          │ 2.2.7-r0      │ expat: large number of colons in input makes parser consume  │
│              │                │          │        │                   │               │ high amount...                                               │
│              │                │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2018-20843                   │
...

To scan only vulnerabilities (disables the default secret scanner):

trivy image --scanners vuln python:3.4-alpine

To enable only misconfiguration or license scanning:

trivy image --scanners misconfig myapp:latest
trivy image --scanners license myapp:latest

Scan container image metadata

Container images have configuration metadata, the container image instructions that built them. Trivy can scan this configuration for misconfigurations and secrets:

# Dockerfile-style misconfigurations (USER, HEALTHCHECK, ADD vs COPY)
trivy image --image-config-scanners misconfig myapp:latest

# Secrets in environment variables (credential leaks in config)
trivy image --image-config-scanners secret myapp:latest

These are disabled by default. The misconfiguration scanner converts the image config into Dockerfile format and runs Dockerfile checks against it. The secret scanner looks for credentials in environment variables stored in the config JSON.


Scan an image tar file

Trivy can scan images saved as tar archives. Useful for offline or CI environments:

docker pull ruby:3.1-alpine3.15
docker save ruby:3.1-alpine3.15 -o ruby-3.1.tar
trivy image --input ruby-3.1.tar

Scan by architecture

By default, Trivy scans on the host's architecture. Cross-architecture images need the --platform flag:

trivy image --platform=linux/arm alpine:3.16.1

SBOM generation and vulnerability scanning

Trivy can generate Software Bill of Materials (SBOM) for container images:

# Generate SBOM in CycloneDX format
trivy image --format cyclonedx -o sbom.json python:3.4-alpine

# Generate SBOM in SPDX format
trivy image --format spdx-json -o sbom.json python:3.4-alpine

Compliance

The docker-cis-1.6.0 check evaluates container image configurations against the CIS Docker Benchmark:

trivy image --compliance docker-cis-1.6.0 myapp:latest

Authentication

For private registries, authenticate with:

trivy registry login --username myuser --password mypassword registry.example.com

Then scan as normal. Trivy reads the registry credentials before pulling the image.


Filter findings

Trivy lets you narrow scans by severity:

# Only HIGH and CRITICAL
trivy image --severity HIGH,CRITICAL alpine:3.18

# Only HIGH severity
trivy image --severity HIGH alpine:3.18

Add to CI/CD

# .github/workflows/trivy.yml
name: Trivy
on: [pull_request]
jobs:
  trivy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: myapp:latest
          format: table
          exit-code: 1
          severity: HIGH,CRITICAL

exit-code: 1 causes the job to fail on HIGH or CRITICAL findings. Use format: sarif and add the upload-sarif action for inline annotations.


Understand what Trivy scans

Trivy checks four categories against files inside container images:

Category What it finds Enabled by default
Vulnerabilities CVEs in OS packages and language dependencies Yes
Misconfigurations IaC files inside the image (Kubernetes, Terraform) No
Secrets Hardcoded credentials, tokens, API keys Yes
Licenses Open source license compliance No

Takeaways

  • Trivy scans for vulnerabilities and secrets by default. Use --scanners to enable misconfiguration and license scanning.
  • --image-config-scanners scans the image's configuration metadata, not the filesystem inside it.
  • --input lets you scan tar files and OCI layout directories instead of live registries. Useful for offline CI.
  • --severity and --exit-code control CI gating.
  • Use --platform for multi-arch images. Trivy only scans the host architecture without it.

DevSecOps: Scan Infrastructure Code for Misconfigurations with Checkov

Infrastructure code contains security misconfigurations. Open buckets, unencrypted databases, overly permissive access policies. Checkov scans IaC files across 20+ formats for these patterns before you deploy.

TL;DR: Install checkov, scan your IaC directory, configure your repository, and add it to your CI pipeline to catch infrastructure misconfigurations before they reach production.


Install checkov

pip install checkov

Verify:

checkov --version

Scan a project

Basic usage: scan an entire directory recursively:

checkov -d /path/to/iac

Scan a single file:

checkov -f /path/to/main.tf

Checkov groups findings by resource type. AWS findings use CKV_AWS_XX IDs, Azure uses CKV_AZURE_XX, Kubernetes uses CKV_K8S_XX, and so on.


Configuration files

You can configure checkov with a YAML file in your repo:

# .checkov.yaml
check:
  skip-check:
    - CKV_AWS_21
    - CKV_AWS_34
compact: true

The skip-check list matches --skip-check on the command line. The compact flag collapses findings and only displays failures.


Output formats

Checkov supports several output formats. The default is text. For CI integration, use JSON, SARIF, or JUnit XML.

# Default: human-readable text
checkov -d .

# JSON (parseable in CI)
checkov -d . -o json -o findings.json

# SARIF (GitHub Code Scanning, GitLab SAST)
checkov -d . -o sarif -o findings.sarif

# JUnit XML (for CI systems)
checkov -d . -o junitxml -o findings.xml

SARIF integrates with GitHub Code Scanning and GitLab SAST which renders findings as in-line annotations on the source files.


Add to CI/CD

The checkov-action with the github_failed_only output reports failures on the GitHub action page.

# .github/workflows/checkov.yml
name: Checkov
on: [pull_request]
jobs:
  checkov:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          output_format: github_failed_only

Understand the checks

Checkov has 750+ built-in policies across 20+ IaC types.

IaC formats

IaC Type Format Suppression
Terraform / OpenTofu .tf, .tofu, .hcl, .tfvars # checkov:skip=CKV_AWS_21: reason
Kubernetes .yaml, .json checkov.io/skip1: CKV_K8S_20=reason
CloudFormation .json, .yaml, .yml, .template # checkov:skip=CKV_AWS_1: reason
Dockerfile Named files (Dockerfile, Dockerfile.*, dockerfile) #checkov:skip=CKV_DOCKER_1: reason
Bicep .bicep #checkov:skip=CKV_AZURE_1: reason
ARM Templates .json CLI only (--skip-check)
Ansible .yaml, .yml, .json #checkov:skip=CKV_ANSIBLE_1: reason
Argo Workflows .yaml checkov.io/skip1: CKV_ARGO_1=reason
Serverless Framework .yml, .yaml #checkov:skip=CKV_AWS_1: reason
AWS SAM .yaml, .yml #checkov:skip=CKV_AWS_1: reason
OpenAPI .yaml, .json // checkov:skip=CKV_OPENAPI_1: reason
Azure Pipelines .yml, .yaml #checkov:skip=CKV_AZUREPIPELINES_1: reason

Auto-detected

Helm and Kustomize auto-detect config file presence, then template out to Kubernetes manifests for scanning. AWS CDK requires running cdk synth to generate a CloudFormation JSON template, which checkov then scans.

IaC Type Format Suppression
Helm Auto-detected by Chart.yaml presence Via #checkov:skip on templates
Kustomize Auto-detected by kustomization.yaml presence Via #checkov:skip on templates
AWS CDK Synthesized from .ts, .js, .py (cdk synth to JSON first) CDK construct metadata

VCS configuration

Checkov evaluates organization and repository settings from the respective APIs, not from code files. You need to provide a personal access token for each platform.

IaC Type Format Suppression
GitHub Actions .yml, .yaml #checkov:skip=CKV_GITHUB_1: reason
GitHub Configuration .json (fetched from GitHub API) CLI only (--skip-check)
GitLab CI .yml, .yaml #checkov:skip=CKV_GITLABCI_1: reason
GitLab Configuration .json (fetched from GitLab API) CLI only (--skip-check)
Bitbucket Pipelines .yml #checkov:skip=CKV_CIRCLECIPIPELINES_1: reason
Bitbucket Configuration .json (fetched from Bitbucket API) CLI only (--skip-check)

See the GitHub, GitLab, and Bitbucket scan examples for required environment variables.

Other

IaC Type Format Suppression
SCA .lock, .json, .txt, .xml, .toml (varies by language) File-level comment in lock file
Git History (repository commit history, not a file format) CLI only (--skip-check)
Terraform Plan .tfplan (Terraform plan output, not source) CLI only (--skip-check)

Checkov's full policy index is auto-generated from the source and available at Policy Index.


Takeaways

  • Use --skip-check or .checkov.yaml for known misconfigurations. Don't skip checks blindly.
  • Use checkov:skip comments to suppress individual findings in the code. Explain why.
  • SARIF output integrates with GitHub Code Scanning for inline annotations on source files.
  • Run checkov in CI on pull requests. Prevent misconfigurations from reaching production.

DevSecOps: Manage Terraform Versions with tfenv

State file incompatibility between Terraform versions breaks your pipelines. tfenv manages multiple versions so you can pin a specific version, switch between them, and keep CI aligned with your local environment.

TL;DR: Install tfenv, switch to the version your project needs, and pin it in CI to prevent version drift between local and remote environments.


Install tfenv

git clone https://github.com/tfutils/tfenv.git ~/.tfenv

Add it to your shell profile:

# ~/.bashrc, ~/.zshrc, etc.
export PATH="$HOME/.tfenv/bin:$PATH"

Verify:

tfenv --version

Check available versions

# All available versions
tfenv list-remote

# Filter for a specific major version (1)
tfenv list-remote | grep "^1\."

Terraform uses semantic versioning: breaking changes only happen between major versions. Minor and patch updates are compatible with existing state.


Switch versions

# Install a specific version
tfenv install 1.15.0

# List installed versions
tfenv list

# Switch to a version 
tfenv use 1.15.0

# Write current version to .terraform-version
tfenv pin

This file is tracked in git. Anyone who runs tfenv use (or terraform with tfenv installed) will use the pinned version.

# Reads from .terraform-version by default
tfenv use

Takeaways

  • .terraform-version is tracked in git, so the whole team uses the same Terraform version. This is the source of truth.
  • tfenv use reads from .terraform-version by default. tfenv pin writes to it. The primary workflow is both without arguments.
  • Terraform uses semantic versioning: breaking changes only happen between major versions. Minor and patch updates are safe.

DevSecOps: Run SAST on Python Code with Bandit

Insecure Python patterns (eval(), os.system(), hardcoded credentials) end up in production repos. Bandit scans Python code to find them.

TL;DR: Install Bandit, configure your repository, run a scan, and add it to your CI pipeline to catch security issues before they reach production.


Install Bandit

pip install bandit

The apt package is outdated at 1.6.2. Use pip for the current version (1.9.4).

Verify:

bandit --version

Extras

Bandit has optional extras for additional functionality:

# SARIF output formatter (for GitHub and other tools)
pip install bandit[sarif]

# Baseline comparison (ignore known-vulnerabilities)
pip install bandit[baseline]

# TOML configuration support
pip install bandit[toml]

These aren't needed to start. They become useful once you're past the basic scanning phase.


Understand the rule IDs

Bandit uses alphanumeric rule IDs. The letter prefix groups them by category:

Prefix Category What it checks
B1xx Assertion assert statements in production code
B2xx Exec/eval eval(), exec(), pickle usage
B3xx Blacklist calls Dangerous function calls (shell injection, SSL bypass)
B4xx Blacklist imports Insecure imports (pickle, xml, subprocess with shell=True)
B5xx Crypto Weak crypto algorithms (MD5, DES, RC4)
B6xx OS os.system(), subprocess with shell=True, command injection
B7xx Network SSL/TLS misconfiguration, insecure protocols

The most commonly skipped rules are B101 (assert statements) and B602 (os.system). Whether you skip them depends on your code. If you're running Bandit on test files or production code with assertions, they'll generate noise.

Scan a project

Basic usage:

# Single file
bandit single_file.py

# Entire directory
bandit -r /path/to/project

# Limit context lines shown in output
bandit examples/*.py -n 3 --severity-level high

Skip specific rules by ID:

bandit -r . --skip B101,B602

Skip specific directories:

bandit -r . --exclude ./tests/

Scan from stdin:

cat examples/imports.py | bandit -

You can filter by severity or confidence level:

# Medium security level
bandit -r . -ll

# High security + High confidence
bandit -r . -lll -iii

# By severity name
bandit -r . --severity-level high

# By confidence name
bandit -r . --confidence-level high

Output formats

Bandit supports several output formats. The default is plain text. For CI integration, use JSON or SARIF.

# Default: human-readable text
bandit -r .

# JSON (parseable in CI)
bandit -r . -f json -o findings.json

# SARIF (for GitHub and other tools)
bandit -r . -f sarif -o findings.sarif

Configuration files

You can configure Bandit with a YAML or TOML file, or an INI file called .bandit.

YAML

# bandit.yaml
skips:
  - B101
  - B602

exclude_dirs:
  - tests
  - migrations
bandit -c bandit.yaml -r .

TOML

# pyproject.toml
[tool.bandit]
exclude_dirs = ["tests", "migrations"]
skips = ["B101", "B602"]
bandit -c pyproject.toml -r .

Config generator

Bandit ships bandit-config-generator which generates a full configuration file with all detected plugins:

bandit-config-generator -s B101,B602 -o bandit.yaml

This is useful for understanding what plugins are available and their default settings. Edit the output down to what your project actually needs.


Suppress individual findings

If a line triggers a finding that you've reviewed and determined is safe, add # nosec:

# This hash is for unique IDs only, not security
the_hash = md5(data).hexdigest() # nosec

The whole line gets suppressed. If you only want to suppress specific checks on that line, name them:

self.process = subprocess.Popen('/bin/ls *', shell=True)  # nosec B602, B607

You can use full test names instead of IDs:

assert yaml.load("{}") == []  # nosec assert_used

Always add a comment explaining why you're suppressing the finding. Without context, future reviewers won't know if the suppression is intentional or lazy.


Baseline: ignore known vulnerabilities

If you have findings that are known and not currently actionable (e.g., a cleartext password in a unit test), generate a baseline and pass it to subsequent scans:

# Generate baseline
bandit -f json -o baseline.json -r .

# Subsequent scans ignore baseline findings
bandit -b baseline.json -f json -r .

This is cleaner than suppressing individual lines when you have dozens of known-vulnerabilities.


Add to local git hooks

Add Bandit as a pre-commit git hook:

#!/bin/sh
# .git/hooks/pre-commit

pip install -q bandit
bandit -r . -ll -f json -o /dev/null

Make it executable:

chmod +x .git/hooks/pre-commit

Add to CI/CD

# .github/workflows/bandit.yml
name: Bandit SAST
on: [pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install Bandit
        run: pip install bandit[sarif]
      - name: Run Bandit
        run: bandit -r . -f sarif -o bandit-results.sarif
      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: bandit-results.sarif

The SARIF output format integrates directly with GitHub's code scanning UI, which is nicer than trying to parse plain-text results.


Takeaways

  • Bandit catches the common things: eval(), os.system(), weak crypto, hardcoded secrets
  • Use --severity-level and --confidence-level to control finding noise
  • # nosec suppresses a line. Name the specific checks if you only want to suppress certain ones
  • Baseline files are cleaner than suppressing individual findings for known-vulnerabilities
  • SARIF output integrates with GitHub code scanning
  • Local git hooks catch findings before they reach CI
  • The apt version is stale. Use pip for the current rule set.
  • Always explain why when you skip a rule or add a # nosec comment

DevSecOps: Scan for Secrets in Your Git Repository

Accidentally committed API keys and tokens cause breaches. Gitleaks scans Git history for these patterns.

TL;DR: Install gitleaks, configure your repository, run a scan, and add it to your CI pipeline to catch secrets before they reach production.


Install gitleaks

sudo apt install gitleaks

Verify the install:

gitleaks --version

Configure gitleaks

Create a gitleaks.toml file in your repository root:

[extend]
using = "github.com/gitleaks/gitleaks"

[allowlist]
description = "global allow lists"
regexes = [
    # patterns to allow
]

Add a custom rule:

[[rules]]
id = "my-custom-secret"
description = "Custom secret pattern"
regex = '''(your-pattern-here)'''

Scan Your Repository

# Full history scan
gitleaks detect --source .

# Verbose output (shows actual secrets)
gitleaks detect --source . --verbose

# Verbose output (redacts actual secrets)
gitleaks detect --source . --verbose --redact

Add to local git hooks

#!/bin/sh
# .git/hooks/pre-commit

sudo apt install gitleaks
gitleaks detect --source . --verbose --redact /dev/null

Make it executable:

chmod +x .git/hooks/pre-commit

Add gitleaks to CI/CD

name: gitleaks
on: [pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v3
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Takeaways

  • Scan the full Git history. People commit secrets and then delete them from current files, but the values remain in commit history.
  • Use --verbose when investigating findings. It reveals the secret value, not just the file and line.
  • Add gitleaks to your CI on pull requests. Preventing the secret from reaching production is cheaper than responding after it does.