-
-
Notifications
You must be signed in to change notification settings - Fork 18
chore: apply @lavamoat/harden defaults
#335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Mrtenz
wants to merge
5
commits into
main
Choose a base branch
from
mrtenz/harden
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ad5734f
Apply `@lavamoat/harden` defaults
Mrtenz 35c727f
Remove `@lavamoat/harden` dependency
Mrtenz acadae8
Remove old `allow-scripts` plugin
Mrtenz 22188d6
Ignore LavaMoat folder in ESLint
Mrtenz e5463e9
Add `@yarnpkg/shell` to depcheck ignores
Mrtenz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ["SESSION", "SSH", "KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| }, | ||
| }, | ||
| } | ||
| } | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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-pluginneeds to start with a dot, since it's already inside a separatelavamoatfolder. But either way is fine with me.