diff --git a/shared/spdx/convertSpdxToSvg.ts b/shared/spdx/convertSpdxToSvg.ts index a08d47d..52f29c0 100644 --- a/shared/spdx/convertSpdxToSvg.ts +++ b/shared/spdx/convertSpdxToSvg.ts @@ -2,7 +2,7 @@ import { IDocument } from '../models/spdx/2.3/IDocument'; /** * Convert an SPDX document to SVG graph diagram - * @param spdxJson The SPDX document + * @param spdx The SPDX document * @return The SPDX as SVG buffer */ export async function convertSpdxToSvgAsync(spdx: IDocument): Promise { diff --git a/shared/spdx/mergeSpdxDocuments.ts b/shared/spdx/mergeSpdxDocuments.ts new file mode 100644 index 0000000..f1ac7cc --- /dev/null +++ b/shared/spdx/mergeSpdxDocuments.ts @@ -0,0 +1,42 @@ +import path from 'path'; +import { getCreatorOrganization } from '../models/spdx/2.3/ICreationInfo'; +import { DocumentVersion, IDocument } from '../models/spdx/2.3/IDocument'; + +/** + * Merge multiple SPDX documents to a single SPDX document + * @param packageName The name of the merged ackage + * @param packageVersion The version of the merged package + * @param sourceDocuments The source SPDX documents to be merged + * @return The merged SPDX document + */ +export function mergeSpdxDocuments( + packageName: string | undefined, + packageVersion: string | undefined, + sourceDocuments: IDocument[], +): IDocument { + const packageGuid = crypto.randomUUID(); + const rootDocument = sourceDocuments[0]; + const rootNamespaceUri = new URL(rootDocument.documentNamespace); + const rootOrganisation = getCreatorOrganization(rootDocument.creationInfo); + return { + SPDXID: 'SPDXRef-DOCUMENT', + spdxVersion: DocumentVersion.SPDX_2_3, + name: `${packageName} ${packageVersion}`.trim(), + dataLicense: rootDocument.dataLicense || 'CC0-1.0', + documentNamespace: `${rootNamespaceUri.protocol}//${rootNamespaceUri.host}/${packageName}/${packageVersion}/${packageGuid}`, + creationInfo: { + created: new Date().toISOString(), + creators: [`Organization: ${rootOrganisation}`, `Tool: rhyskoedijk/sbom-azure-devops-1.0`], + }, + files: sourceDocuments.flatMap((d) => + d.files.map((f) => ({ + ...f, + fileName: path.join(d.packages.find((p) => p.SPDXID == d.documentDescribes[0])?.name || '', f.fileName), + })), + ), + packages: sourceDocuments.flatMap((d) => d.packages), + externalDocumentRefs: sourceDocuments.flatMap((d) => d.externalDocumentRefs), + relationships: sourceDocuments.flatMap((d) => d.relationships), + documentDescribes: sourceDocuments.flatMap((d) => d.documentDescribes), + }; +} diff --git a/ui/sbom-report-tab.tsx b/ui/sbom-report-tab.tsx index fd161d4..4f96ab0 100644 --- a/ui/sbom-report-tab.tsx +++ b/ui/sbom-report-tab.tsx @@ -13,6 +13,7 @@ import { ZeroData } from 'azure-devops-ui/ZeroData'; import { ISbomBuildArtifact } from '../shared/models/ISbomBuildArtifact'; import { getDisplayNameForDocument, IDocument } from '../shared/models/spdx/2.3/IDocument'; +import { mergeSpdxDocuments } from '../shared/spdx/mergeSpdxDocuments'; import { BuildRestClient } from './clients/BuildRestClient'; import { SbomDocumentPage } from './components/sbom/SbomDocumentPage'; @@ -26,7 +27,13 @@ const SPDX_JSON_ATTACHMENT_TYPE = 'spdx.json'; const SPDX_SVG_ATTACHMENT_TYPE = 'spdx.svg'; interface State { + build?: { + id: number; + name?: string; + number?: string; + }; artifacts?: ISbomBuildArtifact[]; + summaryArtifact?: ISbomBuildArtifact; loadingMessage?: string; loadError?: any; } @@ -81,6 +88,13 @@ export class Root extends React.Component<{}, State> { if (!buildId) { throw new Error('Unable to access the current build data'); } + this.setState({ + build: { + id: buildId, + name: (buildPageData.build as any)?.name || buildPageData.definition?.name, + number: (buildPageData.build as any)?.number || buildPageData.build?.buildNumber, + }, + }); // Get all SPDX JSON artifact attachments for the current build const buildClient = getClient(BuildRestClient); @@ -158,6 +172,10 @@ export class Root extends React.Component<{}, State> { } } + // Generate a summary SBOM artifact if multiple artifacts were loaded + this.generateSbomSummaryArtifact(); + + // Update the state with the loaded SBOM artifacts console.info(`Loaded ${Object.keys(sbomArtifacts).length} SBOM artifact(s) for build ${buildId}`); this.selectedArtifactId.value = sbomArtifacts[0]?.spdxDocument?.documentNamespace || ''; this.setState({ @@ -178,6 +196,7 @@ export class Root extends React.Component<{}, State> { private async loadSbomArtifactsFromFileUpload(files: File[]): Promise { for (const file of files) { try { + // Load the SPDX JSON file console.info(`Loading SBOM artifact from file upload '${file.name}'...`); const spdxJsonStream = await file.arrayBuffer(); const spdxJsonDocument = JSON.parse(new TextDecoder().decode(spdxJsonStream)) as IDocument; @@ -192,12 +211,16 @@ export class Root extends React.Component<{}, State> { spdxJsonDocument: spdxJsonStream, }; + // Update the state with the new SBOM artifact this.selectedArtifactId.value = newArtifact.id; this.setState({ artifacts: [...(this.state.artifacts || []), newArtifact], loadingMessage: undefined, loadError: undefined, }); + + // Generate a summary SBOM artifact if multiple artifacts were loaded + this.generateSbomSummaryArtifact(); } catch (error) { console.error(error); this.setState({ loadError: error }); @@ -206,6 +229,28 @@ export class Root extends React.Component<{}, State> { } } + /** + * Merge all SBOM artifacts into a single summary artifact + */ + private generateSbomSummaryArtifact() { + if (this.state?.artifacts) { + if (this.state.artifacts.length > 1) { + const mergedDocument = mergeSpdxDocuments( + this.state.build?.name, + this.state.build?.number, + this.state.artifacts.map((a) => a.spdxDocument), + ); + this.setState({ + summaryArtifact: { + id: mergedDocument.documentNamespace, + spdxDocument: mergedDocument, + spdxJsonDocument: new TextEncoder().encode(JSON.stringify(mergedDocument, null, 2)).buffer, + }, + }); + } + } + } + private onSelectedArtifactTabChanged = (newSpdxId: string) => { this.selectedArtifactId.value = newSpdxId; }; @@ -245,6 +290,13 @@ export class Root extends React.Component<{}, State> { className="bolt-tabbar-grey bolt-tabbar-compact flex-shrink" tabGroups={[{ id: 'manifests', name: 'Manifests' }]} > + {this.state.summaryArtifact && ( + + )} {this.state.artifacts.map((artifact, index) => ( { {(props: { selectedArtifactId: string }) => - props.selectedArtifactId && - this.state.artifacts?.find((artifact) => artifact.id === props.selectedArtifactId) ? ( + props.selectedArtifactId && this.state.summaryArtifact?.id === props.selectedArtifactId ? ( + this.loadSbomArtifactsFromFileUpload(files)} + /> + ) : this.state.artifacts?.find((artifact) => artifact.id === props.selectedArtifactId) ? ( artifact.id === props.selectedArtifactId)!} onLoadArtifacts={(files) => this.loadSbomArtifactsFromFileUpload(files)}