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.

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 onlinePOST /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).