Public scan API: submit SBOM or image:tag@sha256:digest, get expandable results #23

Open
opened 2026-06-30 13:51:09 +02:00 by jonas · 0 comments
Owner

Goal

A machine-facing API so CI/CD pipelines can submit a scan target and get a result back, without going through the human UI/OIDC flow. Two submission shapes:

  1. Upload an SBOM (CycloneDX/SPDX) → SPAM stores + scans it (grype) → returns findings.
  2. Reference an image as image:tag@sha256:digest → SPAM scans (or returns cached results for that digest).

The response must be expandable so we can later attach SPAM's triage recommendation (tier, threat/trust score, advisory narrative, suppression state) to the same payload.

What already exists (reuse, don't rebuild)

  • Storage: artifacts.StoreSBOM()SBOM (content-hash dedup) + SBOMBinding ((AssetType, AssetRefID), types REPO_COMMIT / IMAGE_DIGEST) — api/internal/artifacts/models.go.
  • Image identity: ImageDigest keyed by (Registry, Repository, Digest)api/internal/assets/models.go:76. The image:tag@sha256:digest form maps cleanly onto this.
  • Scanning jobs: SBOM_SCAN, IMAGE_SCAN, SBOM_ADHOC_SCAN job types (api/internal/jobs/types.go) run as scanner Deployments; results upserted as ImageVulnFinding / unified vuln views.
  • Internal upload endpoints (runner→API, HMAC-gated): /api/sbom-scan/image-result, /api/sbom-scan/repo-result, /api/image-scan/results. These are the internal plumbing, NOT the public API — the new endpoints sit in front and enqueue jobs.
  • Triage output to attach later: asset_risk table + Tier()/ThreatScore()/TrustScore() in api/internal/assetrisk/score.go; advisory narratives in asset_advisories.

Design — public scan API

New router group /api/v1/scan (versioned, distinct from UI /api/*), in internal/uiapi or a new internal/publicapi package.

Auth

OIDC sessions are for humans; CI needs service tokens. Proposal: new api_token table (scoped, hashed, prefix-identifiable like spam_pat_…), reusing the AES-GCM secrets package for any stored secret material. Tokens carry an ACL scope (which repos/images they may submit/read) consistent with acl.Provider. Admin-issued via /api/admin/....

Submit

  • POST /api/v1/scan/sbom — body: SBOM document (+ optional asset hint {type, ref}); Content-Type indicates format. → StoreSBOM, create binding, enqueue SBOM_SCAN, return {scan_id, status: "queued", self}.
  • POST /api/v1/scan/image — body: {"image": "registry/repo:tag@sha256:..."}. Parse → upsert ImageDigest, enqueue IMAGE_SCAN. If a recent scan exists for that digest, short-circuit and return cached results (configurable freshness).

Poll / fetch result

  • GET /api/v1/scan/{scan_id}{status: queued|running|succeeded|failed, ...}.
  • On success, embed a stable, versioned result envelope:
{
  "scan_id": "…",
  "status": "succeeded",
  "asset": { "type": "IMAGE_DIGEST", "image": "…@sha256:…" },
  "summary": { "critical": 0, "high": 3, "medium": 7, "low": 1, "kev": 0 },
  "findings": [ { "vuln_id": "CVE-…", "severity": "HIGH", "pkg": "…",
                  "installed_version": "…", "fixed_version": "…",
                  "kev": false, "epss": 0.04 } ],
  // expansion slots — empty/absent today, populated later:
  "triage": null,        // { tier, threat_score, trust_score, reasons[] }
  "advisory": null,      // { summary, verdict }
  "suppressions": null   // applied suppression/VEX state
}

The triage / advisory / suppressions keys are the expandability contract: reserved now, filled once we wire assetrisk + advisory + the suppression epic into the response. Gate population behind ?include=triage,advisory.

Sync convenience

  • Optional POST /api/v1/scan/image?wait=30s long-poll that blocks until done or timeout, for simple pipeline steps. Default is async submit + poll (multi-replica friendly — job + DB, no in-memory wait state).

Edge cases

  1. Digest required for caching. tag alone is mutable; cache/identity keys on @sha256:digest. If only a tag is given, reject or resolve-then-record the digest.
  2. Unknown registry / private image. Reuse registrycreds for pull auth; clear error if SPAM can't pull.
  3. SBOM without an asset. Allow "anonymous" scans (no binding) that still return findings but don't enter the fleet dashboards / triage.
  4. Idempotency. Same digest submitted repeatedly → same ImageDigest, dedup scans within freshness window; support an Idempotency-Key.
  5. Rate limiting & size caps on SBOM uploads (CI can be chatty/large).
  6. ACL on read-back. A token may submit an image but only read results within its scope.
  7. Result schema versioning. Envelope carries a schema_version; additive-only changes for the expansion slots.

Tasks

  • Service-token model + issuance (admin) + auth middleware + ACL scoping
  • POST /api/v1/scan/sbom + POST /api/v1/scan/image (parse image:tag@sha256:digest)
  • GET /api/v1/scan/{id} with versioned result envelope + reserved expansion slots
  • Cache/short-circuit on fresh digest results; idempotency
  • Optional ?wait= long-poll
  • OpenAPI/docs + example GitHub Actions / GitLab CI snippet
  • (Later) populate triage/advisory/suppressions via assetrisk + advisory + suppression epic
## Goal A **machine-facing API** so CI/CD pipelines can submit a scan target and get a result back, without going through the human UI/OIDC flow. Two submission shapes: 1. **Upload an SBOM** (CycloneDX/SPDX) → SPAM stores + scans it (grype) → returns findings. 2. **Reference an image** as `image:tag@sha256:digest` → SPAM scans (or returns cached results for that digest). The response must be **expandable** so we can later attach SPAM's **triage recommendation** (tier, threat/trust score, advisory narrative, suppression state) to the same payload. ## What already exists (reuse, don't rebuild) - **Storage:** `artifacts.StoreSBOM()` → `SBOM` (content-hash dedup) + `SBOMBinding` (`(AssetType, AssetRefID)`, types `REPO_COMMIT` / `IMAGE_DIGEST`) — `api/internal/artifacts/models.go`. - **Image identity:** `ImageDigest` keyed by `(Registry, Repository, Digest)` — `api/internal/assets/models.go:76`. The `image:tag@sha256:digest` form maps cleanly onto this. - **Scanning jobs:** `SBOM_SCAN`, `IMAGE_SCAN`, `SBOM_ADHOC_SCAN` job types (`api/internal/jobs/types.go`) run as scanner Deployments; results upserted as `ImageVulnFinding` / unified vuln views. - **Internal upload endpoints (runner→API, HMAC-gated):** `/api/sbom-scan/image-result`, `/api/sbom-scan/repo-result`, `/api/image-scan/results`. **These are the internal plumbing, NOT the public API** — the new endpoints sit in front and enqueue jobs. - **Triage output to attach later:** `asset_risk` table + `Tier()`/`ThreatScore()`/`TrustScore()` in `api/internal/assetrisk/score.go`; advisory narratives in `asset_advisories`. ## Design — public scan API New router group `/api/v1/scan` (versioned, distinct from UI `/api/*`), in `internal/uiapi` or a new `internal/publicapi` package. ### Auth OIDC sessions are for humans; CI needs **service tokens**. Proposal: new `api_token` table (scoped, hashed, prefix-identifiable like `spam_pat_…`), reusing the AES-GCM `secrets` package for any stored secret material. Tokens carry an ACL scope (which repos/images they may submit/read) consistent with `acl.Provider`. Admin-issued via `/api/admin/...`. ### Submit - `POST /api/v1/scan/sbom` — body: SBOM document (+ optional `asset` hint `{type, ref}`); `Content-Type` indicates format. → `StoreSBOM`, create binding, enqueue `SBOM_SCAN`, return `{scan_id, status: "queued", self}`. - `POST /api/v1/scan/image` — body: `{"image": "registry/repo:tag@sha256:..."}`. Parse → upsert `ImageDigest`, enqueue `IMAGE_SCAN`. If a recent scan exists for that digest, short-circuit and return cached results (configurable freshness). ### Poll / fetch result - `GET /api/v1/scan/{scan_id}` — `{status: queued|running|succeeded|failed, ...}`. - On success, embed a **stable, versioned result envelope**: ```jsonc { "scan_id": "…", "status": "succeeded", "asset": { "type": "IMAGE_DIGEST", "image": "…@sha256:…" }, "summary": { "critical": 0, "high": 3, "medium": 7, "low": 1, "kev": 0 }, "findings": [ { "vuln_id": "CVE-…", "severity": "HIGH", "pkg": "…", "installed_version": "…", "fixed_version": "…", "kev": false, "epss": 0.04 } ], // expansion slots — empty/absent today, populated later: "triage": null, // { tier, threat_score, trust_score, reasons[] } "advisory": null, // { summary, verdict } "suppressions": null // applied suppression/VEX state } ``` The `triage` / `advisory` / `suppressions` keys are the **expandability contract**: reserved now, filled once we wire `assetrisk` + advisory + the suppression epic into the response. Gate population behind `?include=triage,advisory`. ### Sync convenience - Optional `POST /api/v1/scan/image?wait=30s` long-poll that blocks until done or timeout, for simple pipeline steps. Default is async submit + poll (multi-replica friendly — job + DB, no in-memory wait state). ## Edge cases 1. **Digest required for caching.** `tag` alone is mutable; cache/identity keys on `@sha256:digest`. If only a tag is given, reject or resolve-then-record the digest. 2. **Unknown registry / private image.** Reuse `registrycreds` for pull auth; clear error if SPAM can't pull. 3. **SBOM without an asset.** Allow "anonymous" scans (no binding) that still return findings but don't enter the fleet dashboards / triage. 4. **Idempotency.** Same digest submitted repeatedly → same `ImageDigest`, dedup scans within freshness window; support an `Idempotency-Key`. 5. **Rate limiting & size caps** on SBOM uploads (CI can be chatty/large). 6. **ACL on read-back.** A token may submit an image but only read results within its scope. 7. **Result schema versioning.** Envelope carries a `schema_version`; additive-only changes for the expansion slots. ## Tasks - [ ] Service-token model + issuance (admin) + auth middleware + ACL scoping - [ ] `POST /api/v1/scan/sbom` + `POST /api/v1/scan/image` (parse `image:tag@sha256:digest`) - [ ] `GET /api/v1/scan/{id}` with versioned result envelope + reserved expansion slots - [ ] Cache/short-circuit on fresh digest results; idempotency - [ ] Optional `?wait=` long-poll - [ ] OpenAPI/docs + example GitHub Actions / GitLab CI snippet - [ ] (Later) populate `triage`/`advisory`/`suppressions` via `assetrisk` + advisory + suppression epic
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Motstandskraft/spam#23
No description provided.