Skip to content

Verdicts & the scanner

Every install request hits the proxy, Upwarden computes a verdict for the requested artefact, and the verdict drives both the HTTP response and the audit row. Three verdicts. Each maps to a specific wire shape and a specific operator follow-up.

VerdictHTTPWhat happens
SAFE200Proxy fetches the artefact from the upstream registry and streams it back. Install completes normally.
QUARANTINED403The artefact is held while the scanner analyses it. The PM sees a [UPWARDEN] … QUARANTINED. Analysis in progress. body; retry shortly.
BLOCKED403The artefact has been classified as non-safe. The PM sees a structured 403 body with error, advisory (CVE / severity / safe version), and remediation (guidance) — see What a 403 carries below.

The audit log stamps the verdict on every row, alongside the decisionReason envelope that names why the verdict landed.

The decision pipeline is layered. Each layer can short-circuit; the first one that returns a final verdict wins.

  1. Blocklist match. A known-malicious version (from OSV-style feeds + the operator’s own blocklist) → BLOCKED, immediate. When the matching entry carries a CVE / GHSA id, the 403 body carries the full advisory envelope so the consumer can route on severity. Operator-side: see the blocklist sync (reference: env vars OSV_*).
  2. Cooldown. A version younger than VANGUARD_DEFAULT_MIN_AGE_DAYSBLOCKED. This is the freshly-published-malware window — a legitimate maintainer doesn’t need their new release to land in customer builds within the first 24h, but an attacker absolutely does. Tunable per-tenant via the policy override surface.
  3. Per-tenant policy. Tier-driven defaults + tenant overrides. Allowlist hit → SAFE. Denylist hit → BLOCKED.
  4. Pre-scan. A fast structural check (decompressed size, manifest shape, install scripts) — catches packages that clearly shouldn’t ship even before the scanner runs. Operator-tunable via SCANNER_SIZE_ALLOWLIST for legitimate large artefacts. Two heuristics fire here:
    • Build-tool fingerprint — when the blocked package is a known transitive of a build tool (esbuild, swc, sass-embedded, …), the 403 body carries the upstream tool name and the affected version range so the developer can pin against the right thing.
    • Size-jump heuristic. A tarball that’s more than 5× the median of its three most recent prior versions is flagged as a size_jump_5x_median anomaly — historically a strong supply-chain compromise tell (event-stream@3.3.6, ua-parser-js@0.7.29). Tunable via SCANNER_SIZE_JUMP_MULTIPLIER.
  5. Scanner. If the version is unseen-before, the scanner does similarity search (via the embedding endpoint) against known-malicious packages. The PM waits up to FAST_PASS_TIMEOUT_MS (default 3s); if the scanner takes longer, the response is QUARANTINED and the analysis continues in the background.
  6. Default-safe. Once a version has cleared every layer above, it’s SAFE — cached for MANIFEST_CACHE_TTL_MS so subsequent requests don’t re-run the pipeline.

The split between BLOCKED and QUARANTINED matters operationally:

  • BLOCKED is a terminal verdict — re-installing won’t change the answer until the operator allowlists, removes the version, or the cooldown lapses.
  • QUARANTINED is temporary — the scanner is still running. Retrying the install in a minute often resolves to SAFE.

Surfacing them as distinct verdicts means a developer who sees QUARANTINED knows to wait, and an automated CI pipeline can treat them differently (retry vs. abort).

Every BLOCKED response is a structured JSON body. The error field is a human-readable single line the package manager will print verbatim. Everything else is machine-actionable — parse it in CI / scripts to route remediation:

{
"error": "[UPWARDEN] Package 'lodash@4.17.20' is BLOCKED. Downgrade to known-safe version: 'lodash@4.17.21'.",
"advisory": {
"source": "osv",
"id": "GHSA-35jh-r3h4-6jhm",
"cve": "CVE-2021-23337",
"severity": "HIGH",
"summary": "Command Injection in lodash"
},
"remediation": {
"safe_version": "4.17.21",
"guidance": "Pin to ^4.17.21 in package.json and re-run `npm install`."
}
}

Field semantics:

  • error — the only field a package manager (npm, pip, cargo, …) surfaces directly to the developer. Stable across versions.
  • advisory — present when the block is anchored to an OSV / GHSA record. cve is optional (some advisories ship without one). Use severity for routing (LOW / MEDIUM / HIGH / CRITICAL).
  • remediationsafe_version is the highest known-safe version below the blocked one. guidance is plain text suitable for paste into a PR comment / Slack message. When the block matched a build-tool fingerprint (esbuild / swc / sass-embedded transitive), remediation also carries fingerprint: { tool, affected, fixed_in } so the developer can update the upstream tool rather than chase the transitive.

A QUARANTINED response is the same envelope shape minus advisory / remediation: just error, with a retry hint. Verdicts that don’t carry an advisory (cooldown, policy denylist, manual block) omit the advisory block but keep remediation when a safe version exists.

Verdicts don’t just power the per-version response. When a PM sends a manifest request (e.g. npm install foo with foo resolved by a range like ^1.2.0), the proxy returns the manifest with non-safe versions stripped out. The PM then resolves the range against only safe versions and never sees the bad one.

This is silent rollback — a build that asked for “the latest 1.x” gets the latest safe 1.x. No npm error surfaces; the build stays green. The 403 path only fires when a developer pins exactly to a non-safe version.

See Silent rollback for the wire-level detail.

Every install (and every block) writes one or more audit_log rows. The audit row is the canonical “why did my install (not) go through” record. It carries:

  • tenantId, apiKeyId, packageName, version
  • decision — the verdict (SAFE / BLOCKED / QUARANTINED)
  • decisionReason — a structured JSON envelope naming the specific layer that fired (oss_first_party, blocklist_hit, cooldown_active, etc.)
  • verdictSource — which surface produced the row. The common values:
    • proxy:<ecosystem>:tarball / :manifest — package serving paths
    • osv-block / ghsa-block — BLOCKED via the advisory layer (1)
    • cooldown — BLOCKED via the freshness window (2)
    • policy-allowlist / policy-denylist — per-tenant policy (3)
    • fingerprint — BLOCKED via the build-tool fingerprint (4)
    • size-jump — flagged by the size-jump heuristic (4); usually QUARANTINED while the scanner takes a closer look
    • scanner — embedding-similarity layer (5)
    • vanguard_account:<event> — account-side audit events (sign-in, key creation, …)

See Querying the audit log for the query patterns.