sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+399
View File
@@ -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);
+193
View File
@@ -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 });
}
+113
View File
@@ -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&section=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 });
}
+381
View File
@@ -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));
+28
View File
@@ -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);
});
});
+80
View File
@@ -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');
});
});
+159
View File
@@ -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;
});
}
+318
View File
@@ -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);
});
});
+115
View File
@@ -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;
});
}
+41
View File
@@ -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');
});
});