---
title: "API reference. Lazaretto"
description: "Lazaretto API reference: the free lockfile check, the known-bad lookup, and the paid deterministic scan with evidence."
url: "https://lazaretto.dev/docs/api"
---

[Home](https://lazaretto.dev/) / API reference

Reference

# API reference

Lazaretto API reference: the free lockfile check, the known-bad lookup, and the paid deterministic scan with evidence.

Signals provider for agent skills, tools, and packages. Base URL: `https://lazaretto.dev`. The machine-readable contract is [`/openapi.json`](https://lazaretto.dev/openapi.json), and every tool is also served over a [remote MCP server](https://lazaretto.dev/docs/api#mcp).

Every scan response carries a `disclaimer`. Verdicts are `malicious`, `flagged`, `clear`, `error`. `clear` means "no known-bad match and no rule fired". It is not a statement about risk.

---

## Authentication and payment

The free endpoints (the lockfile check, the known-bad lookup, attestation verification) need nothing. A paid call is authorized one of two ways, chosen per call:

- **`X-API-Key`** holding credits. One credit per verdict; an `error` verdict is never billed. To get a key:

  - `POST /v1/trial` returns a free developer key with a daily scan allowance that refills. No payment.
  - `POST /v1/credits/topup` sells a credit pack over x402 (USDC on Base) and returns a key, or tops up the key you present. Packs do not expire.
- **x402, per call**, with no account. Send `POST /v1/scan` without a key and the `402` response carries the payment challenge: pay, then retry with the payment header. The payment settles only after a non-error verdict. A request that carries an `X-API-Key` is decided by the key and never by a payment, so send an x402 payment without one.

A person without a crypto wallet can buy the same packs with a card at [lazaretto.dev/buy](https://lazaretto.dev/buy) and gets a key on the spot.

Prices are quoted in every `402` body and at [lazaretto.dev/#pricing](https://lazaretto.dev/#pricing). Check a key's balance with `GET /v1/usage`.

---

## POST /v1/scan

Auth: `X-API-Key: <key>`, or an x402 payment (see above). Body:

```
{
  "target": {
    "type": "github_repo | raw_url | clawhub_skill | npm_package | pypi_package | mcp_server | mcp_tools | inline",
    "ref": "owner/repo | owner/repo@ref | package@version | pypi-name==version | owner/slug@version | https://raw.githubusercontent.com/… | https://host/mcp",
    "content": "…raw text, ONLY for type=inline or type=mcp_tools"
  },
  "depth": "lookup | full"
}
```

### `depth` semantics

| depth | What runs | Use |
| --- | --- | --- |
| `lookup` | fetch → hash → **known-bad / IOC match only** (no heuristic rules) | Cheap "is this a known-bad artifact?" with a billable answer. |
| `full` | `lookup` **plus** the deterministic rule engine + reputation | The product. |

`lookup` returns `findings: []` and a `clear` verdict at `medium` confidence when nothing is known-bad (it is a shallower check than a `full` clear). A `malicious` result is identical under either depth: IOC matching is always on.

### `mcp_server`: checking a server before you connect to it

`ref` is the server's https endpoint. Lazaretto speaks JSON-RPC to it (`initialize`, then `tools/list`) and analyzes **what the server advertises to an agent**: tool names, descriptions, parameter schemas, and the server-level `instructions` string.

That text is the attack surface. A tool description is documentation a model obeys, so a description that quietly orders the agent to open an agent config file first and pass its contents along in a spare parameter is executable social engineering, and it never appears in a package scan. Detections include hidden directive blocks, orders pointing the agent at private keys or agent config, parameters whose purpose is to carry secrets or conversation history out, standing orders about another server's tools (cross-server shadowing), and invisible-unicode payloads.

Evidence names the exact tool: `mcp/tools/<tool>.txt` for a tool, and `mcp/instructions.txt` for server-level text. `target_hash` covers the advertised set, so a server that changes its tools after the scan does not inherit the old verdict. Re-scan and compare.

```
curl -s -X POST https://lazaretto.dev/v1/scan \
  -H "X-API-Key: $KEY" -H 'content-type: application/json' \
  -d '{"target":{"type":"mcp_server","ref":"https://example.com/mcp"}}'
```

Two limits worth stating plainly. This reads what a server SAYS, not what its code does, so a server that advertises innocent tools and misbehaves at call time is out of scope. And a server can answer differently to different callers; the verdict covers the tool set we were served, which is what `target_hash` pins.

Over MCP, the same check is the `scan_mcp_server` tool.

### `mcp_tools`: checking a server that has no endpoint

Most MCP servers run locally over stdio. Nothing can connect to them from outside, so `mcp_server` cannot help, and that is most of the ecosystem. But your client already read the tool list at startup, so hand us that JSON in `content`:

```
curl -s -X POST https://lazaretto.dev/v1/scan \
  -H "X-API-Key: $KEY" -H 'content-type: application/json' \
  -d "{\"target\":{\"type\":\"mcp_tools\",\"content\":$(jq -Rs . tools.json)}}"
```

It accepts a whole `tools/list` response, a `{"tools":[...]}` object, or a bare array, and contacts nothing. The rules and the rendering are shared with `mcp_server`, so a payload cannot be caught over the wire and missed here; a test asserts the two produce byte-identical text for the same tools. Over MCP this is the `check_mcp_tools` tool.

### `target_hash`: how it is computed (reproducible by consumers)

Verdicts bind to `target_hash`, never to URLs (TOCTOU, PRD §4.1). A consumer that installs an artifact should recompute this hash over what landed on disk and compare; a mismatch means the report does not apply.

- **Single file** (`raw_url`, `inline`, or a one-file artifact): `target_hash = "sha256:" + sha256(utf8_bytes_of_content)`.
- **Multi-file artifact** (a repo tarball, npm tgz, or ClawHub zip): an **order-independent** hash over the analyzed members:

  1. For each analyzed member, form the line `"<path>\n<sha256hex(member_bytes)>"`. `<path>` is the member path with the archive's top-level directory prefix stripped (e.g. `repo-<sha>/` or `package/`).
  2. **Sort** those lines lexicographically (ascending, by code unit).
  3. Join with `"\n"` and append a trailing `"\n"`.
  4. `target_hash = "sha256:" + sha256(utf8_bytes_of_that_string)`.

This is stable across re-fetches and independent of archive member order.

Reference implementation (`src/analyzer/hash.ts`, `canonicalArtifactHash`):

```
import { createHash } from 'node:crypto';
const sha256 = (b) => createHash('sha256').update(b).digest('hex');

function targetHash(files /* [{path, content}] */) {
  if (files.length === 1) return 'sha256:' + sha256(Buffer.from(files[0].content, 'utf8'));
  const lines = files
    .map((f) => `${f.path}\n${sha256(Buffer.from(f.content, 'utf8'))}`)
    .sort();
  return 'sha256:' + sha256(Buffer.from(lines.join('\n') + '\n', 'utf8'));
}
```

Notes: binary members are included by their byte hash (we hash what we fetched even if we don't text-analyze it). The paths and member set must match what the scanner analyzed; the free `GET /v1/known-bad/{sha256}` accepts this same hash.

### 200 response

```
{
  "scan_id": "…",
  "target_hash": "sha256:9a3c…",
  "verdict": "malicious | flagged | clear",
  "confidence": "high | medium | low",
  "risk": "critical | high | medium | low | none",
  "risk_summary": "Reads credential material and can send it off the machine…",
  "known_bad": { "matched": true, "match_type": "exact_hash | fuzzy_hash | embedded_ioc | known_publisher | malicious_package", "sources": ["…"], "first_seen": "2026-02-01" },
  "findings": [ { "rule_id": "cred.ssh_read", "category": "credential_access", "severity": "high", "description": "…", "evidence": { "file": "setup.sh", "line": 12, "snippet": "cat ~/.ssh/id_rsa | curl …", "sanitizer_notes": ["…"] } } ],
  "reputation": { "publisher": "owner", "notes": ["…"] },
  "rules_version": "2026.07.02",
  "scanned_at": "2026-07-02T…Z",
  "disclaimer": "…"
}
```

`known_bad.matched` is tri-state: `true` (a match), `false` (checked, no match), or `null` (we could not consult a source, so we never imply clear-of-known-bad). `malicious` is always high confidence and always match-backed; heuristics cap at `flagged`.

Gate on **`risk`**, not `verdict`. `verdict` only says whether anything fired, so a credential stealer and a bundler that calls `Function()` are both `flagged`. `risk` separates them: reading secrets *and* being able to ship them off the machine is `critical`; constructing code at run time is `medium`.

For npm targets, `match_type: "malicious_package"` means the package identity is listed as malware in the OSV/OpenSSF corpus. This is scoped to the affected versions, so a project that was compromised in one release is not condemned in its later clean ones. **Pin an exact version** (`name@1.2.3`): for an unpinned name where an advisory covers only some versions, we return `matched: null` rather than guess, because guessing either way is a false statement about a real project.

### Status codes

- `200` verdict returned.
- `400` malformed or unsupported target.
- `401` invalid API key.
- `402` payment required: no key and no payment (the body carries the x402 challenge), or a key with no credits left (the body says how to buy more).
- `409` `payment_in_use`: the same x402 payment is already in use by another request, or was used for one within the last few minutes. One payment pays for one request, so nothing was run or settled for this one. Sign a new payment.
- `422` `verdict:"error"` (couldn't fetch or parse). Never billed.
- `429` rate limited (`Retry-After` header).

If an x402 payment was verified but the payment facilitator did not answer the settlement clearly (an error, a timeout, a dropped connection, or a failure reported after a transfer may have gone out), nobody knows whether the money moved. The scan result still comes back as a `200`, without an `attestation`, and with a `payment` block: `{"outcome":"unknown","detail":"...","contact":"contact@lazaretto.dev","time":"..."}`. Do not pay again for that scan. If you were charged, nothing more is owed; email [contact@lazaretto.dev](mailto:contact@lazaretto.dev) with the paying wallet address and the `time` if in doubt.

---

## Attestations (portable, verifiable verdicts)

Every non-error scan response includes an **`attestation`** (except an x402 scan whose payment did not settle, or whose payment outcome is unknown, above): a compact JWS (EdDSA/Ed25519) signed by Lazaretto over the verdict core. It lets one agent hand a verdict to another (or embed it in a README or lockfile), and the recipient trusts it **without re-scanning, re-paying, or trusting the messenger.**

The signed claims: `iss`, `sub` (the artifact: its `sha256:…` content hash, or `type:ref` package identity for a taken-down package), `subject_kind`, `target`, `verdict`, `risk`, `confidence`, `known_bad`, `rules_version`, `iat`. There is no `exp`: a verdict is "clear at scan time under rules vX," not "clear forever."

**Verify offline** against the public keys at `GET /.well-known/jwks.json`, then confirm the artifact you will run matches `claims.sub`.

JS (`jose`):

```
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://lazaretto.dev/.well-known/jwks.json'));
const { payload } = await jwtVerify(attestation, JWKS, { issuer: 'https://lazaretto.dev' });
if (payload.sub !== 'sha256:' + yourArtifactHash) throw new Error('attestation is for a different artifact');
```

JS (WebCrypto, no deps):

```
const jwks = await (await fetch('https://lazaretto.dev/.well-known/jwks.json')).json();
const u = s => Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
const [h, p, s] = attestation.split('.');
const jwk = jwks.keys.find(k => k.kid === JSON.parse(new TextDecoder().decode(u(h))).kid);
const key = await crypto.subtle.importKey('jwk', { kty:'OKP', crv:'Ed25519', x: jwk.x }, { name:'Ed25519' }, false, ['verify']);
const valid = await crypto.subtle.verify({ name:'Ed25519' }, key, u(s), new TextEncoder().encode(`${h}.${p}`));
```

**Or verify online** with `POST /v1/verify` (free, anonymous). It checks the signature and adds a contradiction check the offline path cannot: a previously `clear` subject that is now a known-bad match comes back `{ valid: true, contradicted: { now: "known_bad" } }`, so a stale verdict is caught.

```
curl -s -X POST https://lazaretto.dev/v1/verify \
  -H 'content-type: application/json' -d '{"attestation":"<the JWS string>"}'
```

An empty JWKS means signing is not configured on that deployment; treat any attestation as unverifiable there.

---

## POST /v1/scan/batch

Metered: one credit per package that returns a verdict, nothing for one that errors. Requires an `X-API-Key` holding credits.

Reads the code of each exactly-pinned dependency in a lockfile, where `POST /v1/lockfile` only matches identities. Send a `package-lock.json`, `yarn.lock` or `pnpm-lock.yaml` (raw, or as `{"lockfile":"<contents>"}`), or an explicit `{"packages":[...]}` list of `{"name","version"}` objects or `"name@version"` strings.

Each result carries a verdict, a risk level, the ids of the rules that fired and a one-line risk summary. It does not carry file-and-line evidence: for that, scan the package on its own with `POST /v1/scan`.

At most 25 packages are scanned per call, in lockfile order, so sending the same lockfile again rescans the same first 25. `not_scanned.packages` lists the `name@version` of each package left out; to continue, send that list back as `{"packages":[...]}`. Read `complete_coverage` before trusting the result: `false` means something was capped, errored, only partly readable, or skipped, so it is not a clean bill of health for the whole tree.

---

## Remote MCP server

Add `https://lazaretto.dev/mcp` to any MCP-capable client. Nothing to install: it is Streamable HTTP, JSON-RPC (`initialize`, `tools/list`, `tools/call`, `ping`). Open that URL in a browser, or GET it as JSON, for a description of the endpoint and its current tool list.

```
{ "mcpServers": { "lazaretto": {
    "type": "http",
    "url": "https://lazaretto.dev/mcp",
    "headers": { "X-API-Key": "your-key" }
} } }
```

Free, with no key:

| Tool | What it does |
| --- | --- |
| `check_lockfile` | The lockfile check below. Takes a whole lockfile, or `packages`, a list of `name@version` strings, so a large tree need not pass through a model's context. |
| `known_bad_lookup` | `GET /v1/known-bad/{sha256}`: is this exact hash a known-bad indicator. |
| `verify_attestation` | `POST /v1/verify`: check an attestation's signature, and whether its subject is now known-bad. |
| `find_attestation` | `GET /v1/attestations/{subject}`: find an existing signed verdict by hash or package identity. When nobody has attested it, an npm identity gets a labelled identity check against the advisory corpus instead, which is not an attestation. |
| `get_free_key` | `POST /v1/trial`: mint a free developer key inside the session. Same one-per-source limit as the HTTP route, so it is the same key by another door, not a second faucet. |

Metered, against an `X-API-Key` holding credits (the free trial key included): one credit per verdict, nothing for an error.

| Tool | What it does |
| --- | --- |
| `scan_artifact` | `POST /v1/scan`: a full scan of one artifact, with file-and-line evidence. |
| `scan_mcp_server` | `POST /v1/scan` with `type: "mcp_server"`: what a server advertises, before you connect. |
| `check_mcp_tools` | `POST /v1/scan` with `type: "mcp_tools"`: tool definitions your client already holds. |
| `scan_lockfile_deep` | `POST /v1/scan/batch`: one credit per package. Pass `packages` (the `not_scanned.packages` list) to continue a capped run. |

Paying per call over x402 happens on `POST /v1/scan`, not over MCP. A metered tool called without a key returns a `payment_required` result that lists every way to pay.

---

## POST /v1/lockfile

**Free. No API key. Rate limited by IP.**

Checks every exactly-pinned version in a lockfile against the malicious-package feed. One call covers a whole dependency tree.

```
curl -s -X POST https://lazaretto.dev/v1/lockfile \
  -H 'content-type: application/json' --data @package-lock.json
```

Also accepts `yarn.lock` (v1 and Berry) or `pnpm-lock.yaml` (v5/v6/v9) posted as `text/plain`, or an explicit list for agents that already resolved the tree:

```
{ "packages": [ { "name": "chalk", "version": "5.6.1" } ] }
```

Response:

```
{
  "checked": 812,
  "format": "package-lock",
  "malicious": [ { "name": "chalk", "version": "5.6.1", "ids": ["MAL-2025-46969"],
                   "advisory_url": "https://osv.dev/vulnerability/MAL-2025-46969" } ],
  "unverified": [],
  "skipped": { "count": 107, "reason": "file:/link:/workspace:/git references … no package identity to look up" },
  "truncated": false,
  "note": "1 pinned package version is listed as malware. Remove or upgrade before installing."
}
```

**Only exact versions are checked.** A range like `^5.0.0` has no definitive answer: chalk was malicious in 5.6.1 and clean in the releases either side, so answering about the range would be a guess in one direction or the other.

`truncated` is `true` when the lockfile exceeded the per-request package limit. When it is set, `checked` is the CLIPPED count and the remainder was never looked at, so the result is not an all-clear at any size. The `note` says so first. Split the lockfile or call the API per workspace.

`skipped` counts entries that name a dependency but not a published release (`file:`, `link:`, `workspace:`, git). There is no registry identity to check, so they are reported rather than silently dropped: "we checked 1325 of your 1432 entries" and "you are clean" are different statements.

**Fail-closed.** `malicious` being empty is an all-clear only when `unverified` is also empty **and** `truncated` is `false`. Anything we could not check is listed under `unverified` with a reason, and a `503` means the feed was unreachable, which is not an all-clear either.

`truncated: true` is the case with no list to look in. A lockfile longer than the per-request package limit is clipped, and the packages past the limit go into neither `malicious` nor `unverified`: they were never looked at. `checked` counts only the ones that were. Send the remainder in a second call, as `{"packages": ["name@version", ...]}`, or split the lockfile by workspace, before treating the tree as checked.

This reports package IDENTITY only, against published malware advisories. It is not a behavioral scan: for evidence about what a specific artifact actually does, use `POST /v1/scan`.

---

## POST /v1/watch (re-check a dependency set on demand)

One credit to create, free to read.

Every verdict we issue is a statement about a **moment**. `chalk@5.6.1` was an ordinary dependency until it was not, and a team that ran a lockfile check the week before got an answer that was correct and then quietly stopped being correct. A one-off check cannot cover that, and neither can a signed attestation: freshness is the one thing a portable verdict cannot carry.

A watch is the standing question. Register a dependency set once; we keep the package **identities** and what we knew at that moment. Each time you read the watch, it is re-evaluated against the current advisory corpus. Nothing runs on a schedule and nothing is pushed to you, so poll it: a daily CI job is enough.

```
curl -s -X POST https://lazaretto.dev/v1/watch \
  -H "X-API-Key: $KEY" -H 'content-type: application/json' \
  --data @package-lock.json
```

Returns a `watch_id` and a `watch_token` shown **once** (we store only its hash). An optional https `webhook_url` is accepted and stored, but no deliveries are sent yet, so do not rely on it for alerts. Read the watch instead.

```
curl -s https://lazaretto.dev/v1/watch/$ID -H "x-watch-token: $TOKEN"
```

The response separates `newly_malicious` (clean when you registered, listed now, with what it was before) from `still_malicious` (already listed then, so not news). Reading acknowledges an alert, so a daily check does not re-report the same package every morning. A `503` with `degraded: true` means we could not check, which is **not** an all-clear, and a degraded run never banks an answer we did not get.

`DELETE /v1/watch/{id}` with the same token stops the watch **and deletes the dependency list**. Someone who stops has asked us to stop holding it, not just to stop looking at it.

We store identities only: never lockfile contents, resolved URLs, or code. A dependency list already says a lot about a company's stack, so we hold the least that answers the question.

---

## GET /v1/known-bad/{sha256}

Free, rate-limited, **no auth**. `{sha256}` is the `target_hash` (hex, optional `sha256:` prefix). Returns `{ target_hash, known_bad, disclaimer }`. `503` with `matched: null` if the IOC store is unavailable (fails closed).

## GET /v1/health · /v1/rules · /.well-known/security.txt · /.well-known/agent-card

Liveness + p50/p95 latency + IOC count; the public rule catalog (categories + IDs, never detection logic); responsible-disclosure contact; and discovery metadata: the endpoints, the MCP tools, and, while payments are advertised, what each paid call costs and how to pay for it.

---

Machine-readable index: https://lazaretto.dev/llms.txt. OpenAPI: https://lazaretto.dev/openapi.json. MCP endpoint: https://lazaretto.dev/mcp. Any page on this site is available as markdown by adding .md to its path.
