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.
Files changed (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. 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 };
@@ -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 };