image-delta-mcp
by joepangallo
README.md
# image-delta-mcp — UNOFFICIAL: built as interview prep, not affiliated with or endorsed by Chainguard.
An MCP server over Chainguard's **public, free-tier** image data that answers,
inside Claude Code:
> *"What changed between these two versions of this image, and what CVEs did we shed?"*
It shells out to three standard supply-chain tools — [`crane`](https://github.com/google/go-containerregistry)
(tags/digests/manifests), [`cosign`](https://github.com/sigstore/cosign) (SPDX SBOM
attestations), and [`grype`](https://github.com/anchore/grype) (registry-direct CVE
scanning) — wraps them in strict validation, rate limiting, and a digest-keyed
cache, and exposes four small tools to any MCP client.
## Guardrails (why this exists and what it will not do)
- **Unofficial.** Interview-prep project. Not affiliated with, endorsed by, or
representing Chainguard in any way.
- **Free-tier public data only.** `cgr.dev` free-tier images, their public
Sigstore attestations, and public upstream registries. No gated catalog
access, no scraping.
- **Polite to registries.** Every registry-touching call goes through a token
bucket (4 burst, 1/s refill) and a digest-keyed on-disk cache under
`.cache/`, so repeated queries do not hammer cgr.dev or Docker Hub.
- **Honest about gaps.** Missing SBOMs, unresolvable platforms, free-tier tag
limitations, and scanner-database freshness are reported in `data_gaps`
fields — never papered over as empty-but-successful results.
## The four tools
| Tool | Question it answers |
|------|---------------------|
| `list_versions(image)` | What tags exist, with digests and created dates — plus how many historical digests are publicly recoverable from signature tags. |
| `diff_packages(image, ref_a, ref_b)` | Package-level delta (added / removed / version-changed) between two refs, from the SPDX SBOM attestations attached to each digest. |
| `diff_cves(image, ref_a, ref_b)` | CVEs **shed** and **introduced** between two refs, from identical registry-direct grype scans. |
| `compare_to_upstream(image, upstream_ref)` | The money tool: a Chainguard image vs. its upstream equivalent, CVE counts side by side. |
Refs are tags (`latest`) or digests (`sha256:<64 hex>`); platform defaults to
`linux/amd64` (`linux/arm64` supported).
## Real demo results (run 2026-07-21, grype DB of 2026-07-21)
These are actual outputs from `npm run smoke` on this machine — no numbers
below are invented, and they will drift as images and the vulnerability
database update.
### 1. "What CVEs did we shed?" — `cgr.dev/chainguard/node`, an older digest vs `latest`
Older ref `sha256:0029ab60fc5a…` (created **2023-01-14**) → `latest`
`sha256:c002402b3552…` (created **2026-07-17**):
```
454 CVEs → 2 CVEs
447 CVE instances shed (16 Critical, 127 High, 100 Medium, 15 Low, 189 Unknown)
1 introduced (1 Medium), 1 retained
packages: 189 added, 2 removed, 17 version-changed
```
The older digest was recovered from public signature tags (`sha256-*.sig`) —
the free tier exposes only rolling tags, but 7,697 historical index digests
of `node` are publicly enumerable and diffable this way.
### 2. Chainguard vs upstream — `cgr.dev/chainguard/nginx` vs `docker.io/library/nginx:latest`
```
cgr.dev/chainguard/nginx:latest 0 CVEs
docker.io/library/nginx:latest 340 CVEs (21 Critical, 58 High, 86 Medium, 7 Low, 103 Negligible, 65 Unknown)
```
Same scanner, same database, same platform (`linux/amd64`), both
registry-direct — an apples-to-apples scanner comparison, not an official
vendor count.
### The 3-minute demo script (inside Claude Code)
1. *"What CVEs did we shed moving chainguard/node from `sha256:0029ab60fc5a…` to latest?"*
→ clean shed/introduced delta with the package changes behind it.
2. *"Compare chainguard/nginx to docker.io/library/nginx."*
→ the zero-CVE claim reproduced live from public data by a tool an agent can call.
3. Close: built in a day on hardened template conventions, 144 tests, externally
reviewed — and an agent can call this before choosing a base image.
## Architecture
```
Claude Code (stdio, primary) optional: Streamable HTTP (127.0.0.1, Bearer auth)
│ │
└────────────► McpServer (4 tools, zod-validated inputs)
│
validate.ts │ tight regexes for image/tag/digest/platform,
(before any │ checked BEFORE anything is spawned
spawn) ▼
exec.ts ── execFile ONLY, binary allowlist {crane, cosign, grype},
│ per-arg character checks, hard timeouts + SIGKILL,
│ bounded output buffers
▼
rate-limit.ts (token bucket) ──► crane / cosign / grype ──► registries
│
cache.ts — digest-keyed on-disk cache (.cache/):
immutable entries for digest-addressed content
(manifests, configs, SBOMs), TTL entries for tag
lists (30m), tag→digest (15m), grype scans (24h)
```
Data flow for a diff: tag/digest → index digest (`crane digest`) → platform
image digest (`crane manifest`) → SBOM (`cosign download attestation`,
predicate `https://spdx.dev/Document`, with legacy `.sbom`-attachment
fallback) and CVEs (`grype registry:image@digest -o json`) → pure diff logic
(`src/diff.ts`).
## Honest limitations
- **Free tier only sees rolling tags.** `latest`, `latest-dev`, etc. Versioned
tags are a paid feature; "older refs" here are historical digests recovered
from public signature tags, which are unordered and must be dated
individually.
- **CVE counts are scanner-relative.** grype against its daily DB — not
Chainguard's advisory feed, not an official count from either vendor.
Numbers move as the DB updates; scans are cached up to 24h.
- **Upstream images have no SBOMs.** `diff_packages` works where SBOM
attestations exist (Chainguard images); for most upstream images it reports
a data gap instead.
- **SBOM parity.** Chainguard SBOMs are per-platform in-toto attestations;
very old digests may predate them (legacy attachment fallback included, but
some gaps remain and are reported as such).
- **Attestations are downloaded, not verified.** `cosign download attestation`
fetches; it does not verify signatures against Fulcio/Rekor. A production
version would `cosign verify-attestation` against Chainguard's identity.
- **grype's own registry traffic is not rate-limited** by this server's token
bucket (it pulls layers internally). The bucket gates how often scans start;
the 24h scan cache keeps repeats near zero.
## Install & run
Prereqs: Node >= 20 and the three binaries:
```bash
brew install crane cosign grype
```
Build and test:
```bash
npm install
npm test # 144 vitest tests — fully offline, binaries mocked
npm run build
npm run smoke # the real pipeline against public registries (network!)
```
### Claude Code (stdio — primary mode)
```bash
claude mcp add image-delta -- node /absolute/path/to/image-delta-mcp/dist/index.js
```
or in `.mcp.json`:
```json
{
"mcpServers": {
"image-delta": {
"command": "node",
"args": ["/absolute/path/to/image-delta-mcp/dist/index.js"]
}
}
}
```
### Optional HTTP mode (hardened, off by default)
```bash
IMAGE_DELTA_API_KEY="$(openssl rand -hex 24)" PORT=3900 node dist/index.js --http
# POST /mcp with Authorization: Bearer <key>; binds 127.0.0.1; /healthz unauthenticated
```
Refuses to start without a key (>= 16 chars). Timing-safe Bearer comparison,
per-IP token bucket, 2 MB body cap, stateless transport.
Env: `IMAGE_DELTA_CACHE_DIR` overrides the cache location (default `.cache/`
in the package root).
## Quality gate
- 144 vitest tests (validation, exec safety, rate limiting, cache, SBOM and
grype parsing, diff logic, all four tools against a mocked registry, MCP
end-to-end over an in-memory transport, HTTP auth/limits integration).
Tests never touch the network.
- Integration smoke script (`scripts/smoke.mjs`) exercises the real pipeline;
the demo numbers above are its output.
- Adversarial security self-review + external Codex review — findings and
fixes recorded in [SECURITY-REVIEW.md](SECURITY-REVIEW.md).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing