fhirsmith 0.16.0 → 0.17.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 +65 -0
- package/README.md +40 -19
- package/config-template.json +25 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/library/html-server.js +16 -16
- package/library/html.js +9 -0
- package/library/narrative-check.js +203 -0
- package/library/npm-audit.js +42 -3
- package/package.json +2 -1
- package/packages/openapi.yaml +6 -3
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +33 -69
- package/packages/readme.md +1 -1
- package/packages/utilities/dedupe-versions.js +22 -8
- package/registry/crawler.js +8 -1
- package/registry/registry.js +18 -11
- package/release.sh +80 -0
- package/shl/shl.js +5 -3
- package/testing/readme.md +1 -1
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-cs.js +86 -18
- package/tx/cs/cs-loinc.js +32 -14
- package/tx/library/resource-ids.js +16 -1
- package/tx/library.js +9 -2
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/problems.js +7 -5
- package/tx/provider.js +5 -1
- package/tx/tx-html.js +46 -7
- package/tx/tx.js +22 -6
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/workers/expand.js +1 -1
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/read.js +87 -5
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +88 -12
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
* Checks FHIR narrative (and any other XHTML that will be shown in a page) for active
|
|
9
|
+
* content: anything that can run script, submit or navigate on its own, load another
|
|
10
|
+
* document, or restyle the page around it.
|
|
11
|
+
*
|
|
12
|
+
* FHIR's rule is that narrative "SHALL contain only the basic HTML formatting elements ...,
|
|
13
|
+
* <a> elements (either name or href), images and internally contained style attributes",
|
|
14
|
+
* and "SHALL NOT contain a head, a body element, external stylesheet references, deprecated
|
|
15
|
+
* elements, scripts, forms, base/link/xlink, frames, iframes, objects or event related
|
|
16
|
+
* attributes". This checks the active-content part of that, not the whole rule.
|
|
17
|
+
*
|
|
18
|
+
* The XHTML is parsed the way a browser parses it when it is inserted into a page (parse5
|
|
19
|
+
* implements the HTML parsing algorithm), so entity-encoded, mixed-case or malformed markup
|
|
20
|
+
* is checked as the browser would see it, not as it is written.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const parse5 = require('parse5');
|
|
24
|
+
|
|
25
|
+
// Elements that run script, load or embed another document, submit data, or change how
|
|
26
|
+
// the page around them is read. svg and math are here because FHIR narrative has no
|
|
27
|
+
// inline graphics (images are <img>), and both can carry script.
|
|
28
|
+
const ACTIVE_ELEMENTS = new Set([
|
|
29
|
+
'script', 'style', 'link', 'meta', 'base',
|
|
30
|
+
'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'portal', 'template',
|
|
31
|
+
'form', 'input', 'button', 'select', 'textarea', 'isindex', 'keygen',
|
|
32
|
+
'svg', 'math', 'head', 'body', 'html'
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// Attributes that hold a URL
|
|
36
|
+
const URL_ATTRIBUTES = new Set([
|
|
37
|
+
'href', 'src', 'action', 'formaction', 'xlink:href', 'background', 'poster', 'srcset',
|
|
38
|
+
'data', 'codebase', 'cite', 'longdesc', 'usemap', 'dynsrc', 'lowsrc', 'ping', 'manifest'
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// Attributes that are active whatever their value
|
|
42
|
+
const ACTIVE_ATTRIBUTES = new Set(['srcdoc', 'http-equiv', 'formaction']);
|
|
43
|
+
|
|
44
|
+
// CSS in a style attribute that runs script or binds behaviour (old IE and Gecko)
|
|
45
|
+
const ACTIVE_CSS = /expression\(|javascript:|vbscript:|-moz-binding|behavior:|@import/;
|
|
46
|
+
|
|
47
|
+
// The scheme of a URL, as a browser reads it: leading spaces and control characters
|
|
48
|
+
// are ignored, and so are tabs and newlines anywhere
|
|
49
|
+
function urlScheme(url) {
|
|
50
|
+
// eslint-disable-next-line no-control-regex -- control characters are what browsers strip
|
|
51
|
+
const cleaned = String(url).replace(/^[\x00-\x20]+/, '').replace(/[\t\n\r]/g, '');
|
|
52
|
+
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
|
|
53
|
+
return m ? { scheme: m[1].toLowerCase(), rest: cleaned.substring(m[0].length) } : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every URL in an attribute value (srcset has several)
|
|
57
|
+
function urlsIn(name, value) {
|
|
58
|
+
if (name === 'srcset') {
|
|
59
|
+
return String(value).split(',').map(s => s.trim().split(/\s+/)[0]).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
return [value];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// CSS with comments removed and escapes decoded, so \65 xpression( is seen as expression(
|
|
65
|
+
function normaliseCss(css) {
|
|
66
|
+
return String(css)
|
|
67
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
68
|
+
.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => {
|
|
69
|
+
const cp = parseInt(hex, 16);
|
|
70
|
+
return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : '';
|
|
71
|
+
})
|
|
72
|
+
.replace(/\\(.)/g, '$1')
|
|
73
|
+
.replace(/\s+/g, '')
|
|
74
|
+
.toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function describe(node) {
|
|
78
|
+
return `<${node.tagName}>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkAttribute(node, attr, problems) {
|
|
82
|
+
const name = (attr.prefix ? `${attr.prefix}:${attr.name}` : attr.name).toLowerCase();
|
|
83
|
+
const value = attr.value || '';
|
|
84
|
+
if (name.startsWith('on')) {
|
|
85
|
+
problems.push(`an event handler attribute (${name}) on ${describe(node)}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (ACTIVE_ATTRIBUTES.has(name)) {
|
|
89
|
+
problems.push(`a ${name} attribute on ${describe(node)}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (name === 'style') {
|
|
93
|
+
if (ACTIVE_CSS.test(normaliseCss(value))) {
|
|
94
|
+
problems.push(`script or behaviour in the style attribute of ${describe(node)}`);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (URL_ATTRIBUTES.has(name)) {
|
|
99
|
+
for (const url of urlsIn(name, value)) {
|
|
100
|
+
const s = urlScheme(url);
|
|
101
|
+
if (!s) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (s.scheme === 'javascript' || s.scheme === 'vbscript' || s.scheme === 'livescript') {
|
|
105
|
+
problems.push(`a ${s.scheme}: URL in the ${name} of ${describe(node)}`);
|
|
106
|
+
} else if (s.scheme === 'data') {
|
|
107
|
+
// an inline image is fine: an image can't run script
|
|
108
|
+
const isImage = node.tagName === 'img' && (name === 'src' || name === 'srcset')
|
|
109
|
+
&& /^image\//i.test(s.rest.trim());
|
|
110
|
+
if (!isImage) {
|
|
111
|
+
problems.push(`a data: URL in the ${name} of ${describe(node)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function walk(node, problems) {
|
|
119
|
+
if (node.tagName) {
|
|
120
|
+
if (ACTIVE_ELEMENTS.has(node.tagName.toLowerCase())) {
|
|
121
|
+
problems.push(`a ${describe(node)} element`);
|
|
122
|
+
}
|
|
123
|
+
for (const attr of node.attrs || []) {
|
|
124
|
+
checkAttribute(node, attr, problems);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// a <template>'s content is a separate fragment
|
|
128
|
+
for (const child of [...(node.childNodes || []), ...(node.content ? [node.content] : [])]) {
|
|
129
|
+
walk(child, problems);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Checks XHTML for active content.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} xhtml - e.g. a narrative's text.div
|
|
137
|
+
* @returns {string|null} what was found (the first few things, if several), or null if
|
|
138
|
+
* there is no active content
|
|
139
|
+
*/
|
|
140
|
+
function checkNarrative(xhtml) {
|
|
141
|
+
if (xhtml == null || xhtml === '') {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (typeof xhtml !== 'string') {
|
|
145
|
+
return 'the narrative is not a string';
|
|
146
|
+
}
|
|
147
|
+
const problems = [];
|
|
148
|
+
walk(parse5.parseFragment(xhtml), problems);
|
|
149
|
+
if (problems.length === 0) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const shown = [...new Set(problems)];
|
|
153
|
+
const more = shown.length > 3 ? ` (and ${shown.length - 3} more)` : '';
|
|
154
|
+
return `active content in the narrative: ${shown.slice(0, 3).join('; ')}${more}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Checks every narrative in a resource: its own, its contained resources', and those of
|
|
159
|
+
* the resources in a Bundle's entries.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} resource - the resource as JSON
|
|
162
|
+
* @returns {string|null} what was found, with where, or null
|
|
163
|
+
*/
|
|
164
|
+
function checkResourceNarratives(resource) {
|
|
165
|
+
const check = (res, where) => {
|
|
166
|
+
if (!res || typeof res !== 'object') {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const found = checkNarrative(res.text?.div);
|
|
170
|
+
if (found) {
|
|
171
|
+
return `${where}: ${found}`;
|
|
172
|
+
}
|
|
173
|
+
for (const [i, c] of (Array.isArray(res.contained) ? res.contained : []).entries()) {
|
|
174
|
+
const r = check(c, `${where}.contained[${i}]${c?.id ? ` (#${c.id})` : ''}`);
|
|
175
|
+
if (r) {
|
|
176
|
+
return r;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const [i, e] of (Array.isArray(res.entry) ? res.entry : []).entries()) {
|
|
180
|
+
const r = check(e?.resource, `${where}.entry[${i}]`);
|
|
181
|
+
if (r) {
|
|
182
|
+
return r;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
};
|
|
187
|
+
return check(resource, resource?.resourceType ? `${resource.resourceType}${resource.id ? '/' + resource.id : ''}` : 'resource');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* As checkResourceNarratives, but throws.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} resource
|
|
194
|
+
* @param {string} [context] - for the message, e.g. the package it came from
|
|
195
|
+
*/
|
|
196
|
+
function assertNoActiveContent(resource, context) {
|
|
197
|
+
const found = checkResourceNarratives(resource);
|
|
198
|
+
if (found) {
|
|
199
|
+
throw new Error(context ? `${context}: ${found}` : found);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { checkNarrative, checkResourceNarratives, assertNoActiveContent };
|
package/library/npm-audit.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
// Config (all optional), top level of config.json:
|
|
14
14
|
// "npmAudit": { "enabled": true, "intervalHours": 24 }
|
|
15
15
|
//
|
|
16
|
-
// Each check also looks up the latest fhirsmith release on npm
|
|
17
|
-
// whether
|
|
16
|
+
// Each check also looks up the latest fhirsmith release on npm. The dashboard always says
|
|
17
|
+
// whether a newer release is available, and the advice about any findings says whether
|
|
18
|
+
// upgrading will help. When this server is already on the latest release it won't:
|
|
18
19
|
// npm ignores our package.json "overrides" for anything but a clone + npm ci, so an
|
|
19
20
|
// npm-installed server can carry transitive versions the repo itself has overridden away.
|
|
20
21
|
|
|
@@ -185,6 +186,41 @@ class NpmAudit {
|
|
|
185
186
|
return true;
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
/**
|
|
190
|
+
* This server's own version, from package.json (collectPackages reads it too, but that
|
|
191
|
+
* can fail for reasons that have nothing to do with the version)
|
|
192
|
+
*/
|
|
193
|
+
readInstalledVersion() {
|
|
194
|
+
try {
|
|
195
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8'));
|
|
196
|
+
if (pkg.version) {
|
|
197
|
+
this.installedVersion = pkg.version;
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
// leave it as it was
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Dashboard line: this server's version, and whether there is a newer release
|
|
206
|
+
*/
|
|
207
|
+
renderRelease() {
|
|
208
|
+
if (!this.installedVersion) {
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
let html = `<p><strong>FHIRsmith release:</strong> this server runs ${escape(this.installedVersion)}`;
|
|
212
|
+
const cmp = compareVersions(this.installedVersion, this.latestVersion);
|
|
213
|
+
if (cmp === null) {
|
|
214
|
+
html += ' (the latest release could not be checked)';
|
|
215
|
+
} else if (cmp < 0) {
|
|
216
|
+
html += ` - <span style="color:#b60">FHIRsmith ${escape(this.latestVersion)} is available</span>` +
|
|
217
|
+
' (<a href="https://github.com/HealthIntersections/FHIRsmith/releases">release notes</a>)';
|
|
218
|
+
} else {
|
|
219
|
+
html += ', the latest release';
|
|
220
|
+
}
|
|
221
|
+
return html + '</p>';
|
|
222
|
+
}
|
|
223
|
+
|
|
188
224
|
/**
|
|
189
225
|
* Look up the latest fhirsmith release. A failure only means the advice is less specific,
|
|
190
226
|
* so it is logged and otherwise ignored (the previous answer is kept)
|
|
@@ -227,6 +263,9 @@ class NpmAudit {
|
|
|
227
263
|
if (this.stats) {
|
|
228
264
|
this.stats.task('npm audit', 'Checking');
|
|
229
265
|
}
|
|
266
|
+
// independent of the audit: failures here are logged and otherwise ignored
|
|
267
|
+
this.readInstalledVersion();
|
|
268
|
+
await this.checkLatest();
|
|
230
269
|
try {
|
|
231
270
|
const packages = this.collectPackages();
|
|
232
271
|
const response = await axios.post(this.url, packages, {
|
|
@@ -255,7 +294,6 @@ class NpmAudit {
|
|
|
255
294
|
this.lastError = null;
|
|
256
295
|
const summary = this.summary();
|
|
257
296
|
if (findings.length > 0) {
|
|
258
|
-
await this.checkLatest();
|
|
259
297
|
if (this.logger) {
|
|
260
298
|
this.logger.warn(`npm audit: ${summary} in ${this.packageCount} packages: ` +
|
|
261
299
|
findings.map(f => `${f.name} (${f.severity}) ${f.url}`).join('; '));
|
|
@@ -356,6 +394,7 @@ class NpmAudit {
|
|
|
356
394
|
if (!this.enabled) {
|
|
357
395
|
return html + '<p><strong>npm audit:</strong> disabled (npmAudit.enabled = false)</p></div>';
|
|
358
396
|
}
|
|
397
|
+
html += this.renderRelease();
|
|
359
398
|
if (!this.checkedAt) {
|
|
360
399
|
html += '<p><strong>npm audit:</strong> ';
|
|
361
400
|
html += this.lastError ? `<span style="color:#b00">could not be run: ${escape(this.lastError)}</span>` : 'not run yet';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"txVersion": "1.9.6",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"natural": "^8.1.1",
|
|
57
57
|
"node-cron": "^4.6.0",
|
|
58
58
|
"pako": "^2.1.0",
|
|
59
|
+
"parse5": "^7.3.0",
|
|
59
60
|
"passport": "^0.7.0",
|
|
60
61
|
"passport-facebook": "^3.0.0",
|
|
61
62
|
"passport-github2": "^0.1.12",
|
package/packages/openapi.yaml
CHANGED
|
@@ -350,9 +350,12 @@ paths:
|
|
|
350
350
|
summary: Download a package tarball, or get a version's document
|
|
351
351
|
description: |
|
|
352
352
|
Returns the package tarball (`.tgz`) - or, when the request's `Accept` header
|
|
353
|
-
|
|
354
|
-
the
|
|
355
|
-
|
|
353
|
+
prefers JSON, the version's npm document: the same object as that version's entry
|
|
354
|
+
in the package document. Preferring JSON means ranking `application/json` (or npm's
|
|
355
|
+
`application/vnd.npm.install-v1+json`) above any `*/*`, `application/*` or tarball
|
|
356
|
+
type by q-value: `application/json` alone does, but `application/json, text/plain, */*`
|
|
357
|
+
(axios's default, which SUSHI sends) does not. The response varies on `Accept` and
|
|
358
|
+
`FHIR-Package-Download`.
|
|
356
359
|
|
|
357
360
|
A request with a `FHIR-Package-Download` header always gets the tarball. The
|
|
358
361
|
HL7 Java tools send it; a client that downloads tarballs from this URL while
|