all posts

Sandboxing SBOM and Vulnerability Scans of Untrusted Artifacts

Ajay Kumar··8 min read

Supply-chain security tooling has a blind spot that's easy to miss because it's recursive: the tool that generates an SBOM or scans a container image for known vulnerabilities has to fully unpack that image first — every layer, every archive, every package manifest — before it can tell you anything about what's inside. Unpacking untrusted, attacker-influenced input is precisely the operation that security tooling exists to warn everyone else about. A scanner running against a customer-uploaded image, a third-party base image, or a dependency pulled from a registry you don't control is, for the duration of that unpack step, running the exact category of risky operation its own output is meant to flag.

I'm Ajay, I build PandaStack. This post is about isolating the scanners themselves — SBOM generators like Syft, vulnerability scanners like Trivy or Grype, and anything that has to parse tar layers, zip archives, and package manifests it didn't produce — with the same discipline you'd apply to any other untrusted-input processor, plus the specific things that go wrong when the untrusted input is a full container image.

Before it's a scanner, it's an archive parser

Strip away the vulnerability database and the CVE matching, and a container-image scanner's core job is: decompress layers, walk a filesystem tree, extract package manifests (`package-lock.json`, `requirements.txt`, `go.sum`, RPM and dpkg databases, Java JARs, and dozens more formats), and hash files for comparison against known-vulnerable versions. Every one of those steps is a decompression or parsing routine operating on bytes an adversary controls if the image came from outside your organization — a customer upload in a security-scanning SaaS product, a public base image, a package pulled transitively into a build.

  • Decompression bombs — a small, legitimate-looking layer that expands to gigabytes on extraction is the classic denial-of-service against exactly this kind of pipeline, and it doesn't require a bug, just an unbounded extraction step.
  • Crafted archive and manifest formats — parser bugs in tar, zip, or format-specific manifest readers are a real and recurring CVE category in the scanning tools themselves; you are trusting the scanner's own dependency tree not to have the exact class of bug it's hunting for in yours.
  • Symlink and path-traversal tricks inside a layer — a maliciously constructed layer can attempt to write outside the extraction directory during unpacking, which matters enormously if the unpack step runs with any privilege or shares a filesystem with anything else.
  • Polyglot and format-confusion files — a file that's simultaneously a valid manifest in one parser's eyes and something else entirely in another's can produce different scan results depending on which tool looks at it, which is itself a way to hide a vulnerable dependency from an automated gate.
The threat model here isn't hypothetical malice in every image — most images scanned are perfectly ordinary. It's that a scanning pipeline processing artifacts at scale, from sources you don't fully control, will eventually see the one crafted or simply pathological input, and the pipeline's design should assume that's a when, not an if.

One microVM per artifact, no exceptions for "probably fine" images

The fix is the same shape as sandboxing any other untrusted-input processor: give each scan its own disposable guest with its own filesystem and its own kernel, so a decompression bomb exhausts one VM's resources instead of a shared worker's, and a parser exploit lands in a kernel that's destroyed moments later rather than a host shared with other tenants' scans. This matters most for exactly the products where it's easiest to skip — a CI pipeline scanning your own team's images feels safe enough to run on a shared runner, right up until a base image pulled from a public registry turns out not to be.

# Inside a fresh, disposable microVM: unpack and scan happen together,
# in a guest that only ever holds this one artifact.
set -euo pipefail

IMAGE_TAR="$1"          # the untrusted artifact, already written to /work
OUT_SBOM="/work/sbom.json"
OUT_SCAN="/work/vulns.json"

# Resource ceilings on the unpack step itself: a decompression bomb hits
# these limits and dies here, instead of filling this VM's disk and
# taking neighboring work down with it -- there is no neighboring work.
ulimit -f 8388608   # 8 GiB max file size written during extraction

syft "docker-archive:${IMAGE_TAR}" -o cyclonedx-json="${OUT_SBOM}"
grype "sbom:${OUT_SBOM}" -o json > "${OUT_SCAN}"

echo "sbom and scan written; guest will be destroyed on return"
from pandastack import Sandbox

def scan_untrusted_image(artifact_id: str, image_tar_bytes: bytes) -> dict:
    """Unpack and scan ONE artifact in a guest that never sees another
    customer's image and has no route off the box."""
    sbx = Sandbox.create(
        template="code-interpreter",   # scanners + syft/grype baked in
        ttl_seconds=600,               # a decompression bomb gets reaped, not billed
        metadata={"artifact": artifact_id, "kind": "sbom-scan"},
    )
    try:
        # No legitimate reason for a local scan to phone out mid-unpack;
        # the vuln database is refreshed out-of-band, not per scan.
        sbx.network.set_egress(default="deny", allow=[])

        sbx.filesystem.write("/work/image.tar", image_tar_bytes)
        out = sbx.exec("bash /opt/scan/run.sh /work/image.tar", timeout_seconds=480)
        if out.exit_code != 0:
            # A scan that failed to complete is NOT the same as a clean scan.
            # Surface it as incomplete, never as "no vulnerabilities found."
            raise ScanIncomplete(artifact_id, out.stderr)

        return {
            "artifact": artifact_id,
            "sbom": sbx.filesystem.read("/work/sbom.json"),
            "vulnerabilities": sbx.filesystem.read("/work/vulns.json"),
        }
    finally:
        sbx.kill()  # the raw image, any extracted layers, all gone

An incomplete scan is not a clean scan — and attackers know it

The single most important failure-handling rule in this whole pipeline: if the scan doesn't finish — timeout, crash, resource limit hit, extraction error — that is not the same result as "no vulnerabilities found," and any downstream gate (a CI check, an admission controller, a marketplace approval flow) needs to treat those as distinctly different outcomes. A decompression bomb or a parser crash that takes down the scanner before it reaches the vulnerable package is, functionally, a way to get a vulnerable image past a scan-gated pipeline. If your gate's logic is "block on findings, otherwise allow," a scanner that never reports any findings because it never got that far will pass the image straight through.

  • Distinguish exit states explicitly — a completed scan with zero findings, a completed scan with findings, and an incomplete scan (timeout, crash, resource limit) are three different states, and only the first two should ever be treated as a pass/fail signal.
  • Fail closed on incomplete scans — block the artifact, or route it to manual review, rather than defaulting to allow when the scanner didn't finish. The default that fails open is the one an attacker is incentivized to trigger deliberately.
  • Bound the unpack step independently of the scan itself — a hard timeout and a disk-usage ceiling on extraction specifically, so a bomb is caught in seconds rather than after it's consumed the guest's whole disk.
  • Log resource consumption per scan — CPU, memory, and disk used during extraction are useful signals on their own; an artifact whose unpack step used 50x the resources of a typical image is worth flagging even before the scan result comes back.

The SBOM itself is a sensitive artifact, not just a byproduct

It's worth remembering that the output of this pipeline — the SBOM — is a precise map of exactly which dependencies, at exactly which versions, a piece of software uses, which makes it useful to an attacker looking for a foothold as well as to a defender doing due diligence. If you're operating a scanning service across multiple customers' images, the SBOMs and scan results deserve the same per-tenant handling as the images that produced them: don't let one customer's scan results become readable by another, and don't retain raw images longer than the scan actually requires. This is the same per-tenant discipline that applies to any multi-tenant data pipeline, just with a supply-chain security twist.

Shared scanning worker vs container-per-scan vs microVM-per-scan

  • Decompression-bomb blast radius — Shared worker: one hostile artifact's unpack step can exhaust disk or memory on a host processing many other scans concurrently, stalling or crashing unrelated jobs. Container per scan: cgroup limits contain memory better, but a shared kernel and often a shared disk volume remain. MicroVM per scan: a fixed disk and memory allocation per guest, so a bomb hits its own ceiling and nothing else does.
  • Parser-exploit containment — Shared worker: a bug in the archive or manifest parser runs with whatever privilege the shared process has, against a host handling other tenants' artifacts. Container: process isolation helps, kernel is still shared. MicroVM: a real kernel boundary around code parsing bytes it did not produce, moments after those bytes arrived.
  • Fail-open risk — Shared worker: an overloaded shared process under load from a bomb is more likely to silently drop or truncate work, which can look like a clean scan. Container: similar risk, smaller blast radius. MicroVM: one scan per guest makes "did this scan actually complete" a clean binary question per run, easier to gate on correctly.
  • Multi-tenant SBOM handling — Shared worker: scan outputs for different customers' images can end up adjacent in shared storage or logs if scoping isn't perfect. Container: better, not structural. MicroVM: each scan's inputs and outputs are confined to one guest's lifetime, with nothing to leak between tenants by default.
  • Cost of isolation — Shared worker: cheapest per scan, which is why most CI pipelines start here. MicroVM: on PandaStack a create restores a baked snapshot in about 179ms p50 (~203ms p99); scanning artifacts one-per-guest instead of batching them into a shared worker is a small, boundable cost against the artifact-parsing risk it removes.

The summary

A vulnerability scanner is, underneath the CVE database, an archive and manifest parser processing bytes it doesn't control — which makes it exactly the kind of workload security tooling usually warns you to isolate. Give it a disposable microVM per artifact, deny network egress by default since a local unpack-and-match job has no legitimate reason to phone out, bound the extraction step independently with its own resource ceilings, and — critically — treat an incomplete scan as a blocked artifact rather than a clean one. The scanner's job is to catch what's hiding in someone else's supply chain; it shouldn't be the softest target in yours.

Frequently asked questions

Why does a vulnerability scanner need sandboxing — isn't the scanner the trusted code here?

The scanning logic is trusted, but before it can scan anything it has to unpack the artifact — decompress layers, walk a filesystem, parse package manifests in a dozen formats — and that unpacking step is processing bytes controlled by whoever produced the artifact, which is untrusted input by definition for any image you didn't build yourself. Parser bugs in the extraction step are a real and recurring vulnerability class in scanning tools, so the scanner is trustworthy at the layer that reports CVEs and is simultaneously running the same category of risky operation as any other untrusted-input processor at the layer that unpacks the artifact.

What's the actual attack here — is anyone really weaponizing container images against scanners?

The clearest and most common version isn't a targeted exploit, it's a decompression bomb: a small, ordinary-looking layer that expands to many times its size on extraction, which can exhaust disk or memory on whatever's doing the unpacking without requiring any bug at all. Crafted archive formats that trigger parser bugs are rarer but real, and both are worth designing against if your pipeline scans artifacts from sources you don't fully control, such as customer uploads or public base images.

Why is it dangerous for an incomplete scan to be treated as a clean scan?

Because a scan-gated pipeline (CI check, admission controller, marketplace approval) typically has logic like 'block on findings, otherwise allow.' If a decompression bomb or a parser crash stops the scanner before it reaches a vulnerable package, the scanner reports no findings — not because the image is clean, but because it never got far enough to know. Any downstream gate needs to treat 'scan completed with zero findings' and 'scan did not complete' as distinct outcomes, and fail closed (block or route to review) on the second.

Does the scanning guest need network access to fetch the vulnerability database?

Usually not per scan. The standard pattern is refreshing the vulnerability database on a schedule, baked into the scanning template or synced separately, so an individual scan is a purely local operation: unpack the artifact, match against the already-present database, write results. That means default-deny egress costs nothing functionally and removes network access as a channel entirely for the step handling the untrusted artifact.

Is per-artifact microVM isolation overkill for a small team scanning their own internal images?

For a small, fully internal set of images your team builds and controls end to end, the risk is lower and a shared runner is a reasonable trade-off. The calculus changes the moment any external input enters the picture — a public base image, a customer-supplied artifact, a dependency pulled from outside your registry — because that's exactly the artifact category most likely to eventually be crafted or simply pathological, and it's usually indistinguishable from a normal image until the scanner has already tried to unpack it.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.