- Updated the build_web_crawler_sidecar.py script to remove full Chromium from bundled browsers and added a function to prune non-runtime files. - Enhanced create-release-fragment.mjs to validate package security audit data. - Introduced package-content-policy.mjs to enforce restrictions on forbidden file types and directories in packages. - Added prepare-package-stage.mjs for staging package preparation, including runtime dependency installation and resource pruning. - Implemented print-package-audit-summary.mjs to generate a summary of package security audits. - Created runtime-bundle-graph.mjs to analyze runtime dependencies and collect package names. - Refactored verify-runtime-bundles.mjs to utilize the new runtime-bundle-graph module for better dependency management. - Updated release-fragment-contract.test.ts to include tests for package audit integration and validation.
131 lines
4.8 KiB
JavaScript
131 lines
4.8 KiB
JavaScript
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { listPackage, extractFile, getRawHeader } from '@electron/asar'
|
|
import {
|
|
assertMinimalPackageJson,
|
|
assertSafePackagePaths,
|
|
auditPackageDirectory,
|
|
scanTextForSecrets
|
|
} from './package-content-policy.mjs'
|
|
|
|
const inputDirectory = path.resolve(process.argv[2] || 'dist')
|
|
const reportPath = path.join(inputDirectory, 'package-audit.json')
|
|
|
|
const findFiles = (directory, predicate) => {
|
|
const result = []
|
|
const walk = (current) => {
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
const entryPath = path.join(current, entry.name)
|
|
if (entry.isDirectory()) walk(entryPath)
|
|
else if (entry.isFile() && predicate(entryPath)) result.push(entryPath)
|
|
}
|
|
}
|
|
walk(directory)
|
|
return result
|
|
}
|
|
|
|
const findApplicationRoot = (archivePath) => {
|
|
let current = path.dirname(archivePath)
|
|
while (current !== path.dirname(current)) {
|
|
if (current.endsWith('.app')) return current
|
|
current = path.dirname(current)
|
|
}
|
|
return path.dirname(path.dirname(archivePath))
|
|
}
|
|
|
|
const listArchiveFiles = (archivePath) => {
|
|
const files = []
|
|
const walk = (node, parent = '') => {
|
|
for (const [name, entry] of Object.entries(node.files || {})) {
|
|
const relativePath = parent ? `${parent}/${name}` : name
|
|
if (entry.files) walk(entry, relativePath)
|
|
else
|
|
files.push({
|
|
relativePath,
|
|
size: Number(entry.size || 0),
|
|
unpacked: Boolean(entry.unpacked)
|
|
})
|
|
}
|
|
}
|
|
walk(getRawHeader(archivePath).header)
|
|
return files
|
|
}
|
|
|
|
const formatBytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MiB`
|
|
|
|
if (!fs.existsSync(inputDirectory)) throw new Error(`package output not found: ${inputDirectory}`)
|
|
|
|
const archives = findFiles(inputDirectory, (filePath) => path.basename(filePath) === 'app.asar')
|
|
if (archives.length === 0) throw new Error(`no packaged app.asar found under ${inputDirectory}`)
|
|
|
|
const applications = []
|
|
for (const archivePath of archives) {
|
|
const archiveEntries = listPackage(archivePath).map((entry) => entry.replace(/^\//u, ''))
|
|
assertSafePackagePaths(archiveEntries)
|
|
const archiveRoots = [...new Set(archiveEntries.map((entry) => entry.split('/')[0]))].sort()
|
|
const unexpectedRoots = archiveRoots.filter(
|
|
(name) => !['node_modules', 'out', 'package.json'].includes(name)
|
|
)
|
|
if (unexpectedRoots.length > 0) {
|
|
throw new Error(`unexpected app.asar root entries: ${unexpectedRoots.join(', ')}`)
|
|
}
|
|
|
|
const packagedPackageJson = JSON.parse(extractFile(archivePath, 'package.json').toString('utf8'))
|
|
assertMinimalPackageJson(packagedPackageJson, `${archivePath}:package.json`)
|
|
|
|
const archiveFiles = listArchiveFiles(archivePath)
|
|
for (const item of archiveFiles) {
|
|
if (!item.relativePath.startsWith('out/') || item.size > 25 * 1024 * 1024) continue
|
|
if (!/\.(?:js|cjs|mjs|json|html|css)$/u.test(item.relativePath)) continue
|
|
const source = extractFile(archivePath, item.relativePath).toString('utf8')
|
|
scanTextForSecrets(source, `${archivePath}:${item.relativePath}`)
|
|
}
|
|
|
|
const appRoot = findApplicationRoot(archivePath)
|
|
const directoryAudit = auditPackageDirectory(appRoot)
|
|
const totalBytes = directoryAudit.files.reduce((sum, item) => sum + item.size, 0)
|
|
const topFiles = [...directoryAudit.files]
|
|
.sort((left, right) => right.size - left.size)
|
|
.slice(0, 30)
|
|
.map((item) => ({ path: item.relativePath, bytes: item.size }))
|
|
applications.push({
|
|
root: path.relative(inputDirectory, appRoot) || '.',
|
|
archive: path.relative(inputDirectory, archivePath),
|
|
totalBytes,
|
|
archiveBytes: fs.statSync(archivePath).size,
|
|
archiveFileCount: archiveFiles.length,
|
|
unpackedPayloadBytes: archiveFiles
|
|
.filter((item) => item.unpacked)
|
|
.reduce((sum, item) => sum + item.size, 0),
|
|
topFiles
|
|
})
|
|
process.stdout.write(
|
|
`[package] audit passed: ${path.basename(appRoot)} ${formatBytes(totalBytes)}, app.asar ${formatBytes(fs.statSync(archivePath).size)}\n`
|
|
)
|
|
}
|
|
|
|
const installerExtensions = new Set(['.dmg', '.zip', '.exe', '.appimage', '.deb', '.snap'])
|
|
const installers = fs
|
|
.readdirSync(inputDirectory, { withFileTypes: true })
|
|
.filter(
|
|
(entry) => entry.isFile() && installerExtensions.has(path.extname(entry.name).toLowerCase())
|
|
)
|
|
.map((entry) => ({
|
|
name: entry.name,
|
|
bytes: fs.statSync(path.join(inputDirectory, entry.name)).size
|
|
}))
|
|
.sort((left, right) => right.bytes - left.bytes)
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
applications,
|
|
installers
|
|
}
|
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
|
process.stdout.write(`[package] audit report: ${reportPath}\n`)
|