fhirsmith 0.15.0 → 0.16.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/CHANGELOG.md +51 -1
- package/config-template.json +10 -0
- package/library/npm-audit.js +131 -11
- package/library/sqlite-session-store.js +133 -0
- package/package.json +19 -29
- package/packages/npm-check-page.js +108 -0
- package/packages/npm-check.js +450 -0
- package/packages/openapi.yaml +158 -5
- package/packages/package-crawler.js +203 -20
- package/packages/packages.js +250 -8
- package/packages/readme.md +55 -2
- package/packages/utilities/dedupe-versions.js +242 -0
- package/registry/registry.js +10 -3
- package/token/token.js +7 -10
- package/translations/Messages.properties +3 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-icd11.js +23 -6
- package/tx/cs/icd11-expressions.js +5 -6
- package/tx/importers/icd11-schema.md +1 -0
- package/tx/importers/import-icd11.js +39 -0
- package/tx/importers/import-rxnorm.module.js +1 -1
- package/tx/importers/import-sct.module.js +1 -1
- package/tx/library/designations.js +1 -1
- package/tx/sct/text-index.js +1 -1
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/workers/lookup.js +7 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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) => {
|