UNPKG

@cyclonedx/cdxgen

Version:

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

629 lines (609 loc) 19.1 kB
import { existsSync, mkdtempSync, rmSync, 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 { collectGolemEvidence, isGolemGoLanguage, readGolemJsonFile, runGolemAnalysis, } from "./golem.js"; describe("golem helpers", () => { it("recognizes Go language aliases", () => { assert.strictEqual(isGolemGoLanguage("go"), true); assert.strictEqual(isGolemGoLanguage("golang"), true); assert.strictEqual(isGolemGoLanguage("java"), false); }); it("collects occurrence, callstack, and safe property evidence", () => { const report = { tool: { version: "2.2.0" }, options: { noRecurse: false, includeAllFlows: false, }, modules: [ { path: "example.com/app", main: true, purl: "pkg:golang/example.com/app", goVersion: "1.26", }, { path: "github.com/google/uuid", version: "v1.6.0", purl: "pkg:golang/github.com/google/uuid@v1.6.0", goVersion: "1.20", }, ], imports: [ { module: { purl: "pkg:golang/github.com/google/uuid@v1.6.0" }, direct: true, aliasKind: "default", usageScope: "runtime", range: { start: { filename: "main.go", line: 2, column: 8 } }, }, ], usages: [ { module: { purl: "pkg:golang/github.com/google/uuid@v1.6.0" }, kind: "selector", symbolKind: "function", call: true, usageScope: "test", range: { start: { filename: "main.go", line: 3, column: 22 } }, enclosing: { id: "example.com/app||main|func()", kind: "function", name: "main", usageScope: "test", }, }, ], files: [{ generatedBy: "protoc-gen-go" }], buildDirectives: [{ kind: "go-generate" }, { kind: "go-embed" }], nativeArtifacts: [{ kind: "assembly" }], supplyChain: { goDirectiveVersion: "1.26", toolchainDirective: "go1.26.3", goWorkPresent: true, vendorDirectoryPresent: true, replaces: [ { modulePath: "github.com/google/uuid", targetPathKind: "relative", localReplacement: true, }, ], excludes: [{ modulePath: "example.com/unused/module" }], modules: [ { purl: "pkg:golang/github.com/google/uuid@v1.6.0", vendored: true, privateModuleCandidate: false, licenseFiles: ["LICENSE"], properties: { localReplacement: "true" }, }, ], }, securitySignals: [ { category: "weak-crypto", severity: "high", packagePath: "github.com/google/uuid", }, ], callGraph: { mode: "static", stats: { nodeCount: 2, edgeCount: 1 }, edges: [ { sourceId: "example.com/app.main", sourceName: "example.com/app.main", targetId: "github.com/google/uuid.NewString", callType: "static", position: { filename: "main.go", line: 3, column: 36 }, }, { sourceId: "example.com/app.main", sourceName: "example.com/app.main", targetId: "opaque.generated.symbol", sourcePurl: "pkg:golang/example.com/app", sinkPurl: "pkg:golang/github.com/google/uuid@v1.6.0#uuid", purls: [ "pkg:golang/example.com/app", "pkg:golang/github.com/google/uuid@v1.6.0#uuid", ], callType: "static", position: { filename: "main.go", line: 4, column: 11 }, }, ], }, stats: { packageCount: 2, moduleCount: 2, fileCount: 1, importCount: 1, declarationCount: 1, usageCount: 1, runtimeUsageCount: 1, testUsageCount: 1, generatedFileCount: 1, buildDirectiveCount: 2, nativeArtifactCount: 1, securitySignalCount: 1, goModReplaceCount: 1, goModExcludeCount: 1, vendorModuleCount: 1, workspaceModuleCount: 1, licenseFileModuleCount: 1, }, }; const evidence = collectGolemEvidence(report, [ { purl: "pkg:golang/example.com/app" }, { purl: "pkg:golang/github.com/google/uuid@v1.6.0" }, ]); assert.deepStrictEqual( Array.from( evidence.purlLocationMap["pkg:golang/github.com/google/uuid@v1.6.0"], ).sort(), ["main.go#2", "main.go#3", "main.go#4"], ); assert.strictEqual( evidence.dataFlowFrames["pkg:golang/github.com/google/uuid@v1.6.0"] .length, 3, ); assert.ok( evidence.componentPropertiesMap[ "pkg:golang/github.com/google/uuid@v1.6.0" ].some( (property) => property.name === "cdx:golem:securitySignalCategory" && property.value === "weak-crypto", ), ); assert.ok( evidence.componentPropertiesMap[ "pkg:golang/github.com/google/uuid@v1.6.0" ].some( (property) => property.name === "cdx:golem:usageScopes" && property.value === "runtime,test", ), ); assert.ok( evidence.componentPropertiesMap[ "pkg:golang/github.com/google/uuid@v1.6.0" ].some( (property) => property.name === "cdx:golem:occurrenceEvidenceKinds" && property.value === "import,symbolCall", ), ); assert.ok( evidence.componentPropertiesMap[ "pkg:golang/github.com/google/uuid@v1.6.0" ].some( (property) => property.name === "cdx:golem:licenseFiles" && property.value === "LICENSE", ), ); assert.ok( evidence.metadataProperties.some( (property) => property.name === "cdx:golem:buildDirectiveKinds" && property.value === "go-embed,go-generate", ), ); assert.ok( evidence.metadataProperties.some( (property) => property.name === "cdx:golem:generatorKinds" && property.value === "protoc-gen-go", ), ); assert.ok( evidence.metadataProperties.some( (property) => property.name === "cdx:golem:goModReplaceCount" && property.value === "1", ), ); assert.ok( evidence.metadataProperties.some( (property) => property.name === "cdx:golem:noRecurse" && property.value === "false", ), ); assert.ok( evidence.metadataProperties.some( (property) => property.name === "cdx:golem:includeAllFlows" && property.value === "false", ), ); assert.ok(!JSON.stringify(evidence).includes("go run")); assert.ok(!JSON.stringify(evidence).includes("example.com/unused/module@")); }); it("uses direct Golem purl attributes for data-flow evidence matching", () => { const report = { modules: [ { path: "example.com/app", main: true, purl: "pkg:golang/example.com/app", }, { path: "github.com/acme/dep", version: "v1.2.3", purl: "pkg:golang/github.com/acme/dep@v1.2.3", }, ], dataFlow: { nodes: [ { id: "source", purl: "pkg:golang/github.com/acme/dep@v1.2.3#subpkg", function: "github.com/acme/dep/subpkg.Source", position: { filename: "dep.go", line: 12, column: 7 }, category: "config", }, { id: "sink", purl: "pkg:golang/github.com/acme/dep@v1.2.3#subpkg", function: "github.com/acme/dep/subpkg.Sink", position: { filename: "dep.go", line: 18, column: 3 }, category: "filesystem", }, ], slices: [ { id: "slice", sourceId: "source", sinkId: "sink", nodeIds: ["source", "sink"], purls: ["pkg:golang/github.com/acme/dep@v1.2.3#subpkg"], sourceCategory: "config", sinkCategory: "filesystem", ruleId: "GOLEM-DATAFLOW-CONFIG-FILE", sinkScope: "runtime", taintKinds: ["config"], }, ], }, }; const evidence = collectGolemEvidence(report, [ { purl: "pkg:golang/example.com/app" }, { purl: "pkg:golang/github.com/acme/dep@v1.2.3" }, ]); assert.deepStrictEqual( Array.from( evidence.purlLocationMap["pkg:golang/github.com/acme/dep@v1.2.3"], ).sort(), ["dep.go#12", "dep.go#18"], ); assert.strictEqual( evidence.dataFlowFrames["pkg:golang/github.com/acme/dep@v1.2.3"][0] .length, 2, ); assert.ok( evidence.componentPropertiesMap[ "pkg:golang/github.com/acme/dep@v1.2.3" ].some( (property) => property.name === "cdx:golem:dataFlowRuleId" && property.value === "GOLEM-DATAFLOW-CONFIG-FILE", ), ); }); it("converts golem crypto evidence into crypto components", () => { const report = { modules: [ { path: "example.com/app", main: true, purl: "pkg:golang/example.com/app", }, ], crypto: { assets: [ { id: "asset-md5", name: "md5", assetType: "algorithm", primitive: "hash", oid: "1.2.840.113549.2.5", strength: "weak", packagePath: "example.com/app", symbol: "crypto/md5.Sum", usageScope: "runtime", range: { start: { filename: "main.go", line: 10, column: 8 } }, }, ], operations: [ { operationType: "hash", algorithm: "md5", assetId: "asset-md5", packagePath: "example.com/app", symbol: "crypto/md5.Sum", usageScope: "runtime", range: { start: { filename: "main.go", line: 10, column: 8 } }, }, ], materials: [ { id: "material-private-key", type: "private-key", name: "privateKeyPEM", packagePath: "example.com/app", symbol: "literal", usageScope: "runtime", range: { start: { filename: "main.go", line: 5, column: 6 } }, }, ], protocols: [ { id: "protocol-tls", name: "TLS", type: "tls", packagePath: "example.com/app", symbol: "crypto/tls.Config", usageScope: "runtime", range: { start: { filename: "main.go", line: 12, column: 10 } }, }, ], findings: [ { ruleId: "GOLEM-CRYPTO-WEAK-MD5", severity: "high", packagePath: "example.com/app", range: { start: { filename: "main.go", line: 10, column: 8 } }, }, ], }, dataFlow: { mode: "all", nodes: [ { id: "source-env", kind: "source", name: "Getenv", symbol: "os.Getenv", packagePath: "example.com/app", module: { purl: "pkg:golang/example.com/app" }, purl: "pkg:golang/example.com/app", function: "example.com/app.main", position: { filename: "main.go", line: 9, column: 10 }, source: true, category: "environment", taintKinds: ["secret", "crypto-key"], }, { id: "sink-crypto", kind: "sink", name: "Sum", symbol: "crypto/md5.Sum", packagePath: "example.com/app", module: { purl: "pkg:golang/example.com/app" }, purl: "pkg:golang/example.com/app", function: "example.com/app.main", position: { filename: "main.go", line: 10, column: 8 }, sink: true, category: "crypto", taintKinds: ["secret", "crypto-key"], }, ], slices: [ { id: "slice-crypto", sourceId: "source-env", sinkId: "sink-crypto", nodeIds: ["source-env", "sink-crypto"], sourceCategory: "environment", sinkCategory: "crypto", sourcePackagePath: "example.com/app", sourcePurl: "pkg:golang/example.com/app", sinkPackagePath: "example.com/app", sinkPurl: "pkg:golang/example.com/app", sinkSymbol: "crypto/md5.Sum", ruleId: "GOLEM-DATAFLOW-CRYPTO-MATERIAL", severity: "high", confidence: "medium", sinkScope: "runtime", taintKinds: ["crypto-key", "secret"], }, ], stats: { sourceCount: 1, sinkCount: 1, sliceCount: 1, nodeCount: 2, edgeCount: 1, summaryCount: 0, functionCount: 1, instructionCount: 4, }, }, options: { dataFlowMode: "all", dataFlowCallGraphMode: "none", dataFlowPacks: ["all"], }, stats: { dataFlowSourceCount: 1, dataFlowSinkCount: 1, dataFlowSliceCount: 1, }, }; const evidence = collectGolemEvidence(report, [ { purl: "pkg:golang/example.com/app" }, ]); const algorithmComponent = evidence.cryptoComponents.find( (component) => component.name === "md5", ); assert.strictEqual(algorithmComponent.type, "cryptographic-asset"); assert.strictEqual( algorithmComponent.cryptoProperties.assetType, "algorithm", ); assert.strictEqual( algorithmComponent.cryptoProperties.oid, "1.2.840.113549.2.5", ); assert.ok( evidence.cryptoComponents.some( (component) => component.cryptoProperties?.relatedCryptoMaterialProperties?.type === "private-key", ), ); assert.ok( evidence.cryptoComponents.some( (component) => component.cryptoProperties?.protocolProperties?.type === "tls", ), ); assert.ok( evidence.componentPropertiesMap["pkg:golang/example.com/app"].some( (property) => property.name === "cdx:golem:cryptoFinding" && property.value === "GOLEM-CRYPTO-WEAK-MD5", ), ); assert.ok( evidence.cryptoGeneratePurls["pkg:golang/example.com/app"].has( algorithmComponent["bom-ref"], ), ); assert.ok( evidence.componentPropertiesMap["pkg:golang/example.com/app"].some( (property) => property.name === "cdx:golem:cryptoDataFlowRuleId" && property.value === "GOLEM-DATAFLOW-CRYPTO-MATERIAL", ), ); assert.ok( evidence.metadataProperties.some( (property) => property.name === "cdx:golem:cryptoDataFlowCount" && property.value === "1", ), ); assert.strictEqual( evidence.dataFlowFrames["pkg:golang/example.com/app"][0].length, 2, ); assert.ok(!JSON.stringify(evidence).includes("PRIVATE KEY")); }); it("spawns golem deep mode with performance-oriented data-flow defaults", async () => { const safeSpawnSync = sinon.stub().returns({ status: 0 }); const { runGolemAnalysis } = await esmock("./golem.js", { "./plugins.js": { resolvePluginBinary: sinon.stub().returns("golem") }, "./utils.js": { DEBUG_MODE: false, getTmpDir: sinon.stub().returns("/tmp"), safeExistsSync: sinon.stub().returns(true), safeMkdtempSync: sinon.stub(), safeRmSync: sinon.stub(), safeSpawnSync, }, }); assert.strictEqual( runGolemAnalysis("/tmp/project", "/tmp/out.json", { deep: true }), true, ); const args = safeSpawnSync.firstCall.args[1]; assert.strictEqual(args[args.indexOf("--callgraph") + 1], "static"); assert.strictEqual(args[args.indexOf("--dataflow") + 1], "all"); assert.strictEqual( args[args.indexOf("--dataflow-callgraph") + 1], "static", ); assert.strictEqual( args[args.indexOf("--dataflow-pattern-packs") + 1], "all", ); assert.strictEqual(args[args.indexOf("--dataflow-max-slices") + 1], "250"); assert.ok(args.includes("--dataflow-skip-generated")); assert.ok(args.includes("--dataflow-skip-tests")); assert.ok(args.includes("--max-procs")); }); it("spawns golem with argument arrays and shell disabled", async () => { const safeSpawnSync = sinon.stub().returns({ status: 0 }); const { runGolemAnalysis } = await esmock("./golem.js", { "./plugins.js": { resolvePluginBinary: sinon.stub().returns("golem") }, "./utils.js": { DEBUG_MODE: false, getTmpDir: sinon.stub().returns("/tmp"), safeExistsSync: sinon.stub().returns(true), safeMkdtempSync: sinon.stub(), safeRmSync: sinon.stub(), safeSpawnSync, }, }); assert.strictEqual( runGolemAnalysis("/tmp/project", "/tmp/out.json", { golemCallgraph: "rta", }), true, ); sinon.assert.calledOnce(safeSpawnSync); assert.strictEqual(safeSpawnSync.firstCall.args[0], "golem"); assert.ok(Array.isArray(safeSpawnSync.firstCall.args[1])); assert.strictEqual(safeSpawnSync.firstCall.args[2].shell, false); }); it("runs optional golem data-flow E2E smoke test when the binary is available", () => { const projectDir = mkdtempSync(join(tmpdir(), "cdxgen-golem-e2e-")); const outputFile = join(projectDir, "golem.json"); try { writeFileSync( join(projectDir, "go.mod"), "module example.com/app\n\ngo 1.22\n", ); writeFileSync( join(projectDir, "main.go"), `package main import ( "crypto/aes" "os" ) func main() { key := []byte(os.Getenv("APP_CRYPTO_KEY")) if len(key) >= 16 { _, _ = aes.NewCipher(key[:16]) } } `, ); if ( !runGolemAnalysis(projectDir, outputFile, { deep: true, golemDataflowMaxSlices: 20, golemDataflowWorkers: 1, golemMaxProcs: 1, }) || !existsSync(outputFile) ) { return; } const report = readGolemJsonFile(outputFile); assert.ok(report?.dataFlow); const evidence = collectGolemEvidence(report, [ { purl: "pkg:golang/example.com/app" }, ]); assert.ok( evidence.metadataProperties.some( (property) => property.name === "cdx:golem:dataFlowMode", ), ); } finally { rmSync(projectDir, { recursive: true, force: true }); } }); });