Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .depcheckrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@vitest/coverage-istanbul",
"@vitest/eslint-plugin",
"@yarnpkg/types",
"@yarnpkg/shell",
"eslint-config-*",
"eslint-import-resolver-typescript",
"eslint-plugin-*",
Expand Down
9 changes: 0 additions & 9 deletions .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs

This file was deleted.

5 changes: 3 additions & 2 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ npmPreapprovedPackages:
- '@metamask-previews/*'
- '@lavamoat/*'

# Protect the runtime of calls to "yarn run" scripts using a local plugin.
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs
spec: 'https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js'
- path: ./lavamoat/plugin-allow-scripts.js
- path: ./lavamoat/.runner-plugin.js
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import vitest from '@metamask/eslint-config-vitest';

const config = createConfig([
{
ignores: ['dist/', 'docs/', '.yarn/'],
ignores: ['dist/', 'docs/', '.yarn/', 'lavamoat/'],
},

{
Expand Down
1 change: 1 addition & 0 deletions lavamoat/.env.ban.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["SESSION", "SSH", "KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH"]
315 changes: 315 additions & 0 deletions lavamoat/.runner-plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
/**
* Yarn 4 plugin for script environment hardening.
*
* @module
*/

/** @typedef {NonNullable<import('@yarnpkg/core').Hooks['wrapScriptExecution']>} WrapScriptExecutionHook */

/* global makeRunScriptWrapper */

/**
* Yarn 4 plugin factory for wrapScriptExecution hook.
*
* @param {any} require - Yarn's require function
* @returns {{ hooks: { wrapScriptExecution: Function } }}
*/
module.exports = {
name: '@yarnpkg/plugin-runner',
factory: function (/** @type {NodeJS.Require} */ require) {
return {
hooks: {
/** @type {WrapScriptExecutionHook} */
wrapScriptExecution: async (
executor,
project,
locator,
scriptName,
extra,
) => {
const path = require('node:path');
const fs = require('node:fs');
const workspace = project.tryWorkspaceByLocator(locator);

if (!workspace) {
// a script is being executed outside of a workspace context, so we can't apply any custom logic
// This is the case when a postinstal is running.

// "Do nothing" - return the original executor immediately
// without running any custom plugin logic or reading manifests.
// TODO: implement wrapping these scripts with reasonable defaults
return executor;
}

const pkgJson = workspace.manifest.raw;
const binFolder =
extra.env.BERRY_BIN_FOLDER || `node_modules${path.sep}.bin`;

const wrapper = makeRunScriptWrapper(
{
scriptName,
scriptPayload: extra.script,
projectRoot: extra.cwd,
pathBinMatcher: (fragment) => {
return fragment.endsWith(binFolder);
},
customizePermissionsConfig: addMandatoryReads,
readScriptsConfig: () => {
return pkgJson.scriptsConfig;
},
},
{
readFileSync: fs.readFileSync,
pathJoin: path.join,
pathDelimiter: path.delimiter,
},
);

const newEnv = wrapper.processEnv(extra.env);
for (const key of Object.keys(extra.env)) {
delete extra.env[key];
}
Object.assign(extra.env, newEnv);
return executor;
},
},
};
},
};

/**
* @param {Record<string, boolean | string | string[]>} configOptions
* @param {NodeJS.ProcessEnv} _env
*/
function addMandatoryReads(configOptions, _env) {
if (!configOptions['--permission']) {
return;
}
if (!Array.isArray(configOptions['--allow-fs-read'])) {
configOptions['--allow-fs-read'] = [];
}
if (!Array.isArray(configOptions['--allow-fs-write'])) {
configOptions['--allow-fs-write'] = [];
}

// figure out the /tmp dir for the current platform

const tmpdir =
process.platform === 'win32'
? process.env.TEMP || '' // make typescript shut up
: '/tmp';
// yarn script execution makes heavy use of temporary dirs
configOptions['--allow-fs-read'].push(tmpdir);
configOptions['--allow-fs-write'].push(tmpdir);
}
/// <reference path="./makeRunScriptWrapper.global.d.ts" />

/**
* @typedef {Record<string, boolean | string | string[]>} ConfigOptions
*/

/**
* @param {MakeRunScriptWrapperOptions} param0
* @param {MakeRunScriptWrapperIO} param1
* @returns {MakeRunScriptWrapper}
*/
function makeRunScriptWrapper(
{
scriptName,
scriptPayload: _scriptPayload, // might be useful to read in the future
projectRoot,
pathBinMatcher,
customizePermissionsConfig,
readScriptsConfig,
},
{ readFileSync, pathJoin, pathDelimiter },
) {
const DEFAULT_PERMISSION_KEY = '#default';

/** @param {string} filePath */
function readJsonFile(filePath) {
return JSON.parse(readFileSync(filePath, 'utf8'));
}

/**
* @param {object} opts
* @param {Record<string, string> | undefined} opts.scriptsConfig
* @param {string} [opts.scriptName]
* @param {string} opts.projectRoot
*/
function readConfig({
scriptsConfig,
scriptName = DEFAULT_PERMISSION_KEY,
projectRoot,
}) {
if (!scriptsConfig) {
return {};
}
const configName =
scriptsConfig[scriptName] || scriptsConfig[DEFAULT_PERMISSION_KEY];

// config needs to be optional, because it's opt-in first and specifying a default turns it opt-out.
if (!configName) {
return {};
}
const configPath = pathJoin(projectRoot, configName);
let conf;
try {
conf = readJsonFile(configPath);
if (typeof conf !== 'object' || conf === null) {
throw Error(`Expected an object, got ${typeof conf}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw Error(
`[LavaMoat] Error loading script config file "${configPath}": ${message}`,
{ cause: err },
);
}
return conf;
}

/** @param {ConfigOptions} configOptions */
function makeFlagsFromConfig(configOptions) {
return Object.entries(configOptions)
.map(([arg, value]) => {
if (typeof value === 'boolean') {
return value ? arg : '';
} else if (Array.isArray(value)) {
return value.map((v) => `${arg}="${v}"`).join(' ');
} else {
return `${arg}="${value}"`;
}
})
.filter(Boolean)
.join(' ');
}

/**
* Checks the config obtained from package.json and puts it in as NODE_OPTIONS
*
* @param {string | undefined} existingOptions
* @param {ConfigOptions} configOptions
* @param {NodeJS.ProcessEnv} env
*/
function installNodeOptions(existingOptions, configOptions, env) {
if (!configOptions) {
return existingOptions || '';
}

customizePermissionsConfig(configOptions, env);

const confOption = makeFlagsFromConfig(configOptions);

return `${existingOptions || ''} ${confOption.trim()}`.trim();
}

/**
* Filter environment variables based on ban keywords
*
* @param {NodeJS.ProcessEnv} env
* @param {string} lavamoatDir
* @returns {NodeJS.ProcessEnv}
*/
function filterEnv(env, lavamoatDir) {
const banFilePath = pathJoin(lavamoatDir, '.env.ban.json');
let banConfig;

try {
banConfig = readJsonFile(banFilePath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
`[LavaMoat] Warning: Failed to read .env.ban.json: ${message}.`,
);
return env;
}
let banKeywords = [];
try {
if (Array.isArray(banConfig)) {
banKeywords.push(...banConfig);
} else {
throw Error(`Expected .env.ban.json to contain an array of keywords`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
`[LavaMoat] Warning: Failed to read .env.ban.json: ${message}.`,
);
return env;
}

/** @type {NodeJS.ProcessEnv} */
const filteredEnv = {};
const bannedEnv = [];
banKeywords = banKeywords.map((keyword) => keyword.toLowerCase());
for (const [key, value] of Object.entries(env)) {
if (
!key.toLowerCase().startsWith('npm_config') &&
banKeywords.some((keyword) => key.toLowerCase().includes(keyword))
) {
bannedEnv.push(key);
} else {
filteredEnv[key] = value;
}
}
if (bannedEnv.length > 0) {
console.error(
`[LavaMoat] Warning: The following environment variables were banned: ${bannedEnv.join(', ')}`,
);
}
return filteredEnv;
}

/** @param {string} PATH */
function envPathOpinions(PATH) {
const pathFragments = PATH.split(pathDelimiter);
// This is to eliminate bin confusion attacks.
// Find node_modules/.bin and remove it, put it on the end that gets looked up last when looking for a name in the path.

/** @type {string[]} */
const filteredFragments = [];
/** @type {string[]} */
const nodeModulesBinFragments = [];

for (const fragment of pathFragments) {
if (pathBinMatcher(fragment)) {
nodeModulesBinFragments.push(fragment);
} else {
filteredFragments.push(fragment);
}
}
// Why would there be multiple bin fragments? In a npm workspace, local bin and workspace root bin is added
filteredFragments.push(...nodeModulesBinFragments);
return filteredFragments.join(pathDelimiter);
}

return {
processEnv: (existingEnv) => {
const scriptsConfig = readScriptsConfig(projectRoot);
const config = readConfig({
scriptsConfig,
scriptName,
projectRoot,
});

// Smell: Windows environment variables are case-insensitive, but Node's process.env
// might expose it as 'Path' instead of 'PATH'. Checking both ensures it doesn't get wiped out.

const existingPath = existingEnv.PATH || existingEnv.Path || '';

const lavamoatDir = pathJoin(projectRoot, 'lavamoat');

const fixedEnv = {
...filterEnv(existingEnv, lavamoatDir),
PATH: envPathOpinions(existingPath),
NODE_OPTIONS: installNodeOptions(
existingEnv.NODE_OPTIONS,
config.nodeOptions,
existingEnv,
),
};
return fixedEnv;
},
};
}
17 changes: 17 additions & 0 deletions lavamoat/plugin-allow-scripts.js

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you prefer for this file to also strt with a dot? I'd consider it an inconsistency on my side.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think runner-plugin needs to start with a dot, since it's already inside a separate lavamoat folder. But either way is fine with me.

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//prettier-ignore
module.exports = {
name: "@yarnpkg/plugin-allow-scripts",
factory: function (/** @type {(arg0: string) => { execute: any; }} */ require) {
const { execute } = require(`@yarnpkg/shell`);
return {
hooks: {
afterAllInstalled: async () => {
const exitCode = await execute('yarn run allow-scripts')
if (exitCode !== 0) {
process.exit(exitCode)
}
},
},
}
}
};
14 changes: 14 additions & 0 deletions lavamoat/scripts.loose.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"notes": "Should prevent basic malicious behavior of writing to locations outside the project and sending network requests, but not break any of the advanced packages in use. Everything below the net permission can be used to bypass the restrictions by a more sophisticated attacker.",
"nodeOptions": {
"--permission": true,
"--allow-fs-read": ["/"],
"--allow-fs-write": ["./"],
"--allow-net": false,
"--allow-child-process": true,
"--allow-worker": true,
"--allow-addons": true,
"--allow-wasi": true,
"--allow-inspector": false
}
}
Loading
Loading