319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
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;
|
||
});
|
||
});
|