From 707d8cc08d9ae1bb0955a111307253ed98d6b70c Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 12:42:06 +0300 Subject: [PATCH 1/6] fix(security): reject unvalidated symlinks after zip extraction extract-zip has an unpatched symlink path-traversal vulnerability (GHSA-jmr9-qjv8-65gv, no fixed release exists). It creates symlinks from zip entries without validating their target, so a malicious backup/asset archive could plant a symlink pointing outside the extraction directory. Reject any symlink found in the extracted tree before dist.service.ts and changelog-generator.ts read/write through it. --- src/lib/changelog-generator.ts | 4 +++- src/lib/dist.service.ts | 3 ++- src/lib/helpers/zip.helper.ts | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/lib/changelog-generator.ts b/src/lib/changelog-generator.ts index f887464..fff49b8 100644 --- a/src/lib/changelog-generator.ts +++ b/src/lib/changelog-generator.ts @@ -8,6 +8,7 @@ import { colors } from './helpers/color.helper'; import * as os from 'os'; import * as crypto from 'crypto'; import extractZip from 'extract-zip'; +import { rejectSymlinks } from './helpers/zip.helper.js'; export const changelogTemplate = /* HTML */ ` @@ -398,7 +399,8 @@ export class ChangelogGenerator { private async unzipFile(assetPath: string, destinationPath: string): Promise { fs.rmSync(destinationPath, { force: true, recursive: true }); - return extractZip(assetPath, { dir: destinationPath }); + await extractZip(assetPath, { dir: destinationPath }); + await rejectSymlinks(destinationPath); } private normalizeAssetFolderPath(assetPath: string): string { diff --git a/src/lib/dist.service.ts b/src/lib/dist.service.ts index b8f3928..dbb3ed1 100644 --- a/src/lib/dist.service.ts +++ b/src/lib/dist.service.ts @@ -11,7 +11,7 @@ import { createLogger } from './logger.js'; import { Logger } from 'vite'; import { colors } from './helpers/color.helper.js'; import { writeBuildVersionManifest } from './version-manifest.js'; -import { zipDirectoryToBuffer } from './helpers/zip.helper.js'; +import { zipDirectoryToBuffer, rejectSymlinks } from './helpers/zip.helper.js'; import { runNextBuildProcess } from './next-build-runner.js'; import { createDefaultZipFileName, normalizeRelativeOutputPath } from './output-path.js'; @@ -357,6 +357,7 @@ export class DistService { await fs.mkdir(extractedDir, { recursive: true }); await fs.writeFile(zipPath, backupFile); await extractZip(zipPath, { dir: extractedDir }); + await rejectSymlinks(extractedDir); const contentRootDir = await this.normalizeExtractedRootDir(extractedDir); const allFiles = await this.listFilesRecursive(contentRootDir); diff --git a/src/lib/helpers/zip.helper.ts b/src/lib/helpers/zip.helper.ts index c2b39c6..cd0da44 100644 --- a/src/lib/helpers/zip.helper.ts +++ b/src/lib/helpers/zip.helper.ts @@ -26,3 +26,26 @@ export async function zipDirectoryToBuffer(dir: string): Promise { return await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); } + +/** + * Recursively throws if `dir` contains a symlink. + * + * extract-zip does not validate symlink targets (GHSA-jmr9-qjv8-65gv, unpatched as of writing), + * so a malicious archive can plant a symlink that points outside the extraction directory. Call + * this right after extraction and before any code reads/writes through the extracted paths. + */ +export async function rejectSymlinks(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isSymbolicLink()) { + throw new Error(`Zip archive contains a symlink ("${entry.name}"), which is not allowed`); + } + + if (entry.isDirectory()) { + await rejectSymlinks(fullPath); + } + } +} From 9c5f9b509aff93d2cf6e18680a69074041df2c5b Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 12:42:13 +0300 Subject: [PATCH 2/6] chore(deps): fix npm audit vulnerabilities --- package-lock.json | 18 ++++++++++++++---- package.json | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index a613bc4..ba54bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -4645,9 +4645,19 @@ "license": "MIT" }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", + "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" diff --git a/package.json b/package.json index 6c393b1..929236e 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", From 73768d6fe46c14d7ec5a3c4a6d281312ec966284 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 12:42:20 +0300 Subject: [PATCH 3/6] chore(deps): fix audit vulnerabilities in test fixtures --- tests/test-commonjs/package-lock.json | 20 +++++++++++++++----- tests/test-nextjs-cjs/package-lock.json | 20 +++++++++++++++----- tests/test-nextjs/package-lock.json | 22 ++++++++++++++++------ tests/test-nextjs/package.json | 2 +- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/tests/test-commonjs/package-lock.json b/tests/test-commonjs/package-lock.json index aaa506e..118202d 100644 --- a/tests/test-commonjs/package-lock.json +++ b/tests/test-commonjs/package-lock.json @@ -1279,13 +1279,13 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", + "integrity": "sha512-irgUyTRBICNIdD25MyY4S2RMPjdjBfwN62H9ovYKgW2/jx6r7X94N+VoV1Puwsqt+jNT9EqBKUBfvrI7wsOung==", "license": "ISC", "dependencies": { "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -3436,9 +3436,19 @@ "license": "MIT" }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", + "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" diff --git a/tests/test-nextjs-cjs/package-lock.json b/tests/test-nextjs-cjs/package-lock.json index 4b5f232..3b3fc72 100644 --- a/tests/test-nextjs-cjs/package-lock.json +++ b/tests/test-nextjs-cjs/package-lock.json @@ -1823,13 +1823,13 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", + "integrity": "sha512-irgUyTRBICNIdD25MyY4S2RMPjdjBfwN62H9ovYKgW2/jx6r7X94N+VoV1Puwsqt+jNT9EqBKUBfvrI7wsOung==", "license": "ISC", "dependencies": { "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -5440,9 +5440,19 @@ "dev": true }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", + "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" diff --git a/tests/test-nextjs/package-lock.json b/tests/test-nextjs/package-lock.json index 7b4a3c5..28ac4f1 100644 --- a/tests/test-nextjs/package-lock.json +++ b/tests/test-nextjs/package-lock.json @@ -11,7 +11,7 @@ "@metricinsights/pp-dev": "file:../../metricinsights-pp-dev-latest.tgz", "axios": "^1.18.1", "cac": "^7.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -1917,13 +1917,13 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", + "integrity": "sha512-irgUyTRBICNIdD25MyY4S2RMPjdjBfwN62H9ovYKgW2/jx6r7X94N+VoV1Puwsqt+jNT9EqBKUBfvrI7wsOung==", "license": "ISC", "dependencies": { "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -5520,9 +5520,19 @@ } }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", + "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" diff --git a/tests/test-nextjs/package.json b/tests/test-nextjs/package.json index 09139ac..d80af31 100644 --- a/tests/test-nextjs/package.json +++ b/tests/test-nextjs/package.json @@ -14,7 +14,7 @@ "@metricinsights/pp-dev": "file:../../metricinsights-pp-dev-latest.tgz", "axios": "^1.18.1", "cac": "^7.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", From 0a2834f2ec6713deeac7a7d549fe17bfc12dde5b Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 12:50:29 +0300 Subject: [PATCH 4/6] chore(license): add MIT license (PP-4041) mi-examples repos are required to be MIT licensed. Add a LICENSE file and switch package.json's license field from ISC to MIT to match. --- LICENSE | 21 +++++++++++++++++++++ package-lock.json | 2 +- package.json | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a7c9a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Metric Insights, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package-lock.json b/package-lock.json index ba54bea..b6ea588 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "@metricinsights/pp-dev", "version": "1.2.0-beta.3", - "license": "ISC", + "license": "MIT", "dependencies": { "axios": "^1.18.1", "cac": "^7.0.0", diff --git a/package.json b/package.json index 929236e..4d771da 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ ] } }, - "license": "ISC", + "license": "MIT", "engines": { "node": ">=24" }, From 670c48fec79bef3717ab987dd802daf1cdf361fb Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 13:01:37 +0300 Subject: [PATCH 5/6] fix(ci): allowlist extract-zip's unfixable advisory in audit-all npm audit has no fix for extract-zip's symlink advisory (GHSA-jmr9-qjv8-65gv, already mitigated in application code), so audit-all always exited 1 and failed the CI build job regardless of the code-level mitigation. Rewrite audit-all.mjs to evaluate `npm audit --json` against a small, documented allowlist of GHSA ids instead of trusting npm's raw exit code, so a known, unfixable, mitigated advisory no longer blocks CI while any other high or critical vulnerability still fails the build. --- CLAUDE.md | 6 +++ scripts/audit-all.mjs | 120 +++++++++++++++++++++++++++++++++++------- 2 files changed, 106 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 69d28ed..29b9995 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,12 @@ This runs `npm audit` in root + `tests/test-commonjs`, `tests/test-nextjs`, `tes If test-fixture lockfiles need patching, add/update `overrides` in their `package.json` and run `npm install` there. +If an advisory has no upstream fix at all (`fixAvailable: false` and no newer version exists), don't +try to force an override that doesn't exist. Mitigate it in application code instead, then add the +GHSA id to the `ALLOWLIST` map in `scripts/audit-all.mjs` with a comment explaining the mitigation — +that's the only thing that lets `audit:all` pass without silently hiding real, fixable vulnerabilities. +Remove the entry as soon as a real fix ships upstream. + ## After changing root package source ```bash diff --git a/scripts/audit-all.mjs b/scripts/audit-all.mjs index 7623a96..88bd5a7 100644 --- a/scripts/audit-all.mjs +++ b/scripts/audit-all.mjs @@ -1,8 +1,11 @@ /** - * Runs `npm audit` in the repository root and every tests/* package that has a package.json. - * Exits with code 1 if any audit reports vulnerabilities or fails. + * Runs `npm audit --json` in the repository root and every tests/* package that has a package.json. + * Fails (exit 1) if any package reports a high/critical vulnerability that isn't in ALLOWLIST below. * - * Audit level: "high" for every target. + * ALLOWLIST exists for advisories with no upstream fix that are mitigated outside of npm (e.g. an + * application-level code change). Every entry must document why it's safe to allow, so this can't + * silently swallow an unrelated future advisory against the same package. Remove an entry as soon as + * a real fix ships upstream. */ import { existsSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -11,6 +14,17 @@ import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const ALLOWLIST = new Map([ + [ + 'GHSA-jmr9-qjv8-65gv', + 'extract-zip unvalidated symlink path traversal — no patched release exists (2.0.1 is latest ' + + 'and still vulnerable). Mitigated via rejectSymlinks() in src/lib/helpers/zip.helper.ts, called ' + + 'after every extractZip() call and before the extracted tree is read from.', + ], +]); + +const FAILING_SEVERITIES = new Set(['high', 'critical']); + /** @type {Array<{ label: string; cwd: string }>} */ const targets = [{ label: 'root', cwd: root }]; @@ -37,44 +51,110 @@ if (existsSync(testsDir)) { * Windows: `execFileSync("npm", …)` is unreliable (npm.cmd / EINVAL); use cmd.exe. * Unix: invoke `npm` directly (no shell) to avoid DEP0190. */ -function runNpmAudit(cwd) { +function runNpmAuditJson(cwd) { if (process.platform === 'win32') { - return spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm audit --audit-level=high'], { - cwd, - stdio: 'inherit', - }); + return spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm audit --json'], { cwd, encoding: 'utf-8' }); } - return spawnSync('npm', ['audit', '--audit-level=high'], { cwd, stdio: 'inherit' }); + return spawnSync('npm', ['audit', '--json'], { cwd, encoding: 'utf-8' }); } -const results = []; +/** Extract a GHSA id (as GitHub formats it, e.g. "GHSA-jmr9-qjv8-65gv") from an advisory URL. */ +function ghsaIdFromUrl(url) { + const match = /GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i.exec(url ?? ''); + return match ? match[0] : null; +} + +/** Resolve the set of root GHSA ids a vulnerability entry ultimately stems from. */ +function resolveGhsaIds(vulnerabilities, name, seen) { + if (seen.has(name)) { + return []; + } + + seen.add(name); + + const entry = vulnerabilities[name]; + + if (!entry) { + return [`UNKNOWN:${name}`]; + } + + const ids = []; -for (const { label, cwd } of targets) { + for (const via of entry.via) { + if (typeof via === 'string') { + ids.push(...resolveGhsaIds(vulnerabilities, via, seen)); + } else { + ids.push(ghsaIdFromUrl(via.url) ?? `UNKNOWN:${via.title ?? name}`); + } + } + + return ids; +} + +function auditTarget(label, cwd) { const bar = '='.repeat(60); console.log(`\n${bar}\n npm audit — ${label}\n${bar}\n`); - const spawned = runNpmAudit(cwd); - const code = spawned.status ?? (spawned.error ? 1 : 0); + const spawned = runNpmAuditJson(cwd); + + if (spawned.error || !spawned.stdout) { + console.error(spawned.error ?? spawned.stderr ?? 'npm audit produced no output'); + return { label, ok: false }; + } + + let report; - results.push({ label, code }); + try { + report = JSON.parse(spawned.stdout); + } catch (err) { + console.error('Failed to parse `npm audit --json` output:', err.message); + console.error(spawned.stdout); + return { label, ok: false }; + } + + const vulnerabilities = report.vulnerabilities ?? {}; + let unresolvedCount = 0; + + for (const [name, entry] of Object.entries(vulnerabilities)) { + if (!FAILING_SEVERITIES.has(entry.severity)) { + continue; + } + + const ghsaIds = [...new Set(resolveGhsaIds(vulnerabilities, name, new Set()))]; + const unallowlisted = ghsaIds.filter((id) => !ALLOWLIST.has(id)); + + if (unallowlisted.length === 0) { + const reasons = ghsaIds.map((id) => `${id} — ${ALLOWLIST.get(id)}`).join('; '); + + console.log(` ⚠ ${name} (${entry.severity}) — ALLOWLISTED: ${reasons}`); + continue; + } + + unresolvedCount += 1; + console.log(` ✗ ${name} (${entry.severity}) — ${unallowlisted.join(', ')}`); + } + + if (unresolvedCount === 0) { + console.log(' ✓ no unresolved high/critical vulnerabilities'); + } + + return { label, ok: unresolvedCount === 0 }; } +const results = targets.map(({ label, cwd }) => auditTarget(label, cwd)); + console.log(`\n${'='.repeat(60)}\n Audit summary\n${'='.repeat(60)}`); let failed = false; -for (const { label, code } of results) { - const ok = code === 0; - +for (const { label, ok } of results) { if (!ok) { failed = true; } - const status = ok ? 'ok' : `failed (exit ${code})`; - - console.log(` ${ok ? '✓' : '✗'} ${label}: ${status}`); + console.log(` ${ok ? '✓' : '✗'} ${label}: ${ok ? 'ok' : 'failed'}`); } console.log(''); From 21fa412ded827a9266aa4d8e8d5d524e8c36c748 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 13:18:49 +0300 Subject: [PATCH 6/6] fix(variables-editor): guard missing browser APIs, update stale test selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI never ran the unit test suite because it always failed earlier at the `build` job's npm audit step — so these bugs from the PP-4021 values-editor redesign went unnoticed until the audit-all fix let `test` actually run: - scrollToValueRow() called detail.scrollTo() unconditionally; jsdom (and potentially older WebViews) don't implement Element.prototype.scrollTo. Guard it like the existing null checks, degrading to no auto-scroll. - showJsonDiffModal() called requestAnimationFrame() unconditionally before wiring the modal's Save/Cancel button handlers; jsdom doesn't implement it either, so the whole confirm-save flow threw before those listeners were ever attached. Guard it the same way. - Two tests still queried '#content tbody input', a selector from the old table-based values tab; the redesign moved to '.ve-values-detail'. Updated both to the current markup. - save() now opens a confirmation diff modal instead of saving immediately; updated the pending-save test to click '.ve-modal-ok' before asserting the save request fired. --- src/lib/variables-editor.ts | 6 ++++-- tests/unit/lib/variables-editor.spec.ts | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/variables-editor.ts b/src/lib/variables-editor.ts index 609e874..1ef018c 100644 --- a/src/lib/variables-editor.ts +++ b/src/lib/variables-editor.ts @@ -1001,7 +1001,9 @@ function showJsonDiffModal(beforeText, afterText, onConfirm) { } }); // Wait a frame so the modal layout is ready before scrolling. - requestAnimationFrame(function () { updateChangeNav(); }); + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function () { updateChangeNav(); }); + } } function close(confirmed) { @@ -2916,7 +2918,7 @@ function scrollToValueRow(i, smooth) { const detail = document.querySelector('.ve-values-detail'); const target = document.getElementById('value-item-' + i); - if (!detail || !target) { return; } + if (!detail || !target || typeof detail.scrollTo !== 'function') { return; } const top = target.getBoundingClientRect().top - detail.getBoundingClientRect().top + detail.scrollTop; diff --git a/tests/unit/lib/variables-editor.spec.ts b/tests/unit/lib/variables-editor.spec.ts index f070cae..82d2637 100644 --- a/tests/unit/lib/variables-editor.spec.ts +++ b/tests/unit/lib/variables-editor.spec.ts @@ -447,7 +447,7 @@ describe('registerVariablesEditorRoutes', () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - const valueInput = dom.window.document.querySelector('#content tbody input'); + const valueInput = dom.window.document.querySelector('#content .ve-values-detail input'); expect(valueInput?.value).toBe('newer'); @@ -562,11 +562,13 @@ describe('registerVariablesEditorRoutes', () => { save: () => void; updateValueField: (index: number, value: string) => void; }; - const valueInputBeforeSave = dom.window.document.querySelector('#content tbody input')!; + const valueInputBeforeSave = dom.window.document.querySelector('#content .ve-values-detail input')!; valueInputBeforeSave.value = 'submitted'; editorWindow.updateValueField(0, 'submitted'); editorWindow.save(); + // save() now opens a confirmation diff modal instead of saving immediately — confirm it. + dom.window.document.querySelector('.ve-modal-ok')!.click(); expect(fetch).toHaveBeenCalledTimes(2); valueInputBeforeSave.value = 'newer unsaved edit'; @@ -582,7 +584,7 @@ describe('registerVariablesEditorRoutes', () => { await new Promise((resolve) => setTimeout(resolve, 0)); } - const valueInput = dom.window.document.querySelector('#content tbody input'); + const valueInput = dom.window.document.querySelector('#content .ve-values-detail input'); expect(fetch).toHaveBeenCalledTimes(2); expect(valueInput?.value).toBe('newer unsaved edit');