diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index 050b0bd6..6217964a 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -6,6 +6,12 @@ on: permissions: contents: read +env: + # How each command below is invoked. Jobs override this when debug logging is + # enabled, so that commands run through the permission audit rather than + # directly, without every step needing a second copy of itself. + RUN: yarn + jobs: prepare: name: Prepare @@ -36,7 +42,11 @@ jobs: is-high-risk-environment: false persist-credentials: false node-version: ${{ matrix.node-version }} - - run: yarn build + - name: Enable permission audit for debug runs + if: ${{ runner.debug == '1' && matrix.node-version == '26.x' }} + run: echo 'RUN=yarn audit-script --verbose' >> "$GITHUB_ENV" + - name: Build + run: $RUN build - name: Require clean working directory shell: bash run: | @@ -59,13 +69,17 @@ jobs: is-high-risk-environment: false persist-credentials: false node-version: ${{ matrix.node-version }} - - run: yarn lint + - name: Enable permission audit for debug runs + if: ${{ runner.debug == '1' && matrix.node-version == '26.x' }} + run: echo 'RUN=yarn audit-script --verbose' >> "$GITHUB_ENV" + - name: Lint + run: $RUN lint - name: Validate RC changelog if: ${{ startsWith(github.head_ref, 'release/') }} - run: yarn lint:changelog --rc + run: $RUN lint:changelog --rc - name: Validate changelog if: ${{ !startsWith(github.head_ref, 'release/') }} - run: yarn lint:changelog + run: $RUN lint:changelog - name: Require clean working directory shell: bash run: | @@ -88,7 +102,11 @@ jobs: is-high-risk-environment: false persist-credentials: false node-version: ${{ matrix.node-version }} - - run: yarn test + - name: Enable permission audit for debug runs + if: ${{ runner.debug == '1' && matrix.node-version == '26.x' }} + run: echo 'RUN=yarn audit-script --verbose' >> "$GITHUB_ENV" + - name: Test + run: $RUN test - name: Require clean working directory shell: bash run: | @@ -113,7 +131,11 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install dependencies via Yarn run: rm yarn.lock && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn - - run: yarn test + - name: Enable permission audit for debug runs + if: ${{ runner.debug == '1' && matrix.node-version == '26.x' }} + run: echo 'RUN=yarn audit-script --verbose' >> "$GITHUB_ENV" + - name: Test + run: $RUN test - name: Restore lockfile run: git restore yarn.lock - name: Require clean working directory diff --git a/.nvmrc b/.nvmrc index b009dfb9..4806896a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/* +v26.9.0 diff --git a/package.json b/package.json index f1353546..52570a2c 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { + "audit-script": "node ./scripts/audit-script.ts", "build": "tsc --project tsconfig.build.json", "build:docs": "typedoc", "lint": "yarn lint:oxlint && yarn lint:constraints && yarn lint:misc --check && yarn lint:dependencies --check && yarn lint:changelog", @@ -52,7 +53,8 @@ "@metamask/oxlint-config-nodejs": "^0.1.1", "@metamask/oxlint-config-typescript": "^0.1.1", "@metamask/oxlint-config-vitest": "^0.1.1", - "@types/node": "^18.18", + "@types/node": "^26.6.2", + "@types/yargs": "^17.0.35", "@typescript/native": "npm:typescript@^7.0.2", "@vitest/coverage-istanbul": "^4.1.4", "@vitest/eslint-plugin": "^1.1.4", @@ -65,7 +67,8 @@ "typedoc": "^0.26.11", "typescript": "npm:@typescript/typescript6@^6.0.2", "vite": "^8.0.8", - "vitest": "^4.1.4" + "vitest": "^4.1.4", + "yargs": "^18.2.0" }, "resolutions": { "fflate": "0.8.2" @@ -88,6 +91,7 @@ }, "scriptsConfig": { "#default": "lavamoat/scripts.strict.json", + "audit-script": "lavamoat/scripts.yarn.json", "build": "lavamoat/scripts.build.json", "lint": "lavamoat/scripts.yarn.json", "lint:changelog": "lavamoat/scripts.lint.json", diff --git a/scripts/audit-script.ts b/scripts/audit-script.ts new file mode 100644 index 00000000..df630263 --- /dev/null +++ b/scripts/audit-script.ts @@ -0,0 +1,947 @@ +/* eslint-disable n/no-sync */ + +import { spawn } from 'node:child_process'; +import type { ChannelListener } from 'node:diagnostics_channel'; +import { + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +/** + * Permissions that are a plain on/off switch in the generated config, keyed by + * the permission name the audit channels report. + */ +const BOOLEAN_FLAGS = { + ChildProcess: '--allow-child-process', + Net: '--allow-net', + WorkerThreads: '--allow-worker', + Addon: '--allow-addons', + WASI: '--allow-wasi', + Inspector: '--allow-inspector', + FFI: '--allow-ffi', +}; + +/** + * Environment variables worth expressing paths in terms of, most specific + * first. Anything matching one of these keeps the config portable across + * machines and CI runners. + */ +const ENV_TOKENS = [ + 'GITHUB_STEP_SUMMARY', + 'GITHUB_OUTPUT', + 'GITHUB_ENV', + 'GITHUB_PATH', + 'GITHUB_WORKSPACE', + 'RUNNER_TEMP', + 'RUNNER_TOOL_CACHE', + 'XDG_CACHE_HOME', + 'XDG_CONFIG_HOME', + 'npm_config_cache', + 'YARN_CACHE_FOLDER', + 'TMPDIR', + 'HOME', +]; + +/** + * Write permissions that are added implicitly by `lavamoat/.runner-plugin.js`. + */ +const RUNNER_PROVIDED_WRITES = new Set(['$TMPDIR']); + +/** + * Files that module and workspace resolution probes for while walking from the + * project directory up to the file system root. The hits are almost all misses, + * but they are still audited, and granting each one would bake this machine's + * directory layout into the config. + */ +const RESOLUTION_MARKERS = new Set([ + // Module and workspace resolution. + '.git', + '.npmrc', + '.yarnrc.yml', + 'jsconfig.json', + 'lerna.json', + 'node_modules', + 'package-lock.json', + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'tsconfig.json', + 'yarn.lock', + // Tools that search for their own configuration the same way. + '.browserslistrc', + '.editorconfig', + 'browserslist', + 'browserslist.config.js', + 'browserslist.config.mjs', +]); + +const AUDIT_DIRECTORY = mkdtempSync(join(tmpdir(), 'permission-audit-')); +const AUDIT_LOG = join(AUDIT_DIRECTORY, 'permissions.ndjson'); + +/** + * macOS and Windows resolve paths case-insensitively, and tools like + * TypeScript probe with inconsistent casing. Comparing case-sensitively there + * would leak absolute machine paths into the config as unmatched outsiders. + */ +const USE_CASE_INSENSITIVE_PATHS = + process.platform === 'darwin' || process.platform === 'win32'; + +/** + * The command line options this script was invoked with. + */ +type Options = { + /** + * The `package.json` script to audit. + */ + scriptName: string; + + /** + * Anything this script does not recognise, handed to the audited script + * rather than interpreted here, so that auditing a command that takes + * arguments reads the same as running it: `audit test --coverage`. + */ + scriptArgs: string[]; + + /** + * Where to write the generated config, or `undefined` to write it to + * standard output. + */ + outPath: string | undefined; + + /** + * Whether to show the audited script's own output and explain how each grant + * was chosen, rather than printing the config alone. + */ + verbose: boolean; +}; + +/** + * A directory that absolute paths are rewritten relative to, turning a machine + * specific path into a portable grant. + * + * Matching is anchored at the start of the path, which is what makes the + * longest entry the most specific one: every token names a place to start + * from, never a segment that could appear part way along. + */ +type PortablePrefix = { + /** + * The absolute path to match, normalised for comparison. + */ + prefix: string; + + /** + * What to rewrite a matching path to, such as `./` or `$HOME`. + */ + token: string; +}; + +/** + * The payload published on a `node:permission-model:*` channel. Unlike + * {@link AuditRecord}, the permission arrives as a bare string, since the + * channel makes no guarantee about which names it may add. + */ +type PermissionMessage = { + /** + * Which permission was checked. + */ + permission: string; + + /** + * The resource it applied to, if any. + */ + resource: string; +}; + +/** + * A single permission grant, along with why it ended up that broad. + */ +type Grant = { + /** + * The grant as it will appear in the generated config. + */ + type: string; + + /** + * Where the path sits. + */ + location: 'project' | 'token' | 'ancestor' | 'outside'; +}; + +/** + * One permission check, as published by the audit channels and appended to the + * log by the collector. + */ +type AuditRecord = { + /** + * The process that exercised the permission. + */ + pid: string; + + /** + * That process's arguments, used to attribute the record when reporting. + */ + argv: string; + + /** + * Which permission was checked. + */ + permission: + | 'Addon' + | 'ChildProcess' + | 'FFI' + | 'FileSystemRead' + | 'FileSystemWrite' + | 'FileSystem' + | 'Inspector' + | 'Net' + | 'WorkerThreads' + | 'WASI'; + + /** + * The path, host, or command it applied to. Empty for the permissions that + * are a plain on/off switch, such as `WorkerThreads`. + */ + resource: string; +}; + +/** + * A grant that had to reach outside the project, kept so that a widened config + * is never silent about why it was widened. + */ +type Escape = { + /** + * Whether the grant was needed for reading or for writing. + */ + action: 'read' | 'write'; + + /** + * The grant that was emitted. + */ + grant: string; + + /** + * How many audited paths collapsed into this same grant. + */ + count: number; + + /** + * One of the paths responsible, shown as an example. + */ + cause: string; +}; + +/** + * What the audit records add up to: a description of everything the script + * actually did, before any of it is turned into a config. + */ +type Summary = { + /** + * The paths the script read, already generalised into portable grants. + */ + reads: Set; + + /** + * The paths the script wrote, already generalised into portable grants. + */ + writes: Set; + + /** + * The `--allow-*` flags for the permissions that are a plain on/off switch. + */ + flags: Set; + + /** + * The hosts the script connected to. Not expressible in the config, which + * treats networking as a single switch, but useful when reviewing it. + */ + hosts: Set; + + /** + * The commands the script spawned, useful for the same reason as the hosts. + */ + commands: Set; + + /** + * Every grant that reached outside the project, keyed by action and grant. + */ + escapes: Map; +}; + +/** + * A generated LavaMoat script config. + */ +type Config = { + /** + * How the config was produced and how far it can be trusted. + */ + notes: string; + + /** + * The flags to run the script with. + */ + nodeOptions: Record; +}; + +/** + * Normalise a path for comparison on the current platform. Length is + * preserved, so offsets taken from a comparison key still apply to the + * original path. + * + * @param path - The path to normalise. + * @returns The comparison key. + */ +function buildComparablePath(path: string): string { + return USE_CASE_INSENSITIVE_PATHS ? path.toLowerCase() : path; +} + +/** + * Compare two strings alphabetically. This is the same comparator `sort` uses + * by default. We define it here so that we can use it in places where we are + * using a more complex sorting strategy, and we need a default fallback. + * + * @param a - The first string. + * @param b - The second string. + * @returns A negative number if `a` sorts first, a positive number if `b` + * does, and zero if they are equal. + */ +function compareStrings(a: string, b: string): number { + if (a === b) { + return 0; + } + + return a < b ? -1 : 1; +} + +/** + * Resolve a path to its canonical form, tolerating paths that no longer exist. + * + * @param path - The path to resolve. + * @returns The canonical path, or the original on failure. + */ +function canonicalisePath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** + * Build the table used to rewrite absolute paths into portable tokens, longest + * first so the most specific prefix wins. + * + * @returns The prefixes, most specific first. + */ +function buildPortablePrefixes(): PortablePrefix[] { + const prefixes: PortablePrefix[] = []; + + /** + * Register a path and its canonical form under the same token. + * + * @param path - The path to register. + * @param token - The token to rewrite it to. + */ + const add = (path: string | undefined, token: string): void => { + if (!path || !isAbsolute(path)) { + return; + } + + for (const variant of new Set([resolve(path), canonicalisePath(path)])) { + prefixes.push({ prefix: buildComparablePath(variant), token }); + } + }; + + // The project directory has to win over `$HOME`, which contains it. Ordering + // by length below takes care of that. + add(process.cwd(), './'); + + for (const name of ENV_TOKENS) { + // eslint-disable-next-line n/no-process-env + add(process.env[name], `$${name}`); + } + + add(tmpdir(), '$TMPDIR'); + add(homedir(), '$HOME'); + + return prefixes.sort((a, b) => b.prefix.length - a.prefix.length); +} + +const PORTABLE_PREFIXES = buildPortablePrefixes(); + +/** + * Build the set of directories between the project and the filesystem root. + * + * @param projectPath - The path to the project. + * @returns Every strict ancestor of the project directory. + */ +function buildProjectAncestors(projectPath: string): Set { + const ancestors = new Set(); + + let currentDirectory = projectPath; + let parentDirectory = dirname(currentDirectory); + + while (parentDirectory !== currentDirectory) { + ancestors + .add(buildComparablePath(parentDirectory)) + .add(buildComparablePath(canonicalisePath(parentDirectory))); + currentDirectory = parentDirectory; + parentDirectory = dirname(currentDirectory); + } + + return ancestors; +} + +const PROJECT_ANCESTORS = buildProjectAncestors(process.cwd()); + +/** + * Decide whether a path was read only because a tool searched upwards from the + * project for a dependency or a configuration file. + * + * @param target - The resolved path from an audit record. + * @returns True if the path is a by-product of such a search. + */ +function isAncestorSearchPath(target: string): boolean { + const key = buildComparablePath(target); + + if (PROJECT_ANCESTORS.has(key)) { + return true; + } + + if ( + PROJECT_ANCESTORS.has(buildComparablePath(dirname(target))) && + RESOLUTION_MARKERS.has(buildComparablePath(basename(target))) + ) { + return true; + } + + // Node walks every ancestor's `node_modules` looking for a dependency, so + // those hits describe the machine's directory layout rather than the script. + for (const ancestor of PROJECT_ANCESTORS) { + const modules = join(ancestor, 'node_modules'); + if (key === modules || key.startsWith(modules + sep)) { + return true; + } + } + + return false; +} + +/** + * Rewrite an absolute path into a portable, generalised grant. + * + * @param path - The absolute path from an audit record. + * @param action - Whether the path was read or written. Resolution only ever + * reads, so a write to one of those paths is a genuine write and must not be + * widened to the whole file system. + * @returns The classified grant, or undefined if the path is unusable. + */ +function tryCreatingGrant( + path: string, + action: 'read' | 'write', +): Grant | undefined { + if (!path || !isAbsolute(path)) { + return undefined; + } + + const target = resolve(path); + + if (action === 'read' && isAncestorSearchPath(target)) { + return { type: '/', location: 'ancestor' }; + } + + const key = buildComparablePath(target); + for (const { prefix, token } of PORTABLE_PREFIXES) { + if (key !== prefix && !key.startsWith(prefix + sep)) { + continue; + } + + if (token === './') { + return { type: './', location: 'project' }; + } + + return { type: token, location: 'token' }; + } + + // Outside anything recognisable, grant the top-level directory rather than a + // machine-specific path. + // + // On Windows this drops the drive, so `C:\Windows` becomes `/Windows`. That + // is knowingly left alone: keeping the drive would put a path in the config + // that means nothing on any other machine, and Windows has no portable + // top-level namespace to rewrite it to. Such a grant needs a human anyway, + // and it is reported as an escape so that it gets one. + const [, top] = target.split(sep); + return { type: top ? `/${top}` : '/', location: 'outside' }; +} + +/** + * Collect the permissions that a script accesses via Node's diagnostics + * channel, writing them to the log named by `PERMISSION_AUDIT_LOG`. + * + * Note that this function does not run in this process, but the process + * responsible for auditing permissions (see {@link runScript}). + */ +async function collect(): Promise { + // eslint-disable-next-line n/no-process-env + const logPath = process.env.PERMISSION_AUDIT_LOG; + + // Without a log path there is nothing to report to, so stay out of the way + // entirely rather than half-instrumenting the process. + if (!logPath) { + return; + } + + // Because this function runs in a separate process, it cannot reference + // imports outside of itself. + const { channel } = await import('node:diagnostics_channel'); + const { closeSync, openSync, writeSync } = await import('node:fs'); + + const fd = openSync(logPath, 'a'); + + // The collector's own bookkeeping shows up on the very channels it listens + // to. Drop those events so the report describes the audited script, not the + // auditor. + const ignored = new Set([logPath]); + + let writing = false; + + const listener: ChannelListener = (message: unknown): void => { + const { permission, resource } = message as PermissionMessage; + if (writing || ignored.has(resource)) { + return; + } + + writing = true; + try { + // O_APPEND writes below PIPE_BUF are atomic, so sibling processes can + // share this file without locking. Synchronous keeps the record durable + // even if the process dies before `exit` handlers run. + writeSync( + fd, + `${JSON.stringify({ + pid: process.pid, + argv: process.argv.slice(1), + permission, + resource, + })}\n`, + ); + } catch { + // A broken audit log must never take down the audited script. + } finally { + writing = false; + } + }; + + for (const name of [ + 'fs', + 'net', + 'child', + 'worker', + 'inspector', + 'wasi', + 'addon', + 'ffi', + ]) { + channel(`node:permission-model:${name}`).subscribe(listener); + } + + process.on('exit', () => { + closeSync(fd); + }); +} + +/** + * Holds a mini-script that simply runs the `collect` function. This script is + * loaded alongside the script that we want to audit so that we can collect + * its accessed permissions (see {@link runScript}). + */ +const COLLECTOR_URL = `data:text/javascript,${encodeURIComponent( + `await (${collect.toString()})()`, +)}`; + +/** + * Run the audited script and resolve with its exit status. + * + * @param scriptName - The name of the script to run. + * @param scriptArgs - Arguments to hand to the script. + * @param verbose - Whether to include verbose output. + * @param logPath - Where the collector should append records. + * @returns The exit code. + */ +async function runScript( + scriptName: string, + scriptArgs: string[], + verbose: boolean, + logPath: string, +): Promise { + return new Promise((resolveRun) => { + const flags = [ + '--permission-audit', + '--disable-warning=SecurityWarning', + `--import ${COLLECTOR_URL}`, + ]; + + // `node --run` forwards everything after its own `--` to the script, so + // auditing a command with arguments works the same as running it. + const args = ['--run', scriptName]; + if (scriptArgs.length > 0) { + args.push('--', ...scriptArgs); + } + + const child = spawn('node', args, { + // The audited script's output is noise next to the generated config, so + // it is discarded unless it was asked for. Inheriting hands over this + // process's own descriptors, so the script's output keeps its place in + // the stream rather than arriving through a second pipe that a reader + // would have to interleave. Nothing is buffered either, so a chatty + // script cannot blow up memory here. + stdio: verbose ? 'inherit' : 'ignore', + env: { + // eslint-disable-next-line n/no-process-env + ...process.env, + NODE_OPTIONS: flags.join(' '), + + // The `collect` function (which we are loading alongside the script + // we want to audit) uses this log file to capture detected permissions. + PERMISSION_AUDIT_LOG: logPath, + }, + }); + + child.on('close', (code, signal) => resolveRun(signal ? 1 : (code ?? 0))); + }); +} + +/** + * Read every record written during a pass. + * + * @param logPath - The log to read. + * @returns The parsed records. + */ +function readAuditLog(logPath: string): AuditRecord[] { + let contents; + try { + contents = readFileSync(logPath, 'utf8'); + } catch { + return []; + } + + return contents + .split('\n') + .filter(Boolean) + .flatMap((line) => { + // A process killed mid-write can leave a partial final line behind. + try { + return [JSON.parse(line) as AuditRecord]; + } catch { + return []; + } + }); +} + +/** + * Collapse a set of grants, dropping entries made redundant by a broader one. + * + * @param grants - The grants to collapse. + * @returns The sorted, collapsed grants. + */ +function collapse(grants: Set): string[] { + if (grants.has('/')) { + return ['/']; + } + + // Prefer the project-relative grant first; it is the one reviewers care about. + const sorted = [...grants].sort((a, b) => { + if (a === './') { + return -1; + } + + if (b === './') { + return 1; + } + + return compareStrings(a, b); + }); + + return sorted.filter( + (grant) => + !sorted.some((other) => other !== grant && grant.startsWith(`${other}/`)), + ); +} + +/** + * Parse the command line arguments. + * + * @returns The options the script was invoked with. + */ +async function parseArgv(): Promise { + const argv = await yargs(hideBin(process.argv)) + // Anything this script does not recognise belongs to the script being + // audited, so auditing a command reads the same as running it. + .parserConfiguration({ 'unknown-options-as-args': true }) + .command('$0