From 1347b253377ee055dbd8a593fae2f196155a4e01 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 29 Jul 2026 12:37:49 +0100 Subject: [PATCH 1/2] npm: parse contentPolicy field into Version.Metadata npm now lets maintainers declare dual-use content via a contentPolicy object in package.json, propagated into the packument version object. Parse it as a typed *ContentPolicy and expose it under Metadata["npm:contentPolicy"] alongside attestations and signatures. https://docs.npmjs.com/policies/dual-use --- internal/npm/npm.go | 44 +++++++++++++++++++++++++--------------- internal/npm/npm_test.go | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/internal/npm/npm.go b/internal/npm/npm.go index 41fc192..ca758aa 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -51,15 +51,15 @@ func (r *Registry) URLs() core.URLBuilder { //nolint:ireturn } type packageResponse struct { - ID string `json:"_id"` - Name string `json:"name"` - Description string `json:"description"` - Homepage interface{} `json:"homepage"` - Repository interface{} `json:"repository"` - Versions map[string]versionInfo `json:"versions"` - Time map[string]string `json:"time"` - Maintainers []maintainerInfo `json:"maintainers"` - DistTags map[string]string `json:"dist-tags"` + ID string `json:"_id"` + Name string `json:"name"` + Description string `json:"description"` + Homepage interface{} `json:"homepage"` + Repository interface{} `json:"repository"` + Versions map[string]versionInfo `json:"versions"` + Time map[string]string `json:"time"` + Maintainers []maintainerInfo `json:"maintainers"` + DistTags map[string]string `json:"dist-tags"` } type versionInfo struct { @@ -79,6 +79,17 @@ type versionInfo struct { NpmUser map[string]interface{} `json:"_npmUser"` Engines interface{} `json:"engines"` Funding interface{} `json:"funding"` + + ContentPolicy *ContentPolicy `json:"contentPolicy"` +} + +// ContentPolicy is the npm package.json "contentPolicy" field, propagated +// into the packument version object. Maintainers set it to declare +// dual-use content (security-relevant capabilities that automated +// scanning could otherwise flag as malicious). See +// https://docs.npmjs.com/policies/dual-use. +type ContentPolicy struct { + Class string `json:"class"` // "dual-use" is the only value npm defines today } // deprecatedField is the npm version "deprecated" field, which the packument @@ -231,13 +242,14 @@ func (r *Registry) FetchVersions(ctx context.Context, name string) ([]core.Versi Integrity: integrity, Status: status, Metadata: map[string]any{ - "deprecated": string(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, + "deprecated": string(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, + "npm:contentPolicy": v.ContentPolicy, }, }) } diff --git a/internal/npm/npm_test.go b/internal/npm/npm_test.go index 63f4884..b0c5680 100644 --- a/internal/npm/npm_test.go +++ b/internal/npm/npm_test.go @@ -160,6 +160,48 @@ func TestFetchVersions_NoProvenance(t *testing.T) { if sigs, _ := versions[0].Metadata["npm:signatures"].([]Signature); len(sigs) != 0 { t.Errorf("expected empty signatures, got %+v", sigs) } + if cp, _ := versions[0].Metadata["npm:contentPolicy"].(*ContentPolicy); cp != nil { + t.Errorf("expected nil contentPolicy, got %+v", cp) + } +} + +// TestFetchVersions_ContentPolicy asserts the package.json contentPolicy +// field round-trips into Version.Metadata as a typed *ContentPolicy. +func TestFetchVersions_ContentPolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := map[string]interface{}{ + "_id": "dualuse", + "name": "dualuse", + "dist-tags": map[string]string{"latest": "1.0.0"}, + "versions": map[string]interface{}{ + "1.0.0": map[string]interface{}{ + "name": "dualuse", + "version": "1.0.0", + "contentPolicy": map[string]string{"class": "dual-use"}, + "dist": map[string]interface{}{"integrity": "sha512-xxx", "tarball": "https://example.invalid/dualuse-1.0.0.tgz"}, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + reg := New(server.URL, core.DefaultClient()) + versions, err := reg.FetchVersions(context.Background(), "dualuse") + if err != nil { + t.Fatalf("FetchVersions: %v", err) + } + if len(versions) != 1 { + t.Fatalf("versions = %d, want 1", len(versions)) + } + + cp, ok := versions[0].Metadata["npm:contentPolicy"].(*ContentPolicy) + if !ok || cp == nil { + t.Fatalf("Metadata[npm:contentPolicy] not a *ContentPolicy: %T", versions[0].Metadata["npm:contentPolicy"]) + } + if cp.Class != "dual-use" { + t.Errorf("contentPolicy.class = %q, want %q", cp.Class, "dual-use") + } } // TestFetchVersions_LegacyEnginesArray verifies that versions whose From c78d0d38eb8990a77b5a9b2383de1a8c8d1c2ba0 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 29 Jul 2026 13:06:23 +0100 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal/npm/npm_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/npm/npm_test.go b/internal/npm/npm_test.go index b0c5680..c9e0576 100644 --- a/internal/npm/npm_test.go +++ b/internal/npm/npm_test.go @@ -160,8 +160,13 @@ func TestFetchVersions_NoProvenance(t *testing.T) { if sigs, _ := versions[0].Metadata["npm:signatures"].([]Signature); len(sigs) != 0 { t.Errorf("expected empty signatures, got %+v", sigs) } - if cp, _ := versions[0].Metadata["npm:contentPolicy"].(*ContentPolicy); cp != nil { - t.Errorf("expected nil contentPolicy, got %+v", cp) + cpAny, ok := versions[0].Metadata["npm:contentPolicy"] + if !ok { + t.Fatalf("expected Metadata[npm:contentPolicy] key to be present") + } + cp, ok := cpAny.(*ContentPolicy) + if !ok || cp != nil { + t.Errorf("expected nil *ContentPolicy, got %T (%+v)", cpAny, cp) } }