UNPKG

@cyclonedx/cdxgen

Version:

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

444 lines (405 loc) 14 kB
import { createHash } from "node:crypto"; import * as nodeFs from "node:fs"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import esmock from "esmock"; import { assert, describe, it } from "poku"; import sinon from "sinon"; import { createAsarFixture, writeElectronAsarIntegrityPlist, } from "../../test/helpers/asar-fixture-builder.js"; import { cleanupAsarTempDir, extractAsarToTempDir, listAsarEntries, parseAsarArchive, readAsarArchiveHeaderSync, rewriteExtractedArchivePaths, } from "./asarutils.js"; import { safeRmSync } from "./utils.js"; const baseTempDir = mkdtempSync(join(tmpdir(), "cdxgen-asar-poku-")); function align4(value) { return value + ((4 - (value % 4)) % 4); } function makeStringPickle(value) { const valueBuffer = Buffer.from(value, "utf8"); const alignedStringLength = align4(valueBuffer.length); const payloadLength = 4 + alignedStringLength; const buffer = Buffer.alloc(4 + payloadLength); buffer.writeUInt32LE(payloadLength, 0); buffer.writeInt32LE(valueBuffer.length, 4); valueBuffer.copy(buffer, 8); return buffer; } function makeSizePickle(value) { const buffer = Buffer.alloc(8); buffer.writeUInt32LE(4, 0); buffer.writeUInt32LE(value, 4); return buffer; } function rewriteArchiveHeaderSync(archivePath, transformHeader) { const archiveBuffer = readFileSync(archivePath); const headerPickleSize = archiveBuffer.readUInt32LE(4); const headerBuffer = archiveBuffer.subarray(8, 8 + headerPickleSize); const headerStringLength = headerBuffer.readInt32LE(4); const headerString = headerBuffer.toString("utf8", 8, 8 + headerStringLength); const nextHeader = transformHeader(JSON.parse(headerString)); const nextHeaderPickle = makeStringPickle(JSON.stringify(nextHeader)); writeFileSync( archivePath, Buffer.concat([ makeSizePickle(nextHeaderPickle.length), nextHeaderPickle, archiveBuffer.subarray(8 + headerPickleSize), ]), ); } process.on("exit", () => { safeRmSync(baseTempDir, { force: true, recursive: true }); }); describe("extractAsarToTempDir()", () => { it("returns undefined when dry-run blocks ASAR extraction", async () => { const safeExtractArchive = sinon.stub().resolves(false); const { extractAsarToTempDir: extractAsarToTempDirMocked } = await esmock( "./asarutils.js", { "./utils.js": { DEBUG_MODE: false, getTmpDir: sinon.stub().returns("/tmp"), isDryRun: false, recordActivity: sinon.stub(), safeCopyFileSync: sinon.stub(), safeExtractArchive, safeMkdirSync: sinon.stub(), safeMkdtempSync: sinon.stub().returns("/tmp/asar-deps-test"), safeRmSync: sinon.stub(), safeWriteSync: sinon.stub(), }, }, ); const extractedDir = await extractAsarToTempDirMocked("/tmp/sample.asar"); assert.strictEqual(extractedDir, undefined); sinon.assert.calledOnce(safeExtractArchive); }); }); describe("parseAsarArchive()", () => { it("catalogs file inventory, hashes, evidence, and security-sensitive properties", async () => { const archivePath = join(baseTempDir, "fixture.asar"); createAsarFixture(archivePath, { corruptIntegrityPaths: ["config/settings.json"], executablePaths: ["scripts/postinstall.js"], symlinks: { "config-link": "config/settings.json", }, unpackedPaths: ["native/addon.node"], }); const analysis = await parseAsarArchive(archivePath, {}); const entryList = listAsarEntries(archivePath); assert.ok(entryList.entries.some((entry) => entry.path === "config-link")); assert.strictEqual(analysis.parentComponent.name, "Sample Electron App"); assert.strictEqual( analysis.parentComponent.purl, "pkg:npm/sample-electron-app@1.2.3", ); assert.strictEqual( analysis.summary.integrityMismatchCount, 1, "expected one mismatched declared integrity hash", ); assert.ok(analysis.summary.capabilities.includes("fileAccess")); assert.ok(analysis.summary.capabilities.includes("network")); assert.ok(analysis.summary.capabilities.includes("hardware")); assert.ok(analysis.summary.capabilities.includes("dynamicFetch")); assert.ok(analysis.summary.capabilities.includes("dynamicImport")); assert.strictEqual(analysis.summary.hasEval, true); const archiveProps = analysis.parentComponent.properties; assert.strictEqual( archiveProps.find((property) => property.name === "cdx:asar:hasEval") ?.value, "true", ); assert.strictEqual( archiveProps.find( (property) => property.name === "cdx:asar:hasNativeAddons", )?.value, "true", ); assert.strictEqual( archiveProps.find( (property) => property.name === "cdx:asar:hasIntegrityMismatch", )?.value, "true", ); const mainFileComponent = analysis.components.find((component) => component.properties?.some( (property) => property.name === "cdx:asar:path" && property.value === "src/main.js", ), ); assert.ok(mainFileComponent, "expected src/main.js file component"); assert.ok(mainFileComponent.hashes?.length, "expected SHA-256 hash"); assert.strictEqual( mainFileComponent.evidence?.occurrences?.[0]?.location, `${archivePath}#/src/main.js`, ); assert.strictEqual( mainFileComponent.properties.find( (property) => property.name === "cdx:asar:js:hasDynamicFetch", )?.value, "true", ); assert.strictEqual( mainFileComponent.properties.find( (property) => property.name === "cdx:asar:js:capability:hardware", )?.value, "true", ); const unpackedComponent = analysis.components.find((component) => component.properties?.some( (property) => property.name === "cdx:asar:path" && property.value === "native/addon.node", ), ); assert.ok(unpackedComponent, "expected native addon component"); assert.strictEqual( unpackedComponent.properties.find( (property) => property.name === "cdx:asar:unpacked", )?.value, "true", ); }); it("extracts ASAR archives and rewrites extracted source paths back to archive paths", async () => { const archivePath = join(baseTempDir, "fixture-extract.asar"); createAsarFixture(archivePath, { unpackedPaths: ["native/addon.node"], }); const extractedDir = await extractAsarToTempDir(archivePath); assert.ok(extractedDir, "expected extraction temp dir"); assert.ok(existsSync(join(extractedDir, "src", "main.js"))); assert.ok(existsSync(join(extractedDir, "native", "addon.node"))); const component = { evidence: { identity: { methods: [ { confidence: 1, technique: "manifest-analysis", value: join(extractedDir, "package.json"), }, ], }, occurrences: [ { location: join(extractedDir, "src", "main.js"), }, ], }, properties: [ { name: "SrcFile", value: join( extractedDir, "node_modules", "sketchy-addon", "package.json", ), }, ], }; rewriteExtractedArchivePaths(component, extractedDir, archivePath); assert.strictEqual( component.properties[0].value, `${archivePath}#/node_modules/sketchy-addon/package.json`, ); assert.strictEqual( component.evidence.identity.methods[0].value, `${archivePath}#/package.json`, ); assert.strictEqual( component.evidence.occurrences[0].location, `${archivePath}#/src/main.js`, ); cleanupAsarTempDir(extractedDir); assert.ok(!existsSync(extractedDir), "expected extracted temp dir cleanup"); }); it("verifies Electron ASAR signing metadata and emits a crypto component", async () => { const appDir = join(baseTempDir, "Signed.app"); const archivePath = join( appDir, "Contents", "Resources", "app & signed.asar", ); mkdirSync(join(appDir, "Contents", "Resources"), { recursive: true }); createAsarFixture(archivePath); const { headerString } = readAsarArchiveHeaderSync(archivePath); const headerHash = createHash("sha256") .update(headerString, "utf8") .digest("hex"); writeElectronAsarIntegrityPlist(join(appDir, "Contents", "Info.plist"), { "Resources/app & signed.asar": { algorithm: "SHA256", hash: headerHash, }, }); const analysis = await parseAsarArchive(archivePath, { specVersion: 1.7 }); const signingComponent = analysis.components.find( (component) => component.type === "cryptographic-asset" && component.properties?.some( (property) => property.name === "cdx:asar:signingVerified" && property.value === "true", ), ); assert.strictEqual( analysis.parentComponent.properties.find( (property) => property.name === "cdx:asar:hasSigningMetadata", )?.value, "true", ); assert.strictEqual( analysis.parentComponent.properties.find( (property) => property.name === "cdx:asar:signingVerified", )?.value, "true", ); assert.strictEqual( analysis.parentComponent.properties.find( (property) => property.name === "cdx:asar:signingScope", )?.value, "header-only", ); assert.ok(signingComponent, "expected ASAR signing crypto component"); assert.strictEqual( signingComponent.properties.find( (property) => property.name === "cdx:asar:signingScope", )?.value, "header-only", ); assert.ok( analysis.dependencies.some( (dependency) => dependency.ref === analysis.parentComponent["bom-ref"] && dependency.dependsOn.includes(signingComponent["bom-ref"]), ), "expected parent archive to depend on the signing component", ); }); it("rejects ASAR headers with oversized file entries", async () => { const archivePath = join(baseTempDir, "fixture-oversized.asar"); createAsarFixture(archivePath, { extraEntries: { "huge.bin": { content: "x", size: 256 * 1024 * 1024 + 1, }, }, }); await assert.rejects( () => parseAsarArchive(archivePath, {}), /Invalid ASAR file entry/, ); }); it("rejects ASAR entries with offsets beyond the safe read limit", async () => { const archivePath = join(baseTempDir, "fixture-offset.asar"); createAsarFixture(archivePath, { extraEntries: { "too-far.bin": { content: "x", offset: Number.MAX_SAFE_INTEGER + 10, size: 1, }, }, }); await assert.rejects( () => parseAsarArchive(archivePath, {}), /offset exceeds the safe read limit/, ); }); it("rejects ASAR headers with excessive nesting depth", async () => { const archivePath = join(baseTempDir, "fixture-deep.asar"); const deeplyNestedPath = `${Array.from({ length: 260 }, (_, index) => `d${index}`).join("/")}/payload.txt`; createAsarFixture(archivePath, { extraEntries: { [deeplyNestedPath]: { content: "payload", }, }, }); await assert.rejects( () => parseAsarArchive(archivePath, {}), /nesting exceeds 256 levels/, ); }); it("rejects ASAR headers with conflicting entry kinds", async () => { const archivePath = join(baseTempDir, "fixture-conflicting-kinds.asar"); createAsarFixture(archivePath); rewriteArchiveHeaderSync(archivePath, (header) => { header.files["bad-link"] = { files: {}, link: "src/main.js", }; return header; }); await assert.rejects( () => parseAsarArchive(archivePath, {}), /Invalid ASAR symlink entry/, ); }); it("rejects symlinks that escape the extraction root", async () => { const archivePath = join(baseTempDir, "fixture-link-escape.asar"); createAsarFixture(archivePath, { symlinks: { "escape-link": "../../outside.txt", }, }); const extractedDir = await extractAsarToTempDir(archivePath); assert.strictEqual(extractedDir, undefined); }); it("rejects circular symlink chains during extraction", async () => { const archivePath = join(baseTempDir, "fixture-link-cycle.asar"); createAsarFixture(archivePath, { symlinks: { a: "b", b: "a", }, }); const extractedDir = await extractAsarToTempDir(archivePath); assert.strictEqual(extractedDir, undefined); }); it("reuses one packed-entry file descriptor per parse and extraction pass", async () => { const archivePath = join(baseTempDir, "fixture-open-reuse.asar"); createAsarFixture(archivePath, { unpackedPaths: ["native/addon.node"], }); const openSync = sinon.spy((...args) => nodeFs.openSync(...args)); const closeSync = sinon.spy((...args) => nodeFs.closeSync(...args)); const { cleanupAsarTempDir: cleanupAsarTempDirMocked, extractAsarToTempDir: extractAsarToTempDirMocked, parseAsarArchive: parseAsarArchiveMocked, } = await esmock("./asarutils.js", { "node:fs": { ...nodeFs, closeSync, openSync, }, }); await parseAsarArchiveMocked(archivePath, {}); const extractedDir = await extractAsarToTempDirMocked(archivePath); assert.ok(extractedDir, "expected extraction temp dir"); assert.strictEqual(openSync.callCount, 4); assert.strictEqual(closeSync.callCount, 4); cleanupAsarTempDirMocked(extractedDir); }); });