Skip to content

Commit

Permalink
Add security advisory summary to document header; Add security adviso…
Browse files Browse the repository at this point in the history
…ry severity to packages table; Add introduced through column to security advisories table
  • Loading branch information
rhyskoedijk committed Nov 12, 2024
1 parent 61d4bbf commit 2d6e133
Show file tree
Hide file tree
Showing 6 changed files with 293 additions and 96 deletions.
63 changes: 57 additions & 6 deletions ui/components/SpdxDocumentHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Props, State> {
Expand All @@ -38,6 +47,19 @@ export class SpdxDocumentHeader extends React.Component<Props, State> {
}

static getDerivedStateFromProps(props: Props): State {
const securityAdvisoryCountsBySeverityName: Record<string, number> = {};
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,
Expand All @@ -51,6 +73,15 @@ export class SpdxDocumentHeader extends React.Component<Props, State> {
.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 = [
Expand Down Expand Up @@ -94,7 +125,7 @@ export class SpdxDocumentHeader extends React.Component<Props, State> {
id: 'exportXlsx',
text: 'Export to XLSX',
iconProps: {
iconName: 'Export',
iconName: 'ExcelDocument',
},
important: false,
onActivate: () => downloadSpdxAsXlsx(document),
Expand All @@ -103,9 +134,10 @@ export class SpdxDocumentHeader extends React.Component<Props, State> {
id: 'exportSvg',
text: 'Export to SVG',
iconProps: {
iconName: 'Export',
iconName: 'BranchFork2',
},
important: false,
disabled: document.documentGraphSvg === undefined,
onActivate: () => downloadSpdxAsSvg(document),
},
];
Expand All @@ -123,16 +155,35 @@ export class SpdxDocumentHeader extends React.Component<Props, State> {
}
return (
<CustomHeader className="bolt-header-with-commandbar">
<HeaderIcon iconProps={{ iconName: 'Certificate', className: 'font-size-xxl' }} />
<HeaderIcon titleSize={TitleSize.Small} iconProps={{ iconName: 'Certificate', className: 'font-size-xxl' }} />
<HeaderTitleArea>
<HeaderTitleRow>
<HeaderTitle ariaLevel={3} className="text-ellipsis" titleSize={TitleSize.Large}>
{this.state.documentName}
</HeaderTitle>
</HeaderTitleRow>
<HeaderDescription className="secondary-text">
Created by {this.state.documentCreatedByOrganisation} on {this.state.documentCreatedOn.toLocaleString()}{' '}
using {this.state.documentCreatedWithTool} ({this.state.documentSpdxVersion})
<HeaderDescription className="flex-column summary-line summary-line-non-link">
<div className="secondary-text">
Created by {this.state.documentCreatedByOrganisation} on {this.state.documentCreatedOn.toLocaleString()}{' '}
using {this.state.documentCreatedWithTool} ({this.state.documentSpdxVersion})
</div>
{this.state.documentSecurityAdvisoryCountsBySeverity.length > 0 && (
<PillGroup className="flex-row margin-top-8" overflow={PillGroupOverflow.wrap}>
{this.state.documentSecurityAdvisoryCountsBySeverity
.sort((a, b) => b.severity.id - a.severity.id)
.map((securityAdvisoryGroup) => (
<Pill
className="margin-right-8"
key={securityAdvisoryGroup.severity.id}
size={PillSize.compact}
variant={PillVariant.colored}
color={securityAdvisoryGroup.severity.color}
>
{securityAdvisoryGroup.severity.name} ({securityAdvisoryGroup.count})
</Pill>
))}
</PillGroup>
)}
</HeaderDescription>
</HeaderTitleArea>
<HeaderCommandBar items={this.state.commandBarItems} />
Expand Down
4 changes: 2 additions & 2 deletions ui/components/SpdxFileTableCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,11 @@ export class SpdxFileTableCard extends React.Component<Props, State> {
return (
<ZeroData
iconProps={{ iconName: 'Package' }}
primaryText="Empty"
primaryText={this.props.filter.getFilterItemValue('keyword') ? 'No Match' : 'No Files'}
secondaryText={
this.props.filter.getFilterItemValue('keyword')
? 'Filter does not match any files.'
: 'Document contains no files.'
: 'Document does not contain any files.'
}
imageAltText=""
className="margin-vertical-20"
Expand Down
48 changes: 34 additions & 14 deletions ui/components/SpdxGraphCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ interface Props {
document: ISpdx22Document;
}

interface State {}
interface State {
documentGraphSvgContainerRef?: React.Ref<HTMLDivElement>;
}

export class SpdxGraphCard extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
this.state = SpdxGraphCard.getDerivedStateFromProps(props);
}

static getDerivedStateFromProps(props: Props): State {
return {};
return {
documentGraphSvgContainerRef: React.createRef<HTMLDivElement>(),
};
}

public componentDidUpdate(prevProps: Readonly<Props>): void {
Expand All @@ -32,29 +36,45 @@ export class SpdxGraphCard extends React.Component<Props, State> {
if (!this.props?.document?.documentGraphSvg) {
return (
<ZeroData
iconProps={{ iconName: 'GitGraph' }}
primaryText="Generating Graph"
secondaryText="Please wait while the graph is generated..."
iconProps={{ iconName: 'BranchFork2' }}
primaryText="Graph Data Missing"
secondaryText="Document does not contain graph data."
imageAltText=""
className="margin-vertical-20"
className="page-content margin-vertical-20"
/>
);
}

function setCursor(elementRef: React.Ref<HTMLElement> | undefined, cursorStyle: string) {
if (elementRef && 'current' in elementRef && elementRef.current) {
elementRef.current.style.cursor = cursorStyle;
}
}

return (
<TransformWrapper
initialScale={1}
minScale={1}
maxScale={30}
maxScale={100}
centerOnInit={true}
centerZoomedOut={true}
wheel={{ activationKeys: ['Control', 'Shift'] }}
onPanningStart={() => {
setCursor(this.state.documentGraphSvgContainerRef, 'grabbing');
}}
onPanningStop={() => {
setCursor(this.state.documentGraphSvgContainerRef, 'grab');
}}
>
<TransformComponent>
<div
style={{ backgroundColor: 'white', width: '100vw', minHeight: '80vh' }}
dangerouslySetInnerHTML={{ __html: this.props.document.documentGraphSvg || '' }}
/>
</TransformComponent>
{({ zoomIn, zoomOut, resetTransform, ...rest }) => (
<TransformComponent>
<div
ref={this.state.documentGraphSvgContainerRef}
style={{ backgroundColor: 'white', width: '100vw', minHeight: '80vh', cursor: 'grab' }}
dangerouslySetInnerHTML={{ __html: this.props.document.documentGraphSvg || '' }}
/>
</TransformComponent>
)}
</TransformWrapper>
);
}
Expand Down
44 changes: 27 additions & 17 deletions ui/components/SpdxPackageTableCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -95,9 +97,11 @@ export class SpdxPackageTableCard extends React.Component<Props, State> {
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,
};
}),
};
Expand Down Expand Up @@ -291,11 +295,11 @@ export class SpdxPackageTableCard extends React.Component<Props, State> {
return (
<ZeroData
iconProps={{ iconName: 'Package' }}
primaryText="Empty"
primaryText={this.props.filter.getFilterItemValue('keyword') ? 'No Match' : 'No Packages'}
secondaryText={
this.props.filter.getFilterItemValue('keyword')
? 'Filter does not match any packages.'
: 'Document contains no packages.'
: 'Document does not contain any packages.'
}
imageAltText=""
className="margin-vertical-20"
Expand Down Expand Up @@ -395,18 +399,24 @@ function renderPackageSecurityAdvisoriesCell(
columnIndex: columnIndex,
tableColumn: tableColumn,
children: (
<div className="bolt-table-cell-content flex-row flex-wrap rhythm-horizontal-4">
{tableItem.securityAdvisories.map((advisory, index) => (
<Link
className="secondary-text bolt-table-link bolt-table-inline-link"
target="_blank"
href={advisory.uri}
key={index}
excludeTabStop
>
{advisory.id}
</Link>
))}
<div className="bolt-table-cell-content flex-row flex-wrap rhythm-horizontal-8 rhythm-vertical-8">
{tableItem.securityAdvisories
.sort((a, b) => b.severity.id - a.severity.id)
.map((advisory, index) => (
<div key={index} className="flex-column margin-vertical-8">
<Pill size={PillSize.compact} variant={PillVariant.colored} color={advisory.severity.color}>
{advisory.severity.name}
</Pill>
<Link
className="secondary-text bolt-table-link bolt-table-inline-link"
target="_blank"
href={advisory.uri}
excludeTabStop
>
{advisory.id}
</Link>
</div>
))}
</div>
),
});
Expand Down
Loading

0 comments on commit 2d6e133

Please sign in to comment.