Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Dependabot for FHIRsmith.
#
# Email notification of vulnerable dependencies does NOT come from this file - it comes
# from Dependabot alerts, which are switched on in the repository settings
# (Settings > Code security > Dependabot alerts, and Dependabot security updates), and
# delivered according to each person's GitHub notification settings
# (Settings > Notifications > Dependabot alerts > Email).
#
# What this file does: when an alert is raised, Dependabot opens one pull request that
# moves the affected packages to fixed versions. Routine version-bump PRs are turned off
# (open-pull-requests-limit: 0) - only security fixes produce PRs.

version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 0
groups:
npm-security:
applies-to: security-updates
patterns:
- "*"
commit-message:
prefix: "deps"
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,41 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.14.2] -
## [0.14.2] - 2026-10-06

### Security

- Dependency updates for published advisories, including axios (several high severity), brace-expansion (denial of service), and http-cache-semantics and @tootallnate/once (removed with the old sqlite3 build chain)

### Added

- OpenAPI descriptions for the package server (/packages), the terminology registry (/tx-reg), the TestReport module (/testing) and the R5 terminology endpoints: each serves `openapi.json`, `openapi.yaml` and an HTML reference at `openapi` (JSON for non-browser clients), linked from the module's pages and advertised in an RFC 8631 `Link` header on every response. The FHIR resource schemas are generated from the FHIR definitions, constrained to what each module accepts
- npm audit self-check: once a day the server checks its installed packages (and FHIRsmith itself) against the npm advisory database - report only, nothing is changed. Findings show as a banner on the home page, in full on the dashboard, and in the log. `npmAudit.enabled` = false turns it off (e.g. for servers with no internet access)
- Registry: new Software page (/tx-reg/software) listing each registered server's software and version, and for FHIRsmith servers, the release date, its age, and how many releases behind it is. The crawler now records `CapabilityStatement.software.version`

### Changed

- Terminology server: contained resources are supported only for ValueSets that contain ValueSets (which `compose.include.valueSet` can import by `#id`). Any other contained resource is rejected (CONTAINED_RESOURCE_NOT_SUPPORTED) wherever the resource comes from; when loading a package, the offending resource is skipped rather than stopping the load
- $validate-code: the `inactive` and `status` output parameters describe the code being returned. In a CodeableConcept where a different coding is inactive, that coding still gets its warning, but the parameters are not set for the returned code
- Registry discovery API follows the tx ecosystem IG: rows carry `fhirVersion` and the IG's security flags (`open`, `token`, ...) alongside the existing security string; candidate lists are only given when `url` is supplied; R-codes (R4, R4B, R5, ...) map to their release versions; and the resolve fallback that returned servers authoritative for a code system but not hosting it has been removed
- The TerminologyCapabilities statement no longer lists expansion parameters the server doesn't act on (`limitedExpansion`, `_incomplete`, `incomplete-ok` and others); `limitedExpansion`/`incomplete-ok` are no longer read, and no longer part of the expansion cache key (they never had any effect)
- TestReport module: a TestReport with contained resources is refused
- Packages and registry page footers say when the crawler last updated the data ("last updated 35 minutes ago"), or "not yet updated" before the first crawl - they used to show a raw `[%crawler-date%]` placeholder
- Packages crawler log: the start time is shown relative ("35 minutes ago") and the duration in seconds; the end time is dropped
- Dependencies: sqlite3 6 (connect-sqlite3 now uses the same sqlite3, which drops the old node-gyp/make-fetch-happen chain), and updates for axios, brace-expansion, moment, ip-address, csv-parse and uuid. The PR build now fails only on high/critical advisories in what ships (`npm audit --omit=dev`)

### Fixed

- $expand: RxNorm expansions that asked for a concept's children failed with `SQLITE_MISUSE`; an over-limit expansion of the whole of RxNorm is now refused before it is built
- $expand: a value set with neither a compose nor an expansion gets a clear error (VALUESET_NO_COMPOSE)
- $validate-code: in a CodeableConcept, the inactive warning names the coding that is actually inactive (it used to name the first coding), and a later active coding no longer hides the warning from an earlier inactive one
- Packages: `/status`, `/stats` and `/search` hung instead of answering (the `/:id` route swallowed them)
- Packages: dependency searches accept `id#version` and `id|version` (as sent by the Java PackageClient) as well as `id@version`; npm search (`/-/v1/search`) honours `text`, `size` and `from`, and accepts the other npm and PackageClient parameters
- XIG: the version shown on the pages

### Tx Conformance Statement

FHIRsmith passed all 3585 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v6.10.4)

## [0.14.1] - 2026-09-29

Expand Down
9 changes: 9 additions & 0 deletions config-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@
// how often the counters are written out
"intervalMinutes": 10
},
// Once a day the server checks its installed npm packages against the npm advisory
// database (what `npm audit` does - report only, nothing is changed). Problems show on
// the home page and the dashboard, and in the log. Needs outbound access to
// registry.npmjs.org. The whole block is optional.
"npmAudit": {
// set false to turn the check off (e.g. a server with no internet access)
"enabled": true,
"intervalHours": 24
},
"modules": {
"shl": {
"enabled": false,
Expand Down
2 changes: 2 additions & 0 deletions library/html-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class HtmlServer {
// [%ver%] is the FHIRsmith version in every template (it follows the FHIRsmith link)
.replace(/\[%ver%\]/g, escape(packageJson.version))
.replace(/\[%download-date%\]/g, escape(renderOptions.downloadDate))
// "last updated 35 minutes ago" / "not yet updated", for the crawler-driven modules
.replace(/\[%crawler-status%\]/g, escape(renderOptions.crawlerStatus || 'not yet updated'))
.replace(/\[%total-resources%\]/g, escape(renderOptions.totalResources.toLocaleString()))
.replace(/\[%total-packages%\]/g, escape(renderOptions.totalPackages.toLocaleString()))
.replace(/\[%endpoint-path%\]/g, escape(renderOptions.endpointpath))
Expand Down
265 changes: 265 additions & 0 deletions library/npm-audit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// library/npm-audit.js
// Periodic self-check of this server's installed npm packages against the npm advisory
// database - the same lookup `npm audit` does, made directly so it needs neither the npm
// binary nor a particular working directory. Reports only; it never changes anything.
//
// The package list comes from node_modules/.package-lock.json (what is actually
// installed), falling back to package-lock.json. Dev dependencies are left out, and
// fhirsmith itself is included, so an advisory published against fhirsmith shows up too.
//
// Config (all optional), top level of config.json:
// "npmAudit": { "enabled": true, "intervalHours": 24 }

const fs = require('fs');
const path = require('path');
const axios = require('axios');
const escape = require('escape-html');

const DEFAULT_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk';
const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info'];
const HOUR_MS = 60 * 60 * 1000;
const FIRST_RUN_DELAY_MS = 60 * 1000; // let startup finish first

function describeAgo(time, now = Date.now()) {
const mins = Math.max(0, Math.floor((now - time) / 60000));
if (mins < 1) {
return 'just now';
}
if (mins < 60) {
return `${mins} minute${mins === 1 ? '' : 's'} ago`;
}
const hours = Math.floor(mins / 60);
if (hours < 48) {
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
}
return `${Math.floor(hours / 24)} days ago`;
}

class NpmAudit {
constructor(config = {}, logger = null, appDir = path.join(__dirname, '..'), stats = null) {
this.enabled = config.enabled !== false;
this.intervalHours = config.intervalHours > 0 ? config.intervalHours : 24;
this.url = config.url || DEFAULT_URL;
this.timeout = config.timeout || 60000;
this.logger = logger;
this.appDir = appDir;
this.stats = stats;
this.timers = [];
this.running = false;
// the outcome of the last completed check
this.checkedAt = null; // Date of the last successful check
this.packageCount = 0;
this.findings = []; // [{name, installed: [versions], id, severity, title, url, range}]
this.lastError = null; // message from the last attempt, if it failed
this.lastAttempt = null;
}

/**
* name -> [installed versions], production packages only, plus this package itself
*/
collectPackages() {
let lock = null;
for (const file of [path.join(this.appDir, 'node_modules', '.package-lock.json'), path.join(this.appDir, 'package-lock.json')]) {
try {
lock = JSON.parse(fs.readFileSync(file, 'utf8'));
break;
} catch (e) {
// try the next one
}
}
if (!lock || !lock.packages) {
throw new Error('No package-lock.json found to audit');
}
const found = new Map();
const add = (name, version) => {
if (!found.has(name)) {
found.set(name, new Set());
}
found.get(name).add(version);
};
for (const [key, info] of Object.entries(lock.packages)) {
if (!key || !info || info.dev || info.link || !info.version) {
continue;
}
const marker = 'node_modules/';
const name = info.name || key.substring(key.lastIndexOf(marker) + marker.length);
add(name, info.version);
}
try {
const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8'));
if (pkg.name && pkg.version) {
add(pkg.name, pkg.version);
}
} catch (e) {
// no package.json - nothing to add
}
const result = {};
for (const [name, versions] of found) {
result[name] = [...versions].sort();
}
return result;
}

async run() {
if (this.running) {
return;
}
this.running = true;
this.lastAttempt = new Date();
if (this.stats) {
this.stats.task('npm audit', 'Checking');
}
try {
const packages = this.collectPackages();
const response = await axios.post(this.url, packages, {
timeout: this.timeout,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
const data = response.data && typeof response.data === 'object' ? response.data : {};
const findings = [];
for (const [name, advisories] of Object.entries(data)) {
for (const a of Array.isArray(advisories) ? advisories : []) {
findings.push({
name,
installed: packages[name] || [],
id: a.id,
severity: SEVERITIES.includes(a.severity) ? a.severity : 'info',
title: a.title || '',
url: a.url || '',
range: a.vulnerable_versions || ''
});
}
}
findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || a.name.localeCompare(b.name));
this.findings = findings;
this.packageCount = Object.keys(packages).length;
this.checkedAt = new Date();
this.lastError = null;
const summary = this.summary();
if (findings.length > 0) {
if (this.logger) {
this.logger.warn(`npm audit: ${summary} in ${this.packageCount} packages: ` +
findings.map(f => `${f.name} (${f.severity}) ${f.url}`).join('; '));
}
if (this.stats) {
this.stats.taskError('npm audit', summary);
}
} else {
if (this.logger) {
this.logger.info(`npm audit: no known vulnerabilities in ${this.packageCount} packages`);
}
if (this.stats) {
this.stats.taskDone('npm audit', `No known vulnerabilities (${this.packageCount} packages)`);
}
}
} catch (error) {
this.lastError = error.message;
if (this.logger) {
this.logger.warn('npm audit could not be run: ' + error.message);
}
if (this.stats) {
this.stats.taskError('npm audit', 'Could not check: ' + error.message);
}
} finally {
this.running = false;
}
}

start() {
if (!this.enabled) {
return;
}
if (this.stats) {
this.stats.addTask('npm audit', `${this.intervalHours} hr`);
}
// run() handles its own errors, so the promise never rejects
const first = setTimeout(() => void this.run(), FIRST_RUN_DELAY_MS);
const repeat = setInterval(() => void this.run(), this.intervalHours * HOUR_MS);
for (const t of [first, repeat]) {
if (t.unref) {
t.unref();
}
this.timers.push(t);
}
}

stop() {
for (const t of this.timers) {
clearTimeout(t);
clearInterval(t);
}
this.timers = [];
}

counts() {
const counts = {};
for (const f of this.findings) {
counts[f.severity] = (counts[f.severity] || 0) + 1;
}
return counts;
}

// "3 known vulnerabilities (1 critical, 2 high)"
summary() {
const n = this.findings.length;
if (n === 0) {
return 'no known vulnerabilities';
}
const counts = this.counts();
const parts = SEVERITIES.filter(s => counts[s]).map(s => `${counts[s]} ${s}`);
return `${n} known vulnerabilit${n === 1 ? 'y' : 'ies'} (${parts.join(', ')})`;
}

isSerious() {
return this.findings.some(f => f.severity === 'critical' || f.severity === 'high');
}

/**
* Home page: nothing unless there is something to report
*/
renderBanner(now = Date.now()) {
if (!this.enabled || !this.checkedAt || this.findings.length === 0) {
return '';
}
const cls = this.isSerious() ? 'alert-danger' : 'alert-warning';
return `<div class="alert ${cls}" role="alert"><strong>Security:</strong> npm audit found ` +
`${escape(this.summary())} in the packages this server uses ` +
`(checked ${escape(describeAgo(this.checkedAt.getTime(), now))}). ` +
'The server administrator should upgrade to the latest FHIRsmith release. ' +
'<a href="/dashboard#npm-audit">Details</a></div>';
}

/**
* Dashboard: always shown, so a check that is failing or switched off is visible too
*/
renderDashboard(now = Date.now()) {
let html = '<div id="npm-audit">';
if (!this.enabled) {
return html + '<p><strong>npm audit:</strong> disabled (npmAudit.enabled = false)</p></div>';
}
if (!this.checkedAt) {
html += '<p><strong>npm audit:</strong> ';
html += this.lastError ? `<span style="color:#b00">could not be run: ${escape(this.lastError)}</span>` : 'not run yet';
return html + '</p></div>';
}
const colour = this.findings.length === 0 ? '#070' : (this.isSerious() ? '#b00' : '#b60');
html += `<p><strong>npm audit:</strong> <span style="color:${colour}">${escape(this.summary())}</span>` +
` in ${this.packageCount} packages, checked ${escape(describeAgo(this.checkedAt.getTime(), now))}`;
if (this.lastError) {
html += ` <span style="color:#b00">(the latest check failed: ${escape(this.lastError)})</span>`;
}
html += '</p>';
if (this.findings.length > 0) {
html += '<table class="grid"><tr><th>Severity</th><th>Package</th><th>Installed</th><th>Vulnerable</th><th>Advisory</th></tr>';
for (const f of this.findings) {
const link = /^https:\/\//.test(f.url) ? `<a href="${escape(f.url)}">${escape(f.title || f.url)}</a>` : escape(f.title);
html += `<tr><td>${escape(f.severity)}</td><td>${escape(f.name)}</td><td>${escape(f.installed.join(', '))}</td>` +
`<td>${escape(f.range)}</td><td>${link}</td></tr>`;
}
html += '</table>';
}
return html + '</div>';
}
}

module.exports = { NpmAudit, describeAgo };
21 changes: 21 additions & 0 deletions library/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,27 @@ const Utilities = {
}

return parts.join(' ');
},

/**
* How long ago a time was, for people: "35 minutes ago", "3 hours ago", "2 days ago"
* @param {Date|string|number} time - the earlier time
* @param {number} now - Date.now(), for testing
* @returns {string}
*/
describeAgo(time, now = Date.now()) {
const secs = Math.max(0, Math.floor((now - new Date(time).getTime()) / 1000));
const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`;
if (secs < 60) {
return 'just now';
}
if (secs < 3600) {
return plural(Math.floor(secs / 60), 'minute');
}
if (secs < 172800) {
return plural(Math.floor(secs / 3600), 'hour');
}
return plural(Math.floor(secs / 86400), 'day');
}

};
Expand Down
Loading
Loading