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.

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