Container Image Scanning¶
Availability: Pro and Enterprise · Tenant-scoped
See the Tier & Scope Matrix for how this is enforced today.
VirtualSutra AI can scan container images for CVEs, OS compliance violations, and generate Software Bills of Materials (SBOMs). Container scanning runs in parallel with cloud and codebase scanning as part of a regular inspection.
Before you start¶
Before scanning container images, ensure you have:
- Registry access - the VirtualSutra scanner can pull from your registry (see setup per registry type below)
- Image reference - registry hostname, repository path, and tag or digest for each image to scan
- License blocklist (optional) - a list of SPDX license IDs to flag (e.g.
GPL-3.0,AGPL-3.0)
No Docker daemon required
The scanner uses Skopeo for daemon-free image pulls. It does not need a Docker socket or a running Docker Desktop.
What gets scanned¶
For each container image you add to the inspection scope:
| Capability | Tool | Default |
|---|---|---|
| CVE scanning | Trivy | On |
| SBOM generation | Syft (CycloneDX JSON) | On |
| OS compliance | OpenSCAP + SCAP Security Guide | Off (opt-in) |
| Signature verification | cosign | On |
| SLSA provenance | slsa-verifier (verified when you give a source_uri), otherwise presence only | On |
All image pulls use Skopeo for daemon-free operation - the scanner never requires a Docker socket. Signature and provenance checks run immediately after the pull, in the same step that already holds your registry credentials.
Adding images to an inspection¶
In the inspection wizard, expand Container Images under Step 1 - Targets and click Add Image.
Public images¶
For any publicly accessible image (Docker Hub, GHCR public repos, ECR Public):
| Field | Example |
|---|---|
| Registry | ghcr.io |
| Repository | my-org/my-app |
| Tag | v1.2.3 |
| Platform | linux/amd64 |
Leave the username and password fields blank.
Private images - setup per registry¶
Private registries require the scanner to authenticate before pulling. Set up access once, then reference credentials in the wizard or inspection.json.
AWS ECR¶
The scanner uses the same IAM role as the cloud inspector. Attach the read-only ECR policy to the VirtualSutra inspection role:
| Method | Policy / Role | Scope |
|---|---|---|
| Managed policy (simplest) | arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly | All ECR repos in the account |
| Inline least-privilege | ecr:GetDownloadUrlForLayer, ecr:BatchGetImage, ecr:BatchCheckLayerAvailability, ecr:GetAuthorizationToken | Specific ECR repos via resource ARN |
# Attach managed policy
aws iam attach-role-policy \
--role-name VirtualSutraInspection \
--policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
No separate username/password needed - ECR auth uses the IAM role automatically.
What to enter in the wizard: set Registry to your ECR endpoint (e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com). Leave username and credentials blank.
Azure ACR¶
Grant the service principal the AcrPull role on the container registry resource:
# Find your ACR resource ID
ACR_ID=$(az acr show --name <acr-name> --resource-group <rg> --query id -o tsv)
# Assign AcrPull role to the VirtualSutra service principal
az role assignment create \
--assignee "<client-id>" \
--role "AcrPull" \
--scope "$ACR_ID"
| Role | Purpose |
|---|---|
AcrPull | Pull images and list repositories (read-only) |
What to enter in the wizard: set Registry to your ACR login server (e.g. myacr.azurecr.io). Enter the service principal Client ID as username and Client Secret as credentials - or use env://ACR_PASSWORD if configured in the environment.
GCP Artifact Registry / GCR¶
Grant the service account roles/artifactregistry.reader on the project:
gcloud projects add-iam-policy-binding PROJECT_ID \
--member="serviceAccount:vsutra-ai-inspector@PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/artifactregistry.reader"
For legacy GCR (gcr.io), grant roles/storage.objectViewer on the GCR storage bucket instead.
| Registry | IAM Role |
|---|---|
Artifact Registry (*-docker.pkg.dev) | roles/artifactregistry.reader |
Container Registry (gcr.io) | roles/storage.objectViewer |
What to enter in the wizard: set Registry to your Artifact Registry hostname (e.g. us-docker.pkg.dev). Use _json_key as username and paste the service account JSON key as credentials - or use env://GCR_SA_KEY if configured.
Docker Hub / GHCR / self-hosted¶
For any registry that uses username + password or token authentication:
| Field | Value |
|---|---|
| Username | Your registry username (or env://REGISTRY_USERNAME) |
| Credentials | Your registry password or token (or env://REGISTRY_PASSWORD) |
For GitHub Container Registry (ghcr.io), create a Personal Access Token with read:packages scope and use it as the password with your GitHub username.
For Docker Hub, use your Docker Hub username and a Personal Access Token with Read-only access.
Multiple platforms
You can add the same image multiple times with different platforms (e.g. linux/amd64 and linux/arm64) to get separate findings per architecture. Each platform counts as one container scanner task.
Step-by-step: adding a container image in the wizard¶
- Navigate to Runs → New Inspection (or
/runs/new) - Under Step 1 - Targets, scroll to Container images and click Add
-
Fill in the fields:
Field What to enter Registry Registry hostname - e.g. ghcr.io,docker.io,123456789.dkr.ecr.us-east-1.amazonaws.com,us-docker.pkg.devRepository Image path - e.g. my-org/my-appTag Tag or digest - e.g. v1.2.3,latest,sha256:abc...Credentials Leave blank for public images. For private: enter password/token directly or use env://VAR_NAMEsecret referenceUsername Leave blank for public or ECR. For ACR/GHCR/Docker Hub: enter username or env://VAR_NAME -
Toggle scanners:
- Trivy (CVE scanning) - enabled by default
- Syft (SBOM) - enabled by default
- OpenSCAP (OS compliance) - disabled by default; enable for CIS benchmark (and, on Ubuntu, STIG) checks
-
Optionally add a license blocklist - comma-separated SPDX IDs (e.g.
GPL-3.0, AGPL-3.0) -
Click Add - repeat for additional images
-
Proceed to Step 2 - Scope and Step 3 - Output, then submit
Each image is scanned as an independent task running in parallel with all other scanner tasks.
Timing and resource usage¶
| Image size | Trivy only | Trivy + SBOM | Trivy + SBOM + OpenSCAP |
|---|---|---|---|
| Small (< 200 MB) | ~30 seconds | ~45 seconds | ~3 minutes |
| Medium (200–500 MB) | ~1 minute | ~2 minutes | ~5 minutes |
| Large (> 500 MB) | ~2–3 minutes | ~3–5 minutes | ~8–12 minutes |
OpenSCAP requires extracting the full image filesystem and is the main contributor to scan time. Only enable it when you need CIS benchmark (and, on Ubuntu, STIG) checks.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
UNAUTHORIZED or denied in agent logs | Missing or incorrect registry credentials | Verify credentials; for ECR check IAM role has AmazonEC2ContainerRegistryReadOnly |
manifest unknown | Tag doesn't exist in the registry | Verify the tag with skopeo inspect docker://registry/repo:tag |
no matching manifest for linux/amd64 | Image isn't published for the requested platform | Check available platforms with docker manifest inspect registry/repo:tag; adjust the platform field |
No OpenSCAP rule findings, only CKV_VSUTRA_IMG_OSCAP_0 | The image's OS is not supported for OS compliance | The image is not assessed, which is not the same as compliant. See Enabling OS compliance scanning for the operating systems that are |
CKV_VSUTRA_IMG_OSCAP_2 and a coverage gap on a RHEL / UBI image | RHEL benchmarks read the RPM database, which the scanner is not permitted to do | No setting changes this in this release (support is planned); until then, assess RHEL-based images another way |
CKV_VSUTRA_IMG_OSCAP_1 says 0 failed | Only rules that can be judged on an image were evaluated | Not a compliance verdict: the rules it lists as set aside must be checked on the host or cluster |
| SBOM is empty or very small | Syft can't detect packages | Likely a distroless or scratch-based image; Syft needs OS package managers or language lock files to discover components |
Findings¶
Container scanner findings appear alongside cloud and codebase findings in the Findings tab.
CVE findings¶
Each CVE found in the image produces a finding:
- Severity - mapped from the CVSS score: Critical (≥ 9.0), High (7.0–8.9), Medium (4.0–6.9), Low (< 4.0)
- Resource - the container image and the affected package, e.g.
ghcr.io/my-org/my-app:v1.2.3 / openssl 3.0.2 - Title -
CVE-2024-XXXXX in <package> - Compliance - CIS Docker Benchmark and NIST SP 800-190 control mappings where applicable
- Remediation - click any CVE finding to see upgrade guidance. VirtualSutra includes Dockerfile-level remediation steps for 49 common packages including imagemagick, openssl, glibc, curl, libwebp, xz-utils, openssh, nginx, Python, Node.js, and more. Use the Has Fix toggle in the findings filter to show only findings with remediation guidance.
Filter by Agent: container-scanner in the Findings sidebar to see only container findings.
License violation findings¶
If you configured a license_blocklist, any package in the SBOM whose license matches the blocklist produces a medium finding:
- Resource -
package:<ecosystem>(e.g.package:npm,package:pip) - Title -
Blocked license <SPDX-ID> in <package>
Useful for enforcing that production images do not ship GPL-licensed or AGPL-licensed dependencies.
OS compliance findings (opt-in)¶
When OS compliance is enabled for an image, each failing benchmark rule that can be judged on an image produces a finding:
- Resource -
container:os - Title - the rule's benchmark title, prefixed
OS compliance:(e.g.OS compliance: Ensure the Default Umask is Set Correctly in /etc/profile) - Evaluated against the benchmark that matches the image's OS
Rules about a running machine (partitions, mounts, kernel settings, services, file ownership) are set aside, not reported. See Enabling OS compliance scanning.
Signature and provenance¶
Every scanned image gets two status columns on the run's Resources tab, next to the image row: Signature and Provenance. The container image PDF report carries the same statuses in a Signature & Provenance table.
You name an image by tag. When the scanner can read the digest the tag points to, it reads it once before the pull and pulls and verifies that digest, so a tag that moves mid-scan cannot make the verdict describe a different image, and the digest is shown on each signing finding. If the digest cannot be read, the image is pulled and verified by tag and no digest is shown.
Name the image by tag
Give the tag, not a digest (sha256:…): a digest in the tag field is refused when you submit the scan.
Signature¶
| Badge | Meaning |
|---|---|
| Signed · Verified | A cosign signature was found and verified against the key or signer identity you configured, with the transparency log checked |
| Signed · Verified (key only, no transparency log) | The signature verified against the public key you configured, and the transparency log was not consulted. Reported under the air-gap deployment profile (DEPLOYMENT_PROFILE=airgap). Drawn as an outlined green badge, not the filled green of Signed · Verified |
| Signed · Verified (SCT not checked) | The signature verified against the signer identity you configured, with the certificate-transparency check turned off by your operator (see A private Fulcio without a certificate-transparency log) |
| Signed · Unverified | A signature is present, but nothing is configured to verify it against - or keyless verification was skipped under an air gap (see below). We can tell you it is signed, not by whom |
| Unsigned | No signature exists for this image |
| Signature Error | A signature exists and none of them verified against the key or identity you configured |
Signed · Verified (key only, no transparency log) is a different state from Signed · Verified, not a variant of it: the key matched, but whether the signature was recorded in a transparency log has not been checked. Hovering that badge reads: "The signature verified against the configured public key. The transparency log was not consulted." The same holds for Signed · Verified (SCT not checked): the signer matched, but whether its certificate was logged has not been checked.
Provenance¶
| Badge | Meaning |
|---|---|
| SLSA L3 | Verified SLSA build provenance. Verified provenance is always level 3: the verifier only accepts provenance from a recognised SLSA level 3 builder |
| Provenance Unverified | A provenance attestation exists but was not verified (you did not give a source_uri) |
| No Provenance | No provenance attestation was found |
| Provenance Error | An attestation exists and failed to verify |
A — (shown as Not checked in the PDF) means the check did not apply to that row (it is not a container image), the check could not be made (see When a check cannot run), or the status is one the console does not recognise. The console never shows an unrecognised status as its raw text.
Provenance is only verified when you tell the scanner what to verify it against with source_uri. Without it the scanner reports only whether an attestation exists, never that it is genuine.
What you can configure per image¶
| Setting | Effect |
|---|---|
enable_signature_check | On by default. Set false to skip signature and provenance checks for this image |
cosign_public_key_ref | Secret reference to a cosign public key. Enables key-based verification, which works even with no internet access |
expected_signer_identity + expected_oidc_issuer | Keyless verification - the CI identity and OIDC issuer you expect the image to have been signed by. Both are needed |
require_slsa_level | 0–3. Leave unset for report-only. Set it and an image whose verified provenance is below the bar is raised as a high-severity finding; an image with no provenance at all is a medium one. Without a source_uri provenance is only detected, never verified, so the level can never be met |
source_uri | The source the image was built from, for example github.com/myorg/myapp. Turns provenance from "is there an attestation" into a verification against that source |
builder_id | Optional: the builder you expect. Only meaningful together with source_uri |
Leave both the key and the identity unset and the scanner still tells you whether a signature exists - it just cannot tell you it is valid.
The findings it produces¶
| Situation | Check | Severity |
|---|---|---|
| Image is unsigned | CKV_VSUTRA_IMG_SIGN_1 | Medium |
| Signature present but nothing configured to verify it | CKV_VSUTRA_IMG_SIGN_2 | Low |
| Signature verification failed | CKV_VSUTRA_IMG_SIGN_3 | High |
| Signature could not be checked | CKV_VSUTRA_IMG_SIGN_4 | Low |
| Signature verified against your public key only, transparency log not consulted (air-gap profile, no Sigstore trusted root) | CKV_VSUTRA_IMG_SIGN_5 | Informational |
| Signature verified keyless with the certificate-transparency check turned off by your operator (a private Fulcio without a CT log) | CKV_VSUTRA_IMG_SIGN_6 | Informational |
| No SLSA provenance | CKV_VSUTRA_IMG_SLSA_1 | Low (Medium if you set require_slsa_level) |
| Provenance below your required level, or verification failed | CKV_VSUTRA_IMG_SLSA_2 | High |
| Provenance could not be checked | CKV_VSUTRA_IMG_SLSA_3 | Low |
| Signature verified, and the provenance is not absent, failed or below your bar | CKV_VSUTRA_IMG_SIGN_0 | Informational - a deliberate good-news finding, so a clean image is visible in the run rather than silently absent |
When a check cannot run¶
If a registry, a transparency log or a trust root cannot be reached, or a verifier cannot run, the image's signature or provenance is reported as not checked. That is a low finding (SIGN_4 / SLSA_3) that says plainly the image has not been checked - not that it is unsigned or invalid.
It is also recorded as a coverage gap for that image. Verification never fails a scan: the image's other findings are kept and the scan task completes, but the run ends partial and names the image and the check that was not made, so a not-checked image is never read as a verified one. Ask your operator to fix the cause (for example an unreachable transparency log) and resume the run.
Air-gapped installs¶
Keyless (Sigstore) verification normally reaches the public Rekor and Fulcio services. Under the air-gap profile the scanner refuses to contact the public Sigstore services at all, so what you can verify depends on what you provide:
| You provide | Result |
|---|---|
A cosign public key (cosign_public_key_ref) | Works fully offline. Without a trusted root the transparency log is not consulted, so a signature that verifies is shown as Signed · Verified (key only, no transparency log), never as Signed · Verified |
| A cosign public key and a Sigstore trusted root | The signature's embedded transparency-log entry is checked against your trusted root, still offline: a signature that passes is Signed · Verified. One that carries no entry, or an entry from a log your trusted root does not hold, falls back to the key alone and is shown as Signed · Verified (key only, no transparency log), as does one whose check could not be completed; where it can tell, the finding's reason says which |
| Keyless settings and a Sigstore trusted root | Keyless verification runs against your keys. A Rekor URL is optional, but without one a signature that does not carry its own transparency-log proof is reported not checked |
| Keyless settings, a PEM root and your own Rekor | Keyless verification is attempted, but many signatures end up not checked, because a PEM root carries no Rekor or transparency-log keys. Prefer a trusted root |
| Keyless settings only | Not attempted: shown as Signed · Unverified. Under the air-gap profile this is not a coverage gap |
source_uri (verified provenance) | Not available. The provenance verifier can only use the public Sigstore services, so provenance is reported not checked. Under the air-gap profile this is not counted as a coverage gap, because nothing you configure can change it |
Your operator supplies the Rekor URL and the trust root when installing (the Helm values global.imageSigning.rekorUrl and global.imageSigning.trustRoot). A trusted root is created with cosign trusted-root create from your own Fulcio, Rekor, certificate-transparency log and timestamp authority.
A private Fulcio without a certificate-transparency log¶
A Fulcio with no certificate-transparency log issues certificates without an SCT, so keyless verification of its signatures fails. Your operator can turn that one check off on a self-hosted or air-gapped install (global.imageSigning.insecureIgnoreSct: true; refused on the hosted SaaS profile, which is expected to verify against public Sigstore); such a signature is then shown as Signed · Verified (SCT not checked), never as Signed · Verified.
Downloading SBOM artifacts¶
After a run completes, CycloneDX JSON SBOMs are available for download:
- Open the completed run
- Click the SBOMs tab (or navigate to Reports → SBOMs)
- Click Download next to the image you want
Each SBOM file is named <image-slug>.cdx.json (e.g. ghcr.io_my-org_my-app_v1.2.3_linux-amd64.cdx.json).
SBOMs can also be fetched via the API:
# List available SBOMs for a run
curl -H "X-API-Key: $API_KEY" https://your-vsutra/api/runs/<run_id>/sbom
# Download a specific SBOM
curl -H "X-API-Key: $API_KEY" \
https://your-vsutra/api/runs/<run_id>/sbom/<filename> \
-o my-app.cdx.json
Enabling OS compliance scanning¶
OS compliance scanning (OpenSCAP) is disabled by default because it requires extracting the full image filesystem. Enable it per image in the inspection.json config:
{
"scope": {
"container_images": [
{
"registry": "ghcr.io",
"repository": "my-org/my-app",
"tag": "v1.2.3",
"enable_openscap": true
}
]
}
}
OS compliance scanning adds roughly 5–10 minutes per image depending on image size.
OpenSCAP itself contacts no host: it never downloads remote content, on any install, so it works the same when air-gapped (the image is still pulled from its registry as usual).
Which images are assessed is decided from the image's own /etc/os-release:
| Image OS | Assessed with |
|---|---|
| Debian 12, Debian 13 | CIS Level 1 Server |
| Ubuntu 22.04, Ubuntu 24.04 | CIS Level 1 Server, or STIG (openscap_profile: stig). A STIG scan always ends with a coverage gap: 3 of its rules need a privilege the scanner does not have and are not evaluated (CKV_VSUTRA_IMG_OSCAP_2) |
| RHEL / UBI 8, 9, 10 | Not evaluated in this release: the image gets CKV_VSUTRA_IMG_OSCAP_2 and the scan reports a coverage gap. Support is planned |
| Any other OS (Alpine, Rocky, AlmaLinux, Amazon Linux, ...) | Not assessed: one informational CKV_VSUTRA_IMG_OSCAP_0 |
No identifiable OS (no /etc/os-release: scratch, static images) | The OpenSCAP sub-scan fails and the scan reports a coverage gap |
An assessed image always gets one summary finding, CKV_VSUTRA_IMG_OSCAP_1, with the number of rules evaluated, failed and set aside, and one finding per failed rule, named by its XCCDF rule id. An image is not a running machine, so rules about file ownership, kernel settings, services and mounts are set aside rather than reported as passed or failed. 0 failed is therefore not a compliance verdict; see OS compliance findings.
Example inspection.json snippet¶
{
"scope": {
"container_images": [
{
"registry": "ghcr.io",
"repository": "my-org/api",
"tag": "v2.1.0",
"platforms": ["linux/amd64"],
"enable_trivy": true,
"enable_openscap": false,
"enable_sbom": true,
"license_blocklist": ["GPL-3.0", "AGPL-3.0"]
},
{
"registry": "123456789.dkr.ecr.us-east-1.amazonaws.com",
"repository": "my-service",
"tag": "latest",
"platforms": ["linux/amd64"],
"enable_trivy": true,
"enable_sbom": true
}
]
}
}
For a full list of ContainerImageConfig fields, see the Container Image Security Scanner agent reference.
Registry access
Make sure the VirtualSutra service account has read access to any private registries before starting the run. See Container Registry Access.