Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
AnalysisType,
AnalyzedPackageWithVersion,
ImagePackagesAnalysis,
} from "../types";
} from "../../types";

export function analyze(
targetImage: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
IAptFiles,
ImagePackagesAnalysis,
OSRelease,
} from "../types";
} from "../../types";

export function analyze(
targetImage: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
AnalyzedPackageWithVersion,
ChiselPackage,
ImagePackagesAnalysis,
} from "../types";
} from "../../types";

/**
* Analyzes Ubuntu Chisel packages from a Docker image.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ImagePackagesAnalysis,
OSRelease,
SourcePackage,
} from "../types";
} from "../../types";

export function analyze(
targetImage: string,
Expand Down
101 changes: 101 additions & 0 deletions lib/analyzer/package-sources/sboms/spdx.ts
Original file line number Diff line number Diff line change
@@ -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<ImagePackagesAnalysis> {
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}`;
}
19 changes: 13 additions & 6 deletions lib/analyzer/static-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -103,6 +107,7 @@ export async function analyze(
getRpmSqliteDbFileContentAction,
getRpmNdbFileContentAction,
getChiselManifestAction,
getSpdxFileContentAction,
...getOsReleaseActions,
getNodeBinariesFileContentAction,
getOpenJDKBinariesFileContentAction,
Expand Down Expand Up @@ -185,6 +190,7 @@ export async function analyze(
]);

const distrolessAptFiles = getAptFiles(extractedLayers);
const spdxFileContents = getSpdxFileContents(extractedLayers);

const manifestFiles: ManifestFile[] = [];
if (checkForGlobs) {
Expand Down Expand Up @@ -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}`);
Expand Down
10 changes: 10 additions & 0 deletions lib/analyzer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions lib/inputs/spdx/static.ts
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 29 additions & 1 deletion lib/parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions test/fixtures/sbom/deduplication/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Binary file not shown.
Binary file added test/fixtures/sbom/simple/dhi-test.tar
Binary file not shown.
2 changes: 2 additions & 0 deletions test/fixtures/sbom/simple/spdx.malformed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{ invalid json - missing closing brace

Loading