diff --git a/lib/analyzer/package-managers/apk.ts b/lib/analyzer/package-sources/package-managers/apk.ts similarity index 98% rename from lib/analyzer/package-managers/apk.ts rename to lib/analyzer/package-sources/package-managers/apk.ts index 7c892b06c..22ce2e7c8 100644 --- a/lib/analyzer/package-managers/apk.ts +++ b/lib/analyzer/package-sources/package-managers/apk.ts @@ -2,7 +2,7 @@ import { AnalysisType, AnalyzedPackageWithVersion, ImagePackagesAnalysis, -} from "../types"; +} from "../../types"; export function analyze( targetImage: string, diff --git a/lib/analyzer/package-managers/apt.ts b/lib/analyzer/package-sources/package-managers/apt.ts similarity index 99% rename from lib/analyzer/package-managers/apt.ts rename to lib/analyzer/package-sources/package-managers/apt.ts index 3e1065d69..d2640b208 100644 --- a/lib/analyzer/package-managers/apt.ts +++ b/lib/analyzer/package-sources/package-managers/apt.ts @@ -6,7 +6,7 @@ import { IAptFiles, ImagePackagesAnalysis, OSRelease, -} from "../types"; +} from "../../types"; export function analyze( targetImage: string, diff --git a/lib/analyzer/package-managers/chisel.ts b/lib/analyzer/package-sources/package-managers/chisel.ts similarity index 98% rename from lib/analyzer/package-managers/chisel.ts rename to lib/analyzer/package-sources/package-managers/chisel.ts index c0caa7e71..74c178fc5 100644 --- a/lib/analyzer/package-managers/chisel.ts +++ b/lib/analyzer/package-sources/package-managers/chisel.ts @@ -3,7 +3,7 @@ import { AnalyzedPackageWithVersion, ChiselPackage, ImagePackagesAnalysis, -} from "../types"; +} from "../../types"; /** * Analyzes Ubuntu Chisel packages from a Docker image. diff --git a/lib/analyzer/package-managers/rpm.ts b/lib/analyzer/package-sources/package-managers/rpm.ts similarity index 99% rename from lib/analyzer/package-managers/rpm.ts rename to lib/analyzer/package-sources/package-managers/rpm.ts index e9d0bc631..dec69fa8c 100644 --- a/lib/analyzer/package-managers/rpm.ts +++ b/lib/analyzer/package-sources/package-managers/rpm.ts @@ -7,7 +7,7 @@ import { ImagePackagesAnalysis, OSRelease, SourcePackage, -} from "../types"; +} from "../../types"; export function analyze( targetImage: string, diff --git a/lib/analyzer/package-sources/sboms/spdx.ts b/lib/analyzer/package-sources/sboms/spdx.ts new file mode 100644 index 000000000..fb45a7932 --- /dev/null +++ b/lib/analyzer/package-sources/sboms/spdx.ts @@ -0,0 +1,101 @@ +import { + AnalysisType, + AnalyzedPackageWithVersion, + ImagePackagesAnalysis, +} from "../../types"; + +// Supported hardened image vendor prefixes +const VENDOR_PREFIXES = ["dhi"] as const; +type VendorPrefix = (typeof VENDOR_PREFIXES)[number]; + +const VENDOR_PREFIX_PATTERN = new RegExp(`^(${VENDOR_PREFIXES.join("|")})/`); + +export function analyze( + targetImage: string, + spdxFileContents: string[], +): Promise { + const analyzedPackages: AnalyzedPackageWithVersion[] = []; + + for (const fileContent of spdxFileContents) { + const currentPackages = parseSpdxFile(fileContent); + analyzedPackages.push(...currentPackages); + } + + return Promise.resolve({ + Image: targetImage, + AnalyzeType: AnalysisType.Spdx, + Analysis: analyzedPackages, + }); +} + +function parseSpdxFile(text: string): AnalyzedPackageWithVersion[] { + const pkgs: AnalyzedPackageWithVersion[] = []; + + try { + const spdxDoc = JSON.parse(text); + + if (!spdxDoc.packages || !Array.isArray(spdxDoc.packages)) { + return pkgs; + } + + // Usually packages.length === 1, but iterate anyway for safety + for (const pkg of spdxDoc.packages) { + const analyzedPkg = parseSpdxLine(pkg); + pkgs.push(analyzedPkg); + } + } catch (err) { + console.error(`Failed to parse SPDX: ${err.message}`); + } + + return pkgs; +} + +function parseSpdxLine(pkg: any): AnalyzedPackageWithVersion { + const { vendor, cleanName } = parseVendorName(pkg.name); + const version = pkg.versionInfo; + const purl = + extractPurl(pkg) || + (vendor ? createPurl(cleanName, version, vendor) : undefined); + + return { + Name: cleanName, + Version: version, + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: purl, + }; +} + +function parseVendorName(name: string): { + vendor: VendorPrefix | undefined; + cleanName: string; +} { + const match = name.match(VENDOR_PREFIX_PATTERN); + if (match) { + return { + vendor: match[1] as VendorPrefix, + cleanName: name.replace(VENDOR_PREFIX_PATTERN, ""), + }; + } + return { vendor: undefined, cleanName: name }; +} + +function extractPurl(pkg: any): string | undefined { + if (!pkg.externalRefs || !Array.isArray(pkg.externalRefs)) { + return undefined; + } + + const purlRef = pkg.externalRefs.find((ref) => ref.referenceType === "purl"); + + return purlRef?.referenceLocator; +} + +function createPurl( + name: string, + version: string, + vendor: VendorPrefix, +): string { + return `pkg:${vendor}/${name}@${version}`; +} diff --git a/lib/analyzer/static-analyzer.ts b/lib/analyzer/static-analyzer.ts index 99823084f..715b1858a 100644 --- a/lib/analyzer/static-analyzer.ts +++ b/lib/analyzer/static-analyzer.ts @@ -56,8 +56,11 @@ import { getRpmSqliteDbFileContent, getRpmSqliteDbFileContentAction, } from "../inputs/rpm/static"; -import { resolveNestedJarsOption } from "../option-utils"; -import { isTrue } from "../option-utils"; +import { + getSpdxFileContentAction, + getSpdxFileContents, +} from "../inputs/spdx/static"; +import { isTrue, resolveNestedJarsOption } from "../option-utils"; import { ImageType, ManifestFile, PluginOptions } from "../types"; import { nodeFilesToScannedProjects, @@ -69,16 +72,17 @@ import { pipFilesToScannedProjects } from "./applications/python"; import { getApplicationFiles } from "./applications/runtime-common"; import { AppDepsScanResultWithoutTarget } from "./applications/types"; import * as osReleaseDetector from "./os-release"; -import { analyze as apkAnalyze } from "./package-managers/apk"; +import { analyze as apkAnalyze } from "./package-sources/package-managers/apk"; import { analyze as aptAnalyze, analyzeDistroless as aptDistrolessAnalyze, -} from "./package-managers/apt"; -import { analyze as chiselAnalyze } from "./package-managers/chisel"; +} from "./package-sources/package-managers/apt"; +import { analyze as chiselAnalyze } from "./package-sources/package-managers/chisel"; import { analyze as rpmAnalyze, mapRpmSqlitePackages, -} from "./package-managers/rpm"; +} from "./package-sources/package-managers/rpm"; +import { analyze as spdxAnalyze } from "./package-sources/sboms/spdx"; import { ImagePackagesAnalysis, OSRelease, @@ -103,6 +107,7 @@ export async function analyze( getRpmSqliteDbFileContentAction, getRpmNdbFileContentAction, getChiselManifestAction, + getSpdxFileContentAction, ...getOsReleaseActions, getNodeBinariesFileContentAction, getOpenJDKBinariesFileContentAction, @@ -185,6 +190,7 @@ export async function analyze( ]); const distrolessAptFiles = getAptFiles(extractedLayers); + const spdxFileContents = getSpdxFileContents(extractedLayers); const manifestFiles: ManifestFile[] = []; if (checkForGlobs) { @@ -225,6 +231,7 @@ export async function analyze( ), aptDistrolessAnalyze(targetImage, distrolessAptFiles, osRelease), chiselAnalyze(targetImage, chiselPackages), + spdxAnalyze(targetImage, spdxFileContents), ]); } catch (err) { debug(`Could not detect installed OS packages: ${err.message}`); diff --git a/lib/analyzer/types.ts b/lib/analyzer/types.ts index 118fba2d1..127fb470d 100644 --- a/lib/analyzer/types.ts +++ b/lib/analyzer/types.ts @@ -36,10 +36,20 @@ export interface ImagePackagesAnalysis extends ImageAnalysis { Analysis: AnalyzedPackageWithVersion[]; } +/** + * Represents the source of OS package data from static image analysis. + * + * Primary sources (Apk, Apt, Rpm, Chisel): Native package manager databases - source of truth + * Supplemental sources (Spdx): SBOMs that augment primary findings, never selected as primary + * Fallback (Linux): Used when no package source is detected + * + * See lib/analyzer/package-sources/ for implementations. + */ export enum AnalysisType { Apk = "Apk", Apt = "Apt", Rpm = "Rpm", + Spdx = "Spdx", Chisel = "Chisel", Binaries = "binaries", Linux = "linux", // default/unknown/tech-debt diff --git a/lib/inputs/spdx/static.ts b/lib/inputs/spdx/static.ts new file mode 100644 index 000000000..b62eb3cac --- /dev/null +++ b/lib/inputs/spdx/static.ts @@ -0,0 +1,35 @@ +import { normalize as normalizePath } from "path"; +import { ExtractAction, ExtractedLayers } from "../../extractor/types"; +import { streamToString } from "../../stream-utils"; + +export const getSpdxFileContentAction: ExtractAction = { + actionName: "spdx-files", + filePathMatches: (filePath) => { + const normalized = normalizePath(filePath); + return ( + normalized.includes("/docker/sbom/") && + normalized.includes("spdx.") && + normalized.endsWith(".json") + ); + }, + callback: streamToString, +}; + +export function getSpdxFileContents( + extractedLayers: ExtractedLayers, +): string[] { + const files: string[] = []; + + for (const fileName of Object.keys(extractedLayers)) { + if (!("spdx-files" in extractedLayers[fileName])) { + continue; + } + + const content = extractedLayers[fileName]["spdx-files"]; + if (typeof content === "string") { + files.push(content); + } + } + + return files; +} diff --git a/lib/parser/index.ts b/lib/parser/index.ts index 1123cdb8f..01b735a50 100644 --- a/lib/parser/index.ts +++ b/lib/parser/index.ts @@ -19,7 +19,11 @@ export function parseAnalysisResults( analysis: StaticPackagesAnalysis, ): AnalysisInfo { let analysisResult = analysis.results.filter((res) => { - return res.Analysis && res.Analysis.length > 0; + return ( + res.Analysis && + res.Analysis.length > 0 && + res.AnalyzeType !== AnalysisType.Spdx // In the future, we may want to abstract this to be any supplemental analysis type + ); })[0]; if (!analysisResult) { @@ -32,6 +36,30 @@ export function parseAnalysisResults( }; } + // Merge SPDX packages into the main result + // But skip any SPDX packages that conflict with existing package manager records + // (apt/apk/rpm/chisel) + const spdxResult = analysis.results.find( + (r) => r.AnalyzeType === AnalysisType.Spdx, + ); + if ( + spdxResult && + spdxResult.Analysis.length > 0 && + analysisResult.AnalyzeType !== AnalysisType.Spdx + ) { + // Create a set of existing package names from the primary package manager for fast lookup + const existingPackageNames = new Set( + analysisResult.Analysis.map((pkg) => pkg.Name), + ); + + // Only add SPDX packages that don't conflict with existing packages + const nonConflictingSpdxPackages = spdxResult.Analysis.filter( + (pkg) => !existingPackageNames.has(pkg.Name), + ); + + analysisResult.Analysis.push(...nonConflictingSpdxPackages); + } + let packageFormat: string; switch (analysisResult.AnalyzeType) { case AnalysisType.Apt: diff --git a/test/fixtures/sbom/deduplication/Dockerfile b/test/fixtures/sbom/deduplication/Dockerfile new file mode 100644 index 000000000..ea1147782 --- /dev/null +++ b/test/fixtures/sbom/deduplication/Dockerfile @@ -0,0 +1,72 @@ +FROM debian:bookworm-slim + +# Install packages via apt +RUN apt-get update && apt-get install -y curl wget && rm -rf /var/lib/apt/lists/* + +# Create properly formatted SPDX file for curl (conflicts with apt) +RUN mkdir -p /docker/sbom/curl && \ + echo '{ \ + "spdxVersion": "SPDX-2.3", \ + "dataLicense": "CC0-1.0", \ + "SPDXID": "SPDXRef-dhi-curl", \ + "name": "SPDX document for dhi/curl 7.88.0", \ + "documentNamespace": "dhi-curl-7.88.0", \ + "creationInfo": { \ + "creators": ["Organization: Test", "Tool: manual"], \ + "created": "2024-01-01T00:00:00Z" \ + }, \ + "packages": [ \ + { \ + "name": "dhi/curl", \ + "SPDXID": "SPDXRef-dhi-curl", \ + "versionInfo": "7.88.0", \ + "supplier": "Organization: Test", \ + "downloadLocation": "NOASSERTION", \ + "filesAnalyzed": false, \ + "licenseConcluded": "NOASSERTION", \ + "licenseDeclared": "NOASSERTION", \ + "externalRefs": [ \ + { \ + "referenceCategory": "PACKAGE-MANAGER", \ + "referenceType": "purl", \ + "referenceLocator": "pkg:dhi/curl@7.88.0" \ + } \ + ], \ + "primaryPackagePurpose": "CONTAINER" \ + } \ + ] \ +}' > /docker/sbom/curl/spdx.curl.json + +# Create properly formatted SPDX file for redis-server (no conflict) +RUN mkdir -p /docker/sbom/redis && \ + echo '{ \ + "spdxVersion": "SPDX-2.3", \ + "dataLicense": "CC0-1.0", \ + "SPDXID": "SPDXRef-dhi-redis", \ + "name": "SPDX document for dhi/redis-server 7.0.15", \ + "documentNamespace": "dhi-redis-server-7.0.15", \ + "creationInfo": { \ + "creators": ["Organization: Test", "Tool: manual"], \ + "created": "2024-01-01T00:00:00Z" \ + }, \ + "packages": [ \ + { \ + "name": "dhi/redis-server", \ + "SPDXID": "SPDXRef-dhi-redis-server", \ + "versionInfo": "7.0.15", \ + "supplier": "Organization: Test", \ + "downloadLocation": "NOASSERTION", \ + "filesAnalyzed": false, \ + "licenseConcluded": "NOASSERTION", \ + "licenseDeclared": "NOASSERTION", \ + "externalRefs": [ \ + { \ + "referenceCategory": "PACKAGE-MANAGER", \ + "referenceType": "purl", \ + "referenceLocator": "pkg:dhi/redis-server@7.0.15" \ + } \ + ], \ + "primaryPackagePurpose": "CONTAINER" \ + } \ + ] \ +}' > /docker/sbom/redis/spdx.redis-server.json \ No newline at end of file diff --git a/test/fixtures/sbom/deduplication/spdx-conflict-test.tar.gz b/test/fixtures/sbom/deduplication/spdx-conflict-test.tar.gz new file mode 100644 index 000000000..5f5e15fd4 Binary files /dev/null and b/test/fixtures/sbom/deduplication/spdx-conflict-test.tar.gz differ diff --git a/test/fixtures/sbom/simple/dhi-test.tar b/test/fixtures/sbom/simple/dhi-test.tar new file mode 100644 index 000000000..c166d8a3f Binary files /dev/null and b/test/fixtures/sbom/simple/dhi-test.tar differ diff --git a/test/fixtures/sbom/simple/spdx.malformed.json b/test/fixtures/sbom/simple/spdx.malformed.json new file mode 100644 index 000000000..439c3bf99 --- /dev/null +++ b/test/fixtures/sbom/simple/spdx.malformed.json @@ -0,0 +1,2 @@ +{ invalid json - missing closing brace + diff --git a/test/fixtures/sbom/simple/spdx.pkg-binutils.json b/test/fixtures/sbom/simple/spdx.pkg-binutils.json new file mode 100644 index 000000000..504707e42 --- /dev/null +++ b/test/fixtures/sbom/simple/spdx.pkg-binutils.json @@ -0,0 +1,36 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-dhi-pkg-binutils", + "name": "SPDX document for dhi/pkg-binutils 2.45-debian13", + "documentNamespace": "dhi-pkg-binutils-2.45-debian13", + "creationInfo": { + "creators": [ + "Organization: Docker, Inc.", + "Tool: dhi/build" + ], + "created": "1970-01-01T00:00:00Z" + }, + "packages": [ + { + "name": "dhi/pkg-binutils", + "SPDXID": "SPDXRef-dhi-pkg-binutils", + "versionInfo": "2.45-debian13", + "supplier": "Organization: Docker, Inc.", + "originator": "Organization: Docker, Inc.", + "downloadLocation": "https://hub.docker.com/hardened-images/dhi/pkg-binutils", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:docker/dhi/pkg-binutils@2.45-debian13?platform=linux%2Farm64&os_name=debian&os_version=13" + } + ], + "primaryPackagePurpose": "CONTAINER" + } + ] +} + diff --git a/test/fixtures/sbom/simple/spdx.python.json b/test/fixtures/sbom/simple/spdx.python.json new file mode 100644 index 000000000..dd0cbecc8 --- /dev/null +++ b/test/fixtures/sbom/simple/spdx.python.json @@ -0,0 +1,37 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-dhi-python", + "name": "SPDX document for dhi/python 3.13.11-debian13-dev", + "documentNamespace": "dhi-python-3.13.11-debian13-dev", + "creationInfo": { + "creators": [ + "Organization: Docker, Inc.", + "Tool: dhi/build" + ], + "created": "1970-01-01T00:00:00Z" + }, + "packages": [ + { + "name": "dhi/python", + "SPDXID": "SPDXRef-dhi-python", + "versionInfo": "3.13.11-debian13-dev", + "supplier": "Organization: Docker, Inc.", + "originator": "Organization: Docker, Inc.", + "downloadLocation": "https://dhi.docker.com/catalog/python", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "description": "A minimal Python image / Python 3.13.x (dev)", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:docker/dhi/python@3.13.11-debian13-dev?platform=linux/arm64&os_name=debian&os_version=13" + } + ], + "primaryPackagePurpose": "CONTAINER" + } + ] +} + diff --git a/test/lib/analyzer/package-managers/apt.spec.ts b/test/lib/analyzer/package-sources/package-managers/apt.spec.ts similarity index 86% rename from test/lib/analyzer/package-managers/apt.spec.ts rename to test/lib/analyzer/package-sources/package-managers/apt.spec.ts index 8527bcefa..63b2a0252 100644 --- a/test/lib/analyzer/package-managers/apt.spec.ts +++ b/test/lib/analyzer/package-sources/package-managers/apt.spec.ts @@ -1,5 +1,5 @@ -import { purl } from "../../../../lib/analyzer/package-managers/apt"; -import type { AnalyzedPackageWithVersion } from "../../../../lib/analyzer/types"; +import { purl } from "../../../../../lib/analyzer/package-sources/package-managers/apt"; +import type { AnalyzedPackageWithVersion } from "../../../../../lib/analyzer/types"; describe("purl()", () => { it.each([ diff --git a/test/lib/analyzer/package-managers/chisel.spec.ts b/test/lib/analyzer/package-sources/package-managers/chisel.spec.ts similarity index 93% rename from test/lib/analyzer/package-managers/chisel.spec.ts rename to test/lib/analyzer/package-sources/package-managers/chisel.spec.ts index 4234b7bbb..baf5cd320 100644 --- a/test/lib/analyzer/package-managers/chisel.spec.ts +++ b/test/lib/analyzer/package-sources/package-managers/chisel.spec.ts @@ -1,5 +1,5 @@ -import { analyze } from "../../../../lib/analyzer/package-managers/chisel"; -import { AnalysisType, ChiselPackage } from "../../../../lib/analyzer/types"; +import { analyze } from "../../../../../lib/analyzer/package-sources/package-managers/chisel"; +import { AnalysisType, ChiselPackage } from "../../../../../lib/analyzer/types"; describe("chisel analyzer", () => { describe("analyze()", () => { diff --git a/test/lib/analyzer/package-managers/rpm.spec.ts b/test/lib/analyzer/package-sources/package-managers/rpm.spec.ts similarity index 98% rename from test/lib/analyzer/package-managers/rpm.spec.ts rename to test/lib/analyzer/package-sources/package-managers/rpm.spec.ts index 4dd54de57..3c3e5c238 100644 --- a/test/lib/analyzer/package-managers/rpm.spec.ts +++ b/test/lib/analyzer/package-sources/package-managers/rpm.spec.ts @@ -5,8 +5,8 @@ import { analyze, mapRpmSqlitePackages, parseSourceRPM, -} from "../../../../lib/analyzer/package-managers/rpm"; -import { SourcePackage } from "../../../../lib/analyzer/types"; +} from "../../../../../lib/analyzer/package-sources/package-managers/rpm"; +import { SourcePackage } from "../../../../../lib/analyzer/types"; describe("RPM Package Version and Epoch Handling", () => { describe("formats version without epoch", () => { @@ -272,7 +272,7 @@ describe("parseSourceRPM", () => { it("should correctly parse all valid source RPM strings from source_rpms.csv", () => { const csvFilePath = path.join( __dirname, - "../../../../test/fixtures/rpm/source_rpms.csv", + "../../../../../test/fixtures/rpm/source_rpms.csv", ); let fileContent; try { diff --git a/test/lib/analyzer/package-sources/sboms/spdx.spec.ts b/test/lib/analyzer/package-sources/sboms/spdx.spec.ts new file mode 100644 index 000000000..92bc68bf4 --- /dev/null +++ b/test/lib/analyzer/package-sources/sboms/spdx.spec.ts @@ -0,0 +1,164 @@ +import { analyze } from "../../../../../lib/analyzer/package-sources/sboms/spdx"; +import { AnalysisType } from "../../../../../lib/analyzer/types"; +import { getTextFromFixture } from "../../../../util"; + +describe("SPDX analyzer", () => { + describe("analyze()", () => { + it("parses single SPDX file content", async () => { + const spdxFileContents = [ + getTextFromFixture("sbom/simple/spdx.pkg-binutils.json"), + ]; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Image).toBe("test-image"); + expect(result.AnalyzeType).toBe(AnalysisType.Spdx); + expect(result.Analysis).toHaveLength(1); + expect(result.Analysis[0]).toMatchObject({ + Name: "pkg-binutils", // "dhi/" prefix stripped + Version: "2.45-debian13", + }); + }); + + it("parses multiple SPDX file contents", async () => { + const spdxFileContents = [ + getTextFromFixture("sbom/simple/spdx.pkg-binutils.json"), + getTextFromFixture("sbom/simple/spdx.python.json"), + ]; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Analysis).toHaveLength(2); + expect(result.Analysis[0].Name).toBe("pkg-binutils"); + expect(result.Analysis[1].Name).toBe("python"); + }); + + it("extracts PURL from externalRefs when present", async () => { + const spdxFileContents = [ + getTextFromFixture("sbom/simple/spdx.pkg-binutils.json"), + ]; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Analysis[0].Purl).toBe( + "pkg:docker/dhi/pkg-binutils@2.45-debian13?platform=linux%2Farm64&os_name=debian&os_version=13", + ); + }); + + it("creates dhi PURL when externalRefs is missing", async () => { + const spdxFileContents = [ + JSON.stringify({ + spdxVersion: "SPDX-2.3", + packages: [ + { + name: "dhi/curl", + versionInfo: "7.88.1", + // No externalRefs + }, + ], + }), + ]; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Analysis[0].Purl).toBe("pkg:dhi/curl@7.88.1"); + }); + + it("handles malformed SPDX gracefully", async () => { + const spdxFileContents = [ + getTextFromFixture("sbom/simple/spdx.malformed.json"), + getTextFromFixture("sbom/simple/spdx.python.json"), + ]; + + const result = await analyze("test-image", spdxFileContents); + + // Should skip broken file but process valid one + expect(result.Analysis).toHaveLength(1); + expect(result.Analysis[0].Name).toBe("python"); + }); + + it("handles SPDX with no packages array", async () => { + const spdxFileContents = [JSON.stringify({ spdxVersion: "SPDX-2.3" })]; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Analysis).toHaveLength(0); + }); + + it("strips 'dhi/' prefix from package names", async () => { + const spdxFileContents = [ + JSON.stringify({ + spdxVersion: "SPDX-2.3", + packages: [ + { + name: "dhi/my-package", + versionInfo: "1.0.0", + }, + ], + }), + ]; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Analysis[0].Name).toBe("my-package"); + expect(result.Analysis[0].Name).not.toContain("dhi/"); + }); + + it("handles empty array of SPDX files", async () => { + const spdxFileContents: string[] = []; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Analysis).toHaveLength(0); + }); + + it("sets all standard package fields", async () => { + const spdxFileContents = [ + JSON.stringify({ + spdxVersion: "SPDX-2.3", + packages: [ + { + name: "dhi/test-pkg", + versionInfo: "1.0.0", + }, + ], + }), + ]; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Analysis[0]).toEqual({ + Name: "test-pkg", + Version: "1.0.0", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/test-pkg@1.0.0", + }); + }); + + it("processes packages from multiple SPDX files in order", async () => { + const spdxFileContents = [ + JSON.stringify({ + spdxVersion: "SPDX-2.3", + packages: [ + { name: "dhi/first", versionInfo: "1.0.0" }, + { name: "dhi/second", versionInfo: "2.0.0" }, + ], + }), + JSON.stringify({ + spdxVersion: "SPDX-2.3", + packages: [{ name: "dhi/third", versionInfo: "3.0.0" }], + }), + ]; + + const result = await analyze("test-image", spdxFileContents); + + expect(result.Analysis).toHaveLength(3); + expect(result.Analysis[0].Name).toBe("first"); + expect(result.Analysis[1].Name).toBe("second"); + expect(result.Analysis[2].Name).toBe("third"); + }); + }); +}); diff --git a/test/lib/analyzer/package-sources/test/lib/analyzer/package-sources b/test/lib/analyzer/package-sources/test/lib/analyzer/package-sources new file mode 100644 index 000000000..fd0789a29 --- /dev/null +++ b/test/lib/analyzer/package-sources/test/lib/analyzer/package-sources @@ -0,0 +1 @@ +test/lib/analyzer/package-sources \ No newline at end of file diff --git a/test/lib/inputs/spdx/static.spec.ts b/test/lib/inputs/spdx/static.spec.ts new file mode 100644 index 000000000..0e4035a05 --- /dev/null +++ b/test/lib/inputs/spdx/static.spec.ts @@ -0,0 +1,178 @@ +import { ExtractedLayers } from "../../../../lib/extractor/types"; +import { + getSpdxFileContentAction, + getSpdxFileContents, +} from "../../../../lib/inputs/spdx/static"; + +describe("SPDX static input extraction", () => { + describe("getSpdxFileContentAction", () => { + it("matches SPDX file paths in /docker/sbom/", () => { + expect( + getSpdxFileContentAction.filePathMatches( + "/docker/sbom/python/spdx.python.json", + ), + ).toBe(true); + + expect( + getSpdxFileContentAction.filePathMatches( + "/docker/sbom/pkg-binutils/spdx.pkg-binutils.json", + ), + ).toBe(true); + + expect( + getSpdxFileContentAction.filePathMatches( + "/docker/sbom/curl/spdx.curl.json", + ), + ).toBe(true); + }); + + it("does not match non-SPDX files", () => { + expect(getSpdxFileContentAction.filePathMatches("/etc/os-release")).toBe( + false, + ); + + expect( + getSpdxFileContentAction.filePathMatches("/app/package.json"), + ).toBe(false); + + expect( + getSpdxFileContentAction.filePathMatches("/lib/apk/db/installed"), + ).toBe(false); + }); + + it("does not match SPDX-like paths outside /docker/sbom/", () => { + expect( + getSpdxFileContentAction.filePathMatches("/other/sbom/spdx.test.json"), + ).toBe(false); + + expect( + getSpdxFileContentAction.filePathMatches("/app/spdx.data.json"), + ).toBe(false); + }); + + it("requires 'spdx.' in the filename", () => { + expect( + getSpdxFileContentAction.filePathMatches( + "/docker/sbom/python/metadata.json", + ), + ).toBe(false); + + expect( + getSpdxFileContentAction.filePathMatches( + "/docker/sbom/python/package.json", + ), + ).toBe(false); + }); + + it("has correct actionName", () => { + expect(getSpdxFileContentAction.actionName).toBe("spdx-files"); + }); + }); + + describe("getSpdxFileContents()", () => { + it("extracts SPDX file contents from extractedLayers", () => { + const extractedLayers: ExtractedLayers = { + "/docker/sbom/python/spdx.python.json": { + "spdx-files": '{"spdxVersion": "SPDX-2.3", "packages": []}', + }, + "/docker/sbom/curl/spdx.curl.json": { + "spdx-files": + '{"spdxVersion": "SPDX-2.3", "packages": [{"name": "curl"}]}', + }, + "/etc/os-release": { + "os-release": "NAME=Alpine", + }, + }; + + const result = getSpdxFileContents(extractedLayers); + + expect(result).toHaveLength(2); + expect(result[0]).toContain('"spdxVersion": "SPDX-2.3"'); + expect(result[1]).toContain('"spdxVersion": "SPDX-2.3"'); + expect(result[1]).toContain('"name": "curl"'); + }); + + it("returns empty array when no SPDX files present", () => { + const extractedLayers: ExtractedLayers = { + "/etc/os-release": { + "os-release": "NAME=Alpine", + }, + "/lib/apk/db/installed": { + "apk-db": "P:alpine-baselayout\nV:3.2.0", + }, + }; + + const result = getSpdxFileContents(extractedLayers); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it("handles missing actionName gracefully", () => { + const extractedLayers: ExtractedLayers = { + "/docker/sbom/python/spdx.python.json": { + "wrong-action": "content", // Wrong action name + }, + }; + + const result = getSpdxFileContents(extractedLayers); + + expect(result).toHaveLength(0); + }); + + it("handles non-string content gracefully", () => { + const extractedLayers: ExtractedLayers = { + "/docker/sbom/python/spdx.python.json": { + "spdx-files": 12345, // Not a string + }, + }; + + const result = getSpdxFileContents(extractedLayers); + + expect(result).toHaveLength(0); + }); + + it("extracts content only from matching paths", () => { + const extractedLayers: ExtractedLayers = { + "/docker/sbom/python/spdx.python.json": { + "spdx-files": '{"valid": "spdx"}', + }, + "/other/path/spdx.test.json": { + "other-action": '{"should": "ignore"}', + }, + }; + + const result = getSpdxFileContents(extractedLayers); + + expect(result).toHaveLength(1); + expect(result[0]).toContain('"valid": "spdx"'); + }); + + it("handles multiple SPDX files from different layers", () => { + const extractedLayers: ExtractedLayers = { + "/docker/sbom/python/spdx.python.json": { + "spdx-files": '{"package": "python"}', + }, + "/docker/sbom/curl/spdx.curl.json": { + "spdx-files": '{"package": "curl"}', + }, + "/docker/sbom/openssl/spdx.openssl.json": { + "spdx-files": '{"package": "openssl"}', + }, + }; + + const result = getSpdxFileContents(extractedLayers); + + expect(result).toHaveLength(3); + expect( + result.some((content) => content.includes('"package": "python"')), + ).toBe(true); + expect( + result.some((content) => content.includes('"package": "curl"')), + ).toBe(true); + expect( + result.some((content) => content.includes('"package": "openssl"')), + ).toBe(true); + }); + }); +}); diff --git a/test/lib/parser/index.spec.ts b/test/lib/parser/index.spec.ts new file mode 100644 index 000000000..329bc7e14 --- /dev/null +++ b/test/lib/parser/index.spec.ts @@ -0,0 +1,487 @@ +import { + AnalysisType, + StaticPackagesAnalysis, +} from "../../../lib/analyzer/types"; +import { parseAnalysisResults } from "../../../lib/parser/index"; + +describe("parseAnalysisResults", () => { + const mockOSRelease = { + name: "debian", + version: "12", + prettyName: "Debian GNU/Linux 12 (bookworm)", + }; + + describe("SPDX deduplication", () => { + it("should include SPDX packages when there are no conflicts with apt packages", () => { + const analysis: StaticPackagesAnalysis = { + imageId: "test-image-123", + platform: "linux/amd64", + osRelease: mockOSRelease, + results: [ + { + Image: "test-image", + AnalyzeType: AnalysisType.Apt, + Analysis: [ + { + Name: "curl", + Version: "7.88.1-10+deb12u8", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:deb/debian/curl@7.88.1-10+deb12u8", + }, + ], + }, + { + Image: "test-image", + AnalyzeType: AnalysisType.Spdx, + Analysis: [ + { + Name: "python", + Version: "3.11.2", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/python@3.11.2", + }, + ], + }, + ], + binaries: [], + imageLayers: ["layer1", "layer2"], + applicationDependenciesScanResults: [], + manifestFiles: [], + }; + + const result = parseAnalysisResults("test-image", analysis); + + // Should include both apt and SPDX packages since there's no conflict + expect(result.depInfosList).toHaveLength(2); + expect(result.depInfosList[0].Name).toBe("curl"); + expect(result.depInfosList[0].Purl).toBe( + "pkg:deb/debian/curl@7.88.1-10+deb12u8", + ); + expect(result.depInfosList[1].Name).toBe("python"); + expect(result.depInfosList[1].Purl).toBe("pkg:dhi/python@3.11.2"); + }); + + it("should prioritize apt packages over SPDX when there are duplicate names", () => { + const analysis: StaticPackagesAnalysis = { + imageId: "test-image-123", + platform: "linux/amd64", + osRelease: mockOSRelease, + results: [ + { + Image: "test-image", + AnalyzeType: AnalysisType.Apt, + Analysis: [ + { + Name: "curl", + Version: "7.88.1-10+deb12u8", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:deb/debian/curl@7.88.1-10+deb12u8", + }, + { + Name: "python", + Version: "3.11.2-1+deb12u4", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:deb/debian/python@3.11.2-1+deb12u4", + }, + ], + }, + { + Image: "test-image", + AnalyzeType: AnalysisType.Spdx, + Analysis: [ + { + Name: "python", + Version: "3.11.2", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/python@3.11.2", + }, + { + Name: "redis-server", + Version: "7.0.15", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/redis-server@7.0.15", + }, + ], + }, + ], + binaries: [], + imageLayers: ["layer1", "layer2"], + applicationDependenciesScanResults: [], + manifestFiles: [], + }; + + const result = parseAnalysisResults("test-image", analysis); + + // Should have 3 packages: curl and python from apt, redis-server from SPDX + expect(result.depInfosList).toHaveLength(3); + + // Find each package + const curlPkg = result.depInfosList.find((pkg) => pkg.Name === "curl"); + const pythonPkg = result.depInfosList.find( + (pkg) => pkg.Name === "python", + ); + const redisPkg = result.depInfosList.find( + (pkg) => pkg.Name === "redis-server", + ); + + // Verify curl from apt + expect(curlPkg).toBeDefined(); + expect(curlPkg?.Version).toBe("7.88.1-10+deb12u8"); + expect(curlPkg?.Purl).toBe("pkg:deb/debian/curl@7.88.1-10+deb12u8"); + + // Verify python from apt (NOT from SPDX - apt takes precedence) + expect(pythonPkg).toBeDefined(); + expect(pythonPkg?.Version).toBe("3.11.2-1+deb12u4"); + expect(pythonPkg?.Purl).toBe("pkg:deb/debian/python@3.11.2-1+deb12u4"); + expect(pythonPkg?.Purl).not.toContain("dhi"); + + // Verify redis-server from SPDX (no conflict) + expect(redisPkg).toBeDefined(); + expect(redisPkg?.Version).toBe("7.0.15"); + expect(redisPkg?.Purl).toBe("pkg:dhi/redis-server@7.0.15"); + }); + + it("should prioritize apk packages over SPDX when there are duplicate names", () => { + const analysis: StaticPackagesAnalysis = { + imageId: "test-image-123", + platform: "linux/arm64", + osRelease: { + name: "alpine", + version: "3.19", + prettyName: "Alpine Linux 3.19", + }, + results: [ + { + Image: "test-image", + AnalyzeType: AnalysisType.Apk, + Analysis: [ + { + Name: "curl", + Version: "8.5.0-r0", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:apk/alpine/curl@8.5.0-r0", + }, + ], + }, + { + Image: "test-image", + AnalyzeType: AnalysisType.Spdx, + Analysis: [ + { + Name: "curl", + Version: "8.5.0", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/curl@8.5.0", + }, + ], + }, + ], + binaries: [], + imageLayers: ["layer1"], + applicationDependenciesScanResults: [], + manifestFiles: [], + }; + + const result = parseAnalysisResults("test-image", analysis); + + // Should only have curl from apk, not from SPDX + expect(result.depInfosList).toHaveLength(1); + expect(result.depInfosList[0].Name).toBe("curl"); + expect(result.depInfosList[0].Version).toBe("8.5.0-r0"); + expect(result.depInfosList[0].Purl).toBe("pkg:apk/alpine/curl@8.5.0-r0"); + }); + + it("should work when there are only SPDX packages (no apt/apk)", () => { + const analysis: StaticPackagesAnalysis = { + imageId: "test-image-123", + platform: "linux/arm64", + osRelease: mockOSRelease, + results: [ + { + Image: "test-image", + AnalyzeType: AnalysisType.Apt, + Analysis: [], + }, + { + Image: "test-image", + AnalyzeType: AnalysisType.Spdx, + Analysis: [ + { + Name: "python", + Version: "3.11.2", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/python@3.11.2", + }, + { + Name: "redis-server", + Version: "7.0.15", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/redis-server@7.0.15", + }, + ], + }, + ], + binaries: [], + imageLayers: ["layer1"], + applicationDependenciesScanResults: [], + manifestFiles: [], + }; + + const result = parseAnalysisResults("test-image", analysis); + + // Should have both SPDX packages + expect(result.depInfosList).toHaveLength(2); + expect(result.depInfosList[0].Name).toBe("python"); + expect(result.depInfosList[1].Name).toBe("redis-server"); + }); + + it("should prioritize rpm packages over SPDX when there are duplicate names", () => { + const analysis: StaticPackagesAnalysis = { + imageId: "test-image-123", + platform: "linux/amd64", + osRelease: { + name: "rhel", + version: "9", + prettyName: "Red Hat Enterprise Linux 9", + }, + results: [ + { + Image: "test-image", + AnalyzeType: AnalysisType.Rpm, + Analysis: [ + { + Name: "openssl", + Version: "3.0.7-27.el9", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:rpm/rhel/openssl@3.0.7-27.el9", + }, + ], + }, + { + Image: "test-image", + AnalyzeType: AnalysisType.Spdx, + Analysis: [ + { + Name: "openssl", + Version: "3.0.7", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/openssl@3.0.7", + }, + { + Name: "nginx", + Version: "1.24.0", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/nginx@1.24.0", + }, + ], + }, + ], + binaries: [], + imageLayers: ["layer1"], + applicationDependenciesScanResults: [], + manifestFiles: [], + }; + + const result = parseAnalysisResults("test-image", analysis); + + // Should have 2 packages: openssl from rpm, nginx from SPDX + expect(result.depInfosList).toHaveLength(2); + + const opensslPkg = result.depInfosList.find( + (pkg) => pkg.Name === "openssl", + ); + const nginxPkg = result.depInfosList.find((pkg) => pkg.Name === "nginx"); + + // Verify openssl from rpm (NOT from SPDX) + expect(opensslPkg).toBeDefined(); + expect(opensslPkg?.Version).toBe("3.0.7-27.el9"); + expect(opensslPkg?.Purl).toBe("pkg:rpm/rhel/openssl@3.0.7-27.el9"); + + // Verify nginx from SPDX (no conflict) + expect(nginxPkg).toBeDefined(); + expect(nginxPkg?.Purl).toBe("pkg:dhi/nginx@1.24.0"); + }); + + it("should prioritize chisel packages over SPDX when there are duplicate names", () => { + const analysis: StaticPackagesAnalysis = { + imageId: "test-image-123", + platform: "linux/amd64", + osRelease: mockOSRelease, + results: [ + { + Image: "test-image", + AnalyzeType: AnalysisType.Chisel, + Analysis: [ + { + Name: "base-files", + Version: "12.3ubuntu1", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:deb/ubuntu/base-files@12.3ubuntu1", + }, + ], + }, + { + Image: "test-image", + AnalyzeType: AnalysisType.Spdx, + Analysis: [ + { + Name: "base-files", + Version: "12.3", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/base-files@12.3", + }, + ], + }, + ], + binaries: [], + imageLayers: ["layer1"], + applicationDependenciesScanResults: [], + manifestFiles: [], + }; + + const result = parseAnalysisResults("test-image", analysis); + + // Should only have base-files from chisel, not from SPDX + expect(result.depInfosList).toHaveLength(1); + expect(result.depInfosList[0].Name).toBe("base-files"); + expect(result.depInfosList[0].Version).toBe("12.3ubuntu1"); + expect(result.depInfosList[0].Purl).toBe( + "pkg:deb/ubuntu/base-files@12.3ubuntu1", + ); + }); + + it("should handle multiple duplicate packages between apt and SPDX", () => { + const analysis: StaticPackagesAnalysis = { + imageId: "test-image-123", + platform: "linux/amd64", + osRelease: mockOSRelease, + results: [ + { + Image: "test-image", + AnalyzeType: AnalysisType.Apt, + Analysis: [ + { + Name: "curl", + Version: "7.88.1-10+deb12u8", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:deb/debian/curl@7.88.1-10+deb12u8", + }, + { + Name: "wget", + Version: "1.21.3-1+b2", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:deb/debian/wget@1.21.3-1+b2", + }, + ], + }, + { + Image: "test-image", + AnalyzeType: AnalysisType.Spdx, + Analysis: [ + { + Name: "curl", + Version: "7.88.1", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/curl@7.88.1", + }, + { + Name: "wget", + Version: "1.21.3", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/wget@1.21.3", + }, + { + Name: "redis-tools", + Version: "7.0.15", + Source: undefined, + Provides: [], + Deps: {}, + AutoInstalled: undefined, + Purl: "pkg:dhi/redis-tools@7.0.15", + }, + ], + }, + ], + binaries: [], + imageLayers: ["layer1", "layer2"], + applicationDependenciesScanResults: [], + manifestFiles: [], + }; + + const result = parseAnalysisResults("test-image", analysis); + + // Should have 3 packages total: curl and wget from apt, redis-tools from SPDX + expect(result.depInfosList).toHaveLength(3); + + const curlPkg = result.depInfosList.find((pkg) => pkg.Name === "curl"); + const wgetPkg = result.depInfosList.find((pkg) => pkg.Name === "wget"); + const redisPkg = result.depInfosList.find( + (pkg) => pkg.Name === "redis-tools", + ); + + // Verify all packages from apt (NOT from SPDX) + expect(curlPkg?.Purl).toBe("pkg:deb/debian/curl@7.88.1-10+deb12u8"); + expect(wgetPkg?.Purl).toBe("pkg:deb/debian/wget@1.21.3-1+b2"); + + // Verify redis-tools from SPDX (no conflict) + expect(redisPkg?.Purl).toBe("pkg:dhi/redis-tools@7.0.15"); + }); + }); +}); diff --git a/test/system/package-managers/__snapshots__/apk.spec.ts.snap b/test/system/package-sources/package-managers/__snapshots__/apk.spec.ts.snap similarity index 100% rename from test/system/package-managers/__snapshots__/apk.spec.ts.snap rename to test/system/package-sources/package-managers/__snapshots__/apk.spec.ts.snap diff --git a/test/system/package-managers/__snapshots__/chisel.spec.ts.snap b/test/system/package-sources/package-managers/__snapshots__/chisel.spec.ts.snap similarity index 100% rename from test/system/package-managers/__snapshots__/chisel.spec.ts.snap rename to test/system/package-sources/package-managers/__snapshots__/chisel.spec.ts.snap diff --git a/test/system/package-managers/__snapshots__/deb.spec.ts.snap b/test/system/package-sources/package-managers/__snapshots__/deb.spec.ts.snap similarity index 100% rename from test/system/package-managers/__snapshots__/deb.spec.ts.snap rename to test/system/package-sources/package-managers/__snapshots__/deb.spec.ts.snap diff --git a/test/system/package-managers/__snapshots__/rpm.spec.ts.snap b/test/system/package-sources/package-managers/__snapshots__/rpm.spec.ts.snap similarity index 100% rename from test/system/package-managers/__snapshots__/rpm.spec.ts.snap rename to test/system/package-sources/package-managers/__snapshots__/rpm.spec.ts.snap diff --git a/test/system/package-managers/apk.spec.ts b/test/system/package-sources/package-managers/apk.spec.ts similarity index 90% rename from test/system/package-managers/apk.spec.ts rename to test/system/package-sources/package-managers/apk.spec.ts index c11e28e38..45e5a1273 100644 --- a/test/system/package-managers/apk.spec.ts +++ b/test/system/package-sources/package-managers/apk.spec.ts @@ -1,5 +1,5 @@ -import { scan } from "../../../lib/index"; -import { execute } from "../../../lib/sub-process"; +import { scan } from "../../../../lib/index"; +import { execute } from "../../../../lib/sub-process"; describe("apk package manager tests", () => { afterAll(async () => { diff --git a/test/system/package-managers/chisel.spec.ts b/test/system/package-sources/package-managers/chisel.spec.ts similarity index 86% rename from test/system/package-managers/chisel.spec.ts rename to test/system/package-sources/package-managers/chisel.spec.ts index 434109817..ffd83d446 100644 --- a/test/system/package-managers/chisel.spec.ts +++ b/test/system/package-sources/package-managers/chisel.spec.ts @@ -1,5 +1,5 @@ -import { scan } from "../../../lib/index"; -import { execute } from "../../../lib/sub-process"; +import { scan } from "../../../../lib/index"; +import { execute } from "../../../../lib/sub-process"; describe("chisel package manager tests", () => { afterAll(async () => { diff --git a/test/system/package-managers/deb.spec.ts b/test/system/package-sources/package-managers/deb.spec.ts similarity index 86% rename from test/system/package-managers/deb.spec.ts rename to test/system/package-sources/package-managers/deb.spec.ts index c45ff3e2d..eed840da1 100644 --- a/test/system/package-managers/deb.spec.ts +++ b/test/system/package-sources/package-managers/deb.spec.ts @@ -1,5 +1,5 @@ -import { scan } from "../../../lib/index"; -import { execute } from "../../../lib/sub-process"; +import { scan } from "../../../../lib/index"; +import { execute } from "../../../../lib/sub-process"; describe("deb package manager tests", () => { afterAll(async () => { diff --git a/test/system/package-managers/rpm.spec.ts b/test/system/package-sources/package-managers/rpm.spec.ts similarity index 96% rename from test/system/package-managers/rpm.spec.ts rename to test/system/package-sources/package-managers/rpm.spec.ts index 7069ac4b0..96aebc774 100644 --- a/test/system/package-managers/rpm.spec.ts +++ b/test/system/package-sources/package-managers/rpm.spec.ts @@ -1,6 +1,6 @@ -import { Docker } from "../../../lib/docker"; -import { scan } from "../../../lib/index"; -import { execute } from "../../../lib/sub-process"; +import { Docker } from "../../../../lib/docker"; +import { scan } from "../../../../lib/index"; +import { execute } from "../../../../lib/sub-process"; describe("rpm package manager tests", () => { beforeAll(() => { diff --git a/test/system/package-sources/sboms/spdx-deduplication.spec.ts b/test/system/package-sources/sboms/spdx-deduplication.spec.ts new file mode 100644 index 000000000..7692064ab --- /dev/null +++ b/test/system/package-sources/sboms/spdx-deduplication.spec.ts @@ -0,0 +1,110 @@ +import * as path from "path"; +import { scan } from "../../../../lib/index"; + +describe("SPDX deduplication with apt conflicts", () => { + const imagePath = path.join( + __dirname, + "../../../fixtures/sbom/deduplication/spdx-conflict-test.tar.gz", + ); + + it("should prioritize apt packages over SPDX when names conflict", async () => { + const pluginResult = await scan({ + path: `oci-archive:${imagePath}`, + }); + + expect(pluginResult).toBeDefined(); + expect(pluginResult.scanResults).toBeDefined(); + expect(pluginResult.scanResults.length).toBeGreaterThan(0); + + // Find the depGraph result + const depGraphResult = pluginResult.scanResults.find((result) => { + return result.facts.some((fact) => { + if (fact.type === "depGraph") { + return true; + } + return false; + }); + }); + + expect(depGraphResult).toBeDefined(); + + // Get the depGraph fact + const depGraphFact = depGraphResult?.facts.find( + (fact) => fact.type === "depGraph", + ); + expect(depGraphFact).toBeDefined(); + + if (depGraphFact && depGraphFact.type === "depGraph") { + const pkgs = depGraphFact.data.getPkgs(); + + // Should have curl from apt + const curlPkgs = pkgs.filter((pkg) => + pkg.name.toLowerCase().includes("curl"), + ); + expect(curlPkgs.length).toBeGreaterThan(0); + + // Verify NO duplicate curl entries + const curlNames = curlPkgs.map((pkg) => pkg.name); + const uniqueCurlNames = new Set(curlNames); + expect(curlNames.length).toBe(uniqueCurlNames.size); + + // The curl package should be from apt (debian), not from SPDX (dhi) + const curlPkg = curlPkgs.find((pkg) => pkg.name === "curl"); + expect(curlPkg).toBeDefined(); + expect(curlPkg?.version).toBeDefined(); + // Apt version should have debian package format (e.g., "7.88.1-10+deb12u8") + expect(curlPkg?.version).not.toBe("7.88.0"); // Not the exact SPDX version + + // Should have wget from apt + const wgetPkg = pkgs.find((pkg) => + pkg.name.toLowerCase().includes("wget"), + ); + expect(wgetPkg).toBeDefined(); + expect(wgetPkg?.version).toBeDefined(); + + // Should have redis-server from SPDX (no conflict with apt) + const redisPkg = pkgs.find( + (pkg) => + pkg.name.toLowerCase().includes("redis-server") || + pkg.name.toLowerCase() === "redis-server", + ); + expect(redisPkg).toBeDefined(); + expect(redisPkg?.version).toBe("7.0.15"); // SPDX version should be included + + // Verify total package count is reasonable + // Should have: apt packages (curl, wget, base-files, etc.) + redis-server from SPDX + expect(pkgs.length).toBeGreaterThan(3); + } + }, 120000); // 2 minute timeout for image scanning + + it("should not have duplicate packages when SPDX and apt both define the same package", async () => { + const pluginResult = await scan({ + path: `oci-archive:${imagePath}`, + }); + + const depGraphFact = pluginResult.scanResults[0]?.facts.find( + (fact) => fact.type === "depGraph", + ); + + if (depGraphFact && depGraphFact.type === "depGraph") { + const pkgs = depGraphFact.data.getPkgs(); + const packageNames = pkgs.map((pkg) => pkg.name); + + // Check for duplicates by comparing array length to Set size + const uniqueNames = new Set(packageNames); + expect(packageNames.length).toBe(uniqueNames.size); + + // Specifically check curl is not duplicated + const curlCount = packageNames.filter((name) => + name.toLowerCase().includes("curl"), + ).length; + expect(curlCount).toBeGreaterThan(0); // Should exist + + // No package should appear more than once + packageNames.forEach((name) => { + const count = packageNames.filter((n) => n === name).length; + expect(count).toBe(1); + }); + } + }, 120000); +}); diff --git a/test/system/package-sources/sboms/spdx.spec.ts b/test/system/package-sources/sboms/spdx.spec.ts new file mode 100644 index 000000000..e84a98f80 --- /dev/null +++ b/test/system/package-sources/sboms/spdx.spec.ts @@ -0,0 +1,55 @@ +import * as path from "path"; +import { scan } from "../../../../lib/index"; + +describe("SPDX (Docker Hardened Images) package manager tests", () => { + const imagePath = path.join( + __dirname, + "../../../fixtures/sbom/simple/dhi-test.tar", + ); + + it("should correctly analyze SPDX files from a Docker Hardened Image", async () => { + const pluginResult = await scan({ + path: `oci-archive:${imagePath}`, + platform: "linux/arm64", + }); + + expect(pluginResult).toBeDefined(); + expect(pluginResult.scanResults).toBeDefined(); + expect(pluginResult.scanResults.length).toBeGreaterThan(0); + + // Find the SPDX scan result + const depGraphResult = pluginResult.scanResults.find((result) => { + return result.facts.some((fact) => { + if (fact.type === "depGraph") { + return true; + } + return false; + }); + }); + + // Verify depGraph result exists + expect(depGraphResult).toBeDefined(); + + // Get the depGraph fact + const depGraphFact = depGraphResult?.facts.find( + (fact) => fact.type === "depGraph", + ); + expect(depGraphFact).toBeDefined(); + + if (depGraphFact && depGraphFact.type === "depGraph") { + const pkgs = depGraphFact.data.getPkgs(); + + // Should have detected redis-tools package from SPDX + const redisToolsPkg = pkgs.find((pkg) => + pkg.name.toLowerCase().includes("redis-tools"), + ); + expect(redisToolsPkg).toBeDefined(); + + // Should have detected redis-server package from SPDX + const redisServerPkg = pkgs.find((pkg) => + pkg.name.toLowerCase().includes("redis-server"), + ); + expect(redisServerPkg).toBeDefined(); + } + }, 120000); // 2 minute timeout for pulling and scanning image +});