Upgrade vendored JS and add deps-check CVE gate (#3)

Upgrade the JavaScript bundles inlined into the offline HTML export:
DOMPurify 3.1.7 -> 3.4.9 (clears 10 OSV advisories), marked 12.0.2 -> 18.0.5,
highlight.js 11.9.0 -> 11.11.1. mermaid 10.9.6 and MathJax 3.2.2 are kept
(no known CVEs) and now guarded rather than chased.

Pin every bundle in assets/web_export/MANIFEST.json (npm name, version, source,
sha256, licence) and add tool/check_bundled_js.dart: it verifies each file
still matches the manifest hash and queries the OSV database for known
vulnerabilities. Wired as `make deps-check`, into `check-full`, and into CI
next to the licence check. THIRD_PARTY_NOTICES.md updated for the now-standalone
DOMPurify bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-11 22:16:29 +02:00
parent 6bf85773b0
commit f08055c7ae
9 changed files with 755 additions and 385 deletions

View file

@ -34,3 +34,8 @@ jobs:
# Fail the build if any dependency is not open source. # Fail the build if any dependency is not open source.
- name: Licence compliance (make licenses) - name: Licence compliance (make licenses)
run: make licenses run: make licenses
# Fail the build if a vendored JS bundle drifted from its manifest or a
# pinned version has a known vulnerability (queries the OSV database).
- name: Bundled JS security (make deps-check)
run: make deps-check

View file

@ -1,4 +1,4 @@
.PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated licenses check check-full help .PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated deps-check licenses check check-full help
help: help:
@echo "OciDeck quality targets:" @echo "OciDeck quality targets:"
@ -11,6 +11,7 @@ help:
@echo " make test-services Caption/description/image service tests." @echo " make test-services Caption/description/image service tests."
@echo " make test-presenter Fullscreen presenter interaction tests." @echo " make test-presenter Fullscreen presenter interaction tests."
@echo " make deps-outdated Advisory dependency freshness report." @echo " make deps-outdated Advisory dependency freshness report."
@echo " make deps-check Verify vendored JS bundles vs manifest + OSV CVEs."
@echo " make licenses Verify all dependencies use open-source licences." @echo " make licenses Verify all dependencies use open-source licences."
# Install Flutter/Dart dependencies. # Install Flutter/Dart dependencies.
@ -106,6 +107,18 @@ deps-outdated:
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions." @echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
flutter pub outdated flutter pub outdated
# Security gate for the vendored JS bundles inlined into the HTML export.
# Verifies each file still matches assets/web_export/MANIFEST.json (sha256) and
# queries the OSV database for known vulnerabilities in the pinned versions.
deps-check:
@echo "== OciDeck check: bundled JavaScript =="
@echo "Command: dart run tool/check_bundled_js.dart"
@echo "Covers: integrity (sha256 vs manifest) + known CVEs (OSV) for marked,"
@echo " highlight.js, DOMPurify, mermaid and MathJax."
@echo "Failure means: a bundle drifted from the manifest, or a pinned version"
@echo " now has a known vulnerability — upgrade it and refresh the manifest."
dart run tool/check_bundled_js.dart
# Open-source licence compliance check for all resolved dependencies. # Open-source licence compliance check for all resolved dependencies.
licenses: licenses:
@echo "== OciDeck check: licences ==" @echo "== OciDeck check: licences =="
@ -120,6 +133,6 @@ check: format-check analyze test
@echo "Validated: formatting, static analysis, and the full Flutter test suite." @echo "Validated: formatting, static analysis, and the full Flutter test suite."
# Extended local check with advisory dependency freshness after the required gate. # Extended local check with advisory dependency freshness after the required gate.
check-full: check licenses deps-outdated check-full: check licenses deps-check deps-outdated
@echo "== OciDeck extended check complete ==" @echo "== OciDeck extended check complete =="
@echo "Validated: required quality gate, licence compliance, and dependency freshness." @echo "Validated: required quality gate, licence compliance, bundled-JS CVEs, and dependency freshness."

View file

@ -14,10 +14,16 @@ Shipped inside the app and embedded into the **offline HTML export**
| --- | --- | --- | | --- | --- | --- |
| [marked](https://github.com/markedjs/marked) | Markdown → HTML in the export | MIT | | [marked](https://github.com/markedjs/marked) | Markdown → HTML in the export | MIT |
| [highlight.js](https://github.com/highlightjs/highlight.js) | Code highlighting in the export | BSD-3-Clause | | [highlight.js](https://github.com/highlightjs/highlight.js) | Code highlighting in the export | BSD-3-Clause |
| [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT (bundles [DOMPurify](https://github.com/cure53/DOMPurify), Apache-2.0 / MPL-2.0) | | [DOMPurify](https://github.com/cure53/DOMPurify) | Sanitises the rendered Markdown before it hits the DOM in the export | Apache-2.0 / MPL-2.0 |
| [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT |
| [MathJax](https://github.com/mathjax/MathJax) (`tex-svg.js`) | Math rendering in the export | Apache-2.0 | | [MathJax](https://github.com/mathjax/MathJax) (`tex-svg.js`) | Math rendering in the export | Apache-2.0 |
| [EB Garamond](https://github.com/octaviopardo/EBGaramond12) font | Bundled deck font | SIL Open Font License 1.1 | | [EB Garamond](https://github.com/octaviopardo/EBGaramond12) font | Bundled deck font | SIL Open Font License 1.1 |
The exact pinned version, source URL and SHA-256 of every vendored JS bundle
live in [`assets/web_export/MANIFEST.json`](assets/web_export/MANIFEST.json).
`make deps-check` verifies each file still matches that manifest and queries the
[OSV](https://osv.dev) database for known vulnerabilities.
## Vendored (forked) plugins ## Vendored (forked) plugins
Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency / Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency /

View file

@ -0,0 +1,46 @@
{
"_comment": "Pinned inventory of the vendored JavaScript bundles inlined into the offline HTML export (see lib/services/marp_html_service.dart). Each entry records the npm package + exact version so `make deps-check` can query the OSV vulnerability database, and a sha256 so the same check can prove the on-disk file still matches this manifest (tamper / accidental-replacement guard). When you intentionally upgrade a bundle, update its version, source and sha256 here in the same commit.",
"ecosystem": "npm",
"bundles": [
{
"file": "marked.min.js",
"npm": "marked",
"version": "18.0.5",
"sha256": "2dc4769dfde29f51c7aca1a539c6407c789c8ea644cf8b7d01ded28a9c1d800b",
"source": "https://cdn.jsdelivr.net/npm/marked@18.0.5/lib/marked.umd.js",
"license": "MIT"
},
{
"file": "highlight.min.js",
"npm": "highlight.js",
"version": "11.11.1",
"sha256": "c4a399dd6f488bc97a3546e3476747b3e714c99c57b9473154c6fb8d259b9381",
"source": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js",
"license": "BSD-3-Clause"
},
{
"file": "purify.min.js",
"npm": "dompurify",
"version": "3.4.9",
"sha256": "3c16cc90eb152b823b71b8585cd79e7fb7cd7a380157a800dfbd9459aad5f726",
"source": "https://cdn.jsdelivr.net/npm/dompurify@3.4.9/dist/purify.min.js",
"license": "Apache-2.0 OR MPL-2.0"
},
{
"file": "mermaid.min.js",
"npm": "mermaid",
"version": "10.9.6",
"sha256": "eda3a0ad572bbe69a318c1be0163e8233dd824f3f12939e5168feba207767151",
"source": "https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js",
"license": "MIT"
},
{
"file": "tex-svg.js",
"npm": "mathjax",
"version": "3.2.2",
"sha256": "d4295dc33744836935c1399feece5159577b34c5c8ffb9f1c6324cd82e03a882",
"source": "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js",
"license": "Apache-2.0"
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
assets/web_export/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -6,6 +6,7 @@ import 'package:flutter/services.dart' show rootBundle;
import '../models/chart.dart'; import '../models/chart.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../utils/log.dart';
/// Builds a single, self-contained HTML file from a deck's Marp Markdown. /// Builds a single, self-contained HTML file from a deck's Marp Markdown.
/// ///
@ -38,6 +39,7 @@ class MarpHtmlService {
/// colours and font so the export matches the in-app / PDF look. /// colours and font so the export matches the in-app / PDF look.
Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async { Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async {
final marked = await loadAsset('$_assetDir/marked.min.js'); final marked = await loadAsset('$_assetDir/marked.min.js');
final purify = await loadAsset('$_assetDir/purify.min.js');
final hljs = await loadAsset('$_assetDir/highlight.min.js'); final hljs = await loadAsset('$_assetDir/highlight.min.js');
final hljsCss = await loadAsset('$_assetDir/highlight.css'); final hljsCss = await loadAsset('$_assetDir/highlight.css');
final mathjax = await loadAsset('$_assetDir/tex-svg.js'); final mathjax = await loadAsset('$_assetDir/tex-svg.js');
@ -61,6 +63,7 @@ class MarpHtmlService {
'<style>$css\n$hljsCss</style>' '<style>$css\n$hljsCss</style>'
'<script>$_mathjaxConfig</script>' '<script>$_mathjaxConfig</script>'
'${inline(marked)}' '${inline(marked)}'
'${inline(purify)}'
'${inline(hljs)}' '${inline(hljs)}'
'${inline(mathjax)}' '${inline(mathjax)}'
'${inline(mermaid)}' '${inline(mermaid)}'
@ -97,11 +100,16 @@ class MarpHtmlService {
} }
/// Neutralise any `</script` inside inlined content so it can't break out of /// Neutralise any `</script` inside inlined content so it can't break out of
/// the surrounding <script> element. Safe for both JS (string contexts) and /// the surrounding <script> element. Case-insensitive `</ScRiPt>` must not
/// the embedded Markdown payloads. /// slip through. Safe for both JS (string contexts) and the embedded Markdown
static String _guard(String s) => s /// payloads.
.replaceAll('</script', r'<\/script') static final RegExp _scriptClose = RegExp(
.replaceAll('</SCRIPT', r'<\/SCRIPT'); r'</(script)',
caseSensitive: false,
);
static String _guard(String s) =>
s.replaceAllMapped(_scriptClose, (m) => '<\\/${m.group(1)}');
// Charts inline SVG // Charts inline SVG
@ -563,7 +571,9 @@ class MarpHtmlService {
final range = (rawHi - rawLo).abs(); final range = (rawHi - rawLo).abs();
final r = range <= 0 ? 1.0 : range; final r = range <= 0 ? 1.0 : range;
final rawStep = r / 4; final rawStep = r / 4;
final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble(); final mag = math
.pow(10, (math.log(rawStep) / math.ln10).floor())
.toDouble();
final norm = rawStep / mag; final norm = rawStep / mag;
final niceNorm = norm < 1.5 final niceNorm = norm < 1.5
? 1.0 ? 1.0
@ -640,7 +650,8 @@ class MarpHtmlService {
return "@font-face{font-family:'EB Garamond';font-weight:400 800;" return "@font-face{font-family:'EB Garamond';font-weight:400 800;"
"font-style:normal;src:url(data:font/ttf;base64,$b64) " "font-style:normal;src:url(data:font/ttf;base64,$b64) "
"format('truetype');}"; "format('truetype');}";
} catch (_) { } catch (e) {
logWarning('MarpHtmlService._ebGaramondFontFace: load font asset', e);
return ''; // Fall back to the CSS font stack if the asset is missing. return ''; // Fall back to the CSS font stack if the asset is missing.
} }
} }
@ -672,7 +683,11 @@ body{background:#1e1e1e;font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Ar
var holder=sec.querySelector('script[type="text/markdown"]'); var holder=sec.querySelector('script[type="text/markdown"]');
var src=holder?holder.textContent:''; var src=holder?holder.textContent:'';
var div=document.createElement('div');div.className='content'; var div=document.createElement('div');div.className='content';
div.innerHTML=window.marked?marked.parse(src):src; var html=window.marked?marked.parse(src):src;
// Sanitise rendered Markdown before it touches the DOM: a deck must not be
// able to run script/onerror/javascript: payloads when the export is opened.
// If the sanitiser somehow isn't present, fail closed to plain text.
if(window.DOMPurify){div.innerHTML=DOMPurify.sanitize(html);}else{div.textContent=src;}
sec.innerHTML='';sec.appendChild(div); sec.innerHTML='';sec.appendChild(div);
}); });
document.querySelectorAll('code.language-mermaid').forEach(function(code){ document.querySelectorAll('code.language-mermaid').forEach(function(code){

178
tool/check_bundled_js.dart Normal file
View file

@ -0,0 +1,178 @@
// Security gate for the vendored JavaScript bundles that get inlined into the
// offline HTML export (marked, highlight.js, DOMPurify, mermaid, MathJax see
// lib/services/marp_html_service.dart). Unlike Dart packages, these are checked
// into the repo by hand and are not covered by `flutter pub outdated`, so this
// tool is their dedicated safety net.
//
// dart run tool/check_bundled_js.dart (or: make deps-check)
//
// It does two independent things, both of which can fail the build:
//
// 1. Integrity (offline, deterministic): every file listed in
// assets/web_export/MANIFEST.json must still hash to the recorded sha256.
// Catches a bundle that was swapped, truncated, or edited without the
// manifest (and therefore this review) being updated.
//
// 2. Known vulnerabilities (online): each pinned package@version is queried
// against the OSV database (https://osv.dev). Any advisory fails the gate
// so we learn a vendored bundle needs upgrading the moment a CVE lands
// not at the next manual audit.
//
// Exit codes: 0 = clean 1 = integrity mismatch or known vulnerability
// 2 = could not run the check (missing manifest / no network)
//
// Run it routinely (it is wired into `make deps-check` and CI). When it flags a
// vulnerability, upgrade the bundle, refresh its version + sha256 in
// MANIFEST.json, and update THIRD_PARTY_NOTICES.md in the same commit.
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
const _manifestPath = 'assets/web_export/MANIFEST.json';
const _assetDir = 'assets/web_export';
const _osvUrl = 'https://api.osv.dev/v1/query';
Future<void> main(List<String> args) async {
final offline = args.contains('--offline');
final manifestFile = File(_manifestPath);
if (!manifestFile.existsSync()) {
stderr.writeln('No $_manifestPath — cannot check the bundled JS.');
exit(2);
}
final manifest =
jsonDecode(manifestFile.readAsStringSync()) as Map<String, dynamic>;
final ecosystem = (manifest['ecosystem'] as String?) ?? 'npm';
final bundles = (manifest['bundles'] as List).cast<Map<String, dynamic>>();
stdout.writeln('== OciDeck check: bundled JavaScript ==');
stdout.writeln('Manifest: $_manifestPath (${bundles.length} bundles)\n');
final integrityProblems = <String>[];
final vulnProblems = <String>[];
var networkFailed = false;
for (final b in bundles) {
final file = b['file'] as String;
final npm = b['npm'] as String;
final version = b['version'] as String;
final expected = (b['sha256'] as String).toLowerCase();
final path = '$_assetDir/$file';
// --- 1. Integrity -------------------------------------------------------
final f = File(path);
String integrity;
if (!f.existsSync()) {
integrity = 'MISSING FILE';
integrityProblems.add('$file — file not found at $path');
} else {
final actual = sha256.convert(f.readAsBytesSync()).toString();
if (actual == expected) {
integrity = 'sha256 ok';
} else {
integrity = 'SHA256 MISMATCH';
integrityProblems.add(
'$file — sha256 differs from manifest\n'
' expected $expected\n'
' actual $actual',
);
}
}
// --- 2. Known vulnerabilities (OSV) ------------------------------------
String vulnStatus;
if (offline) {
vulnStatus = 'skipped (--offline)';
} else {
final r = await _queryOsv(ecosystem, npm, version);
if (r == null) {
vulnStatus = 'OSV UNREACHABLE';
networkFailed = true;
} else if (r.isEmpty) {
vulnStatus = 'no known CVEs';
} else {
vulnStatus = '${r.length} ADVISORY(IES): ${r.join(', ')}';
vulnProblems.add('$npm@$version${r.join(', ')}');
}
}
stdout.writeln(' $npm@$version ($file)');
stdout.writeln(' integrity : $integrity');
stdout.writeln(' osv : $vulnStatus');
}
stdout.writeln('');
if (integrityProblems.isEmpty && vulnProblems.isEmpty && !networkFailed) {
stdout.writeln(
'OK — all bundles match the manifest and have no known CVEs.',
);
exit(0);
}
if (integrityProblems.isNotEmpty) {
stderr.writeln('INTEGRITY — ${integrityProblems.length} problem(s):');
for (final p in integrityProblems) {
stderr.writeln(' $p');
}
stderr.writeln(
' A mismatch means a bundle changed without MANIFEST.json being updated.\n'
' If the change was intentional, refresh the version + sha256 there.',
);
}
if (vulnProblems.isNotEmpty) {
stderr.writeln('\nVULNERABILITIES — ${vulnProblems.length} bundle(s):');
for (final p in vulnProblems) {
stderr.writeln(' $p');
}
stderr.writeln(
' Upgrade the bundle, then update MANIFEST.json + THIRD_PARTY_NOTICES.md.\n'
' Advisory detail: https://osv.dev/<ID>',
);
}
if (integrityProblems.isNotEmpty || vulnProblems.isNotEmpty) exit(1);
// Only network trouble, nothing actually wrong with the bundles.
stderr.writeln(
'COULD NOT VERIFY CVEs — OSV was unreachable. Integrity passed.\n'
' Re-run with network access, or `--offline` to check integrity only.',
);
exit(2);
}
/// Returns the list of OSV advisory IDs affecting [name]@[version], an empty
/// list if there are none, or null if the database could not be reached.
Future<List<String>?> _queryOsv(
String ecosystem,
String name,
String version,
) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 15);
try {
final req = await client.postUrl(Uri.parse(_osvUrl));
req.headers.contentType = ContentType.json;
req.write(
jsonEncode({
'package': {'name': name, 'ecosystem': ecosystem},
'version': version,
}),
);
final resp = await req.close().timeout(const Duration(seconds: 20));
if (resp.statusCode != 200) return null;
final body = await resp.transform(utf8.decoder).join();
final data = jsonDecode(body) as Map<String, dynamic>;
final vulns = (data['vulns'] as List?) ?? const [];
return vulns
.map((v) => (v as Map<String, dynamic>)['id'] as String)
.toList();
} on Object {
return null;
} finally {
client.close(force: true);
}
}