New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

@lavamoat/allow-scripts

Package Overview
Dependencies
Maintainers
4
Versions
27
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@lavamoat/allow-scripts - npm Package Compare versions

Comparing version

to
2.3.0

.eslintignore

41

package.json
{
"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
}

@@ -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
}
}
}