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://user@registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'], ['userinfo — 合言葉だけ埋める', 'https://:pass@registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'], ['userinfo — 両方埋める', 'https://user:pass@registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'], ['userinfo — @ で別ホストへ逃がす', 'https://registry.npmjs.org@evil.example/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; }); });