can-i-ignore-scripts
Version:
A cli to search your dependencies and check what could break when you stop allowing install scripts to run.
218 lines (193 loc) • 6.91 kB
JavaScript
import fs from "node:fs";
import util from "node:util";
import { createInterface } from "node:readline/promises";
import { readdir, stat, realpath } from "node:fs/promises";
import { join } from "path";
/**
* Recursively finds files, following symlinks and returning virtual paths.
* @param {string} dirPath - The current VIRTUAL path being searched.
* @param {string} fileNameToFind - The name of the file to find.
* @param {Set<string>} visitedPaths - A set of visited CANONICAL paths to prevent cycles.
* @returns {Promise<string[]>} A promise that resolves to an array of virtual file paths.
*/
async function findFilesRecursively(
dirPath,
fileNameToFind,
visitedPaths = new Set()
) {
let results = [];
try {
// 1. Get the CANONICAL path for cycle detection ONLY.
const p = await realpath(dirPath);
// 2. Check if the canonical path has been visited to prevent infinite loops.
if (visitedPaths.has(p)) {
return []; // Stop this branch to prevent a cycle.
}
visitedPaths.add(p);
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
// 3. Construct the next VIRTUAL path for traversal and results.
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
if (entry.name[0] === ".") {
// Skip hidden directories like .git, .svn, .hg, .cache, etc.
continue;
}
// Recurse using the virtual path.
results.push(
...(await findFilesRecursively(
fullPath,
fileNameToFind,
visitedPaths
))
);
} else if (entry.isSymbolicLink()) {
// ⛓️ For a symlink, check what it points to.
const linkStats = await stat(fullPath); // stat() follows the link.
if (linkStats.isDirectory()) {
// If it points to a directory, recurse using the virtual path.
results.push(
...(await findFilesRecursively(
fullPath,
fileNameToFind,
visitedPaths
))
);
}
} else if (entry.isFile() && entry.name === fileNameToFind) {
// 4. If we find a match, add the complete virtual path to our results.
results.push(fullPath);
}
}
} catch (err) {
// Silently ignore errors like broken symlinks or permission denied.
if (err.code !== "ENOENT" && err.code !== "EACCES") {
console.error(`Error processing path ${dirPath}:`, err);
}
}
return results;
}
const data = JSON.parse(
fs.readFileSync(new URL("./data.json", import.meta.url))
);
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const scriptNames = ["preinstall", "postinstall", "install"];
const width = 70;
const lines = {
thick: "▀".repeat(width),
thin: "_".repeat(width),
};
console.log(`
█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█
▄▄· ▄▄▄· ▐ ▄ ▄ ▪ ▄▄ ▐ ▄ ▄▄▄ ▄▄▄ ▄▄▄▄·
▐█ ▌ ▐█ ▀█ ·█▌▐█ ██ ██ ▐█ ▀ █▌▐█ ▐▄ █· █ ▀· .▀ .█▌
██ ▄▄▄█▀▀█ ▐█▐▐▌ ▐█· ▐█· ▄█ ▀█▄ ▐█▐▐▌ ▄█▀▄ ▐▀▀▄ ▐█▀ ▄█▀▀▀·
▐███▌▐█ ▪▐▌██▐█▌ ▐█▌ ▐█▌ ▐█▄ ▐█ ██▐█▌▐█▌.▐▌▐▄ █▌▐█▄▄▄▌ ▀
·▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀ ▀▀▀ ·▀▀▀▀ ▀▀ █▪ ▀█▄▀▪.▀ ▀ ▀▀▀ ▀
`);
const locations = fs.globSync(["node_modules", "**/node_modules"]);
console.log(`Looking in the following locations:
${locations.join('\n ')}
`);
const files = (
await Promise.all(
locations.map((loc) => findFilesRecursively(loc, "package.json"))
)
).flat();
const found = files
.map((file) => {
let json;
try {
json = JSON.parse(fs.readFileSync(file));
} catch (e) {
console.error(`Oops, ${file} doesn't seem to be a valid JSON`);
return null;
}
const { name, scripts = {}, version } = json;
return {
name,
version,
scripts: Object.fromEntries(
Object.entries(scripts).filter(([key]) => scriptNames.includes(key))
),
path: file.substr(0, file.length - 12),
};
})
.filter((pkg) => pkg && Object.keys(pkg.scripts).length > 0)
.reduce((acc, pkg) => {
if (!acc[pkg.name]) {
acc[pkg.name] = [];
}
pkg.info = `https://socket.dev/npm/package/${pkg.name}/files/${pkg.version}/package.json`;
acc[pkg.name].push(pkg);
return acc;
}, {});
if (Object.keys(found).length === 0) {
console.log("No scripts found");
process.exit(0);
}
const keep = [];
const check = [];
console.log(`${lines.thick}
Found following packages with scripts:
${lines.thin} ${Object.keys(found)
.map((name) => {
const lookup = `look it up: https://socket.dev/npm/package/${name}/`;
if (data.ignore[name]) {
return `
[ ignore ] '${name}' has scripts but they can be ignored
${lookup}
reason: ${data.ignore[name]}`;
} else if (data.keep[name]) {
keep.push(name);
return `
[ keep ] '${name}' may need its scripts to run
${lookup}
reason: ${data.keep[name]} `;
} else {
let tip = "";
if (
found[name]
.flatMap((pkg) => Object.values(pkg.scripts))
.join("")
.includes("node-gyp")
) {
tip = "(it seems to use gyp for building, so you might need it)";
keep.push(name);
} else {
check.push(name);
}
return `
[ check? ] '${name}' needs reviewing ${tip}
${util.inspect(found[name])}`;
}
})
.sort()
.reverse()
.join(`\n${lines.thin}`)}`);
console.log(`
${lines.thick}
What now?
Install @lavamoat/allow-scripts and use the info above to populate your allowlist.
${lines.thin}
npm i -g @lavamoat/allow-scripts
allow-scripts setup
allow-scripts auto
npm pkg set scripts.setup='npm ci && allow-scripts'
${lines.thin}
`);
console.log(
"█▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄█\n"
);
console.log(
`If you check some packages, contribute your findings on github.
https://github.com/naugtur/can-i-ignore-scripts/blob/main/data.json
`
);
// Exit with non-zero (fail) if there were packages with scripts we
// couldn't ignore. This lets us easily test for failure in shells.
process.exit(keep.length + check.length > 0 ? 1 : 0);