Skip to content
Open
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
8 changes: 8 additions & 0 deletions .github/workflows/dependency-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ on:
paths:
- .github/workflows/dependency-audit.yml
- scripts/audit-shipped-dependencies.mjs
- scripts/check-allow-scripts.mjs
- scripts/third-party-closure.mjs
- package.json
- package-lock.json
Expand All @@ -36,6 +37,7 @@ on:
paths:
- .github/workflows/dependency-audit.yml
- scripts/audit-shipped-dependencies.mjs
- scripts/check-allow-scripts.mjs
- scripts/third-party-closure.mjs
- package.json
- package-lock.json
Expand Down Expand Up @@ -75,6 +77,12 @@ jobs:
# not lock every pull request out of the repository.
run: node scripts/audit-shipped-dependencies.mjs --allow-unavailable

- name: Check the allowScripts map against the lockfile
# npm's install-script approval gate keys on exact name@version; a
# routine bump or a new optional script-carrying dependency silently
# stales the map. Failing here keeps the drift from landing.
run: node scripts/check-allow-scripts.mjs

- name: Verify registry signatures
# The full tree, not `--omit=dev`. Two reasons it has to be both:
# the renderer roots live in devDependencies while their code ships
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,15 @@
"yaml": "2.9.1"
},
"allowScripts": {
"@astryxdesign/cli@0.6.3": false,
"@astryxdesign/core@0.6.3": false,
"@jackwener/opencli@1.8.8": false,
"electron-winstaller@5.4.0": true,
"esbuild@0.28.2": true,
"@jackwener/opencli@1.8.8": true,
"node-pty@1.2.0-beta.15": true
"fsevents@2.3.3": true,
"node-pty@1.2.0-beta.15": true,
"protobufjs@7.6.5": false,
"tree-sitter-javascript@0.25.0": true
},
"overrides": {
"@ai-sdk/code-mode": {
Expand Down
137 changes: 137 additions & 0 deletions scripts/check-allow-scripts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/*
* 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.
*/

// Drift guard for the `allowScripts` map in the root package.json.
//
// npm >= 11.6 asks for an explicit approval before running a dependency's
// lifecycle scripts, keyed by exact `name@version`. The map only helps while
// it matches package-lock.json: this PR chain started because two keyed
// versions trailed the lockfile after routine bumps, and the optional
// Darwin-only fsevents was never listed at all. This check fails on any of:
//
// 1. a lockfile entry with `hasInstallScript` that the map does not key
// (optionality is not an exemption — fsevents is exactly the case);
// 2. a map key whose `name@version` no longer matches a lockfile entry
// that carries install scripts (the stale-version failure mode);
// 3. a map value that is not a boolean, which silently means neither
// "approve" nor "deny" to the gate.
//
// It does not judge the true/false decisions themselves — only that every
// script-carrying package has one, and that nothing in the map is dead.

import { readFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';

const repoRoot = path.resolve(
path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')),
'..',
);
const args = process.argv.slice(2);

function readOption(flag) {
const index = args.indexOf(flag);
return index === -1 ? null : args[index + 1];
}

const packageJsonPath = readOption('--package') ?? path.join(repoRoot, 'package.json');
const lockfilePath = readOption('--lock') ?? path.join(repoRoot, 'package-lock.json');

const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
const lock = JSON.parse(readFileSync(lockfilePath, 'utf8'));

const allowScripts = packageJson.allowScripts ?? null;
if (allowScripts === null || typeof allowScripts !== 'object' || Array.isArray(allowScripts)) {
console.error('package.json has no allowScripts object; nothing to guard.');
process.exit(1);
}

// A lockfile key is "node_modules/..." with the dependency name as the last
// `node_modules/` segment (scoped names span two path parts).
function nameFromLockKey(lockKey) {
const marker = lockKey.lastIndexOf('node_modules/');
return marker === -1 ? lockKey : lockKey.slice(marker + 'node_modules/'.length);
}

// Split an exact `name@version` key on its final '@' so scoped names survive.
function splitAllowKey(key) {
const at = key.lastIndexOf('@');
if (at <= 0) return null;
return { name: key.slice(0, at), version: key.slice(at + 1) };
}

const problems = [];
const lockEntries = new Map(); // name@version -> lockfile key
for (const [lockKey, entry] of Object.entries(lock.packages ?? {})) {
if (lockKey === '') continue; // the root project is not a dependency of itself
if (!entry.hasInstallScript) continue;
const exact = `${nameFromLockKey(lockKey)}@${entry.version}`;
if (lockEntries.has(exact)) {
problems.push(
`lockfile carries ${exact} at two paths: ${lockEntries.get(exact)} and ${lockKey}`,
);
continue;
}
lockEntries.set(exact, lockKey);
}

for (const [exact] of lockEntries) {
if (!(exact in allowScripts)) {
const entry = lock.packages[lockEntries.get(exact)] ?? {};
const where = entry.optional
? ` (optional${entry.os?.length ? `, os: ${entry.os.join('/')}` : ''})`
: '';
problems.push(`missing: ${exact}${where} has install scripts but no allowScripts decision`);
}
}

const keyedExact = new Set();
for (const [key, value] of Object.entries(allowScripts)) {
if (typeof value !== 'boolean') {
problems.push(
`invalid: "${key}" maps to ${JSON.stringify(value)}; the gate expects true or false`,
);
continue;
}
const split = splitAllowKey(key);
if (!split || !split.version) {
problems.push(`invalid: "${key}" is not an exact name@version key`);
continue;
}
keyedExact.add(key);
if (!lockEntries.has(key)) {
problems.push(
`stale: "${key}" matches no lockfile entry with install scripts (version bumped, or scripts gone)`,
);
}
}

if (problems.length > 0) {
console.error(`allowScripts is out of sync with ${path.basename(lockfilePath)}:\n`);
for (const problem of problems) console.error(` - ${problem}`);
console.error(
`\n${keyedExact.size} keyed / ${lockEntries.size} script-carrying packages. Fix package.json's allowScripts map.`,
);
process.exit(1);
}

console.log(
`allowScripts covers all ${lockEntries.size} install-script packages with exact name@version keys.`,
);
123 changes: 123 additions & 0 deletions scripts/check-allow-scripts.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* 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 { execFile } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { promisify } from 'node:util';

const run = promisify(execFile);
const script = new URL('./check-allow-scripts.mjs', import.meta.url).pathname.replace(
/^\/([A-Za-z]:)/,
'$1',
);

function writeFixture({ allowScripts, lockPackages }) {
const dir = mkdtempSync(path.join(tmpdir(), 'allow-scripts-'));
const packageJsonPath = path.join(dir, 'package.json');
const lockfilePath = path.join(dir, 'package-lock.json');
writeFileSync(packageJsonPath, JSON.stringify({ allowScripts }));
writeFileSync(lockfilePath, JSON.stringify({ packages: lockPackages }));
return { packageJsonPath, lockfilePath };
}

async function runCheck(fixture) {
try {
const { stdout } = await run(process.execPath, [
script,
'--package',
fixture.packageJsonPath,
'--lock',
fixture.lockfilePath,
]);
return { code: 0, stdout };
} catch (error) {
return { code: error.code, stdout: '', stderr: String(error.stderr) };
}
}

const BASE_LOCK = {
'': { name: 'maka', version: '0.2.0', hasInstallScript: true },
'node_modules/esbuild': { version: '0.28.2', hasInstallScript: true },
'node_modules/fsevents': {
version: '2.3.3',
hasInstallScript: true,
optional: true,
os: ['darwin'],
},
'node_modules/node-pty': { version: '1.2.0-beta.15', hasInstallScript: true },
};

test('a synced map passes', async () => {
const fixture = writeFixture({
allowScripts: {
'esbuild@0.28.2': true,
'fsevents@2.3.3': true,
'node-pty@1.2.0-beta.15': true,
},
lockPackages: BASE_LOCK,
});
const result = await runCheck(fixture);
if (result.code !== 0) throw new Error(`expected exit 0, got ${result.code}: ${result.stderr}`);
});

test('an unkeyed optional darwin-only package fails (the fsevents case)', async () => {
const fixture = writeFixture({
allowScripts: { 'esbuild@0.28.2': true, 'node-pty@1.2.0-beta.15': true },
lockPackages: BASE_LOCK,
});
const result = await runCheck(fixture);
if (result.code !== 1) throw new Error(`expected exit 1, got ${result.code}`);
if (!result.stderr.includes('fsevents@2.3.3 (optional, os: darwin)'))
throw new Error(`missing fsevents note: ${result.stderr}`);
});

test('a map key trailing a lockfile bump fails (the stale-version case)', async () => {
const fixture = writeFixture({
allowScripts: {
'esbuild@0.27.7': true,
'fsevents@2.3.3': true,
'node-pty@1.2.0-beta.15': true,
},
lockPackages: BASE_LOCK,
});
const result = await runCheck(fixture);
if (result.code !== 1) throw new Error(`expected exit 1, got ${result.code}`);
if (!result.stderr.includes('stale: "esbuild@0.27.7"'))
throw new Error(`missing stale entry: ${result.stderr}`);
if (!result.stderr.includes('missing: esbuild@0.28.2'))
throw new Error(`missing missing-entry: ${result.stderr}`);
});

test('a non-boolean value fails', async () => {
const fixture = writeFixture({
allowScripts: {
'esbuild@0.28.2': 'yes',
'fsevents@2.3.3': true,
'node-pty@1.2.0-beta.15': true,
},
lockPackages: BASE_LOCK,
});
const result = await runCheck(fixture);
if (result.code !== 1) throw new Error(`expected exit 1, got ${result.code}`);
if (!result.stderr.includes('"esbuild@0.28.2" maps to "yes"'))
throw new Error(`missing invalid value: ${result.stderr}`);
});
Loading