README
¶
Results API server
A cloud-agnostic, self-hosted OpenSSF Scorecard results API server
(cmd/scorecard-api). It serves pre-computed Scorecard results from any object
store and generates them live on demand when the cache misses — a read-through
cache over an in-process scan engine ("hybrid").
It speaks the ossf/scorecard-webapp
GET contract, so it is a drop-in --base-url target for
uwu-tools/scorecard-mcp and any
client of the public api.scorecard.dev.
This is not the deployment path. What serves
api.scorecard.devisapi/, and that is where production API surface belongs. This server was built here as a provider-agnostic implementation of the same contract, on the assumption that its durable pieces would graft outward intoossf/scorecard-webappandossf/scorecard— but both of those trees were imported into this repository instead, so the graft reversed. It is retained and still builds.docs/upstream-graft.mdexplains the reversal and what still genuinely grafts upstream.
Why it exists
The public Scorecard API only covers repositories that opted in via
publish_results: true, and it is a hosted service you do not operate. Teams
that want results for private repos, for repos not in the weekly public
scan, or on infrastructure they run themselves have no first-class option. This
server fills that gap: it serves the same contract from any object store and
computes results on demand.
How it works
Every request flows through a single read-through cache (the orchestrator), which decides serve-vs-scan:
flowchart TD
C["Client — GET /projects/{host}/{org}/{repo}?commit=SHA"] --> O["orchestrator (read-through cache)"]
O --> G{"store hit and fresh?"}
G -- "yes" --> RC["200 — result (source: cached or upstream)"]
G -- "no (miss or stale)" --> MODE{"upstream fallback?"}
MODE -- "off" --> SF["single-flight: one scan per key"]
MODE -- "fetch-first" --> FE["fallback.Fetch — upstream API (≤ max-age)"]
MODE -- "safety-net" --> SF
FE -- "hit" --> BF["backfill (origin = upstream)"]
BF --> RU["200 — source: upstream"]
FE -- "miss" --> SF
SF --> SC["scan.Run — live pkg/scorecard.Run (bounded)"]
SC -- "within SCORECARD_SYNC_TIMEOUT" --> PUT["store.Put — latest + commit-pinned"]
PUT --> RL["200 — source: live"]
SC -- "skip / error (safety-net)" --> FE2["fallback.Fetch — upstream rescue"]
FE2 --> RU
SC -- "exceeds timeout" --> R202["202 + Retry-After (scan continues in background)"]
R202 -. "next request hits the cache" .-> O
- Freshness: commit-pinned results (
?commit=SHA) are immutable and cached forever;latestresults carry a freshness window — the TTL for locally-scanned results, the fallback max-age for upstream-sourced ones — and are refreshed on expiry. - De-duplication: concurrent requests for the same key coalesce into exactly one scan (single-flight), so a burst of clients can't trigger redundant scans or exhaust SCM rate limits.
- Upstream fallback (optional): off by default. When enabled, a cache miss
can reuse a recent result from an upstream Scorecard API instead of scanning,
reported honestly as
X-Scorecard-Source: upstream— see Upstream result fallback. - Persistence: a live scan writes both the
latestpointer and the commit-pinned object; a used upstream result is backfilled tagged as upstream. Results are immediately reusable by this server, the public webapp, andscorecard-mcp.
Server components
The binary wires nine focused packages: config → store + scanner + flags → orchestrator (+ optional fallback) → HTTP. The model package (JSON2 +
provenance + repo-ref parsing) is shared across all of them.
| Component | Path | Responsibility |
|---|---|---|
| Binary | cmd/scorecard-api |
Loads config, wires the store, scanner, and orchestrator, and serves the HTTP API with graceful shutdown. |
| model | internal/model |
Lean mirror of Scorecard JSON2 plus provenance and platform/owner/repo parsing (default github.com; also gitlab.com; 40-hex commits). |
| store | internal/store |
Object storage over gocloud.dev/blob; backend chosen by URL; implements the {host}/{org}/{repo}[/{commit}]/results.json key contract. |
| scan | internal/scan |
The live engine: wraps pkg/scorecard.Run, reuses the OSS-Fuzz/CII/vulnerability clients across scans, and formats results to JSON2. |
| fallback | internal/fallback |
Optional best-effort client that fetches an existing latest result from an upstream Scorecard API over the /projects contract (bounded by a timeout and max-age). |
| tokens | internal/tokens |
SCM token pool (feeds Scorecard's GITHUB_AUTH_TOKEN rotation), per-host rate limiter, and bounded-concurrency worker pool with backoff. |
| orchestrator | internal/orchestrator |
The read-through cache: freshness/TTL policy, single-flight de-duplication, and the sync-vs-async (200/202) decision. |
| httpapi | internal/httpapi |
The webapp GET contract (/projects, /badge), plus /capabilities, /health, /readyz, and error mapping. |
| config | internal/config |
12-factor environment configuration with fail-fast validation. |
| flags | internal/flags |
Runtime feature-flag seam over OpenFeature; in-process env-seeded static provider with fail-safe defaults (gates the upstream fallback's enabled/mode). |
Prerequisites
Beyond Go matching go.mod:
- An object store reachable by a
gocloud.dev/blobURL — a local directory (file://…) is enough to start; see Storage backends. - For live scans only: network egress (the engine calls the SCM API and
Scorecard's auxiliary data sources) and an SCM token —
GITHUB_AUTH_TOKEN, orSCORECARD_GITHUB_TOKENSfor a pool fed into Scorecard's token rotation. Serving already-cached results needs neither.
Build
From the repository root:
go build -o scorecard-api ./cmd/scorecard-api
Run the server
export SCORECARD_RESULTS_BUCKET_URL="file:///tmp/scorecard" # required
export GITHUB_AUTH_TOKEN="ghp_..." # only for live scans
./scorecard-api
The server logs its listen address (default :8080) and the resolved bucket URL,
then serves until it receives SIGINT/SIGTERM.
Endpoints
| Method & path | Returns |
|---|---|
GET /projects/{host}/{org}/{repo} |
Latest result as canonical Scorecard JSON2 |
GET /projects/{host}/{org}/{repo}?commit={sha} |
The immutable result for a 40-hex commit |
GET /projects/{host}/{org}/{repo}/badge |
SVG badge for the aggregate score |
GET /capabilities |
This server's mode, coverage, freshness policy, and caveats |
GET /health |
Liveness (always 200 while serving) |
GET /readyz |
Readiness (503 until dependencies are usable) |
host includes the TLD (e.g. github.com). github.com and gitlab.com are
supported.
# Latest (cache HIT if present, else a live scan populates the cache)
curl -s http://localhost:8080/projects/github.com/ossf/scorecard | jq .score
# A specific, immutable commit
curl -s "http://localhost:8080/projects/github.com/ossf/scorecard?commit=<40-hex-sha>"
# Badge, capabilities, health
curl -s http://localhost:8080/projects/github.com/ossf/scorecard/badge
curl -s http://localhost:8080/capabilities | jq .
curl -sI http://localhost:8080/health
On a cache miss the server attempts a synchronous scan within
SCORECARD_SYNC_TIMEOUT. If it finishes in time you get 200 with the result;
otherwise you get 202 Accepted with a Retry-After header while the scan
continues in the background and populates the cache for your next request.
Malformed refs return 400, unreachable/blocked repos 404, and scan failures
502.
Provenance headers
Result bodies are canonical JSON2, served verbatim so webapp-compatible clients parse them unchanged. Provenance is carried in response headers instead:
| Header | Meaning |
|---|---|
X-Scorecard-Source |
cached or live |
X-Scorecard-Resolved-Commit |
The commit the result was computed at |
X-Scorecard-Generated-At |
Result generation date (RFC 3339) |
X-Scorecard-Version |
Scorecard engine version |
X-Scorecard-Complete |
Whether every check produced a conclusive score |
GET /capabilities
Exists so clients report provenance from the server instead of assuming public-cache behavior:
{
"mode": "cached+live",
"checks": "all",
"requires_opt_in": false,
"latest_ttl_seconds": 86400,
"caveats": [
"Scorecard results are heuristic signals, not a verdict; a repository is never labeled secure or insecure.",
"A score of -1 is inconclusive, not a failing score.",
"Results are generated on demand for any repository the configured token can access; no publish_results opt-in is required.",
"Latest results are cached with a TTL and refreshed on expiry; pin a commit for an immutable result."
]
}
Storage backends
Results are persisted through gocloud.dev/blob, so the
backend is selected entirely by a URL — nothing cloud-specific is compiled in.
Credentials resolve via each backend's default chain.
| Backend | SCORECARD_RESULTS_BUCKET_URL example |
|---|---|
| Local filesystem | file:///var/lib/scorecard |
| S3-compatible (AWS S3, self-hosted, etc.) | s3://my-bucket?region=us-east-1&endpoint=localhost:9000&s3ForcePathStyle=true |
| Azure Blob | azblob://my-container |
| Google Cloud Storage | gs://my-bucket |
| In-memory (tests only) | mem:// |
For the local filesystem backend, the bucket directory is almost always a
separate mount from the process's own temp directory — a container volume, a
bind mount, or a Kubernetes PVC. fileblob's default write path (temp file in
os.TempDir(), then rename into place) fails with invalid cross-device link
in that case, so this server always opens file:// buckets with fileblob's
no_tmp_dir option
forced on: the temp file is written next to the destination instead. You may
briefly see a results.json.*.tmp file next to a result during a write.
Object keys match scorecard-webapp exactly, so the same objects are servable by
the public webapp and readable by scorecard-mcp:
{host}/{org}/{repo}/results.json # latest (mutable, TTL)
{host}/{org}/{repo}/{commit}/results.json # pinned (immutable)
Configuration
All configuration comes from the environment. Only the bucket URL is required.
| Variable | Default | Description |
|---|---|---|
SCORECARD_RESULTS_BUCKET_URL |
— (required) | gocloud.dev/blob URL of the result store |
SCORECARD_LISTEN_ADDR |
:8080 |
HTTP listen address (falls back to :$PORT) |
SCORECARD_LATEST_TTL |
24h |
Freshness window for latest results |
SCORECARD_SYNC_TIMEOUT |
20s |
How long a request waits before returning 202 |
SCORECARD_SCAN_TIMEOUT |
5m |
Bound on a background scan |
SCORECARD_RETRY_AFTER |
10s |
Retry-After hint on a 202 |
SCORECARD_SCAN_CONCURRENCY |
4 |
Max simultaneous live scans |
SCORECARD_ENABLED_CHECKS |
all | Comma-separated check names to restrict to |
SCORECARD_GITHUB_TOKENS |
— | Comma-separated SCM token pool (falls back to GITHUB_AUTH_TOKEN) |
SCORECARD_HOST_RATE_PER_SECOND |
0 (unlimited) |
Per-host scan rate limit |
SCORECARD_HOST_RATE_BURST |
1 |
Per-host rate burst |
SCORECARD_LOG_LEVEL |
info |
debug, info, warn, or error |
SCORECARD_FLAGS_PROVIDER |
static |
Feature-flag provider (in-process, env-seeded; static only for now) |
SCORECARD_FALLBACK_URL |
— (disabled) | Upstream Scorecard API base URL (e.g. https://api.scorecard.dev); enables the fallback |
SCORECARD_FALLBACK_TIMEOUT |
5s |
Per-fetch timeout for the upstream fallback |
SCORECARD_FALLBACK_MAX_AGE |
168h (7d) |
Max age of an upstream result to use or backfill |
Upstream result fallback
Optionally, the server can reuse an existing result from an upstream Scorecard API
(e.g. api.scorecard.dev) instead of always scanning locally. It is off by
default; set SCORECARD_FALLBACK_URL to enable it. Two orderings are selected
by the fallback.mode feature flag:
fetch-first(default) — cache, then upstream, then a live scan on an upstream miss. Cheapest: reuse a recent upstream result and scan only when there is none. For a repository the operator does not own, a local scan is often less complete anyway (the token can't read branch protection and similar), so the upstream is frequently comparable and far cheaper.safety-net— cache, then a live scan, and the upstream only when a scan can't run (rate-limited, blocked, transient failure).
Runtime toggles are feature flags, overridable per flag via SCORECARD_FLAG_*:
| Flag override | Default | Description |
|---|---|---|
SCORECARD_FLAG_FALLBACK_ENABLED |
true |
Kill-switch (when a URL is configured) |
SCORECARD_FLAG_FALLBACK_MODE |
fetch-first |
fetch-first or safety-net |
An upstream result may be up to a week old (bounded by SCORECARD_FALLBACK_MAX_AGE),
may omit some checks, and covers only repositories that opted in upstream via
publish_results. It is served and backfilled with X-Scorecard-Source: upstream
and its own generation date, so a client can tell it from a local scan.
Commit-pinned requests never use the upstream (it answers only latest), and
/capabilities reports mode cached+upstream+live with these caveats when the
fallback is enabled.
As a scorecard-mcp backend
scorecard-mcp is an MCP server
that reads Scorecard results. Point it at this server with -base-url, and its
get_repo_score / get_check_result tools resolve against your cache + live
scans instead of the public API:
scorecard-mcp -base-url http://localhost:8080
Configure it in an MCP host (Claude Desktop/Code, VS Code) the same way, setting
the base URL to your server. See the scorecard-mcp README for host wiring.
Verifying end to end
docs/acceptance.md is a reproducible runbook that
drives a real scorecard-mcp binary against this server and checks both a cache
HIT on fileblob and a cache MISS that triggers a live scan, persists,
and re-serves from cache.
Running locally with Docker Compose
Run from this directory — the compose stack and its Dockerfile live here,
and build with the repository root as their context:
cd cmd/scorecard-api
cp .env.example .env # set GITHUB_AUTH_TOKEN for live scans
docker compose up --build
This builds the image and serves on :8080, persisting results to ./data on
the host (via a bind mount) so they survive docker compose down and are
browsable directly — see Storage backends. The container
runs as a non-root user, so ./data must be writable by it; if Compose
doesn't create it for you, mkdir -p data && chmod 0777 data first.
To exercise the S3-compatible code path instead of the default local filesystem store, layer the S3 override, which also spins up a self-hosted S3-compatible store and creates the bucket:
docker compose -f docker-compose.yml -f docker-compose.s3.yml up --build
Roadmap
Delivered in v0: the blob store (all drivers), the read-through cache
(single-flight + sync/async), the live scan engine, the token/rate manager, the
HTTP contract (/projects, /badge, /capabilities, /health, /readyz),
and acceptance against scorecard-mcp on fileblob. Planned or deferred:
- S3-compatible leg of the smoke test in CI (the store already has a gated integration test).
- Teach
scorecard-mcpto read/capabilitiesso it reports this server's provenance instead of the hardcoded public-cache caveats. - A
gocloud.dev/pubsubbroker for multi-node scan fan-out. - A warm-cache scheduler and an analytics/index layer.
- Signed-upload
POST(Sigstore) and request-level auth / multi-tenancy. - Grafting the durable pieces upstream (see
docs/upstream-graft.md).
Convergence. Putting gocloud.dev/blob under the batch pipeline and letting
this orchestrator serve batch-produced results is what landing the two in one
repository makes possible. It needs its own spec and is deliberately deferred
(C11).
Acknowledgements
This server mirrors the GET contract and blob layout defined by
ossf/scorecard-webapp, and uses
uwu-tools/scorecard-mcp as its
reference client and acceptance test.
Documentation
¶
Overview ¶
Command scorecard-api is a cloud-agnostic, self-hosted OpenSSF Scorecard results API server. It serves pre-computed results from any object store and generates them live on demand — a read-through cache over an in-process scan engine ("hybrid"). It speaks the ossf/scorecard-webapp GET contract so it is a drop-in --base-url target for uwu-tools/scorecard-mcp.
All configuration comes from the environment; see internal/config.