'use strict'; const fs = require('node:fs'); const path = require('node:path'); const zlib = require('node:zlib'); const { spawnSync } = require('node:child_process'); const ROOT = path.resolve(__dirname, '..'); const REQUIRED_EXTENSION_FILES = Object.freeze([ 'manifest.json', 'popup.html', 'popup.js', 'popup.css', 'options.html', 'options.js', 'options.css', ]); const TEXT_EXTENSIONS = new Set(['.css', '.html', '.js', '.json', '.md', '.ps1', '.yml', '.yaml']); const FORBIDDEN_PATTERNS = Object.freeze([ ['legacy bridge token', /apt-local-bridge-token/i], ['legacy pairing header', /X-APT-Token/i], ['legacy cookie proxy', /cookie-proxy-/i], ['GitHub release API', /api\.github\.com/i], ['executable updater', /download-and-install-update/i], ['broad process termination', /taskkill/i], ]); function fail(message) { throw new Error(message); } function normalizeRelative(name) { if (typeof name !== 'string' || name.length === 0 || name.includes('\0')) fail(`Unsafe empty or NUL path: ${name}`); const unix = name.replaceAll('\\', '/'); if (unix.startsWith('/') || /^[A-Za-z]:\//.test(unix)) fail(`Unsafe absolute path: ${name}`); const normalized = path.posix.normalize(unix); if (normalized === '..' || normalized.startsWith('../')) fail(`Unsafe parent path: ${name}`); return normalized.replace(/^\.\//, ''); } function assertTextPolicy(name, content) { for (const [label, pattern] of FORBIDDEN_PATTERNS) { if (pattern.test(content)) fail(`Forbidden ${label} string in ${name}`); } } function listDirectory(root, { ignoreTopLevel = [] } = {}) { const absoluteRoot = path.resolve(root); if (!fs.statSync(absoluteRoot).isDirectory()) fail(`Expected a directory: ${root}`); const files = new Map(); const ignored = new Set(ignoreTopLevel); const walk = (directory) => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const absolute = path.join(directory, entry.name); const relative = normalizeRelative(path.relative(absoluteRoot, absolute)); if (!relative.includes('/') && ignored.has(relative)) continue; if (entry.isSymbolicLink()) fail(`Unsafe symbolic link: ${relative}`); if (entry.isDirectory()) walk(absolute); else if (entry.isFile()) files.set(relative, () => fs.readFileSync(absolute)); else fail(`Unsupported filesystem entry: ${relative}`); } }; walk(absoluteRoot); return files; } function findEndOfCentralDirectory(buffer) { const minimum = Math.max(0, buffer.length - 65_557); for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) { if (buffer.readUInt32LE(offset) === 0x06054b50) return offset; } fail('Invalid ZIP: end-of-central-directory record not found'); } function listZip(zipPath) { const zip = fs.readFileSync(zipPath); const end = findEndOfCentralDirectory(zip); const entryCount = zip.readUInt16LE(end + 10); let offset = zip.readUInt32LE(end + 16); const files = new Map(); for (let index = 0; index < entryCount; index += 1) { if (zip.readUInt32LE(offset) !== 0x02014b50) fail('Invalid ZIP central-directory entry'); const flags = zip.readUInt16LE(offset + 8); const method = zip.readUInt16LE(offset + 10); const compressedSize = zip.readUInt32LE(offset + 20); const uncompressedSize = zip.readUInt32LE(offset + 24); const nameLength = zip.readUInt16LE(offset + 28); const extraLength = zip.readUInt16LE(offset + 30); const commentLength = zip.readUInt16LE(offset + 32); const externalAttributes = zip.readUInt32LE(offset + 38); const localOffset = zip.readUInt32LE(offset + 42); const name = normalizeRelative(zip.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')); offset += 46 + nameLength + extraLength + commentLength; if (name.endsWith('/')) continue; const unixMode = externalAttributes >>> 16; if ((unixMode & 0o170000) === 0o120000) fail(`Unsafe ZIP symbolic link: ${name}`); if ((flags & 1) !== 0) fail(`Encrypted ZIP entries are unsupported: ${name}`); if (files.has(name)) fail(`Duplicate ZIP entry: ${name}`); files.set(name, () => { if (zip.readUInt32LE(localOffset) !== 0x04034b50) fail(`Invalid ZIP local entry: ${name}`); const localNameLength = zip.readUInt16LE(localOffset + 26); const localExtraLength = zip.readUInt16LE(localOffset + 28); const start = localOffset + 30 + localNameLength + localExtraLength; const compressed = zip.subarray(start, start + compressedSize); const content = method === 0 ? compressed : method === 8 ? zlib.inflateRawSync(compressed) : fail(`Unsupported ZIP method ${method}: ${name}`); if (content.length !== uncompressedSize) fail(`Invalid ZIP size for ${name}`); return content; }); } return files; } function listAsar(asarPath) { const asar = require('@electron/asar'); const files = new Map(); for (const archiveName of asar.listPackage(asarPath)) { if (!archiveName.startsWith('/')) fail(`Unsafe ASAR path: ${archiveName}`); const name = normalizeRelative(archiveName.slice(1)); let stat; try { stat = asar.statFile(asarPath, name); } catch (error) { fail(`Invalid ASAR entry ${name}: ${error.message}`); } if (!Number.isSafeInteger(stat.size)) continue; if (!name.startsWith('node_modules/')) files.set(name, () => asar.extractFile(asarPath, name)); } return files; } function findFile(files, suffix) { const matches = [...files.keys()].filter((name) => name === suffix || name.endsWith(`/${suffix}`)); if (matches.length !== 1) fail(`Expected exactly one ${suffix}, found ${matches.length}`); return matches[0]; } function verifyExtension(files, expectedVersion) { const manifestName = findFile(files, 'chrome-extension/manifest.json'); const extensionRoot = manifestName.slice(0, -'manifest.json'.length); for (const required of REQUIRED_EXTENSION_FILES) { if (!files.has(`${extensionRoot}${required}`)) fail(`Missing expected extension file: ${required}`); } let manifest; try { manifest = JSON.parse(files.get(manifestName)().toString('utf8')); } catch (error) { fail(`Invalid extension manifest: ${error.message}`); } if (manifest.version !== expectedVersion) { fail(`Extension version ${manifest.version} does not match application version ${expectedVersion}`); } } function verifyTextFiles(files) { for (const [name, load] of files) { if (TEXT_EXTENSIONS.has(path.extname(name).toLowerCase())) assertTextPolicy(name, load().toString('utf8')); } } function verifySyntax(files) { const javascript = [...files.keys()].filter((name) => name.endsWith('.js')); javascript.push('scripts/verify-kit.js'); for (const name of javascript) { const result = spawnSync(process.execPath, ['--check', path.join(ROOT, name)], { encoding: 'utf8' }); if (result.status !== 0) fail(`JavaScript syntax check failed for ${name}: ${result.stderr.trim()}`); } } function verifySource() { const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); const lock = JSON.parse(fs.readFileSync(path.join(ROOT, 'package-lock.json'), 'utf8')); const files = listDirectory(ROOT, { ignoreTopLevel: ['.git', 'dist', 'node_modules'] }); verifyExtension(files, pkg.version); for (const [label, version] of [['lockfile', lock.version], ['lockfile root package', lock.packages?.['']?.version]]) { if (version !== pkg.version) fail(`${label} version ${version} does not match application version ${pkg.version}`); } const productionRoots = [ 'main.js', 'preload.js', 'renderer.js', 'renderer-controller.js', 'device-tree.js', 'sidebar-controller.js', 'sidebar-view.js', 'index.html', 'src/', 'chrome-extension/', ]; const productionFiles = new Map([...files].filter(([name]) => productionRoots.some((root) => name === root || name.startsWith(root)) )); verifyTextFiles(productionFiles); verifySyntax(productionFiles); return pkg.version; } function verifySupplied(target, expectedVersion, mode) { const absolute = path.resolve(target); if (!fs.existsSync(absolute)) fail(`Supplied ${mode} does not exist: ${target}`); const isDirectory = fs.statSync(absolute).isDirectory(); const files = isDirectory ? listDirectory(absolute) : listZip(absolute); const hostNames = [...files.keys()]; if (mode === 'build' && !hostNames.includes('chrome-extension/manifest.json')) { const asarName = hostNames.find((name) => /(?:^|\/)resources\/app\.asar$/i.test(name)); if (!asarName || !isDirectory) fail('Missing packaged app.asar in supplied build'); for (const [name, load] of listAsar(path.join(absolute, asarName))) files.set(name, load); } verifyExtension(files, expectedVersion); verifyTextFiles(files); if (!hostNames.some((name) => /(?:^|\/)Alta(?:CameraProxy| Camera Proxy)(?:-[^/]*)?\.exe$/i.test(name))) { fail(`Missing expected APT executable in supplied ${mode}`); } if (mode === 'kit' && !hostNames.some((name) => /(?:^|\/)aware-cam-proxy\.exe$/i.test(name))) { fail('Missing expected aware-cam-proxy.exe in supplied kit'); } } function main(argv) { const version = verifySource(); if (argv.length > 0) { const buildMode = argv[0] === '--build'; const target = buildMode ? argv[1] : argv[0]; if (!target || argv.length !== (buildMode ? 2 : 1)) fail('Usage: node scripts/verify-kit.js [--build |]'); verifySupplied(target, version, buildMode ? 'build' : 'kit'); } process.stdout.write(`APT source${argv.length ? ' and supplied artifact' : ''} verified at version ${version}.\n`); } if (require.main === module) { try { main(process.argv.slice(2)); } catch (error) { process.stderr.write(`Verification failed: ${error.message}\n`); process.exitCode = 1; } } module.exports = { normalizeRelative, verifySource, verifySupplied };