Skip to content
9 changes: 4 additions & 5 deletions .github/workflows/dependency-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,10 @@ jobs:
# the renderer roots, which npm labels dev even though they ship.
run: npm ci --ignore-scripts

- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=moderate

- name: Audit shipped desktop closure
run: node scripts/audit-shipped-dependencies.mjs
- name: Audit shipped product closure
# A valid moderate+ advisory still fails. Registry availability must
# not lock every pull request out of the repository.
run: node scripts/audit-shipped-dependencies.mjs --allow-unavailable

- name: Verify registry signatures
# The full tree, not `--omit=dev`. Two reasons it has to be both:
Expand Down
9 changes: 2 additions & 7 deletions .github/workflows/desktop-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,7 @@ jobs:
cache: npm

- name: Install dependencies
run: npm ci

- name: Audit the shipped Desktop dependency closure
run: |
npm audit --omit=dev --audit-level=moderate
node scripts/audit-shipped-dependencies.mjs
run: npm ci --no-audit

- name: Update stable Rust for native Desktop artifacts
run: rustup update stable --no-self-update
Expand Down Expand Up @@ -254,7 +249,7 @@ jobs:
cache: npm

- name: Install publisher dependencies
run: npm ci --ignore-scripts
run: npm ci --ignore-scripts --no-audit

- name: Download every verified Desktop build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/npm-publication.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ jobs:
cache: npm

- name: Install publisher dependencies
run: npm ci --ignore-scripts
run: npm ci --ignore-scripts --no-audit

- name: Block shipped advisories without blocking on audit availability
run: node scripts/audit-shipped-dependencies.mjs --allow-unavailable

- name: Select the publication npm toolchain
run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')"
Expand Down
8 changes: 1 addition & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,7 @@ jobs:
- name: Install dependencies with the pinned npm
run: npm ci

- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=moderate

- name: Audit shipped desktop closure
- name: Audit shipped product closure
run: node scripts/audit-shipped-dependencies.mjs

- name: Update stable Rust for Desktop native artifacts
Expand Down Expand Up @@ -349,9 +346,6 @@ jobs:
- name: Install dependencies with the pinned npm
run: npm ci

- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=moderate

- name: Build the CLI production workspace closure
run: |
npm --workspace maka-agent run build:workspace-deps
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check",
"generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs",
"check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check",
"check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs",
"check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs",
"package:macos-arm64": "node scripts/package-macos.mjs arm64",
"package:macos-x64": "node scripts/package-macos.mjs x64",
"verify:macos": "node scripts/verify-macos-dmg.mjs",
Expand Down
160 changes: 100 additions & 60 deletions scripts/audit-shipped-dependencies.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,64 +17,36 @@
* under the License.
*/

// Audits what the desktop artifact ships, not what npm labels production.
// Audits what the product artifacts ship, not what npm labels production.
//
// `npm audit --omit=dev` covers the Node production closure, but the renderer
// roots live in `devDependencies` (so electron-builder keeps their unread
// sources out of `app.asar`) while vite still bundles them into
// `dist-renderer`. A vulnerability in react would therefore ship without the
// production audit ever seeing it. This audits the full npm report and fails
// on anything that lands in the shipped desktop closure — Node production
// plus everything reachable from the declared renderer roots.
// The renderer roots live in `devDependencies` (so electron-builder keeps
// their unread sources out of `app.asar`) while vite still bundles them into
// `dist-renderer`. An `npm audit --omit=dev` would therefore miss shipped
// vulnerabilities such as one in react. This is the product audit authority:
// one full npm report, filtered to anything that lands in the shipped CLI or
// Desktop closure — Node production plus everything reachable from the
// declared renderer roots.
//
// An advisory names a package and npm resolves it to the installed copies it
// actually reaches (`nodes`). Only a copy whose exact version is in the
// shipped closure fails here: the same name installed at a vulnerable version
// on a tooling-only path (electron → @electron/get → undici) is not shipped
// and must not turn this red. When npm elides the paths, the name alone
// fails, since the miss would otherwise be silent.
import { execFileSync } from 'node:child_process';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { npmSpawnOptions } from './npm-spawn.mjs';
import { collectWorkspaceClosure } from './third-party-closure.mjs';

const repoRoot = resolve(import.meta.dirname, '..');
const SEVERITY_RANK = { info: 0, low: 1, moderate: 2, high: 3, critical: 4 };
// Matches the `--audit-level=moderate` the production audit step uses.
const FAIL_RANK = SEVERITY_RANK.moderate;
const AUDIT_TIMEOUT_MS = 60_000;

function npmAuditReport() {
const options = npmSpawnOptions({
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
});
try {
return JSON.parse(execFileSync('npm', ['audit', '--json'], options));
} catch (error) {
// npm audit exits nonzero when it finds anything; the JSON is complete.
if (typeof error.stdout === 'string' && error.stdout.trim().startsWith('{')) {
return JSON.parse(error.stdout);
}
throw error;
}
}

const closure = collectWorkspaceClosure({
workspaceName: '@maka/desktop',
manifestPath: join(repoRoot, 'apps', 'desktop', 'package.json'),
});
const shippedVersions = new Map();
for (const { name, version } of closure) {
if (!shippedVersions.has(name)) shippedVersions.set(name, new Set());
shippedVersions.get(name).add(version);
}

const lockPackages =
JSON.parse(readFileSync(join(repoRoot, 'package-lock.json'), 'utf8')).packages ?? {};

function shippedCopies(vulnerability) {
function shippedCopies(vulnerability, shippedVersions, lockPackages) {
const shipped = shippedVersions.get(vulnerability.name);
if (!shipped) return [];
const nodes = vulnerability.nodes ?? [];
Expand All @@ -84,26 +56,94 @@ function shippedCopies(vulnerability) {
.filter((version) => version !== undefined && shipped.has(version));
}

const report = npmAuditReport();
const flagged = Object.values(report.vulnerabilities ?? {})
.filter(
(vulnerability) =>
(SEVERITY_RANK[vulnerability.severity] ?? SEVERITY_RANK.critical) >= FAIL_RANK,
)
.map((vulnerability) => ({ vulnerability, copies: shippedCopies(vulnerability) }))
.filter(({ copies }) => copies.length > 0)
.sort((left, right) => left.vulnerability.name.localeCompare(right.vulnerability.name));
function unavailableDetail(audit, report) {
const registryDetail = [report?.error?.summary, report?.error?.detail].filter(Boolean).join(': ');
if (registryDetail) return registryDetail;
if (audit.error instanceof Error) return audit.error.message;
if (typeof audit.stderr === 'string' && audit.stderr.trim()) return audit.stderr.trim();
return `npm audit exited with status ${audit.status ?? 'unknown'}`;
}

export function evaluateShippedAudit(audit, shippedVersions, lockPackages) {
let report;
try {
report = JSON.parse(audit.stdout || '{}');
} catch {
return { outcome: 'unavailable', detail: unavailableDetail(audit) };
}
if (!report.vulnerabilities || typeof report.vulnerabilities !== 'object') {
return { outcome: 'unavailable', detail: unavailableDetail(audit, report) };
}
const flagged = Object.values(report.vulnerabilities)
.filter(
(vulnerability) =>
(SEVERITY_RANK[vulnerability.severity] ?? SEVERITY_RANK.critical) >= FAIL_RANK,
)
.map((vulnerability) => ({
vulnerability,
copies: shippedCopies(vulnerability, shippedVersions, lockPackages),
}))
.filter(({ copies }) => copies.length > 0)
.sort((left, right) => left.vulnerability.name.localeCompare(right.vulnerability.name));
return { outcome: flagged.length > 0 ? 'blocked' : 'clean', flagged };
}

function collectShippedVersions() {
const closures = [
collectWorkspaceClosure({ workspaceName: 'maka-agent' }),
collectWorkspaceClosure({
workspaceName: '@maka/desktop',
manifestPath: join(repoRoot, 'apps', 'desktop', 'package.json'),
}),
];
const shippedVersions = new Map();
for (const closure of closures) {
for (const { name, version } of closure) {
if (!shippedVersions.has(name)) shippedVersions.set(name, new Set());
shippedVersions.get(name).add(version);
}
}
return shippedVersions;
}

function main() {
const allowUnavailable = process.argv.includes('--allow-unavailable');
const audit = spawnSync(
'npm',
['audit', '--json'],
npmSpawnOptions({
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
timeout: AUDIT_TIMEOUT_MS,
}),
);
const shippedVersions = collectShippedVersions();
const lockPackages =
JSON.parse(readFileSync(join(repoRoot, 'package-lock.json'), 'utf8')).packages ?? {};
const result = evaluateShippedAudit(audit, shippedVersions, lockPackages);
if (result.outcome === 'unavailable') {
const message = `[audit-shipped] registry audit unavailable: ${result.detail}`;
if (allowUnavailable) {
console.warn(`${message}; continuing without an advisory result`);
return;
}
throw new Error(message);
}

console.log(
`[audit-shipped] desktop shipped closure: ${shippedVersions.size} packages; ` +
`advisories reaching it at moderate or above: ${flagged.length}`,
);
for (const { vulnerability, copies } of flagged) {
const causes = (vulnerability.via ?? [])
.map((via) => (typeof via === 'string' ? `via ${via}` : `${via.title} (${via.url})`))
.join('; ');
console.error(
`[audit-shipped] ${vulnerability.name}@${copies.join(', ')}: ${vulnerability.severity} — ${causes}`,
console.log(
`[audit-shipped] product shipped closure: ${shippedVersions.size} packages; ` +
`advisories reaching it at moderate or above: ${result.flagged.length}`,
);
for (const { vulnerability, copies } of result.flagged) {
const causes = (vulnerability.via ?? [])
.map((via) => (typeof via === 'string' ? `via ${via}` : `${via.title} (${via.url})`))
.join('; ');
console.error(
`[audit-shipped] ${vulnerability.name}@${copies.join(', ')}: ${vulnerability.severity} — ${causes}`,
);
}
if (result.outcome === 'blocked') process.exitCode = 1;
}
if (flagged.length > 0) process.exit(1);

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
73 changes: 73 additions & 0 deletions scripts/audit-shipped-dependencies.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { evaluateShippedAudit } from './audit-shipped-dependencies.mjs';

test('blocks a valid report that reaches a shipped dependency', () => {
const result = evaluateShippedAudit(
{
status: 1,
stdout: JSON.stringify({
vulnerabilities: {
react: {
name: 'react',
severity: 'high',
nodes: ['node_modules/react'],
via: [],
},
tooling: {
name: 'tooling',
severity: 'critical',
nodes: ['node_modules/tooling'],
via: [],
},
},
}),
},
new Map([['react', new Set(['19.2.4'])]]),
{
'node_modules/react': { version: '19.2.4' },
'node_modules/tooling': { version: '1.0.0' },
},
);

assert.equal(result.outcome, 'blocked');
assert.deepEqual(
result.flagged.map(({ vulnerability }) => vulnerability.name),
['react'],
);
});

test('classifies an audit service error as unavailable', () => {
const result = evaluateShippedAudit(
{
status: 1,
stdout: JSON.stringify({ error: { summary: '', detail: '' } }),
stderr: 'npm warn audit network timeout',
error: new Error('npm audit timed out'),
},
new Map(),
{},
);

assert.equal(result.outcome, 'unavailable');
assert.equal(result.detail, 'npm audit timed out');
});
2 changes: 2 additions & 0 deletions scripts/ci-test-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const RELEASE_CONTRACT_FILES = new Set([
'.github/workflows/release-linux-check.yml',
'.github/workflows/release-windows-check.yml',
'.github/workflows/windows-recovery.yml',
'scripts/audit-shipped-dependencies.mjs',
'scripts/audit-shipped-dependencies.test.mjs',
'scripts/package-macos.mjs',
'scripts/package-macos-autoupdate-next.mjs',
'scripts/package-macos-arm64-cli.mjs',
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-test-plan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ test('release authority changes select their dedicated contract gate', () => {
'.github/workflows/release-cli-finalize.yml',
'.github/workflows/release-cli-stage.yml',
'.github/workflows/release.yml',
'scripts/audit-shipped-dependencies.mjs',
'scripts/package-macos.mjs',
'scripts/package-macos-autoupdate-next.mjs',
'scripts/package-macos-arm64-cli.mjs',
Expand Down
6 changes: 4 additions & 2 deletions scripts/release-cli-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function main() {
);
}
buildRuntimeWorkspaces({ clean: true });
checkProductionAudit();
if (!nightlyVersion && !allowDirty) checkProductionAudit();
runNpm(['run', 'check:cli-third-party-notices']);
runNpm(['run', 'check:runtime-host-peer-dependencies']);
runNpm(['run', 'check:runtime-host-peer-notices']);
Expand Down Expand Up @@ -220,9 +220,11 @@ function buildFromCleanDependencyTree() {
const preparedPeerPrebuilds = copyPeerPrebuildInputToCleanTree(cleanRoot);
console.log('[release-cli] installing the committed dependency tree with npm ci');
const cleanEnvironment = releaseNpmEnvironment(process.env, join(cleanRoot, '.npmrc'));
const installArguments = ['ci'];
if (nightlyVersion) installArguments.push('--no-audit');
execFileSync(
'npm',
['ci'],
installArguments,
npmSpawnOptions({ cwd: cleanRoot, env: cleanEnvironment, stdio: 'inherit' }),
);
execFileSync(process.execPath, [join(cleanRoot, 'scripts/release-cli-package.mjs')], {
Expand Down
Loading