@lavamoat/allow-scripts
Advanced tools
Comparing version 2.1.0 to 2.3.0
{ | ||
"name": "@lavamoat/allow-scripts", | ||
"version": "2.1.0", | ||
"version": "2.3.0", | ||
"main": "src/index.js", | ||
@@ -8,6 +8,12 @@ "bin": { | ||
}, | ||
"exports": { | ||
".": "./src/index.js", | ||
"./setup": "./src/setup.js" | ||
}, | ||
"license": "MIT", | ||
"dependencies": { | ||
"@lavamoat/aa": "^3.1.0", | ||
"@lavamoat/aa": "^3.1.1", | ||
"@npmcli/run-script": "^1.8.1", | ||
"bin-links": "4.0.1", | ||
"npm-normalize-package-bin": "^3.0.0", | ||
"yargs": "^16.2.0" | ||
@@ -28,5 +34,6 @@ }, | ||
"devDependencies": { | ||
"@metamask/eslint-config-nodejs": "^10.0.0", | ||
"ava": "^3.15.0", | ||
"eslint-plugin-ava": "^11.0.0", | ||
"eslint-plugin-standard": "^5.0.0" | ||
"eslint-plugin-node": "^11.1.0" | ||
}, | ||
@@ -36,5 +43,6 @@ "scripts": { | ||
"test:run": "ava --timeout=30s test/index.js", | ||
"test:prep": "for d in ./test/projects/*/ ; do (cd \"$d\" && ../../../src/cli.js auto --experimental-bins); done", | ||
"lint": "yarn lint:eslint && yarn lint:deps", | ||
"lint:eslint": "eslint \"src/**/*.js\"", | ||
"lint:fix": "eslint src/**/*.js --fix", | ||
"lint:eslint": "eslint .", | ||
"lint:fix": "eslint . --fix", | ||
"lint:deps": "depcheck" | ||
@@ -44,3 +52,6 @@ }, | ||
"homepage": "https://github.com/LavaMoat/LavaMoat/tree/main/packages/allow-scripts", | ||
"gitHead": "ccf17166df451d850f1b284ae8d3fc2e211bd149" | ||
"engines": { | ||
"node": ">=14.0.0" | ||
}, | ||
"gitHead": "178db076d9a8dbd1c6b5c9eb6d3b3d7ebd06214b" | ||
} |
@@ -107,1 +107,15 @@ # @lavamoat/allow-scripts | ||
``` | ||
# Experimental protection against bin script confusion | ||
Bin script confusion is a new attack where a dependency gets its script to run by declaring executables that end up on the path and later get triggered either by the user or by other programs. More details in [npm bin script confusion: Abusing ‘bin’ to hijack ‘node’ command](https://socket.dev/blog/npm-bin-script-confusion) by Socket.dev | ||
To enable protection against bin script confusion, run all of the above `allow-scripts` commands with the `--experimental-bins` flag. | ||
What does it do? | ||
- `setup` will add a new configuration option to your project package manager RC file to disable linking up bin scripts | ||
- `auto` will generate an allowlist of top-level bin scripts allowed for execution | ||
- `run` will link up the allowed scripts and replace not allowed scripts with an error | ||
When you attempt to run a bin script not in the allowlist, you will get an error with instructions on how to enable it manually. |
@@ -5,3 +5,4 @@ #!/usr/bin/env node | ||
const { runAllowedPackages, setDefaultConfiguration, printPackagesList } = require('./index.js') | ||
const { writeRcFile, addPreinstallAFDependency } = require('./setup.js') | ||
const { writeRcFile, editPackageJson } = require('./setup.js') | ||
const { FEATURE } = require('./toggles') | ||
@@ -18,2 +19,4 @@ start().catch((err) => { | ||
const command = parsedArgs.command || 'run' | ||
FEATURE.bins = parsedArgs.experimentalBins | ||
switch (command) { | ||
@@ -37,3 +40,3 @@ // (default) run scripts | ||
writeRcFile() | ||
addPreinstallAFDependency() | ||
editPackageJson() | ||
return | ||
@@ -52,3 +55,11 @@ } | ||
.command('$0', 'run the allowed scripts') | ||
.command('list', 'list the packages and their allowlist status') | ||
.command('run', 'run the allowed scripts') | ||
.command('auto', 'generate scripts policy in package.json') | ||
.command('setup', 'configure local repository to use allow-scripts') | ||
.option('experimental-bins', { | ||
alias: 'bin', | ||
describe: 'opt-in to set up experimental protection against bin script confusion', | ||
type: 'boolean', | ||
default: false, | ||
}) | ||
.help() | ||
@@ -55,0 +66,0 @@ |
452
src/index.js
@@ -0,28 +1,91 @@ | ||
// @ts-check | ||
// @ts-ignore: Object is possibly 'undefined'. | ||
const { promises: fs } = require('fs') | ||
const path = require('path') | ||
const npmRunScript = require('@npmcli/run-script') | ||
const normalizeBin = require('npm-normalize-package-bin') | ||
const { linkBinAbsolute, linkBinRelative } = require('./linker.js') | ||
const { FEATURE } = require('./toggles.js') | ||
const { loadCanonicalNameMap } = require('@lavamoat/aa') | ||
const setup = require('./setup') | ||
/** | ||
* @typedef {Object} PkgConfs | ||
* @property {Object} packageJson | ||
* @property {Object} configs | ||
* @property {ScriptsConfig} configs.lifecycle | ||
* @property {BinsConfig} configs.bin | ||
* @property {boolean} somePoliciesAreMissing | ||
* | ||
* Individual package info | ||
* @typedef {Object} PkgInfo | ||
* @property {string} canonicalName | ||
* @property {string} path | ||
* @property {Object} scripts | ||
* | ||
* Individual bin link info | ||
* @typedef {Object} BinInfo | ||
* @property {string} canonicalName | ||
* @property {boolean} isDirect | ||
* @property {string} bin | ||
* @property {string} path | ||
* @property {string} link | ||
* @property {string} fullLinkPath | ||
* | ||
* Configuration for a type of scripts policies | ||
* @typedef {Object} ScriptsConfig | ||
* @property {Object} allowConfig | ||
* @property {Map<string,[PkgInfo]>} packagesWithScripts | ||
* @property {Array} allowedPatterns | ||
* @property {Array} disallowedPatterns | ||
* @property {Array} missingPolicies | ||
* @property {Array} excessPolicies | ||
* | ||
* @typedef {Map<string,[BinInfo]>} BinCandidates | ||
* | ||
* Configuration for a type of bins policies | ||
* @typedef {Object} BinsConfig | ||
* @property {Object} allowConfig | ||
* @property {BinCandidates} binCandidates | ||
* @property {Array<BinInfo>} allowedBins | ||
* @property {Array<BinInfo>} firewalledBins | ||
* @property {Array} excessPolicies | ||
* @property {boolean} somePoliciesAreMissing | ||
*/ | ||
module.exports = { | ||
getOptionsForBin, | ||
runAllowedPackages, | ||
setDefaultConfiguration, | ||
printPackagesList, | ||
setup, | ||
} | ||
async function runAllowedPackages ({ rootDir }) { | ||
async function getOptionsForBin({ rootDir, name }) { | ||
const { | ||
packagesWithLifecycleScripts, | ||
allowedPatterns, | ||
missingPolicies | ||
configs: { | ||
bin: { | ||
binCandidates, | ||
}, | ||
}, | ||
} = await loadAllPackageConfigurations({ rootDir }) | ||
if (missingPolicies.length) { | ||
return binCandidates.get(name) | ||
} | ||
async function runAllowedPackages({ rootDir }) { | ||
const { | ||
configs: { | ||
lifecycle, | ||
bin, | ||
}, | ||
somePoliciesAreMissing, | ||
} = await loadAllPackageConfigurations({ rootDir }) | ||
if (somePoliciesAreMissing) { | ||
console.log('\n@lavamoat/allow-scripts has detected dependencies without configuration. explicit configuration required.') | ||
console.log('run "allow-scripts auto" to automatically populate the configuration.\n') | ||
console.log('packages missing configuration:') | ||
missingPolicies.forEach(pattern => { | ||
const collection = packagesWithLifecycleScripts.get(pattern) || [] | ||
console.log(`- ${pattern} [${collection.length} location(s)]`) | ||
}) | ||
printMissingPoliciesIfAny(lifecycle) | ||
@@ -33,17 +96,29 @@ // exit with error | ||
if (FEATURE.bins && bin.allowConfig) { | ||
// Consider: Might as well delete entire .bin and recreate in case it was left there | ||
// install bins | ||
if (bin.binCandidates.size > 0) { | ||
console.log('installing bin scripts') | ||
await installBinScripts(bin.allowedBins) | ||
await installBinFirewall(bin.firewalledBins, path.join(__dirname, './whichbin.js')) | ||
} else { | ||
console.log('no bin scripts found in dependencies') | ||
} | ||
} | ||
// run scripts in dependencies | ||
if (allowedPatterns.length) { | ||
const allowedPackagesWithLifecycleScripts = [].concat(...Array.from(packagesWithLifecycleScripts.entries()) | ||
.filter(([pattern]) => allowedPatterns.includes(pattern)) | ||
.map(([, packages]) => packages) | ||
) | ||
if (lifecycle.allowedPatterns.length) { | ||
const allowedPackagesWithScriptsLifecycleScripts = Array.from(lifecycle.packagesWithScripts.entries()) | ||
.filter(([pattern]) => lifecycle.allowedPatterns.includes(pattern)) | ||
.flatMap(([, packages]) => packages) | ||
console.log('running lifecycle scripts for event "preinstall"') | ||
await runAllScriptsForEvent({ event: 'preinstall', packages: allowedPackagesWithLifecycleScripts }) | ||
await runAllScriptsForEvent({ event: 'preinstall', packages: allowedPackagesWithScriptsLifecycleScripts }) | ||
console.log('running lifecycle scripts for event "install"') | ||
await runAllScriptsForEvent({ event: 'install', packages: allowedPackagesWithLifecycleScripts }) | ||
await runAllScriptsForEvent({ event: 'install', packages: allowedPackagesWithScriptsLifecycleScripts }) | ||
console.log('running lifecycle scripts for event "postinstall"') | ||
await runAllScriptsForEvent({ event: 'postinstall', packages: allowedPackagesWithLifecycleScripts }) | ||
await runAllScriptsForEvent({ event: 'postinstall', packages: allowedPackagesWithScriptsLifecycleScripts }) | ||
} else { | ||
console.log('no allowed scripts found in configuration') | ||
console.log('no allowed lifecycle scripts found in configuration') | ||
} | ||
@@ -56,6 +131,74 @@ | ||
await runScript({ event: 'prepublish', path: rootDir }) | ||
// TODO: figure out if we should be doing this: | ||
await runScript({ event: 'prepare', path: rootDir }) | ||
} | ||
async function runAllScriptsForEvent ({ event, packages }) { | ||
async function setDefaultConfiguration({ rootDir }) { | ||
const conf = await loadAllPackageConfigurations({ rootDir }) | ||
const { | ||
configs: { | ||
lifecycle, | ||
bin, | ||
}, | ||
somePoliciesAreMissing, | ||
} = conf | ||
console.log('\n@lavamoat/allow-scripts automatically updating configuration') | ||
if (!somePoliciesAreMissing) { | ||
console.log('\nconfiguration looks good as is, no changes necessary') | ||
return | ||
} | ||
console.log('\nadding configuration:') | ||
lifecycle.missingPolicies.forEach(pattern => { | ||
console.log(`- lifecycle ${pattern}`) | ||
lifecycle.allowConfig[pattern] = false | ||
}) | ||
if(FEATURE.bins && bin.somePoliciesAreMissing) { | ||
bin.allowConfig = prepareBinScriptsPolicy(bin.binCandidates) | ||
console.log(`- bin scripts linked: ${Object.keys(bin.allowConfig).join(',')}`) | ||
} | ||
// update package json | ||
await savePackageConfigurations({ | ||
rootDir, | ||
conf, | ||
}) | ||
} | ||
async function printPackagesList({ rootDir }) { | ||
const { | ||
configs: { | ||
bin, | ||
lifecycle, | ||
}, | ||
} = await loadAllPackageConfigurations({ rootDir }) | ||
printPackagesByBins(bin) | ||
printPackagesByScriptConfiguration(lifecycle) | ||
} | ||
function printMissingPoliciesIfAny({ missingPolicies = [], packagesWithScripts = new Map() }) { | ||
if(missingPolicies.length) { | ||
console.log('packages missing configuration:') | ||
missingPolicies.forEach(pattern => { | ||
const collection = packagesWithScripts.get(pattern) || [] | ||
console.log(`- ${pattern} [${collection.length} location(s)]`) | ||
}) | ||
} | ||
} | ||
// internals | ||
/** | ||
* | ||
* @param {Object} arg | ||
* @param {string} arg.event | ||
* @param {Array<PkgInfo>} arg.packages | ||
*/ | ||
async function runAllScriptsForEvent({ event, packages }) { | ||
for (const { canonicalName, path, scripts } of packages) { | ||
@@ -68,4 +211,24 @@ if (event in scripts) { | ||
} | ||
/** | ||
* @param {Array<BinInfo>} allowedBins | ||
*/ | ||
async function installBinScripts(allowedBins) { | ||
for (const { bin, path, link, canonicalName } of allowedBins) { | ||
console.log(`- ${bin} - from package: ${canonicalName}`) | ||
await linkBinRelative({ path, bin, link, force: true }) | ||
} | ||
} | ||
/** | ||
* Points all bins on the list to whichbin.js cli app from allow-scripts | ||
* @param {Array<BinInfo>} firewalledBins | ||
* @param {string} link - absolute path to the whichbin.js script | ||
*/ | ||
async function installBinFirewall(firewalledBins, link) { | ||
// Note how we take the path of the original package so that the bin is added at the appropriate level of node_modules nesting | ||
for (const { bin, path: packagePath } of firewalledBins) { | ||
await linkBinAbsolute({path: packagePath, bin, link, force: true } ) | ||
} | ||
} | ||
async function runScript ({ path, event }) { | ||
async function runScript({ path, event }) { | ||
await npmRunScript({ | ||
@@ -85,50 +248,66 @@ // required, the script to run | ||
// Defaults true when stdio:'inherit', otherwise suppressed | ||
banner: true | ||
banner: true, | ||
}) | ||
} | ||
async function setDefaultConfiguration ({ rootDir }) { | ||
const { | ||
packageJson, | ||
allowScriptsConfig, | ||
missingPolicies, | ||
excessPolicies | ||
} = await loadAllPackageConfigurations({ rootDir }) | ||
console.log('\n@lavamoat/allow-scripts automatically updating configuration') | ||
const bannedBins = new Set(['node', 'npm', 'yarn', 'pnpm']) | ||
if (!missingPolicies.length && !excessPolicies.length) { | ||
console.log('\nconfiguration looks good as is, no changes necesary') | ||
return | ||
/** | ||
* @param {BinCandidates} binCandidates | ||
*/ | ||
function prepareBinScriptsPolicy(binCandidates) { | ||
const policy = {} | ||
// pick direct dependencies without conflicts and enable them by default unless colliding with bannedBins | ||
for ( const [bin, infos] of binCandidates.entries()) { | ||
const binsFromDirectDependencies = infos.filter(i => i.isDirect) | ||
if(binsFromDirectDependencies.length === 1 && !bannedBins.has(bin)) { | ||
// there's no conflicts, seems fairly obvious choice to put it up | ||
policy[bin] = binsFromDirectDependencies[0].fullLinkPath | ||
} | ||
} | ||
return policy | ||
} | ||
if (missingPolicies.length) { | ||
console.log('\nadding configuration for missing packages:') | ||
missingPolicies.forEach(pattern => { | ||
console.log(`- ${pattern}`) | ||
allowScriptsConfig[pattern] = false | ||
/** | ||
* @param {BinsConfig} param0 | ||
*/ | ||
function printPackagesByBins({ | ||
allowedBins, | ||
excessPolicies, | ||
}) { | ||
console.log('\n# allowed packages with bin scripts') | ||
if (allowedBins.length) { | ||
allowedBins.forEach(({ canonicalName, bin }) => { | ||
console.log(`- ${canonicalName} [${bin}]`) | ||
}) | ||
} else { | ||
console.log(' (none)') | ||
} | ||
// update package json | ||
if (!packageJson.lavamoat) packageJson.lavamoat = {} | ||
packageJson.lavamoat.allowScripts = allowScriptsConfig | ||
const packageJsonPath = path.resolve(rootDir, 'package.json') | ||
const packageJsonSerialized = JSON.stringify(packageJson, null, 2) + '\n' | ||
await fs.writeFile(packageJsonPath, packageJsonSerialized) | ||
if (excessPolicies.length) { | ||
console.log('\n# packages with bin scripts that no longer need configuration (package or script removed or script path outdated)') | ||
excessPolicies.forEach(bin => { | ||
console.log(`- ${bin}`) | ||
}) | ||
} | ||
} | ||
async function printPackagesList ({ rootDir }) { | ||
const { | ||
packagesWithLifecycleScripts, | ||
allowedPatterns, | ||
disallowedPatterns, | ||
missingPolicies, | ||
excessPolicies | ||
} = await loadAllPackageConfigurations({ rootDir }) | ||
/** | ||
* @param {ScriptsConfig} param0 | ||
*/ | ||
function printPackagesByScriptConfiguration({ | ||
packagesWithScripts, | ||
allowedPatterns, | ||
disallowedPatterns, | ||
missingPolicies, | ||
excessPolicies, | ||
}) { | ||
console.log('\n# allowed packages') | ||
console.log('\n# allowed packages with lifecycle scripts') | ||
if (allowedPatterns.length) { | ||
allowedPatterns.forEach(pattern => { | ||
const collection = packagesWithLifecycleScripts.get(pattern) || [] | ||
const collection = packagesWithScripts.get(pattern) || [] | ||
console.log(`- ${pattern} [${collection.length} location(s)]`) | ||
@@ -140,6 +319,6 @@ }) | ||
console.log('\n# disallowed packages') | ||
console.log('\n# disallowed packages with lifecycle scripts') | ||
if (disallowedPatterns.length) { | ||
disallowedPatterns.forEach(pattern => { | ||
const collection = packagesWithLifecycleScripts.get(pattern) || [] | ||
const collection = packagesWithScripts.get(pattern) || [] | ||
console.log(`- ${pattern} [${collection.length} location(s)]`) | ||
@@ -152,5 +331,5 @@ }) | ||
if (missingPolicies.length) { | ||
console.log('\n# unconfigured packages!') | ||
console.log('\n# unconfigured packages with lifecycle scripts') | ||
missingPolicies.forEach(pattern => { | ||
const collection = packagesWithLifecycleScripts.get(pattern) || [] | ||
const collection = packagesWithScripts.get(pattern) || [] | ||
console.log(`- ${pattern} [${collection.length} location(s)]`) | ||
@@ -161,5 +340,5 @@ }) | ||
if (excessPolicies.length) { | ||
console.log('\n# packages that dont need configuration (missing or no lifecycle scripts)') | ||
console.log('\n# packages with lifecycle scripts that no longer need configuration due to package or scripts removal') | ||
excessPolicies.forEach(pattern => { | ||
const collection = packagesWithLifecycleScripts.get(pattern) || [] | ||
const collection = packagesWithScripts.get(pattern) || [] | ||
console.log(`- ${pattern} [${collection.length} location(s)]`) | ||
@@ -170,7 +349,39 @@ }) | ||
async function loadAllPackageConfigurations ({ rootDir }) { | ||
const packagesWithLifecycleScripts = new Map() | ||
/** | ||
* | ||
* @param {Object} args | ||
* @param {string} args.rootDir | ||
* @param {PkgConfs} args.conf | ||
* @returns {Promise} | ||
*/ | ||
async function savePackageConfigurations({ rootDir, conf: { | ||
packageJson, | ||
configs: { lifecycle, bin }, | ||
} }) { | ||
// update package json | ||
if (!packageJson.lavamoat) { | ||
packageJson.lavamoat = {} | ||
} | ||
packageJson.lavamoat.allowScripts = lifecycle.allowConfig | ||
packageJson.lavamoat.allowBins = bin.allowConfig | ||
const packageJsonPath = path.resolve(rootDir, 'package.json') | ||
const packageJsonSerialized = JSON.stringify(packageJson, null, 2) + '\n' | ||
await fs.writeFile(packageJsonPath, packageJsonSerialized) | ||
} | ||
/** | ||
* | ||
* @param {Object} args | ||
* @param {string} args.rootDir | ||
* @returns {Promise<PkgConfs>} | ||
*/ | ||
async function loadAllPackageConfigurations({ rootDir }) { | ||
const packagesWithScriptsLifecycle = new Map() | ||
const binCandidates = new Map() | ||
const dependencyMap = await loadCanonicalNameMap({ rootDir, includeDevDeps: true }) | ||
const sortedDepEntries = Array.from(dependencyMap.entries()).sort(sortBy(([filePath, canonicalName]) => canonicalName)) | ||
const packageJson = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf8')) | ||
const directDeps = new Set([...Object.keys(packageJson.devDependencies||{}),...Object.keys(packageJson.dependencies||{})]) | ||
for (const [filePath, canonicalName] of sortedDepEntries) { | ||
@@ -180,8 +391,9 @@ // const canonicalName = getCanonicalNameForPath({ rootDir, filePath: filePath }) | ||
try { | ||
depPackageJson = JSON.parse(await fs.readFile(path.join(filePath, 'package.json'))) | ||
depPackageJson = JSON.parse(await fs.readFile(path.join(filePath, 'package.json'), 'utf-8')) | ||
} catch (err) { | ||
const branchIsOptional = branch.some(node => node.optional) | ||
if (err.code === 'ENOENT' && branchIsOptional) { | ||
continue | ||
} | ||
// FIXME: leftovers of code that used to work | ||
// const branchIsOptional = branch.some(node => node.optional) | ||
// if (err.code === 'ENOENT' && branchIsOptional) { | ||
// continue | ||
// } | ||
throw err | ||
@@ -193,45 +405,103 @@ } | ||
if (lifeCycleScripts.length) { | ||
const collection = packagesWithLifecycleScripts.get(canonicalName) || [] | ||
const collection = packagesWithScriptsLifecycle.get(canonicalName) || [] | ||
collection.push({ | ||
canonicalName, | ||
path: filePath, | ||
scripts: depScripts | ||
scripts: depScripts, | ||
}) | ||
packagesWithLifecycleScripts.set(canonicalName, collection) | ||
packagesWithScriptsLifecycle.set(canonicalName, collection) | ||
} | ||
if (FEATURE.bins && depPackageJson.bin) { | ||
const binsList = Object.entries(normalizeBin(depPackageJson)?.bin || {}) | ||
binsList.forEach(([name, link]) => { | ||
const collection = binCandidates.get(name) || [] | ||
if (collection.length === 0) { | ||
binCandidates.set(name, collection) | ||
} | ||
collection.push({ | ||
// canonical name for a direct dependency is just dependency name | ||
isDirect: directDeps.has(canonicalName), | ||
bin: name, | ||
path: filePath, | ||
link, | ||
fullLinkPath: path.relative(rootDir,path.join(filePath, link)), | ||
canonicalName, | ||
}) | ||
}) | ||
} | ||
} | ||
const packageJson = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf8')) | ||
const lavamoatConfig = packageJson.lavamoat || {} | ||
const allowScriptsConfig = lavamoatConfig.allowScripts || {} | ||
// packages with config | ||
const configuredPatterns = Object.keys(allowScriptsConfig) | ||
// select allowed + disallowed | ||
const allowedPatterns = Object.entries(allowScriptsConfig).filter(([pattern, packageData]) => packageData === true).map(([pattern]) => pattern) | ||
const disallowedPatterns = Object.entries(allowScriptsConfig).filter(([pattern, packageData]) => packageData === false).map(([pattern]) => pattern) | ||
const missingPolicies = [...packagesWithLifecycleScripts.keys()] | ||
.filter(pattern => packagesWithLifecycleScripts.has(pattern)) | ||
.filter(pattern => !configuredPatterns.includes(pattern)) | ||
const excessPolicies = Object.keys(allowScriptsConfig).filter(pattern => !packagesWithLifecycleScripts.has(pattern)) | ||
const configs = { | ||
lifecycle: indexLifecycleConfiguration({ | ||
packagesWithScripts: packagesWithScriptsLifecycle, | ||
allowConfig: lavamoatConfig.allowScripts, | ||
}), | ||
bin: indexBinsConfiguration({ | ||
binCandidates, | ||
allowConfig: lavamoatConfig.allowBins, | ||
}), | ||
} | ||
const somePoliciesAreMissing = !!(configs.lifecycle.missingPolicies.length || configs.bin.somePoliciesAreMissing) | ||
return { | ||
packageJson, | ||
allowScriptsConfig, | ||
packagesWithLifecycleScripts, | ||
allowedPatterns, | ||
disallowedPatterns, | ||
missingPolicies, | ||
excessPolicies | ||
configs, | ||
somePoliciesAreMissing, | ||
} | ||
} | ||
/** | ||
* Adds helpful redundancy to the config object thus producing a full ScriptsConfig type | ||
* @param {*} config | ||
* @return {ScriptsConfig} | ||
*/ | ||
function indexLifecycleConfiguration(config) { | ||
config.allowConfig = config.allowConfig || {} | ||
// packages with config | ||
const configuredPatterns = Object.keys(config.allowConfig) | ||
// select allowed + disallowed | ||
config.allowedPatterns = Object.entries(config.allowConfig).filter(([pattern, packageData]) => !!packageData).map(([pattern]) => pattern) | ||
config.disallowedPatterns = Object.entries(config.allowConfig).filter(([pattern, packageData]) => !packageData).map(([pattern]) => pattern) | ||
config.missingPolicies = Array.from(config.packagesWithScripts.keys()) | ||
.filter(pattern => !configuredPatterns.includes(pattern)) | ||
config.excessPolicies = configuredPatterns.filter(pattern => !config.packagesWithScripts.has(pattern)) | ||
return config | ||
} | ||
/** | ||
* Adds helpful redundancy to the config object thus producing a full ScriptsConfig type | ||
* @param {*} config | ||
* @return {BinsConfig} | ||
*/ | ||
function indexBinsConfiguration(config) { | ||
// only autogenerate the initial config. A better heuristic would be to detect if any scripts from direct dependencies are missing | ||
config.somePoliciesAreMissing = !config.allowConfig && config.binCandidates.size > 0 | ||
config.excessPolicies = Object.keys(config.allowConfig || {}).filter(b => !config.binCandidates.has(b)) | ||
config.allowedBins = Object.entries(config.allowConfig || {}).map(([bin, fullPath]) => config.binCandidates.get(bin)?.find((/** @type BinInfo */ candidate) => candidate.fullLinkPath === fullPath)).filter(a => a) | ||
config.firewalledBins = Array.from(config.binCandidates.values()).flat().filter(binInfo => !config.allowedBins.includes(binInfo)) | ||
return config | ||
} | ||
function sortBy(getterFn) { | ||
return (a,b) => { | ||
return (a, b) => { | ||
const aVal = getterFn(a) | ||
const bVal = getterFn(b) | ||
if (aVal > bVal) return 1 | ||
else if (aVal < bVal) return -1 | ||
else return 0 | ||
if (aVal > bVal) { | ||
return 1 | ||
} else if (aVal < bVal) { | ||
return -1 | ||
} else { | ||
return 0 | ||
} | ||
} | ||
} |
117
src/setup.js
@@ -5,10 +5,33 @@ const { existsSync, | ||
writeFileSync, | ||
createWriteStream, | ||
} = require('fs') | ||
} = require('fs') | ||
const { spawnSync } = require('child_process') | ||
const path = require('path') | ||
const { FEATURE } = require('./toggles') | ||
const NPM = { | ||
RCFILE: '.npmrc', | ||
CONF: { | ||
SCRIPTS: 'ignore-scripts=true', | ||
BINS: 'bin-links=false', | ||
}, | ||
} | ||
const YARN1 = { | ||
RCFILE: '.yarnrc', | ||
CONF: { | ||
SCRIPTS: 'ignore-scripts true', | ||
BINS: '--*.no-bin-links true', | ||
}, | ||
} | ||
const YARN3 = { | ||
RCFILE: '.yarnrc.yml', | ||
CONF: { | ||
SCRIPTS: 'enableScripts: false', | ||
}, | ||
} | ||
module.exports = { | ||
writeRcFile, | ||
addPreinstallAFDependency | ||
areBinsBlocked, | ||
editPackageJson, | ||
} | ||
@@ -21,13 +44,15 @@ | ||
function writeRcFileContent({file, exists, entry}){ | ||
let rcPath = addInstallParentDir(file) | ||
if (!exists) { | ||
writeFileSync(rcPath, entry + '\n') | ||
console.log(`@lavamoat/allow-scripts: created ${rcPath} file with entry: ${entry}.`) | ||
return | ||
function isEntryPresent(entry, file) { | ||
const rcPath = addInstallParentDir(file) | ||
if (!existsSync(rcPath)) { | ||
return false | ||
} | ||
const rcFileContents = readFileSync(rcPath, 'utf8') | ||
return rcFileContents.includes(entry) | ||
} | ||
const rcFileContents = readFileSync(rcPath, 'utf8') | ||
if (rcFileContents.includes(entry)) { | ||
function writeRcFileContent({file, entry}) { | ||
const rcPath = addInstallParentDir(file) | ||
if (isEntryPresent(entry, file)) { | ||
console.log(`@lavamoat/allow-scripts: file ${rcPath} already exists with entry: ${entry}.`) | ||
@@ -40,6 +65,21 @@ } else { | ||
let binsBlockedMemo | ||
/** | ||
* | ||
* @param {Object} args | ||
* @param {boolean} noMemoization - turn off memoization, make a fresh lookup | ||
* @returns {boolean} | ||
*/ | ||
function areBinsBlocked({ noMemoization = false } = {}) { | ||
if(noMemoization || binsBlockedMemo === undefined) { | ||
binsBlockedMemo = isEntryPresent(NPM.CONF.BINS, NPM.RCFILE) || isEntryPresent(YARN1.CONF.BINS, YARN1.RCFILE) | ||
// Once yarn3 support via plugin comes in, this function would need to detect that, or cease to exist. | ||
} | ||
return binsBlockedMemo | ||
} | ||
function writeRcFile () { | ||
const yarnRcExists = existsSync(addInstallParentDir('.yarnrc')) | ||
const yarnYmlExists = existsSync(addInstallParentDir('.yarnrc.yml')) | ||
const npmRcExists = existsSync(addInstallParentDir('.npmrc')) | ||
const yarnRcExists = existsSync(addInstallParentDir(YARN1.RCFILE)) | ||
const yarnYmlExists = existsSync(addInstallParentDir(YARN3.RCFILE)) | ||
const npmRcExists = existsSync(addInstallParentDir(NPM.RCFILE)) | ||
const yarnLockExists = existsSync(addInstallParentDir('yarn.lock')) | ||
@@ -50,12 +90,19 @@ | ||
configs.push({ | ||
file: ".yarnrc", | ||
file: YARN1.RCFILE, | ||
exists: yarnRcExists, | ||
entry: "ignore-scripts true", | ||
entry: YARN1.CONF.SCRIPTS, | ||
}) | ||
if(FEATURE.bins) { | ||
configs.push({ | ||
file: YARN1.RCFILE, | ||
exists: yarnRcExists, | ||
entry: YARN1.CONF.BINS, | ||
}) | ||
} | ||
} | ||
if (yarnYmlExists || yarnLockExists) { | ||
configs.push({ | ||
file: ".yarnrc.yml", | ||
file: YARN3.RCFILE, | ||
exists: yarnYmlExists, | ||
entry: "enableScripts: false", | ||
entry: YARN3.CONF.SCRIPTS, | ||
}) | ||
@@ -66,6 +113,13 @@ } | ||
configs.push({ | ||
file: ".npmrc", | ||
file: NPM.RCFILE, | ||
exists: npmRcExists, | ||
entry: "ignore-scripts=true", | ||
entry: NPM.CONF.SCRIPTS, | ||
}) | ||
if(FEATURE.bins) { | ||
configs.push({ | ||
file: NPM.RCFILE, | ||
exists: npmRcExists, | ||
entry: NPM.CONF.BINS, | ||
}) | ||
} | ||
} | ||
@@ -76,3 +130,3 @@ | ||
function addPreinstallAFDependency () { | ||
function editPackageJson () { | ||
let cmd, cmdArgs | ||
@@ -91,7 +145,20 @@ | ||
if (result.status !== 0) { | ||
process.stderr.write(result.stderr); | ||
process.exit(result.status); | ||
process.stderr.write(result.stderr) | ||
process.exit(result.status) | ||
} else { | ||
console.log('@lavamoat/allow-scripts:: Added dependency @lavamoat/preinstall-always-fail.') | ||
console.log('@lavamoat/allow-scripts: Added dependency @lavamoat/preinstall-always-fail.') | ||
} | ||
if(FEATURE.bins) { | ||
// no motivation to fix lint here, there's a better implementation of this in a neighboring branch | ||
// eslint-disable-next-line node/global-require | ||
const packageJson = require(addInstallParentDir('package.json')) | ||
if(!packageJson.scripts) { | ||
packageJson.scripts = {} | ||
} | ||
// If you think `node ` is redundant below, be aware that `./cli.js` won't work on Windows, | ||
// but passing a unix-style path to node on Windows works fine. | ||
packageJson.scripts['allow-scripts'] = 'node ./node_modules/@lavamoat/allow-scripts/src/cli.js' | ||
writeFileSync(addInstallParentDir('package.json'), JSON.stringify(packageJson, null, 2)) | ||
} | ||
} |
@@ -6,3 +6,3 @@ const test = require('ava') | ||
test('cli - auto command', async (t) => { | ||
test('cli - auto command', (t) => { | ||
// set up the directories | ||
@@ -20,8 +20,11 @@ let allowScriptsSrcRoot = path.join(__dirname, '..', 'src') | ||
// npm init -y | ||
spawnSync('npm', ['init', '-y'], {cwd: projectRoot}) | ||
spawnSync('npm', ['init', '-y'], realisticEnvOptions(projectRoot)) | ||
// run the auto command | ||
let cmd = path.join(allowScriptsSrcRoot, 'cli.js') | ||
let result = spawnSync(cmd, ['auto'], {cwd: projectRoot}) | ||
let result = spawnSync(cmd, ['auto'], realisticEnvOptions(projectRoot)) | ||
// forward error output for debugging | ||
console.error(result.stderr.toString('utf-8')) | ||
// get the package.json | ||
@@ -31,6 +34,46 @@ const packageJsonContents = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')) | ||
// assert its contents | ||
t.deepEqual(packageJsonContents.lavamoat, {allowScripts: {'bbb>evil_dep': false}}) | ||
t.deepEqual(packageJsonContents.lavamoat, { | ||
allowScripts: { | ||
'bbb>evil_dep': false | ||
} | ||
}) | ||
}) | ||
test('cli - auto command with experimental bins', (t) => { | ||
// set up the directories | ||
let allowScriptsSrcRoot = path.join(__dirname, '..', 'src') | ||
let projectRoot = path.join(__dirname, 'projects', '1') | ||
test('cli - run command - good dep at the root', async (t) => { | ||
// delete any existing package.json | ||
fs.unlink(path.join(projectRoot, 'package.json'), err => { | ||
if (err && err.code !== 'ENOENT') { | ||
throw err | ||
} | ||
}) | ||
// npm init -y | ||
spawnSync('npm', ['init', '-y'], realisticEnvOptions(projectRoot)) | ||
// run the auto command | ||
let cmd = path.join(allowScriptsSrcRoot, 'cli.js') | ||
let result = spawnSync(cmd, ['auto', '--experimental-bins'], realisticEnvOptions(projectRoot)) | ||
// forward error output for debugging | ||
console.error(result.stderr.toString('utf-8')) | ||
// get the package.json | ||
const packageJsonContents = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')) | ||
// assert its contents | ||
t.deepEqual(packageJsonContents.lavamoat, { | ||
allowBins: { | ||
aaa: 'node_modules/aaa/index.js', | ||
karramba: 'node_modules/bbb/index.js', | ||
}, | ||
allowScripts: { | ||
'bbb>evil_dep': false | ||
} | ||
}) | ||
}) | ||
test('cli - run command - good dep at the root', (t) => { | ||
// set up the directories | ||
@@ -40,6 +83,16 @@ let allowScriptsSrcRoot = path.join(__dirname, '..', 'src') | ||
// clean up from a previous run | ||
// the force option is only here to stop rm complaining if target is missing | ||
fs.rmSync(path.join(projectRoot, './node_modules/.bin'), { | ||
recursive: true, | ||
force: true | ||
}) | ||
// run the "run" command | ||
let cmd = path.join(allowScriptsSrcRoot, 'cli.js') | ||
let result = spawnSync(cmd, ['run'], {cwd: projectRoot}) | ||
let result = spawnSync(cmd, ['run'], realisticEnvOptions(projectRoot)) | ||
// forward error output for debugging | ||
console.error(result.stderr.toString('utf-8')) | ||
// assert the output | ||
@@ -61,5 +114,44 @@ t.deepEqual(result.stdout.toString().split('\n'), [ | ||
}) | ||
test('cli - run command - good dep at the root with experimental bins', (t) => { | ||
// set up the directories | ||
let allowScriptsSrcRoot = path.join(__dirname, '..', 'src') | ||
let projectRoot = path.join(__dirname, 'projects', '2') | ||
// clean up from a previous run | ||
// the force option is only here to stop rm complaining if target is missing | ||
fs.rmSync(path.join(projectRoot, './node_modules/.bin'), { | ||
recursive: true, | ||
force: true | ||
}) | ||
test('cli - run command - good dep as a sub dep', async (t) => { | ||
// run the "run" command | ||
let cmd = path.join(allowScriptsSrcRoot, 'cli.js') | ||
let result = spawnSync(cmd, ['run', '--experimental-bins'], realisticEnvOptions(projectRoot)) | ||
// forward error output for debugging | ||
console.error(result.stderr.toString('utf-8')) | ||
// assert the output | ||
t.deepEqual(result.stdout.toString().split('\n'), [ | ||
'installing bin scripts', | ||
'- good - from package: good_dep', | ||
'running lifecycle scripts for event \"preinstall\"', | ||
'- good_dep', | ||
'running lifecycle scripts for event \"install\"', | ||
'running lifecycle scripts for event \"postinstall\"', | ||
'running lifecycle scripts for top level package', | ||
'', | ||
]) | ||
t.assert(fs.existsSync(path.join(projectRoot, './node_modules/.bin/good')), 'Expected a bin script to be installed in top level node_modules') | ||
// note | ||
// we could also test whether the preinstall script is | ||
// actually executing. we did it manually by replacing | ||
// with | ||
// "preinstall": "touch /tmp/$( date '+%Y-%m-%d_%H-%M-%S' )" | ||
}) | ||
test('cli - run command - good dep as a sub dep', (t) => { | ||
// set up the directories | ||
@@ -69,6 +161,16 @@ let allowScriptsSrcRoot = path.join(__dirname, '..', 'src') | ||
// generate the bin link | ||
spawnSync('npm', ['rebuild', 'good_dep'], realisticEnvOptions(projectRoot)) | ||
// clean up from a previous run | ||
// the force option is only here to stop rm complaining if target is missing | ||
fs.rmSync(path.join(projectRoot, './node_modules/bbb/.goodscriptworked'), { force: true }) | ||
// run the "run" command | ||
let cmd = path.join(allowScriptsSrcRoot, 'cli.js') | ||
let result = spawnSync(cmd, ['run'], {cwd: projectRoot}) | ||
let result = spawnSync(cmd, ['run'], realisticEnvOptions(projectRoot)) | ||
// uncomment to forward error output for debugging | ||
// console.error(result.stdout.toString('utf-8')) | ||
// console.error(result.stderr.toString('utf-8')) | ||
// assert the output | ||
@@ -80,5 +182,52 @@ t.deepEqual(result.stdout.toString().split('\n'), [ | ||
'running lifecycle scripts for event \"postinstall\"', | ||
'- bbb', | ||
'running lifecycle scripts for top level package', | ||
'', | ||
]) | ||
}) | ||
test('cli - run command - good dep as a sub dep with experimental bins', (t) => { | ||
// set up the directories | ||
let allowScriptsSrcRoot = path.join(__dirname, '..', 'src') | ||
let projectRoot = path.join(__dirname, 'projects', '3') | ||
// clean up from a previous run | ||
// the force option is only here to stop rm complaining if target is missing | ||
fs.rmSync(path.join(projectRoot, './node_modules/bbb/.goodscriptworked'), { force: true }) | ||
fs.rmSync(path.join(projectRoot, './node_modules/bbb/node_modules/.bin'), { | ||
recursive: true, | ||
force: true | ||
}) | ||
// run the "run" command | ||
let cmd = path.join(allowScriptsSrcRoot, 'cli.js') | ||
let result = spawnSync(cmd, ['run', '--experimental-bins'], realisticEnvOptions(projectRoot)) | ||
// uncomment to forward error output for debugging | ||
// console.error(result.stdout.toString('utf-8')) | ||
// console.error(result.stderr.toString('utf-8')) | ||
t.assert(fs.existsSync(path.join(projectRoot, './node_modules/bbb/node_modules/.bin/good')), 'Expected a nested bin script to be installed in bbb/node_modules/.bin') | ||
const errarr = result.stderr.toString().split('\n') | ||
t.assert(errarr.every(line=>!line.includes('you shall not pass')), 'Should not have run the parent script from the nested package postinstall') | ||
t.assert(errarr.some(line=>line.includes(`"good": "node_modules/`)), 'Expected to see instructions on how to enable a bin script1') | ||
t.assert(errarr.some(line=>line.includes(`node_modules/good_dep/cli.sh`)), 'Expected to see instructions on how to enable a bin script2') | ||
t.assert(errarr.some(line=>line.includes(`node_modules/aaa/shouldntrun.sh`)), 'Expected to see instructions on how to enable a bin script3') | ||
// assert the output | ||
t.deepEqual(result.stdout.toString().split('\n'), [ | ||
'installing bin scripts', | ||
'- good - from package: aaa', | ||
'running lifecycle scripts for event \"preinstall\"', | ||
'- bbb>good_dep', | ||
'running lifecycle scripts for event \"install\"', | ||
'running lifecycle scripts for event \"postinstall\"', | ||
'- bbb', | ||
'', | ||
]) | ||
}) | ||
function realisticEnvOptions(projectRoot) { | ||
return { cwd: projectRoot, env: { ...process.env, INIT_CWD: projectRoot } } | ||
} |
@@ -6,2 +6,3 @@ { | ||
"main": "index.js", | ||
"bin": "index.js", | ||
"scripts": { | ||
@@ -8,0 +9,0 @@ "test": "echo \"Error: no test specified\" && exit 1" |
@@ -6,2 +6,5 @@ { | ||
"main": "index.js", | ||
"bin": { | ||
"npm": "index.js" | ||
}, | ||
"scripts": { | ||
@@ -8,0 +11,0 @@ "test": "echo \"Error: no test specified\" && exit 1", |
@@ -9,2 +9,5 @@ { | ||
}, | ||
"bin": { | ||
"karramba": "index.js" | ||
}, | ||
"scripts": { | ||
@@ -11,0 +14,0 @@ "test": "echo \"Error: no test specified\" && exit 1" |
@@ -6,2 +6,3 @@ { | ||
"main": "index.js", | ||
"bin": "example.js", | ||
"scripts": { | ||
@@ -8,0 +9,0 @@ "test": "echo \"Error: no test specified\" && exit 1" |
@@ -6,2 +6,5 @@ { | ||
"main": "index.js", | ||
"bin": { | ||
"good": "index.js" | ||
}, | ||
"scripts": { | ||
@@ -8,0 +11,0 @@ "test": "echo \"Error: no test specified\" && exit 1", |
@@ -22,4 +22,7 @@ { | ||
"bbb>evil_dep": false | ||
}, | ||
"allowBins": { | ||
"good": "node_modules/good_dep/index.js" | ||
} | ||
} | ||
} |
@@ -6,2 +6,5 @@ { | ||
"main": "index.js", | ||
"bin": { | ||
"good": "shouldntrun.sh" | ||
}, | ||
"scripts": { | ||
@@ -8,0 +11,0 @@ "test": "echo \"Error: no test specified\" && exit 1" |
@@ -6,2 +6,5 @@ { | ||
"main": "index.js", | ||
"bin": { | ||
"good": "cli.sh" | ||
}, | ||
"scripts": { | ||
@@ -8,0 +11,0 @@ "test": "echo \"Error: no test specified\" && exit 1", |
@@ -11,3 +11,4 @@ { | ||
"scripts": { | ||
"test": "echo \"Error: no test specified\" && exit 1" | ||
"test": "echo \"Error: no test specified\" && exit 1", | ||
"postinstall": "good - does it run" | ||
}, | ||
@@ -14,0 +15,0 @@ "keywords": [], |
@@ -6,2 +6,5 @@ { | ||
"main": "index.js", | ||
"bin": { | ||
"npm": "index.js" | ||
}, | ||
"scripts": { | ||
@@ -8,0 +11,0 @@ "test": "echo \"Error: no test specified\" && exit 1", |
@@ -13,3 +13,3 @@ { | ||
"scripts": { | ||
"test": "echo \"Error: no test specified\" && exit 1" | ||
"test": "good" | ||
}, | ||
@@ -22,5 +22,9 @@ "keywords": [], | ||
"bbb>good_dep": true, | ||
"evil_dep": false | ||
"evil_dep": false, | ||
"bbb": true | ||
}, | ||
"allowBins": { | ||
"good": "node_modules/aaa/shouldntrun.sh" | ||
} | ||
} | ||
} |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Install scripts
Supply chain riskInstall scripts are run when the package is installed. The majority of malware in npm is hidden in install scripts.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
43994
30
891
121
5
4
6
8
3
+ Addedbin-links@4.0.1
+ Addedbin-links@4.0.1(transitive)
+ Addedcmd-shim@6.0.3(transitive)
+ Addedimurmurhash@0.1.4(transitive)
+ Addednpm-normalize-package-bin@3.0.1(transitive)
+ Addedread-cmd-shim@4.0.0(transitive)
+ Addedsignal-exit@4.1.0(transitive)
+ Addedwrite-file-atomic@5.0.1(transitive)
Updated@lavamoat/aa@^3.1.1