diff --git a/scripts/ci/verify-host-leakage.js b/scripts/ci/verify-host-leakage.js new file mode 100644 index 00000000..dfabc61d --- /dev/null +++ b/scripts/ci/verify-host-leakage.js @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Scans the packaged artifact for absolute paths pointing at the BUILD host (e.g. C:\Users\EricZ, /Users/) baked into shipped files - the exact "works on my machine" failure mode where a path resolves on the dev's box but never on a user's. + +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const h = require('./lib/app-harness'); + +function parseArgs(argv) { + const out = { app: null, allow: [] }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--app') out.app = argv[++i]; + else if (argv[i] === '--allow') out.allow.push(argv[++i]); + } + return out; +} + +// Build-host paths a packaged artifact must NEVER contain. +function hostPatterns() { + const homeWin = `C:\\Users\\${os.userInfo().username}`; + const homePosix = (process.env.HOME || ''); + const patterns = []; + // Windows-style: backslashes get JSON-double-escaped in source maps, so we + // search both literal-backslash and double-backslash forms. + if (process.platform === 'win32') { + patterns.push(homeWin); + patterns.push(homeWin.replace(/\\/g, '\\\\')); + patterns.push(homeWin.replace(/\\/g, '/')); + } + // POSIX-style: any /Users/ or /home/ from the build machine. + if (homePosix) patterns.push(homePosix); + // Source-map sentinels often containing dev-only roots. + patterns.push('/Users/runner/work'); // GH Actions macos runner home + patterns.push('D:\\a\\openswarm'); // GH Actions windows runner work dir + patterns.push('/home/runner/work'); // GH Actions linux runner work dir + // Belt-and-braces: literal username regardless of platform. + patterns.push(os.userInfo().username); + return Array.from(new Set(patterns.filter((p) => p && p.length > 4))); +} + +// File extensions worth scanning. We skip binaries (large + irrelevant) and +// only inspect text-ish files where a path could meaningfully be baked. +function shouldScan(file) { + const ext = path.extname(file).toLowerCase(); + if (['.js', '.mjs', '.cjs', '.json', '.html', '.css', '.txt', '.map', '.cfg', '.ini', '.toml', '.yml', '.yaml', '.py'].includes(ext)) return true; + // app.asar is a packed binary holding the entire main+renderer JS bundle. We + // string-scan it the same way - false positives are unlikely on host-path + // patterns because they don't naturally appear in minified bundle bytes. + if (file.endsWith('.asar')) return true; + return false; +} + +// Files known to bake build paths by design (PEP 610 pip metadata, debug +// manifests etc.). Allowed by default so the gate stays quiet on benign +// noise and loud on real leakage. +const DEFAULT_ALLOW = [ + /\.dist-info[\\/]direct_url\.json$/, // PEP 610 install provenance, not read at runtime + /\.dist-info[\\/]RECORD$/, // wheel file list, lists relative paths only +]; + +function walk(dir, out = []) { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, out); + else if (e.isFile()) out.push(p); + } + return out; +} + +// Pure: scan a byte buffer for any of the patterns. Used by both the disk +// path (scanFile) and the selftest harness, which feeds synthetic buffers +// to mutation-check the gate's discrimination. +function scanBuffer(buf, patterns) { + const text = Buffer.isBuffer(buf) ? buf.toString('binary') : String(buf); + const hits = []; + for (const pat of patterns) { + let idx = 0; + while ((idx = text.indexOf(pat, idx)) !== -1) { + const start = Math.max(0, idx - 24); + const end = Math.min(text.length, idx + pat.length + 40); + hits.push({ pattern: pat, offset: idx, context: text.slice(start, end).replace(/[\x00-\x1f]/g, '.') }); + idx += pat.length; + if (hits.length > 3) break; + } + } + return hits; +} + +function scanFile(file, patterns) { + try { return scanBuffer(fs.readFileSync(file), patterns); } + catch { return []; } +} + +module.exports = { hostPatterns, scanBuffer, scanFile, shouldScan, DEFAULT_ALLOW }; + +function main() { + const args = parseArgs(process.argv.slice(2)); + const appExe = h.packagedAppPath(args.app); + // For an unpacked Electron build the artifact dir is the .exe's parent (Windows) + // or two levels above the binary inside the .app (macOS). + const artifactRoot = process.platform === 'darwin' + ? path.dirname(path.dirname(path.dirname(appExe))) + : path.dirname(appExe); + + process.stdout.write(`Scanning artifact root: ${artifactRoot}\n`); + const patterns = hostPatterns(); + const allow = args.allow; + process.stdout.write(`Looking for ${patterns.length} host-path pattern(s): ${patterns.join(', ')}\n`); + + const files = walk(artifactRoot).filter(shouldScan); + process.stdout.write(`Scanning ${files.length} text-ish files...\n`); + + const findings = []; + for (const f of files) { + const hits = scanFile(f, patterns); + if (!hits.length) continue; + // Built-in known-benign + caller-supplied allow regexes. + if (DEFAULT_ALLOW.some((rx) => rx.test(f))) continue; + if (allow.some((rx) => new RegExp(rx).test(f))) continue; + findings.push({ file: f, hits }); + } + + if (findings.length === 0) { + process.stdout.write('\nHOST-LEAKAGE PASS: no build-host paths found inside the packaged artifact.\n'); + process.exit(0); + } + + process.stderr.write(`\nHOST-LEAKAGE FAIL: ${findings.length} file(s) contain build-host paths:\n`); + for (const { file, hits } of findings.slice(0, 50)) { + process.stderr.write(`\n ${file}\n`); + for (const h of hits.slice(0, 3)) process.stderr.write(` @${h.offset} matches "${h.pattern}": ...${h.context}...\n`); + } + if (findings.length > 50) process.stderr.write(`\n ...and ${findings.length - 50} more files.\n`); + process.stderr.write('\nThese paths exist on the build host but will not exist on a user machine.\n'); + process.stderr.write('Pass --allow to whitelist false positives (e.g. webpack source-map dev paths).\n'); + process.exit(1); +} + +if (require.main === module) main();