@lavamoat/allow-scripts
Advanced tools
Comparing version
{ | ||
"name": "@lavamoat/allow-scripts", | ||
"version": "1.0.6", | ||
"version": "2.3.0", | ||
"main": "src/index.js", | ||
@@ -8,16 +8,23 @@ "bin": { | ||
}, | ||
"exports": { | ||
".": "./src/index.js", | ||
"./setup": "./src/setup.js" | ||
}, | ||
"license": "MIT", | ||
"dependencies": { | ||
"@lavamoat/preinstall-always-fail": "^1.0.0", | ||
"@lavamoat/aa": "^3.1.1", | ||
"@npmcli/run-script": "^1.8.1", | ||
"@yarnpkg/lockfile": "^1.1.0", | ||
"npm-logical-tree": "^1.2.1", | ||
"resolve": "^1.20.0", | ||
"semver": "^7.3.4", | ||
"bin-links": "4.0.1", | ||
"npm-normalize-package-bin": "^3.0.0", | ||
"yargs": "^16.2.0" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/LavaMoat/LavaMoat.git", | ||
"directory": "packages/allow-scripts" | ||
}, | ||
"publishConfig": { | ||
"access": "public" | ||
}, | ||
"description": "a tool for only running dependency lifecycle hooks specified in an allowlist", | ||
"description": "A tool for running only the dependency lifecycle hooks specified in an allowlist.", | ||
"directories": { | ||
@@ -27,14 +34,22 @@ "test": "test" | ||
"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" | ||
}, | ||
"scripts": { | ||
"test": "echo \"Error: no test specified\"", | ||
"lint": "npm run lint:eslint && npm run lint:deps", | ||
"lint:eslint": "eslint \"src/**/*.js\"", | ||
"lint:fix": "eslint src/**/*.js --fix", | ||
"test": "yarn test:run", | ||
"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 .", | ||
"lint:fix": "eslint . --fix", | ||
"lint:deps": "depcheck" | ||
}, | ||
"author": "", | ||
"homepage": "https://github.com/LavaMoat/LavaMoat/tree/main/packages/allow-scripts" | ||
"homepage": "https://github.com/LavaMoat/LavaMoat/tree/main/packages/allow-scripts", | ||
"engines": { | ||
"node": ">=14.0.0" | ||
}, | ||
"gitHead": "178db076d9a8dbd1c6b5c9eb6d3b3d7ebd06214b" | ||
} |
# @lavamoat/allow-scripts | ||
a tool for only running dependency lifecycle hooks specified in an allowlist | ||
A tool for running only the dependency lifecycle hooks specified in an _allowlist_. | ||
> For an overview of LavaMoat tools see [the main README](https://github.com/LavaMoat/LavaMoat/tree/main/README.md) | ||
### install | ||
add the package to start using it in your project. be sure to include the `@lavamoat/` namespace in the package name | ||
### Install | ||
Adds the package to start using it in your project. be sure to include the `@lavamoat/` namespace in the package name | ||
```sh | ||
yarn add -D @lavamoat/allow-scripts | ||
``` | ||
or | ||
```sh | ||
npm i -D @lavamoat/allow-scripts | ||
``` | ||
### configure | ||
### Setup | ||
automatically generate a configuration (that skips all lifecycle scripts) and write into `package.json`. edit as necesary. | ||
```sh | ||
yarn allow-scripts setup | ||
``` | ||
or | ||
```sh | ||
npx --no-install allow-scripts setup | ||
``` | ||
> **Warning** if @lavamoat/allow-scripts was not installed prior, npx will try to download and run allow-scripts (note no namespace prefix) which is a different package. We suggest adding --no-install to prevent accidents. | ||
Adds a `.yarnrc` or `.npmrc` (the latter if `package-lock.json` is present) to the package, populates this file with the line `ignore-scripts true`. Immediately after that, adds the dependency `@lavamoat/preinstall-always-fail`. | ||
Adding this package to a project **mitigates** the likelihood of accidentally running any lifecycle scripts by throwing an error during the `preinstall` script execution. | ||
### Configure | ||
Automatically generates and writes a configuration into `package.json`, setting new policies as `false` by default. Edit this file as necessary. | ||
```sh | ||
yarn allow-scripts auto | ||
``` | ||
or | ||
```sh | ||
npx --no-install allow-scripts auto | ||
``` | ||
configuration goes in `package.json` | ||
Configuration goes in `package.json` | ||
```json | ||
@@ -32,34 +62,40 @@ { | ||
### disable scripts | ||
> **Note** While you can configure all install scripts that you've been running to date as allowed, it's best to limit the number of them in case a package with pre-existing install script gets exploited. To figure out which packages' scripts can be ignored, try [can-i-ignore-scripts](https://www.npmjs.com/package/can-i-ignore-scripts) | ||
disable all scripts by default inside `.yarnrc` or `.npmrc` | ||
``` | ||
ignore-scripts true | ||
``` | ||
### Run | ||
consider adding [`@lavamoat/preinstall-always-fail`](../preinstall-always-fail) to ensure you never accidently run install scripts | ||
``` | ||
yarn add -D @lavamoat/preinstall-always-fail | ||
``` | ||
Run **all** lifecycle scripts for the packages specified in `package.json` | ||
### run | ||
run all lifecycle scripts for packages specified in `package.json` | ||
```sh | ||
yarn allow-scripts | ||
``` | ||
or | ||
```sh | ||
npx --no-install allow-scripts | ||
``` | ||
### debug | ||
This is a shorthand for `yarn/npx allow-scripts run`. | ||
prints comprehension of configuration and dependencies with lifecycle scripts | ||
It will fail if it detects dependencies which haven't been set up during [configuration](#Configure) of the package. You will be advised to run `yarn allow-scripts auto`. | ||
### Debug | ||
Prints comprehension of configuration and dependencies with lifecycle scripts, specifying _allowed_ and _disallowed_ packages. | ||
```sh | ||
yarn allow-scripts list | ||
``` | ||
or | ||
```sh | ||
npx --no-install allow-scripts list | ||
``` | ||
### workflow | ||
### Improving your Workflow | ||
consider adding a "setup" npm script for all your post-install steps. no magic here, this is just a regular script. but using this will ensure you run your allowed scripts. its also a good place to add other post-processing commands you use. In the future when you add additional post-processing scripts, e.g. [`patch-package`](https://github.com/ds300/patch-package), you can add them to this "setup" script. | ||
Consider adding a _setup_ npm script for all your post-install steps to ensure the running of your allowed scripts. This can be just a regular script (_no magic needed!_). Also, it is a good place to add other post-processing commands you want to use. | ||
you will need to make an effort to remember to run `yarn setup` instead of just `yarn` :lotus_position: | ||
In the future when you add additional post-processing scripts, e.g. [`patch-package`](https://www.npmjs.com/package/patch-package), you can add them to this _setup_ script. | ||
:thought_balloon: You will need to make an effort to remember to run `yarn setup` instead of just `yarn` :lotus_position: | ||
```json | ||
@@ -71,2 +107,16 @@ { | ||
} | ||
``` | ||
``` | ||
# 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,2 +5,4 @@ #!/usr/bin/env node | ||
const { runAllowedPackages, setDefaultConfiguration, printPackagesList } = require('./index.js') | ||
const { writeRcFile, editPackageJson } = require('./setup.js') | ||
const { FEATURE } = require('./toggles') | ||
@@ -17,2 +19,4 @@ start().catch((err) => { | ||
const command = parsedArgs.command || 'run' | ||
FEATURE.bins = parsedArgs.experimentalBins | ||
switch (command) { | ||
@@ -34,2 +38,7 @@ // (default) run scripts | ||
} | ||
case 'setup': { | ||
writeRcFile() | ||
editPackageJson() | ||
return | ||
} | ||
// (error) unrecognized | ||
@@ -46,3 +55,11 @@ default: { | ||
.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() | ||
@@ -52,8 +69,4 @@ | ||
parsedArgs.command = parsedArgs._[0] | ||
// resolve paths | ||
// parsedArgs.configPath = path.resolve(parsedArgs.configPath) | ||
// parsedArgs.configOverridePath = path.resolve(parsedArgs.configOverridePath) | ||
// parsedArgs.configDebugPath = path.resolve(parsedArgs.configDebugPath) | ||
return parsedArgs | ||
} |
664
src/index.js
@@ -0,40 +1,91 @@ | ||
// @ts-check | ||
// @ts-ignore: Object is possibly 'undefined'. | ||
const { promises: fs } = require('fs') | ||
const path = require('path') | ||
const { promisify } = require('util') | ||
const resolve = promisify(require('resolve')) | ||
const semver = require('semver') | ||
const logicalTree = require('npm-logical-tree') | ||
const yarnLockfileParser = require('@yarnpkg/lockfile') | ||
const npmRunScript = require('@npmcli/run-script') | ||
const yarnLogicalTree = require('./yarnLogicalTree') | ||
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 = { | ||
// primary | ||
getOptionsForBin, | ||
runAllowedPackages, | ||
setDefaultConfiguration, | ||
printPackagesList, | ||
// util | ||
loadTree, | ||
findAllFilePathsForTree, | ||
getAllowedScriptsConfig, | ||
parseYarnLockForPackages, | ||
getCanonicalNameInfo | ||
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) | ||
@@ -45,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') | ||
} | ||
@@ -68,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) { | ||
@@ -80,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({ | ||
@@ -97,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)]`) | ||
@@ -152,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)]`) | ||
@@ -164,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)]`) | ||
@@ -173,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)]`) | ||
@@ -182,211 +349,50 @@ }) | ||
function getAllowedScriptsConfig (packageJson) { | ||
const lavamoatConfig = packageJson.lavamoat || {} | ||
return lavamoatConfig.allowScripts || {} | ||
/** | ||
* | ||
* @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) | ||
} | ||
async function parseYarnLockForPackages () { | ||
const yarnLockfileContent = await fs.readFile('./yarn.lock', 'utf8') | ||
const { object: parsedLockFile } = yarnLockfileParser.parse(yarnLockfileContent) | ||
// parsedLockFile contains an entry from each range to resolved package, so we dedupe | ||
const uniquePackages = new Set(Object.values(parsedLockFile)) | ||
return Array.from(uniquePackages.values(), ({ resolved, version }) => { | ||
const { namespace, canonicalName } = getCanonicalNameInfo(resolved) | ||
return { resolved, version, namespace, canonicalName } | ||
}) | ||
} | ||
/** | ||
* | ||
* @param {Object} args | ||
* @param {string} args.rootDir | ||
* @returns {Promise<PkgConfs>} | ||
*/ | ||
async function loadAllPackageConfigurations({ rootDir }) { | ||
const packagesWithScriptsLifecycle = new Map() | ||
const binCandidates = new Map() | ||
function getCanonicalNameInfo (resolvedUrl) { | ||
const url = new URL(resolvedUrl) | ||
switch (url.host) { | ||
case 'registry.npmjs.org': { | ||
// eg: registry.npmjs.org:/@types/json5/-/json5-0.0.29.tgz | ||
const pathParts = url.pathname.split('/').slice(1) | ||
// support for namespaced packages | ||
const packageName = pathParts.slice(0, pathParts.indexOf('-')).join('/') | ||
return { | ||
namespace: 'npm', | ||
canonicalName: `${packageName}` | ||
} | ||
} | ||
case 'registry.yarnpkg.com': { | ||
const pathParts = url.pathname.split('/').slice(1) | ||
// support for namespaced packages | ||
const packageName = pathParts.slice(0, pathParts.indexOf('-')).join('/') | ||
return { | ||
namespace: 'npm', | ||
canonicalName: `${packageName}` | ||
} | ||
} | ||
case 'github.com': { | ||
// note: protocol may be "git+https" "git+ssh" or something else | ||
// eg: 'git+ssh://git@github.com/ethereumjs/ethereumjs-abi.git#1ce6a1d64235fabe2aaf827fd606def55693508f' | ||
const [, ownerName, repoRaw] = url.pathname.split('/') | ||
// remove final ".git" | ||
const repoName = repoRaw.split('.git').slice(0, -1).join('.git') | ||
return { | ||
namespace: 'github', | ||
canonicalName: `github:${ownerName}/${repoName}` | ||
} | ||
} | ||
case 'codeload.github.com': { | ||
// eg: https://codeload.github.com/LavaMoat/bad-idea-collection-non-canonical-keccak/tar.gz/d4718c405bd033928ebfedaca69f96c5d90ef4b0 | ||
const [, ownerName, repoName] = url.pathname.split('/') | ||
return { | ||
namespace: 'github', | ||
canonicalName: `github:${ownerName}/${repoName}` | ||
} | ||
} | ||
case '': { | ||
// "github" as protocol | ||
// 'eg: github:ipfs/webrtcsupport#0a7099ff04fd36227a32e16966dbb3cca7002378' | ||
if (url.protocol !== 'github:') { | ||
throw new Error(`failed to parse canonical name for url: "${url}"`) | ||
} | ||
const [ownerName, repoName] = url.pathname.split('/') | ||
return { | ||
namespace: 'github', | ||
canonicalName: `github:${ownerName}/${repoName}` | ||
} | ||
} | ||
default: { | ||
return { | ||
namespace: 'url', | ||
canonicalName: `${url.host}:${url.pathname}` | ||
} | ||
} | ||
} | ||
} | ||
async function loadTree ({ rootDir }) { | ||
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')) | ||
// attempt to load lock files | ||
let yarnLockfileContent | ||
let packageLockfileContent | ||
try { | ||
yarnLockfileContent = await fs.readFile(path.join(rootDir, 'yarn.lock'), 'utf8') | ||
} catch (err) { /* ignore error */ } | ||
try { | ||
packageLockfileContent = await fs.readFile(path.join(rootDir, 'package-lock.json'), 'utf8') | ||
} catch (err) { /* ignore error */ } | ||
if (yarnLockfileContent && packageLockfileContent) { | ||
console.warn('@lavamoat/allow-scripts - both yarn and npm lock files detected -- using yarn') | ||
packageLockfileContent = undefined | ||
} | ||
let tree | ||
if (yarnLockfileContent) { | ||
const { object: parsedLockFile } = yarnLockfileParser.parse(yarnLockfileContent) | ||
tree = yarnLogicalTree.loadTree(packageJson, parsedLockFile) | ||
// fix path (via address field) for yarn tree | ||
// TOOO: make parallel | ||
for await (const { node, filePath } of findAllFilePathsForTree(tree)) { | ||
// skip unresolved paths | ||
// TODO: document when/why this would be falsy | ||
if (!filePath) continue | ||
const relativePath = path.relative(rootDir, filePath) | ||
const address = relativePath.slice('node_modules/'.length).split('/node_modules/').join(':') | ||
node.address = address | ||
} | ||
} else if (packageLockfileContent) { | ||
const packageLock = JSON.parse(packageLockfileContent) | ||
tree = logicalTree(packageJson, packageLock) | ||
} else { | ||
throw new Error('@lavamoat/allow-scripts - unable to find lock file (yarn or npm)') | ||
} | ||
// TODO: validate tree (ensure nodes have addresses) | ||
const directDeps = new Set([...Object.keys(packageJson.devDependencies||{}),...Object.keys(packageJson.dependencies||{})]) | ||
return { tree, packageJson } | ||
} | ||
async function * findAllFilePathsForTree (tree) { | ||
const filePathCache = new Map() | ||
for (const { node, branch } of eachNodeInTree(tree)) { | ||
// my intention with yielding with a then is that it will be able to produce the | ||
// next iteration without waiting for the promise to resolve | ||
yield findFilePathForTreeNode(branch, filePathCache).then(filePath => { | ||
return { node, filePath } | ||
}) | ||
} | ||
} | ||
async function findFilePathForTreeNode (branch, filePathCache) { | ||
const currentNode = branch[branch.length - 1] | ||
let resolvedPath | ||
if (branch.length === 1) { | ||
// root package | ||
resolvedPath = process.cwd() | ||
} else { | ||
// dependency | ||
const parentNode = branch[branch.length - 2] | ||
const relativePath = filePathCache.get(parentNode) | ||
try { | ||
const packagePath = await resolve(`${currentNode.name}/package.json`, { basedir: relativePath }) | ||
resolvedPath = path.dirname(packagePath) | ||
} catch (err) { | ||
// error if not a resolution error | ||
if (err.code !== 'MODULE_NOT_FOUND') { | ||
throw err | ||
} | ||
// error if non-optional | ||
const branchIsOptional = branch.some(node => node.optional) | ||
if (!branchIsOptional) { | ||
throw new Error(`@lavamoat/allow-scripts - could not resolve non-optional package "${currentNode.name}" from "${relativePath}"`) | ||
} | ||
// otherwise ignore error | ||
} | ||
} | ||
filePathCache.set(currentNode, resolvedPath) | ||
return resolvedPath | ||
} | ||
function * eachNodeInTree (node, visited = new Set(), branch = []) { | ||
// visit each node only once | ||
if (visited.has(node)) return | ||
visited.add(node) | ||
// add self to branch | ||
branch.push(node) | ||
// visit | ||
yield { node, branch } | ||
// recurse | ||
for (const [, child] of node.dependencies) { | ||
yield * eachNodeInTree(child, visited, [...branch]) | ||
} | ||
} | ||
function getCanonicalNameInfoForTreeNode (node) { | ||
// node.resolved is only defined once in the tree for npm (?) | ||
if (node.resolved) { | ||
return getCanonicalNameInfo(node.resolved) | ||
} | ||
const validSemver = semver.validRange(node.version) | ||
if (validSemver) { | ||
return { | ||
namespace: 'npm', | ||
canonicalName: node.name | ||
} | ||
} else { | ||
return getCanonicalNameInfo(node.version) | ||
} | ||
} | ||
async function loadAllPackageConfigurations ({ rootDir }) { | ||
const { tree, packageJson } = await loadTree({ rootDir }) | ||
const packagesWithLifecycleScripts = new Map() | ||
for (const { node, branch } of eachNodeInTree(tree)) { | ||
// Skip root package | ||
if (branch.length === 1) continue | ||
const { canonicalName } = getCanonicalNameInfoForTreeNode(node) | ||
const nodePath = node.path() | ||
// TODO: follow symbolic links? I couldnt find any in my test repo, | ||
for (const [filePath, canonicalName] of sortedDepEntries) { | ||
// const canonicalName = getCanonicalNameForPath({ rootDir, filePath: filePath }) | ||
let depPackageJson | ||
try { | ||
depPackageJson = JSON.parse(await fs.readFile(path.resolve(nodePath, '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 | ||
@@ -398,35 +404,103 @@ } | ||
if (lifeCycleScripts.length) { | ||
const collection = packagesWithLifecycleScripts.get(canonicalName) || [] | ||
const collection = packagesWithScriptsLifecycle.get(canonicalName) || [] | ||
collection.push({ | ||
canonicalName, | ||
path: nodePath, | ||
scripts: depScripts | ||
path: filePath, | ||
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 allowScriptsConfig = getAllowedScriptsConfig(packageJson) | ||
const lavamoatConfig = packageJson.lavamoat || {} | ||
// packages with config | ||
const configuredPatterns = Object.keys(allowScriptsConfig) | ||
const configs = { | ||
lifecycle: indexLifecycleConfiguration({ | ||
packagesWithScripts: packagesWithScriptsLifecycle, | ||
allowConfig: lavamoatConfig.allowScripts, | ||
}), | ||
bin: indexBinsConfiguration({ | ||
binCandidates, | ||
allowConfig: lavamoatConfig.allowBins, | ||
}), | ||
} | ||
// 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 somePoliciesAreMissing = !!(configs.lifecycle.missingPolicies.length || configs.bin.somePoliciesAreMissing) | ||
return { | ||
tree, | ||
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) => { | ||
const aVal = getterFn(a) | ||
const bVal = getterFn(b) | ||
if (aVal > bVal) { | ||
return 1 | ||
} else if (aVal < bVal) { | ||
return -1 | ||
} else { | ||
return 0 | ||
} | ||
} | ||
} |
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 3 instances 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
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
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 2 instances in 1 package
Mixed license
License(Experimental) Package contains multiple licenses.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
Found 1 instance in 1 package
43994
92.54%5
-28.57%30
233.33%891
75.74%0
-100%121
72.86%4
100%1
Infinity%6
Infinity%8
300%3
Infinity%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed