This commit is contained in:
Executable
+399
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Offline regression check for the server egress boundary artifacts.
|
||||
*
|
||||
* This check never applies host firewall/systemd policy and never contacts the
|
||||
* public network. It validates the shipped templates, exercises an in-process
|
||||
* exact-host+port proxy policy, and demonstrates the transports that still
|
||||
* require the OS boundary because HTTP proxy environment variables do not
|
||||
* contain them.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import dgram from 'node:dgram';
|
||||
import { Resolver } from 'node:dns/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
const SYSTEMD_FILE = new URL('../../deploy/egress/systemd/maestro-egress.service.example', import.meta.url);
|
||||
const SQUID_FILE = new URL('../../deploy/egress/squid.conf', import.meta.url);
|
||||
const ALLOWED_HOST = 'allowed.egress.invalid';
|
||||
const DENIED_HOST = 'denied.egress.invalid';
|
||||
const ALLOWED_PORT = 443;
|
||||
const SECRET = 'EGRESS_SECRET_CANARY_7d7fc02c';
|
||||
|
||||
function listen(server, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
const onReady = () => {
|
||||
server.off('error', reject);
|
||||
resolve(server.address());
|
||||
};
|
||||
if (typeof server.listen === 'function') server.listen(0, host, onReady);
|
||||
else server.bind(0, host, onReady);
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
server.closeAllConnections?.();
|
||||
server.close();
|
||||
}
|
||||
|
||||
function runNode(code, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
['--use-env-proxy', '-e', code],
|
||||
{
|
||||
env: { PATH: process.env.PATH || '/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8', ...env },
|
||||
encoding: 'utf8',
|
||||
timeout: 8_000,
|
||||
maxBuffer: 64 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`local child probe failed: ${stderr.trim() || error.message}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function rawExchange(host, port, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
let response = '';
|
||||
const timer = setTimeout(() => socket.destroy(new Error('raw probe timed out')), 3_000);
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('connect', () => socket.end(request));
|
||||
socket.on('data', (chunk) => { response += chunk; });
|
||||
socket.on('error', reject);
|
||||
socket.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseTarget(value, fallbackScheme = 'http:') {
|
||||
try {
|
||||
const url = value.includes('://') ? new URL(value) : new URL(`${fallbackScheme}//${value}`);
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
|
||||
};
|
||||
} catch {
|
||||
return { hostname: '', port: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function allowedTarget(target) {
|
||||
return target.hostname === ALLOWED_HOST && target.port === ALLOWED_PORT;
|
||||
}
|
||||
|
||||
async function validateTemplates() {
|
||||
const [unit, squid] = await Promise.all([
|
||||
readFile(SYSTEMD_FILE, 'utf8'),
|
||||
readFile(SQUID_FILE, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(unit, /^IPAddressDeny=any$/m);
|
||||
assert.match(unit, /^IPAddressAllow=127\.0\.0\.2\/32$/m);
|
||||
assert.match(unit, /^IPAddressAllow=127\.0\.0\.3\/32$/m);
|
||||
assert.doesNotMatch(unit, /^IPAddressAllow=(?:localhost|127\.0\.0\.0\/8|::1(?:\/128)?)$/m);
|
||||
assert.match(unit, /ExecStart=\/usr\/bin\/env .*HTTP_PROXY=http:\/\/127\.0\.0\.2:3128 .*HTTPS_PROXY=http:\/\/127\.0\.0\.2:3128 .*NO_PROXY= .*\/usr\/bin\/node --use-env-proxy/m);
|
||||
assert.doesNotMatch(unit, /NODE_OPTIONS=/);
|
||||
|
||||
assert.match(squid, /^http_port 127\.0\.0\.2:3128$/m);
|
||||
assert.match(squid, /^acl maestro_llm_host dstdomain -n allowed\.egress\.invalid$/m);
|
||||
assert.match(squid, /^acl maestro_llm_port port 443$/m);
|
||||
assert.match(squid, /^http_access allow maestro_llm_host maestro_llm_port$/m);
|
||||
assert.match(squid, /^access_log none$/m);
|
||||
assert.match(squid, /^cache_store_log none$/m);
|
||||
const accessRules = squid.split(/\r?\n/).filter((line) => /^http_access\s+/.test(line.trim()));
|
||||
assert.equal(accessRules.at(-1)?.trim(), 'http_access deny all');
|
||||
return { systemd: true, squid: true };
|
||||
}
|
||||
|
||||
async function runProxyChecks() {
|
||||
const events = [];
|
||||
const proxy = http.createServer((req, res) => {
|
||||
const selectedTarget = Array.isArray(req.headers['x-egress-target'])
|
||||
? req.headers['x-egress-target'][0]
|
||||
: req.headers['x-egress-target'];
|
||||
const target = parseTarget(selectedTarget || req.url || '');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({ protocol: 'http', decision: allowed ? 'allow' : 'deny', port: target.port });
|
||||
res.writeHead(allowed ? 200 : 403, { 'content-type': 'text/plain' });
|
||||
res.end(allowed ? 'allowed' : 'denied');
|
||||
});
|
||||
proxy.on('connect', (req, socket) => {
|
||||
const target = parseTarget(req.url || '', 'http:');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({ protocol: 'connect', decision: allowed ? 'allow' : 'deny', port: target.port });
|
||||
socket.end(`HTTP/1.1 ${allowed ? '200 Connection Established' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
proxy.on('upgrade', (req, socket) => {
|
||||
const target = parseTarget(req.url || '');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({ protocol: 'websocket', decision: allowed ? 'allow' : 'deny', port: target.port });
|
||||
socket.end(`HTTP/1.1 ${allowed ? '101 Switching Protocols' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
|
||||
const address = await listen(proxy, '127.0.0.1');
|
||||
const proxyUrl = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
const fetchAllowed = await fetch(`${proxyUrl}/private?token=${SECRET}`, {
|
||||
headers: { Authorization: `Bearer ${SECRET}`, 'x-egress-target': `${ALLOWED_HOST}:${ALLOWED_PORT}` },
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
const fetchDenied = await fetch(`${proxyUrl}/private?token=${SECRET}`, {
|
||||
headers: { Authorization: `Bearer ${SECRET}`, 'x-egress-target': `${DENIED_HOST}:${ALLOWED_PORT}` },
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
assert.equal(fetchAllowed.status, 200);
|
||||
assert.equal(fetchDenied.status, 403);
|
||||
await Promise.all([fetchAllowed.text(), fetchDenied.text()]);
|
||||
|
||||
const connectAllowed = await rawExchange('127.0.0.1', address.port,
|
||||
`CONNECT ${ALLOWED_HOST}:${ALLOWED_PORT} HTTP/1.1\r\nHost: ${ALLOWED_HOST}:${ALLOWED_PORT}\r\nAuthorization: Bearer ${SECRET}\r\n\r\n`);
|
||||
const connectDenied = await rawExchange('127.0.0.1', address.port,
|
||||
`CONNECT ${DENIED_HOST}:${ALLOWED_PORT} HTTP/1.1\r\nHost: ${DENIED_HOST}:${ALLOWED_PORT}\r\n\r\n`);
|
||||
assert.match(connectAllowed, /^HTTP\/1\.1 200/);
|
||||
assert.match(connectDenied, /^HTTP\/1\.1 403/);
|
||||
|
||||
const wsAllowed = await rawExchange('127.0.0.1', address.port,
|
||||
`GET ws://${ALLOWED_HOST}:${ALLOWED_PORT}/socket?token=${SECRET} HTTP/1.1\r\nHost: ${ALLOWED_HOST}:${ALLOWED_PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`);
|
||||
const wsDenied = await rawExchange('127.0.0.1', address.port,
|
||||
`GET ws://${DENIED_HOST}:${ALLOWED_PORT}/socket HTTP/1.1\r\nHost: ${DENIED_HOST}:${ALLOWED_PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`);
|
||||
assert.match(wsAllowed, /^HTTP\/1\.1 101/);
|
||||
assert.match(wsDenied, /^HTTP\/1\.1 403/);
|
||||
|
||||
const serializedEvents = JSON.stringify(events);
|
||||
assert.ok(!serializedEvents.includes(SECRET), 'metadata events must not retain request secrets');
|
||||
assert.equal(events.filter((event) => event.decision === 'allow').length, 3);
|
||||
assert.equal(events.filter((event) => event.decision === 'deny').length, 3);
|
||||
return { allowed: 3, denied: 3, secretRetained: false };
|
||||
} finally {
|
||||
close(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
async function reserveTcpPort() {
|
||||
const server = net.createServer();
|
||||
const address = await listen(server, '127.0.0.1');
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function runActualEnvProxyChild(port) {
|
||||
const events = [];
|
||||
const proxy = http.createServer((req, res) => {
|
||||
const target = parseTarget(req.url || '');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({
|
||||
transport: 'absolute-form',
|
||||
decision: allowed ? 'allow' : 'deny',
|
||||
target: `${target.hostname}:${target.port}`,
|
||||
absoluteUrl: /^http:\/\//.test(req.url || ''),
|
||||
});
|
||||
res.writeHead(allowed ? 200 : 403, { 'content-type': 'text/plain', connection: 'close' });
|
||||
res.end(allowed ? 'allowed' : 'denied');
|
||||
});
|
||||
proxy.on('connect', (req, socket) => {
|
||||
const target = parseTarget(req.url || '', 'http:');
|
||||
const allowed = allowedTarget(target);
|
||||
events.push({
|
||||
transport: 'connect',
|
||||
decision: allowed ? 'allow' : 'deny',
|
||||
target: `${target.hostname}:${target.port}`,
|
||||
absoluteUrl: false,
|
||||
});
|
||||
if (!allowed) {
|
||||
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
socket.once('data', () => {
|
||||
socket.end('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 7\r\nConnection: close\r\n\r\nallowed');
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
proxy.once('error', reject);
|
||||
proxy.listen(port, '127.0.0.1', () => {
|
||||
proxy.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
const init = {
|
||||
headers: { Authorization: `Bearer ${SECRET}` },
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
};
|
||||
const allowedResponse = await fetch(`http://${ALLOWED_HOST}:${ALLOWED_PORT}/private?token=${SECRET}`, init);
|
||||
const allowedStatus = allowedResponse.status;
|
||||
await allowedResponse.text();
|
||||
let deniedStatus = 'error';
|
||||
try {
|
||||
const deniedResponse = await fetch(`http://${DENIED_HOST}:${ALLOWED_PORT}/private?token=${SECRET}`, init);
|
||||
deniedStatus = deniedResponse.status;
|
||||
await deniedResponse.text();
|
||||
} catch {}
|
||||
return { allowedStatus, deniedStatus, events };
|
||||
} finally {
|
||||
close(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
async function runActualEnvProxyCheck() {
|
||||
const port = await reserveTcpPort();
|
||||
const proxyUrl = `http://127.0.0.1:${port}`;
|
||||
const childEnv = {
|
||||
PATH: process.env.PATH || '/usr/bin:/bin',
|
||||
HOME: '/tmp',
|
||||
LANG: 'C.UTF-8',
|
||||
MAESTRO_EGRESS_PROXY_CHILD: '1',
|
||||
MAESTRO_EGRESS_PROXY_PORT: String(port),
|
||||
HTTP_PROXY: proxyUrl,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
NO_PROXY: '',
|
||||
};
|
||||
const output = await new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
['--use-env-proxy', fileURLToPath(import.meta.url)],
|
||||
{ env: childEnv, encoding: 'utf8', timeout: 8_000, maxBuffer: 64 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`env proxy child failed: ${stderr.trim() || error.message}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
assert.ok(!output.includes(SECRET));
|
||||
const result = JSON.parse(output);
|
||||
assert.equal(result.allowedStatus, 200);
|
||||
assert.ok(result.deniedStatus === 403 || result.deniedStatus === 'error');
|
||||
const allowed = result.events.find((event) => event.decision === 'allow');
|
||||
const denied = result.events.find((event) => event.decision === 'deny');
|
||||
assert.equal(allowed?.target, `${ALLOWED_HOST}:${ALLOWED_PORT}`);
|
||||
assert.equal(denied?.target, `${DENIED_HOST}:${ALLOWED_PORT}`);
|
||||
if (allowed.transport === 'absolute-form') assert.equal(allowed.absoluteUrl, true);
|
||||
return {
|
||||
nodeFlag: '--use-env-proxy',
|
||||
allowedTransport: allowed.transport,
|
||||
allowedTarget: allowed.target,
|
||||
deniedTarget: denied.target,
|
||||
cleanChildEnvironment: true,
|
||||
secretRetained: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function runDirectBypassChecks() {
|
||||
const hits = { http: 0, raw: 0, childRaw: 0, ipv6: 0, dns: 0 };
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
hits.http += 1;
|
||||
res.end('direct-http');
|
||||
});
|
||||
const rawServer = net.createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
const kind = data.toString() === 'child' ? 'childRaw' : 'raw';
|
||||
hits[kind] += 1;
|
||||
socket.end('ok');
|
||||
});
|
||||
});
|
||||
const ipv6Server = net.createServer((socket) => {
|
||||
hits.ipv6 += 1;
|
||||
socket.end('ok');
|
||||
});
|
||||
const dnsServer = dgram.createSocket('udp4');
|
||||
dnsServer.on('message', (message, remote) => {
|
||||
hits.dns += 1;
|
||||
const response = Buffer.from(message);
|
||||
response[2] = 0x81;
|
||||
response[3] = 0x83;
|
||||
response.writeUInt16BE(0, 6);
|
||||
response.writeUInt16BE(0, 8);
|
||||
response.writeUInt16BE(0, 10);
|
||||
dnsServer.send(response, remote.port, remote.address);
|
||||
});
|
||||
|
||||
const servers = [httpServer, rawServer, ipv6Server, dnsServer];
|
||||
try {
|
||||
const httpAddress = await listen(httpServer, '127.0.0.1');
|
||||
const rawAddress = await listen(rawServer, '127.0.0.1');
|
||||
const ipv6Address = await listen(ipv6Server, '::1');
|
||||
const dnsAddress = await listen(dnsServer, '127.0.0.1');
|
||||
|
||||
const directHttp = await new Promise((resolve, reject) => {
|
||||
const request = http.get(`http://127.0.0.1:${httpAddress.port}/direct`, (response) => {
|
||||
response.resume();
|
||||
response.on('end', () => resolve(response.statusCode));
|
||||
});
|
||||
request.on('error', reject);
|
||||
});
|
||||
assert.equal(directHttp, 200);
|
||||
assert.equal(await rawExchange('127.0.0.1', rawAddress.port, 'parent'), 'ok');
|
||||
assert.equal(await rawExchange('::1', ipv6Address.port, 'ipv6'), 'ok');
|
||||
|
||||
const childResult = await runNode(`
|
||||
const net = require('node:net');
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port: ${rawAddress.port} });
|
||||
socket.on('connect', () => socket.end('child'));
|
||||
socket.on('close', () => process.exit(0));
|
||||
socket.on('error', (error) => { console.error(error); process.exit(1); });
|
||||
`, {
|
||||
HTTP_PROXY: 'http://127.0.0.1:9',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:9',
|
||||
NO_PROXY: '',
|
||||
});
|
||||
assert.equal(childResult, '');
|
||||
|
||||
const resolver = new Resolver();
|
||||
resolver.setServers([`127.0.0.1:${dnsAddress.port}`]);
|
||||
await resolver.resolve4(`dns-${SECRET.toLowerCase()}.invalid`).catch(() => {});
|
||||
|
||||
assert.deepEqual(hits, { http: 1, raw: 1, childRaw: 1, ipv6: 1, dns: 1 });
|
||||
return {
|
||||
demonstrated: ['node:http', 'raw-tcp-ipv4', 'spawned-child-raw-tcp', 'raw-tcp-ipv6', 'direct-dns'],
|
||||
meaning: 'these transports bypass HTTP_PROXY and must be stopped by the OS boundary',
|
||||
};
|
||||
} finally {
|
||||
for (const server of servers) {
|
||||
try { close(server); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.MAESTRO_EGRESS_PROXY_CHILD === '1') {
|
||||
const port = Number(process.env.MAESTRO_EGRESS_PROXY_PORT);
|
||||
assert.ok(Number.isInteger(port) && port > 0);
|
||||
const childResult = await runActualEnvProxyChild(port);
|
||||
const childOutput = JSON.stringify(childResult);
|
||||
assert.ok(!childOutput.includes(SECRET));
|
||||
console.log(childOutput);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const templates = await validateTemplates();
|
||||
const envProxyRuntime = await runActualEnvProxyCheck();
|
||||
const proxyPolicy = await runProxyChecks();
|
||||
const proxyScope = await runDirectBypassChecks();
|
||||
const result = {
|
||||
ok: true,
|
||||
externalNetworkUsed: false,
|
||||
hostIsolationApplied: false,
|
||||
templates,
|
||||
envProxyRuntime,
|
||||
proxyPolicy,
|
||||
proxyScope,
|
||||
};
|
||||
const output = JSON.stringify(result, null, 2);
|
||||
assert.ok(!output.includes(SECRET));
|
||||
console.log(output);
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
// Real worker startup + browser CSP regression. All canaries are loopback-only.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtemp, writeFile, rm, mkdir, copyFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import express from 'express';
|
||||
import { registerBrowserEgress } from '../../dist/bridge/egress-csp.js';
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'maestro-egress-'));
|
||||
const secret = 'egress-canary-secret-must-not-be-logged';
|
||||
const servers = [];
|
||||
let browser;
|
||||
let worker;
|
||||
async function listen(server) {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening'); servers.push(server);
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
const delay = ms => new Promise(resolveDelay => setTimeout(resolveDelay, ms));
|
||||
async function stopWorker() {
|
||||
if (!worker || worker.exitCode !== null || worker.signalCode !== null) return;
|
||||
const exiting = once(worker, 'exit'); worker.kill('SIGTERM');
|
||||
const timer = setTimeout(() => worker?.kill('SIGKILL'), 5000);
|
||||
try { await exiting; } finally { clearTimeout(timer); worker = undefined; }
|
||||
}
|
||||
try {
|
||||
await mkdir(join(temporary, 'dist/db'), { recursive: true });
|
||||
await copyFile(join(root, 'dist/db/schema.sql'), join(temporary, 'dist/db/schema.sql'));
|
||||
const invalidPolicy = join(temporary, 'invalid-policy.json');
|
||||
await writeFile(invalidPolicy, JSON.stringify({ secret }));
|
||||
for (const entry of ['main.js', 'index.js']) {
|
||||
const invalidStart = spawnSync(process.execPath, [join(root, 'dist', entry)], {
|
||||
cwd: temporary, env: { PATH: process.env.PATH, HOME: temporary, AAO_EGRESS_POLICY: invalidPolicy },
|
||||
encoding: 'utf8', timeout: 5000,
|
||||
});
|
||||
assert.equal(invalidStart.status, 1, 'invalid policy rejects startup');
|
||||
assert(invalidStart.stderr.includes('Invalid egress policy'));
|
||||
assert(!invalidStart.stderr.includes(secret));
|
||||
assert(!invalidStart.stdout.includes('maestro starting'), 'no worker startup with invalid policy');
|
||||
}
|
||||
let allowedHits = 0, deniedHits = 0;
|
||||
const makeCanary = hit => {
|
||||
const server = createServer((req, res) => {
|
||||
hit(); res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.url.startsWith('/image')) {
|
||||
res.setHeader('Content-Type', 'image/svg+xml'); res.end('<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="2" height="2"/></svg>');
|
||||
} else { res.end('ok'); }
|
||||
});
|
||||
const ws = new WebSocketServer({ server }); ws.on('connection', socket => { hit(); socket.close(); });
|
||||
return server;
|
||||
};
|
||||
const allowed = await listen(makeCanary(() => allowedHits++));
|
||||
const denied = await listen(makeCanary(() => deniedHits++));
|
||||
let llmHits = 0;
|
||||
const llm = await listen(createServer((_req, res) => { llmHits++; res.setHeader('Content-Type', 'application/json'); res.end('{"data":[{"id":"egress-test-model"}],"models":[]}'); }));
|
||||
const policyPath = join(temporary, 'policy.json');
|
||||
const configPath = join(temporary, 'config.yaml');
|
||||
await writeFile(configPath, 'safety:\n bash_allow_network: true\n');
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
for (const mode of ['report-only', 'enforce']) {
|
||||
const reserve = createServer(); reserve.listen(0, '127.0.0.1'); await once(reserve, 'listening');
|
||||
const port = reserve.address().port; await new Promise(resolveClose => reserve.close(resolveClose));
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const policy = { version: 1, observeNode: true, browser: {
|
||||
mode, connectOrigins: [allowed, allowed.replace('http:', 'ws:')], imageOrigins: [allowed], frameOrigins: [],
|
||||
} };
|
||||
await writeFile(policyPath, JSON.stringify(policy));
|
||||
const fixtureApp = express();
|
||||
registerBrowserEgress(fixtureApp, policy);
|
||||
fixtureApp.get('/sandbox', (_req, res) => {
|
||||
res.setHeader('Content-Security-Policy', 'sandbox');
|
||||
res.type('html').send(`<img id=canary src="${denied}/image?token=${secret}">`);
|
||||
});
|
||||
fixtureApp.get('/', (_req, res) => res.type('html').send('<iframe src="/sandbox"></iframe>'));
|
||||
const fixture = await listen(createServer(fixtureApp));
|
||||
const initialLlmHits = llmHits;
|
||||
let output = '';
|
||||
worker = spawn(process.execPath, [join(root, 'dist/main.js')], {
|
||||
cwd: temporary,
|
||||
// Do not inherit real integrations, credentials, proxies, preload scripts or developer config.
|
||||
env: { PATH: process.env.PATH, HOME: temporary, NODE_ENV: 'test', AAO_MODE: 'worker',
|
||||
AAO_CONFIG: configPath, AAO_EGRESS_POLICY: policyPath, PORT: String(port), LOG_LEVEL: 'info',
|
||||
DB_PATH: join(temporary, `${mode}.db`), WORKTREE_DIR: join(temporary, `${mode}-workspaces`),
|
||||
UI_DIST_PATH: join(root, 'ui/dist'), OLLAMA_BASE_URL: `${llm}/v1`, OLLAMA_MODEL: 'egress-test-model' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
worker.on('error', error => { output += error.message; });
|
||||
const capture = chunk => { output += chunk.toString(); if (output.length > 2_000_000) worker?.kill('SIGTERM'); };
|
||||
worker.stdout.on('data', capture); worker.stderr.on('data', capture);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 300; attempt++) {
|
||||
if (worker.exitCode !== null) throw new Error(`Worker failed to start: ${output.slice(-6000)}`);
|
||||
try { const response = await fetch(`${base}/ui/`, { signal: AbortSignal.timeout(1000) }); if (response.ok) { ready = true; break; } } catch {}
|
||||
await delay(100);
|
||||
}
|
||||
assert(ready, `worker ready (${mode})`);
|
||||
const context = await browser.newContext({ serviceWorkers: 'block' });
|
||||
context.setDefaultTimeout(15000);
|
||||
const unexpected = [];
|
||||
const pageErrors = [];
|
||||
await context.route('**/*', route => {
|
||||
const url = route.request().url();
|
||||
if (!/^https?:/.test(url) || [base, allowed, denied, fixture].includes(new URL(url).origin)) return route.continue();
|
||||
unexpected.push(new URL(url).origin); return route.abort();
|
||||
});
|
||||
await context.routeWebSocket('**/*', route => {
|
||||
const origin = new URL(route.url()).origin.replace(/^ws/, 'http');
|
||||
if ([base, allowed, denied, fixture].includes(origin)) route.connectToServer();
|
||||
else { unexpected.push(origin); route.close(); }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
// App errors only: Playwright's service-worker disabling init script itself
|
||||
// throws inside an intentionally opaque sandbox frame. The fixture has no scripts.
|
||||
page.on('pageerror', error => pageErrors.push(error.message));
|
||||
const response = await page.goto(`${base}/ui/`);
|
||||
const header = mode === 'enforce' ? 'content-security-policy' : 'content-security-policy-report-only';
|
||||
assert(response.headers()[header]?.includes('connect-src'), `${mode} CSP on real UI`);
|
||||
await page.getByTestId('nav-spaces').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('nav-spaces').click();
|
||||
await page.getByTestId('space-rail').waitFor({ state: 'visible' });
|
||||
const attempt = async (origin, kind) => page.evaluate(async ({ origin, kind, secret }) => {
|
||||
const url = `${origin}/${kind}?token=${secret}`;
|
||||
return await new Promise(resolveResult => {
|
||||
const timeout = setTimeout(() => resolveResult('timeout'), 4000);
|
||||
const finish = value => { clearTimeout(timeout); resolveResult(value); };
|
||||
if (kind === 'fetch') fetch(url).then(r => finish(r.ok ? 'ok' : 'error'), () => finish('error'));
|
||||
else if (kind === 'image') { const img = new Image(); img.onload = () => finish('ok'); img.onerror = () => finish('error'); img.src = url; }
|
||||
else { const socket = new WebSocket(url.replace('http:', 'ws:')); socket.onopen = () => { socket.close(); finish('ok'); }; socket.onerror = () => finish('error'); }
|
||||
});
|
||||
}, { origin, kind, secret });
|
||||
const startAllowed = allowedHits, startDenied = deniedHits;
|
||||
for (const kind of ['fetch', 'image', 'websocket']) {
|
||||
assert.equal(await attempt(allowed, kind), 'ok', `${mode} allowed ${kind}`);
|
||||
assert.equal(await attempt(denied, kind), mode === 'enforce' ? 'error' : 'ok', `${mode} unlisted ${kind}`);
|
||||
}
|
||||
assert.equal(allowedHits - startAllowed, 3);
|
||||
assert.equal(deniedHits - startDenied, mode === 'enforce' ? 0 : 3);
|
||||
const sandboxPage = await context.newPage();
|
||||
const sandboxResponse = await context.request.get(`${fixture}/sandbox`);
|
||||
assert(sandboxResponse.headers()['content-security-policy'].includes('sandbox'), 'sandbox is preserved');
|
||||
assert(sandboxResponse.headers()[header].includes('img-src'), 'egress CSP survives later sandbox header');
|
||||
const beforeFrame = deniedHits;
|
||||
await sandboxPage.goto(fixture);
|
||||
const canary = sandboxPage.frameLocator('iframe').locator('#canary');
|
||||
await canary.waitFor({ state: 'attached' });
|
||||
for (let i = 0; i < 50 && !await canary.evaluate(img => img.complete); i++) await delay(100);
|
||||
assert(await canary.evaluate(img => img.complete), 'iframe image request completes');
|
||||
assert.equal(await canary.evaluate(img => img.naturalWidth > 0), mode === 'report-only', 'same-origin sandbox iframe egress');
|
||||
assert.equal(deniedHits - beforeFrame, mode === 'enforce' ? 0 : 1);
|
||||
await sandboxPage.close();
|
||||
for (let i = 0; i < 30 && !output.includes(`[security:csp] blocked="${denied}"`); i++) await delay(100);
|
||||
assert(output.includes(`[security:csp] blocked="${denied}"`), 'browser reports reach real worker');
|
||||
assert(!output.includes(secret), 'canary secret absent from all worker logs');
|
||||
assert.deepEqual(unexpected, [], 'normal UI must not contact unknown origins');
|
||||
// Stay idle across the observation interval and backend status refresh cycle.
|
||||
if (mode === 'enforce') {
|
||||
for (let i = 0; i < 13; i++) { await delay(5000); assert.equal(worker.exitCode, null); }
|
||||
assert(output.includes('[egress-summary]'), 'idle observation summary');
|
||||
}
|
||||
assert(output.includes('[egress] Node destination observation enabled'));
|
||||
const observationLines = output.split('\n').filter(line => /\[egress(?:-summary)?\]/.test(line));
|
||||
assert(!observationLines.some(line => line.includes(secret)), 'secrets absent from observations');
|
||||
let sawLlmHttp = false, sawLlmTcp = false;
|
||||
for (const line of observationLines) {
|
||||
const json = line.slice(line.indexOf('] {') + 2);
|
||||
if (!json.startsWith('{')) continue;
|
||||
const value = JSON.parse(json);
|
||||
const entries = value.entries ?? [value];
|
||||
for (const entry of entries) {
|
||||
sawLlmHttp ||= entry.kind === 'http' && entry.destination === llm;
|
||||
sawLlmTcp ||= entry.kind === 'tcp' && entry.destination === llm.replace('http:', 'tcp:');
|
||||
assert([llm, allowed, denied, base].includes(entry.destination)
|
||||
|| [llm, allowed, denied, base].map(origin => origin.replace('http:', 'tcp:')).includes(entry.destination), 'only controlled destinations observed');
|
||||
}
|
||||
}
|
||||
assert(llmHits > initialLlmHits, 'worker actually contacts fake LLM');
|
||||
assert(sawLlmHttp && sawLlmTcp, 'real worker HTTP and TCP observations are nonempty');
|
||||
assert.deepEqual(pageErrors, [], 'normal UI has no uncaught errors');
|
||||
assert.deepEqual(unexpected, [], 'idle UI has no unexpected destinations');
|
||||
await context.close(); await stopWorker();
|
||||
console.log(`${mode}: real UI startup, HTTP/image/WebSocket canaries, destination-only observation passed`);
|
||||
}
|
||||
} finally {
|
||||
await stopWorker(); await browser?.close();
|
||||
for (const server of servers) { server.closeAllConnections(); server.close(); }
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
// Real Settings UI + persisted config + live CSP. Only loopback test services.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtemp, mkdir, copyFile, writeFile, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
const temp = await mkdtemp(join(tmpdir(), 'maestro-egress-settings-'));
|
||||
const delay = ms => new Promise(resolveDelay => setTimeout(resolveDelay, ms));
|
||||
let worker, browser;
|
||||
let output = '';
|
||||
const servers = [];
|
||||
async function listen(server) { server.listen(0, '127.0.0.1'); await once(server, 'listening'); servers.push(server); return `http://127.0.0.1:${server.address().port}`; }
|
||||
async function stop() {
|
||||
if (!worker || worker.exitCode !== null || worker.signalCode !== null) return;
|
||||
const done = once(worker, 'exit'); worker.kill('SIGTERM');
|
||||
const timeout = setTimeout(() => worker?.kill('SIGKILL'), 5000);
|
||||
try { await done; } finally { clearTimeout(timeout); worker = undefined; }
|
||||
}
|
||||
try {
|
||||
await mkdir(join(temp, 'dist/db'), { recursive: true });
|
||||
await copyFile(join(root, 'dist/db/schema.sql'), join(temp, 'dist/db/schema.sql'));
|
||||
let hits = 0;
|
||||
const canary = await listen(createServer((_req, res) => { hits++; res.setHeader('Access-Control-Allow-Origin', '*'); res.end('ok'); }));
|
||||
const llm = await listen(createServer((_req, res) => { res.setHeader('Content-Type', 'application/json'); res.end('{"data":[{"id":"test-model"}],"models":[]}'); }));
|
||||
const reserve = createServer(); reserve.listen(0, '127.0.0.1'); await once(reserve, 'listening');
|
||||
const port = reserve.address().port; await new Promise(resolveClose => reserve.close(resolveClose));
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const config = join(temp, 'config.yaml');
|
||||
await writeFile(config, 'safety:\n bash_allow_network: true\n');
|
||||
const env = { PATH: process.env.PATH, HOME: temp, NODE_ENV: 'test', AAO_MODE: 'worker', AAO_CONFIG: config,
|
||||
PORT: String(port), DB_PATH: join(temp, 'test.db'), WORKTREE_DIR: join(temp, 'workspaces'),
|
||||
UI_DIST_PATH: join(root, 'ui/dist'), OLLAMA_BASE_URL: `${llm}/v1`, OLLAMA_MODEL: 'test-model', LOG_LEVEL: 'info' };
|
||||
async function start(extra = {}) {
|
||||
output = '';
|
||||
worker = spawn(process.execPath, [join(root, 'dist/main.js')], { cwd: temp, env: { ...env, ...extra }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const capture = value => { output += value.toString(); if (output.length > 2_000_000) worker?.kill('SIGTERM'); };
|
||||
worker.stdout.on('data', capture); worker.stderr.on('data', capture);
|
||||
worker.on('error', error => { output += error.message; });
|
||||
for (let i = 0; i < 300; i++) {
|
||||
if (worker.exitCode !== null) throw new Error(`worker startup failed: ${output.slice(-5000)}`);
|
||||
try { if ((await fetch(`${base}/ui/`, { signal: AbortSignal.timeout(1000) })).ok) return; } catch {}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error('worker did not start');
|
||||
}
|
||||
await start();
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
const context = await browser.newContext({ locale: 'ja-JP', serviceWorkers: 'block' });
|
||||
context.setDefaultTimeout(15000);
|
||||
const external = [];
|
||||
await context.route('**/*', route => {
|
||||
const origin = new URL(route.request().url()).origin;
|
||||
if ([base, canary].includes(origin)) return route.continue();
|
||||
external.push(origin); return route.abort();
|
||||
});
|
||||
await context.routeWebSocket('**/*', route => {
|
||||
if (new URL(route.url()).origin.replace(/^ws/, 'http') === base) route.connectToServer();
|
||||
else { external.push(new URL(route.url()).origin); route.close(); }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${base}/ui/?page=settings§ion=egress-policy`);
|
||||
const mode = page.getByRole('combobox', { name: 'ブラウザの通信', exact: true });
|
||||
const observe = page.getByRole('checkbox', { name: 'サーバー通信の接続先を記録', exact: true });
|
||||
const connectOrigins = page.getByRole('textbox', { name: '接続の許可先', exact: true });
|
||||
await mode.waitFor();
|
||||
assert.equal(await mode.inputValue(), 'off');
|
||||
const pid = worker.pid;
|
||||
async function save() {
|
||||
const response = page.waitForResponse(r => r.url() === `${base}/api/config` && r.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: '保存して反映', exact: true }).click();
|
||||
assert.equal((await response).status(), 200, 'settings save succeeds');
|
||||
assert.equal(worker.pid, pid, 'settings apply without restarting worker');
|
||||
}
|
||||
async function attempt() { return page.evaluate(url => fetch(url).then(r => r.ok, () => false), canary); }
|
||||
await mode.selectOption('report-only'); await observe.check(); await save();
|
||||
assert((await fetch(`${base}/ui/`)).headers.get('content-security-policy-report-only')?.includes('connect-src'));
|
||||
await page.reload(); assert.equal(await attempt(), true, 'report-only does not block');
|
||||
assert(output.includes('Node destination observation enabled'), 'Node monitoring enabled live');
|
||||
await mode.selectOption('enforce'); await save();
|
||||
await page.reload(); const beforeBlocked = hits;
|
||||
assert.equal(await attempt(), false, 'enforce blocks unlisted destination');
|
||||
assert.equal(hits, beforeBlocked, 'blocked request never reaches destination');
|
||||
await connectOrigins.fill(canary); await save();
|
||||
await page.reload(); assert.equal(await attempt(), true, 'adding an allowed origin works');
|
||||
const onDisk = await readFile(config, 'utf8');
|
||||
assert(onDisk.includes('observe_node: true') && onDisk.includes('mode: enforce'), 'settings persisted to YAML');
|
||||
await stop(); await start(); await page.reload();
|
||||
assert.equal(await mode.inputValue(), 'enforce');
|
||||
assert.equal(await connectOrigins.inputValue(), canary);
|
||||
assert.equal(await observe.isChecked(), true);
|
||||
assert.equal(await attempt(), true, 'saved policy survives restart');
|
||||
const locked = join(temp, 'external.json');
|
||||
await writeFile(locked, JSON.stringify({ version: 1, observeNode: false, browser: { mode: 'report-only', connectOrigins: [], imageOrigins: [], frameOrigins: [] } }));
|
||||
await stop(); await start({ AAO_EGRESS_POLICY: locked }); await page.reload();
|
||||
assert.equal(await mode.inputValue(), 'report-only');
|
||||
assert.equal(await mode.isDisabled(), true); assert.equal(await observe.isDisabled(), true); assert.equal(await connectOrigins.isDisabled(), true);
|
||||
const beforeWrite = await readFile(config, 'utf8');
|
||||
const rejected = await fetch(`${base}/api/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ egress: { browser: { mode: 'off' } } }) });
|
||||
assert.equal(rejected.status, 400, 'generic API cannot bypass external lock');
|
||||
assert.equal(await readFile(config, 'utf8'), beforeWrite);
|
||||
assert.deepEqual(external, []);
|
||||
await context.close();
|
||||
console.log('Settings UI: live report/enforce/allowlist, Node monitoring, persistence, external lock passed');
|
||||
} finally {
|
||||
await stop(); await browser?.close();
|
||||
for (const server of servers) { server.closeAllConnections(); server.close(); }
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Browser regression check for UI assets that must remain local.
|
||||
*
|
||||
* This deliberately builds a throwaway Vite app from the production
|
||||
* components. It does not inspect source strings: Chromium must load the
|
||||
* bundled fonts, Monaco worker, and Leaflet marker images, edit and save a
|
||||
* file, and render a map. OSM tiles are intercepted and fulfilled locally;
|
||||
* every other cross-origin request is treated as a failure.
|
||||
*/
|
||||
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
||||
const repoRoot = resolve(scriptDir, '../..');
|
||||
const uiRoot = join(repoRoot, 'ui');
|
||||
const cspMode = process.argv.includes('--csp');
|
||||
const uiNodeModules = join(uiRoot, 'node_modules');
|
||||
|
||||
const importFrom = async (path) => import(pathToFileURL(path).href);
|
||||
const vite = await importFrom(join(uiNodeModules, 'vite/dist/node/index.js'));
|
||||
const reactPluginModule = await importFrom(join(uiNodeModules, '@vitejs/plugin-react/dist/index.js'));
|
||||
const require = createRequire(import.meta.url);
|
||||
const playwrightModule = await importFrom(require.resolve('playwright'));
|
||||
const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default;
|
||||
const reactPlugin = reactPluginModule.default;
|
||||
|
||||
const localModule = (name) => join(uiNodeModules, name);
|
||||
const MONACO = join(uiRoot, 'src/components/userfolder/MonacoFileEditor.tsx');
|
||||
const MAP = join(uiRoot, 'src/components/embed/MapPlacesDetail.tsx');
|
||||
const CSS = join(uiRoot, 'src/index.css');
|
||||
const POSTCSS = join(uiRoot, 'postcss.config.js');
|
||||
|
||||
class ExternalRequestDetected extends Error {
|
||||
constructor(urls) {
|
||||
super(`external requests blocked: ${urls.join(', ')}`);
|
||||
this.name = 'ExternalRequestDetected';
|
||||
}
|
||||
}
|
||||
|
||||
const EXPECTED_CDN_URL = 'https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap';
|
||||
|
||||
function assertNoExternal(urls) {
|
||||
if (urls.length > 0) throw new ExternalRequestDetected(urls);
|
||||
}
|
||||
|
||||
const TILE_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function fixtureSource() {
|
||||
return `<!doctype html>
|
||||
<html lang="ja">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>#root{min-height:900px}</style>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function fixtureAppSource() {
|
||||
return `import React, { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18next from 'i18next';
|
||||
import { MonacoFileEditor } from ${JSON.stringify(MONACO)};
|
||||
import { MapPlacesDetail } from ${JSON.stringify(MAP)};
|
||||
import ${JSON.stringify(CSS)};
|
||||
import jaUserfolder from ${JSON.stringify(join(uiRoot, "src/i18n/locales/ja/userfolder.json"))};
|
||||
import enUserfolder from ${JSON.stringify(join(uiRoot, "src/i18n/locales/en/userfolder.json"))};
|
||||
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.init({
|
||||
lng: 'ja',
|
||||
fallbackLng: 'en',
|
||||
resources: {
|
||||
ja: { userfolder: jaUserfolder, embed: { searchResults: { map: '地図検索結果: 「{{query}}」' }, viewOn: { osm: 'OpenStreetMap で開く' } } },
|
||||
en: { userfolder: enUserfolder, embed: { searchResults: { map: 'Map search results: “{{query}}”' }, viewOn: { osm: 'Open in OpenStreetMap' } } },
|
||||
},
|
||||
});
|
||||
|
||||
const MAP_DATA = {
|
||||
query: '東京駅',
|
||||
places: [
|
||||
{ name: '東京駅', address: '東京都千代田区', lat: 35.681236, lon: 139.767125, type: 'station', details: '中央駅', mapUrl: 'https://www.openstreetmap.org/?mlat=35.681236&mlon=139.767125#map=16/35.681236/139.767125' },
|
||||
{ name: '皇居', address: '東京都千代田区', lat: 35.685175, lon: 139.752799, type: 'park', details: '公園', mapUrl: 'https://www.openstreetmap.org/' },
|
||||
],
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [saved, setSaved] = useState('');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
return <I18nextProvider i18n={i18n}>
|
||||
<main style={{ minHeight: '900px', padding: 16 }}>
|
||||
<h1>Local UI asset probe</h1>
|
||||
<div data-font="ibm-sans" style={{ fontFamily: '"IBM Plex Sans JP"' }}>日本語フォント</div>
|
||||
<div data-font="ibm-mono" style={{ fontFamily: '"IBM Plex Mono"' }}>IBM mono 0123</div>
|
||||
<div data-font="jetbrains" style={{ fontFamily: '"JetBrains Mono"' }}>JetBrains mono 0123</div>
|
||||
<section style={{ height: 300, border: '1px solid #ddd' }}>
|
||||
<MonacoFileEditor
|
||||
subdir="agents-md"
|
||||
filename="AGENTS.md"
|
||||
content="# original\\n"
|
||||
mtime="2026-01-01T00:00:00.000Z"
|
||||
size={12}
|
||||
onSave={async (value) => { setSaved(value); await new Promise(resolve => setTimeout(resolve, 150)); }}
|
||||
onDirtyChange={setDirty}
|
||||
/>
|
||||
</section>
|
||||
<output data-testid="dirty-state">{String(dirty)}</output>
|
||||
<output data-testid="save-result">{saved}</output>
|
||||
<MapPlacesDetail data={MAP_DATA} />
|
||||
</main>
|
||||
</I18nextProvider>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
`;
|
||||
}
|
||||
|
||||
async function makeFixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'maestro-local-ui-assets-'));
|
||||
await mkdir(join(root, 'src'), { recursive: true });
|
||||
await writeFile(join(root, 'index.html'), fixtureSource());
|
||||
await writeFile(join(root, 'src/main.tsx'), fixtureAppSource());
|
||||
return root;
|
||||
}
|
||||
|
||||
async function buildFixture(root, outDir) {
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(uiRoot);
|
||||
try {
|
||||
await vite.build({
|
||||
configFile: false,
|
||||
root,
|
||||
base: '/ui/',
|
||||
plugins: [reactPlugin()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
alias: [
|
||||
{ find: /^react$/, replacement: localModule('react') },
|
||||
{ find: /^react\/(.*)$/, replacement: `${localModule('react')}/$1` },
|
||||
{ find: /^react-dom$/, replacement: localModule('react-dom') },
|
||||
{ find: /^react-dom\/(.*)$/, replacement: `${localModule('react-dom')}/$1` },
|
||||
{ find: /^react-i18next$/, replacement: localModule('react-i18next') },
|
||||
{ find: /^i18next$/, replacement: localModule('i18next') },
|
||||
],
|
||||
},
|
||||
css: { postcss: POSTCSS },
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: true,
|
||||
rollupOptions: { input: join(root, 'index.html') },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
}
|
||||
|
||||
async function closePreview(previewInfo) {
|
||||
const preview = previewInfo?.preview ?? previewInfo;
|
||||
if (!preview) return;
|
||||
if (typeof preview.close === 'function') {
|
||||
await preview.close();
|
||||
return;
|
||||
}
|
||||
if (preview.httpServer) {
|
||||
await new Promise((resolveClose) => preview.httpServer.close(resolveClose));
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreview(root) {
|
||||
const headers = {};
|
||||
if (cspMode) {
|
||||
const { buildBrowserCsp } = await importFrom(join(repoRoot, 'dist/bridge/egress-csp.js'));
|
||||
headers['Content-Security-Policy'] = buildBrowserCsp({
|
||||
mode: 'enforce', connectOrigins: [], imageOrigins: ['https://tile.openstreetmap.org'], frameOrigins: [],
|
||||
});
|
||||
}
|
||||
const preview = await vite.preview({
|
||||
configFile: false,
|
||||
root,
|
||||
base: '/ui/',
|
||||
preview: { host: '127.0.0.1', port: 0, strictPort: false, headers },
|
||||
});
|
||||
const address = preview.httpServer.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Vite preview did not expose a TCP address');
|
||||
return { preview, origin: `http://127.0.0.1:${address.port}` };
|
||||
}
|
||||
|
||||
async function browserCheck() {
|
||||
const root = await makeFixture();
|
||||
const outDir = join(root, 'dist');
|
||||
let preview;
|
||||
let browser;
|
||||
let context;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
context = await browser.newContext({ serviceWorkers: 'block' });
|
||||
await buildFixture(root, outDir);
|
||||
preview = await runPreview(root);
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
const external = [];
|
||||
const osm = [];
|
||||
const localFailures = [];
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
const workerResponses = [];
|
||||
const local = new URL(preview.origin);
|
||||
context.on('requestfailed', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin === local.origin) localFailures.push(`request failed ${request.url()}: ${request.failure()?.errorText ?? 'unknown'}`);
|
||||
});
|
||||
context.on('response', (response) => {
|
||||
const url = new URL(response.url());
|
||||
if (url.origin === local.origin && response.status() >= 400) {
|
||||
localFailures.push(`HTTP ${response.status()} ${response.url()}`);
|
||||
}
|
||||
if (url.origin === local.origin && /(?:worker|\.worker)[^/]*\.js/i.test(url.pathname) && response.ok()) {
|
||||
workerResponses.push(response.url());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => pageErrors.push(String(error)));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
// This static fixture needs no WebSocket connection. Intercept before any
|
||||
// server connection so a future dependency cannot bypass the HTTP guard.
|
||||
await context.routeWebSocket('**/*', socket => {
|
||||
external.push(socket.url());
|
||||
socket.close();
|
||||
});
|
||||
await context.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
requests.push(url.toString());
|
||||
if (url.origin === local.origin) return route.continue();
|
||||
if (url.hostname === 'tile.openstreetmap.org') {
|
||||
osm.push(url.toString());
|
||||
return route.fulfill({ status: 200, contentType: 'image/png', body: TILE_PNG });
|
||||
}
|
||||
external.push(url.toString());
|
||||
return route.abort('blockedbyclient');
|
||||
});
|
||||
await page.goto(`${preview.origin}/ui/`, { waitUntil: 'domcontentloaded' });
|
||||
assertNoExternal(external);
|
||||
if (localFailures.length > 0 || pageErrors.length > 0 || consoleErrors.length > 0) {
|
||||
throw new Error(`local UI asset failures: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
||||
}
|
||||
await page.locator('[data-testid="monaco-file-editor"]').waitFor();
|
||||
await page.locator('.leaflet-container').waitFor();
|
||||
try {
|
||||
await page.waitForFunction(() => Boolean(document.querySelector('.monaco-editor [role="textbox"]') || document.querySelector('[data-testid="monaco-load-error"]')), undefined, { timeout: 45000 });
|
||||
} catch (error) {
|
||||
const editorState = await page.locator('[data-testid="monaco-file-editor"]').evaluate(element => ({ html: element.innerHTML, text: element.textContent }));
|
||||
throw new Error(`Monaco did not mount: ${JSON.stringify({ editorState, localFailures, pageErrors, consoleErrors, requests: requests.slice(-30) })}`, { cause: error });
|
||||
}
|
||||
if (await page.locator('[data-testid="monaco-load-error"]').count() > 0) {
|
||||
throw new Error(`Monaco lazy load failed: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
||||
}
|
||||
await page.waitForFunction(() => document.fonts.status === 'loaded', undefined, { timeout: 45000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const fontFamilies = await page.evaluate(async () => {
|
||||
const names = ['IBM Plex Sans JP', 'IBM Plex Mono', 'JetBrains Mono'];
|
||||
await Promise.all(names.map(name => document.fonts.load(`16px "${name}"`)));
|
||||
return Object.fromEntries(names.map(name => [name, document.fonts.check(`16px "${name}"`)]));
|
||||
});
|
||||
if (Object.values(fontFamilies).some(value => value !== true)) {
|
||||
throw new Error(`font faces did not load: ${JSON.stringify(fontFamilies)}`);
|
||||
}
|
||||
const fontRequests = requests.filter(url => /(?:plex|jetbrains)/i.test(url) && /\.(?:woff2?|ttf)(?:[?#]|$)/i.test(url));
|
||||
if (new Set(fontRequests.map(url => /ibm-plex-sans-jp|ibm-plex-mono|jetbrains-mono/i.exec(url)?.[0])).size < 3) {
|
||||
throw new Error(`expected all three local font families, got: ${fontRequests.join(', ')}`);
|
||||
}
|
||||
|
||||
const editor = page.locator('.monaco-editor [role="textbox"]').first();
|
||||
await editor.waitFor();
|
||||
await editor.focus();
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
await page.keyboard.type('# edited locally\\n');
|
||||
await page.getByTestId('monaco-save').click();
|
||||
// onSave can report persisted content before the child's dirty-state
|
||||
// notification reaches the parent. Wait for both observable outcomes.
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector('[data-testid="save-result"]')?.textContent?.includes('edited locally') &&
|
||||
document.querySelector('[data-testid="dirty-state"]')?.textContent === 'false');
|
||||
|
||||
await page.waitForFunction(() => document.querySelectorAll('.leaflet-marker-icon').length === 2);
|
||||
const markerCount = await page.locator('.leaflet-marker-icon').count();
|
||||
if (markerCount !== 2) throw new Error(`expected two rendered map markers, got ${markerCount}`);
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.leaflet-marker-icon')].every(element => element instanceof HTMLImageElement && element.naturalWidth > 0 && element.naturalHeight > 0), undefined, { timeout: 30000 });
|
||||
const markerSources = await page.locator('.leaflet-marker-icon').evaluateAll(elements => elements.map(element => element.getAttribute('src') ?? ''));
|
||||
const markerUrls = markerSources.map((src) => src.startsWith('data:') ? src : new URL(src, `${local.origin}/ui/`).href);
|
||||
if (markerUrls.some(url => !url.startsWith(`${local.origin}/ui/`) && !url.startsWith('data:image/'))) {
|
||||
throw new Error(`marker image escaped same-origin asset path: ${markerUrls.join(', ')}`);
|
||||
}
|
||||
const markerSizes = await page.locator('.leaflet-marker-icon').evaluateAll(elements => elements.map(element => ({
|
||||
width: element instanceof HTMLImageElement ? element.naturalWidth : 0,
|
||||
height: element instanceof HTMLImageElement ? element.naturalHeight : 0,
|
||||
})));
|
||||
if (markerSizes.some(size => size.width <= 0 || size.height <= 0)) {
|
||||
throw new Error(`marker images did not decode: ${JSON.stringify(markerSizes)}`);
|
||||
}
|
||||
if (osm.length === 0) throw new Error('Leaflet did not request an OSM tile');
|
||||
if (workerResponses.length === 0) {
|
||||
await context.waitForEvent('response', {
|
||||
predicate: response => {
|
||||
const url = new URL(response.url());
|
||||
return url.origin === local.origin && /(?:worker|\.worker)[^/]*\.js/i.test(url.pathname) && response.ok();
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
assertNoExternal(external);
|
||||
if (localFailures.length > 0 || pageErrors.length > 0 || consoleErrors.length > 0) {
|
||||
throw new Error(`local UI asset failures: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
||||
}
|
||||
const positive = {
|
||||
requests: [...requests], external: [], osm: [...osm], fontRequests,
|
||||
markerSources: markerUrls.map(url => url.startsWith('data:') ? 'data:image' : new URL(url).pathname),
|
||||
};
|
||||
|
||||
// Inject only after the real components finish loading: this exercises
|
||||
// the final guard, including requests that start after DOMContentLoaded.
|
||||
await page.evaluate(url => {
|
||||
document.addEventListener('securitypolicyviolation', event => {
|
||||
if ((event.blockedURI === url || event.blockedURI === new URL(url).origin) && event.effectiveDirective === 'style-src-elem' && event.disposition === 'enforce') {
|
||||
document.body.dataset.cspProbeBlocked = 'true';
|
||||
}
|
||||
});
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.onload = link.onerror = () => { document.body.dataset.cdnProbeFinished = 'true'; };
|
||||
document.head.append(link);
|
||||
}, EXPECTED_CDN_URL);
|
||||
await page.waitForFunction(() => document.body.dataset.cdnProbeFinished === 'true', undefined, { timeout: 10000 });
|
||||
|
||||
let negativeCaught = false;
|
||||
if (cspMode) {
|
||||
await page.waitForFunction(() => document.body.dataset.cspProbeBlocked === 'true', undefined, { timeout: 10000 });
|
||||
assertNoExternal(external);
|
||||
negativeCaught = true;
|
||||
} else try {
|
||||
assertNoExternal(external);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ExternalRequestDetected && external.length === 1 &&
|
||||
external[0] === EXPECTED_CDN_URL)) throw error;
|
||||
negativeCaught = true;
|
||||
}
|
||||
if (!negativeCaught) throw new Error('external CDN negative fixture was not rejected by the browser network guard');
|
||||
return { ...positive, negativeExternalCdnDetected: negativeCaught };
|
||||
} finally {
|
||||
await context?.close();
|
||||
await browser?.close();
|
||||
await closePreview(preview);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const positive = await browserCheck();
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
cspEnforced: cspMode,
|
||||
sameOriginRequests: positive.requests.length - positive.external.length - positive.osm.length,
|
||||
localFontFiles: positive.fontRequests.length,
|
||||
osmTilesMocked: positive.osm.length,
|
||||
markerSources: positive.markerSources,
|
||||
negativeExternalCdnDetected: positive.negativeExternalCdnDetected,
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const workflowPath = new URL('../../.gitea/workflows/ci.yml', import.meta.url);
|
||||
|
||||
describe('untrusted pull request CI', () => {
|
||||
it('does not use a privileged PR trigger or shared npm cache', async () => {
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
|
||||
expect(workflow).not.toMatch(/\bpull_request_target\s*:/);
|
||||
expect(workflow).not.toMatch(/^\s+cache:\s*npm\s*$/m);
|
||||
expect(workflow).toMatch(/\bpermissions:\s*\n\s+contents:\s*read\b/);
|
||||
});
|
||||
|
||||
it('checks lockfiles before installing without lifecycle scripts', async () => {
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
const policyIndex = workflow.indexOf('node scripts/security/dependency-policy.mjs');
|
||||
const installIndex = workflow.indexOf('npm ci --ignore-scripts');
|
||||
|
||||
expect(policyIndex).toBeGreaterThan(-1);
|
||||
expect(installIndex).toBeGreaterThan(policyIndex);
|
||||
expect(workflow).toContain('npm --prefix ui ci --ignore-scripts');
|
||||
expect(workflow).toContain('node scripts/security/rebuild-native.mjs');
|
||||
expect(workflow).toContain('persist-credentials: false');
|
||||
expect(workflow).not.toMatch(/^\s+npm ci\s*$/m);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const DIGEST_PIN = /^[^\s@]+:[^\s@]+@sha256:[a-f0-9]{64}$/;
|
||||
const EXACT_PYTHON_PIN = /^[A-Za-z0-9_.-]+(?:\[[A-Za-z0-9_,.-]+\])?==[^*<>=!~\s;]+(?:\s*;.+)?$/;
|
||||
|
||||
export function checkContainerPolicy(dockerfile) {
|
||||
const violations = [];
|
||||
for (const [index, line] of dockerfile.split(/\r?\n/).entries()) {
|
||||
const match = line.match(/^\s*FROM\s+(\S+)/i);
|
||||
if (!match || match[1].toLowerCase() === 'scratch') continue;
|
||||
if (!DIGEST_PIN.test(match[1])) {
|
||||
violations.push({ code: 'unpinned-image', line: index + 1, value: match[1] });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `npm ci` / `npm install` in the image must carry `--ignore-scripts`.
|
||||
*
|
||||
* Why this check exists
|
||||
* ---------------------
|
||||
* The builder stage had `--ignore-scripts` from the start; the runtime stage —
|
||||
* the one that actually ships — did not, and it installs `build-essential` on
|
||||
* the same layer. A hijacked dependency's install script would have run there
|
||||
* with a compiler in reach: the most permissive spot in the pipeline. The
|
||||
* design and the PR both claimed install scripts were "off everywhere", so the
|
||||
* gap was invisible in the prose (found reviewing PR #1074).
|
||||
*
|
||||
* Only RUN instructions are inspected, and comment lines are dropped first:
|
||||
* this Dockerfile explains the Playwright bin collision in prose that mentions
|
||||
* `npm ci` several times, and matching those would make the check cry wolf.
|
||||
* Line continuations are joined, because the runtime install is one link in a
|
||||
* long `&&` chain spread over many lines.
|
||||
*/
|
||||
export function checkDockerfileInstallPolicy(dockerfile) {
|
||||
const violations = [];
|
||||
const lines = dockerfile.split(/\r?\n/);
|
||||
let buffer = '';
|
||||
let startLine = 0;
|
||||
let inRun = false;
|
||||
for (const [index, raw] of lines.entries()) {
|
||||
const line = raw.replace(/\s+#.*$/, '');
|
||||
if (/^\s*#/.test(raw)) continue;
|
||||
if (!inRun) {
|
||||
if (!/^\s*RUN\s/i.test(line)) continue;
|
||||
inRun = true;
|
||||
startLine = index + 1;
|
||||
buffer = '';
|
||||
}
|
||||
buffer += line.replace(/\\\s*$/, ' ');
|
||||
if (/\\\s*$/.test(line)) continue;
|
||||
for (const command of buffer.split(/&&|;|\|\|/)) {
|
||||
if (!/\bnpm\b[^|]*\s(?:ci|install|i)(?:\s|$)/.test(command)) continue;
|
||||
if (/--ignore-scripts\b/.test(command)) continue;
|
||||
violations.push({
|
||||
code: 'npm-install-runs-scripts',
|
||||
line: startLine,
|
||||
value: command.trim(),
|
||||
});
|
||||
}
|
||||
buffer = '';
|
||||
inRun = false;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function checkPythonRequirements(requirements) {
|
||||
const violations = [];
|
||||
for (const [index, rawLine] of requirements.split(/\r?\n/).entries()) {
|
||||
const value = rawLine.replace(/\s+#.*$/, '').trim();
|
||||
if (!value || value.startsWith('#')) continue;
|
||||
if (value.startsWith('-') || value.includes(' @ ') || /(?:https?|git\+|file):/i.test(value)) {
|
||||
violations.push({ code: 'non-index-python-dependency', line: index + 1, value });
|
||||
continue;
|
||||
}
|
||||
if (!EXACT_PYTHON_PIN.test(value)) {
|
||||
violations.push({ code: 'unpinned-python-dependency', line: index + 1, value });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { checkLockfilePolicy } from './dependency-policy.mjs';
|
||||
import { rebuildArgs, validateRebuildEntries, verify } from './rebuild-native.mjs';
|
||||
|
||||
const registryOrigin = 'https://registry.npmjs.org/';
|
||||
|
||||
describe('dependency policy adversarial cases', () => {
|
||||
it('rejects a non-link package when resolved and integrity are both missing', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': {
|
||||
version: '1.0.0',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
['node_modules/evil'],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'missing-resolved',
|
||||
name: 'node_modules/evil',
|
||||
});
|
||||
expect(violations).toContainEqual({
|
||||
code: 'missing-integrity',
|
||||
name: 'node_modules/evil',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects lookalike registry hostnames', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': {
|
||||
version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org.evil.example/evil.tgz',
|
||||
integrity: 'sha512-example',
|
||||
},
|
||||
},
|
||||
},
|
||||
[],
|
||||
registryOrigin,
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'non-registry-url',
|
||||
name: 'node_modules/evil',
|
||||
});
|
||||
});
|
||||
|
||||
// このテストは名前で path / name / version / integrity の 4 つを縛ると言っている。
|
||||
// ところが実際に起こしていたのは version の食い違いだけで、integrity 照合を
|
||||
// 削っても 35 件すべて緑だった(PR #1074 のレビュー M8)。4 つを別々に起こす。
|
||||
const soundLockfile = (over = {}) => ({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/native': {
|
||||
version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
|
||||
integrity: 'sha512-approved',
|
||||
hasInstallScript: true,
|
||||
...over,
|
||||
},
|
||||
},
|
||||
});
|
||||
const soundApproval = (over = {}) => [{
|
||||
path: 'node_modules/native',
|
||||
name: 'native',
|
||||
version: '1.0.0',
|
||||
integrity: 'sha512-approved',
|
||||
verifier: 'better-sqlite3-memory-db',
|
||||
...over,
|
||||
}];
|
||||
|
||||
it('accepts an approval that matches the lockfile on every field', () => {
|
||||
expect(validateRebuildEntries(soundLockfile(), soundApproval())).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects an integrity mismatch even when the version agrees', () => {
|
||||
expect(() => validateRebuildEntries(soundLockfile({ integrity: 'sha512-swapped' }), soundApproval()))
|
||||
.toThrow(/integrity mismatch/i);
|
||||
});
|
||||
|
||||
it('rejects a target the lockfile says has no install script', () => {
|
||||
// 許可一覧に「script を持たない依存」を書けると、rebuild だけが走る経路になる
|
||||
expect(() => validateRebuildEntries(soundLockfile({ hasInstallScript: false }), soundApproval()))
|
||||
.toThrow(/no install script/i);
|
||||
});
|
||||
|
||||
it('rejects a path whose last node_modules segment is not the approved name', () => {
|
||||
// 経路の取り出しは lastIndexOf でなければならない。indexOf に変えると
|
||||
// node_modules/a/node_modules/b が "a/node_modules/b" になり、
|
||||
// 名前の食い違いを見逃す
|
||||
expect(() => validateRebuildEntries(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/outer/node_modules/native': {
|
||||
version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
|
||||
integrity: 'sha512-approved',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
soundApproval({ path: 'node_modules/outer/node_modules/native', name: 'outer' }),
|
||||
)).toThrow(/name mismatch/i);
|
||||
});
|
||||
|
||||
it('accepts a nested path when the approved name matches its last segment', () => {
|
||||
expect(validateRebuildEntries(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/outer/node_modules/native': {
|
||||
version: '1.0.0',
|
||||
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
|
||||
integrity: 'sha512-approved',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
soundApproval({ path: 'node_modules/outer/node_modules/native' }),
|
||||
)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses a verifier it does not know, instead of silently skipping', () => {
|
||||
// verify を即 return にしても 35 件すべて緑だった(レビュー M7)。
|
||||
// 「実際にデータベースを開いて確かめる」が丸ごと消えても誰も気づかない状態。
|
||||
expect(() => verify('no-such-verifier')).toThrow(/unsupported native rebuild verifier/i);
|
||||
});
|
||||
|
||||
it('actually opens a database for the verifier this repository uses', () => {
|
||||
// 中身が消えたら(即 return になったら)ここが空振りになるので、
|
||||
// 検証が本当に走ったことを、走った先の失敗で見る
|
||||
expect(() => verify('better-sqlite3-memory-db')).not.toThrow();
|
||||
});
|
||||
|
||||
it('binds native rebuild permission to path, name, version, and integrity', () => {
|
||||
const lockfile = {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/native': {
|
||||
version: '2.0.0',
|
||||
resolved: 'https://registry.npmjs.org/native/-/native-2.0.0.tgz',
|
||||
integrity: 'sha512-new',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const approved = [{
|
||||
path: 'node_modules/native',
|
||||
name: 'native',
|
||||
version: '1.0.0',
|
||||
integrity: 'sha512-approved',
|
||||
verifier: 'better-sqlite3-memory-db',
|
||||
}];
|
||||
|
||||
expect(() => validateRebuildEntries(lockfile, approved)).toThrow(/version mismatch/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('how npm rebuild is invoked', () => {
|
||||
// 引数の並びに意味がある箇所。PR #1074 のレビューが、`--` を消しても
|
||||
// 35 件すべて緑だったと報告した(M6)。
|
||||
it('separates the package name from flags with --', () => {
|
||||
const args = rebuildArgs('better-sqlite3');
|
||||
expect(args[args.length - 2]).toBe('--');
|
||||
expect(args[args.length - 1]).toBe('better-sqlite3');
|
||||
});
|
||||
|
||||
it('builds from source instead of downloading a prebuilt binary', () => {
|
||||
// 配布物を取ってこないぶん安全側。消すと取得物が 1 つ増える
|
||||
expect(rebuildArgs('better-sqlite3')).toContain('--build-from-source');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { posix as posixPath } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const TRUSTED_REGISTRY_ORIGIN = 'https://registry.npmjs.org/';
|
||||
const DEFAULT_ALLOWLIST_PATH = 'security/install-scripts-allowlist.json';
|
||||
|
||||
export function isAllowedRegistryUrl(resolved, registryOrigin) {
|
||||
try {
|
||||
const url = new URL(resolved);
|
||||
const registry = new URL(registryOrigin);
|
||||
return (
|
||||
url.protocol === registry.protocol &&
|
||||
url.hostname === registry.hostname &&
|
||||
url.port === registry.port &&
|
||||
url.username === '' &&
|
||||
url.password === '' &&
|
||||
url.pathname.startsWith(registry.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// npm はリポジトリ内の workspace を、ダウンロード物ではなくリポジトリ相対パスの
|
||||
// キーで記録する(ルートの package-lock.json なら packages/foo、ui/ 配下からは
|
||||
// ../packages/foo)。レジストリから取得する依存は必ず node_modules/ 区間を含む。
|
||||
function isWorkspaceSourcePath(name) {
|
||||
return !name.startsWith('node_modules/') && !name.includes('/node_modules/');
|
||||
}
|
||||
|
||||
/**
|
||||
* link の指す先が、リポジトリの内側に収まっているか。
|
||||
*
|
||||
* この関数が在る理由
|
||||
* ------------------
|
||||
* link を「同じ lockfile にキーとして在るか」だけで通していた時期がある。その条件は
|
||||
* 歯止めにならない——lockfile を書ける側が link と実体キーの両方を書けるので、
|
||||
* `node_modules/evil -> ../../../../tmp/payload` に同名のキーを併記するだけで、
|
||||
* 取得元・ハッシュ・レジストリ判定のすべてを回避できた(PR #1074 のレビューで実証)。
|
||||
*
|
||||
* そこで、lockfile の置き場所を基準に解決して、リポジトリルートより上へ出ないことを
|
||||
* 確かめる。`ui/package-lock.json` の `../packages/answer-worker-contract` は通り、
|
||||
* それ以上さかのぼるものと絶対パスは止まる。
|
||||
*
|
||||
* @param resolved lockfile に書かれた link の指す先(リポジトリ相対)
|
||||
* @param lockfileDir lockfile が置かれたディレクトリ(リポジトリルート基準・posix)
|
||||
*/
|
||||
export function isLinkInsideRepo(resolved, lockfileDir) {
|
||||
if (typeof resolved !== 'string' || resolved === '') return false;
|
||||
// 絶対パスと Windows のドライブ表記は、リポジトリ相対ではないので受け付けない。
|
||||
if (posixPath.isAbsolute(resolved) || /^[A-Za-z]:/.test(resolved)) return false;
|
||||
const joined = posixPath.normalize(posixPath.join(lockfileDir, resolved));
|
||||
return joined !== '..' && !joined.startsWith('../');
|
||||
}
|
||||
|
||||
export function checkLockfilePolicy(
|
||||
lockfile,
|
||||
allowlist,
|
||||
registryOrigin = TRUSTED_REGISTRY_ORIGIN,
|
||||
lockfileDir = '.',
|
||||
) {
|
||||
if (lockfile?.lockfileVersion !== 3 || typeof lockfile.packages !== 'object') {
|
||||
throw new Error('dependency policy requires npm lockfile version 3 with a packages map');
|
||||
}
|
||||
if (!Array.isArray(allowlist) || !allowlist.every((entry) => typeof entry === 'string')) {
|
||||
throw new Error('install script allowlist must be an array of lockfile package paths');
|
||||
}
|
||||
|
||||
const allowed = new Set(allowlist);
|
||||
const violations = [];
|
||||
|
||||
for (const [name, entry] of Object.entries(lockfile.packages)) {
|
||||
if (!name || !entry || typeof entry !== 'object') continue;
|
||||
|
||||
// link は同じ lockfile 内に実体キーがある場合だけ認める。実体を持たない link は
|
||||
// 追跡できない外部の入力を指すため、従来どおり拒否する。
|
||||
// 実体キーが在ることに加えて、指す先がリポジトリの内側であることまで見る。
|
||||
// 片方だけでは、lockfile を書ける側が両方を握って素通りできる。
|
||||
const linkTargetIsInLockfile =
|
||||
entry.link === true &&
|
||||
typeof entry.resolved === 'string' &&
|
||||
Object.hasOwn(lockfile.packages, entry.resolved) &&
|
||||
isLinkInsideRepo(entry.resolved, lockfileDir);
|
||||
const isWorkspaceSource = isWorkspaceSourcePath(name);
|
||||
|
||||
if (entry.link && !linkTargetIsInLockfile) {
|
||||
violations.push({ code: 'unsupported-link', name });
|
||||
} else if (!entry.link && !isWorkspaceSource && !entry.resolved) {
|
||||
violations.push({ code: 'missing-resolved', name });
|
||||
} else if (
|
||||
!entry.link &&
|
||||
entry.resolved &&
|
||||
!isAllowedRegistryUrl(entry.resolved, registryOrigin)
|
||||
) {
|
||||
violations.push({ code: 'non-registry-url', name });
|
||||
}
|
||||
|
||||
// workspace の実体にも install script 検査は残す(多層防御)。
|
||||
if (entry.hasInstallScript && !allowed.has(name)) {
|
||||
violations.push({ code: 'unapproved-install-script', name });
|
||||
}
|
||||
|
||||
// リポジトリ内 workspace はダウンロードしないので integrity を持たない。
|
||||
if (!entry.link && !isWorkspaceSource && !entry.integrity) {
|
||||
violations.push({ code: 'missing-integrity', name });
|
||||
}
|
||||
}
|
||||
|
||||
// 許可一覧の腐敗検知。install script を持たなくなった依存が一覧に残ると、
|
||||
// 将来その依存が再び script を持ったときに無審査で通ってしまう。
|
||||
for (const path of allowlist) {
|
||||
const entry = lockfile.packages[path];
|
||||
if (!entry || !entry.hasInstallScript) {
|
||||
violations.push({ code: 'stale-allowlist-entry', name: path });
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
export async function main(args) {
|
||||
if (args.length === 0) {
|
||||
throw new Error('usage: dependency-policy.mjs <package-lock.json> [...]');
|
||||
}
|
||||
|
||||
const policy = await readJson(DEFAULT_ALLOWLIST_PATH);
|
||||
let failed = false;
|
||||
|
||||
for (const lockfilePath of args) {
|
||||
const lockfile = await readJson(lockfilePath);
|
||||
const allowlist = policy.lockfiles?.[lockfilePath];
|
||||
if (!Array.isArray(allowlist)) {
|
||||
throw new Error(`no install script allowlist is defined for ${lockfilePath}`);
|
||||
}
|
||||
|
||||
const lockfileDir = posixPath.dirname(lockfilePath.split('\\').join('/'));
|
||||
const violations = checkLockfilePolicy(lockfile, allowlist, TRUSTED_REGISTRY_ORIGIN, lockfileDir);
|
||||
for (const violation of violations) {
|
||||
failed = true;
|
||||
process.stderr.write(`${lockfilePath}: ${violation.code}: ${violation.name}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main(process.argv.slice(2)).catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { checkLockfilePolicy } from './dependency-policy.mjs';
|
||||
import { validateRebuildEntries } from './rebuild-native.mjs';
|
||||
|
||||
const lockedNative = {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/better-sqlite3': {
|
||||
version: '12.6.2',
|
||||
resolved: 'https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz',
|
||||
integrity: 'sha512-approved',
|
||||
hasInstallScript: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function approval(name) {
|
||||
return [{
|
||||
path: 'node_modules/better-sqlite3',
|
||||
name,
|
||||
version: '12.6.2',
|
||||
integrity: 'sha512-approved',
|
||||
verifier: 'better-sqlite3-memory-db',
|
||||
}];
|
||||
}
|
||||
|
||||
describe('native rebuild adversarial cases', () => {
|
||||
it('rejects a package name that does not match the approved lockfile path', () => {
|
||||
expect(() => validateRebuildEntries(lockedNative, approval('sharp'))).toThrow(
|
||||
/name mismatch/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects npm option injection as a package name', () => {
|
||||
expect(() => validateRebuildEntries(lockedNative, approval('--prefix=/tmp/evil'))).toThrow(
|
||||
/invalid package name|name mismatch/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects lockfile links because this repository has no workspaces', () => {
|
||||
const violations = checkLockfilePolicy(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/evil': { link: true },
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
expect(violations).toContainEqual({
|
||||
code: 'unsupported-link',
|
||||
name: 'node_modules/evil',
|
||||
});
|
||||
});
|
||||
|
||||
it('can be imported when a Node eval has no script argv', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['-e', "import('./scripts/security/rebuild-native.mjs')"],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' },
|
||||
);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const LOCKFILE_PATH = 'package-lock.json';
|
||||
const POLICY_PATH = 'security/install-scripts-allowlist.json';
|
||||
const SUPPORTED_VERIFIERS = new Set(['better-sqlite3-memory-db']);
|
||||
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
|
||||
|
||||
function packageNameFromPath(path) {
|
||||
const marker = "node_modules/";
|
||||
const index = path.lastIndexOf(marker);
|
||||
return index === -1 ? "" : path.slice(index + marker.length);
|
||||
}
|
||||
|
||||
export function validateRebuildEntries(lockfile, approvedEntries) {
|
||||
if (lockfile?.lockfileVersion !== 3 || typeof lockfile.packages !== 'object') {
|
||||
throw new Error('native rebuild requires npm lockfile version 3');
|
||||
}
|
||||
if (!Array.isArray(approvedEntries)) {
|
||||
throw new Error('native rebuild allowlist must be an array');
|
||||
}
|
||||
|
||||
return approvedEntries.map((approval) => {
|
||||
for (const field of ['path', 'name', 'version', 'integrity', 'verifier']) {
|
||||
if (typeof approval?.[field] !== 'string' || approval[field] === '') {
|
||||
throw new Error(`native rebuild approval is missing ${field}`);
|
||||
}
|
||||
}
|
||||
if (!SUPPORTED_VERIFIERS.has(approval.verifier)) {
|
||||
throw new Error(`unsupported native rebuild verifier: ${approval.verifier}`);
|
||||
}
|
||||
|
||||
if (!PACKAGE_NAME.test(approval.name)) {
|
||||
throw new Error("invalid package name for native rebuild: " + approval.name);
|
||||
}
|
||||
if (packageNameFromPath(approval.path) !== approval.name) {
|
||||
throw new Error("native rebuild name mismatch for " + approval.path);
|
||||
}
|
||||
|
||||
const locked = lockfile.packages[approval.path];
|
||||
if (!locked) throw new Error(`native rebuild path missing from lockfile: ${approval.path}`);
|
||||
if (!locked.hasInstallScript) {
|
||||
throw new Error(`native rebuild target has no install script: ${approval.path}`);
|
||||
}
|
||||
if (locked.version !== approval.version) {
|
||||
throw new Error(`native rebuild version mismatch for ${approval.path}`);
|
||||
}
|
||||
if (locked.integrity !== approval.integrity) {
|
||||
throw new Error(`native rebuild integrity mismatch for ${approval.path}`);
|
||||
}
|
||||
|
||||
return { ...approval };
|
||||
});
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
env: { ...process.env, npm_config_audit: 'false' },
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `npm rebuild` に渡す引数。
|
||||
*
|
||||
* `--build-from-source` は、配布済みバイナリを取ってこさせず必ず手元で組ませる
|
||||
* ための指定(取得物を 1 つ減らすので安全側)。CI の runner にコンパイラ・make・
|
||||
* python3 が要るようになる代わりなので、消すときはそこまで見ること。
|
||||
*
|
||||
* `--` は、名前が `-` で始まっていても旗として解釈させないための区切り。名前は
|
||||
* すでに PACKAGE_NAME で縛ってあるので二重の守りだが、縛りのほうが緩んだときに
|
||||
* ここだけで止まる形にしておく。
|
||||
*/
|
||||
export function rebuildArgs(name) {
|
||||
return ['rebuild', '--build-from-source', '--foreground-scripts', '--no-audit', '--', name];
|
||||
}
|
||||
|
||||
export function verify(verifier) {
|
||||
if (verifier === 'better-sqlite3-memory-db') {
|
||||
run(process.execPath, [
|
||||
'-e',
|
||||
"const Database=require('better-sqlite3'); new Database(':memory:').close()",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
throw new Error(`unsupported native rebuild verifier: ${verifier}`);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const [lockfile, policy] = await Promise.all([
|
||||
readFile(LOCKFILE_PATH, 'utf8').then(JSON.parse),
|
||||
readFile(POLICY_PATH, 'utf8').then(JSON.parse),
|
||||
]);
|
||||
const approved = validateRebuildEntries(lockfile, policy.rebuild?.[LOCKFILE_PATH]);
|
||||
|
||||
for (const entry of approved) {
|
||||
run('npm', rebuildArgs(entry.name));
|
||||
verify(entry.verifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { parse } from 'yaml';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const workflowPath = new URL('../../.gitea/workflows/ci.yml', import.meta.url);
|
||||
|
||||
describe('CI workflow semantic security policy', () => {
|
||||
it('pins actions, minimizes permissions, and leaves no checkout credential', async () => {
|
||||
const source = await readFile(workflowPath, 'utf8');
|
||||
const workflow = parse(source);
|
||||
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' });
|
||||
expect(workflow.on).toHaveProperty('pull_request');
|
||||
expect(source).not.toContain('${{ secrets.');
|
||||
|
||||
for (const job of Object.values(workflow.jobs)) {
|
||||
expect(job.permissions ?? workflow.permissions).toEqual({ contents: 'read' });
|
||||
for (const step of job.steps ?? []) {
|
||||
if (!step.uses) continue;
|
||||
expect(step.uses).toMatch(/^[^@\s]+@[a-f0-9]{40}$/);
|
||||
expect(step.uses).not.toMatch(/(?:^|\/)cache(?:\/(?:restore|save))?@/);
|
||||
expect(step.with ?? {}).not.toHaveProperty('cache');
|
||||
if (step.uses.startsWith('actions/checkout@')) {
|
||||
expect(step.with?.['persist-credentials']).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not invoke repository package hooks before policy or native rebuild', async () => {
|
||||
const source = await readFile(workflowPath, 'utf8');
|
||||
|
||||
expect(source).toContain(
|
||||
'node scripts/security/dependency-policy.mjs package-lock.json ui/package-lock.json',
|
||||
);
|
||||
expect(source).toContain('node scripts/security/rebuild-native.mjs');
|
||||
expect(source).not.toContain('npm run security:dependency-policy');
|
||||
expect(source).not.toContain('npm run ci:rebuild-native');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user