OSS License Compliance Scanning, One MicroVM Per Scan
Every company that ships software eventually receives an email beginning "as part of our diligence process." Attached is a spreadsheet. The spreadsheet wants to know, for every piece of open source in your product, which license it is under, what that license obliges you to do, and whether you have done it. It is simultaneously the least glamorous artifact your engineering organization produces and one of the very few that a lawyer will read line by line, in a room, with a deadline attached to it.
The uncomfortable engineering fact underneath that request is that you cannot answer it from a lockfile. To produce a license report that is actually true, something on your infrastructure has to fetch and unpack the real source of every transitive dependency, because that is where the license file, the per-file SPDX headers and the third-party code somebody vendored into a subdirectory in 2019 actually live. And in several major ecosystems, working out what the dependency tree even is means running the package manager's resolver, which is a polite euphemism for executing code that arrived over the internet ten seconds ago.
So the tool whose output goes to your lawyers is, mechanically, a machine that downloads several thousand arbitrary archives from public registries, expands them onto a filesystem, and sometimes runs them. That machine is usually a CI runner. The same CI runner that holds your registry token.
I am Ajay; I build PandaStack, which runs every sandbox as a Firecracker microVM. This post is about the license half of dependency analysis specifically — SPDX expressions, copyleft obligations, what the various scanners actually inspect, and why the accuracy requirement and the safety requirement pull in opposite directions here in a way they do not for vulnerability scanning. If you want the CVE half, or the general argument about install-time code execution, there are separate posts linked at the bottom. This one is about the report you cannot get wrong for reasons that have nothing to do with security.
A lockfile is not a license report
Here is the version almost everyone ships first, and I have written it myself: walk the lockfile, read the license field out of each package's manifest, dump it to CSV, sort by license, send it upstairs. It takes an afternoon, it produces a document that looks exactly like a compliance artifact, and it is wrong in both directions at once.
It is wrong in the permissive direction whenever a package's manifest under-reports what is inside it. A package declares MIT and ships a vendor/ directory containing a BSD-3-Clause single-header library and a zlib-licensed compression routine, each perfectly legitimately, each carrying its own attribution obligation that you have just failed to inherit. A Java uber-JAR shades half a dozen upstream projects into its own namespace and the POM lists exactly one license. A Python package bundles a compiled C library whose source lives somewhere else entirely. None of this is malicious. It is the ordinary, sanctioned way software is assembled, and a metadata-only scan is structurally blind to all of it.
It is wrong in the restrictive direction too, which is the failure mode that wastes engineering weeks. A manifest with the free-text string "GPL" in it gets flagged by a substring match, an escalation happens, and three days later somebody discovers the actual grant is GPL-2.0-only WITH Classpath-exception-2.0 — which is the license the JDK class library ships under and is emphatically not the thing anyone was worried about. A Maven POM inherits a license block from a parent POM that describes the parent project rather than the artifact. An old package uses the deprecated "GPL-2.0+" spelling that your allowlist, keyed on modern identifiers, does not recognise as anything at all.
- Declared metadata — the license field in package.json, the Core Metadata in a Python distribution, the licenses block in a POM, the Cargo.toml field. Fast to read, trivially available, and a claim by the publisher rather than a fact about the code.
- The license files shipped in the artifact — LICENSE, LICENSE-MIT, COPYING, NOTICE. These are the actual grant. npm forces LICENSE files into published tarballs regardless of the files field, which is genuinely helpful; Python wheels historically dropped them, which is why PEP 639's License-File and the .dist-info licenses directory matter, and why an sdist sometimes contains a license the wheel does not.
- Per-file SPDX headers and comment-block license texts — the only evidence available for a single file lifted out of another project and dropped into a utils directory. This is where a project that is 99% Apache-2.0 turns out to have four GPL-licensed files nobody remembers adding.
- Vendored directories and bundled binaries — vendor/, third_party/, shaded classes in a JAR, a prebuilt .so with no source anywhere in the tree. The last one is a question a scanner cannot answer and a human has to.
- The absence of all of the above — a package with no license field, no license file and no headers. This is not a gap in your data. It is a finding, and it is the most restrictive one in the tree.
The license field in a manifest is a claim by the publisher. The license text in the tarball is evidence. A report assembled from claims is a report you cannot defend in the room where it matters.
SPDX identifiers, and why an expression is not a string
The SPDX License List gives every well-known license a short, stable identifier — MIT, Apache-2.0, BSD-3-Clause, GPL-2.0-only, MPL-2.0 — and that standardisation is the single most useful thing to happen to this field. It means two tools can agree on what they found without arguing about whether "BSD" means the two-clause or the three-clause version, and it means your policy can be written in the same vocabulary your scanner emits.
What trips people up is that SPDX does not stop at identifiers. It defines a small expression grammar on top of them, and the operators are load-bearing. Once you understand that a license expression is a tiny algebra rather than an enum value, a whole category of compliance bug stops happening to you.
- MIT — a single license, a single set of obligations. Most of your tree looks like this and it is not the interesting part.
- Apache-2.0 OR MIT — dual licensed. You elect one arm and comply with that one; the choice is yours. This is the near-universal convention in the Rust ecosystem and it appears constantly elsewhere. A scanner cannot elect for you, because the election is a business decision, and if nobody records which arm you took then your report has an unanswered question in it.
- GPL-2.0-only AND MIT — both arms bind simultaneously. This almost always means the artifact physically contains code under each license, and the practical reading is that you must satisfy the union of the obligations. Any denied arm denies the whole expression.
- GPL-2.0-only WITH Classpath-exception-2.0 — an exception attached to a license, which materially changes what it requires. This is the expression your substring-matching denylist gets wrong, loudly, on a Tuesday.
- LGPL-2.1-or-later — the -or-later suffix is a permission the copyright holder granted you, and it is a different identifier from -only. The deprecated "LGPL-2.1+" spelling means the same thing and will not match a modern list, so normalise before you compare.
- NOASSERTION — SPDX's honest way of saying "we could not determine this." It is not a synonym for permissive, it is not the same as NONE, and treating it as an empty cell is the most expensive rounding error available in this field.
Why a false negative here is a legal problem, not a bug
This is the asymmetry that makes license scanning a genuinely different engineering problem from vulnerability scanning, and it is worth sitting with for a moment. When a vulnerability scanner misses something, you have unmitigated risk: a bad thing might happen later, and when you find out, you patch and move on. When a license scanner misses something, the bad thing has already happened and has been happening continuously since the release that shipped the dependency. There is no patch that undoes a distribution. The remedy is a conversation, and sometimes a re-release, and occasionally a negotiation.
The obligations themselves depend far more on how your software leaves the building than most engineers expect. Reciprocal licenses in the GPL family condition their strongest requirements on conveying the software to someone else, which is why a company running GPL-licensed code purely internally is in a very different position from one shipping it in a binary. The AGPL closes that gap deliberately for network-interactive use, which is precisely why it is the license that a SaaS business needs to care about and a desktop-app business worries about less. The LGPL permits linking under conditions that turn on how you link. MPL-2.0's reciprocity is file-scoped rather than project-scoped. EPL-2.0 has its own shape again. The result is that the same dependency can be entirely fine in your internal admin tool and a serious problem in the binary you ship to customers, and a policy that does not encode your distribution mode is answering a question nobody asked.
It is equally worth saying the unglamorous part out loud: permissive licenses are not obligation-free. MIT and BSD require you to reproduce the copyright notice and the license text. Apache-2.0 adds NOTICE file propagation and some attribution mechanics. The most common real-world compliance failure I have seen is not a dramatic copyleft violation — it is a mobile app or a desktop binary shipping with no attribution screen at all while depending on nine hundred MIT packages. It is cheap to fix, it is entirely automatable from a good SPDX document, and it is embarrassing to have pointed out to you by an acquirer's counsel.
And then there is the package with no license at all. No field, no file, no header. Copyright's default is not "free to use"; it is all rights reserved, and permission has to come from somewhere. A metadata-only scanner reports that package as an empty cell, the empty cell gets sorted to the bottom of the spreadsheet, and the most restrictive item in your entire dependency tree is the one nobody looked at.
What each scanner actually inspects
The tools in this space are not interchangeable and they are not competing implementations of the same idea. They inspect different things, at different depths, and answer different questions. Picking the wrong one produces a report that is confidently wrong rather than usefully incomplete, which is worse.
- ScanCode Toolkit — rule-based full-text license detection across every file it can read, plus copyright statement extraction and package manifest parsing, with match confidence scores and the ability to emit SPDX and CycloneDX. It is slow, thorough and noisy, and it is the one you want when the question is "what is actually in these files," because it is the one that reads the files.
- FOSSology — a server with a human review workflow rather than a CLI you bolt into CI. Its scanning agents come at the problem from different angles (heuristic and regex matching, full-text comparison against known license texts, dedicated SPDX-identifier detection, copyright extraction), but its real feature is the clearing interface and decision reuse: a human conclusion recorded once and automatically applied to the next release. That human-in-the-loop step is exactly the thing a CI-only pipeline is missing.
- licensee — the Ruby library behind the license label GitHub shows on a repository. It compares the project's own top-level license file against a corpus and reports a match above a confidence threshold. It answers "what is this project's license," not "what is in this project's dependency tree," and it is routinely pressed into service as a compliance tool by people who have not noticed the difference.
- go-licenses — walks the Go import graph, locates the license file in each module's directory and classifies it, with a notion of how restrictive each classification is. It needs a populated module cache, so it has a genuine fetch step. Go is the pleasant ecosystem here: module zips are content-addressed and there are no install hooks, so fetching is downloading and expanding archives and nothing more.
- Syft, CycloneDX generators, pip-licenses, license-checker — fast metadata readers. They report the declared field, which is exactly the right tool when you want a quick inventory and exactly the wrong one when you want a defensible report. Note the trap in pip-licenses in particular: it reads the metadata of installed distributions, which means the environment has to be installed first — the safe-looking tool has an arbitrary-code-execution step hiding in its prerequisites.
- REUSE — checks that every file in a repository carries an SPDX header and a matching license text. A hygiene tool for your own project, not a discovery tool for someone else's, and a good thing to adopt on the code you publish.
Run two of these over the same tree and they will disagree, which surprises people and should not. The disagreement is itself information: a package where the declared license and the concluded license diverge is a package that deserves thirty seconds of human attention, and a good pipeline surfaces that divergence as the headline rather than burying it. SPDX has fields for exactly this distinction — the declared license and the concluded license are separate properties of a package, and the whole discipline lives in the gap between them.
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "checkout-service-1.42.0",
"creationInfo": {
"created": "2026-09-06T10:14:02Z",
"creators": ["Tool: scancode-toolkit", "Organization: Example Ltd"]
},
"packages": [
{
"SPDXID": "SPDXRef-pkg-boring",
"name": "ms",
"versionInfo": "2.1.3",
"licenseDeclared": "MIT",
"licenseConcluded": "MIT",
"comment": "The common case. Manifest and files agree. 90% of the tree."
},
{
"SPDXID": "SPDXRef-pkg-vendored",
"name": "fast-image-utils",
"versionInfo": "3.2.0",
"licenseDeclared": "MIT",
"licenseConcluded": "MIT AND BSD-3-Clause AND Zlib",
"licenseComments": "Manifest says MIT. vendor/stb/ ships stb_image.h and vendor/miniz/ ships miniz.c, each with its own license header. AND, not OR: every arm binds, and each one carries its own notice obligation you are now failing to meet.",
"comment": "This is the false negative that a metadata-only scan produces, and it is the reason the report has to read files."
},
{
"SPDXID": "SPDXRef-pkg-dual",
"name": "hyper-parser",
"versionInfo": "0.9.1",
"licenseDeclared": "NOASSERTION",
"licenseConcluded": "Apache-2.0 OR MIT",
"licenseComments": "No license field in the manifest, but LICENSE-APACHE and LICENSE-MIT are both present and the README offers either. OR means WE elect one. The scanner cannot elect; recording which arm we took is a decision, and the decision belongs in this document.",
"comment": "Election recorded separately in the policy evaluation, not inferred."
},
{
"SPDXID": "SPDXRef-pkg-nothing",
"name": "internal-tooling-shim",
"versionInfo": "1.0.4",
"licenseDeclared": "ISC",
"licenseConcluded": "NOASSERTION",
"licenseComments": "The manifest claims ISC. There is no LICENSE file, no SPDX header, and no copyright statement anywhere in the tarball. NOASSERTION is not a synonym for permissive: absent a grant, the default under copyright is all rights reserved.",
"comment": "Blank in a spreadsheet reads as 'fine'. It is the opposite."
}
]
}Four packages, four different stories, and only the first one is the story that a metadata scan can tell. The second is a false negative that a spreadsheet would report as MIT. The third has an unanswered question in it that only a human can close. The fourth is the one that gets sorted to the bottom and forgotten.
Turning the report into a gate that fails for the right reasons
A report nobody enforces is a very expensive log line. But a gate that fails for the wrong reason is worse than no gate, because the team learns to route around it within a fortnight and then you have neither enforcement nor trust. The policy has to be written in the same vocabulary the scanner emits, it has to evaluate expressions rather than match strings, and it has to know how your software is distributed.
# license-policy.yaml
#
# Evaluated against the CONCLUDED expression of every package -- never
# against the declared metadata field, which is a claim by the publisher
# rather than evidence about the code.
version: 1
# Obligations trigger on how the artifact leaves the building, so the
# policy is per distribution mode rather than global. The same dependency
# can be entirely fine in an internal tool and a serious problem in a
# binary you ship to customers.
distribution: saas # saas | shipped-binary | customer-onprem | internal
allow:
- MIT
- Apache-2.0
- BSD-2-Clause
- BSD-3-Clause
- ISC
- Zlib
# "Allow" still means "generate the notice file". Permissive is not the
# same as obligation-free, and the most common real-world breach in the
# wild is a missing attribution screen, not a GPL violation.
review: # not blocked; a human decides and the decision sticks
- MPL-2.0 # file-level reciprocity: scope is the modified file
- EPL-2.0
- LGPL-2.1-only
- LGPL-3.0-or-later # relinking obligations depend on HOW you link
- CDDL-1.0
deny:
- AGPL-3.0-only
- AGPL-3.0-or-later # with distribution: saas, this is the live one
- SSPL-1.0 # not OSI-approved; treat it as proprietary
- NOASSERTION # no detectable grant == all rights reserved
# Expression semantics, because "does the string contain GPL" is not a test.
expressions:
# "Apache-2.0 OR MIT" -- we may elect either arm. Record the election.
or: elect-first-allowed-and-record
# "GPL-2.0-only AND MIT" -- every arm binds. Any denied arm denies.
and: every-arm-must-pass
# "GPL-2.0-only WITH Classpath-exception-2.0" is NOT GPL-2.0-only.
# Resolve the exception before matching or you will block the JDK and
# spend an afternoon explaining why the build went red.
with: resolve-exception-before-matching
# "GPL-2.0+" is the deprecated spelling of "GPL-2.0-or-later" and still
# turns up in a decade of old metadata. Normalise, do not string-match.
normalise_deprecated_ids: true
fail_on:
- denied
- incomplete_scan # a scan that crashed is not a scan that found nothing
Two details in there earn their keep. The first is that the policy is evaluated against the concluded expression, never the declared field — which means the gate cannot be defeated by a manifest that says something convenient. The second is the review tier. A binary allow/deny list forces you to make a legal judgement at policy-authoring time for every license in existence, which nobody has the standing to do; a review tier lets the pipeline route the genuinely ambiguous cases to a human once, and reuse that decision until the dependency's version changes.
The part nobody puts in the architecture diagram
So: to get the concluded license you have to read the files, and to read the files you have to have them. For a mid-sized Node application that is several thousand tarballs and comfortably several hundred thousand individual files, fetched from public registries, expanded onto a filesystem, and then walked by a text-matching engine. Every one of those steps is a piece of trusted code processing bytes that an adversary could have chosen, and the population of possible adversaries is "anyone who can publish a package."
- Path traversal in archive entries — the zip-slip family. An entry named ../../etc/cron.d/anything, written by an extractor that concatenates paths without normalising them. This is a tar problem as much as a zip problem and it has been rediscovered in a new library every year for a decade.
- Symlink escape — the more elegant version. One archive entry creates a symlink pointing at /, a later entry writes through it, and no single entry ever contains a suspicious-looking path. Reject symlinks that resolve outside the extraction root, and check after resolution rather than before.
- Decompression bombs — no bug required, just a small archive that expands enormously. On a shared scanning host this is a denial of service against every other tenant's scan; on a machine of its own it is a job that hits its own ceiling and dies.
- Inode exhaustion — the hazard specific to this workload. License scanning is small-file-bound rather than CPU-bound, and an archive containing a million eight-byte files will find your filesystem's inode limit long before it fills your disk. Budget files, not just bytes.
- Tar features nobody wanted — hardlinks to files outside the extraction root, device nodes, setuid bits preserved on extraction. Extract as an unprivileged user with --no-same-owner and --no-same-permissions, and never as root because it was convenient.
- Filesystem-level collisions — two entries differing only in case on a case-insensitive filesystem, paths over the length limit, Unicode normalisation differences that make two distinct entries land on the same inode. The result is a scan that silently reads the wrong file's contents, which is a false result rather than a crash and therefore much harder to notice.
- The detection engine itself — license matching is pattern matching over untrusted text at scale. A pathological single-line file, a binary blob that looks textual enough to attempt, a regex with catastrophic backtracking; the failure is usually a hang rather than an exploit, but a hung scan that gets marked as complete is a false negative with a legal consequence.
#!/usr/bin/env bash
# Runs INSIDE a disposable guest that holds no credentials and can reach
# nothing but the registries on the egress allowlist. Every line below
# assumes the worst about the bytes it is going to expand, because the
# whole point of the exercise is to expand bytes strangers published.
set -euo pipefail
WORK=/work
SRC="$WORK/src" # the repository under audit, written in by the caller
VENDOR="$WORK/vendor" # where every dependency's REAL source lands
OUT="$WORK/out"
mkdir -p "$VENDOR" "$OUT"
# 1. Budgets first. A license scan has an unusual resource profile: it is
# not CPU-bound like a build, it is small-file-bound. Thousands of
# tarballs expanding into hundreds of thousands of tiny files will find
# your inode limit long before it finds your disk.
ulimit -f 4194304 # 4 GiB ceiling on any single extracted file
ulimit -u 512 # a fork bomb in an install hook is this VM's problem
# 2. Resolve. This step runs third-party code by design (see the post). We
# deliberately do NOT pass --ignore-scripts: a partially resolved tree
# produces a report that is wrong in the direction that costs money.
cd "$SRC"
npm ci --prefer-offline --no-audit --no-fund
# 3. Enumerate what was actually resolved, straight from the lockfile.
# npm lockfileVersion 2/3 records the tarball URL and an integrity hash
# for every package in the tree, including the transitive ones nobody
# on your team has ever heard of.
node -e '
const lock = require("./package-lock.json");
for (const [path, meta] of Object.entries(lock.packages || {})) {
if (!path || !meta.resolved) continue;
const name = path.split("node_modules/").pop();
console.log([name, meta.version, meta.resolved, meta.integrity].join(" "));
}
' > "$WORK/tarballs.txt"
# 4. Fetch and unpack each one. The LICENSE file, the per-file SPDX headers
# and any vendored third-party directory only exist inside the tarball --
# none of it is in the lockfile, and some of it contradicts the manifest.
while read -r name version url integrity; do
dest="$VENDOR/${name}/${version}"
mkdir -p "$dest"
curl -fsSL --max-time 60 --max-filesize 268435456 "$url" -o "$WORK/pkg.tgz"
# Verify the bytes match what the lockfile committed to BEFORE unpacking
# them. A mismatch is a hard stop, not a warning you page someone about
# on Monday.
calc="sha512-$(openssl dgst -sha512 -binary "$WORK/pkg.tgz" | openssl base64 -A)"
if [ -n "$integrity" ] && [ "$calc" != "$integrity" ]; then
echo "REJECT ${name}@${version}: integrity mismatch" >&2
exit 1
fi
# Refuse absolute paths and traversal before a single byte is written.
# Zip-slip is a tar problem too, and "the archive contained a symlink to
# / and the next entry wrote through it" is the classic escape.
if tar -tzf "$WORK/pkg.tgz" | grep -qE '^/|(^|/)\.\.(/|$)'; then
echo "REJECT ${name}@${version}: path traversal in archive" >&2
exit 1
fi
tar -xzf "$WORK/pkg.tgz" -C "$dest" \
--no-same-owner --no-same-permissions --no-overwrite-dir
rm -f "$WORK/pkg.tgz"
done < "$WORK/tarballs.txt"
# 5. Scan the SOURCE, not the metadata. ScanCode does full-text license
# matching and copyright extraction over every file it can read, which
# is the only way to see the BSD-licensed vendor/ directory inside a
# package whose manifest cheerfully says "MIT".
scancode --license --copyright --package --info \
--license-text --strip-root \
--processes 4 --timeout 120 \
--spdx-tv "$OUT/scan.spdx" \
--json-pp "$OUT/scan.json" \
"$VENDOR" "$SRC"
# Check flag spellings against `scancode --help`; the tool moves and this
# blog post does not.
echo "wrote $OUT/scan.spdx -- this guest is now disposable"
The important habits in that script are ordering ones. The integrity hash is verified before anything is unpacked, not after, because a mismatch you detect post-extraction is a mismatch you detect from inside the blast radius. The archive listing is inspected for traversal before a single byte is written. Extraction happens as an unprivileged user with ownership and permission preservation explicitly disabled. And the resource ceilings are set at the top, before any third-party code has had a chance to run, rather than being applied to a process that has already forked.
The resolve step is remote code execution, by design
There is a step above all of that which deserves naming plainly, even though it is the subject of its own post. In npm, installing a package can run preinstall, install, postinstall and prepare scripts. In Python, a source distribution's setup.py is a program, and a PEP 517 build backend is a program you invoke. In Gradle, the build file is a program in the fullest sense. These are not vulnerabilities and there is nothing to patch — they are documented, supported features working exactly as designed, and a great deal of legitimate software genuinely needs them.
The obvious reflex is to reach for --ignore-scripts and declare the problem solved. In a security audit that is a defensible trade with a known cost. In a license scan it is a worse trade than people realise, and the reason is specific to this workload: an unresolved or partially resolved tree produces a report that is incomplete, and incompleteness in a license report is exactly the false negative that has legal consequences. Optional and platform-specific dependencies do not appear. Native modules that fetch or generate vendored sources at build time leave you scanning a directory that does not yet contain the third-party code you are obliged to find. You end up attesting to a dependency tree you did not actually resolve, which is a worse position than not producing the report at all, because now there is a document with your name on it.
In a vulnerability scan, skipping install scripts costs you coverage. In a license scan, it costs you the report's defensibility — you are attesting to a tree you never fully resolved, in a document written for people who will assume you did.
The reframe that actually resolves the tension is the same one that resolves it everywhere else: the question is not how to stop dependency code from running, because running it is how you find out what the tree is. The question is what is standing next to that code when it runs. That is a question about the machine, and it has a machine-shaped answer.
One microVM per scan, holding nothing
The pattern is a disposable Firecracker microVM per scan. Inside it: the source under audit, the scanners, and a toolchain. Not inside it: your registry publish token, your cloud credentials, your signing keys, a route to your artifact store, a mounted Docker socket, or the residue of the last repository you scanned. The isolation boundary is the CPU's virtualization extensions under KVM rather than a namespace over a shared kernel, so the guest gets its own kernel to be compromised, and it is deleted a few minutes later.
The reason this is affordable per scan rather than per batch is that creating a guest is a snapshot restore, not a boot. You bake a template once with ScanCode, its license rule index, the language toolchains and the package managers already installed, snapshot it warm, and every create after that restores that frozen machine — roughly 179ms at p50 and 203ms at p99 on PandaStack. The genuine cold boot, around three seconds, happens once at bake time. Memory is copy-on-write and the rootfs is a reflink clone, so the thousandth scan does not copy gigabytes into existence. A TTL set at create time means a resolve that wedges on a hostile package reaps itself rather than becoming a support ticket.
Egress: an allowlist, not a default-deny
Here is where license scanning genuinely differs from the vulnerability-scanning case, and it is worth being precise about it. A vulnerability scan can run fully offline: refresh the CVE database out of band, bake it into the template, and give the scanning guest no network at all, because unpacking and matching is a purely local operation. A license scan cannot do that. Fetching the dependency source is not a side effect of the workload; it is the workload. You are always going to be talking to registries.
So the posture is default-deny with an explicit allowlist of registry hosts, plus an explicit block on the cloud metadata endpoint for the belt-and-braces reason that a link-local address is a well-known destination for anything that wants credentials. Each PandaStack sandbox gets its own Linux network namespace and TAP device from a pool of 16,384 pre-allocated /30 subnets, so this is a real per-guest network segment where policy is enforced rather than a shared bridge with hopeful rules on it.
There is a bonus in this that is easy to miss: the destination log becomes evidence. If the guest's only permitted destinations were five named registries, then "where did the code in this report come from" has an answer backed by network policy rather than by trust. And a package attempting to reach anywhere else during resolution is now a signal you captured rather than an event you never saw.
Fanning out across a portfolio
Compliance work is rarely one repository. It is every repository, on a schedule, ahead of a release or a funding round. The naive shape provisions a machine per repo and waits; the better shape warms one guest, lets it build the scanner's license rule index, and forks it per scan. A same-host fork lands in 400–750ms and shares memory and disk copy-on-write, so each scan gets a private, disposable copy of the warm environment; a cross-host fork is 1.2–3.5s when you are deliberately spreading load. Every fork is still a full isolation boundary with its own kernel and its own network namespace, so one repository's hostile postinstall cannot observe the next repository's scan. When the run finishes there is nothing idle left to pay for.
import json
from pandastack import Sandbox
# Registries only. A license scan has exactly one legitimate reason to touch
# the network -- fetching the source it is about to read -- and everything it
# fetches is named in a lockfile you can review. Anything contacting a host
# outside this list is, at best, a package doing something interesting.
REGISTRIES = [
"registry.npmjs.org:443",
"codeload.github.com:443", # some deps resolve to git tarballs
"pypi.org:443",
"files.pythonhosted.org:443",
"proxy.golang.org:443",
]
def scan_inside(sbx, repo: str, commit: str, source_tarball: bytes) -> dict:
"""Everything that happens INSIDE one guest. Shared by the single-scan
and fan-out paths so the two cannot quietly drift apart."""
sbx.network.set_egress(
default="deny",
allow=REGISTRIES,
block=["169.254.169.254"], # no cloud metadata endpoint, ever
)
sbx.filesystem.write("/work/src.tar.gz", source_tarball)
sbx.exec(
"mkdir -p /work/src && "
"tar -xzf /work/src.tar.gz -C /work/src --strip-components=1"
)
# Resolve, fetch, unpack, scan. This is the step where third-party
# install hooks execute, and it is also the only way to see the license
# text those packages ship. Both facts are true at the same time, which
# is the entire argument for doing it in a machine you can throw away.
out = sbx.exec("bash /opt/licensescan/run.sh", timeout_seconds=1500)
if out.exit_code != 0:
# An incomplete scan is not a clean bill of health. Never let it
# collapse into "no findings" on the way to a compliance gate.
raise ScanIncomplete(repo, commit, out.stderr[-4000:])
report = json.loads(sbx.filesystem.read("/work/out/scan.json"))
return {
"repo": repo,
"commit": commit,
"spdx": sbx.filesystem.read("/work/out/scan.spdx"),
"packages_scanned": len(report.get("packages", [])),
# Evidence about WHERE it ran, recorded next to the result, so that
# "could that machine have touched anything of ours?" is a property
# of the run rather than a paragraph in a policy document.
"environment": {
"isolation": "firecracker-microvm",
"sandbox_id": sbx.id,
"egress_default": "deny",
"egress_allow": REGISTRIES,
"credentials_present": [],
},
}
def license_scan(repo: str, commit: str, source_tarball: bytes) -> dict:
"""One scan, one machine.
What the guest holds: the source under audit and the scanners. What it
does not hold: registry publish tokens, cloud credentials, signing keys,
a route to the artifact store, or last week's scan. That absence is not
hygiene -- it is the evidence the report rests on.
"""
sbx = Sandbox.create(
template="base", # scancode + toolchains baked into the snapshot
ttl_seconds=1800, # backstop: a wedged resolve reaps itself
metadata={"kind": "license-scan", "repo": repo, "commit": commit},
)
try:
return scan_inside(sbx, repo, commit, source_tarball)
finally:
# The resolved tree, several thousand unpacked tarballs, whatever a
# postinstall hook decided to plant, and the kernel it planted it on:
# all destroyed together, with no cleanup script to get wrong.
sbx.kill()
def scan_portfolio(repos: list[tuple[str, str, bytes]]) -> list[dict]:
"""Fan out across every repository you own. Fork a guest that has already
warmed the scanner's license rule index instead of provisioning each one
from scratch: a same-host fork lands in 400-750ms and shares memory and
disk copy-on-write, so every scan gets a private, disposable copy of the
warm environment and none of them can observe each other."""
warm = Sandbox.create(template="base", ttl_seconds=7200)
try:
warm.exec("scancode --reindex-licenses") # pay this once, not N times
results = []
for repo, commit, tarball in repos:
child = warm.fork() # its own kernel, its own network namespace
try:
results.append(scan_inside(child, repo, commit, tarball))
finally:
child.kill()
return results
finally:
warm.kill()
The evidence, not just the report
When somebody serious reviews this work — an acquirer's counsel, an auditor, a customer's security team with a long questionnaire — the question is not only what the report says. It is whether the report can be trusted, which is a question about how it was produced. The good news is that if you built the pipeline this way, the answer is a set of facts about the machine rather than a set of assurances about your process.
- The input digest — the commit SHA of the source and the SHA-256 of the lockfile. "We scanned main" is not a statement anybody can verify six months later; a pair of hashes is.
- The template generation — which baked snapshot the guest was restored from, which pins the scanner version, the license rule index and the toolchains in a single restorable identifier. "ubuntu-latest" is not a version.
- Tool and rule-database versions — license detection improves, and a package that scanned clean under an older rule set may not under a newer one. The version is part of the result.
- The egress policy and the destinations actually contacted — what the machine was allowed to reach and what it did reach. This is the provenance of the source you scanned.
- Credentials present: none, and provably so — not because a policy said so but because the sandbox was created without them and the run's metadata records an empty set.
- Start time, end time and exit status — so an incomplete scan is a distinguishable state rather than a report with fewer rows in it. This is the single easiest thing to get wrong and the one with the worst consequences.
The structural claim you get for free is the one worth writing down: the machine that expanded several thousand strangers' archives had no path to the artifact store, the signing keys or the publish token, and it ceased to exist when the scan finished. That is a sentence you can put in an answer to an auditor, and it is true because of how the machine was created rather than because somebody remembered to configure it that way.
Four places to run a license scan
Side by side. Everything about containers and third-party services below is a description of general architectural properties and common defaults rather than a benchmark of any particular product — verify specifics against the relevant documentation. The only measured numbers here are PandaStack's.
- On the CI host — What expands the tarballs: the same user, on the same filesystem, as every other job on that runner. Credential proximity: maximum; the resolve step runs next to your registry token, deploy keys and cloud role, and does not need to escape anything to read them. Egress control: usually whatever the runner's network allows, which is usually everything. Evidence quality: weak; "it ran on our CI" pins nothing. Best for: a first pass on your own code when you are still working out whether you have a problem.
- In a container on a shared runner — What expands the tarballs: a namespaced process with a clean filesystem, which genuinely helps with the mess and the leftovers. Credential proximity: unchanged unless you did the work; a token in the environment is a token the install hook reads, and no escape is required. Egress control: possible with per-job network policy, frequently not configured. Isolation: cgroups and namespaces over a shared kernel, so a parser exploit in the extraction step lands on the kernel every other job is using. Evidence quality: better — you can pin an image digest. Best for: internal work where the threat model is mess rather than adversaries.
- A vendor SaaS scanner — What expands the tarballs: their infrastructure, not yours, which is a real and legitimate reduction in your operational risk. The trade is different in kind rather than smaller: you are uploading your proprietary source, or granting a repository integration, to a third party, and their scan results and retention policy are now part of your compliance surface. Coverage of private or internal registries usually needs deliberate work. Evidence quality: often excellent, and their review workflows are frequently better than anything you will build. Best for: teams who want the human review workflow and are comfortable with the source-disclosure trade. Verify their current handling against their own documentation.
- A microVM per scan — What expands the tarballs: a guest with its own kernel, created for this one repository and deleted afterwards. Credential proximity: none, structurally; the machine is created without secrets, so there is nothing for a resolve step to find. Egress control: a per-guest network namespace with a registry allowlist and a logged destination list that doubles as provenance. Isolation: hardware-enforced under KVM. Latency: about 179ms p50 and 203ms p99 to create, because create is a snapshot restore; 400–750ms for a same-host fork when fanning out across a portfolio. Evidence quality: strong — template generation, input digests, egress policy and an empty credential set, all recorded per run. Best for: proprietary source you cannot upload, portfolios you scan repeatedly, and any report that will be read by somebody with a legal budget.
What this does not give you
A machine-generated license report is an input to legal review, not a verdict, and any vendor — including me — who implies otherwise is selling you a liability. Some specific limits worth stating before somebody discovers them the hard way.
Scanners disagree, and disagreement is not a bug to be tuned away. Full-text matching runs on confidence thresholds, and a threshold high enough to eliminate false positives will miss modified license texts, while one low enough to catch every variant will flag a paragraph of the GPL quoted in a README as a GPL grant. The right response is to surface the divergence between declared and concluded, not to pick a threshold that makes the numbers look tidy.
A scanner cannot make elections or judgement calls. Which arm of Apache-2.0 OR MIT you choose is a decision. Whether your linking arrangement makes something a derivative work is a legal question with real disagreement among people who do this for a living, and no tool that has never seen your build graph is going to settle it. Whether a prebuilt binary blob with no accompanying source is acceptable is a business decision. What the tooling can do — and this is genuinely valuable — is put every one of those questions in front of the right person exactly once, with the evidence attached, instead of never.
And the isolation is not a substitute for the rest of your supply-chain posture. A microVM per scan means a hostile package cannot reach your credentials or your neighbours during the scan. It says nothing about whether you should be depending on that package, whether the version you resolved is the version you reviewed, or whether the thing you eventually ship was built from the same tree you scanned. Those are separate problems with separate posts.
The summary
License compliance is an obligation you meet by reading files, and the files belong to strangers. There is no version of this that does not involve fetching several thousand archives from public registries and expanding them somewhere, and in most ecosystems there is no version that does not involve running the package manager's resolver, which runs their code. The accuracy requirement and the safety requirement pull against each other, and the usual resolution — pass --ignore-scripts, read the metadata field, ship the CSV — resolves it by quietly sacrificing the accuracy, which is the half a lawyer is going to read.
Put the whole operation in a machine you are willing to lose. One microVM per scan, created from a baked snapshot in about a fifth of a second, holding no credentials, allowed to reach the registries and nothing else, emitting an SPDX or CycloneDX document through the API and then ceasing to exist. Evaluate policy against the concluded expression rather than the declared field, keep a review tier so ambiguity reaches a human once instead of never, and record the environment alongside the result so the report's provenance is a property of the run. Then hand the whole thing to the people whose job it actually is — with the honest caveat attached, because the report is an input to their judgement and never a replacement for it.
Frequently asked questions
What is the difference between a declared license and a concluded license?
The declared license is what the package says about itself — the license field in package.json, the metadata in a Python distribution, the licenses block in a POM. It is a claim by the publisher, it takes milliseconds to read, and it is what almost every fast tool reports. The concluded license is what an analysis of the actual files supports: the LICENSE files shipped in the archive, per-file SPDX headers, license texts in comment blocks, and any vendored third-party directory carrying its own terms. SPDX models these as separate properties precisely because they diverge, and the divergence is the interesting part. A package declaring MIT while shipping a BSD-licensed single-header library in vendor/ has a concluded expression of MIT AND BSD-3-Clause, and the second arm carries an attribution obligation you inherit whether or not your tooling noticed it. Build your policy gate on the concluded expression; use the declared field only as a fast pre-filter and as one half of the comparison worth flagging.
Can I just read the license field from package.json or PyPI metadata?
For a rough inventory, yes, and it is a perfectly reasonable first pass. For anything that will be read by a lawyer, no, because it is wrong in both directions. It under-reports whenever a package legitimately bundles code under other terms — vendored C libraries, shaded classes in an uber-JAR, a handful of files lifted from another project — which is ordinary, sanctioned practice rather than anything sinister. It over-reports whenever free-text metadata is imprecise: the string 'GPL' in a manifest might mean GPL-2.0-only WITH Classpath-exception-2.0, which is what the JDK class library ships under and not the thing that triggered your escalation. And it is silent in the worst case of all, when a package has no license field, no license file and no headers, which a spreadsheet renders as an empty cell and copyright law renders as all rights reserved. Reading the field is fast; reading the files is what makes the report true.
Does a license scan really need to execute the package manager's resolver?
In several major ecosystems, yes, and that is the uncomfortable part. npm runs preinstall, install, postinstall and prepare scripts as documented features; a Python source distribution's setup.py is a program and a PEP 517 build backend is a program you invoke; a Gradle build file is a full program. You need the resolver because you need the actual resolved tree, including optional and platform-specific dependencies, and because some packages fetch or generate vendored third-party sources during their build step — which is exactly the code you are obliged to find. Passing --ignore-scripts is a defensible trade in a security audit where you accept a coverage gap, but in a license scan the gap becomes an incomplete tree, and an incomplete tree becomes a report that attests to something you did not actually resolve. Go is the pleasant exception: module zips are content-addressed and there are no install hooks, so fetching is genuinely just downloading and expanding archives. Everywhere else, the honest answer is to run the resolver on a machine you are willing to destroy.
What does a per-scan microVM give me that a container on a CI runner does not?
Two things, and the second matters more than the first. The first is the isolation boundary itself: a microVM gives the extraction and detection steps their own guest kernel under KVM rather than a namespaced process sharing a kernel with every other job on the runner, which matters because unpacking untrusted archives and running pattern matchers over untrusted text are exactly the operations where a parser bug becomes somebody else's problem. The second is credential proximity, and it is the one people underestimate: a malicious postinstall script does not need to escape a container to read process.env, open an outbound socket or hit the cloud metadata endpoint, because all three are ordinary permitted operations inside it. The microVM's real contribution is that the machine is created with nothing on it — no publish token, no signing key, no route to your artifact store — so there is nothing to find. That absence is also the thing you can put in an audit answer, because it is a property of how the guest was created rather than a policy somebody has to keep enforcing.
Is a machine-generated SPDX document enough to satisfy legal or an acquirer's diligence?
It is the input to that process, not the output of it, and framing it as the output is how teams get into trouble. A generated document gets you an accurate, evidence-backed inventory with declared and concluded licenses, copyright statements, and a clear list of the packages where those two disagree or where nothing could be determined at all. What it cannot do is elect which arm of a dual license you are taking, decide whether a particular linking arrangement creates a derivative work, judge whether a prebuilt binary with no accompanying source is acceptable to ship, or know how your product is distributed — which is the fact that determines whether most reciprocal obligations trigger in the first place. Scanners also disagree with each other, because full-text matching runs on confidence thresholds and modified license texts are common. What a well-built pipeline actually delivers is a much better use of expensive human time: every genuine judgement call surfaced once, with the evidence and the file paths attached, plus a provenance record showing the scan ran on a machine that held none of your credentials and could reach nothing but the registries.
Keep reading
- Sandboxing SBOM and vulnerability scans — the security half of the same unpack step — CVEs rather than obligations
- Why installing a package is executing a package — the full treatment of the resolve step this post has to run anyway
- Sandboxing AI-agent dependency audits — when the thing running the audit is itself an agent you did not write
- Controlling network egress for untrusted code — how to build the registry allowlist the scanning guest runs behind
- Hermetic builds and SLSA provenance — the same evidence argument, applied to the build rather than the scan
- Ephemeral CI on PandaStack — snapshot-restore creates, per-sandbox network namespaces, TTL reaping
49ms p50 cold start. Fork, snapshot, and scale to zero.