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 | inline",
"ref": "owner/repo | owner/repo@ref | package@version | owner/slug@version | https://raw.githubusercontent.com/…",
"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.
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).
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.
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).