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.
The three verdicts
Section titled “The three verdicts”| Verdict | HTTP | What happens |
|---|---|---|
| SAFE | 200 | Proxy fetches the artefact from the upstream registry and streams it back. Install completes normally. |
| QUARANTINED | 403 | The artefact is held while the scanner analyses it. The PM sees a [UPWARDEN] … QUARANTINED. Analysis in progress. body; retry shortly. |
| BLOCKED | 403 | The 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.
How a verdict is computed
Section titled “How a verdict is computed”The decision pipeline is layered. Each layer can short-circuit; the first one that returns a final verdict wins.
- 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_*). - Cooldown. A version younger than
VANGUARD_DEFAULT_MIN_AGE_DAYS→ BLOCKED. 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. - Per-tenant policy. Tier-driven defaults + tenant overrides. Allowlist hit → SAFE. Denylist hit → BLOCKED.
- 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_ALLOWLISTfor 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_mediananomaly — historically a strong supply-chain compromise tell (event-stream@3.3.6, ua-parser-js@0.7.29). Tunable viaSCANNER_SIZE_JUMP_MULTIPLIER.
- 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. - Default-safe. Once a version has cleared every layer above, it’s SAFE — cached for
MANIFEST_CACHE_TTL_MSso subsequent requests don’t re-run the pipeline.
Why three verdicts and not two
Section titled “Why three verdicts and not two”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).
What a 403 carries
Section titled “What a 403 carries”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.cveis optional (some advisories ship without one). Useseverityfor routing (LOW/MEDIUM/HIGH/CRITICAL).remediation—safe_versionis the highest known-safe version below the blocked one.guidanceis plain text suitable for paste into a PR comment / Slack message. When the block matched a build-tool fingerprint (esbuild / swc / sass-embedded transitive),remediationalso carriesfingerprint: { 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.
Silent rollback
Section titled “Silent rollback”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.
Reading the verdict after the fact
Section titled “Reading the verdict after the fact”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,versiondecision— 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 pathsosv-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 lookscanner— embedding-similarity layer (5)vanguard_account:<event>— account-side audit events (sign-in, key creation, …)
See Querying the audit log for the query patterns.
What’s next
Section titled “What’s next”- Silent rollback — how floating ranges stay green.
- Querying the audit log — find out why a verdict landed.
- Troubleshooting — common reasons a package you expected lands as BLOCKED.