Skip to content

Commit af2435c

Browse files
authored
Support projected GeoParquet CRS overrides (#1766)
* fix: support projected GeoParquet CRS overrides * ci: unblock the dependency audit on unpatchable image-size advisories The `Dependency audit` job started failing on every PR after GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq (image-size DoS) had their affected range widened to `<= 2.0.2` on 2026-08-07. This is not specific to this branch — main's lockfile fails the same audit; main's last run only passed because it ran minutes before the advisory data propagated. There is no upgrade path: 2.0.2 is the latest image-size release and no patched version exists. It arrives via texture-compressor, a dependency of @loaders.gl/textures, which is pulled in by @deck.gl/geo-layers and @deck.gl/mesh-layers — core deps — so it cannot be dropped either. - Add scripts/audit-check.mjs, a wrapper over `npm audit --omit=dev` that still fails on every high/critical advisory except those in a documented ALLOWLIST. Plain `npm audit` has no way to accept a single finding, so one unpatchable transitive advisory reddens every PR until upstream ships a fix. - Allowlist the two image-size advisories. texture-compressor is only ever spawned via `npx` from @loaders.gl/textures' encodeImageURLToCompressedTextureURL, a Node-only encoder that GeoLibre never calls and that cannot bundle into the browser build, so no attacker-supplied image reaches the vulnerable parsers. - Wire it up as `npm run audit:ci` and call it from the CI audit job. - Document the gate and the rules for allowlisting in CLAUDE.md. Stale allowlist entries warn rather than fail, since the advisory database is a live service and a transient omission must not redden an unrelated PR. * Address review feedback - scripts/audit-check.mjs: fail closed when the audit does not actually run. `npm audit --json` returns valid JSON on a registry outage, auth failure or npm internal error, but as an `{error, message}` envelope with no `vulnerabilities` key. The previous `report.vulnerabilities ?? {}` read that as zero advisories and exited 0, so a broken audit would have passed the gate — a fail-open regression against the plain `npm audit` step it replaced. Verified against an unreachable registry: the wrapper now exits 1 with the underlying npm message. Also rejects spawn failures, kill signals, non-object JSON, and a missing `vulnerabilities` section. * Address review feedback - scripts/audit-check.mjs: reject an array-valued `vulnerabilities` section. Arrays are `typeof "object"`, so `{"vulnerabilities": []}` passed the guard and then yielded zero entries — a malformed report reading as clean. - scripts/audit-check.mjs: stop dropping advisories whose `via.url` carries no GHSA id. They were skipped outright, so a high/critical one would have passed the gate; they now fall back to a key built from the url/source/name, which can never match an ALLOWLIST entry and therefore still blocks. Print the advisory's own url rather than synthesizing one from the key. * Address review feedback - scripts/audit-check.mjs: spawn npm with `shell: process.platform === "win32"`. npm is npm.cmd on Windows and Node will not resolve it without a shell, so `npm run audit:ci` failed with ENOENT on Windows dev machines. Matches the same guard already used in scripts/build-embed.mjs and scripts/tauri-build.mjs. * Address review feedback - scripts/audit-check.mjs: also consult `report.error.summary` when npm returns an error envelope. A registry outage populates the top-level `message` and leaves `error.summary`/`error.detail` as empty strings, but other npm errors do the reverse, so try all three before falling back to stderr.
1 parent 6597457 commit af2435c

7 files changed

Lines changed: 170 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ jobs:
3636
# local build/dev tooling (e.g. sharp/libvips via wrangler+miniflare, which
3737
# never ships in a GeoLibre artifact), while still blocking anything that
3838
# reaches users. Dependabot bumps the dev toolchain separately.
39+
# Wrapped in scripts/audit-check.mjs because plain `npm audit` cannot accept
40+
# a single advisory: see the ALLOWLIST there for the unpatchable, unreachable
41+
# findings that would otherwise redden every PR indefinitely.
3942
- name: Audit npm dependencies (high and critical)
40-
run: npm audit --omit=dev --audit-level=high
43+
run: npm run audit:ci
4144

4245
e2e:
4346
name: E2E smoke (Playwright)

CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,18 @@ in CI; add specs under `e2e/`.
5555

5656
Dependencies are watched two ways: **Dependabot** (`.github/dependabot.yml`)
5757
opens grouped weekly update PRs for npm, pip (backend + `python/`), cargo, and
58-
Actions, and the CI **`audit` job** runs `npm audit --audit-level=high`
58+
Actions, and the CI **`audit` job** runs `npm run audit:ci`
5959
(blocking) plus a non-blocking `pip-audit` of the resolved backend environment.
60+
`audit:ci` is `scripts/audit-check.mjs`, a thin wrapper over `npm audit
61+
--omit=dev` that still fails on every high/critical advisory *except* the ones
62+
listed in its `ALLOWLIST`. The wrapper exists because plain `npm audit` cannot
63+
accept a single finding, so one unpatchable transitive advisory reddens every PR
64+
until upstream ships a fix — which for an unmaintained leaf package may be never.
65+
Only allowlist an advisory when there is **no patched version to upgrade to** and
66+
the vulnerable code is **unreachable from a GeoLibre runtime path**, and say why
67+
on both counts in the entry. Anything upgradeable gets upgraded instead. Stale
68+
entries print a warning rather than failing, since the advisory database is a
69+
live service and a transient omission must not redden an unrelated PR.
6070

6171
The `python/` package has its own pytest suite (`cd python && pytest`) and is built into a wheel via `npm run build:embed` (produces `apps/geolibre-desktop/dist-embed`, consumed by `python/hatch_build.py`). Its version is dynamic, sourced from `python/src/geolibre/__init__.py`.
6272

apps/geolibre-desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
"maplibre-gl-swipe": "^0.11.1",
8989
"maplibre-gl-time-slider": "^1.8.4",
9090
"maplibre-gl-usgs-lidar": "^0.11.1",
91-
"maplibre-gl-vector": "^0.10.8",
91+
"maplibre-gl-vector": "^0.10.9",
9292
"openai": "^7.3.0",
9393
"qrcode.react": "^4.2.0",
9494
"react": "^19.2.8",

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"test:worker": "npm run typecheck -w geolibre-viewer-worker && npm run typecheck -w geolibre-collab-worker && npm run typecheck -w geolibre-collab-node && npm test -w geolibre-collab-node && npm run typecheck -w geolibre-tiles-worker && npm run typecheck -w geolibre-ai-proxy-worker",
2424
"test:e2e": "playwright test",
2525
"check:rust": "cargo check --manifest-path apps/geolibre-desktop/src-tauri/Cargo.toml",
26+
"audit:ci": "node scripts/audit-check.mjs",
2627
"ci": "npm run lint && npm run build && npm run test:frontend:coverage && npm run test:worker && npm run test:backend:coverage && npm run check:rust",
2728
"tauri": "npm run tauri -w geolibre-desktop",
2829
"tauri:dev": "npm run tauri dev -w geolibre-desktop",

packages/plugins/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"maplibre-gl-swipe": "^0.11.1",
6565
"maplibre-gl-time-slider": "^1.8.4",
6666
"maplibre-gl-usgs-lidar": "^0.11.1",
67-
"maplibre-gl-vector": "^0.10.8",
67+
"maplibre-gl-vector": "^0.10.9",
6868
"netcdfjs": "^4.0.0",
6969
"ngeohash": "^0.6.4",
7070
"open-location-code-typescript": "^1.5.0",

scripts/audit-check.mjs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// CI dependency gate: `npm audit --omit=dev` with a documented allowlist.
2+
// Run with: node scripts/audit-check.mjs (or `npm run audit:ci`)
3+
//
4+
// Plain `npm audit --audit-level=high` has no way to accept a single advisory,
5+
// so one unpatchable transitive finding reddens every PR until upstream ships a
6+
// fix — which, for an unmaintained leaf package, may be never. This keeps the
7+
// gate blocking on high/critical, but lets ALLOWLIST carry the advisories that
8+
// have no fix to upgrade to *and* no reachable GeoLibre code path.
9+
//
10+
// Rules for adding an entry: there must be no patched version available, the
11+
// vulnerable code must not reach a GeoLibre runtime path, and the reason has to
12+
// say why on both counts. Anything upgradeable gets upgraded instead.
13+
import { spawnSync } from "node:child_process";
14+
15+
// Severities that fail the build. Moderate/low are left to Dependabot PRs.
16+
const BLOCKING = new Set(["high", "critical"]);
17+
18+
const ALLOWLIST = new Map([
19+
[
20+
"GHSA-w3rx-r6r6-pgpr",
21+
"image-size DoS (ICNS parser infinite loop). No patched version exists — " +
22+
"the advisory covers <=2.0.2 and 2.0.2 is the latest release. It reaches " +
23+
"us only as a dependency of texture-compressor, which @loaders.gl/textures " +
24+
"spawns via `npx` from encodeImageURLToCompressedTextureURL (a Node-only " +
25+
"encoder). GeoLibre never calls that encoder and it cannot bundle into the " +
26+
"browser build, so no attacker-supplied image is ever parsed by it.",
27+
],
28+
[
29+
"GHSA-5p2g-fcmc-qvqq",
30+
"image-size DoS (JXL/HEIF parser infinite loops). Same package, same lack of " +
31+
"a patched version, and the same unreachable texture-compressor path as " +
32+
"GHSA-w3rx-r6r6-pgpr above.",
33+
],
34+
]);
35+
36+
const audit = spawnSync("npm", ["audit", "--omit=dev", "--json"], {
37+
encoding: "utf8",
38+
maxBuffer: 32 * 1024 * 1024,
39+
// npm is npm.cmd on Windows, which Node will not resolve without a shell.
40+
shell: process.platform === "win32",
41+
});
42+
43+
// The gate must fail closed: anything short of a report we can actually read is
44+
// an error, never an implicit "clean". npm's exit code can't carry that, since
45+
// it also goes non-zero merely because vulnerabilities exist — so the report
46+
// itself is the signal.
47+
function unusable(why, detail) {
48+
console.error(`npm audit did not return a usable report: ${why}`);
49+
if (detail) console.error(detail);
50+
process.exit(1);
51+
}
52+
53+
if (audit.error) unusable("npm could not be run.", audit.error.message);
54+
if (audit.signal) unusable(`npm was killed by ${audit.signal}.`, audit.stderr);
55+
56+
let report;
57+
try {
58+
report = JSON.parse(audit.stdout);
59+
} catch {
60+
unusable("stdout was not JSON.", audit.stdout || audit.stderr);
61+
}
62+
63+
// A registry outage, an auth failure or an npm internal error still prints valid
64+
// JSON — but an `{error, message}` envelope with no `vulnerabilities` key rather
65+
// than a report. Left unchecked, `report.vulnerabilities ?? {}` would read that
66+
// as zero advisories and pass the gate exactly when the audit did not run.
67+
if (report === null || typeof report !== "object" || Array.isArray(report)) {
68+
unusable("stdout was JSON but not an object.", audit.stdout);
69+
}
70+
if (report.error) {
71+
// A registry outage fills the top-level `message` and leaves `error.summary`
72+
// and `error.detail` empty strings; other npm errors do the reverse. Try all
73+
// three so the failure output carries whichever one npm populated.
74+
unusable(
75+
"npm reported an error.",
76+
report.error.detail || report.error.summary || report.message || audit.stderr,
77+
);
78+
}
79+
// Arrays are typeof "object" too, and an array would yield zero entries below
80+
// rather than an error — so a malformed report would read as clean.
81+
if (
82+
typeof report.vulnerabilities !== "object" ||
83+
report.vulnerabilities === null ||
84+
Array.isArray(report.vulnerabilities)
85+
) {
86+
unusable("the report has no `vulnerabilities` section.", audit.stdout);
87+
}
88+
89+
// Flatten the report to one entry per advisory. `via` holds advisory objects for
90+
// the package that actually carries the flaw, and plain package-name strings for
91+
// the dependents that only inherit it — so collecting the objects covers every
92+
// affected package without counting the same advisory once per dependent.
93+
const advisories = new Map();
94+
for (const vuln of Object.values(report.vulnerabilities)) {
95+
for (const via of vuln.via ?? []) {
96+
if (typeof via !== "object") continue;
97+
// Fail closed on an advisory we cannot name: fall back to a key built from
98+
// whatever npm did give us. It can never match an ALLOWLIST entry (those are
99+
// GHSA ids), so a high/critical one still blocks instead of being dropped.
100+
const id =
101+
/(GHSA-[\w-]+)/.exec(via.url ?? "")?.[1] ??
102+
`unidentified advisory (${via.url ?? via.source ?? via.name})`;
103+
const entry = advisories.get(id) ?? {
104+
title: via.title,
105+
severity: via.severity,
106+
url: via.url,
107+
packages: new Set(),
108+
};
109+
entry.packages.add(via.name);
110+
advisories.set(id, entry);
111+
}
112+
}
113+
114+
const blocking = [...advisories].filter(
115+
([id, a]) => BLOCKING.has(a.severity) && !ALLOWLIST.has(id),
116+
);
117+
const allowed = [...advisories].filter(([id]) => ALLOWLIST.has(id));
118+
119+
for (const [id, a] of allowed) {
120+
console.log(`allowed ${a.severity.padEnd(8)} ${id} ${[...a.packages].join(", ")}`);
121+
console.log(` ${ALLOWLIST.get(id)}`);
122+
}
123+
124+
// Stale entries are a warning, not a failure: the advisory database is a live
125+
// service, so a transient omission must not redden an unrelated PR. The warning
126+
// is still worth acting on — delete the entry once upstream has a fix.
127+
for (const id of ALLOWLIST.keys()) {
128+
if (!advisories.has(id)) {
129+
console.warn(`warning: ${id} is allowlisted but no longer reported — drop it.`);
130+
}
131+
}
132+
133+
if (blocking.length === 0) {
134+
console.log(`\nNo unallowed high/critical advisories (${allowed.length} allowlisted).`);
135+
process.exit(0);
136+
}
137+
138+
console.error(
139+
`\n${blocking.length} unallowed high/critical advisor${blocking.length === 1 ? "y" : "ies"}:`,
140+
);
141+
for (const [id, a] of blocking) {
142+
console.error(` ${a.severity.padEnd(8)} ${id} ${[...a.packages].join(", ")}`);
143+
console.error(` ${a.title}`);
144+
if (a.url) console.error(` ${a.url}`);
145+
}
146+
console.error("\nUpgrade the dependency, or add an entry to ALLOWLIST in scripts/audit-check.mjs.");
147+
process.exit(1);

0 commit comments

Comments
 (0)