UNPKG

@cyclonedx/cdxgen

Version:

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

467 lines (442 loc) 14.3 kB
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, } 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 { buildPurlAliasMap, collectDosaiDataFlowFrames, collectDosaiPurlEvidence, collectDosaiServicesFromMethods, isDosaiDotnetLanguage, normalizeDosaiServiceMap, persistDosaiSemanticsReport, resolveComponentPurl, } from "./dosai.js"; describe("dosai helpers", () => { it("recognizes C#, VB.NET, and F# language aliases", () => { for (const language of [ "csharp", "dotnet", "vb", "vbnet", "visualbasic", "f#", "fs", "fsharp", ]) { assert.strictEqual(isDosaiDotnetLanguage(language), true); } }); it("matches versionless dosai purls to cdxgen component purls", () => { const components = [{ purl: "pkg:nuget/System.Text.Json@10.0.0" }]; const aliases = buildPurlAliasMap(components); assert.strictEqual( resolveComponentPurl("pkg:nuget/System.Text.Json", aliases), "pkg:nuget/System.Text.Json@10.0.0", ); }); it("collects package occurrence evidence from dosai PackageReachability", () => { const methodsSlice = { CallGraph: { Edges: [ { Id: "e1", FileName: "System.Text.Json.dll", LineNumber: 12, CalledMethodName: "System.Text.Json.JsonSerializer.Deserialize", TargetName: "Deserialize", }, ], Nodes: [ { Id: "n1", FileName: "Program.cs", LineNumber: 10, ClassName: "Program", Name: "Main", }, { Id: "n2", FileName: "System.Text.Json.dll", LineNumber: 0, ClassName: "JsonSerializer", Name: "Deserialize", }, ], }, PackageReachability: [ { Purl: "pkg:nuget/System.Text.Json", EdgeIds: ["e1"], NodeIds: ["n1", "n2"], SourceLocations: [ { Path: "Controllers/Parser.cs", FileName: "Parser.cs", LineNumber: 42, ColumnNumber: 13, Kind: "CallGraphEdge", }, ], }, ], }; const retMap = collectDosaiPurlEvidence(methodsSlice, [ { purl: "pkg:nuget/System.Text.Json@10.0.0" }, ]); assert.deepStrictEqual( Array.from( retMap.purlLocationMap["pkg:nuget/System.Text.Json@10.0.0"], ).sort(), ["Controllers/Parser.cs#42"], ); assert.ok( retMap.purlMethodsMap["pkg:nuget/System.Text.Json@10.0.0"].has( "System.Text.Json.JsonSerializer.Deserialize", ), ); }); it("keeps PackageReachability fallback occurrence evidence source-only", () => { const retMap = collectDosaiPurlEvidence( { CallGraph: { Edges: [ { Id: "e1", FileName: "System.Text.Json.dll", LineNumber: 12, CalledMethodName: "System.Text.Json.JsonSerializer.Deserialize", CallLocation: { FileName: "Program.fs", LineNumber: 8, }, }, { Id: "e2", FileName: "Controllers/EpisodesController.cs", LineNumber: 17, CalledMethodName: "System.Text.Json.JsonSerializer.Serialize", }, ], }, PackageReachability: [ { Purl: "pkg:nuget/System.Text.Json", EdgeIds: ["e1", "e2"], }, ], }, [{ purl: "pkg:nuget/System.Text.Json@10.0.0" }], ); assert.deepStrictEqual( Array.from(retMap.purlLocationMap["pkg:nuget/System.Text.Json@10.0.0"]), ["Program.fs#8", "Controllers/EpisodesController.cs#17"], ); assert.ok( !Array.from( retMap.purlLocationMap["pkg:nuget/System.Text.Json@10.0.0"], ).some((location) => location.includes(".dll")), ); }); it("collects package occurrence evidence from dosai Dependencies with purls", () => { const retMap = collectDosaiPurlEvidence( { Dependencies: [ { Path: "Program.vb", FileName: "Program.vb", Name: "Newtonsoft.Json", Purl: "pkg:nuget/Newtonsoft.Json@13.0.3", LineNumber: 4, ColumnNumber: 9, }, ], }, [{ purl: "pkg:nuget/Newtonsoft.Json@13.0.3" }], ); assert.deepStrictEqual( Array.from(retMap.purlLocationMap["pkg:nuget/Newtonsoft.Json@13.0.3"]), ["Program.vb#4"], ); assert.ok( retMap.purlModulesMap["pkg:nuget/Newtonsoft.Json@13.0.3"].has( "Newtonsoft.Json", ), ); }); it("collects package occurrence evidence from dosai R file Dependencies", () => { const retMap = collectDosaiPurlEvidence( { Dependencies: [ { Path: "dependencyImports.R", FileName: "dependencyImports.R", Name: "Newtonsoft.Json", Purl: "pkg:nuget/Newtonsoft.Json@13.0.3", LineNumber: 1, ColumnNumber: 1, }, ], PackageReachability: [ { Purl: "pkg:nuget/Newtonsoft.Json@13.0.3", SourceLocations: [ { Path: "dependencyImports.R", FileName: "dependencyImports.R", LineNumber: 1, Kind: "Dependency", }, ], }, ], }, [{ purl: "pkg:nuget/Newtonsoft.Json@13.0.3" }], ); assert.deepStrictEqual( Array.from(retMap.purlLocationMap["pkg:nuget/Newtonsoft.Json@13.0.3"]), ["dependencyImports.R#1"], ); }); it("builds CycloneDX services from dosai ApiEndpoints without raw policy names", () => { const servicesMap = collectDosaiServicesFromMethods({ ApiEndpoints: [ { Route: "/api/podcasts?sig=secret", FileName: "EpisodesController.cs", Path: "Controllers/EpisodesController.cs", ClassName: "EpisodesController", MethodName: "Get", HttpMethod: "GET", EndpointKind: "Attribute", AuthorizationRequired: true, AuthorizationPolicies: ["InternalPolicyName"], Roles: ["Admin"], AllowAnonymous: false, LineNumber: 42, ColumnNumber: 9, }, ], }); const services = normalizeDosaiServiceMap(servicesMap); assert.strictEqual(services.length, 1); assert.deepStrictEqual(services[0].endpoints, ["/api/podcasts"]); assert.strictEqual(services[0].authenticated, true); assert.ok( services[0].properties.some( (property) => property.name === "cdx:dosai:authorizationPolicyCount" && property.value === "1", ), ); assert.ok(!JSON.stringify(services[0]).includes("InternalPolicyName")); }); it("collects callstack frames from dosai data-flow slices", () => { const frames = collectDosaiDataFlowFrames( { Nodes: [ { Id: "dfn1", Path: "Controllers/EpisodesController.cs", Namespace: "Podcast.Api", ClassName: "EpisodesController", MethodName: "Get", LineNumber: 12, ColumnNumber: 5, }, { Id: "dfn2", Path: "Services/JsonLoader.cs", Namespace: "Podcast.Api", ClassName: "JsonLoader", MethodName: "Load", LineNumber: 20, ColumnNumber: 9, }, ], Slices: [ { NodeIds: ["dfn1", "dfn2"], Purls: ["pkg:nuget/System.Text.Json"], }, ], }, [{ purl: "pkg:nuget/System.Text.Json@10.0.0" }], ); assert.strictEqual(frames["pkg:nuget/System.Text.Json@10.0.0"].length, 1); assert.strictEqual( frames["pkg:nuget/System.Text.Json@10.0.0"][0][1].function, "Load", ); }); it("rejects unsafe dosai command inputs before spawning", async () => { const safeSpawnSync = sinon.stub().returns({ status: 0 }); const { runDosaiCommand } = await esmock("./dosai.js", { "./plugins.js": { resolvePluginBinary: sinon.stub().returns("dosai") }, "./utils.js": { DEBUG_MODE: false, getTmpDir: sinon.stub().returns("/tmp"), safeExistsSync: sinon.stub().returns(true), safeMkdtempSync: sinon.stub(), safeRmSync: sinon.stub(), safeSpawnSync, }, }); assert.strictEqual( runDosaiCommand("methods;rm -rf /", "/tmp/project", "/tmp/out.json"), false, ); assert.strictEqual( runDosaiCommand("methods", "/tmp/project\n--bad", "/tmp/out.json"), false, ); sinon.assert.notCalled(safeSpawnSync); }); it("spawns dosai with argument arrays and shell disabled", async () => { const safeSpawnSync = sinon.stub().returns({ status: 0 }); const { runDosaiCommand } = await esmock("./dosai.js", { "./plugins.js": { resolvePluginBinary: sinon.stub().returns("dosai") }, "./utils.js": { DEBUG_MODE: false, getTmpDir: sinon.stub().returns("/tmp"), safeExistsSync: sinon.stub().returns(true), safeMkdtempSync: sinon.stub(), safeRmSync: sinon.stub(), safeSpawnSync, }, }); assert.strictEqual( runDosaiCommand("dataflows", "/tmp/project", "/tmp/out.json", { dataFlowPatterns: "/tmp/patterns.json", patternPacks: "/tmp/packs", }), true, ); sinon.assert.calledOnce(safeSpawnSync); assert.strictEqual(safeSpawnSync.firstCall.args[0], "dosai"); assert.ok(Array.isArray(safeSpawnSync.firstCall.args[1])); assert.strictEqual(safeSpawnSync.firstCall.args[2].shell, false); }); it("persists the combined native dosai report to semanticsSlicesFile when provided", () => { // Under --profile research, evinse passes --semantics-slices-file to the // dosai branch; persistDosaiSemanticsReport must write the FULL combined // native report {Metadata, methods, dataflows} there and keep it, so // downstream tools (depscan) read it as the source of truth. The // intermediate methods/dataflows slice files are owned by evinse's // createSlice; this helper only persists the combined report. const durableDir = mkdtempSync(join(tmpdir(), "cdxgen-dosai-durable-")); const durablePath = join(durableDir, "dotnet-semantics.slices.json"); const methodsSlice = { Metadata: { SchemaVersion: "3.3.0", AnalyzerVersion: "3.0.5.0", Tool: "Dosai", }, CallGraph: { Nodes: [], Edges: [] }, ApiEndpoints: [], PackageReachability: [ { Purl: "pkg:nuget/Newtonsoft.Json@13.0.3", Reachable: true, ReachabilityKind: "Dependency", Confidence: "Low", SourceLocations: [], }, ], }; const dataFlowSlice = { Metadata: { SchemaVersion: "3.3.0", AnalyzerVersion: "3.0.5.0", Tool: "Dosai", }, Nodes: [], Edges: [], Slices: [ { Id: "dfs1", SinkPurl: "pkg:nuget/Newtonsoft.Json", Purls: ["pkg:nuget/Newtonsoft.Json"], }, ], PackageReachability: [ { Purl: "pkg:nuget/Newtonsoft.Json", Reachable: true, ReachabilityKind: "DataFlowNode", Confidence: "High", SourceLocations: [ { Path: "Program.cs", FileName: "Program.cs", LineNumber: 10 }, ], }, ], }; try { const persisted = persistDosaiSemanticsReport( { semanticsSlicesFile: durablePath }, methodsSlice, dataFlowSlice, ); // the durable file persists on disk with the combined native report assert.strictEqual(persisted, durablePath); assert.ok(existsSync(durablePath), "durable report file must persist"); const report = JSON.parse(readFileSync(durablePath, "utf-8")); // native PascalCase Metadata (taken from the richest data-flow slice) assert.strictEqual(report.Metadata?.Tool, "Dosai"); assert.strictEqual(report.Metadata?.SchemaVersion, "3.3.0"); assert.ok(report.methods, "persisted report must carry methods"); assert.ok(report.dataflows, "persisted report must carry dataflows"); assert.ok( report.dataflows.PackageReachability?.length, "persisted dataflows must carry PackageReachability", ); assert.ok( report.dataflows.Slices?.length, "persisted dataflows must carry Slices", ); } finally { rmSync(durableDir, { recursive: true, force: true }); } }); it("persistDosaiSemanticsReport returns undefined and writes nothing when both slices are empty", () => { const dir = mkdtempSync(join(tmpdir(), "cdxgen-dosai-empty-")); const durablePath = join(dir, "dotnet-semantics.slices.json"); try { const result = persistDosaiSemanticsReport( { semanticsSlicesFile: durablePath }, {}, undefined, ); assert.strictEqual(result, undefined); assert.ok( !existsSync(durablePath), "no report should be written when both slices are empty", ); } finally { rmSync(dir, { recursive: true, force: true }); } }); it("persistDosaiSemanticsReport writes nothing when no path is set", () => { const dir = mkdtempSync(join(tmpdir(), "cdxgen-dosai-none-")); try { const result = persistDosaiSemanticsReport( { semanticsSlicesFile: undefined }, { Metadata: { Tool: "Dosai" } }, { Metadata: { Tool: "Dosai" } }, ); assert.strictEqual(result, undefined); // nothing was written anywhere under the temp dir assert.strictEqual(readdirSync(dir).length, 0); } finally { rmSync(dir, { recursive: true, force: true }); } }); });