diff --git a/plugins/quay/dev/__data__/security_vulnerabilities.ts b/plugins/quay/dev/__data__/security_vulnerabilities.ts index a5305444d8..4503b8a73a 100644 --- a/plugins/quay/dev/__data__/security_vulnerabilities.ts +++ b/plugins/quay/dev/__data__/security_vulnerabilities.ts @@ -1,4 +1,5 @@ import { + Layer, SecurityDetailsResponse, VulnerabilitySeverity, } from '../../src/types'; @@ -327,3 +328,48 @@ export const securityDetails: SecurityDetailsResponse = { }, }, }; + +export const v1securityDetails: SecurityDetailsResponse = { + ...securityDetails, + status: 'unsupported', + data: { + ...securityDetails.data, + Layer: { + ...(securityDetails?.data?.Layer ?? {}), + Features: [], + } as Layer, + }, +}; + +export const v2securityDetails: SecurityDetailsResponse = { + ...securityDetails, + status: 'queued', + data: { + ...securityDetails.data, + Layer: { + ...(securityDetails?.data?.Layer ?? {}), + Features: securityDetails.data?.Layer?.Features?.slice(0, 5) ?? [], + } as Layer, + }, +}; + +export const v3securityDetails: SecurityDetailsResponse = { + ...securityDetails, + data: { + ...securityDetails.data, + Layer: { + ...(securityDetails?.data?.Layer ?? {}), + Features: [], + } as Layer, + }, +}; +export const v4securityDetails: SecurityDetailsResponse = { + ...securityDetails, + data: { + ...securityDetails.data, + Layer: { + ...(securityDetails?.data?.Layer ?? {}), + Features: securityDetails.data?.Layer?.Features?.slice(0, 5) ?? [], + } as Layer, + }, +}; diff --git a/plugins/quay/dev/__data__/tags.ts b/plugins/quay/dev/__data__/tags.ts index 6118c0318c..53120c8a57 100644 --- a/plugins/quay/dev/__data__/tags.ts +++ b/plugins/quay/dev/__data__/tags.ts @@ -10,6 +10,46 @@ export const tags = { size: 275862608, last_modified: 'Tue, 06 Feb 2024 09:39:24 -0000', }, + { + name: 'v4', + reversion: false, + start_ts: 1707212364, + manifest_digest: + 'sha256:29c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775d', + is_manifest_list: false, + size: 265862608, + last_modified: 'Tue, 06 Feb 2024 09:39:24 -0000', + }, + { + name: 'v3', + reversion: false, + start_ts: 1707212364, + manifest_digest: + 'sha256:79c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775d', + is_manifest_list: false, + size: 265862608, + last_modified: 'Tue, 06 Feb 2024 09:39:24 -0000', + }, + { + name: 'v2', + reversion: false, + start_ts: 1707212364, + manifest_digest: + 'sha256:89c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775e', + is_manifest_list: false, + size: 235862608, + last_modified: 'Tue, 06 Feb 2024 09:39:24 -0000', + }, + { + name: 'v1', + reversion: false, + start_ts: 1707212364, + manifest_digest: + 'sha256:99c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775f', + is_manifest_list: false, + size: 225862608, + last_modified: 'Tue, 06 Feb 2024 09:39:24 -0000', + }, ], page: 1, has_additional: false, diff --git a/plugins/quay/dev/index.tsx b/plugins/quay/dev/index.tsx index 5f4f1dcf83..5e0c3e24f7 100644 --- a/plugins/quay/dev/index.tsx +++ b/plugins/quay/dev/index.tsx @@ -12,7 +12,13 @@ import { quayApiRef, QuayApiV1 } from '../src/api'; import { QuayPage, quayPlugin } from '../src/plugin'; import { labels } from './__data__/labels'; import { manifestDigest } from './__data__/manifest_digest'; -import { securityDetails } from './__data__/security_vulnerabilities'; +import { + securityDetails, + v1securityDetails, + v2securityDetails, + v3securityDetails, + v4securityDetails, +} from './__data__/security_vulnerabilities'; import { tags } from './__data__/tags'; const mockEntity: Entity = { @@ -45,7 +51,34 @@ export class MockQuayApiClient implements QuayApiV1 { return manifestDigest; } - async getSecurityDetails() { + async getSecurityDetails(_: string, __: string, digest: string) { + if ( + digest === + 'sha256:79c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775d' + ) { + return v3securityDetails; + } + + if ( + digest === + 'sha256:89c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775e' + ) { + return v2securityDetails; + } + if ( + digest === + 'sha256:99c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775f' + ) { + return v1securityDetails; + } + + if ( + digest === + 'sha256:29c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775d' + ) { + return v4securityDetails; + } + return securityDetails; } } diff --git a/plugins/quay/src/components/QuayRepository/tableHeading.tsx b/plugins/quay/src/components/QuayRepository/tableHeading.tsx index 81e77445f3..c86d90ce4c 100644 --- a/plugins/quay/src/components/QuayRepository/tableHeading.tsx +++ b/plugins/quay/src/components/QuayRepository/tableHeading.tsx @@ -5,7 +5,7 @@ import { Link, Progress, TableColumn } from '@backstage/core-components'; import { Tooltip } from '@material-ui/core'; import makeStyles from '@material-ui/core/styles/makeStyles'; -import { vulnerabilitySummary } from '../../lib/utils'; +import { securityScanComparator, vulnerabilitySummary } from '../../lib/utils'; import type { QuayTagData } from '../../types'; export const columns: TableColumn[] = [ @@ -52,10 +52,18 @@ export const columns: TableColumn[] = [ const tagManifest = rowData.manifest_digest_raw; const retStr = vulnerabilitySummary(rowData.securityDetails); - return {retStr}; + return ( + + {retStr} + + ); }, id: 'securityScan', - sorting: false, + customSort: (a: QuayTagData, b: QuayTagData) => + securityScanComparator(a, b), }, { title: 'Size', diff --git a/plugins/quay/src/lib/utils.test.ts b/plugins/quay/src/lib/utils.test.ts index cb1103ed7b..d0660bd7dd 100644 --- a/plugins/quay/src/lib/utils.test.ts +++ b/plugins/quay/src/lib/utils.test.ts @@ -1,5 +1,16 @@ +import { + securityDetails, + v1securityDetails, + v2securityDetails, + v3securityDetails, +} from '../../dev/__data__/security_vulnerabilities'; +import { tags } from '../../dev/__data__/tags'; import { Layer, VulnerabilitySeverity } from '../types'; -import { SEVERITY_COLORS, vulnerabilitySummary } from './utils'; +import { + securityScanComparator, + SEVERITY_COLORS, + vulnerabilitySummary, +} from './utils'; import { mockLayer } from './utils.data'; describe('SEVERITY_COLORS', () => { @@ -45,3 +56,121 @@ describe('vulnerabilitySummary', () => { expect(result).toMatch('High: 3, Medium: 2, Low: 1'); }); }); + +describe('compareSecurityScans', () => { + const { tags: tagArray } = tags; + + const data = [ + { + ...tagArray[0], + securityStatus: 'scanned', + securityDetails: mockLayer, + }, + { + ...tagArray[0], + name: 'stable', + securityStatus: 'scanned', + securityDetails: securityDetails?.data?.Layer, + }, + { + ...tagArray[1], + securityStatus: 'scanned', + securityDetails: v3securityDetails?.data?.Layer, + }, + { + ...tagArray[2], + securityStatus: 'scanned', + securityDetails: { + ...securityDetails?.data?.Layer, + Features: [], + }, + }, + { + ...tagArray[3], + securityStatus: 'queued', + securityDetails: v2securityDetails?.data?.Layer, + }, + { + ...tagArray[4], + securityStatus: 'unsupported', + securityDetails: v1securityDetails?.data?.Layer, + }, + ] as any[]; + + it('should sort security scan values in the ascending order', () => { + const expected = [ + 'latest-linux-arm64', // High: 3, Medium: 2, Low: 1 ; High value + 'stable', // High: 2, Medium: 2, Low: 1 ; High value + 'v4', // Medium: 1; No High, but has Medium and Low + 'v3', // Passed + 'v2', // Queued; + 'v1', // Unsupported + ]; + + const names = data + .sort((a, b) => securityScanComparator(a, b, 'asc')) + .map(tag => tag.name); + expect(names).toEqual(expected); + }); + it('should sort security scan values in the descending order', () => { + const expected = [ + 'v1', // Unsupported + 'v2', // Queued; + 'v4', // Passed + 'v3', // Medium: 1; No High, but has Medium and Low + 'stable', // High: 2, Medium: 2, Low: 1 ; High value + 'latest-linux-arm64', // High: 3, Medium: 2, Low: 1 ; High value + ]; + + const names = data + .sort((a, b) => securityScanComparator(a, b, 'desc')) + .map(tag => tag.name); + expect(names).toEqual(expected); + }); + + it('should not perform sort on the scanning row', () => { + const mockData = [ + { + ...tagArray[0], + name: 'v1beta', + securityStatus: 'scanning', + }, + ...data, + ]; + const expected = [ + 'v1beta', // Scanning; Show loading indicator in UI. + 'v1', // Unsupported + 'v2', // Queued; + 'v4', // Passed + 'v3', // Medium: 1; No High, but has Medium and Low + 'stable', // High: 2, Medium: 2, Low: 1 ; High value + 'latest-linux-arm64', // High: 3, Medium: 2, Low: 1 ; High value + ]; + + // Scanning row should not change the order + const names = mockData + .sort((a, b) => securityScanComparator(a, b, 'desc')) + .map(tag => tag.name); + expect(names).toEqual(expected); + + const mockData1 = [ + data[0], // v1 + { + ...tagArray[0], + name: 'v1beta', + securityStatus: 'scanning', + }, + data[1], // v2 + ]; + const expectedNames = [ + 'v1', // Unsupported + 'v1beta', // Scanning; Show loading indicator in UI. + 'v2', // Queued; + ]; + + const tagNames = mockData1 + .sort((a, b) => securityScanComparator(a, b, 'desc')) + .map(tag => tag.name); + expect(tagNames).toEqual(expectedNames); + }); +}); diff --git a/plugins/quay/src/lib/utils.ts b/plugins/quay/src/lib/utils.ts index d4a0d4e465..fd80e37256 100644 --- a/plugins/quay/src/lib/utils.ts +++ b/plugins/quay/src/lib/utils.ts @@ -1,4 +1,9 @@ -import { Layer, VulnerabilityOrder, VulnerabilitySeverity } from '../types'; +import { + Layer, + QuayTagData, + VulnerabilityOrder, + VulnerabilitySeverity, +} from '../types'; export const SEVERITY_COLORS = new Proxy( new Map([ @@ -38,3 +43,87 @@ export const vulnerabilitySummary = (layer: Layer): string => { .join(', '); return scanResults.trim() !== '' ? scanResults : 'Passed'; }; + +const securityScanOrder = [ + 'High', + 'Medium', + 'Low', + 'Passed', + 'Scanning', + 'Queued', + 'Unscanned', + 'Unsupported', +]; + +export const capitalizeFirstLetter = (s: string): string => { + return s.charAt(0).toUpperCase() + s.slice(1); +}; + +export const securityScanComparator = ( + ar: QuayTagData, + br: QuayTagData, + order: 'asc' | 'desc' = 'desc', +) => { + const a = vulnerabilitySummary(ar.securityDetails); + const b = vulnerabilitySummary(br.securityDetails); + + const parseScan = (scan: string) => { + const values: { [key: string]: number } = { + High: 0, + Medium: 0, + Low: 0, + }; + scan.split(', ').forEach((part: string) => { + const [key, value] = part.split(': '); + if (values[key] !== undefined) { + values[key] = parseInt(value, 10); + } + }); + return values; + }; + + const aParts = a.split(', '); + const bParts = b.split(', '); + + const multiplier = order === 'asc' ? 1 : -1; + + if ( + aParts.length >= 1 && + bParts.length >= 1 && + aParts[0] !== 'Passed' && + bParts[0] !== 'Passed' + ) { + const aParsed = parseScan(a); + const bParsed = parseScan(b); + + if (aParsed.High !== bParsed.High) { + return (bParsed.High - aParsed.High) * multiplier; + } + if (aParsed.Medium !== bParsed.Medium) { + return (bParsed.Medium - aParsed.Medium) * multiplier; + } + if (aParsed.Low !== bParsed.Low) { + return (bParsed.Low - aParsed.Low) * multiplier; + } + } + + const finalAValue = capitalizeFirstLetter( + ar.securityStatus === 'scanned' + ? aParts[0].split(':')[0] + : (ar.securityStatus ?? 'scanning'), + ); + + const finalBValue = capitalizeFirstLetter( + br.securityStatus === 'scanned' + ? bParts[0].split(':')[0] + : (br.securityStatus ?? 'scanning'), + ); + + if (finalAValue === 'Scanning' || finalBValue === 'Scanning') return 1; + + return ( + (securityScanOrder.indexOf(finalAValue) - + securityScanOrder.indexOf(finalBValue)) * + multiplier + ); +}; diff --git a/plugins/quay/src/types.ts b/plugins/quay/src/types.ts index 936c125e31..d9f3b56eb4 100644 --- a/plugins/quay/src/types.ts +++ b/plugins/quay/src/types.ts @@ -64,7 +64,7 @@ export interface Platform { } export interface SecurityDetailsResponse { - status: 'unsupported' | 'unscanned' | 'scanning' | 'scanned'; + status: 'unsupported' | 'unscanned' | 'scanning' | 'scanned' | 'queued'; data: Data | null; } export interface Data { diff --git a/plugins/quay/tests/quay.spec.ts b/plugins/quay/tests/quay.spec.ts index abd5431af0..c33f2dd2c8 100644 --- a/plugins/quay/tests/quay.spec.ts +++ b/plugins/quay/tests/quay.spec.ts @@ -40,7 +40,10 @@ test.describe('Quay plugin', () => { test('Vulnerabilities are listed', async () => { const severity = ['High:', 'Medium:', 'Low:']; for (const lvl of severity) { - await expect(page.getByRole('link', { name: lvl })).toBeVisible(); + const tagWithAllVulnerabilities = await page.getByTestId( + 'latest-linux-arm64-security-scan', + ); + await expect(tagWithAllVulnerabilities).toContainText(lvl); } });