160 lines
6.5 KiB
JavaScript
160 lines
6.5 KiB
JavaScript
#!/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 <package-lock.json> [...]');
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|