Files
maestro/scripts/security/dependency-policy.mjs
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

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;
});
}