mlab docs

SBOM scan (API)

Scan a whole lockfile or SBOM for known vulnerabilities in one request, by URL or by POSTing the file, from a script or CI.

The /scan page parses your lockfile in the browser and checks every dependency. GET/POST /api/v2/scan is the same scanner for scripts and CI: hand it a lockfile by URL or as the request body, it parses it server-side and returns one result per dependency.

Unlike /api/v2/query (one coordinate at a time), this scans the whole manifest in a single request.

Supported files

EcosystemFiles
npmpackage-lock.json, npm-shrinkwrap.json, yarn.lock (v1 and berry), pnpm-lock.yaml (v5 to v9)
crates.ioCargo.lock
PyPIrequirements.txt (== pins only), poetry.lock, uv.lock, pdm.lock, Pipfile.lock
Packagistcomposer.lock
RubyGemsGemfile.lock
Gogo.sum
Mavenpom.xml (literal versions), gradle.lockfile
NuGetpackages.lock.json
Pubpubspec.lock
Hexmix.lock
misemise.lock (npm, pipx, cargo, gem, go and dotnet backends)
AnyCycloneDX (JSON or XML) and SPDX (JSON 2.x, tag-value, JSON-LD 3.0)

Detection uses the file name (the filename query parameter, or the URL), then the content. format forces a parser: npm, yarn, pnpm, cargo, pip, poetry, uv, pdm, pipfile, composer, gem, go, maven, nuget, pub, hex, mise, cyclonedx, spdx.

In an SBOM, every component is read, nested ones included: its purl when it has one (OS packages too: deb, apk, with the distro release), otherwise its CPE, which is matched against NVD configurations. Advisories found that way carry database_specific.source: "nvd-cpe".

Input modes

HowExample
URL (GET)GET /api/v2/scan?url=https://.../Cargo.lock
URL (POST)body {"url":"https://.../Cargo.lock"}
Raw body--data-binary @Cargo.lock
Inline textbody {"manifest":"...","format":"cargo"}
Pre-parsedbody {"packages":[{ecosystem,name,version} | {purl} | {cpe}]}

Scan a local lockfile

curl -s -X POST "https://vuln.mlab.sh/api/v2/scan?filename=Cargo.lock" --data-binary @Cargo.lock

Force the parser for an unusually named file:

curl -s -X POST "https://vuln.mlab.sh/api/v2/scan?format=npm" --data-binary @my-lock.json

Scan by URL

curl -s "https://vuln.mlab.sh/api/v2/scan?url=https://raw.githubusercontent.com/rust-lang/cargo/master/Cargo.lock"

The URL modes fetch a URL you supply, behind an SSRF guard: http/https on port 80 or 443 only, and the target must resolve to a public address (private, loopback, link-local and cloud-metadata ranges are refused). Redirects are followed (at most 4) with the same check, and the file is capped at your tier's request size.

Limits

CallerPackages per scanRequest sizeCooldown between scans
Anonymous (per IP)5124 MiB10 s
Free account (session or API token)10248 MiB5 s
Paid plan409632 MiBnone

Generate a token at /me/tokens and send it as Authorization: Bearer <token> (or X-API-Key: <token>). It applies your account's limits, and its scans land in your scan history. The cooldown is per account, so a token and your browser session share it.

Every response states your tier and its limits. When a limit is hit, the answer says so in plain words:

  • More packages than your tier scans: the manifest is cut, truncated: true, and truncation gives the counts and a message. The packages past the cut were not scanned, never clean.
  • A body over the size limit: 413 with message.
  • A scan inside the cooldown: 429 with Retry-After and retry_after_secs. Wait and send the same request again: nothing was spent.

Below the top tier, truncation, 413 and 429 carry an upgrade block with what the next tier unlocks.

{
  "error": "cooldown",
  "message": "Anonymous scans are limited to one every 10 seconds. Retry in 7 seconds.",
  "retry_after_secs": 7,
  "tier": "anonymous",
  "limits": { "max_packages": 512, "cooldown_secs": 10, "max_body_bytes": 4194304 },
  "upgrade": {
    "tier": "free", "max_packages": 1024, "cooldown_secs": 5, "max_body_bytes": 8388608,
    "message": "Create a free account to scan up to 1024 packages per scan (8 MiB per request), with a 5 second cooldown.",
    "url": "https://vuln.mlab.sh/sso/login?next=/scan"
  }
}

Response

{
  "hash": "8a1564f1d99d2afa",
  "cached": false,
  "count": 2,
  "tier": "anonymous",
  "limits": { "max_packages": 512, "cooldown_secs": 10, "max_body_bytes": 4194304 },
  "truncated": false,
  "outage": false,
  "ttl": 10800,
  "packages": [ { "ecosystem": "crates.io", "name": "time", "version": "0.1.44" } ],
  "results": [ { "ok": true, "vulns": [ /* OSV vulnerability records */ ] } ]
}
  • results[i] matches packages[i]. The URL, raw and inline modes echo the coordinates in packages; a pre-parsed request does not.
  • results[i].ok == false means that coordinate could not be scanned (unqueryable, or the database was unavailable and outage is true). It is not a clean result.
  • unscanned (server-parsed modes) lists entries that could not become a coordinate at all: a git or path dependency, an unpinned requirement, the Flutter SDK in a pubspec.lock, an SBOM component with neither purl nor CPE.
  • Each vulnerability is an OSV record. Its CVE is in aliases, or for Debian and Ubuntu records in upstream (or in an id such as DEBIAN-CVE-2024-1234). Several records can describe the same CVE (GHSA and RUSTSEC, for example).
  • The result is cached for 3 hours, keyed by the set of coordinates (cached, ttl). An outage is never cached.

A gate in a few lines

Fail the build when any dependency has a known vulnerability, or when the scan did not cover everything:

r=$(curl -sf -X POST "https://vuln.mlab.sh/api/v2/scan?filename=Cargo.lock" \
  -H "Authorization: Bearer $VULN_MLAB_TOKEN" --data-binary @Cargo.lock) || exit 2
echo "$r" | jq -e '
  ([.results[] | select(.ok | not)] | length) == 0
  and (.truncated | not)
  and ([.results[].vulns // [] | length] | add) == 0' > /dev/null \
  || { echo "Vulnerable or not fully scanned"; exit 1; }

For anything more than that, use one of the integrations: they handle the cooldown, severity thresholds, annotations and reports.

See also

On this page