Files
maestro/scripts/security/dependency-policy.test.mjs
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

319 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { checkLockfilePolicy, isAllowedRegistryUrl, main } from './dependency-policy.mjs';
const registryOrigin = 'https://registry.npmjs.org/';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const readJson = (relative) =>
JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
describe('checkLockfilePolicy', () => {
it('rejects remote tarballs, missing integrity, and unapproved install scripts', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': {
resolved: 'https://evil.example/evil-1.0.0.tgz',
hasInstallScript: true,
},
},
},
[],
registryOrigin,
);
expect(violations).toEqual([
{ code: 'non-registry-url', name: 'node_modules/evil' },
{ code: 'unapproved-install-script', name: 'node_modules/evil' },
{ code: 'missing-integrity', name: 'node_modules/evil' },
]);
});
it('accepts an exact lockfile path that is explicitly allowlisted', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/native-package': {
resolved: 'https://registry.npmjs.org/native-package/-/native-package-1.0.0.tgz',
integrity: 'sha512-example',
hasInstallScript: true,
},
},
},
['node_modules/native-package'],
registryOrigin,
);
expect(violations).toEqual([]);
});
it('does not let a package-name allowlist approve a nested copy implicitly', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/parent/node_modules/native-package': {
resolved: 'https://registry.npmjs.org/native-package/-/native-package-2.0.0.tgz',
integrity: 'sha512-example',
hasInstallScript: true,
},
},
},
['node_modules/native-package'],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'unapproved-install-script',
name: 'node_modules/parent/node_modules/native-package',
});
});
it('rejects unsupported lockfile formats instead of silently passing', () => {
expect(() =>
checkLockfilePolicy({ lockfileVersion: 2, packages: {} }, [], registryOrigin),
).toThrow(/lockfile version 3/i);
});
});
describe('this repository', () => {
// 許可一覧を実物の lockfile と突き合わせる。install script を持つ依存が増えた/
// 場所が変わった/一覧から消えた場合、CI の前に npm test で落ちる。
it.each(['package-lock.json', 'ui/package-lock.json'])(
'satisfies the install script policy for %s',
(lockfilePath) => {
const policy = readJson('security/install-scripts-allowlist.json');
// link の判定は lockfile の置き場所を基準に解決する。CI の入口
// (dependency-policy.mjs の main)と同じ値を渡さないと、ui/ の
// ../packages/... が「リポジトリ外」と判定されてしまう。
const lockfileDir = path.posix.dirname(lockfilePath);
const violations = checkLockfilePolicy(
readJson(lockfilePath),
policy.lockfiles[lockfilePath],
registryOrigin,
lockfileDir,
);
expect(violations).toEqual([]);
},
);
});
describe('in-repo workspace entries', () => {
const workspaceLockfile = {
lockfileVersion: 3,
packages: {
'node_modules/@scope/contract': { resolved: 'packages/contract', link: true },
'packages/contract': { name: '@scope/contract', version: '0.0.0' },
},
};
it('accepts a link whose target is a workspace source in the same lockfile', () => {
expect(checkLockfilePolicy(workspaceLockfile, [], registryOrigin)).toEqual([]);
});
it('accepts a parent-relative workspace path as npm writes it from a sub-package', () => {
// ui/package-lock.json から見た ../packages/... の形。lockfile が ui/ に在るので
// 1 つ上へ戻ってもリポジトリの内側に収まる。
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/@scope/contract': { resolved: '../packages/contract', link: true },
'../packages/contract': { name: '@scope/contract', version: '0.0.0' },
},
},
[],
registryOrigin,
'ui',
);
expect(violations).toEqual([]);
});
// ここから 3 件は、実体キーを併記すれば外部を指す link が素通りした穴を塞ぐもの
// (PR #1074 のレビューが実証)。lockfile を書ける側が link と実体キーの両方を
// 握れるので、「同じ lockfile に在るか」だけでは歯止めにならない。
it('rejects a link that escapes the repository even when its target key is present', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': { resolved: '../../../../tmp/evil-payload', link: true },
'../../../../tmp/evil-payload': { name: 'evil', version: '1.0.0' },
},
},
[],
registryOrigin,
'.',
);
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
});
it('rejects an absolute link target even when its key is present', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': { resolved: '/etc/evil', link: true },
'/etc/evil': { name: 'evil', version: '1.0.0' },
},
},
[],
registryOrigin,
'.',
);
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
});
it('rejects a sub-package link that climbs past the repository root', () => {
// ui/ から 2 つ上がればリポジトリの外。1 つまでなら正当(上のテスト)。
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': { resolved: '../../outside/evil', link: true },
'../../outside/evil': { name: 'evil', version: '1.0.0' },
},
},
[],
registryOrigin,
'ui',
);
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
});
it('still rejects a link whose target is absent from the lockfile', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/@scope/contract': { resolved: '../../outside/contract', link: true },
},
},
[],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'unsupported-link',
name: 'node_modules/@scope/contract',
});
});
it('still rejects an install script on a workspace source that is not allowlisted', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'packages/contract': { name: '@scope/contract', version: '0.0.0', hasInstallScript: true },
},
},
[],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'unapproved-install-script',
name: 'packages/contract',
});
});
});
describe('allowlist rot', () => {
it('reports an allowlisted path that no longer exists in the lockfile', () => {
const violations = checkLockfilePolicy(
{ lockfileVersion: 3, packages: {} },
['node_modules/removed'],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'stale-allowlist-entry',
name: 'node_modules/removed',
});
});
it('reports an allowlisted package that no longer has an install script', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/calm': {
resolved: 'https://registry.npmjs.org/calm/-/calm-1.0.0.tgz',
integrity: 'sha512-example',
},
},
},
['node_modules/calm'],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'stale-allowlist-entry',
name: 'node_modules/calm',
});
});
});
// レジストリ判定は 6 つの比較でできている。PR #1074 のレビューが、そのうち
// 4 つ(protocol / port / userinfo / pathname)をそれぞれ削っても 35 件すべて
// 緑のままだと報告した。効いていたのは hostname 一致だけ。判定そのものは
// 正しく動いているので、守るものが無いという指摘。1 つずつ固定する。
describe('registry url check — each comparison earns its keep', () => {
const ok = 'https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz';
it('accepts the real registry', () => {
expect(isAllowedRegistryUrl(ok, registryOrigin)).toBe(true);
});
it.each([
['protocol — 平文に落とす', 'http://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'],
['port — 別のポートへ向ける', 'https://registry.npmjs.org:8443/left-pad/-/left-pad-1.3.0.tgz'],
['userinfo — 利用者名だけ埋める', 'https://[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
['userinfo — 合言葉だけ埋める', 'https://:[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
['userinfo — 両方埋める', 'https://user:[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
['userinfo — @ で別ホストへ逃がす', 'https://[email protected]/left-pad.tgz'],
['hostname — 似た名前の別ホスト', 'https://registry.npmjs.org.evil.example/left-pad.tgz'],
['hostname — 部分一致を狙う', 'https://evil-registry.npmjs.org.example/left-pad.tgz'],
])('rejects %s', (_label, url) => {
expect(isAllowedRegistryUrl(url, registryOrigin)).toBe(false);
});
it('rejects a registry origin with a path prefix when the url leaves it', () => {
// pathname 比較を消すと、別のパス配下でも通ってしまう
expect(isAllowedRegistryUrl('https://example.test/other/x.tgz', 'https://example.test/scoped/')).toBe(false);
expect(isAllowedRegistryUrl('https://example.test/scoped/x.tgz', 'https://example.test/scoped/')).toBe(true);
});
});
describe('the entry point CI actually runs', () => {
// main は CI が叩く唯一の入口なのに、どのテストからも呼ばれていなかった
// (PR #1074 のレビュー P2-2)。純関数だけ固めて配線が無テスト、の典型。
it('throws when a lockfile has no allowlist defined', async () => {
await expect(main(['no-such-lockfile-in-the-policy.json'])).rejects.toThrow();
});
it('requires at least one lockfile path', async () => {
await expect(main([])).rejects.toThrow(/usage/);
});
it('leaves the exit code alone when this repository passes', async () => {
const before = process.exitCode;
process.exitCode = 0;
await main(['package-lock.json', 'ui/package-lock.json']);
expect(process.exitCode).toBe(0);
process.exitCode = before;
});
});