fhirsmith 0.14.2 → 0.15.1

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 (43) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/config-template.json +10 -0
  3. package/library/changelog.js +57 -0
  4. package/library/npm-audit.js +62 -9
  5. package/package.json +2 -2
  6. package/packages/npm-check-page.js +108 -0
  7. package/packages/npm-check.js +450 -0
  8. package/packages/openapi.yaml +158 -5
  9. package/packages/package-crawler.js +203 -20
  10. package/packages/packages.js +250 -8
  11. package/packages/readme.md +55 -2
  12. package/packages/utilities/dedupe-versions.js +242 -0
  13. package/registry/crawler.js +17 -0
  14. package/registry/fhirsmith-releases.js +3 -6
  15. package/registry/model.js +3 -0
  16. package/registry/registry.js +28 -3
  17. package/translations/Messages.properties +3 -0
  18. package/tx/README.md +20 -4
  19. package/tx/cm/cm-api.js +25 -10
  20. package/tx/cm/cm-database.js +0 -4
  21. package/tx/cm/cm-package.js +53 -14
  22. package/tx/cs/cs-api.js +6 -1
  23. package/tx/cs/cs-icd11.js +23 -6
  24. package/tx/cs/cs-provider-api.js +22 -7
  25. package/tx/cs/cs-provider-list.js +10 -9
  26. package/tx/cs/icd11-expressions.js +5 -6
  27. package/tx/importers/icd11-schema.md +1 -0
  28. package/tx/importers/import-icd11.js +39 -0
  29. package/tx/library/resource-ids.js +107 -0
  30. package/tx/library.js +96 -24
  31. package/tx/ocl/cm-ocl.cjs +23 -15
  32. package/tx/ocl/cs-ocl.cjs +9 -10
  33. package/tx/ocl/vs-ocl.cjs +17 -17
  34. package/tx/provider.js +80 -13
  35. package/tx/tx.fhir.org.yml +17 -9
  36. package/tx/tx.js +7 -1
  37. package/tx/vs/vs-api.js +25 -10
  38. package/tx/vs/vs-database.js +0 -4
  39. package/tx/vs/vs-package.js +48 -31
  40. package/tx/vs/vs-vsac.js +20 -5
  41. package/tx/workers/lookup.js +7 -4
  42. package/tx/workers/metadata.js +2 -1
  43. package/tx/workers/read.js +3 -18
@@ -7,9 +7,11 @@
7
7
  const axios = require('axios');
8
8
  const {XMLParser} = require('fast-xml-parser');
9
9
  const crypto = require('crypto');
10
+ const zlib = require('zlib');
10
11
  const fs = require('fs');
11
12
  const path = require('path');
12
13
  const dns = require('dns');
14
+ const net = require('net');
13
15
  const http = require('http');
14
16
  const https = require('https');
15
17
  const ipaddr = require('ipaddr.js');
@@ -30,6 +32,19 @@ function isNonPublicAddress(ip) {
30
32
  }
31
33
  }
32
34
 
35
+ // True for a loopback address: 127.0.0.0/8, ::1, and IPv4-mapped forms of them
36
+ function isLoopbackAddress(ip) {
37
+ try {
38
+ let addr = ipaddr.parse(ip);
39
+ if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
40
+ addr = addr.toIPv4Address();
41
+ }
42
+ return addr.range() === 'loopback';
43
+ } catch (e) {
44
+ return false;
45
+ }
46
+ }
47
+
33
48
  class PackageCrawler {
34
49
  log;
35
50
  packages = new Set();
@@ -180,7 +195,8 @@ class PackageCrawler {
180
195
  // Enforced at connection time (so it also covers redirect targets and defeats
181
196
  // DNS-rebinding), this is the SSRF guard for all outbound http(s) fetches.
182
197
  // Set config.allowPrivateAddresses = true to disable (e.g. for local test registries).
183
- ssrfLookup() {
198
+ // allowLoopback lets loopback addresses through, and nothing else that isn't public.
199
+ ssrfLookup(allowLoopback = false) {
184
200
  const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
185
201
  return (hostname, options, callback) => {
186
202
  if (typeof options === 'function') {
@@ -195,7 +211,7 @@ class PackageCrawler {
195
211
  }
196
212
  if (!allowPrivate) {
197
213
  for (const a of addresses) {
198
- if (isNonPublicAddress(a.address)) {
214
+ if (isNonPublicAddress(a.address) && !(allowLoopback && isLoopbackAddress(a.address))) {
199
215
  callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
200
216
  return;
201
217
  }
@@ -211,26 +227,49 @@ class PackageCrawler {
211
227
  }
212
228
 
213
229
  // http/https agents that route every connection through the SSRF lookup. Cached so
214
- // connections can be pooled across requests.
215
- guardedAgents() {
216
- if (!this._guardedAgents) {
217
- const lookup = this.ssrfLookup();
230
+ // connections can be pooled across requests. Node doesn't do a lookup when the host is
231
+ // an IP address (http://127.0.0.1/, http://169.254.169.254/), so those are checked here.
232
+ // allowLoopback: loopback addresses are allowed too (/npm-check, so it can check this
233
+ // server itself); other non-public addresses are still refused. The crawler doesn't use it.
234
+ guardedAgents({allowLoopback = false} = {}) {
235
+ this._guardedAgentSets = this._guardedAgentSets || {};
236
+ const key = allowLoopback ? 'loopback' : 'public';
237
+ if (!this._guardedAgentSets[key]) {
238
+ const lookup = this.ssrfLookup(allowLoopback);
239
+ const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
240
+ const blockedLiteral = (options) => {
241
+ const host = String(options.host || options.hostname || '').replace(/^\[(.*)\]$/, '$1');
242
+ if (!allowPrivate && net.isIP(host) && isNonPublicAddress(host) && !(allowLoopback && isLoopbackAddress(host))) {
243
+ return new Error('Blocked request to non-public address ' + host);
244
+ }
245
+ return null;
246
+ };
218
247
  class GuardedHttpAgent extends http.Agent {
219
248
  createConnection(options, cb) {
249
+ const err = blockedLiteral(options);
250
+ if (err) {
251
+ process.nextTick(() => cb(err));
252
+ return undefined;
253
+ }
220
254
  return super.createConnection(Object.assign({}, options, { lookup }), cb);
221
255
  }
222
256
  }
223
257
  class GuardedHttpsAgent extends https.Agent {
224
258
  createConnection(options, cb) {
259
+ const err = blockedLiteral(options);
260
+ if (err) {
261
+ process.nextTick(() => cb(err));
262
+ return undefined;
263
+ }
225
264
  return super.createConnection(Object.assign({}, options, { lookup }), cb);
226
265
  }
227
266
  }
228
- this._guardedAgents = {
267
+ this._guardedAgentSets[key] = {
229
268
  httpAgent: new GuardedHttpAgent({ keepAlive: true }),
230
269
  httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
231
270
  };
232
271
  }
233
- return this._guardedAgents;
272
+ return this._guardedAgentSets[key];
234
273
  }
235
274
 
236
275
  // Resolve a local feed path and confine it to an allowed root (path-injection guard).
@@ -319,6 +358,8 @@ class PackageCrawler {
319
358
  const response = await axios.get(url, {
320
359
  timeout: 60000,
321
360
  responseType: 'arraybuffer',
361
+ // We want the package file's own bytes: decodePackageBody undoes the Content-Encoding
362
+ decompress: false,
322
363
  signal: this.abortController?.signal,
323
364
  headers: {
324
365
  'User-Agent': 'FHIR Package Crawler/1.0'
@@ -328,7 +369,7 @@ class PackageCrawler {
328
369
  });
329
370
 
330
371
  this.totalBytes += response.data.byteLength;
331
- return Buffer.from(response.data);
372
+ return this.decodePackageBody(Buffer.from(response.data), response.headers['content-encoding']);
332
373
  }
333
374
  } catch (error) {
334
375
  debugLog(`Failed to fetch ${url}: ${error.message}`);
@@ -339,6 +380,35 @@ class PackageCrawler {
339
380
  }
340
381
  }
341
382
 
383
+ // Undoes the HTTP Content-Encoding of a downloaded package. A .tgz is already gzip, and some
384
+ // servers (doc.mednet.swiss, for one) label it Content-Encoding: gzip / x-gzip whatever the
385
+ // request asks for, as if the tar had been compressed for transfer. Unzipping that, as an
386
+ // HTTP client normally would, leaves a bare tar. So for gzip, the body is taken as the
387
+ // package unless unzipping it gives another gzip file - a server that really did compress
388
+ // a .tgz for transfer.
389
+ decodePackageBody(body, contentEncoding) {
390
+ const encodings = String(contentEncoding || '').toLowerCase().split(',').map(e => e.trim()).filter(e => e && e !== 'identity');
391
+ for (const enc of encodings.reverse()) {
392
+ if (enc === 'gzip' || enc === 'x-gzip') {
393
+ try {
394
+ const inner = zlib.gunzipSync(body);
395
+ if (inner[0] === 0x1f && inner[1] === 0x8b) {
396
+ body = inner;
397
+ }
398
+ } catch (e) {
399
+ // not gzip after all: leave it for extractNpmPackage to report
400
+ }
401
+ } else if (enc === 'br') {
402
+ body = zlib.brotliDecompressSync(body);
403
+ } else if (enc === 'deflate') {
404
+ body = zlib.inflateSync(body);
405
+ } else {
406
+ throw new Error(`Unsupported Content-Encoding ${enc}`);
407
+ }
408
+ }
409
+ return body;
410
+ }
411
+
342
412
  async updateTheFeed(url, source, email, packageRestrictions) {
343
413
  const feedLog = {
344
414
  url: url,
@@ -493,6 +563,7 @@ class PackageCrawler {
493
563
 
494
564
  if (this.packages.has(id)) {
495
565
  this.log.info(`Ignoring package ${id} because it's already been seen in another feed`);
566
+ itemLog.status = 'Seen in another feed';
496
567
  return;
497
568
  }
498
569
  this.packages.add(id);
@@ -503,6 +574,23 @@ class PackageCrawler {
503
574
  return;
504
575
  }
505
576
 
577
+ // A new GUID for an id#version we already hold: decide before downloading anything.
578
+ // store() checks again against the id#version inside the tarball.
579
+ const [titleId, titleVersion] = id.split('#');
580
+ if (titleId && titleVersion) {
581
+ const check = await this.checkVersion(titleId, titleVersion, guid);
582
+ if (check.action === 'skip') {
583
+ itemLog.status = 'Already Stored';
584
+ return;
585
+ }
586
+ if (check.action === 'error') {
587
+ this.log.error(check.reason);
588
+ itemLog.status = 'error';
589
+ itemLog.error = check.reason;
590
+ return;
591
+ }
592
+ }
593
+
506
594
  // Parse publication date
507
595
  let pubDate;
508
596
  let pd;
@@ -527,9 +615,9 @@ class PackageCrawler {
527
615
  this.log.info('Fetching package: ' + url);
528
616
 
529
617
  const packageContent = await this.fetchUrl(url, 'application/tar+gzip');
530
- await this.store(source, url, guid, pubDate, packageContent, id, itemLog);
618
+ const outcome = await this.store(source, url, guid, pubDate, packageContent, id, itemLog);
531
619
 
532
- itemLog.status = 'Fetched';
620
+ itemLog.status = {stored: 'Fetched', replaced: 'Replaced Simplifier copy', skipped: 'Already Stored'}[outcome];
533
621
 
534
622
  } catch (error) {
535
623
  this.log.error(`Exception processing item ${itemLog.guid || index} from ${source}: `+ error.message);
@@ -602,6 +690,82 @@ class PackageCrawler {
602
690
  });
603
691
  }
604
692
 
693
+ // Simplifier re-publishes packages that other feeds publish directly. Its copy is a
694
+ // secondary echo: kept only until the version arrives directly from a publisher's feed.
695
+ isSimplifierGuid(guid) {
696
+ return /^https?:\/\/packages\.simplifier\.net\//i.test(guid || '');
697
+ }
698
+
699
+ async findStoredVersions(id, version) {
700
+ return new Promise((resolve, reject) => {
701
+ this.db.all('SELECT PackageVersionKey, GUID FROM PackageVersions WHERE Id = ? AND Version = ? ORDER BY PackageVersionKey',
702
+ [id, version], (err, rows) => err ? reject(err) : resolve(rows || []));
703
+ });
704
+ }
705
+
706
+ // An id#version is stored once. For a copy arriving with a GUID we haven't stored:
707
+ // store - we don't hold id#version
708
+ // replace - we only hold Simplifier copies, and this copy is direct: it takes their place
709
+ // skip - we already hold it, and this copy is a Simplifier echo
710
+ // error - we already hold it directly, from a different GUID: the new copy is ignored
711
+ async checkVersion(id, version, guid) {
712
+ const rows = await this.findStoredVersions(id, version);
713
+ if (rows.length === 0) {
714
+ return { action: 'store' };
715
+ }
716
+ if (this.isSimplifierGuid(guid)) {
717
+ return { action: 'skip', reason: `${id}#${version} from ${guid} ignored: already stored from ${rows[0].GUID}` };
718
+ }
719
+ const direct = rows.filter(r => !this.isSimplifierGuid(r.GUID));
720
+ if (direct.length === 0) {
721
+ return { action: 'replace', keys: rows.map(r => r.PackageVersionKey) };
722
+ }
723
+ return { action: 'error', reason: `${id}#${version} from ${guid} ignored: already stored from ${direct[0].GUID}` };
724
+ }
725
+
726
+ run(sql, params = []) {
727
+ return new Promise((resolve, reject) => {
728
+ this.db.run(sql, params, (err) => err ? reject(err) : resolve());
729
+ });
730
+ }
731
+
732
+ // Point Packages.CurrentVersion at the most recent version of id
733
+ async refreshCurrentVersion(id) {
734
+ const latest = await new Promise((resolve, reject) => {
735
+ this.db.get(`SELECT PackageVersionKey, Canonical FROM PackageVersions
736
+ WHERE Id = ? AND Version != 'current'
737
+ ORDER BY PubDate DESC, Version DESC LIMIT 1`, [id], (err, row) => err ? reject(err) : resolve(row));
738
+ });
739
+ if (latest) {
740
+ await this.run('UPDATE Packages SET CurrentVersion = ?, Canonical = ? WHERE Id = ?',
741
+ [latest.PackageVersionKey, latest.Canonical, id]);
742
+ }
743
+ }
744
+
745
+ // A direct copy replacing the Simplifier copies we held: it takes over the first row (so its
746
+ // key and download count survive), and any further Simplifier rows go.
747
+ async replaceVersion(keys, packageBuffer, npmPackage, date, guid, id, canonical, urls) {
748
+ const [vkey, ...extra] = keys;
749
+ await this.run(`UPDATE PackageVersions
750
+ SET GUID = ?, PubDate = ?, Indexed = datetime('now'), Kind = ?, Canonical = ?,
751
+ FhirVersions = ?, Description = ?, Hash = ?, Author = ?, License = ?,
752
+ HomePage = ?, Content = ?
753
+ WHERE PackageVersionKey = ?`,
754
+ [guid, date.toISOString(), npmPackage.kind, canonical, npmPackage.fhirVersionList,
755
+ npmPackage.description, this.genHash(packageBuffer), npmPackage.author, npmPackage.license,
756
+ npmPackage.url, packageBuffer, vkey]);
757
+ for (const key of keys) {
758
+ for (const table of ['PackageFHIRVersions', 'PackageDependencies', 'PackageURLs']) {
759
+ await this.run(`DELETE FROM ${table} WHERE PackageVersionKey = ?`, [key]);
760
+ }
761
+ }
762
+ for (const key of extra) {
763
+ await this.run('DELETE FROM PackageVersions WHERE PackageVersionKey = ?', [key]);
764
+ }
765
+ await this.insertRelatedData(vkey, npmPackage, urls);
766
+ await this.refreshCurrentVersion(id);
767
+ }
768
+
605
769
  parsePubDate(dateStr) {
606
770
  // Handle various RSS date formats
607
771
  let cleanDate = dateStr.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -645,12 +809,14 @@ class PackageCrawler {
645
809
  throw new Error(`NPM Id "${id}" is not valid from ${source}`);
646
810
  }
647
811
 
648
- // Save to mirror if configured
649
- if (this.config.mirrorPath) {
650
- let fid = this.fixPrefix(id);
651
- const filename = `${fid}-${version}.tgz`;
652
- const filepath = path.join(this.config.mirrorPath, filename);
653
- fs.writeFileSync(filepath, packageBuffer);
812
+ // The id#version in the tarball is what counts, which need not be what the feed said
813
+ const check = await this.checkVersion(id, version, guid);
814
+ if (check.action === 'skip') {
815
+ this.log.info(check.reason);
816
+ return 'skipped';
817
+ }
818
+ if (check.action === 'error') {
819
+ throw new Error(check.reason);
654
820
  }
655
821
 
656
822
  if (!this.isValidSemVersion(version)) {
@@ -680,7 +846,18 @@ class PackageCrawler {
680
846
  const urls = this.processPackageUrls(npmPackage);
681
847
 
682
848
  // Commit to database
683
- await this.commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls);
849
+ await this.commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls, check.keys);
850
+
851
+ // Only now write the mirror copy: a package that failed validation or didn't commit
852
+ // must not replace the file that is served for the copy we hold
853
+ if (this.config.mirrorPath) {
854
+ let fid = this.fixPrefix(id);
855
+ const filename = `${fid}-${version}.tgz`;
856
+ const filepath = path.join(this.config.mirrorPath, filename);
857
+ fs.writeFileSync(filepath, packageBuffer);
858
+ }
859
+
860
+ return check.action === 'replace' ? 'replaced' : 'stored';
684
861
 
685
862
  } catch (error) {
686
863
  debugLog(error);
@@ -692,7 +869,10 @@ class PackageCrawler {
692
869
  async extractNpmPackage(packageBuffer, source) {
693
870
  try {
694
871
  const files = {};
695
- const zlib = require('zlib');
872
+ if (packageBuffer.length < 2 || packageBuffer[0] !== 0x1f || packageBuffer[1] !== 0x8b) {
873
+ const start = packageBuffer.subarray(0, 8).toString('hex');
874
+ throw new Error(`not a gzip file (it starts with ${start || 'nothing'})`);
875
+ }
696
876
 
697
877
  // First decompress the gzip
698
878
  const decompressed = zlib.gunzipSync(packageBuffer);
@@ -975,7 +1155,10 @@ class PackageCrawler {
975
1155
  return crypto.createHash('sha1').update(data).digest('hex');
976
1156
  }
977
1157
 
978
- async commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls) {
1158
+ async commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls, replaceKeys = null) {
1159
+ if (replaceKeys && replaceKeys.length > 0) {
1160
+ return this.replaceVersion(replaceKeys, packageBuffer, npmPackage, date, guid, id, canonical, urls);
1161
+ }
979
1162
  return new Promise((resolve, reject) => {
980
1163
  // Get next version key
981
1164
  this.db.get('SELECT MAX(PackageVersionKey) as maxKey FROM PackageVersions', (err, row) => {