Socket
Socket
Sign inDemoInstall

@lavamoat/allow-scripts

Package Overview
Dependencies
Maintainers
4
Versions
25
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 2.1.0 to 2.3.0

.eslintignore

23

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

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

@@ -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"
}
}
}
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc