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
9 changes: 9 additions & 0 deletions .github/workflows/release-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,13 @@ jobs:
- name: Release Beta
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# For a workflow_run event, GITHUB_REF is the ref of the workflow FILE (the repo's
# default branch, main) rather than the branch that triggered it (develop) β€” even
# though the checkout step above explicitly checks out develop's commit. semantic-
# release's env-ci detection reads only GITHUB_REF to pick the release branch, so
# left alone it decides it's releasing from main and tries to push there, which the
# branch protection rules reject. This job only ever runs for develop (see the `if:`
# above), so it's always correct to force it here.
GITHUB_REF: refs/heads/develop
GITHUB_REF_NAME: develop
run: npm run release
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 15 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
]
}
},
"license": "ISC",
"license": "MIT",
"engines": {
"node": ">=24"
},
Expand Down Expand Up @@ -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",
Expand Down
120 changes: 100 additions & 20 deletions scripts/audit-all.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 }];

Expand All @@ -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('');
Expand Down
4 changes: 3 additions & 1 deletion src/lib/changelog-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ `<!DOCTYPE html>
<html lang="en">
Expand Down Expand Up @@ -398,7 +399,8 @@ export class ChangelogGenerator {
private async unzipFile(assetPath: string, destinationPath: string): Promise<void> {
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 {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/dist.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions src/lib/helpers/zip.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,26 @@ export async function zipDirectoryToBuffer(dir: string): Promise<Buffer> {

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<void> {
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);
}
}
}
Loading