import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { checkContainerPolicy, checkDockerfileInstallPolicy, checkPythonRequirements, } from './container-policy.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); describe('container supply-chain policy', () => { it('rejects base images that are not pinned by digest', () => { expect(checkContainerPolicy('FROM node:22-alpine')).toContainEqual( expect.objectContaining({ code: 'unpinned-image' }), ); }); it('accepts base images pinned by tag and digest', () => { expect( checkContainerPolicy( `FROM node:22-alpine@sha256:${'a'.repeat(64)} AS builder`, ), ).toEqual([]); }); it.each([ ['pypdf', 'unpinned-python-dependency'], ['pymupdf>=1.24', 'unpinned-python-dependency'], ['httpx~=0.28.0', 'unpinned-python-dependency'], ['PyYAML==6.0.*', 'unpinned-python-dependency'], ['package @ https://example.invalid/package.whl', 'non-index-python-dependency'], ])('rejects unsafe requirement %s', (requirement, code) => { expect(checkPythonRequirements(requirement)).toContainEqual( expect.objectContaining({ code }), ); }); it('accepts an exact index version pin', () => { expect(checkPythonRequirements('PyYAML==6.0.3 # YAML support')).toEqual([]); }); it('accepts every external input in the production container files', () => { const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8'); const requirementFiles = [ 'runtime/python-requirements.txt', 'scripts/x-adapter/requirements.txt', ]; expect(checkContainerPolicy(dockerfile)).toEqual([]); for (const filename of requirementFiles) { const contents = fs.readFileSync(path.join(root, filename), 'utf8'); expect(checkPythonRequirements(contents), filename).toEqual([]); } }); }); describe('install scripts in the image', () => { // 出荷される runtime ステージだけ素の npm ci のままだった(PR #1074 のレビューで発覚)。 // しかも build-essential を同じ層で入れているので、乗っ取られた依存の導入時スクリプトが // コンパイラごと使える。設計も PR 本文も「全部止めている」と書いていたので、 // 文章からは見えない穴だった。ここで固定する。 const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8'); it('runs no npm install without --ignore-scripts anywhere in the image', () => { expect(checkDockerfileInstallPolicy(dockerfile)).toEqual([]); }); it.each([ ['runtime(出荷物)', 'npm ci --omit=dev --ignore-scripts', 'npm ci --omit=dev'], ['builder', 'RUN npm ci --ignore-scripts', 'RUN npm ci'], ['builder の画面側', 'RUN npm --prefix ui ci --ignore-scripts', 'RUN npm --prefix ui ci'], ])('flags %s when --ignore-scripts is dropped', (_label, guarded, bare) => { expect(dockerfile).toContain(guarded); const violations = checkDockerfileInstallPolicy(dockerfile.replace(guarded, bare)); expect(violations).toContainEqual( expect.objectContaining({ code: 'npm-install-runs-scripts' }), ); }); it('does not cry wolf over prose that mentions npm ci', () => { // この Dockerfile は Playwright の bin 衝突を長い散文で説明していて、 // その中に `npm ci` が何度も出てくる。コメントを拾うと常に赤になる。 const violations = checkDockerfileInstallPolicy( ['# NOTE: `npm ci` alone does NOT download the browser', 'RUN npm ci --ignore-scripts'].join('\n'), ); expect(violations).toEqual([]); }); it('keeps the allowlisted native rebuild next to the guarded install', () => { // --ignore-scripts だけ入れて rebuild を忘れると、native が入らないまま出荷される。 expect(dockerfile).toContain('node scripts/security/rebuild-native.mjs'); expect(dockerfile).toContain('COPY security/install-scripts-allowlist.json'); expect(dockerfile).toContain('COPY scripts/security/rebuild-native.mjs'); }); });