diff --git a/ui/components/SpdxDocumentHeader.tsx b/ui/components/SpdxDocumentHeader.tsx index 0ff0d3c..e133583 100644 --- a/ui/components/SpdxDocumentHeader.tsx +++ b/ui/components/SpdxDocumentHeader.tsx @@ -10,7 +10,15 @@ import { TitleSize, } from 'azure-devops-ui/Header'; import { HeaderCommandBar, IHeaderCommandBarItem } from 'azure-devops-ui/HeaderCommandBar'; +import { Pill, PillSize, PillVariant } from 'azure-devops-ui/Pill'; +import { PillGroup, PillGroupOverflow } from 'azure-devops-ui/PillGroup'; +import { + defaultSecurityAdvisorySeverity, + ISecurityAdvisorySeverity, + parseSecurityAdvisory, + securityAdvisorySeverities, +} from '../models/SecurityAdvisory'; import { ISpdx22Document } from '../models/Spdx22'; import { downloadSpdxAsJson } from '../utils/SpdxToJson'; import { downloadSpdxAsSvg } from '../utils/SpdxToSvg'; @@ -29,6 +37,7 @@ interface State { documentCreatedByOrganisation: string | undefined; documentCreatedWithTool: string | undefined; documentProperties: { label: string; value: string | number }[]; + documentSecurityAdvisoryCountsBySeverity: { severity: ISecurityAdvisorySeverity; count: number }[]; } export class SpdxDocumentHeader extends React.Component { @@ -38,6 +47,19 @@ export class SpdxDocumentHeader extends React.Component { } static getDerivedStateFromProps(props: Props): State { + const securityAdvisoryCountsBySeverityName: Record = {}; + props.document.packages + .flatMap((p) => + p.externalRefs.filter((r) => r.referenceCategory === 'SECURITY' && r.referenceType === 'advisory'), + ) + .map((x) => parseSecurityAdvisory(x)?.severity) + .reduce((acc, severity) => { + if (severity !== undefined) { + acc[severity.name] = (acc[severity.name] || 0) + 1; + } + return acc; + }, securityAdvisoryCountsBySeverityName); + const state: State = { commandBarItems: SpdxDocumentHeader.getCommandBarItems(props.document), documentName: props.document.name, @@ -51,6 +73,15 @@ export class SpdxDocumentHeader extends React.Component { .map((c) => c.match(/^Tool\:(.*)$/i)?.[1]?.trim()) .filter((c) => c)?.[0], documentProperties: [], + documentSecurityAdvisoryCountsBySeverity: Object.keys(securityAdvisoryCountsBySeverityName).map( + (severityName) => { + return { + severity: + securityAdvisorySeverities.find((s) => s.name === severityName) || defaultSecurityAdvisorySeverity, + count: securityAdvisoryCountsBySeverityName[severityName], + }; + }, + ), }; state.documentProperties = [ @@ -94,7 +125,7 @@ export class SpdxDocumentHeader extends React.Component { id: 'exportXlsx', text: 'Export to XLSX', iconProps: { - iconName: 'Export', + iconName: 'ExcelDocument', }, important: false, onActivate: () => downloadSpdxAsXlsx(document), @@ -103,9 +134,10 @@ export class SpdxDocumentHeader extends React.Component { id: 'exportSvg', text: 'Export to SVG', iconProps: { - iconName: 'Export', + iconName: 'BranchFork2', }, important: false, + disabled: document.documentGraphSvg === undefined, onActivate: () => downloadSpdxAsSvg(document), }, ]; @@ -123,16 +155,35 @@ export class SpdxDocumentHeader extends React.Component { } return ( - + {this.state.documentName} - - Created by {this.state.documentCreatedByOrganisation} on {this.state.documentCreatedOn.toLocaleString()}{' '} - using {this.state.documentCreatedWithTool} ({this.state.documentSpdxVersion}) + +
+ Created by {this.state.documentCreatedByOrganisation} on {this.state.documentCreatedOn.toLocaleString()}{' '} + using {this.state.documentCreatedWithTool} ({this.state.documentSpdxVersion}) +
+ {this.state.documentSecurityAdvisoryCountsBySeverity.length > 0 && ( + + {this.state.documentSecurityAdvisoryCountsBySeverity + .sort((a, b) => b.severity.id - a.severity.id) + .map((securityAdvisoryGroup) => ( + + {securityAdvisoryGroup.severity.name} ({securityAdvisoryGroup.count}) + + ))} + + )}
diff --git a/ui/components/SpdxFileTableCard.tsx b/ui/components/SpdxFileTableCard.tsx index 043c9e7..dd99897 100644 --- a/ui/components/SpdxFileTableCard.tsx +++ b/ui/components/SpdxFileTableCard.tsx @@ -148,11 +148,11 @@ export class SpdxFileTableCard extends React.Component { return ( ; +} export class SpdxGraphCard extends React.Component { constructor(props: Props) { super(props); - this.state = {}; + this.state = SpdxGraphCard.getDerivedStateFromProps(props); } static getDerivedStateFromProps(props: Props): State { - return {}; + return { + documentGraphSvgContainerRef: React.createRef(), + }; } public componentDidUpdate(prevProps: Readonly): void { @@ -32,29 +36,45 @@ export class SpdxGraphCard extends React.Component { if (!this.props?.document?.documentGraphSvg) { return ( ); } + + function setCursor(elementRef: React.Ref | undefined, cursorStyle: string) { + if (elementRef && 'current' in elementRef && elementRef.current) { + elementRef.current.style.cursor = cursorStyle; + } + } + return ( { + setCursor(this.state.documentGraphSvgContainerRef, 'grabbing'); + }} + onPanningStop={() => { + setCursor(this.state.documentGraphSvgContainerRef, 'grab'); + }} > - -
- + {({ zoomIn, zoomOut, resetTransform, ...rest }) => ( + +
+ + )} ); } diff --git a/ui/components/SpdxPackageTableCard.tsx b/ui/components/SpdxPackageTableCard.tsx index 2151f40..66b4cdc 100644 --- a/ui/components/SpdxPackageTableCard.tsx +++ b/ui/components/SpdxPackageTableCard.tsx @@ -4,6 +4,7 @@ import { Card } from 'azure-devops-ui/Card'; import { IReadonlyObservableValue, ObservableArray, ObservableValue } from 'azure-devops-ui/Core/Observable'; import { Icon, IconSize } from 'azure-devops-ui/Icon'; import { Link } from 'azure-devops-ui/Link'; +import { Pill, PillSize, PillVariant } from 'azure-devops-ui/Pill'; import { ColumnSorting, ITableColumn, @@ -16,6 +17,7 @@ import { import { FILTER_CHANGE_EVENT, IFilter } from 'azure-devops-ui/Utilities/Filter'; import { ZeroData } from 'azure-devops-ui/ZeroData'; +import { ISecurityAdvisorySeverity, parseSecurityAdvisory } from '../models/SecurityAdvisory'; import { IPackage, IRelationship, ISpdx22Document } from '../models/Spdx22'; interface IPackageTableItem { @@ -28,7 +30,7 @@ interface IPackageTableItem { introducedThrough: string[]; packageManager: string; isVulnerable: string; - securityAdvisories: { id: string; uri: string }[]; + securityAdvisories: { id: string; severity: ISecurityAdvisorySeverity; uri: string }[]; } interface Props { @@ -95,9 +97,11 @@ export class SpdxPackageTableCard extends React.Component { packageManager: packageManager || '', isVulnerable: securityAdvisories?.length || false ? 'Yes' : 'No', securityAdvisories: securityAdvisories?.map((a) => { + const advisory = parseSecurityAdvisory(a); return { - id: a.comment?.match(/CVE-[0-9-]+/i)?.[0] || a.referenceLocator?.match(/GHSA-[0-9a-z-]+/i)?.[0] || '', - uri: a.referenceLocator, + id: advisory.id, + severity: advisory.severity, + uri: advisory.url, }; }), }; @@ -291,11 +295,11 @@ export class SpdxPackageTableCard extends React.Component { return ( - {tableItem.securityAdvisories.map((advisory, index) => ( - - {advisory.id} - - ))} +
+ {tableItem.securityAdvisories + .sort((a, b) => b.severity.id - a.severity.id) + .map((advisory, index) => ( +
+ + {advisory.severity.name} + + + {advisory.id} + +
+ ))}
), }); diff --git a/ui/components/SpdxSecurityTableCard.tsx b/ui/components/SpdxSecurityTableCard.tsx index 794eec4..86441e8 100644 --- a/ui/components/SpdxSecurityTableCard.tsx +++ b/ui/components/SpdxSecurityTableCard.tsx @@ -1,33 +1,26 @@ import * as React from 'react'; -import { IColor } from 'azure-devops-extension-api'; import { Card } from 'azure-devops-ui/Card'; import { IReadonlyObservableValue, ObservableArray, ObservableValue } from 'azure-devops-ui/Core/Observable'; +import { Icon, IconSize } from 'azure-devops-ui/Icon'; import { Pill, PillSize, PillVariant } from 'azure-devops-ui/Pill'; -import { ColumnSorting, ITableColumn, sortItems, SortOrder, Table, TwoLineTableCell } from 'azure-devops-ui/Table'; +import { + ColumnSorting, + ITableColumn, + sortItems, + SortOrder, + Table, + TableCell, + TwoLineTableCell, +} from 'azure-devops-ui/Table'; import { FILTER_CHANGE_EVENT, IFilter } from 'azure-devops-ui/Utilities/Filter'; import { ZeroData } from 'azure-devops-ui/ZeroData'; -import { ISpdx22Document } from '../models/Spdx22'; +import { ISecurityAdvisory, parseSecurityAdvisory } from '../models/SecurityAdvisory'; +import { IPackage, IRelationship, ISpdx22Document } from '../models/Spdx22'; -interface ISecurityAdvisorySeverity { - name: string; - color: IColor; -} - -const securityAdvisorySeverities: ISecurityAdvisorySeverity[] = [ - { name: 'Critical', color: { red: 229, green: 115, blue: 115 } }, - { name: 'High', color: { red: 255, green: 138, blue: 101 } }, - { name: 'Moderate', color: { red: 255, green: 183, blue: 77 } }, - { name: 'Low', color: { red: 100, green: 181, blue: 246 } }, -]; - -interface ISecurityAdvisoryTableItem { - id: string; - severity: string; - summary: string; - url: string; - package: { name: string; version: string }; +interface ISecurityAdvisoryTableItem extends ISecurityAdvisory { + introducedThrough: string[]; } interface Props { @@ -61,27 +54,34 @@ export class SpdxSecurityTableCard extends React.Component { } static getDerivedStateFromProps(props: Props): State { - const packages = props.document?.packages || []; + const dependsOnRelationships = (props.document?.relationships || []).filter( + (r) => r.relationshipType === 'DEPENDS_ON', + ); + const rootPackageId = props.document.documentDescribes?.[0]; + const packages = (props.document?.packages || []).filter((p) => { + return dependsOnRelationships?.some((r) => r.relatedSpdxElement === p.SPDXID); + }); const securityAdvisories = packages.flatMap((p) => { return p.externalRefs.filter((r) => r.referenceCategory == 'SECURITY' && r.referenceType == 'advisory') || []; }); const rawTableItems: ISecurityAdvisoryTableItem[] = securityAdvisories.map((x) => { - const pkg = packages.find((p) => p.externalRefs.includes(x)); - const ghsaId = x.referenceLocator?.match(/GHSA-[0-9a-z-]+/i)?.[0]; - const severity = x.comment?.match(/^\[(\w+)\]/)?.[1]?.toPascalCase(); - const summary = x.comment?.match(/^\[(\w+)\]([^;]*)/)?.[2]?.trim(); - const url = x.referenceLocator; + const securityAdvisory = parseSecurityAdvisory(x, packages); + const pkg = packages.find( + (p) => p.name === securityAdvisory.package?.name && p.versionInfo === securityAdvisory.package?.version, + ); + const isTopLevel = + pkg?.SPDXID == rootPackageId || + dependsOnRelationships.some( + (r) => + r.spdxElementId == rootPackageId && + r.relatedSpdxElement === pkg?.SPDXID && + r.relationshipType === 'DEPENDS_ON', + ); return { - id: ghsaId || '', - severity: severity || '', - summary: summary || '', - url: url, - package: { - name: pkg?.name || '', - version: pkg?.versionInfo || '', - }, + ...securityAdvisory, + introducedThrough: getTransitivePackageChain(pkg?.SPDXID || '', packages, props.document.relationships), }; }) || []; @@ -104,7 +104,19 @@ export class SpdxSecurityTableCard extends React.Component { ariaLabelAscending: 'Sorted low to high', ariaLabelDescending: 'Sorted high to low', }, - width: new ObservableValue(-65), + width: new ObservableValue(-55), + }, + { + id: 'package', + name: 'Package', + onSize: tableColumnResize, + readonly: true, + renderCell: renderAdvisoryPackageCell, + sortProps: { + ariaLabelAscending: 'Sorted A to Z', + ariaLabelDescending: 'Sorted Z to A', + }, + width: new ObservableValue(-20), }, { id: 'introducedThrough', @@ -112,10 +124,10 @@ export class SpdxSecurityTableCard extends React.Component { readonly: true, renderCell: renderAdvisoryIntroducedThroughCell, sortProps: { - ariaLabelAscending: 'Sorted A to Z', - ariaLabelDescending: 'Sorted Z to A', + ariaLabelAscending: 'Sorted low to high', + ariaLabelDescending: 'Sorted high to low', }, - width: new ObservableValue(-30), + width: new ObservableValue(-25), }, ]; @@ -138,13 +150,16 @@ export class SpdxSecurityTableCard extends React.Component { [ // Sort on severity (item1: ISecurityAdvisoryTableItem, item2: ISecurityAdvisoryTableItem): number => { - const item1Severity = securityAdvisorySeverities.findIndex((x) => x.name === item1.severity); - const item2Severity = securityAdvisorySeverities.findIndex((x) => x.name === item2.severity); - return item1Severity - item2Severity; + return item1.severity.id - item2.severity.id; }, // Sort on package name (item1: ISecurityAdvisoryTableItem, item2: ISecurityAdvisoryTableItem): number => { - return item1.package.name!.localeCompare(item2.package.name!); + if (!item1.package || !item2.package) return 0; + return item1.package!.name!.localeCompare(item2.package!.name!); + }, + // Sort on number of chained packages + (item1: ISecurityAdvisoryTableItem, item2: ISecurityAdvisoryTableItem): number => { + return item1.introducedThrough.length - item2.introducedThrough.length; }, ], tableColumns, @@ -159,7 +174,7 @@ export class SpdxSecurityTableCard extends React.Component { (item) => !keyword || item.id?.toLowerCase()?.includes(keyword.toLowerCase()) || - item.severity?.toLowerCase()?.includes(keyword.toLowerCase()) || + item.severity?.name?.toLowerCase()?.includes(keyword.toLowerCase()) || item.summary?.toLowerCase()?.includes(keyword.toLowerCase()) || item.package?.name?.toLowerCase()?.includes(keyword.toLowerCase()) || item.url?.toLowerCase()?.includes(keyword.toLowerCase()), @@ -185,12 +200,12 @@ export class SpdxSecurityTableCard extends React.Component { if (!this.state?.tableItems?.length) { return ( { } } +/** + * Get a summary of the transitive dependency chain for a package. + * @param packageId The SPDX ID of the package. + * @param packages The list of packages in the document. + * @param dependsOnRelationships The list of DEPENDS_ON relationships in the document. + * @returns An array package names representing the transitive dependency chain. + */ +function getTransitivePackageChain( + packageId: string, + packages: IPackage[], + dependsOnRelationships: IRelationship[], +): string[] { + const chain: string[] = []; + let currentId = packageId; + while (currentId) { + const relationship = dependsOnRelationships.find((r) => r.relatedSpdxElement === currentId); + if (!relationship) break; + + const pkg = packages.find((p) => p.SPDXID === relationship.spdxElementId); + if (!pkg) break; + + chain.unshift(pkg.name); + currentId = relationship.spdxElementId; + } + + return chain; +} + function renderAdvisorySummaryCell( rowIndex: number, columnIndex: number, @@ -231,15 +274,11 @@ function renderAdvisorySummaryCell( ariaRowIndex: rowIndex, columnIndex: columnIndex, tableColumn: tableColumn, - line1:
{tableItem.summary}
, + line1:
{tableItem.summary}
, line2: (
- x.name === tableItem.severity)?.color} - > - {tableItem.severity} + + {tableItem.severity.name}
{tableItem.id}
@@ -247,7 +286,7 @@ function renderAdvisorySummaryCell( }); } -function renderAdvisoryIntroducedThroughCell( +function renderAdvisoryPackageCell( rowIndex: number, columnIndex: number, tableColumn: ITableColumn, @@ -257,7 +296,30 @@ function renderAdvisoryIntroducedThroughCell( ariaRowIndex: rowIndex, columnIndex: columnIndex, tableColumn: tableColumn, - line1:
{tableItem.package.name}
, - line2:
{tableItem.package.version}
, + line1:
{tableItem.package?.name}
, + line2:
{tableItem.package?.version}
, + }); +} + +function renderAdvisoryIntroducedThroughCell( + rowIndex: number, + columnIndex: number, + tableColumn: ITableColumn, + tableItem: ISecurityAdvisoryTableItem, +): JSX.Element { + return TableCell({ + ariaRowIndex: rowIndex, + columnIndex: columnIndex, + tableColumn: tableColumn, + children: ( +
+ {tableItem.introducedThrough.map((pkg, index) => ( +
+ {index > 0 ? : null} + {pkg} +
+ ))} +
+ ), }); } diff --git a/ui/models/SecurityAdvisory.tsx b/ui/models/SecurityAdvisory.tsx new file mode 100644 index 0000000..b820103 --- /dev/null +++ b/ui/models/SecurityAdvisory.tsx @@ -0,0 +1,54 @@ +import { IColor } from 'azure-devops-extension-api'; +import { IExternalRef, IPackage } from './Spdx22'; + +export interface ISecurityAdvisory { + id: string; + severity: ISecurityAdvisorySeverity; + summary: string; + url: string; + package: ISecurityAdvisoryPackage | undefined; +} + +export interface ISecurityAdvisorySeverity { + id: number; + name: string; + color: IColor; +} + +export interface ISecurityAdvisoryPackage { + name: string; + version: string; +} + +export const securityAdvisorySeverities: ISecurityAdvisorySeverity[] = [ + { id: 0, name: 'None', color: { red: 0, green: 0, blue: 0 } }, + { id: 1, name: 'Low', color: { red: 100, green: 181, blue: 246 } }, + { id: 2, name: 'Moderate', color: { red: 255, green: 183, blue: 77 } }, + { id: 3, name: 'High', color: { red: 255, green: 138, blue: 101 } }, + { id: 4, name: 'Critical', color: { red: 229, green: 115, blue: 115 } }, +]; + +export const defaultSecurityAdvisorySeverity: ISecurityAdvisorySeverity = securityAdvisorySeverities[0]; + +export function parseSecurityAdvisory( + securityAdvisory: IExternalRef, + packages: IPackage[] | undefined = undefined, +): ISecurityAdvisory { + const pkg = packages?.find((p) => p.externalRefs.includes(securityAdvisory)); + const ghsaId = securityAdvisory.referenceLocator?.match(/GHSA-[0-9a-z-]+/i)?.[0]; + const severity = securityAdvisory.comment?.match(/^\[(\w+)\]/)?.[1]?.toPascalCase(); + const summary = securityAdvisory.comment?.match(/^\[(\w+)\]([^;]*)/)?.[2]?.trim(); + const url = securityAdvisory.referenceLocator; + return { + id: ghsaId || '', + severity: securityAdvisorySeverities.find((s) => s.name === severity || '') || defaultSecurityAdvisorySeverity, + summary: summary || '', + url: url, + package: pkg + ? { + name: pkg.name || '', + version: pkg.versionInfo || '', + } + : undefined, + }; +}