UNPKG

@cyclonedx/cdxgen

Version:

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

515 lines (487 loc) 16.6 kB
import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { analyzeRusiProject, collectRusiEvidence, isRusiRustLanguage, readRusiJsonFile, runRusiAnalysis, } from "./rusi.js"; // rusi reports always declare this schema_version prefix; a report-0.1 JSON // carries the full call_graph + data_flow that downstream tools consume. const RUSI_SCHEMA_PREFIX = "https://appthreat.github.io/rusi/schema/report-"; describe("rusi helpers", () => { it("recognizes Rust language aliases", () => { assert.strictEqual(isRusiRustLanguage("rust"), true); assert.strictEqual(isRusiRustLanguage("rs"), true); assert.strictEqual(isRusiRustLanguage("rust-lang"), true); assert.strictEqual(isRusiRustLanguage("RUST"), true); assert.strictEqual(isRusiRustLanguage("java"), false); assert.strictEqual(isRusiRustLanguage("go"), false); }); it("collects metadata, import, usage, callgraph, dataflow, security, and crypto evidence", () => { const report = { schema_version: "1.0", tool: { version: "0.1.0" }, runtime: { rustc_version: "1.75.0", cargo_version: "1.75.0", host: "x86_64-unknown-linux-gnu", }, options: { backend: "stable", analysis_scope: "default", call_graph_mode: "static", data_flow_mode: "security", }, modules: [{ name: "mini-redis", version: "0.1.0" }], imports: [ { path: "tokio::net::TcpListener", purl: "pkg:cargo/tokio@1.35.0", position: { filename: "src/server.rs", line: 10, column: 5 }, }, ], usages: [ { kind: "call", name: "bind", purl: "pkg:cargo/tokio@1.35.0", package_path: "mini-redis", position: { filename: "src/server.rs", line: 15, column: 10 }, }, ], security_signals: [ { category: "unsafe-code", severity: "medium", purl: "pkg:cargo/tokio@1.35.0", position: { filename: "src/server.rs", line: 20, column: 1 }, }, ], call_graph: { mode: "static", nodes: [ { id: "n1", name: "main", package_path: "mini-redis", purl: "pkg:cargo/mini-redis", position: { filename: "src/main.rs", line: 5, column: 1 }, kind: "function", }, { id: "n2", name: "run", package_path: "mini-redis", purl: "pkg:cargo/mini-redis", position: { filename: "src/server.rs", line: 10, column: 1 }, kind: "function", }, ], edges: [ { source_id: "n1", target_id: "n2", source_name: "main", target_name: "run", sourcePurl: "pkg:cargo/mini-redis", targetPurl: "pkg:cargo/mini-redis", purls: ["pkg:cargo/mini-redis"], call_type: "static", position: { filename: "src/main.rs", line: 6, column: 5 }, }, ], }, data_flow: { mode: "security", nodes: [ { id: "s1", kind: "source", name: "env", purl: "pkg:cargo/mini-redis", position: { filename: "src/main.rs", line: 10, column: 5 }, category: "environment", }, { id: "k1", kind: "sink", name: "execute", purl: "pkg:cargo/mini-redis", position: { filename: "src/db.rs", line: 20, column: 5 }, category: "sql", }, ], slices: [ { source_id: "s1", sink_id: "k1", node_ids: ["s1", "k1"], sourcePurl: "pkg:cargo/mini-redis", targetPurl: "pkg:cargo/mini-redis", purls: ["pkg:cargo/mini-redis"], source_category: "environment", sink_category: "sql", rule_name: "SQL_INJECTION", }, ], }, crypto: { components: [ { algorithm: "SHA-256", kind: "hash", provider: "sha2", operation: "digest", symbol: "sha2::Sha256", purl: "pkg:cargo/sha2@0.10.8", position: { filename: "src/crypto.rs", line: 5, column: 1 }, }, ], materials: [ { kind: "key", name: "secret_key", function: "init_crypto", confidence: "high", position: { filename: "src/crypto.rs", line: 10, column: 1 }, }, ], findings: [ { category: "weak-crypto", severity: "high", purl: "pkg:cargo/md5@0.7.0", position: { filename: "src/legacy.rs", line: 2, column: 1 }, }, ], }, stats: { package_count: 1, file_count: 3, import_count: 1, declaration_count: 2, usage_count: 1, security_signal_count: 1, crypto_library_count: 1, crypto_component_count: 1, crypto_finding_count: 1, call_graph_node_count: 2, call_graph_edge_count: 1, data_flow_node_count: 2, data_flow_edge_count: 1, data_flow_slice_count: 1, }, }; const evidence = collectRusiEvidence(report, [ { purl: "pkg:cargo/mini-redis" }, { purl: "pkg:cargo/tokio@1.35.0" }, { purl: "pkg:cargo/sha2@0.10.8" }, { purl: "pkg:cargo/md5@0.7.0" }, ]); // Metadata assert.ok( evidence.metadataProperties.some( (p) => p.name === "cdx:rusi:backend" && p.value === "stable", ), ); assert.ok( evidence.metadataProperties.some( (p) => p.name === "cdx:rusi:dataFlowSliceCount" && p.value === "1", ), ); // Imports & Usages assert.deepStrictEqual( Array.from(evidence.purlLocationMap["pkg:cargo/tokio@1.35.0"]).sort(), ["src/server.rs#10", "src/server.rs#15"], ); assert.ok( evidence.componentPropertiesMap["pkg:cargo/tokio@1.35.0"].some( (p) => p.name === "cdx:rusi:importPath" && p.value === "tokio::net::TcpListener", ), ); // Security Signals assert.ok( evidence.componentPropertiesMap["pkg:cargo/tokio@1.35.0"].some( (p) => p.name === "cdx:rusi:securitySignalCategory" && p.value === "unsafe-code", ), ); // Call Graph & Data Flow assert.ok(evidence.dataFlowFrames["pkg:cargo/mini-redis"].length > 0); assert.ok( evidence.componentPropertiesMap["pkg:cargo/mini-redis"].some( (p) => p.name === "cdx:rusi:dataFlowCategories" && p.value === "environment->sql", ), ); // Crypto const algoComp = evidence.cryptoComponents.find( (c) => c.name === "SHA-256", ); assert.ok(algoComp); assert.strictEqual(algoComp.type, "cryptographic-asset"); assert.ok( algoComp.properties.some( (p) => p.name === "cdx:rusi:crypto:provider" && p.value === "sha2", ), ); const matComp = evidence.cryptoComponents.find( (c) => c.name === "secret_key", ); assert.ok(matComp); assert.strictEqual( matComp.cryptoProperties.assetType, "related-crypto-material", ); assert.ok( evidence.componentPropertiesMap["pkg:cargo/md5@0.7.0"]?.some( (p) => p.name === "cdx:rusi:cryptoFindingCategory" && p.value === "weak-crypto", ), ); }); it("spawns rusi with expected arguments and shell disabled", async () => { const safeSpawnSync = sinon.stub().returns({ status: 0 }); const { runRusiAnalysis } = await esmock("./rusi.js", { "./plugins.js": { resolvePluginBinary: sinon.stub().returns("rusi") }, "./utils.js": { DEBUG_MODE: false, getTmpDir: sinon.stub().returns("/tmp"), safeExistsSync: sinon.stub().returns(true), safeMkdtempSync: sinon.stub(), safeRmSync: sinon.stub(), safeSpawnSync, }, }); assert.strictEqual( runRusiAnalysis("/tmp/project", "/tmp/out.json", { rusiBackend: "compiler", rusiToolchain: "nightly", rusiCallgraph: "static", rusiDataflow: "security", }), true, ); sinon.assert.calledOnce(safeSpawnSync); const args = safeSpawnSync.firstCall.args[1]; assert.strictEqual(args[0], "analyze"); assert.ok(args.includes("--backend")); assert.ok(args.includes("compiler")); assert.ok(args.includes("--toolchain")); assert.ok(args.includes("nightly")); assert.strictEqual(safeSpawnSync.firstCall.args[2].shell, false); }); it("runs optional rusi data-flow E2E smoke test when the binary is available", () => { const projectDir = mkdtempSync(join(tmpdir(), "cdxgen-rusi-e2e-")); const outputFile = join(projectDir, "rusi.json"); try { writeFileSync( join(projectDir, "Cargo.toml"), `[package] name = "rusi-test" version = "0.1.0" edition = "2021" [dependencies] `, ); mkdirSync(join(projectDir, "src")); writeFileSync( join(projectDir, "src", "main.rs"), `fn main() { println!("Hello, world!"); } `, ); if ( !runRusiAnalysis(projectDir, outputFile, { rusiDataflow: "security", }) || !existsSync(outputFile) ) { return; } const report = readRusiJsonFile(outputFile); assert.ok(report?.stats); const evidence = collectRusiEvidence(report, [ { purl: "pkg:cargo/rusi-test" }, ]); assert.ok( evidence.metadataProperties.some( (property) => property.name === "cdx:rusi:dataFlowMode", ), ); } finally { rmSync(projectDir, { recursive: true, force: true }); } }); it("persists the raw rusi report to semanticsSlicesFile when provided", async () => { // Under --profile research, evinse passes --semantics-slices-file to rusi; // analyzeRusiProject must persist the FULL raw report there (call graph + // data-flow slices) instead of writing it to a throwaway temp dir. const durableDir = mkdtempSync(join(tmpdir(), "cdxgen-rusi-durable-")); const durablePath = join(durableDir, "rust-semantics.slices.json"); const mkdtempSpy = sinon.spy((...args) => mkdtempSync(...args)); const rmSpy = sinon.spy((...args) => rmSync(...args)); const syntheticReport = { schema_version: `${RUSI_SCHEMA_PREFIX}0.1`, tool: { name: "rusi", version: "2.5.2" }, call_graph: { mode: "static", nodes: [{ id: "n1", name: "main" }], edges: [], }, data_flow: { mode: "security", nodes: [], edges: [], slices: [] }, stats: { file_count: 1 }, }; // Stub the rusi spawn to write the synthetic report to --out and succeed. const safeSpawnSync = sinon.stub().callsFake((_executable, args) => { writeFileSync( args[args.indexOf("--out") + 1], JSON.stringify(syntheticReport), ); return { status: 0, stdout: "", stderr: "" }; }); const { analyzeRusiProject: mockedAnalyze } = await esmock("./rusi.js", { "./plugins.js": { resolvePluginBinary: sinon.stub().returns("rusi"), }, "./utils.js": { DEBUG_MODE: false, getTmpDir: () => tmpdir(), safeExistsSync: existsSync, safeMkdtempSync: mkdtempSpy, safeRmSync: rmSpy, safeSpawnSync, }, }); const report = mockedAnalyze("/tmp/project", { semanticsSlicesFile: durablePath, profile: "research", }); // the returned report carries the full call graph + data flow assert.strictEqual(report?.schema_version, syntheticReport.schema_version); assert.ok(report?.call_graph, "returned report must carry call_graph"); assert.ok(report?.data_flow, "returned report must carry data_flow"); // the durable file persists on disk with the full report assert.ok(existsSync(durablePath), "durable report file must persist"); const persisted = JSON.parse(readFileSync(durablePath, "utf-8")); assert.ok(persisted.call_graph, "persisted report must carry call_graph"); assert.ok(persisted.data_flow, "persisted report must carry data_flow"); assert.ok( persisted.schema_version.startsWith(RUSI_SCHEMA_PREFIX), "persisted report must declare a report-* schema_version", ); // the durable path is used directly -- no temp dir created or cleaned up assert.strictEqual(mkdtempSpy.callCount, 0); assert.strictEqual(rmSpy.callCount, 0); rmSync(durableDir, { recursive: true, force: true }); }); it("uses a temp dir and deletes the report when semanticsSlicesFile is absent", async () => { // Default behaviour: without --semantics-slices-file the report is written // to a temp dir and removed after parsing, so no file persists on disk. const mkdtempSpy = sinon.spy((...args) => mkdtempSync(...args)); const rmSpy = sinon.spy((...args) => rmSync(...args)); const syntheticReport = { schema_version: `${RUSI_SCHEMA_PREFIX}0.1`, tool: { name: "rusi", version: "2.5.2" }, call_graph: { mode: "static", nodes: [], edges: [] }, data_flow: { mode: "security", nodes: [], edges: [], slices: [] }, }; const safeSpawnSync = sinon.stub().callsFake((_executable, args) => { writeFileSync( args[args.indexOf("--out") + 1], JSON.stringify(syntheticReport), ); return { status: 0, stdout: "", stderr: "" }; }); const { analyzeRusiProject: mockedAnalyze } = await esmock("./rusi.js", { "./plugins.js": { resolvePluginBinary: sinon.stub().returns("rusi"), }, "./utils.js": { DEBUG_MODE: false, getTmpDir: () => tmpdir(), safeExistsSync: existsSync, safeMkdtempSync: mkdtempSpy, safeRmSync: rmSpy, safeSpawnSync, }, }); const report = mockedAnalyze("/tmp/project", { profile: "research" }); // the report is still parsed and returned assert.strictEqual(report?.schema_version, syntheticReport.schema_version); // a temp dir was created and cleaned up in the finally block assert.strictEqual(mkdtempSpy.callCount, 1); assert.strictEqual(rmSpy.callCount, 1); const tempDir = mkdtempSpy.firstCall.returnValue; assert.ok( tempDir?.startsWith(tmpdir()), "temp dir should live under getTmpDir()", ); assert.ok( !existsSync(tempDir), "temp dir must be removed in the finally block so no report persists", ); }); it("persists a report-0.1 JSON with call_graph and data_flow end-to-end (requires rusi)", () => { // E2E persistence smoke test through the real rusi binary. Skips silently // when the binary is unavailable (CI without the plugin). const projectDir = mkdtempSync(join(tmpdir(), "cdxgen-rusi-persist-e2e-")); const durablePath = join(projectDir, "rust-semantics.slices.json"); try { writeFileSync( join(projectDir, "Cargo.toml"), `[package] name = "rusi-persist-test" version = "0.1.0" edition = "2021" [dependencies] `, ); mkdirSync(join(projectDir, "src")); writeFileSync( join(projectDir, "src", "main.rs"), `fn main() { println!("Hello, world!"); } `, ); const report = analyzeRusiProject(projectDir, { semanticsSlicesFile: durablePath, profile: "research", }); if (!report) { // rusi binary unavailable -- skip (mirrors the existing E2E smoke test) return; } assert.ok(existsSync(durablePath), "durable report must persist"); const persisted = JSON.parse(readFileSync(durablePath, "utf-8")); assert.ok( persisted.schema_version?.startsWith(RUSI_SCHEMA_PREFIX), "persisted report must declare a report-* schema_version", ); assert.ok(persisted.call_graph, "persisted report must carry call_graph"); assert.ok(persisted.data_flow, "persisted report must carry data_flow"); } finally { rmSync(projectDir, { recursive: true, force: true }); } }); });