99 lines
4.1 KiB
JavaScript
99 lines
4.1 KiB
JavaScript
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');
|
|
});
|
|
});
|