Skip to content

Commit 1347b25

Browse files
committed
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
1 parent 0b4dcab commit 1347b25

2 files changed

Lines changed: 70 additions & 16 deletions

File tree

internal/npm/npm.go

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,15 @@ func (r *Registry) URLs() core.URLBuilder { //nolint:ireturn
5151
}
5252

5353
type packageResponse struct {
54-
ID string `json:"_id"`
55-
Name string `json:"name"`
56-
Description string `json:"description"`
57-
Homepage interface{} `json:"homepage"`
58-
Repository interface{} `json:"repository"`
59-
Versions map[string]versionInfo `json:"versions"`
60-
Time map[string]string `json:"time"`
61-
Maintainers []maintainerInfo `json:"maintainers"`
62-
DistTags map[string]string `json:"dist-tags"`
54+
ID string `json:"_id"`
55+
Name string `json:"name"`
56+
Description string `json:"description"`
57+
Homepage interface{} `json:"homepage"`
58+
Repository interface{} `json:"repository"`
59+
Versions map[string]versionInfo `json:"versions"`
60+
Time map[string]string `json:"time"`
61+
Maintainers []maintainerInfo `json:"maintainers"`
62+
DistTags map[string]string `json:"dist-tags"`
6363
}
6464

6565
type versionInfo struct {
@@ -79,6 +79,17 @@ type versionInfo struct {
7979
NpmUser map[string]interface{} `json:"_npmUser"`
8080
Engines interface{} `json:"engines"`
8181
Funding interface{} `json:"funding"`
82+
83+
ContentPolicy *ContentPolicy `json:"contentPolicy"`
84+
}
85+
86+
// ContentPolicy is the npm package.json "contentPolicy" field, propagated
87+
// into the packument version object. Maintainers set it to declare
88+
// dual-use content (security-relevant capabilities that automated
89+
// scanning could otherwise flag as malicious). See
90+
// https://docs.npmjs.com/policies/dual-use.
91+
type ContentPolicy struct {
92+
Class string `json:"class"` // "dual-use" is the only value npm defines today
8293
}
8394

8495
// 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
231242
Integrity: integrity,
232243
Status: status,
233244
Metadata: map[string]any{
234-
"deprecated": string(v.Deprecated),
235-
"dist": v.Dist,
236-
"engines": v.Engines,
237-
"_npmUser": v.NpmUser,
238-
"tarball": v.Dist.Tarball,
239-
"npm:attestations": v.Dist.Attestations,
240-
"npm:signatures": v.Dist.Signatures,
245+
"deprecated": string(v.Deprecated),
246+
"dist": v.Dist,
247+
"engines": v.Engines,
248+
"_npmUser": v.NpmUser,
249+
"tarball": v.Dist.Tarball,
250+
"npm:attestations": v.Dist.Attestations,
251+
"npm:signatures": v.Dist.Signatures,
252+
"npm:contentPolicy": v.ContentPolicy,
241253
},
242254
})
243255
}

internal/npm/npm_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,48 @@ func TestFetchVersions_NoProvenance(t *testing.T) {
160160
if sigs, _ := versions[0].Metadata["npm:signatures"].([]Signature); len(sigs) != 0 {
161161
t.Errorf("expected empty signatures, got %+v", sigs)
162162
}
163+
if cp, _ := versions[0].Metadata["npm:contentPolicy"].(*ContentPolicy); cp != nil {
164+
t.Errorf("expected nil contentPolicy, got %+v", cp)
165+
}
166+
}
167+
168+
// TestFetchVersions_ContentPolicy asserts the package.json contentPolicy
169+
// field round-trips into Version.Metadata as a typed *ContentPolicy.
170+
func TestFetchVersions_ContentPolicy(t *testing.T) {
171+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
172+
resp := map[string]interface{}{
173+
"_id": "dualuse",
174+
"name": "dualuse",
175+
"dist-tags": map[string]string{"latest": "1.0.0"},
176+
"versions": map[string]interface{}{
177+
"1.0.0": map[string]interface{}{
178+
"name": "dualuse",
179+
"version": "1.0.0",
180+
"contentPolicy": map[string]string{"class": "dual-use"},
181+
"dist": map[string]interface{}{"integrity": "sha512-xxx", "tarball": "https://example.invalid/dualuse-1.0.0.tgz"},
182+
},
183+
},
184+
}
185+
_ = json.NewEncoder(w).Encode(resp)
186+
}))
187+
defer server.Close()
188+
189+
reg := New(server.URL, core.DefaultClient())
190+
versions, err := reg.FetchVersions(context.Background(), "dualuse")
191+
if err != nil {
192+
t.Fatalf("FetchVersions: %v", err)
193+
}
194+
if len(versions) != 1 {
195+
t.Fatalf("versions = %d, want 1", len(versions))
196+
}
197+
198+
cp, ok := versions[0].Metadata["npm:contentPolicy"].(*ContentPolicy)
199+
if !ok || cp == nil {
200+
t.Fatalf("Metadata[npm:contentPolicy] not a *ContentPolicy: %T", versions[0].Metadata["npm:contentPolicy"])
201+
}
202+
if cp.Class != "dual-use" {
203+
t.Errorf("contentPolicy.class = %q, want %q", cp.Class, "dual-use")
204+
}
163205
}
164206

165207
// TestFetchVersions_LegacyEnginesArray verifies that versions whose

0 commit comments

Comments
 (0)