UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

1,557 lines (1,516 loc) 44.9 kB
import { createHash } from "node:crypto"; import { chmodSync, closeSync, openSync, readFileSync, readSync, statSync, symlinkSync, } from "node:fs"; import { basename, dirname, extname, join, relative, resolve } from "node:path"; import process from "node:process"; import { PackageURL } from "packageurl-js"; import { xml2js } from "xml-js"; import { analyzeJsCapabilitiesSource, analyzeSuspiciousJsSource, } from "./analyzer.js"; import { thoughtLog } from "./logger.js"; import { sanitizeBomPropertyValue } from "./propertySanitizer.js"; import { DEBUG_MODE, getTmpDir, isDryRun, recordActivity, safeCopyFileSync, safeExistsSync, safeExtractArchive, safeMkdirSync, safeMkdtempSync, safeRmSync, safeWriteSync, } from "./utils.js"; const ASAR_JS_ANALYSIS_EXTENSIONS = new Set([ ".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx", ]); const ASAR_LOCKFILE_NAMES = new Set([ "npm-shrinkwrap.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", ]); const ASAR_LIFECYCLE_SCRIPT_NAMES = new Set([ "install", "postinstall", "preinstall", "prepare", "prepublish", ]); const PICKLE_UINT32_SIZE = 4; const PICKLE_SIZE_PICKLE_BYTES = 8; const MAX_ASAR_HEADER_BYTES = 64 * 1024 * 1024; const MAX_ASAR_ENTRY_BYTES = 256 * 1024 * 1024; const MAX_ASAR_OFFSET = BigInt(Number.MAX_SAFE_INTEGER); const MAX_ASAR_HEADER_DEPTH = 256; const MAX_ELECTRON_APP_BUNDLE_SEARCH_DEPTH = 20; function addSanitizedProperty(properties, name, value) { if (value === undefined || value === null || value === "") { return; } const sanitizedValue = sanitizeBomPropertyValue(name, value); if ( sanitizedValue === undefined || sanitizedValue === null || sanitizedValue === "" ) { return; } properties.push({ name, value: String(sanitizedValue), }); } function toArchiveOccurrence(archivePath, entryPath) { return `${archivePath}#/${entryPath.replaceAll("\\", "/")}`; } function normalizeArchiveRelativePath(entryPath) { return String(entryPath || "") .replaceAll("\\", "/") .replace(/^\/+/, ""); } function isPathWithin(baseDir, candidatePath) { const relativePath = relative(resolve(baseDir), resolve(candidatePath)) .replaceAll("\\", "/") .replace(/^\/+/, ""); return ( relativePath === "" || (!relativePath.startsWith("..") && !relativePath.split("/").includes("..")) ); } function parseScopedPackageName(packageName) { if (!packageName || typeof packageName !== "string") { return { group: "", name: "" }; } if (packageName.startsWith("@")) { const [group, ...nameParts] = packageName.slice(1).split("/"); return { group, name: nameParts.join("/"), }; } return { group: "", name: packageName }; } function createAsarPackagePurl(packageName, version) { const parsedName = parseScopedPackageName(packageName); if (!parsedName.name) { return undefined; } return new PackageURL( "npm", parsedName.group || null, parsedName.name, version || null, null, null, ).toString(); } function createGenericArchivePurl(archivePath, version) { return new PackageURL( "generic", null, basename(archivePath, ".asar"), version || null, { type: "asar" }, null, ).toString(); } function parseAsarJson(headerString) { return JSON.parse(headerString, (_key, value) => { if (!value || typeof value !== "object" || Array.isArray(value)) { return value; } const nullPrototypeObject = Object.create(null); for (const [entryKey, entryValue] of Object.entries(value)) { nullPrototypeObject[entryKey] = entryValue; } return nullPrototypeObject; }); } function validateHeaderEntry(entry, entryPath, depth = 0) { if (depth > MAX_ASAR_HEADER_DEPTH) { throw new Error( `ASAR header nesting exceeds ${MAX_ASAR_HEADER_DEPTH} levels at ${entryPath || "/"}`, ); } if (!entry || typeof entry !== "object" || Array.isArray(entry)) { throw new Error(`Invalid ASAR entry at ${entryPath}`); } const hasFiles = Object.hasOwn(entry, "files"); const hasLink = Object.hasOwn(entry, "link"); const hasOffset = Object.hasOwn(entry, "offset"); const hasSize = Object.hasOwn(entry, "size"); if (hasLink) { if ( typeof entry.link !== "string" || !entry.link || hasFiles || entry.unpacked === true || hasOffset || hasSize ) { throw new Error(`Invalid ASAR symlink entry at ${entryPath}`); } return; } if (hasFiles) { if ( !entry.files || typeof entry.files !== "object" || Array.isArray(entry.files) || entry.unpacked === true || hasOffset || hasSize ) { throw new Error(`Invalid ASAR directory entry at ${entryPath}`); } for (const [name, child] of Object.entries(entry.files)) { if ( !name || name === "." || name === ".." || name === "__proto__" || name === "constructor" || name === "prototype" || name.includes("/") || name.includes("\\") ) { throw new Error(`Invalid ASAR child name "${name}" at ${entryPath}`); } validateHeaderEntry(child, `${entryPath}/${name}`, depth + 1); } return; } if (entry.unpacked === true) { if ( hasOffset || typeof entry.size !== "number" || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX_ASAR_ENTRY_BYTES ) { throw new Error(`Invalid ASAR unpacked file entry at ${entryPath}`); } return; } if ( typeof entry.offset !== "string" || !/^\d+$/.test(entry.offset) || typeof entry.size !== "number" || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX_ASAR_ENTRY_BYTES ) { throw new Error(`Invalid ASAR file entry at ${entryPath}`); } } function parseAsarHeaderString(headerBuffer) { if (headerBuffer.length < PICKLE_UINT32_SIZE * 2) { throw new Error("ASAR header pickle is too small."); } const payloadSize = headerBuffer.readUInt32LE(0); if (payloadSize > headerBuffer.length - PICKLE_UINT32_SIZE) { throw new Error("ASAR header payload exceeds archive header size."); } const stringLength = headerBuffer.readInt32LE(PICKLE_UINT32_SIZE); if (stringLength < 0 || stringLength > payloadSize - PICKLE_UINT32_SIZE) { throw new Error("ASAR header string length is invalid."); } return headerBuffer.toString( "utf8", PICKLE_UINT32_SIZE * 2, PICKLE_UINT32_SIZE * 2 + stringLength, ); } export function readAsarArchiveHeaderSync(archivePath) { const fd = openSync(archivePath, "r"); try { const sizeBuffer = Buffer.alloc(PICKLE_SIZE_PICKLE_BYTES); const sizeRead = readSync(fd, sizeBuffer, 0, PICKLE_SIZE_PICKLE_BYTES, 0); if (sizeRead !== PICKLE_SIZE_PICKLE_BYTES) { throw new Error( `Unable to read ASAR header size from ${archivePath}: expected ${PICKLE_SIZE_PICKLE_BYTES} bytes, got ${sizeRead}`, ); } const headerPickleSize = sizeBuffer.readUInt32LE(PICKLE_UINT32_SIZE); if ( headerPickleSize < PICKLE_UINT32_SIZE * 2 || headerPickleSize > MAX_ASAR_HEADER_BYTES ) { throw new Error( `Unsupported ASAR header size ${headerPickleSize} for ${archivePath}`, ); } const headerBuffer = Buffer.alloc(headerPickleSize); const headerRead = readSync( fd, headerBuffer, 0, headerPickleSize, PICKLE_SIZE_PICKLE_BYTES, ); if (headerRead !== headerPickleSize) { throw new Error( `Unable to read ASAR header from ${archivePath}: expected ${headerPickleSize} bytes, got ${headerRead}`, ); } const headerString = parseAsarHeaderString(headerBuffer); const header = parseAsarJson(headerString); if (!header?.files || typeof header.files !== "object") { throw new Error(`Invalid ASAR header root for ${archivePath}`); } validateHeaderEntry(header, "", 0); return { archiveDataOffset: BigInt(PICKLE_SIZE_PICKLE_BYTES + headerPickleSize), header, headerSize: headerPickleSize, headerString, }; } finally { closeSync(fd); } } export function listAsarEntries(archivePath) { const parsedHeader = readAsarArchiveHeaderSync(archivePath); const entries = []; const visitEntries = (filesNode, currentPath = "") => { for (const [name, child] of Object.entries(filesNode || {})) { const childPath = currentPath ? `${currentPath}/${name}` : name; if (child?.files) { entries.push({ path: childPath, type: "directory", unpacked: child.unpacked === true, }); visitEntries(child.files, childPath); continue; } if (child?.link) { entries.push({ link: child.link, path: childPath, type: "link", unpacked: child.unpacked === true, }); continue; } entries.push({ executable: child?.executable === true, integrity: child?.integrity, offset: child?.offset, path: childPath, size: Number(child?.size || 0), type: "file", unpacked: child?.unpacked === true, }); } }; visitEntries(parsedHeader.header.files); return { ...parsedHeader, entries: entries.sort((left, right) => left.path.localeCompare(right.path)), }; } function resolveUnpackedEntryPath(archivePath, entryPath) { const unpackedBaseDir = `${archivePath}.unpacked`; const normalizedEntryPath = normalizeArchiveRelativePath(entryPath); const resolvedEntryPath = resolve( unpackedBaseDir, ...normalizedEntryPath.split("/"), ); if (!isPathWithin(unpackedBaseDir, resolvedEntryPath)) { throw new Error( `Unpacked ASAR entry path escapes archive root: ${normalizedEntryPath}`, ); } return resolvedEntryPath; } function resolveArchiveLinkPath(entryPath, linkTarget) { if (!linkTarget || typeof linkTarget !== "string") { throw new Error(`Invalid ASAR symlink target for ${entryPath}`); } const normalizedEntryPath = normalizeArchiveRelativePath(entryPath); const archiveRoot = "/__asar_root__"; const resolvedLinkPath = resolve( archiveRoot, dirname(normalizedEntryPath), linkTarget, ); if (!isPathWithin(archiveRoot, resolvedLinkPath)) { throw new Error( `ASAR symlink ${entryPath} target escapes archive root: ${linkTarget}`, ); } const archiveRelativeLinkPath = normalizeArchiveRelativePath( relative(archiveRoot, resolvedLinkPath), ); if (!archiveRelativeLinkPath || archiveRelativeLinkPath.startsWith("..")) { throw new Error( `ASAR symlink ${entryPath} target escapes archive root: ${linkTarget}`, ); } return archiveRelativeLinkPath; } function validateArchiveSymlinkEntries(entries) { const symlinkTargets = new Map(); for (const entry of entries) { if (entry.type !== "link") { continue; } symlinkTargets.set( normalizeArchiveRelativePath(entry.path), resolveArchiveLinkPath(entry.path, entry.link), ); } const visitedPaths = new Set(); const visitingPaths = new Set(); const detectCycle = (entryPath) => { if (visitedPaths.has(entryPath)) { return; } if (visitingPaths.has(entryPath)) { throw new Error(`Circular ASAR symlink chain detected at ${entryPath}`); } const linkTarget = symlinkTargets.get(entryPath); if (!linkTarget) { return; } visitingPaths.add(entryPath); detectCycle(linkTarget); visitingPaths.delete(entryPath); visitedPaths.add(entryPath); }; for (const entryPath of symlinkTargets.keys()) { detectCycle(entryPath); } return symlinkTargets; } function readPackedEntryBuffer(archivePath, archiveDataOffset, entry, fd) { const archiveFd = fd ?? openSync(archivePath, "r"); try { if (!Number.isSafeInteger(entry.size) || entry.size < 0) { throw new Error( `Invalid packed ASAR entry size ${entry.size} for ${entry.path}`, ); } if (entry.size > MAX_ASAR_ENTRY_BYTES) { throw new Error( `ASAR entry ${entry.path} exceeds the maximum supported size of ${MAX_ASAR_ENTRY_BYTES} bytes`, ); } const absoluteOffset = archiveDataOffset + BigInt(String(entry.offset || "0")); if (absoluteOffset > MAX_ASAR_OFFSET) { throw new Error( `ASAR entry ${entry.path} offset exceeds the safe read limit`, ); } const buffer = Buffer.alloc(entry.size); const bytesRead = readSync( archiveFd, buffer, 0, entry.size, Number(absoluteOffset), ); if (bytesRead !== entry.size) { throw new Error( `Unable to read complete ASAR entry ${entry.path} from ${archivePath}: expected ${entry.size} bytes, got ${bytesRead}`, ); } return buffer; } finally { if (fd === undefined) { closeSync(archiveFd); } } } function readAsarEntryBufferSync(archivePath, archiveDataOffset, entry, fd) { if (entry.unpacked) { return readFileSync(resolveUnpackedEntryPath(archivePath, entry.path)); } return readPackedEntryBuffer(archivePath, archiveDataOffset, entry, fd); } function sha256Buffer(buffer) { return createHash("sha256").update(buffer).digest("hex"); } function readXmlText(node) { return (node?.elements || []) .filter((child) => ["text", "cdata"].includes(child?.type)) .map((child) => child.text || child.cdata || "") .join(""); } function parsePlistElement(node) { if (!node || node.type !== "element") { return undefined; } switch (node.name) { case "array": return (node.elements || []) .filter((child) => child?.type === "element") .map((child) => parsePlistElement(child)); case "data": case "date": case "string": return readXmlText(node); case "dict": { const plistObject = Object.create(null); const childElements = (node.elements || []).filter( (child) => child?.type === "element", ); for (let index = 0; index < childElements.length - 1; index += 2) { const keyElement = childElements[index]; const valueElement = childElements[index + 1]; if (keyElement?.name !== "key" || !valueElement) { continue; } const keyName = readXmlText(keyElement); if (!keyName) { continue; } plistObject[keyName] = parsePlistElement(valueElement); } return plistObject; } case "false": return false; case "integer": return Number.parseInt(readXmlText(node), 10); case "true": return true; default: return readXmlText(node); } } function parsePlistFile(plistPath) { const plistXml = readFileSync(plistPath, "utf8"); const plistJson = xml2js(plistXml, { compact: false, ignoreCdata: false, ignoreComment: true, ignoreDoctype: true, ignoreInstruction: true, trim: true, }); const plistElement = plistJson?.elements?.find( (element) => element?.type === "element" && element?.name === "plist", ); const rootElement = plistElement?.elements?.find( (element) => element?.type === "element", ); return parsePlistElement(rootElement); } function findEnclosingElectronInfoPlist(archivePath) { let currentDir = dirname(resolve(archivePath)); let currentDepth = 0; while (currentDir && currentDir !== dirname(currentDir)) { if (currentDepth >= MAX_ELECTRON_APP_BUNDLE_SEARCH_DEPTH) { thoughtLog( "Stopping Electron app bundle search after", MAX_ELECTRON_APP_BUNDLE_SEARCH_DEPTH, "levels for", archivePath, ); return undefined; } if (basename(currentDir).endsWith(".app")) { const infoPlistPath = join(currentDir, "Contents", "Info.plist"); if (safeExistsSync(infoPlistPath)) { return { appDir: currentDir, infoPlistPath, }; } } currentDepth += 1; currentDir = dirname(currentDir); } return undefined; } function collectAsarSigningInfo(archivePath, headerString) { const bundleInfo = findEnclosingElectronInfoPlist(archivePath); if (!bundleInfo?.infoPlistPath) { return undefined; } const archiveRelativePath = relative( join(bundleInfo.appDir, "Contents"), resolve(archivePath), ).replaceAll("\\", "/"); if (!archiveRelativePath || archiveRelativePath.startsWith("..")) { return undefined; } let plistData; try { plistData = parsePlistFile(bundleInfo.infoPlistPath); } catch { return undefined; } const asarIntegrityRecord = plistData?.ElectronAsarIntegrity?.[archiveRelativePath]; if (!asarIntegrityRecord || typeof asarIntegrityRecord !== "object") { return undefined; } const computedHash = sha256Buffer(Buffer.from(headerString, "utf8")); const algorithm = String(asarIntegrityRecord.algorithm || "").toUpperCase(); const declaredHash = String(asarIntegrityRecord.hash || "").toLowerCase(); return { algorithm, archiveRelativePath, computedHash, declaredHash, infoPlistPath: bundleInfo.infoPlistPath, scope: "header-only", source: "electron-info-plist", verified: algorithm === "SHA256" && /^[a-f0-9]{64}$/i.test(declaredHash) && declaredHash === computedHash, }; } function createAsarSigningComponent(archivePath, signingInfo, options = {}) { const specVersionNumber = Number(options.specVersion || 0); const componentType = specVersionNumber > 0 && specVersionNumber < 1.6 ? "data" : "cryptographic-asset"; const properties = [{ name: "SrcFile", value: archivePath }]; addSanitizedProperty( properties, "cdx:asar:signingAlgorithm", signingInfo.algorithm, ); addSanitizedProperty( properties, "cdx:asar:signingDeclaredHash", signingInfo.declaredHash, ); addSanitizedProperty( properties, "cdx:asar:headerHash", signingInfo.computedHash, ); addSanitizedProperty( properties, "cdx:asar:signingSource", signingInfo.source, ); addSanitizedProperty(properties, "cdx:asar:signingScope", signingInfo.scope); addSanitizedProperty( properties, "cdx:asar:signingVerified", String(signingInfo.verified), ); addSanitizedProperty( properties, "cdx:asar:signingArchivePath", signingInfo.archiveRelativePath, ); const component = { "bom-ref": `crypto/asar-signature/${encodeURIComponent(archivePath)}@sha256:${signingInfo.declaredHash || signingInfo.computedHash}`, hashes: [ { alg: "SHA-256", content: signingInfo.declaredHash || signingInfo.computedHash, }, ], name: `${basename(archivePath)} asar integrity record`, properties, type: componentType, version: signingInfo.declaredHash || signingInfo.computedHash, }; if (componentType === "cryptographic-asset") { component.cryptoProperties = { assetType: "related-crypto-material", relatedCryptoMaterialProperties: { type: "digest", value: signingInfo.declaredHash || signingInfo.computedHash, }, }; } return component; } function toFileComponentRef(archivePath, entryPath) { return `file:${archivePath}#/${normalizeArchiveRelativePath(entryPath)}`; } function inferPrimaryPackagePath(entries) { const packageEntries = entries .filter( (entry) => entry.type === "file" && basename(entry.path) === "package.json" && !entry.path.includes("/node_modules/"), ) .sort((left, right) => left.path.length - right.path.length); return packageEntries[0]?.path; } function inferMainEntryFlags(packageJson) { const properties = []; addSanitizedProperty(properties, "cdx:asar:main", packageJson?.main); addSanitizedProperty(properties, "cdx:asar:module", packageJson?.module); addSanitizedProperty(properties, "cdx:asar:browser", packageJson?.browser); addSanitizedProperty( properties, "cdx:asar:productName", packageJson?.productName, ); const lifecycleScripts = Object.keys(packageJson?.scripts || {}).filter( (name) => ASAR_LIFECYCLE_SCRIPT_NAMES.has(name), ); if (lifecycleScripts.length) { addSanitizedProperty( properties, "cdx:asar:lifecycleScripts", lifecycleScripts.join(", "), ); } return properties; } function toArchiveVirtualPath(extractedDir, archivePath, candidatePath) { if (!candidatePath || typeof candidatePath !== "string") { return candidatePath; } const normalizedExtractedDir = resolve(extractedDir); const normalizedCandidate = resolve(candidatePath); if (!isPathWithin(normalizedExtractedDir, normalizedCandidate)) { return candidatePath; } const relativePath = relative(normalizedExtractedDir, normalizedCandidate); return `${archivePath}#/${relativePath.replaceAll("\\", "/")}`; } export function rewriteExtractedArchivePaths( subject, extractedDir, archivePath, ) { if (!subject || typeof subject !== "object") { return subject; } if (Array.isArray(subject)) { subject.forEach((entry) => { rewriteExtractedArchivePaths(entry, extractedDir, archivePath); }); return subject; } if (subject.properties?.length) { subject.properties.forEach((property) => { if (typeof property?.value === "string") { property.value = toArchiveVirtualPath( extractedDir, archivePath, property.value, ); } }); } if (subject.evidence?.identity?.methods?.length) { subject.evidence.identity.methods.forEach((method) => { if (typeof method?.value === "string") { method.value = toArchiveVirtualPath( extractedDir, archivePath, method.value, ); } }); } if (subject.evidence?.occurrences?.length) { subject.evidence.occurrences.forEach((occurrence) => { if (typeof occurrence?.location === "string") { occurrence.location = toArchiveVirtualPath( extractedDir, archivePath, occurrence.location, ); } }); } if (subject.components?.length) { rewriteExtractedArchivePaths(subject.components, extractedDir, archivePath); } return subject; } function collectArchiveSummaryProperties( archivePath, summary, primaryPackageJson, primaryPackagePath, ) { const properties = [{ name: "SrcFile", value: archivePath }]; addSanitizedProperty(properties, "cdx:file:kind", "asar-archive"); addSanitizedProperty( properties, "cdx:asar:entryCount", `${summary.entryCount}`, ); addSanitizedProperty( properties, "cdx:asar:fileCount", `${summary.fileCount}`, ); addSanitizedProperty( properties, "cdx:asar:directoryCount", `${summary.directoryCount}`, ); addSanitizedProperty( properties, "cdx:asar:symlinkCount", `${summary.symlinkCount}`, ); addSanitizedProperty( properties, "cdx:asar:jsFileCount", `${summary.jsFileCount}`, ); addSanitizedProperty( properties, "cdx:asar:packageJsonCount", `${summary.packageJsonCount}`, ); addSanitizedProperty( properties, "cdx:asar:lockfileCount", `${summary.lockfileCount}`, ); if (summary.nestedArchiveCount > 0) { addSanitizedProperty(properties, "cdx:asar:hasNestedArchives", "true"); addSanitizedProperty( properties, "cdx:asar:nestedArchiveCount", `${summary.nestedArchiveCount}`, ); } if (summary.unpackedFileCount > 0) { addSanitizedProperty(properties, "cdx:asar:hasUnpackedEntries", "true"); addSanitizedProperty( properties, "cdx:asar:unpackedFileCount", `${summary.unpackedFileCount}`, ); } if (summary.nativeAddonCount > 0) { addSanitizedProperty(properties, "cdx:asar:hasNativeAddons", "true"); addSanitizedProperty( properties, "cdx:asar:nativeAddonCount", `${summary.nativeAddonCount}`, ); } if (summary.integrityMismatchCount > 0) { addSanitizedProperty(properties, "cdx:asar:hasIntegrityMismatch", "true"); addSanitizedProperty( properties, "cdx:asar:integrityMismatchCount", `${summary.integrityMismatchCount}`, ); } if (summary.capabilities.length) { addSanitizedProperty( properties, "cdx:asar:capabilities", summary.capabilities.join(", "), ); summary.capabilities.forEach((capability) => { addSanitizedProperty( properties, `cdx:asar:capability:${capability}`, "true", ); }); } if (summary.executionIndicators.length) { addSanitizedProperty( properties, "cdx:asar:executionIndicators", summary.executionIndicators.join(", "), ); } if (summary.networkIndicators.length) { addSanitizedProperty( properties, "cdx:asar:networkIndicators", summary.networkIndicators.join(", "), ); } if (summary.obfuscationIndicators.length) { addSanitizedProperty( properties, "cdx:asar:obfuscationIndicators", summary.obfuscationIndicators.join(", "), ); } if (summary.hasEval) { addSanitizedProperty(properties, "cdx:asar:hasEval", "true"); } if (summary.hasDynamicFetch) { addSanitizedProperty(properties, "cdx:asar:hasDynamicFetch", "true"); } if (summary.hasDynamicImport) { addSanitizedProperty(properties, "cdx:asar:hasDynamicImport", "true"); } if (summary.headerHash) { addSanitizedProperty(properties, "cdx:asar:headerHash", summary.headerHash); } if (summary.signingInfo) { addSanitizedProperty(properties, "cdx:asar:hasSigningMetadata", "true"); addSanitizedProperty( properties, "cdx:asar:signingAlgorithm", summary.signingInfo.algorithm, ); addSanitizedProperty( properties, "cdx:asar:signingDeclaredHash", summary.signingInfo.declaredHash, ); addSanitizedProperty( properties, "cdx:asar:signingSource", summary.signingInfo.source, ); addSanitizedProperty( properties, "cdx:asar:signingScope", summary.signingInfo.scope, ); addSanitizedProperty( properties, "cdx:asar:signingVerified", String(summary.signingInfo.verified), ); } if (primaryPackagePath) { addSanitizedProperty( properties, "cdx:asar:primaryManifest", `${archivePath}#/${primaryPackagePath}`, ); } if (primaryPackageJson) { inferMainEntryFlags(primaryPackageJson).forEach((property) => { properties.push(property); }); } return properties; } function createArchiveParentComponent( archivePath, summary, primaryPackageJson, primaryPackagePath, ) { const archivePurl = createAsarPackagePurl( primaryPackageJson?.name, primaryPackageJson?.version, ) || createGenericArchivePurl(archivePath, primaryPackageJson?.version); const component = { "bom-ref": decodeURIComponent(archivePurl), description: primaryPackageJson?.description || `Electron ASAR archive ${basename(archivePath)}`, name: primaryPackageJson?.productName || primaryPackageJson?.name || basename(archivePath, ".asar"), purl: archivePurl, type: "application", version: primaryPackageJson?.version || "", }; const parsedName = parseScopedPackageName(primaryPackageJson?.name); if (parsedName.group) { component.group = parsedName.group; } if (primaryPackageJson?.author) { component.author = typeof primaryPackageJson.author === "string" ? primaryPackageJson.author : primaryPackageJson.author?.name || ""; } if (primaryPackageJson?.license) { component.license = primaryPackageJson.license; } if (primaryPackageJson?.repository) { const repositoryUrl = typeof primaryPackageJson.repository === "string" ? primaryPackageJson.repository : primaryPackageJson.repository?.url; if (repositoryUrl) { component.externalReferences = component.externalReferences || []; component.externalReferences.push({ type: "vcs", url: repositoryUrl, }); } } if (primaryPackageJson?.homepage) { component.externalReferences = component.externalReferences || []; component.externalReferences.push({ type: "website", url: primaryPackageJson.homepage, }); } component.properties = collectArchiveSummaryProperties( archivePath, summary, primaryPackageJson, primaryPackagePath, ); component.evidence = { identity: { confidence: 1, field: "purl", methods: [ { confidence: 1, technique: primaryPackagePath ? "manifest-analysis" : "filename", value: primaryPackagePath ? `${archivePath}#/${primaryPackagePath}` : archivePath, }, ], }, }; return component; } function createArchiveEntryComponent( archivePath, entry, computedHash, jsAnalysis, suspiciousAnalysis, ) { const archiveLocation = toArchiveOccurrence(archivePath, entry.path); const properties = [{ name: "SrcFile", value: archivePath }]; addSanitizedProperty(properties, "cdx:file:kind", "asar-entry"); addSanitizedProperty(properties, "cdx:asar:path", entry.path); addSanitizedProperty(properties, "cdx:asar:size", `${entry.size || 0}`); addSanitizedProperty( properties, "cdx:asar:unpacked", String(entry.unpacked === true), ); if (entry.offset !== undefined) { addSanitizedProperty(properties, "cdx:asar:offset", entry.offset); } if (entry.executable) { addSanitizedProperty(properties, "cdx:asar:executable", "true"); } if (entry.link) { addSanitizedProperty(properties, "cdx:asar:linkTarget", entry.link); } if (entry.integrity?.algorithm) { addSanitizedProperty( properties, "cdx:asar:integrityAlgorithm", entry.integrity.algorithm, ); } if (entry.integrity?.hash) { addSanitizedProperty( properties, "cdx:asar:declaredIntegrityHash", entry.integrity.hash, ); } if (entry.integrity?.blockSize) { addSanitizedProperty( properties, "cdx:asar:integrityBlockSize", `${entry.integrity.blockSize}`, ); } if (Array.isArray(entry.integrity?.blocks)) { addSanitizedProperty( properties, "cdx:asar:integrityBlockCount", `${entry.integrity.blocks.length}`, ); } if (computedHash && entry.integrity?.hash) { addSanitizedProperty( properties, "cdx:asar:integrityVerified", String(computedHash === String(entry.integrity.hash).toLowerCase()), ); } if (jsAnalysis?.capabilities?.length) { addSanitizedProperty( properties, "cdx:asar:js:capabilities", jsAnalysis.capabilities.join(", "), ); jsAnalysis.capabilities.forEach((capability) => { addSanitizedProperty( properties, `cdx:asar:js:capability:${capability}`, "true", ); }); } if (jsAnalysis?.hasEval) { addSanitizedProperty(properties, "cdx:asar:js:hasEval", "true"); } if (jsAnalysis?.hasDynamicFetch) { addSanitizedProperty(properties, "cdx:asar:js:hasDynamicFetch", "true"); } if (jsAnalysis?.hasDynamicImport) { addSanitizedProperty(properties, "cdx:asar:js:hasDynamicImport", "true"); } if (jsAnalysis?.indicatorMap?.fileAccess?.length) { addSanitizedProperty( properties, "cdx:asar:js:fileAccessIndicators", jsAnalysis.indicatorMap.fileAccess.join(", "), ); } if (jsAnalysis?.indicatorMap?.network?.length) { addSanitizedProperty( properties, "cdx:asar:js:networkIndicators", jsAnalysis.indicatorMap.network.join(", "), ); } if (jsAnalysis?.indicatorMap?.hardware?.length) { addSanitizedProperty( properties, "cdx:asar:js:hardwareIndicators", jsAnalysis.indicatorMap.hardware.join(", "), ); } if (suspiciousAnalysis?.executionIndicators?.length) { addSanitizedProperty( properties, "cdx:asar:js:executionIndicators", suspiciousAnalysis.executionIndicators.join(", "), ); } if (suspiciousAnalysis?.obfuscationIndicators?.length) { addSanitizedProperty( properties, "cdx:asar:js:obfuscationIndicators", suspiciousAnalysis.obfuscationIndicators.join(", "), ); } return { "bom-ref": toFileComponentRef(archivePath, entry.path), evidence: { identity: { confidence: 1, field: "name", methods: [ { confidence: 1, technique: "filename", value: archiveLocation, }, ], }, occurrences: [{ location: archiveLocation }], }, hashes: computedHash ? [{ alg: "SHA-256", content: computedHash }] : undefined, name: basename(entry.path), properties, type: "file", version: computedHash || undefined, }; } /** * Parse an Electron ASAR archive and emit inventory, metadata, and optional * signing information. * * @param {string} archivePath Absolute or relative path to an ASAR archive * @param {Object} [options={}] Parse options * @param {string} [options.asarVirtualPath] Virtual archive identity to use in * BOM references and evidence for nested ASAR recursion * @param {number} [options.specVersion] CycloneDX spec version used to choose * compatible component types * @returns {Promise<Object>} Parsed archive analysis result */ export async function parseAsarArchive(archivePath, options = {}) { const resolvedArchivePath = resolve(archivePath); const parsedArchive = listAsarEntries(resolvedArchivePath); const archiveIdentityPath = typeof options?.asarVirtualPath === "string" && options.asarVirtualPath ? options.asarVirtualPath : resolvedArchivePath; const signingInfo = collectAsarSigningInfo( resolvedArchivePath, parsedArchive.headerString, ); const summary = { capabilities: new Set(), directoryCount: 0, entryCount: parsedArchive.entries.length, executionIndicators: new Set(), fileCount: 0, headerHash: sha256Buffer(Buffer.from(parsedArchive.headerString, "utf8")), hasDynamicFetch: false, hasDynamicImport: false, hasEval: false, integrityMismatchCount: 0, jsFileCount: 0, lockfileCount: 0, nativeAddonCount: 0, nestedArchiveCount: 0, networkIndicators: new Set(), obfuscationIndicators: new Set(), packageJsonCount: 0, signingInfo, symlinkCount: 0, unpackedFileCount: 0, }; const components = []; const dependencies = []; const packageManifestPaths = []; let primaryPackageJson; const primaryPackagePath = inferPrimaryPackagePath(parsedArchive.entries); let archiveFd; try { for (const entry of parsedArchive.entries) { if (entry.type === "directory") { summary.directoryCount += 1; continue; } if (entry.type === "link") { summary.symlinkCount += 1; components.push( createArchiveEntryComponent(archiveIdentityPath, entry, undefined), ); continue; } summary.fileCount += 1; if (entry.unpacked) { summary.unpackedFileCount += 1; } if (basename(entry.path) === "package.json") { summary.packageJsonCount += 1; packageManifestPaths.push(entry.path); } if (ASAR_LOCKFILE_NAMES.has(basename(entry.path))) { summary.lockfileCount += 1; } if (extname(entry.path) === ".asar") { summary.nestedArchiveCount += 1; } if (extname(entry.path) === ".node") { summary.nativeAddonCount += 1; } let computedHash; let fileBuffer; let jsAnalysis; let suspiciousAnalysis; try { if (!entry.unpacked && archiveFd === undefined) { archiveFd = openSync(resolvedArchivePath, "r"); } fileBuffer = readAsarEntryBufferSync( resolvedArchivePath, parsedArchive.archiveDataOffset, entry, archiveFd, ); computedHash = sha256Buffer(fileBuffer); if ( entry.integrity?.hash && computedHash !== String(entry.integrity.hash).toLowerCase() ) { summary.integrityMismatchCount += 1; } if ( entry.path === primaryPackagePath && basename(entry.path) === "package.json" ) { try { primaryPackageJson = JSON.parse(fileBuffer.toString("utf8")); } catch { // Ignore malformed package metadata and fall back to archive name. } } } catch (error) { thoughtLog("Error reading ASAR entry", entry.path, error.message); throw error; } if (ASAR_JS_ANALYSIS_EXTENSIONS.has(extname(entry.path))) { summary.jsFileCount += 1; const sourceBuffer = fileBuffer || (entry.unpacked ? readFileSync( resolveUnpackedEntryPath(resolvedArchivePath, entry.path), ) : undefined); if (sourceBuffer) { const sourceText = sourceBuffer.toString("utf8"); jsAnalysis = analyzeJsCapabilitiesSource(sourceText); suspiciousAnalysis = analyzeSuspiciousJsSource(sourceText); } } jsAnalysis?.capabilities?.forEach((capability) => { summary.capabilities.add(capability); }); suspiciousAnalysis?.executionIndicators?.forEach((indicator) => { summary.executionIndicators.add(indicator); if (indicator === "eval") { summary.hasEval = true; } }); suspiciousAnalysis?.networkIndicators?.forEach((indicator) => { summary.networkIndicators.add(indicator); }); suspiciousAnalysis?.obfuscationIndicators?.forEach((indicator) => { summary.obfuscationIndicators.add(indicator); }); if (jsAnalysis?.hasDynamicFetch) { summary.hasDynamicFetch = true; } if (jsAnalysis?.hasDynamicImport) { summary.hasDynamicImport = true; } components.push( createArchiveEntryComponent( archiveIdentityPath, entry, computedHash, jsAnalysis, suspiciousAnalysis, ), ); } } finally { if (archiveFd !== undefined) { closeSync(archiveFd); } } const normalizedSummary = { ...summary, capabilities: Array.from(summary.capabilities).sort(), executionIndicators: Array.from(summary.executionIndicators).sort(), networkIndicators: Array.from(summary.networkIndicators).sort(), obfuscationIndicators: Array.from(summary.obfuscationIndicators).sort(), }; const parentComponent = createArchiveParentComponent( archiveIdentityPath, normalizedSummary, primaryPackageJson, primaryPackagePath, ); if (signingInfo) { const signingComponent = createAsarSigningComponent( archiveIdentityPath, signingInfo, options, ); components.push(signingComponent); if (parentComponent?.["bom-ref"] && signingComponent?.["bom-ref"]) { dependencies.push({ ref: parentComponent["bom-ref"], dependsOn: [signingComponent["bom-ref"]], }); } } recordActivity({ capability: "archive-analysis", kind: "read", reason: `Cataloged ${normalizedSummary.fileCount} ASAR file entr${normalizedSummary.fileCount === 1 ? "y" : "ies"} from ${resolvedArchivePath}.`, status: "completed", target: resolvedArchivePath, }); return { components, dependencies, entries: parsedArchive.entries, packageManifestPaths, parentComponent, primaryPackageJson, primaryPackagePath, summary: normalizedSummary, }; } function extractAsarArchive(archivePath, targetDir) { const parsedArchive = listAsarEntries(archivePath); const validatedSymlinkTargets = validateArchiveSymlinkEntries( parsedArchive.entries, ); safeMkdirSync(targetDir, { recursive: true }); let archiveFd; try { for (const entry of parsedArchive.entries) { const destinationPath = resolve( targetDir, ...normalizeArchiveRelativePath(entry.path).split("/"), ); if (!isPathWithin(targetDir, destinationPath)) { throw new Error( `Refusing to extract ASAR entry outside target dir: ${entry.path}`, ); } if (entry.type === "directory") { safeMkdirSync(destinationPath, { recursive: true }); continue; } safeMkdirSync(dirname(destinationPath), { recursive: true }); if (entry.type === "link") { const validatedLinkTarget = validatedSymlinkTargets.get( normalizeArchiveRelativePath(entry.path), ); const resolvedLinkTargetPath = resolve( targetDir, ...validatedLinkTarget.split("/"), ); if (!isPathWithin(targetDir, resolvedLinkTargetPath)) { throw new Error( `ASAR symlink ${entry.path} target escapes extraction root: ${validatedLinkTarget}`, ); } const relativeLinkTarget = relative( dirname(destinationPath), resolvedLinkTargetPath, ); try { symlinkSync(relativeLinkTarget, destinationPath); } catch (error) { if (process.platform === "win32") { thoughtLog( "Unable to recreate ASAR symlink on Windows; falling back", entry.path, error.message, ); try { const linkTargetStats = statSync(resolvedLinkTargetPath); if (linkTargetStats.isDirectory()) { safeMkdirSync(destinationPath, { recursive: true }); } else if (linkTargetStats.isFile()) { safeCopyFileSync(resolvedLinkTargetPath, destinationPath); } continue; } catch { continue; } } throw new Error( `Failed to recreate ASAR symlink ${entry.path} -> ${validatedLinkTarget} at ${destinationPath}: ${error.message}`, ); } continue; } if (entry.unpacked) { safeCopyFileSync( resolveUnpackedEntryPath(archivePath, entry.path), destinationPath, ); continue; } if (archiveFd === undefined) { archiveFd = openSync(archivePath, "r"); } const fileBuffer = readPackedEntryBuffer( archivePath, parsedArchive.archiveDataOffset, entry, archiveFd, ); safeWriteSync(destinationPath, fileBuffer); if (entry.executable && process.platform !== "win32") { try { chmodSync(destinationPath, 0o755); } catch (error) { throw new Error( `Failed to mark extracted ASAR entry ${entry.path} executable at ${destinationPath}: ${error.message}`, ); } } } } finally { if (archiveFd !== undefined) { closeSync(archiveFd); } } } export async function extractAsarToTempDir(archivePath) { let tempDir; try { tempDir = safeMkdtempSync(join(getTmpDir(), "asar-deps-")); const extracted = await safeExtractArchive( archivePath, tempDir, async () => { extractAsarArchive(archivePath, tempDir); }, "asar", { metadata: { archivePath }, }, ); if (!extracted) { return undefined; } return tempDir; } catch (error) { if (DEBUG_MODE) { console.log( `Error extracting ASAR archive ${archivePath}:`, error.message, ); } cleanupAsarTempDir(tempDir); return undefined; } } export function cleanupAsarTempDir(tempDir) { if (!tempDir) { return; } const resolvedDir = resolve(tempDir); const expectedBase = resolve(getTmpDir()); if ( basename(resolvedDir).startsWith("asar-deps-") && resolve(resolvedDir, "..") === expectedBase ) { safeRmSync(resolvedDir, { force: true, recursive: true }); } } export function buildAsarExtractionSummary( archiveAnalysis, extractionPerformed, ) { const properties = []; if (archiveAnalysis?.packageManifestPaths?.length) { addSanitizedProperty( properties, "cdx:asar:embeddedManifests", archiveAnalysis.packageManifestPaths .map((manifestPath) => toArchiveOccurrence("", manifestPath).replace(/^#/, ""), ) .join(", "), ); } if (archiveAnalysis?.summary?.packageJsonCount) { addSanitizedProperty( properties, "cdx:asar:manifestInventoryComplete", String(!isDryRun || extractionPerformed), ); } return properties; }