Files
maestro/scripts/security/check-local-ui-assets.mjs
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

382 lines
17 KiB
JavaScript

#!/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));