Skip to main content
Glama
joepangallo

image-delta-mcp

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