fhirsmith 0.18.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +190 -9
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +199 -24
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +193 -67
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +861 -95
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +125 -61
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/tho-precedence.js +40 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +175 -15
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The feed check: reads a package feed the way the crawler does, and reports what it finds,
|
|
9
|
+
* without storing anything. For /packages/feed-check (feed-check-page.js is its HTML).
|
|
10
|
+
*
|
|
11
|
+
* For each item, as the crawler would: the guid, title, date and link; whether the registry's
|
|
12
|
+
* package-restrictions allow the package from this feed; the package itself (downloaded, unpacked
|
|
13
|
+
* with the crawler's limits, and checked as the crawler checks it before storing); and its
|
|
14
|
+
* signature, against the keys package-feeds.json lists for the feed. Also whether this registry
|
|
15
|
+
* already holds the version, and if so whether it is the same bytes. Pages (RFC 5005 next links)
|
|
16
|
+
* are followed until maxItems items have been checked. Every request goes through the crawler's
|
|
17
|
+
* SSRF guard.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const PackageCrawler = require('./package-crawler');
|
|
22
|
+
|
|
23
|
+
const MB = 1024 * 1024;
|
|
24
|
+
const QUIET = { info() {}, warn() {}, error() {}, debug() {} };
|
|
25
|
+
|
|
26
|
+
class FeedCheck {
|
|
27
|
+
/**
|
|
28
|
+
* config: the packages module's config (crawler limits, allowPrivateAddresses).
|
|
29
|
+
* options: master() - the registry's feed list (package-feeds.json), or null; held(id, version)
|
|
30
|
+
* - {hash} (sha1) of the version if this registry holds it, or null; signing - the feed's
|
|
31
|
+
* signing information ({required, keys}) to use instead of the registry's, or null; maxItems;
|
|
32
|
+
* maxTotalMB.
|
|
33
|
+
*/
|
|
34
|
+
constructor(config, options) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.master = options.master;
|
|
37
|
+
this.held = options.held;
|
|
38
|
+
this.givenSigning = options.signing || null;
|
|
39
|
+
this.maxItems = options.maxItems;
|
|
40
|
+
this.maxTotalBytes = options.maxTotalMB * MB;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A crawler that only reads: no database, nothing stored
|
|
44
|
+
reader() {
|
|
45
|
+
const c = Object.create(PackageCrawler.prototype);
|
|
46
|
+
c.config = this.config;
|
|
47
|
+
c.log = QUIET;
|
|
48
|
+
c.totalBytes = 0;
|
|
49
|
+
c.signatureMode = 'check';
|
|
50
|
+
c.abortController = null;
|
|
51
|
+
c.skippedFeeds = new Set();
|
|
52
|
+
// The crawler also reads local files (localFeedDirs). A feed being checked is someone
|
|
53
|
+
// else's, so everything it links to - pages, packages, signatures - must be http(s)
|
|
54
|
+
for (const name of ['fetchXml', 'fetchUrl', 'fetchJson']) {
|
|
55
|
+
const real = PackageCrawler.prototype[name];
|
|
56
|
+
c[name] = function (url, ...rest) {
|
|
57
|
+
if (!/^https?:\/\//i.test(String(url))) {
|
|
58
|
+
return Promise.reject(new Error(`Not fetched: ${url} is not an http(s) URL`));
|
|
59
|
+
}
|
|
60
|
+
return real.call(this, url, ...rest);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return c;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Checks the feed at url. progress(report) is called as items are checked. Returns the report. */
|
|
67
|
+
async run(url, progress = () => {}) {
|
|
68
|
+
const c = this.reader();
|
|
69
|
+
const feedUrl = c.fixUrl(url);
|
|
70
|
+
const report = {
|
|
71
|
+
url, started: new Date().toISOString(), finished: null,
|
|
72
|
+
registered: null, signing: null, signingSource: null, pages: [], items: [], errors: [], notes: [], truncated: false
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let master = null;
|
|
76
|
+
try {
|
|
77
|
+
master = await this.master();
|
|
78
|
+
} catch (e) {
|
|
79
|
+
report.notes.push(`The registry's feed list couldn't be read, so package restrictions and signing keys aren't checked: ${e.message}`);
|
|
80
|
+
}
|
|
81
|
+
const entry = (master?.feeds || []).find(f => f && f.url && c.fixUrl(f.url) === feedUrl);
|
|
82
|
+
const restrictions = master?.['package-restrictions'] || [];
|
|
83
|
+
if (entry) {
|
|
84
|
+
report.registered = { name: entry.name || null };
|
|
85
|
+
} else if (master) {
|
|
86
|
+
report.notes.push(this.givenSigning
|
|
87
|
+
? 'This feed isn\'t in the registry\'s feed list (package-feeds.json), so the crawler doesn\'t read it. Signatures are checked against the keys given.'
|
|
88
|
+
: 'This feed isn\'t in the registry\'s feed list (package-feeds.json), so the crawler doesn\'t read it, and there are no signing keys to check signatures against.');
|
|
89
|
+
}
|
|
90
|
+
// the signing information given with the check, if any, instead of the registry's
|
|
91
|
+
const signing = this.givenSigning ? c.signingFor(this.givenSigning) : entry ? c.signingFor(entry.signing) : null;
|
|
92
|
+
if (signing) {
|
|
93
|
+
report.signing = { required: signing.required, keys: signing.keys.map(k => k.keyid) };
|
|
94
|
+
report.signingSource = this.givenSigning ? 'given' : 'registry';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const seenPages = new Set();
|
|
98
|
+
const seenTitles = new Set();
|
|
99
|
+
let pageUrl = feedUrl;
|
|
100
|
+
while (pageUrl) {
|
|
101
|
+
if (seenPages.has(pageUrl)) {
|
|
102
|
+
report.errors.push(`The feed's pages loop: ${pageUrl} is linked as a next page again`);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
seenPages.add(pageUrl);
|
|
106
|
+
let xml;
|
|
107
|
+
try {
|
|
108
|
+
xml = await c.fetchXml(pageUrl);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
report.errors.push(e.message);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
const channel = xml && xml.rss && xml.rss.channel;
|
|
114
|
+
if (!channel) {
|
|
115
|
+
report.errors.push(`${pageUrl} is not an RSS feed (there is no rss/channel)`);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
const items = Array.isArray(channel.item) ? channel.item : [channel.item].filter(Boolean);
|
|
119
|
+
report.pages.push({ url: pageUrl, items: items.length });
|
|
120
|
+
let stopped = false;
|
|
121
|
+
for (const item of items) {
|
|
122
|
+
if (report.items.length >= this.maxItems) {
|
|
123
|
+
report.truncated = true;
|
|
124
|
+
stopped = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
const result = await this.checkItem(c, item, feedUrl, signing, restrictions, seenTitles);
|
|
128
|
+
report.items.push(result);
|
|
129
|
+
progress(report);
|
|
130
|
+
if (result.rateLimited) {
|
|
131
|
+
report.errors.push('The feed\'s server is rate limiting requests (HTTP 429), so the check stopped');
|
|
132
|
+
stopped = true;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (stopped) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const links = channel['atom:link'] ? (Array.isArray(channel['atom:link']) ? channel['atom:link'] : [channel['atom:link']]) : [];
|
|
140
|
+
const next = links.find(l => l && l['@_rel'] === 'next' && l['@_href']);
|
|
141
|
+
pageUrl = next ? c.fixUrl(next['@_href']) : null;
|
|
142
|
+
}
|
|
143
|
+
if (report.pages.length > 0 && report.items.length === 0 && report.errors.length === 0) {
|
|
144
|
+
report.notes.push('The feed has no items');
|
|
145
|
+
}
|
|
146
|
+
report.downloadedMB = Math.round(c.totalBytes / MB * 10) / 10;
|
|
147
|
+
report.finished = new Date().toISOString();
|
|
148
|
+
return report;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async checkItem(c, item, feedUrl, signing, restrictions, seenTitles) {
|
|
152
|
+
const r = { title: null, guid: null, link: null, date: null, status: 'ok', problems: [], warnings: [], package: null, signature: null, held: null };
|
|
153
|
+
const text = v => (v && typeof v === 'object' ? v['#text'] : v);
|
|
154
|
+
r.title = text(item.title) ? String(text(item.title)).trim() : null;
|
|
155
|
+
r.guid = item.guid && text(item.guid) ? String(text(item.guid)) : null;
|
|
156
|
+
r.link = text(item.link) ? String(text(item.link)).trim() : null;
|
|
157
|
+
const done = () => {
|
|
158
|
+
r.status = r.skipped ? 'skipped' : r.problems.length > 0 ? 'error' : 'ok';
|
|
159
|
+
return r;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (!r.guid) {
|
|
163
|
+
r.problems.push('There is no guid, so the crawler can\'t process this item');
|
|
164
|
+
}
|
|
165
|
+
if (!r.title) {
|
|
166
|
+
r.problems.push('There is no title (it should be {id}#{version})');
|
|
167
|
+
return done();
|
|
168
|
+
}
|
|
169
|
+
if (!/^[^#\s]+#[^#\s]+$/.test(r.title)) {
|
|
170
|
+
r.warnings.push(`The title isn't {id}#{version}: ${r.title}`);
|
|
171
|
+
}
|
|
172
|
+
if (text(item.notForPublication) === 'true' || text(item.notForPublication) === true) {
|
|
173
|
+
r.skipped = 'The item is marked notForPublication, so the crawler ignores it';
|
|
174
|
+
return done();
|
|
175
|
+
}
|
|
176
|
+
const allowed = c.isPackageAllowed(r.title, feedUrl, restrictions);
|
|
177
|
+
if (!allowed.allowed) {
|
|
178
|
+
r.problems.push(`The registry doesn't allow ${r.title.split('#')[0]} from this feed (it may come from ${allowed.allowedFeeds})`);
|
|
179
|
+
}
|
|
180
|
+
if (seenTitles.has(r.title)) {
|
|
181
|
+
r.warnings.push('This version is listed more than once in the feed; the crawler only takes the first');
|
|
182
|
+
}
|
|
183
|
+
seenTitles.add(r.title);
|
|
184
|
+
let date = null;
|
|
185
|
+
try {
|
|
186
|
+
date = c.parsePubDate(text(item.pubDate));
|
|
187
|
+
r.date = date.toISOString();
|
|
188
|
+
} catch (e) {
|
|
189
|
+
r.problems.push(`The pubDate isn't a valid date: ${text(item.pubDate) || '(missing)'}`);
|
|
190
|
+
}
|
|
191
|
+
if (!r.link) {
|
|
192
|
+
r.problems.push('There is no link to the package');
|
|
193
|
+
return done();
|
|
194
|
+
}
|
|
195
|
+
if (c.totalBytes >= this.maxTotalBytes) {
|
|
196
|
+
r.skipped = `Not downloaded: the check has already downloaded ${this.maxTotalBytes / MB} MB, its limit`;
|
|
197
|
+
return done();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let buf;
|
|
201
|
+
try {
|
|
202
|
+
buf = await c.fetchUrl(c.fixUrl(r.link));
|
|
203
|
+
} catch (e) {
|
|
204
|
+
r.problems.push(e.message);
|
|
205
|
+
r.rateLimited = e.message.includes('RATE_LIMITED');
|
|
206
|
+
return done();
|
|
207
|
+
}
|
|
208
|
+
let npm;
|
|
209
|
+
try {
|
|
210
|
+
npm = await c.extractNpmPackage(buf, `${feedUrl}#${r.guid}`);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
r.problems.push(`The package can't be read: ${e.message}`);
|
|
213
|
+
return done();
|
|
214
|
+
}
|
|
215
|
+
r.package = {
|
|
216
|
+
id: npm.id, version: npm.version, fhirVersions: npm.fhirVersionList || null,
|
|
217
|
+
canonical: npm.canonical || null, sizeKB: Math.round(buf.length / 1024)
|
|
218
|
+
};
|
|
219
|
+
if (`${npm.id}#${npm.version}` !== r.title) {
|
|
220
|
+
r.warnings.push(`The feed says ${r.title}, but the package is ${npm.id}#${npm.version}; the crawler goes by the package`);
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
c.checkPackageContent(npm, r.title, feedUrl);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
r.problems.push(e.message);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// its signature
|
|
229
|
+
const sigUrl = c.signatureUrl(item);
|
|
230
|
+
if (signing) {
|
|
231
|
+
const itemLog = {};
|
|
232
|
+
let signature;
|
|
233
|
+
try {
|
|
234
|
+
signature = await c.fetchSignature(item);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
signature = { url: sigUrl, fetchError: e.message };
|
|
237
|
+
}
|
|
238
|
+
const verdict = c.checkSignature(signing, signature, buf, npm.id, npm.version, date, itemLog);
|
|
239
|
+
const s = itemLog.signature || '';
|
|
240
|
+
r.signature = {
|
|
241
|
+
url: sigUrl,
|
|
242
|
+
state: s.startsWith('valid') ? 'confirmed' : s === 'unsigned' ? 'absent' : s.startsWith('invalid') ? 'rejected' : 'unfetchable',
|
|
243
|
+
detail: s
|
|
244
|
+
};
|
|
245
|
+
if (verdict.refuse) {
|
|
246
|
+
r.problems.push(`The crawler would refuse this package: ${verdict.refuse}`);
|
|
247
|
+
}
|
|
248
|
+
} else if (sigUrl) {
|
|
249
|
+
r.signature = { url: sigUrl, state: 'unchecked', detail: 'Not checked: there are no keys for this feed in the registry\'s feed list' };
|
|
250
|
+
try {
|
|
251
|
+
const signature = await c.fetchSignature(item);
|
|
252
|
+
if (signature.fetchError) {
|
|
253
|
+
r.signature.detail += `; and it couldn't be fetched: ${signature.fetchError}`;
|
|
254
|
+
} else {
|
|
255
|
+
const keyid = JSON.parse(signature.text)?.verificationMaterial?.publicKey?.hint;
|
|
256
|
+
if (keyid) {
|
|
257
|
+
r.signature.detail += `; it says it was signed with ${keyid}`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (e) {
|
|
261
|
+
r.signature.detail += `; and it couldn't be read: ${e.message}`;
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
r.signature = { url: null, state: 'absent', detail: 'The item has no fhir:signature' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// whether this registry already has it
|
|
268
|
+
try {
|
|
269
|
+
const h = await this.held(npm.id, npm.version);
|
|
270
|
+
const sha1 = crypto.createHash('sha1').update(buf).digest('hex');
|
|
271
|
+
r.held = !h ? 'no' : h.hash === sha1 ? 'yes, the same bytes' : 'yes, but different bytes';
|
|
272
|
+
} catch (e) {
|
|
273
|
+
r.held = null;
|
|
274
|
+
}
|
|
275
|
+
return done();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { FeedCheck };
|
package/packages/npm-check.js
CHANGED
|
@@ -18,14 +18,19 @@
|
|
|
18
18
|
* Used by /packages/npm-check (a report on any registry) and by the test suites
|
|
19
19
|
* tests/packages/npm-registry*.test.js, so all of them run the same checks.
|
|
20
20
|
*
|
|
21
|
+
* A registry need not sign packages, but one that lists keys must sign every version with
|
|
22
|
+
* them: that is what `npm audit signatures` checks.
|
|
23
|
+
*
|
|
21
24
|
* References:
|
|
22
25
|
* https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md
|
|
23
26
|
* https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md
|
|
27
|
+
* https://docs.npmjs.com/about-registry-signatures
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
const crypto = require('crypto');
|
|
27
31
|
const zlib = require('zlib');
|
|
28
32
|
const axios = require('axios');
|
|
33
|
+
const { verifySignature } = require('./npm-signing');
|
|
29
34
|
|
|
30
35
|
// What the npm CLI sends when it fetches a package document
|
|
31
36
|
const NPM_ACCEPT = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*';
|
|
@@ -40,7 +45,8 @@ const STEPS = [
|
|
|
40
45
|
{ id: 'search', title: '1. Search', request: 'GET /-/v1/search?text={name}' },
|
|
41
46
|
{ id: 'package', title: '2. Package document', request: 'GET /{name}' },
|
|
42
47
|
{ id: 'version', title: '3. Version document', request: 'GET /{name}/{version}' },
|
|
43
|
-
{ id: 'tarball', title: '4. Tarball', request: 'GET {dist.tarball}' }
|
|
48
|
+
{ id: 'tarball', title: '4. Tarball', request: 'GET {dist.tarball}' },
|
|
49
|
+
{ id: 'signatures', title: '5. Signatures', request: 'GET /-/npm/v1/keys' }
|
|
44
50
|
];
|
|
45
51
|
|
|
46
52
|
// A check's failure, with a message for people
|
|
@@ -302,6 +308,53 @@ const CHECKS = [
|
|
|
302
308
|
c.eq(manifest.name, c.pkg, 'package.json name');
|
|
303
309
|
c.eq(manifest.version, c.version, 'package.json version');
|
|
304
310
|
}
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
// 5. signatures
|
|
314
|
+
{
|
|
315
|
+
step: 'signatures', uses: ['keys'], title: 'the registry\'s keys are a 404 (it doesn\'t sign) or a list of ECDSA P-256 public keys',
|
|
316
|
+
run(c) {
|
|
317
|
+
if (c.response('keys').status === 404) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
c.status('keys', 200);
|
|
321
|
+
const keys = c.json('keys').keys;
|
|
322
|
+
c.ok(Array.isArray(keys) && keys.length > 0, 'keys is not a non-empty array');
|
|
323
|
+
keys.forEach((k, i) => {
|
|
324
|
+
c.ok(typeof k.keyid === 'string' && k.keyid.length > 0, `keys[${i}]: keyid is not a string`);
|
|
325
|
+
c.eq(k.keytype, 'ecdsa-sha2-nistp256', `keys[${i}].keytype`);
|
|
326
|
+
c.eq(k.scheme, 'ecdsa-sha2-nistp256', `keys[${i}].scheme`);
|
|
327
|
+
c.ok(k.expires === null || k.expires === undefined || isDate(k.expires), `keys[${i}]: expires is not null or a date: ${show(k.expires)}`);
|
|
328
|
+
try {
|
|
329
|
+
crypto.createPublicKey({ key: Buffer.from(k.key, 'base64'), format: 'der', type: 'spki' });
|
|
330
|
+
} catch (e) {
|
|
331
|
+
c.fail(`keys[${i}]: key is not a base64 SPKI public key: ${e.message}`);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
step: 'signatures', uses: ['keys', 'packument', 'fullPackument'], title: 'if it has keys, every version is signed with one that was in force when it was published',
|
|
338
|
+
run(c) {
|
|
339
|
+
if (c.response('keys').status === 404) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const keys = c.json('keys').keys || [];
|
|
343
|
+
const doc = c.json('packument');
|
|
344
|
+
const time = c.json('fullPackument').time || {};
|
|
345
|
+
for (const [v, man] of Object.entries(doc.versions || {})) {
|
|
346
|
+
const dist = man.dist || {};
|
|
347
|
+
c.ok(SRI.test(dist.integrity || ''), `versions.${v}: no dist.integrity to sign`);
|
|
348
|
+
c.ok(Array.isArray(dist.signatures) && dist.signatures.length > 0, `versions.${v}: no dist.signatures`);
|
|
349
|
+
// as npm: a signature by any listed key will do
|
|
350
|
+
const good = dist.signatures.find(sig => {
|
|
351
|
+
const key = keys.find(k => k.keyid === sig.keyid);
|
|
352
|
+
return key && (!key.expires || Date.parse(time[v]) < Date.parse(key.expires)) &&
|
|
353
|
+
verifySignature(key, man.name, v, dist.integrity, sig.sig);
|
|
354
|
+
});
|
|
355
|
+
c.ok(good, `versions.${v}: no signature verifies against a key in force at ${time[v] || 'its (unknown) publication time'} (keyids ${dist.signatures.map(sig => sig.keyid).join(', ')})`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
305
358
|
}
|
|
306
359
|
];
|
|
307
360
|
|
|
@@ -396,7 +449,7 @@ async function runNpmCheck(target, get) {
|
|
|
396
449
|
const started = new Date();
|
|
397
450
|
const search = extra => get(`${base}/-/v1/search?text=${encodeURIComponent(pkg)}${extra}`);
|
|
398
451
|
const r = {};
|
|
399
|
-
[r.search, r.searchSize1, r.searchFrom1, r.packument, r.fullPackument, r.versionDoc, r.latestDoc, r.missing, r.missingVersion] = await Promise.all([
|
|
452
|
+
[r.search, r.searchSize1, r.searchFrom1, r.packument, r.fullPackument, r.versionDoc, r.latestDoc, r.missing, r.missingVersion, r.keys] = await Promise.all([
|
|
400
453
|
search(`&size=${SEARCH_SIZE}`),
|
|
401
454
|
search('&size=1'),
|
|
402
455
|
search(`&size=${SEARCH_SIZE}&from=1`),
|
|
@@ -405,7 +458,8 @@ async function runNpmCheck(target, get) {
|
|
|
405
458
|
get(packageUrl(base, pkg, version)),
|
|
406
459
|
get(packageUrl(base, pkg, 'latest')),
|
|
407
460
|
get(packageUrl(base, MISSING_PACKAGE)),
|
|
408
|
-
get(packageUrl(base, pkg, '99999.0.0'))
|
|
461
|
+
get(packageUrl(base, pkg, '99999.0.0')),
|
|
462
|
+
get(`${base}/-/npm/v1/keys`)
|
|
409
463
|
]);
|
|
410
464
|
if (r.packument.status === 200) {
|
|
411
465
|
try {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* npm registry signatures.
|
|
9
|
+
*
|
|
10
|
+
* A registry signature says "this registry served name@version with this integrity". It is
|
|
11
|
+
* the registry's statement, not the publisher's, so it can be made for every package this
|
|
12
|
+
* server holds, wherever it was crawled from. npm checks them with `npm audit signatures`:
|
|
13
|
+
*
|
|
14
|
+
* - GET {registry}/-/npm/v1/keys lists the registry's public keys. A registry without it
|
|
15
|
+
* (a 404) is one that doesn't sign, and npm skips its packages.
|
|
16
|
+
* - each version's dist.signatures is [{ keyid, sig }], where sig is the base64 DER ECDSA
|
|
17
|
+
* P-256 / SHA-256 signature of `${name}@${version}:${dist.integrity}`, and
|
|
18
|
+
* dist.integrity is the sha512 SRI string of the tarball.
|
|
19
|
+
* - once a registry lists keys, a version with no signature (or a bad one) is an error.
|
|
20
|
+
*
|
|
21
|
+
* References:
|
|
22
|
+
* https://docs.npmjs.com/about-registry-signatures
|
|
23
|
+
* https://github.com/npm/cli/blob/latest/lib/utils/verify-signatures.js
|
|
24
|
+
* https://github.com/npm/pacote/blob/main/lib/registry.js (verifySignatures)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const KEY_TYPE = 'ecdsa-sha2-nistp256';
|
|
32
|
+
const HASH_SLICE = 4 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
// The message a registry signature is over
|
|
35
|
+
function signedMessage(name, version, integrity) {
|
|
36
|
+
return `${name}@${version}:${integrity}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// sha512 SRI string of a tarball. Hashed in slices, yielding between them, so a large
|
|
40
|
+
// package doesn't hold up the event loop.
|
|
41
|
+
async function integrityOf(buffer) {
|
|
42
|
+
const hash = crypto.createHash('sha512');
|
|
43
|
+
for (let i = 0; i < buffer.length; i += HASH_SLICE) {
|
|
44
|
+
hash.update(buffer.subarray(i, i + HASH_SLICE));
|
|
45
|
+
if (i + HASH_SLICE < buffer.length) {
|
|
46
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return 'sha512-' + hash.digest('base64');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The same, synchronously
|
|
53
|
+
function integrityOfSync(buffer) {
|
|
54
|
+
return 'sha512-' + crypto.createHash('sha512').update(buffer).digest('base64');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// npm's key id: SHA256: and the base64 sha256 of the public key (SPKI DER)
|
|
58
|
+
function keyIdOf(spkiDer) {
|
|
59
|
+
return 'SHA256:' + crypto.createHash('sha256').update(spkiDer).digest('base64');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Checks a signature the way npm does (pacote's verifySignatures): key is an entry from
|
|
63
|
+
// /-/npm/v1/keys. Returns true or false.
|
|
64
|
+
function verifySignature(key, name, version, integrity, sig) {
|
|
65
|
+
try {
|
|
66
|
+
const pem = `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`;
|
|
67
|
+
const verifier = crypto.createVerify('SHA256');
|
|
68
|
+
verifier.write(signedMessage(name, version, integrity));
|
|
69
|
+
verifier.end();
|
|
70
|
+
return verifier.verify(pem, sig, 'base64');
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class NpmSigner {
|
|
77
|
+
constructor(privateKey) {
|
|
78
|
+
const details = privateKey.asymmetricKeyDetails || {};
|
|
79
|
+
if (privateKey.asymmetricKeyType !== 'ec' || details.namedCurve !== 'prime256v1') {
|
|
80
|
+
throw new Error(`An npm signing key must be an EC P-256 (prime256v1) key; this one is ${privateKey.asymmetricKeyType} ${details.namedCurve || ''}`.trim());
|
|
81
|
+
}
|
|
82
|
+
this.privateKey = privateKey;
|
|
83
|
+
const der = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
|
|
84
|
+
this.publicKey = der.toString('base64');
|
|
85
|
+
this.keyid = keyIdOf(der);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The signing key in file (PEM, PKCS#8 or SEC1). If there is no file, a new key is made and
|
|
90
|
+
* saved there, readable only by this user. log is told when a key is made.
|
|
91
|
+
*/
|
|
92
|
+
static load(file, log) {
|
|
93
|
+
if (!fs.existsSync(file)) {
|
|
94
|
+
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
95
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
96
|
+
try {
|
|
97
|
+
// wx: never replace a key that appeared meanwhile
|
|
98
|
+
fs.writeFileSync(file, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600, flag: 'wx' });
|
|
99
|
+
log?.info?.(`Made a new npm signing key: ${file}`);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e.code !== 'EEXIST') {
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return new NpmSigner(crypto.createPrivateKey(fs.readFileSync(file)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The body of /-/npm/v1/keys
|
|
110
|
+
keysDocument() {
|
|
111
|
+
return {
|
|
112
|
+
keys: [{
|
|
113
|
+
expires: null,
|
|
114
|
+
keyid: this.keyid,
|
|
115
|
+
keytype: KEY_TYPE,
|
|
116
|
+
scheme: KEY_TYPE,
|
|
117
|
+
key: this.publicKey
|
|
118
|
+
}]
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The base64 DER signature for name@version with this integrity
|
|
123
|
+
sign(name, version, integrity) {
|
|
124
|
+
return crypto.sign('sha256', Buffer.from(signedMessage(name, version, integrity), 'utf8'), this.privateKey).toString('base64');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- publisher signatures ------------------------------------------------------------------------
|
|
129
|
+
//
|
|
130
|
+
// A publisher (the IG Publisher, org.hl7.fhir.utilities.npm.NpmPackageSigner) signs a package with a
|
|
131
|
+
// partner file, {package}.tgz.sigstore.json, and its feed links it from the item (fhir:signature).
|
|
132
|
+
// The file is a Sigstore bundle (v0.3) holding a DSSE envelope whose payload is an in-toto statement
|
|
133
|
+
// with one subject: the package's purl (pkg:npm/{name}@{version}) and the sha512 of the .tgz. The
|
|
134
|
+
// signature is ECDSA P-256 / SHA-256 (DER) over the DSSE pre-authentication encoding of the payload.
|
|
135
|
+
// Which keys may sign a feed's packages is in package-feeds.json (feeds[].signing.keys).
|
|
136
|
+
|
|
137
|
+
const BUNDLE_MEDIA_TYPE = 'application/vnd.dev.sigstore.bundle';
|
|
138
|
+
const IN_TOTO_PAYLOAD = 'application/vnd.in-toto+json';
|
|
139
|
+
const IN_TOTO_STATEMENT = 'https://in-toto.io/Statement/v1';
|
|
140
|
+
|
|
141
|
+
// The npm package URL of a package version
|
|
142
|
+
function purl(name, version) {
|
|
143
|
+
return 'pkg:npm/' + (name.startsWith('@') ? '%40' + name.substring(1) : name) + '@' + version;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// DSSE pre-authentication encoding: "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
|
|
147
|
+
function pae(payloadType, payload) {
|
|
148
|
+
const type = Buffer.from(payloadType, 'utf8');
|
|
149
|
+
return Buffer.concat([Buffer.from(`DSSEv1 ${type.length} ${payloadType} ${payload.length} `, 'utf8'), payload]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Checks a publisher's signature of a package. tgz is the package's bytes; bundle the parsed
|
|
154
|
+
* partner file; keys the feed's keys ([{keyid, key, expires}], key: base64 SPKI DER); name and
|
|
155
|
+
* version the package's own (from its package.json); publishedAt when it was published (a key
|
|
156
|
+
* that had expired by then doesn't count), or null. Returns {keyid, signedAt}; throws an Error
|
|
157
|
+
* saying what is wrong.
|
|
158
|
+
*/
|
|
159
|
+
function verifyPackageSignature(tgz, bundle, keys, name, version, publishedAt) {
|
|
160
|
+
if (!bundle || typeof bundle !== 'object' || typeof bundle.mediaType !== 'string' || !bundle.mediaType.startsWith(BUNDLE_MEDIA_TYPE)) {
|
|
161
|
+
throw new Error('not a Sigstore bundle');
|
|
162
|
+
}
|
|
163
|
+
const envelope = bundle.dsseEnvelope;
|
|
164
|
+
if (!envelope || envelope.payloadType !== IN_TOTO_PAYLOAD || typeof envelope.payload !== 'string') {
|
|
165
|
+
throw new Error('the bundle has no in-toto DSSE envelope');
|
|
166
|
+
}
|
|
167
|
+
const payload = Buffer.from(envelope.payload, 'base64');
|
|
168
|
+
const hint = bundle.verificationMaterial?.publicKey?.hint;
|
|
169
|
+
const signed = pae(IN_TOTO_PAYLOAD, payload);
|
|
170
|
+
const tried = [];
|
|
171
|
+
let signedBy = null;
|
|
172
|
+
for (const sig of Array.isArray(envelope.signatures) ? envelope.signatures : []) {
|
|
173
|
+
const keyid = sig.keyid || hint;
|
|
174
|
+
tried.push(keyid);
|
|
175
|
+
const key = (keys || []).find(k => k.keyid === keyid);
|
|
176
|
+
if (!key || typeof sig.sig !== 'string') {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (key.expires && publishedAt && !(publishedAt.getTime() < Date.parse(key.expires))) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const ok = crypto.verify('sha256', signed,
|
|
184
|
+
{ key: Buffer.from(key.key, 'base64'), format: 'der', type: 'spki' }, Buffer.from(sig.sig, 'base64'));
|
|
185
|
+
if (ok) {
|
|
186
|
+
signedBy = keyid;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
} catch (e) {
|
|
190
|
+
// a malformed key or signature: not this one
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!signedBy) {
|
|
194
|
+
throw new Error(tried.length === 0 ? 'the bundle has no signatures'
|
|
195
|
+
: `no signature verifies against a key this feed lists (signed by ${tried.join(', ')})`);
|
|
196
|
+
}
|
|
197
|
+
let statement;
|
|
198
|
+
try {
|
|
199
|
+
statement = JSON.parse(payload.toString('utf8'));
|
|
200
|
+
} catch (e) {
|
|
201
|
+
throw new Error('the signed payload is not JSON');
|
|
202
|
+
}
|
|
203
|
+
if (statement._type !== IN_TOTO_STATEMENT) {
|
|
204
|
+
throw new Error(`the signed payload is not an in-toto v1 statement (_type is ${statement._type})`);
|
|
205
|
+
}
|
|
206
|
+
const expected = purl(name, version);
|
|
207
|
+
const digest = crypto.createHash('sha512').update(tgz).digest('hex');
|
|
208
|
+
const subjects = Array.isArray(statement.subject) ? statement.subject : [];
|
|
209
|
+
const named = subjects.filter(s => s && s.name === expected);
|
|
210
|
+
if (named.length === 0) {
|
|
211
|
+
throw new Error(`the signature is not for ${expected} (it is for ${subjects.map(s => s && s.name).join(', ') || 'nothing'})`);
|
|
212
|
+
}
|
|
213
|
+
if (!named.some(s => typeof s.digest?.sha512 === 'string' && s.digest.sha512.toLowerCase() === digest)) {
|
|
214
|
+
throw new Error('the signature is for different content: the package\'s sha512 doesn\'t match');
|
|
215
|
+
}
|
|
216
|
+
return { keyid: signedBy, signedAt: statement.predicate?.signedAt || null };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { NpmSigner, integrityOf, integrityOfSync, verifySignature, signedMessage, keyIdOf, verifyPackageSignature, purl, pae };
|