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.
Files changed (43) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +40 -19
  3. package/config-template.json +25 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/library/html-server.js +16 -16
  7. package/library/html.js +9 -0
  8. package/library/narrative-check.js +203 -0
  9. package/library/npm-audit.js +42 -3
  10. package/package.json +2 -1
  11. package/packages/openapi.yaml +6 -3
  12. package/packages/package-crawler.js +189 -82
  13. package/packages/packages.js +33 -69
  14. package/packages/readme.md +1 -1
  15. package/packages/utilities/dedupe-versions.js +22 -8
  16. package/registry/crawler.js +8 -1
  17. package/registry/registry.js +18 -11
  18. package/release.sh +80 -0
  19. package/shl/shl.js +5 -3
  20. package/testing/readme.md +1 -1
  21. package/tx/cm/cm-database.js +62 -0
  22. package/tx/cm/cm-package.js +20 -3
  23. package/tx/cs/cs-cs.js +86 -18
  24. package/tx/cs/cs-loinc.js +32 -14
  25. package/tx/library/resource-ids.js +16 -1
  26. package/tx/library.js +9 -2
  27. package/tx/ocl/README.md +62 -6
  28. package/tx/ocl/cm-ocl.cjs +166 -44
  29. package/tx/ocl/cs-ocl.cjs +163 -13
  30. package/tx/ocl/resolve/public-access.js +143 -0
  31. package/tx/ocl/resolve/reference-resolver.js +458 -0
  32. package/tx/ocl/vs-ocl.cjs +167 -4
  33. package/tx/problems.js +7 -5
  34. package/tx/provider.js +5 -1
  35. package/tx/tx-html.js +46 -7
  36. package/tx/tx.js +22 -6
  37. package/tx/vs/vs-database.js +26 -0
  38. package/tx/vs/vs-package.js +42 -10
  39. package/tx/workers/expand.js +1 -1
  40. package/tx/workers/metadata.js +6 -1
  41. package/tx/workers/read.js +87 -5
  42. package/utilities/submit-test-report.js +1 -1
  43. 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 };
@@ -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, so the advice can say
17
- // whether upgrading will help. When this server is already on the latest release it won't:
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.16.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",
@@ -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
- asks for `application/json` (or npm's `application/vnd.npm.install-v1+json`),
354
- the version's npm document: the same object as that version's entry in the
355
- package document. The response varies on `Accept` and `FHIR-Package-Download`.
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