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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ typecheck: deps ; for dir in $(TS_WORKSPACES); do $(call exec_or_bunx,tsc,--noEm
audit: deps
pnpm -r install
pnpm -r --if-present run audit
pnpm run audit
pnpm run audit:validate

lockfile:
pnpm install --lockfile-only
Expand Down
212 changes: 135 additions & 77 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions docs/repository-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,9 +519,10 @@ docker-down:
```

Use `make audit` to validate the audit exception allowlist against its schema
and expiry dates. The target installs its validator with `pnpm dlx`; enable
Corepack (`corepack enable` and `corepack prepare pnpm@10.15.1 --activate`) so
`pnpm` is available in local and CI environments.
and expiry dates. The shared helper tries `pnpm audit --json` first and falls
back to npm's bulk advisory endpoint when the registry retires pnpm's legacy
audit endpoints. Enable Corepack (`corepack enable` and `corepack prepare
pnpm@10.15.1 --activate`) so `pnpm` is available in local and CI environments.

### pnpm setup sequence

Expand Down
252 changes: 252 additions & 0 deletions frontend-pwa/scripts/audit-utils.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/** @file Tests the shared audit helper, including the bulk advisory fallback. */

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const execFileSyncMock = vi.fn();
const spawnSyncMock = vi.fn();
const githubAdvisoryIdKey = 'github_advisory_id';
const packageNameKey = 'package_name';

vi.mock('node:child_process', () => ({
execFileSync: execFileSyncMock,
spawnSync: spawnSyncMock,
}));

const originalFetch = globalThis.fetch;

/**
* Create a pnpm-like child-process result for audit command tests.
* @param {{ status?: number, stdout?: string, error?: Error | undefined }} [options={}] Result overrides.
* @param {number} [options.status=0] Process exit status.
* @param {string} [options.stdout=''] Command stdout payload.
* @param {Error | undefined} [options.error=undefined] Spawn error to surface.
* @returns {{ error: Error | undefined, status: number, stdout: string }} Mocked pnpm result object.
*/
function createPnpmResult({ status = 0, stdout = '', error = undefined } = {}) {
return { error, status, stdout };
}

/**
* Configure the retired-endpoint pnpm audit flow for fallback tests.
* @param {unknown[]} [lsPayload=[{ name: 'frontend-pwa', dependencies: {} }]] Parsed `pnpm ls` payload for the second mock result.
* @returns {void}
*/
function setupRetiredPnpmAudit(lsPayload = [{ name: 'frontend-pwa', dependencies: {} }]) {
spawnSyncMock
.mockReturnValueOnce(
createPnpmResult({
status: 1,
stdout: JSON.stringify({
error: {
code: 'ERR_PNPM_AUDIT_BAD_RESPONSE',
message:
'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}',
},
}),
}),
)
.mockReturnValueOnce(
createPnpmResult({
stdout: JSON.stringify(lsPayload),
}),
);
}

/**
* Dynamically import the shared audit utility module under test.
* @returns {Promise<typeof import('../../security/audit-utils.js')>} Imported audit utility module.
*/
async function loadAuditUtils() {
const module = await import('../../security/audit-utils.js');
return module;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('runAuditJson', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
globalThis.fetch = vi.fn();
vi.unstubAllEnvs();
vi.stubEnv('npm_config_registry', '');
vi.stubEnv('NPM_CONFIG_REGISTRY', '');
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it('returns pnpm audit output when the native command succeeds', async () => {
spawnSyncMock.mockReturnValueOnce(
createPnpmResult({
status: 1,
stdout: JSON.stringify({
advisories: {
validator: {
[githubAdvisoryIdKey]: 'GHSA-vghf-hv5q-vc2g',
title: 'Validator SSRF',
},
},
}),
}),
);
const { runAuditJson } = await loadAuditUtils();

const result = await runAuditJson();

expect(result).toEqual({
json: {
advisories: {
validator: {
[githubAdvisoryIdKey]: 'GHSA-vghf-hv5q-vc2g',
title: 'Validator SSRF',
},
},
},
status: 1,
});
expect(fetch).not.toHaveBeenCalled();
expect(execFileSyncMock).not.toHaveBeenCalled();
});

it('falls back to the bulk advisory endpoint when pnpm audit hits the retired endpoint', async () => {
setupRetiredPnpmAudit([
{
name: 'frontend-pwa',
path: '/tmp/frontend-pwa',
dependencies: {
'@app/types': {
version: 'link:../packages/types',
},
validator: {
version: '13.15.23',
dependencies: {
nanoid: {
version: '3.3.11',
},
},
},
},
},
]);
execFileSyncMock.mockReturnValueOnce('https://registry.npmjs.org/\n');
fetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
text: async () =>
JSON.stringify({
validator: [
{
id: 100000,
url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g',
title: 'Validator SSRF',
severity: 'high',
},
],
nanoid: [],
}),
});
const { runAuditJson } = await loadAuditUtils();

const result = await runAuditJson();

expect(spawnSyncMock).toHaveBeenNthCalledWith(
1,
'pnpm',
['audit', '--json'],
expect.objectContaining({ encoding: 'utf8' }),
);
expect(spawnSyncMock).toHaveBeenNthCalledWith(
2,
'pnpm',
['ls', '--json', '--depth', 'Infinity'],
expect.objectContaining({ encoding: 'utf8' }),
);
expect(execFileSyncMock).toHaveBeenCalledWith(
'pnpm',
['config', 'get', 'registry'],
expect.objectContaining({ encoding: 'utf8' }),
);
expect(String(fetch.mock.calls[0][0])).toBe(
'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk',
);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
nanoid: ['3.3.11'],
validator: ['13.15.23'],
});
expect(result).toEqual({
json: {
advisories: {
'GHSA-vghf-hv5q-vc2g': {
[githubAdvisoryIdKey]: 'GHSA-vghf-hv5q-vc2g',
id: 100000,
[packageNameKey]: 'validator',
severity: 'high',
title: 'Validator SSRF',
url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g',
},
},
},
status: 1,
});
});

it('throws a clear error when the bulk advisory endpoint fails', async () => {
setupRetiredPnpmAudit();
fetch.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
text: async () => '{"error":"upstream unavailable"}',
});
const { runAuditJson } = await loadAuditUtils();

await expect(runAuditJson()).rejects.toThrow(
'Bulk advisory audit failed (503 Service Unavailable)',
);
});

it('normalizes advisory IDs from the bulk payload URL to lowercase groups', async () => {
setupRetiredPnpmAudit();
fetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
text: async () =>
JSON.stringify({
validator: [
{
id: 100000,
url: 'https://github.com/advisories/GHSA-Vghf-HV5Q-vC2G',
title: 'Validator SSRF',
},
],
}),
});
const { runAuditJson } = await loadAuditUtils();

const result = await runAuditJson();

expect(result.json.advisories).toEqual({
'GHSA-vghf-hv5q-vc2g': expect.objectContaining({
[githubAdvisoryIdKey]: 'GHSA-vghf-hv5q-vc2g',
[packageNameKey]: 'validator',
}),
});
});

it('rejects blank bulk advisory responses instead of treating them as empty JSON', async () => {
setupRetiredPnpmAudit();
fetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
text: async () => ' ',
});
const { runAuditJson } = await loadAuditUtils();

await expect(runAuditJson()).rejects.toThrow(
'Failed to parse bulk advisory audit JSON: response body was empty.',
);
});
});
20 changes: 10 additions & 10 deletions frontend-pwa/scripts/run-audit.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @file Ensures `pnpm audit` only fails for advisories covered by the
* frontend workspace ledger and a validator dependency that includes the
* upstream fix for the current advisory.
/** @file Ensures the frontend audit only fails for advisories covered by the
* workspace ledger and a validator dependency that includes the upstream fix
* for the current advisory.
*
* The validator advisory is considered mitigated when the workspace ships a
* version at or above the minimum safe release, falling back to the legacy
Expand Down Expand Up @@ -171,7 +171,7 @@ function isExecutedDirectly(meta) {
}

/**
* Evaluate pnpm audit output and determine the appropriate exit code.
* Evaluate audit output and determine the appropriate exit code.
*
* @param {{ advisories?: Array<Record<string, unknown>>, status?: number }} payload Audit
* result containing advisories and the pnpm exit status.
Expand Down Expand Up @@ -211,22 +211,22 @@ export function evaluateAudit(payload, options = {}) {
}

/**
* Execute `pnpm audit` and exit according to {@link evaluateAudit}.
* Execute the audit helper and exit according to {@link evaluateAudit}.
*
* @returns {number} Exit code produced by {@link evaluateAudit}.
* @returns {Promise<number>} Exit code produced by {@link evaluateAudit}.
* @example
* const exitCode = main();
* const exitCode = await main();
* console.log(exitCode);
*/
export function main() {
const { json, status } = runAuditJson();
export async function main() {
const { json, status } = await runAuditJson();
const advisories = collectAdvisories(json);
return evaluateAudit({ advisories, status });
}

if (isExecutedDirectly(import.meta)) {
try {
const exitCode = main();
const exitCode = await main();
process.exit(exitCode);
} catch (error) {
// biome-ignore lint/suspicious/noConsole: CLI script reports failures via stderr.
Expand Down
Loading
Loading