Container Scanning

A complete guide to container scanning in GitLab covering vulnerability scanning, image signing, security best practices, and integration with CI/CD pipelines.

Vulnerability Scanning Image Signing Security GitLab
Why Container Scanning Matters

Containers bundle an application with its dependencies — including the operating system libraries, language runtime, and third-party packages. Every one of those components can have known vulnerabilities. A base image that was secure when you built it a month ago might have three new CVEs today. Without scanning, you won't know until an attacker exploits them.

Container scanning catches these issues early. It inspects the image's OS packages and application dependencies against known vulnerability databases (such as NVD, GitHub Advisory Database, or vendor feeds), reports findings, and integrates with your pipeline to fail the build or alert your team. Combined with image signing and admission control, it forms a strong supply chain security posture.

GitLab offers container scanning as part of its security scanning suite, along with the ability to sign images using Cosign and verify signatures before deployment. Together these capabilities let you catch vulnerabilities early and prevent tampered images from reaching production.

Core Principle: Container scanning is not a one-time activity. Images must be rescanned periodically — a "clean" scan today doesn't guarantee safety a month from now, because new CVEs are disclosed constantly.
Container Scanning Flow

Container scanning is typically integrated into a CI/CD pipeline as a dedicated job. The flow is: build the image, push it to the registry, scan the pushed image, and report findings. Scanning the pushed image (rather than the local build) ensures you're scanning exactly what will be deployed.

Pipeline Build image Push to registry
Security Stage Container scanning Generate report
Reporting Merge request Approvals
Deploy Pass policy Deploy
# Pipeline structure stages: - build - test - security - deploy # Build image build: stage: build image: docker:24 services: - docker:24-dind before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Security stage (runs after build) container_scanning: stage: security # Scanning runs against the pushed image
GitLab Container Scanning

GitLab provides built-in container scanning via a CI/CD template. The template runs the scanner (typically Trivy or Grype) against a Docker image and produces a JSON report that GitLab reads to display vulnerabilities in the merge request UI and security dashboard.

Container scanning is available in GitLab Ultimate (formerly Gold). It produces a gl-container-scanning-report.json artifact that GitLab parses and displays. Vulnerabilities appear in merge requests (allowing you to block merges with critical findings), in the project's security dashboard, and in the group security dashboard for organization-wide visibility.

The scanner can be configured to fail the job on high-severity findings (with allow_failure: false), warn but continue (default), or scan only specific severities. A common production policy is: fail on critical, warn on high, ignore medium and low.

# .gitlab-ci.yml with container scanning # Include the official template include: - template: Security/Container-Scanning.gitlab-ci.yml # Configure container scanning container_scanning: stage: security variables: # The image to scan DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Image to pull from registry DOCKER_USER: $CI_REGISTRY_USER DOCKER_PASSWORD: $CI_REGISTRY_PASSWORD # Scanner configuration CS_SEVERITY_THRESHOLD: "HIGH" CS_DOCKERFILE_PATH: "Dockerfile" # Timeout (seconds) CS_SCANNER_TIMEOUT: "300" # Ignore unfixed vulnerabilities CS_IGNORE_UNFIXED: "false" # Report format CS_REPORT_FORMAT: "json" artifacts: reports: container_scanning: gl-container-scanning-report.json allow_failure: false # Fail pipeline on findings # Manual scan configuration (no template) container_scanning_manual: stage: security image: docker:24 services: - docker:24-dind variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - apk add --no-cache curl script: # Install Trivy - curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin # Scan image and generate report - trivy image --format json --output gl-container-scanning-report.json $DOCKER_IMAGE artifacts: reports: container_scanning: gl-container-scanning-report.json paths: - gl-container-scanning-report.json # Scanning with severity filtering container_scanning_filtered: extends: container_scanning variables: CS_SEVERITY_THRESHOLD: "CRITICAL" allow_failure: false # Available scanner variables: # CS_DOCKERFILE_PATH: Path to Dockerfile # CS_SEVERITY_THRESHOLD: Minimum severity (LOW, MEDIUM, HIGH, CRITICAL) # CS_IGNORE_UNFIXED: Only report fixed vulnerabilities # CS_SCANNER_TIMEOUT: Timeout in seconds # CS_DOCKER_IMAGE: Image to scan # CS_SEVERITY_FILTER: Additional severity filtering
Container Scanning Best Practices:
  • Scan the pushed image (not the local build) to verify what will deploy
  • Set severity threshold to CRITICAL for fail-fast policies
  • Enable scanning on every pipeline, not just releases
  • Rescan existing images periodically — new CVEs emerge
  • Filter to fixed vulnerabilities only for actionable results
  • Combine with SAST and dependency scanning for full coverage
Vulnerability Scanning with Trivy

Trivy is the most widely used open-source container vulnerability scanner. It's fast, has a large vulnerability database, supports multiple image formats, and generates reports in many formats including GitLab's expected JSON schema.

Trivy scans image layers, OS packages (Alpine apk, Debian dpkg, RHEL rpm), language dependencies (npm, pip, go, maven), and configuration files. It cross-references findings against vulnerability databases, providing CVE IDs, severity, fixed versions, and remediation guidance.

In a GitLab pipeline, Trivy is usually run as part of the security stage. Its output is a JSON report that GitLab ingests for display. Trivy can also be run locally for pre-commit checks, integrated into other CI systems, or used as part of admission control.

# Install and use Trivy # Install via script (Linux) curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin # Or download binary wget https://github.com/aquasecurity/trivy/releases/latest/download/trivy_0.48.0_Linux-64bit.tar.gz tar xf trivy_0.48.0_Linux-64bit.tar.gz mv trivy /usr/local/bin/ # Scan an image trivy image nginx:latest # Scan with severity filter trivy image --severity HIGH,CRITICAL nginx:latest # Scan with output format trivy image --format json --output report.json nginx:latest trivy image --format table --output report.txt nginx:latest trivy image --format sarif --output report.sarif nginx:latest # Scan local Docker daemon image trivy image --input image.tar # Ignore unfixed vulnerabilities trivy image --ignore-unfixed nginx:latest # Use a specific database trivy image --db-repository registry.gitlab.com/gitlab-org/security-products/analyzers/trivy-db:latest nginx:latest # Complete CI job with Trivy trivy_scan: stage: security image: docker:24 services: - docker:24-dind before_script: - apk add --no-cache curl - curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: # Scan image and generate GitLab-compatible report - trivy image --format template --template "@contrib/gitlab.tpl" \ --output gl-container-scanning-report.json \ --severity HIGH,CRITICAL \ $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA artifacts: reports: container_scanning: gl-container-scanning-report.json paths: - gl-container-scanning-report.json allow_failure: false # Trivy severity levels: # CRITICAL: Exploits in the wild, immediate action needed # HIGH: Significant impact, action needed soon # MEDIUM: Moderate impact, schedule fix # LOW: Minor impact, fix when convenient # UNKNOWN: No severity assigned # Trivy scan types: # --scanners vuln Scan for vulnerabilities (default) # --scanners config Scan for misconfigurations (IaC) # --scanners secret Scan for secrets # --scanners license Scan for license compliance # Combine with: --scanners vuln,config,secret # Trivy configuration file (.trivy.yaml) severity: - HIGH - CRITICAL ignore-unfixed: true exit-code: 1 format: json output: gl-container-scanning-report.json # Scan and fail if any critical CVE trivy image --severity CRITICAL --exit-code 1 nginx:latest
Trivy Advantages:
  • Fast and efficient (seconds per image)
  • Supports all major image formats
  • Scans OS packages, language deps, misconfigs, secrets
  • Multiple output formats (JSON, table, SARIF, JUnit)
  • Free and open source
  • Can be used locally, in CI, and in admission control
Image Signing with Cosign

Container image signing proves that an image was created by a trusted entity and hasn't been tampered with. It's the cryptographic equivalent of a wax seal: a signature attached to the image that can be verified before it's deployed.

Cosign (from the Sigstore project) is the modern standard for image signing. It supports key-based signing (with a key pair you manage) and keyless signing (using OIDC identity — e.g., your GitHub account). It integrates with GitLab CI through pipeline jobs and with Kubernetes through admission controllers.

The workflow is: build the image, push it to the registry, sign it with Cosign, and verify the signature before deploying. Signing is a one-time operation per image version; verification is repeated on every deploy. For Kubernetes, admission controllers (like Kyverno or OPA Gatekeeper) can enforce that only signed images are allowed to run.

# Install Cosign curl -L https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 \ -o /usr/local/bin/cosign chmod +x /usr/local/bin/cosign # Generate a key pair cosign generate-key-pair # This creates: # - cosign.key (private key, keep secret!) # - cosign.pub (public key, share freely) # Sign an image cosign sign --key cosign.key $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Verify an image cosign verify --key cosign.pub $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Signature format (stored as OCI artifact): # - tag: sha256-.sig # - signature: base64-encoded # - cert: optional (for keyless) # Keyless signing (Sigstore) # Uses OIDC identity — no private key management cosign sign $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # This uses the OIDC identity of the CI job # Requires COSIGN_EXPERIMENTAL=1 and OIDC token # In GitLab CI, use the job token for keyless signing sign: stage: security image: bitnami/cosign:latest variables: COSIGN_EXPERIMENTAL: "1" script: - cosign sign --identity-token $CI_JOB_JWT_V2 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA only: - main # Verify with keyless cosign verify --certificate-identity-regexp \ ".*@gitlab\\.com" \ --certificate-oidc-issuer \ "https://gitlab.com" \ $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Attach attestations (SBOM, SLSA provenance) cosign attest --key cosign.key \ --predicate sbom.json \ --type spdxjson \ $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Full pipeline: build, sign, verify stages: - build - security - deploy build: stage: build image: docker:24 services: [docker:24-dind] before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA sign: stage: security image: bitnami/cosign:latest needs: [build] before_script: - cosign login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD script: - cosign sign --key $COSIGN_PRIVATE_KEY $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA only: - main verify: stage: deploy image: bitnami/cosign:latest before_script: - cosign login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD script: - cosign verify --key $COSIGN_PUBLIC_KEY $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA only: - main # Kyverno policy for image verification apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: verify-image-signatures spec: validationFailureAction: enforce rules: - name: verify-signature match: resources: kinds: [Pod] verifyImages: - imageReferences: ["registry.gitlab.com/mygroup/*"] attestors: - entries: - keys: publicKeys: |- -----BEGIN PUBLIC KEY----- -----END PUBLIC KEY-----
Image Signing Best Practices:
  • Sign every image you push to the registry
  • Verify signatures before deploying to Kubernetes
  • Use keyless signing for CI/CD (no key management)
  • Use admission controllers (Kyverno, OPA) to enforce signature verification
  • Store private keys in secret managers (never in code)
  • Rotate keys periodically
  • Combine with SBOM attestations for full provenance
Software Bill of Materials (SBOM)

An SBOM is a machine-readable inventory of all components in an image — OS packages, language dependencies, and files. It's essential for supply chain security: when a new CVE is disclosed, you can check your SBOMs to see which images are affected.

SBOMs are typically generated during the build (using tools like Syft, Trivy, or cdxgen) and stored as OCI artifacts in the registry alongside the image. They can also be attached as attestations (using Cosign), making them verifiable.

# Generate SBOM with Syft syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -o spdx-json > sbom.spdx.json # Generate SBOM with Trivy trivy image --format spdx-json --output sbom.spdx.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Generate SBOM with cdxgen (CycloneDX) cdxgen -o sbom.cdx.json . # Attach SBOM to image with Cosign cosign attach sbom --sbom sbom.spdx.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Verify SBOM cosign verify-attestation --key cosign.pub $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # SBOM in CI pipeline generate_sbom: stage: security image: aquasec/trivy:latest needs: [build] script: - trivy image --format spdx-json --output sbom.spdx.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA artifacts: paths: - sbom.spdx.json # Scanning the SBOM for vulnerabilities # When a new CVE is disclosed, check which images # contain the affected package (using SBOMs)
Security Scanning Types

Container scanning is one part of a broader security scanning strategy. Understanding how the different scan types complement each other helps you build comprehensive coverage.

Container Scanning

Scans image layers (OS packages, language dependencies) for known CVEs. Runs after image build. Catches vulnerabilities in the image contents.

SAST

Static Application Security Testing. Scans source code for vulnerabilities (SQL injection, XSS, etc.). Runs during the build stage.

Dependency Scanning

Scans package manifests (package.json, go.mod, requirements.txt) for vulnerable dependencies. Runs on every commit.

DAST

Dynamic Application Security Testing. Scans running applications for vulnerabilities. Runs against staging environments.

Secret Detection

Scans code and commit history for secrets (API keys, tokens). Runs on every commit.

IaC Scanning

Scans Terraform, Kubernetes manifests, and other IaC for misconfigurations. Runs on every commit.
# Complete security scanning pipeline include: - template: Security/SAST.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml - template: Security/Dependency-Scanning.gitlab-ci.yml - template: Security/Container-Scanning.gitlab-ci.yml - template: Security/DAST.gitlab-ci.yml stages: - build - test - security - deploy # SAST (static analysis) sast: stage: security variables: SAST_EXCLUDED_PATHS: "spec, test, tests, tmp" # Secret detection secret_detection: stage: security variables: SECRET_DETECTION_HISTORIC_SCAN: "true" # Dependency scanning dependency_scanning: stage: security variables: DS_EXCLUDED_PATHS: "spec, test, tests, tmp" # Container scanning container_scanning: stage: security variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA needs: [build] # DAST (dynamic) dast: stage: security variables: DAST_WEBSITE: "https://staging.example.com" only: - main # All security jobs run in parallel, produce reports, # and are aggregated in the security dashboard
Troubleshooting Container Scanning
# Container scanning job fails # 1. Check image exists in registry docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # 2. Check registry credentials docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY # 3. Check scanner configuration cat .gitlab-ci.yml | grep -A 10 container_scanning # 4. View scanner logs # The scanner output is in the job log # "No vulnerabilities found" but expected some # - Ensure the correct image is being scanned # - Check that the vulnerability database is up to date # - Check if --ignore-unfixed is filtering results # - Check severity threshold # Scanner timeout # - Increase CS_SCANNER_TIMEOUT # - Use a smaller image # - Increase runner resources # "Unauthorized" errors # - Check CI_REGISTRY_USER and CI_REGISTRY_PASSWORD # - Check the runner can reach the registry # - Check image pull permissions # Signature verification fails cosign verify --key cosign.pub $IMAGE # - Check the image is signed cosign verify --key cosign.pub $IMAGE --output json # - Check the public key matches the private key used # - Check the image digest (signatures are per digest, not tag) # SBOM generation fails syft $IMAGE -o spdx-json # - Check image exists # - Check the analyzer has permission to pull # Debug with verbose output trivy image --debug $IMAGE cosign verify --key cosign.pub $IMAGE --verbose # Reset scanner cache rm -rf ~/.cache/trivy
Frequently Asked Questions
What's the difference between container scanning and dependency scanning?
Container scanning scans the built image's layers for CVEs. Dependency scanning scans package manifests (package.json, go.mod) for vulnerable dependencies. Both are needed — container scanning catches OS packages, while dependency scanning catches application packages.
Which scanner should I use — Trivy, Grype, or Clair?
Trivy is the most widely used and fastest. Grype is also excellent (from Anchore). Clair is older but mature. For most teams, Trivy is the best default choice because it's fast, well-maintained, and integrates with GitLab.
Should I fail the pipeline on vulnerabilities?
For critical vulnerabilities, yes. For high, maybe. For medium/low, typically no. A common policy: fail on critical, warn on high, ignore lower. This balances security with pragmatism.
What's keyless signing with Cosign?
Keyless signing uses your OIDC identity (e.g., GitHub or GitLab account) to sign images — no private key to manage. The signature is verified against the OIDC identity. It's simpler and more secure than key-based signing for CI/CD.
How do I enforce image signing in Kubernetes?
Use an admission controller like Kyverno or OPA Gatekeeper to require signed images. Configure the policy to reject unsigned images. This prevents tampered images from being deployed.
How often should I rescan images?
New CVEs are disclosed constantly. Rescan existing images weekly or monthly. Some teams rescan daily. The GitLab Container Registry can be integrated with scheduled scanning jobs.
What's an SBOM and why do I need one?
An SBOM (Software Bill of Materials) is a machine-readable inventory of all components in an image. When a new CVE is disclosed, you can search your SBOMs to find affected images. It's required by many compliance standards.
Does container scanning slow down pipelines?
Yes, but typically by 30-60 seconds per scan. This is usually acceptable. You can run scans in parallel with other jobs, or only scan on main and release branches to reduce overhead.
Previous: Registry Cleanup Next: Deploying to Kubernetes

Container scanning is essential for supply chain security. Combine vulnerability scanning, image signing, and SBOMs for comprehensive protection.