Lazaretto API (Phase 0)
Signals provider for agent skills/tools/packages. Auth is X-API-Key only in
Phase 0 (no payments). Base URL is your deployment (e.g.
https://lazaretto-api.fly.dev).
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.
POST /v1/scan
Auth: X-API-Key: <key>. Body:
{
"target": {
"type": "github_repo | raw_url | clawhub_skill | npm_package | pypi_package | mcp_server | 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"
},
"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
200 verdict returned · 400 malformed/unsupported target · 401 missing/invalid
API key · 422 verdict:"error" (couldn't fetch/parse — never billed) · 429
rate limited (Retry-After header).
Attestations (portable, verifiable verdicts)
Every non-error scan response includes an attestation: 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 — 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/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. Anything we could not check is listed there with a reason, and a
503 means the feed was unreachable, which is not an all-clear either.
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 (continuous monitoring)
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, and re-evaluate against the refreshed advisory corpus.
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). Add an optional https webhook_url to be pushed to rather than polled.
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 (no payment fields in Phase 0).