Signals provider for agent skills, tools, and packages. Base URL:
https://lazaretto.dev. The machine-readable contract is
/openapi.json, and every tool is also
served over a remote MCP server.
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-Keyholding credits. One credit per verdict; anerrorverdict is never billed. To get a key:POST /v1/trialreturns a free developer key with a daily scan allowance that refills. No payment.POST /v1/credits/topupsells 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/scanwithout a key and the402response carries the payment challenge: pay, then retry with the payment header. The payment settles only after a non-error verdict. A request that carries anX-API-Keyis 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 and gets a key on the spot.
Prices are quoted in every 402 body and at
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:
- 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>/orpackage/). - Sort those lines lexicographically (ascending, by code unit).
- Join with
"\n"and append a trailing"\n". target_hash = "sha256:" + sha256(utf8_bytes_of_that_string).
- For each analyzed member, form the line
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
200verdict returned.400malformed or unsupported target.401invalid API key.402payment 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).409payment_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.422verdict:"error"(couldn't fetch or parse). Never billed.429rate limited (Retry-Afterheader).
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 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.