Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions internal/npm/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
},
})
}
Expand Down
99 changes: 99 additions & 0 deletions internal/npm/npm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions registries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}