From b5cc17743d510d73ef10ff5cf696e4ea8ff7f9fc Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 12 May 2026 12:33:32 +0100 Subject: [PATCH] npm: typed access to dist.attestations and dist.signatures When a publisher uses trusted publishing, npm exposes two metadata signals alongside each version: a dist.attestations pointer at the separately-fetched sigstore bundle, and a dist.signatures array of ECDSA-P256 signatures over '{name}@{version}:{integrity}'. Both were silently dropped during JSON decode because distInfo only carried shasum/tarball/integrity. Adds typed structs (npm.AttestationRef, npm.Signature) and populates them through to Version.Metadata under 'npm:attestations' and 'npm:signatures' keys. Top-level registries package exports type aliases NPMAttestationRef / NPMSignature plus a NPMProvenance helper so callers can read the typed values without re-casting through map[string]any. Callers that want to validate the registry-published signatures or fetch the sigstore bundle from dist.attestations.url can now do so without re-parsing the version document themselves. --- internal/npm/npm.go | 42 +++++++++++++---- internal/npm/npm_test.go | 99 ++++++++++++++++++++++++++++++++++++++++ registries.go | 30 ++++++++++++ 3 files changed, 163 insertions(+), 8 deletions(-) diff --git a/internal/npm/npm.go b/internal/npm/npm.go index 0cedb86..5963ac5 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -80,9 +80,33 @@ type versionInfo struct { } type distInfo struct { - Shasum string `json:"shasum"` - Tarball string `json:"tarball"` - Integrity string `json:"integrity"` + Shasum string `json:"shasum"` + Tarball string `json:"tarball"` + Integrity string `json:"integrity"` + Attestations *AttestationRef `json:"attestations,omitempty"` + Signatures []Signature `json:"signatures,omitempty"` +} + +// AttestationRef is the npm dist.attestations pointer published +// alongside a version when the publisher used trusted publishing. +// The URL points at a separate endpoint that returns the signed +// sigstore bundle(s); Provenance.PredicateType hints at the in-toto +// predicate type living there (typically +// https://slsa.dev/provenance/v1). +type AttestationRef struct { + URL string `json:"url"` + Provenance struct { + PredicateType string `json:"predicateType"` + } `json:"provenance"` +} + +// Signature is one entry from a version's dist.signatures array — an +// ECDSA P-256 signature over "{name}@{version}:{integrity}" produced +// by the registry's signing key. Keyid identifies which key in +// /-/npm/v1/keys to verify against. +type Signature struct { + Sig string `json:"sig"` + Keyid string `json:"keyid"` } type maintainerInfo struct { @@ -167,11 +191,13 @@ func (r *Registry) FetchVersions(ctx context.Context, name string) ([]core.Versi Integrity: integrity, Status: status, Metadata: map[string]any{ - "deprecated": v.Deprecated, - "dist": v.Dist, - "engines": v.Engines, - "_npmUser": v.NpmUser, - "tarball": v.Dist.Tarball, + "deprecated": v.Deprecated, + "dist": v.Dist, + "engines": v.Engines, + "_npmUser": v.NpmUser, + "tarball": v.Dist.Tarball, + "npm:attestations": v.Dist.Attestations, + "npm:signatures": v.Dist.Signatures, }, }) } diff --git a/internal/npm/npm_test.go b/internal/npm/npm_test.go index 6e84ea1..6269649 100644 --- a/internal/npm/npm_test.go +++ b/internal/npm/npm_test.go @@ -63,6 +63,105 @@ func TestFetchPackage(t *testing.T) { } } +// TestFetchVersions_Provenance asserts the dist.attestations pointer +// and dist.signatures slice round-trip into Version.Metadata as the +// typed AttestationRef and []Signature shapes. +func TestFetchVersions_Provenance(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := map[string]interface{}{ + "_id": "demo", + "name": "demo", + "dist-tags": map[string]string{"latest": "1.0.0"}, + "versions": map[string]interface{}{ + "1.0.0": map[string]interface{}{ + "name": "demo", + "version": "1.0.0", + "license": "MIT", + "dist": map[string]interface{}{ + "integrity": "sha512-deadbeef", + "tarball": "https://example.invalid/demo-1.0.0.tgz", + "attestations": map[string]interface{}{ + "url": "https://registry.example.invalid/-/npm/v1/attestations/demo@1.0.0", + "provenance": map[string]string{ + "predicateType": "https://slsa.dev/provenance/v1", + }, + }, + "signatures": []map[string]string{ + {"sig": "MEUCIabc==", "keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA"}, + }, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + versions, err := reg.FetchVersions(context.Background(), "demo") + if err != nil { + t.Fatalf("FetchVersions: %v", err) + } + if len(versions) != 1 { + t.Fatalf("versions = %d, want 1", len(versions)) + } + + v := versions[0] + att, ok := v.Metadata["npm:attestations"].(*AttestationRef) + if !ok || att == nil { + t.Fatalf("Metadata[npm:attestations] not a *AttestationRef: %T", v.Metadata["npm:attestations"]) + } + if att.URL != "https://registry.example.invalid/-/npm/v1/attestations/demo@1.0.0" { + t.Errorf("attestation URL = %q", att.URL) + } + if att.Provenance.PredicateType != "https://slsa.dev/provenance/v1" { + t.Errorf("attestation predicate type = %q", att.Provenance.PredicateType) + } + + sigs, ok := v.Metadata["npm:signatures"].([]Signature) + if !ok { + t.Fatalf("Metadata[npm:signatures] not a []Signature: %T", v.Metadata["npm:signatures"]) + } + if len(sigs) != 1 || sigs[0].Sig != "MEUCIabc==" { + t.Errorf("signatures = %+v", sigs) + } +} + +// TestFetchVersions_NoProvenance asserts that versions without +// dist.attestations / dist.signatures expose nil and (typed) nil +// slice without panic. +func TestFetchVersions_NoProvenance(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := map[string]interface{}{ + "_id": "plain", + "name": "plain", + "dist-tags": map[string]string{"latest": "1.0.0"}, + "versions": map[string]interface{}{ + "1.0.0": map[string]interface{}{ + "name": "plain", + "version": "1.0.0", + "dist": map[string]interface{}{"integrity": "sha512-xxx", "tarball": "https://example.invalid/plain-1.0.0.tgz"}, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + versions, err := reg.FetchVersions(context.Background(), "plain") + if err != nil { + t.Fatal(err) + } + if att, _ := versions[0].Metadata["npm:attestations"].(*AttestationRef); att != nil { + t.Errorf("expected nil attestation, got %+v", att) + } + if sigs, _ := versions[0].Metadata["npm:signatures"].([]Signature); len(sigs) != 0 { + t.Errorf("expected empty signatures, got %+v", sigs) + } +} + func TestFetchPackageScoped(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Path can be encoded in different ways depending on the URL library diff --git a/registries.go b/registries.go index 8a0ff6d..fffde96 100644 --- a/registries.go +++ b/registries.go @@ -38,6 +38,7 @@ import ( "github.com/git-pkgs/purl" "github.com/git-pkgs/registries/client" "github.com/git-pkgs/registries/internal/core" + "github.com/git-pkgs/registries/internal/npm" ) // Re-export types from internal/core @@ -233,3 +234,32 @@ func BulkFetchLatestVersions(ctx context.Context, purls []string, c *Client) map func BulkFetchLatestVersionsWithConcurrency(ctx context.Context, purls []string, c *Client, concurrency int) map[string]*Version { return core.BulkFetchLatestVersionsWithConcurrency(ctx, purls, c, concurrency) } + +// npm-specific provenance metadata. Typed accessors for the +// dist.attestations pointer and dist.signatures array that npm +// publishes alongside a version. Consumers reading these from +// Version.Metadata as map[string]any can use NPMProvenance to get +// typed values without re-implementing the cast. + +// NPMAttestationRef mirrors npm's dist.attestations pointer published +// alongside a version when the publisher used trusted publishing. +type NPMAttestationRef = npm.AttestationRef + +// NPMSignature mirrors one entry from a version's dist.signatures +// array — an ECDSA P-256 signature over the integrity string with +// keyid identifying the verifying key in /-/npm/v1/keys. +type NPMSignature = npm.Signature + +// NPMProvenance reads the npm-specific provenance fields from a +// Version returned by FetchVersionFromPURL or FetchVersions. Returns +// (nil, nil) when no provenance metadata is recorded — the registry +// doesn't always publish either field. The signatures slice may be +// empty even when the attestation pointer is set, and vice versa. +func NPMProvenance(v *Version) (*NPMAttestationRef, []NPMSignature) { + if v == nil || v.Metadata == nil { + return nil, nil + } + att, _ := v.Metadata["npm:attestations"].(*NPMAttestationRef) + sigs, _ := v.Metadata["npm:signatures"].([]NPMSignature) + return att, sigs +}