Skip to main content

Container image scanning

Container image scanning answers the what’s vulnerable in this image question — complementing the SBOM’s what’s in this image. Findings come from two sources, and both are surfaced next to the image or commit they describe.

How it works

The daily registry sweep

fremforge scans the container images in your organisation’s package registry itself. Nothing has to be added to your workflows.

  • What is scanned. Every tag of every container image. Each tag is resolved to its manifest digest, and the image is scanned with trivy image for vulnerabilities and leaked secrets, including OS packages and language dependencies in every layer.
  • When. Once a day, after the Trivy vulnerability database is refreshed. A digest already scanned against the same database version is skipped, so a newly published CVE is raised against an image that has not changed.
  • Where findings are kept. Keyed by the image digest (frem.sh/<org>/<image>@sha256:<digest>). Dismissals carry over when an image is rescanned.
  • Limits. For a multi-architecture image, only the linux/amd64 variant is scanned. An image larger than 2 GiB (the sum of its layers as stored in the registry) is not pulled, so it is not scanned.

The same sweep also scans each repository’s default branch (trivy fs and trivy config).

The same image pull is also matched against the OSV list of malicious packages. That check is described in Malicious packages; a pull of an image with an open finding is refused, whatever the block action below. Since 2026-10-11 the same holds for images cached through the dependency proxy (see below).

Images cached through the dependency proxy

Images your organisation pulls through the dependency proxy are scanned the same way as the images you push.

  • When. An image is queued for its first scan when your organisation first caches it, and is normally scanned within 15 minutes. After that it is rescanned whenever the Trivy vulnerability database changes, in the same daily sweep as your own images, for as long as it stays in your cache.
  • What is scanned. The single-platform image your client pulled (for a multi-architecture tag, the variant you pulled), with trivy image for vulnerabilities and leaked secrets.
  • Where findings are kept. Keyed frem.sh/<org>/dependency-proxy/<upstream>/<repository>@sha256:<digest>, for example .../dependency-proxy/docker.io/library/alpine@sha256:…, next to your own images in Admin → Security › Findings › Container images and in the findings API. Dismissals carry over when the image is rescanned.
  • Block action. block_pull applies to proxied images too: a pull through the proxy of an image with an undismissed high or critical finding is refused with 403 DENIED.
  • Malicious packages. The language packages inside a cached image are matched against OSV’s malicious-package advisories, as for pushed images, and a match is a finding on the Malware tab. Since 2026-10-11 a pull of a cached image with an open finding is refused, as for a pushed image; see Dependency proxy.
  • Shared scans. An image is identified by its digest, so when several organisations cache the same image it is scanned once and each organisation gets its own copy of the findings. You only ever see findings for images in your own cache.
  • When an image leaves your cache. Its findings stay while the image is still in use: pulled through the proxy by your organisation, or named in one of your deployments, in the last 30 days (the registry access log and the deployment records say which). After that they are removed at the next sweep. An image pulled again after its findings were removed gets them back at the next sweep, from the scan of its digest.

Images your Helm charts deploy

The images a Helm chart in your repository deploys are scanned for vulnerabilities, and the findings are reported on the repository’s fremforge/image-scan check.

  • What is read. Every chart in the repository, that is every directory with a Chart.yaml. In values.yaml: an image: with a reference (image: nginx:1.25.3), and the split form charts use most (image: with registry, repository, tag and digest under it). A split image with no tag, or tag: "", is the chart’s appVersion, which is what {{ .Values.image.tag | default .Chart.AppVersion }} deploys. In templates/: an image: line with a literal reference. Keys such as initImage or sidecarImage count too; imagePullPolicy and imagePullSecrets do not.
  • What is not read. Templates are not rendered and no chart code runs, so an image assembled by template logic, values files other than values.yaml (an overlay you choose at install time), and dependency charts that are not in the repository are not covered. A reference with no registry is Docker Hub (nginx is docker.io/library/nginx), and one with no tag is latest.
  • How the image is fetched. Through your organisation’s dependency proxy, as a pull of yours would be: your upstream credentials, package policy, malware scan and cache apply. If your organisation has no dependency proxy, fremforge’s own proxy fetches it, anonymously and only from registries that allow that (for example ghcr.io, quay.io, registry.k8s.io). An image your proxy refuses (for example one your policy blocks) is not scanned. The linux/amd64 image is scanned with trivy image, for vulnerabilities.
  • Docker Hub needs your own credential. Docker Hub images are pulled only with your organisation’s own Docker Hub credential, never anonymously and never with a fremforge account. Without one, a Docker Hub image in a chart is not scanned: the fremforge/image-scan status says so (“N Docker Hub image(s) in Helm charts not scanned: add your Docker Hub credential”), and Security › Findings › Container images lists the images with a link to add the credential under Upstream credentials. The images are scanned once you add it, from the repository’s next scan; findings scanned for another organisation are never shown to yours.
  • When. An image is queued the first time a scan names it, and is normally scanned within 15 to 30 minutes; its findings appear on the repository’s next scan. An image that is still named is scanned again every few days, because a tag can move and new advisories are published.
  • How a finding reads. The target is the chart file and the image, for example charts/web/values.yaml: docker.io/library/nginx:1.19.0, with the package, its version and the fixed version. The findings go through the image-scan threshold and dismissals, and a pull request is blocked only by findings it adds.

Newer tags for upstream images

An image you pull from upstream has no Dockerfile of yours to fix, so fremforge looks for a newer tag that fixes its findings. For each cached image with open high or critical findings, at most once a day:

  1. The tag you pulled. The digest is matched to the tag your organisation pulled it by, through the multi-architecture index when there is one.
  2. The candidate. This is the newest tag of the same line, which has the same shape and the same numbers except the last: 3.12.4-slim to 3.12.10-slim, or 16.4 to 16.10. It is never another major or minor version, because that is a migration rather than a fix. A tag without such a line, such as 3.12-slim, 16 or pg17, is checked for a rebuild instead: the same tag, rebuilt upstream since you pulled it.
  3. What it fixes. The candidate is pulled and scanned with the same scanner. Every open finding of yours that the candidate no longer has counts as fixed.

The finding then says one of these:

ShownMeaning
Newer tag <tag> fixes N of this image’s findingsMove to that tag. Open PR with <tag> opens the pull request below
Rebuilt upstream: pulling <tag> again fixes NThe same tag is newer upstream. Pull it again; nothing changes in your repository unless a reference pins a digest
Upstream image, no newer fixed tagNo tag of the same line, and no rebuild, has fewer of these findings
Upstream image: newer tags not checked yetNot checked yet, or the registry is not one fremforge reads tags from (Docker Hub, GHCR, Quay, MCR, ECR Public and registry.k8s.io are)

The same is in the findings API as upstream_update.

The pull request. Open PR with <tag>, or POST /orgs/{org}/dependency-updates/security-fixes/image-ref-requests with image_finding_id, searches your organisation’s repositories for references to the image at the tag you pulled. That covers docker compose files, Kubernetes manifests, Helm values with repository: and tag: on separate lines, and CI image: and container: entries, in any of the names the image goes by:

  • frem.sh/<org>/dependency-proxy/docker.io/library/python:3.12.4-slim
  • docker.io/library/python:3.12.4-slim
  • python:3.12.4-slim

Each repository that has a reference gets one security fix pull request that moves all of its references. A digest pin moves to the candidate’s digest. It has the usual branch, label, limits and merge setting. A Dockerfile FROM is not changed here: that is the base-image fix. When no repository references the image, nothing is opened, and the finding keeps showing the tag that fixes it. Up to 60 repositories and 400 YAML files are read per request.

Scanning from your own workflow

You can also scan in CI and send the results in. The fremforge runner image ships Trivy. This example scans the repo filesystem (trivy fs — dependencies + leaked secrets) and the IaC config (trivy config — Dockerfiles, Compose files), then POSTs the findings:

- name: Scan + push findings
  run: |
    trivy fs --format json --output trivy-fs.json .
    trivy config --format json --output trivy-config.json .
    curl -sS --fail \
      -H "Authorization: Bearer ${{ secrets.FREMFORGE_API_TOKEN }}" \
      -H 'Content-Type: application/json' \
      -d @<(jq -c '{
        image_ref: "'"${GITHUB_REPOSITORY}@${GITHUB_SHA}"'",
        commit_sha: "'"${GITHUB_SHA}"'",
        repo_full_name: "'"${GITHUB_REPOSITORY}"'",
        findings: [.Results[].Vulnerabilities[] | {
          cve_id: .VulnerabilityID,
          severity: (.Severity|ascii_downcase),
          package_name: .PkgName,
          installed_version: .InstalledVersion,
          fixed_version: .FixedVersion,
          title: .Title,
          target: .PkgPath
        }]
      }' trivy-fs.json) \
      "https://frem.sh/_app/api/v1/image-scans/findings"

The PAT needs the findings:write scope.

Policy toggles

Security › Policies › Image policies carries these settings (until 2026-10-15 they sat on Findings › Container images):

  • Enabled — when off, ingest returns 202 {accepted: 0, status: 'disabled'} (still parsed, just dropped). Useful for pre-launch teams that want the scan to run but not gate.
  • Block action — none | warn | block_pull:
    • none — store findings, post no commit status. Build-side detection only. Pulls are not checked.
    • warn — store findings, post commit status success with description noting the count. PR merge unblocked. Pulls are checked but never refused: a pull that block_pull would refuse is served, and recorded as would_block in the registry access log and as the audit event registry.pull.would_block (see Audit log and SIEM).
    • block_pull — store findings, post commit status failure when at least one undismissed finding at or above your block threshold exists. On a pull request, only findings the pull request introduces count (see Pull requests: only new findings block). Branch protection with the required fremforge/image-scan status check then blocks merge. It also blocks pulls: a docker pull (by tag or by digest) of an image with such a finding is refused with 403 DENIED, and the message names the threshold and links to the findings. Dismiss or fix the findings to unblock it. Each refusal is the audit event registry.pull.blocked.
  • Require signed images — when on, the ingest also accepts a signature_status field (signed / unsigned / unknown). Unsigned images (or signed images from an issuer not on the allowlist) get a synthetic critical finding so the same block-pull path enforces the signing policy. See Cosign for the signing side.

Block threshold

The findings that block are those at or above one severity, set per organisation. It is the block_severity of the image_scan entry in your org-wide scan policy, the same setting for merges and for pulls:

block_severityBlocks on
criticalcritical
high (default)high and critical
mediummedium, high and critical
lowevery finding

Set it with PUT /_app/api/v1/orgs/<org>/policy:

curl -X PUT -H "Authorization: Bearer ${FREMFORGE_PAT}" -H 'Content-Type: application/json' \
  -d '{"scan_policy": {"image_scan": {"mode": "enforce", "block_severity": "critical"}}}' \
  "https://frem.sh/_app/api/v1/orgs/acme/policy"

or in configuration as code as scan_policy = { image_scan = { mode = "enforce", block_severity = "critical" } }. null, or no image_scan entry, means high, which is what every organisation had before the threshold could be set. Any other value is refused with 400. The token needs policy:write. The console has no control for it yet.

Only the org-wide entry counts. A per-repository scan-policy override can carry a block_severity, but an image pull is not tied to one repository, so the image gates do not read it.

Pull requests: only new findings block

The fremforge/image-scan check on a pull request fails only on findings the pull request introduces. fremforge compares the pull request’s findings at or above your block threshold with the latest scan of the branch it merges into. A finding that branch already has does not block, so a pull request that changes nothing vulnerable can merge in a repository that has open findings. The status then reads, for example, No new high/critical findings; 7 already on main.

  • A finding is the same when its advisory, its package and its file are the same, the identity a dismissal is carried over by. The same advisory in another file is a new finding.
  • A pull request that adds one new high finding to a repository with many existing ones fails: 1 new high/critical finding(s) …; merge blocked; 30 already on main.
  • A pull request that fixes some findings and adds none passes.
  • Existing findings are not hidden. They are listed, counted towards your SLA and gate image pulls as before.

The branch is scanned after a push to your default branch, at most once an hour, with the newest push winning. A finding that reached the branch since its last scan therefore still counts as the pull request’s until the next scan. It is never the other way round: a new finding is never mistaken for an existing one.

When there is no scan of the branch the pull request merges into, every finding at or above the threshold blocks, as before. For the default branch, that pull request’s scan also asks for a scan of the branch. Once the branch’s first scan is recorded, fremforge scans again, by itself, the open pull requests into it whose check failed meanwhile (at most 20 per repository, the most recently updated), so they turn green, or stay red only on what they add, without a re-run or a new push. A pull request into any other branch always counts every finding.

The image pull gate is unchanged: a pull of an image with an undismissed finding at or above the threshold is refused under block_pull, whether the finding is new or not.

Audit log and SIEM

Every pull decision of the image gates is written to your audit log and from there to your SIEM:

  • registry.pull.blocked — a pull was refused: by block_pull (gate image_scan), by malware matching (image_malware) by a layer that ClamAV found infected or could not scan (registry_scan, or registry_blob for a layer fetched directly by digest), or by the image signature check (signature).
  • registry.pull.would_block — a pull was served that one of those gates would have refused: under your warn, or while a gate runs in observation mode.

There is one event per image digest, gate and decision per hour; the next event for the same digest carries the previous hour’s count.

Where findings surface

Admin → Security › Findings › Container images lists scans by image-ref + commit + PR. Each row shows:

  • Severity counts (critical / high / medium / low)
  • Fixable counts (findings with a non-null fixed_version)
  • Status (success / failure / skipped) + the commit status posted
  • Links to the upstream advisories (CVE, GHSA when present)

What a finding looks like in the DB

Stored fields per finding: cve_id, severity, package, installed_version, fixed_version, target (the in-image file path), and the scan_run_id that ties findings produced by the same run. The full Trivy output is NOT stored — just the per-finding rows, which is the queryable shape.

Retention

Findings follow the per-tenant audit retention policy. Aged-out findings keep their hash in the chain (tamper-evidence) but lose the per-row payload.

See also

  • Malicious packages — the same image pull is also matched against OSV MAL-* advisories; a pull of an image with an open finding is refused whatever the block action above.
  • Cosign — image signing + signed-images policy.
  • SBOMs — package inventory in the image (independent of vulnerability state).
  • OSV recommendations — the source-side complement (vulnerabilities in your dependencies before they ship).
  • API reference — POST /_app/api/v1/image-scans/findings ingest endpoint (tenant-scoped via the runner PAT).