@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
329 lines (319 loc) • 11.9 kB
JavaScript
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { assert, describe, it } from "poku";
import {
createAsarFixture,
writeElectronAsarIntegrityPlist,
} from "../../test/helpers/asar-fixture-builder.js";
import { readAsarArchiveHeaderSync } from "../helpers/asarutils.js";
import { auditBom } from "../stages/postgen/auditBom.js";
import { postProcess } from "../stages/postgen/postgen.js";
import { validateBom } from "../validator/bomValidator.js";
import { createAsarBom } from "./index.js";
function getProp(obj, name) {
return obj?.properties?.find((property) => property.name === name)?.value;
}
if (process.platform !== "win32") {
describe("createAsarBom()", () => {
it("catalogs ASAR archives, extracts nested npm metadata, and surfaces audit findings", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "cdxgen-asar-cli-"));
const archivePath = join(fixtureRoot, "app.asar");
createAsarFixture(archivePath, {
corruptIntegrityPaths: ["config/settings.json"],
executablePaths: ["scripts/postinstall.js"],
unpackedPaths: ["native/addon.node"],
});
try {
const bomData = await createAsarBom(archivePath, {
installDeps: false,
multiProject: false,
projectType: ["asar"],
specVersion: 1.7,
});
assert.ok(bomData?.bomJson?.components?.length);
assert.strictEqual(bomData.parentComponent.name, "Sample Electron App");
assert.strictEqual(
getProp(bomData.parentComponent, "cdx:asar:hasEval"),
"true",
);
assert.strictEqual(
getProp(bomData.parentComponent, "cdx:asar:hasDynamicFetch"),
"true",
);
assert.strictEqual(
getProp(bomData.parentComponent, "cdx:asar:hasNativeAddons"),
"true",
);
const mainFileComponent = bomData.bomJson.components.find(
(component) => getProp(component, "cdx:asar:path") === "src/main.js",
);
assert.ok(mainFileComponent, "expected src/main.js component");
assert.strictEqual(
getProp(mainFileComponent, "cdx:asar:js:capability:network"),
"true",
);
const sketchyAddon = bomData.bomJson.components.find(
(component) => component.name === "sketchy-addon",
);
assert.ok(sketchyAddon, "expected extracted npm component");
assert.ok(
String(getProp(sketchyAddon, "SrcFile") || "").includes(
`${archivePath}#/`,
),
);
const postProcessed = postProcess(bomData, {
bomAudit: true,
bomAuditCategories: ["asar-archive"],
installDeps: false,
projectType: ["asar"],
specVersion: 1.7,
});
const findings = await auditBom(postProcessed.bomJson, {
bomAuditCategories: ["asar-archive"],
});
assert.ok(
findings.some((finding) => finding.ruleId === "ASAR-001"),
"expected ASAR eval/dynamic execution finding",
);
assert.ok(
findings.some((finding) => finding.ruleId === "ASAR-004"),
"expected embedded npm install-script finding",
);
} finally {
rmSync(fixtureRoot, { force: true, recursive: true });
}
});
it("scans directories containing multiple ASAR archives", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "cdxgen-asar-dir-"));
const firstArchivePath = join(fixtureRoot, "app-one.asar");
const secondArchivePath = join(fixtureRoot, "nested", "app-two.asar");
mkdirSync(join(fixtureRoot, "nested"), { recursive: true });
createAsarFixture(firstArchivePath, {
extraEntries: {
"src/one.js": { content: "export const one = 1;\n" },
},
});
createAsarFixture(secondArchivePath, {
extraEntries: {
"package.json": {
content: JSON.stringify({
name: "sample-electron-app-two",
version: "2.0.0",
main: "src/two.js",
}),
},
"src/two.js": { content: "export const two = 2;\n" },
},
});
try {
const bomData = await createAsarBom(fixtureRoot, {
installDeps: false,
multiProject: true,
projectType: ["asar"],
specVersion: 1.7,
});
const archiveComponents = (bomData.bomJson?.components || []).filter(
(component) => getProp(component, "cdx:file:kind") === "asar-archive",
);
assert.strictEqual(archiveComponents.length, 2);
} finally {
rmSync(fixtureRoot, { force: true, recursive: true });
}
});
it("keeps distinct nested ASAR archives with different virtual paths", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "cdxgen-asar-case-"));
const outerArchivePath = join(fixtureRoot, "outer.asar");
const firstNestedArchivePath = join(fixtureRoot, "first-nested.asar");
const secondNestedArchivePath = join(fixtureRoot, "second-nested.asar");
createAsarFixture(firstNestedArchivePath);
createAsarFixture(secondNestedArchivePath, {
extraEntries: {
"package.json": {
content: JSON.stringify({
name: "sample-electron-app-upper",
version: "4.5.6",
main: "src/main.js",
}),
},
},
});
createAsarFixture(outerArchivePath, {
extraEntries: {
"nested/first/core.asar": {
content: readFileSync(firstNestedArchivePath),
},
"nested/second/core.asar": {
content: readFileSync(secondNestedArchivePath),
},
},
});
try {
const bomData = await createAsarBom(outerArchivePath, {
installDeps: false,
multiProject: false,
projectType: ["asar"],
specVersion: 1.7,
});
const nestedArchiveComponents = (
bomData.bomJson?.components || []
).filter(
(component) =>
getProp(component, "cdx:file:kind") === "asar-archive" &&
String(getProp(component, "SrcFile") || "").startsWith(
`${outerArchivePath}#/nested/`,
),
);
assert.strictEqual(nestedArchiveComponents.length, 2);
assert.ok(
nestedArchiveComponents.some(
(component) =>
getProp(component, "SrcFile") ===
`${outerArchivePath}#/nested/first/core.asar`,
),
);
assert.ok(
nestedArchiveComponents.some(
(component) =>
getProp(component, "SrcFile") ===
`${outerArchivePath}#/nested/second/core.asar`,
),
);
} finally {
rmSync(fixtureRoot, { force: true, recursive: true });
}
});
it("recursively scans nested ASAR archives and rewrites nested evidence paths", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "cdxgen-asar-nested-"));
const archivePath = join(fixtureRoot, "outer.asar");
const nestedArchivePath = join(fixtureRoot, "inner.asar");
createAsarFixture(nestedArchivePath, {
extraEntries: {
"node_modules/inner-addon/package.json": {
content: JSON.stringify({
name: "inner-addon",
version: "1.0.0",
}),
},
"package-lock.json": {
content: JSON.stringify({
lockfileVersion: 3,
name: "inner-electron-app",
packages: {
"": {
dependencies: {
"inner-addon": "1.0.0",
},
name: "inner-electron-app",
version: "9.9.9",
},
"node_modules/inner-addon": {
name: "inner-addon",
version: "1.0.0",
},
},
}),
},
"package.json": {
content: JSON.stringify({
dependencies: {
"inner-addon": "1.0.0",
},
name: "inner-electron-app",
version: "9.9.9",
main: "src/main.js",
}),
},
},
});
createAsarFixture(archivePath, {
extraEntries: {
"nested/core.asar": {
content: readFileSync(nestedArchivePath),
},
},
});
try {
const bomData = await createAsarBom(archivePath, {
installDeps: false,
multiProject: false,
projectType: ["asar"],
specVersion: 1.7,
});
const nestedArchiveComponent = bomData.bomJson.components.find(
(component) =>
getProp(component, "cdx:file:kind") === "asar-archive" &&
getProp(component, "SrcFile") ===
`${archivePath}#/nested/core.asar`,
);
const nestedMainFileComponent = bomData.bomJson.components.find(
(component) =>
getProp(component, "cdx:asar:path") === "src/main.js" &&
component.evidence?.occurrences?.some(
(occurrence) =>
occurrence.location ===
`${archivePath}#/nested/core.asar#/src/main.js`,
),
);
const nestedNpmComponent = bomData.bomJson.components.find(
(component) =>
component.name === "inner-addon" &&
String(getProp(component, "SrcFile") || "").startsWith(
`${archivePath}#/nested/core.asar#/`,
),
);
assert.ok(nestedArchiveComponent, "expected nested archive component");
assert.ok(
nestedMainFileComponent,
"expected nested archive file inventory component",
);
assert.ok(nestedNpmComponent, "expected nested archive npm component");
} finally {
rmSync(fixtureRoot, { force: true, recursive: true });
}
});
it("produces a schema-valid BOM with ASAR signing crypto components", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "cdxgen-asar-signed-"));
const appDir = join(fixtureRoot, "Signed.app");
const archivePath = join(appDir, "Contents", "Resources", "app.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.asar": {
algorithm: "SHA256",
hash: headerHash,
},
});
try {
const bomData = await createAsarBom(archivePath, {
installDeps: false,
multiProject: false,
projectType: ["asar"],
specVersion: 1.7,
});
const postProcessed = postProcess(bomData, {
installDeps: false,
projectType: ["asar"],
specVersion: 1.7,
});
assert.strictEqual(validateBom(postProcessed.bomJson), true);
assert.ok(
postProcessed.bomJson.components.some(
(component) =>
component.type === "cryptographic-asset" &&
getProp(component, "cdx:asar:signingVerified") === "true",
),
"expected a verified ASAR signing crypto component",
);
} finally {
rmSync(fixtureRoot, { force: true, recursive: true });
}
});
});
}