#!/usr/bin/env node import { readFile } from 'node:fs/promises'; import { posix as posixPath } from 'node:path'; import { pathToFileURL } from 'node:url'; export const TRUSTED_REGISTRY_ORIGIN = 'https://registry.npmjs.org/'; const DEFAULT_ALLOWLIST_PATH = 'security/install-scripts-allowlist.json'; export function isAllowedRegistryUrl(resolved, registryOrigin) { try { const url = new URL(resolved); const registry = new URL(registryOrigin); return ( url.protocol === registry.protocol && url.hostname === registry.hostname && url.port === registry.port && url.username === '' && url.password === '' && url.pathname.startsWith(registry.pathname) ); } catch { return false; } } // npm はリポジトリ内の workspace を、ダウンロード物ではなくリポジトリ相対パスの // キーで記録する(ルートの package-lock.json なら packages/foo、ui/ 配下からは // ../packages/foo)。レジストリから取得する依存は必ず node_modules/ 区間を含む。 function isWorkspaceSourcePath(name) { return !name.startsWith('node_modules/') && !name.includes('/node_modules/'); } /** * link の指す先が、リポジトリの内側に収まっているか。 * * この関数が在る理由 * ------------------ * link を「同じ lockfile にキーとして在るか」だけで通していた時期がある。その条件は * 歯止めにならない——lockfile を書ける側が link と実体キーの両方を書けるので、 * `node_modules/evil -> ../../../../tmp/payload` に同名のキーを併記するだけで、 * 取得元・ハッシュ・レジストリ判定のすべてを回避できた(PR #1074 のレビューで実証)。 * * そこで、lockfile の置き場所を基準に解決して、リポジトリルートより上へ出ないことを * 確かめる。`ui/package-lock.json` の `../packages/answer-worker-contract` は通り、 * それ以上さかのぼるものと絶対パスは止まる。 * * @param resolved lockfile に書かれた link の指す先(リポジトリ相対) * @param lockfileDir lockfile が置かれたディレクトリ(リポジトリルート基準・posix) */ export function isLinkInsideRepo(resolved, lockfileDir) { if (typeof resolved !== 'string' || resolved === '') return false; // 絶対パスと Windows のドライブ表記は、リポジトリ相対ではないので受け付けない。 if (posixPath.isAbsolute(resolved) || /^[A-Za-z]:/.test(resolved)) return false; const joined = posixPath.normalize(posixPath.join(lockfileDir, resolved)); return joined !== '..' && !joined.startsWith('../'); } export function checkLockfilePolicy( lockfile, allowlist, registryOrigin = TRUSTED_REGISTRY_ORIGIN, lockfileDir = '.', ) { if (lockfile?.lockfileVersion !== 3 || typeof lockfile.packages !== 'object') { throw new Error('dependency policy requires npm lockfile version 3 with a packages map'); } if (!Array.isArray(allowlist) || !allowlist.every((entry) => typeof entry === 'string')) { throw new Error('install script allowlist must be an array of lockfile package paths'); } const allowed = new Set(allowlist); const violations = []; for (const [name, entry] of Object.entries(lockfile.packages)) { if (!name || !entry || typeof entry !== 'object') continue; // link は同じ lockfile 内に実体キーがある場合だけ認める。実体を持たない link は // 追跡できない外部の入力を指すため、従来どおり拒否する。 // 実体キーが在ることに加えて、指す先がリポジトリの内側であることまで見る。 // 片方だけでは、lockfile を書ける側が両方を握って素通りできる。 const linkTargetIsInLockfile = entry.link === true && typeof entry.resolved === 'string' && Object.hasOwn(lockfile.packages, entry.resolved) && isLinkInsideRepo(entry.resolved, lockfileDir); const isWorkspaceSource = isWorkspaceSourcePath(name); if (entry.link && !linkTargetIsInLockfile) { violations.push({ code: 'unsupported-link', name }); } else if (!entry.link && !isWorkspaceSource && !entry.resolved) { violations.push({ code: 'missing-resolved', name }); } else if ( !entry.link && entry.resolved && !isAllowedRegistryUrl(entry.resolved, registryOrigin) ) { violations.push({ code: 'non-registry-url', name }); } // workspace の実体にも install script 検査は残す(多層防御)。 if (entry.hasInstallScript && !allowed.has(name)) { violations.push({ code: 'unapproved-install-script', name }); } // リポジトリ内 workspace はダウンロードしないので integrity を持たない。 if (!entry.link && !isWorkspaceSource && !entry.integrity) { violations.push({ code: 'missing-integrity', name }); } } // 許可一覧の腐敗検知。install script を持たなくなった依存が一覧に残ると、 // 将来その依存が再び script を持ったときに無審査で通ってしまう。 for (const path of allowlist) { const entry = lockfile.packages[path]; if (!entry || !entry.hasInstallScript) { violations.push({ code: 'stale-allowlist-entry', name: path }); } } return violations; } async function readJson(path) { return JSON.parse(await readFile(path, 'utf8')); } export async function main(args) { if (args.length === 0) { throw new Error('usage: dependency-policy.mjs [...]'); } const policy = await readJson(DEFAULT_ALLOWLIST_PATH); let failed = false; for (const lockfilePath of args) { const lockfile = await readJson(lockfilePath); const allowlist = policy.lockfiles?.[lockfilePath]; if (!Array.isArray(allowlist)) { throw new Error(`no install script allowlist is defined for ${lockfilePath}`); } const lockfileDir = posixPath.dirname(lockfilePath.split('\\').join('/')); const violations = checkLockfilePolicy(lockfile, allowlist, TRUSTED_REGISTRY_ORIGIN, lockfileDir); for (const violation of violations) { failed = true; process.stderr.write(`${lockfilePath}: ${violation.code}: ${violation.name}\n`); } } if (failed) process.exitCode = 1; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(process.argv.slice(2)).catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); }