diff --git a/package.json b/package.json index 8f9a868..e7b3ddd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sysdig/backstage-plugin-sysdig", - "version": "1.5.4", + "version": "1.6.0", "main": "dist/index.esm.js", "types": "dist/index.d.ts", "license": "Apache-2.0", @@ -35,7 +35,8 @@ "@types/react-dom": "^18.0.0", "@testing-library/react": "^16.0.0", "adm-zip": "^0.6.1", - "js-cookie": "^3.0.6" + "js-cookie": "^3.0.6", + "undici": "^7.29.1" }, "dependencies": { "@backstage/core-components": "^0.18.14", diff --git a/src/components/SysdigVMPipelineFetchComponent/SysdigVMPipelineFetchComponent.test.tsx b/src/components/SysdigVMPipelineFetchComponent/SysdigVMPipelineFetchComponent.test.tsx index eff5547..e22ac58 100644 --- a/src/components/SysdigVMPipelineFetchComponent/SysdigVMPipelineFetchComponent.test.tsx +++ b/src/components/SysdigVMPipelineFetchComponent/SysdigVMPipelineFetchComponent.test.tsx @@ -120,7 +120,7 @@ describe('SysdigVMPipelineFetchComponent', () => { ); expect(await screen.findByText('ghcr.io/sysdiglabs/sample-app:latest')).toBeInTheDocument(); - expect(screen.getByText('failed')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); }); it('filters out rows with null policyEvaluationResult', async () => { diff --git a/src/components/SysdigVMPipelineFetchComponent/SysdigVMPipelineFetchComponent.tsx b/src/components/SysdigVMPipelineFetchComponent/SysdigVMPipelineFetchComponent.tsx index d2e0b2e..4cd4ce9 100644 --- a/src/components/SysdigVMPipelineFetchComponent/SysdigVMPipelineFetchComponent.tsx +++ b/src/components/SysdigVMPipelineFetchComponent/SysdigVMPipelineFetchComponent.tsx @@ -25,9 +25,10 @@ import { SYSDIG_IMAGE_FREETEXT_ANNOTATION, // methods - getStatusColorSpan, + getPolicyEvaluation, getTitleWithBacklink, getChips, + compareSeverities, getBacklink } from '../../lib' import { sysdigApiRef } from '../../api'; @@ -72,23 +73,25 @@ type DenseTableProps = { ... */ -export const DenseTable = ({ pipelineScans, title }: DenseTableProps) => { - const columns: TableColumn[] = [ - { title: 'Status', field: 'policyEvalStatus', width: "2%" }, - { title: 'Image ID', field: 'imageId', width: "23%" }, - { title: 'Asset Name', field: 'asset', width: "35%" }, - { title: 'Vulnerabilities', field: 'vulns', width: "35%" }, +// defined once: material-table resets its sort state when column definitions change between renders +const columns: TableColumn[] = [ + { title: 'Asset Name', field: 'asset', width: "40%" }, + { title: 'Image ID', field: 'imageId', width: "25%" }, + { title: 'Vulnerabilities', field: 'vulns', width: "25%", render: (row: any) => getChips(row.vulns), customSort: (a: any, b: any) => compareSeverities(a.vulns, b.vulns) }, + { title: 'Policy Evaluation', field: 'policyEvalStatus', width: "10%", render: (row: any) => getPolicyEvaluation(row.policyEvalStatus) }, // { title: 'Last Evaluated At', field: 'lastEvaluatedAt', width: "15%" }, // { title: 'URL', field: "url", width: "10%" }, - ]; +]; + +export const DenseTable = ({ pipelineScans, title }: DenseTableProps) => { const data = pipelineScans.filter(scan => { return scan.policyEvaluationResult !== null && scan.policyEvaluationResult !== '' }) .flatMap(scan => { return { - policyEvalStatus: getStatusColorSpan(scan.policyEvaluationResult), + policyEvalStatus: scan.policyEvaluationResult, imageId: {scan.imageId}, asset: scan.pullString, - vulns: getChips(scan.vulnTotalBySeverity), + vulns: scan.vulnTotalBySeverity, // convert image.lastEvaluatedAt to a date string // lastEvaluatedAt: getDate(image.lastEvaluatedAt * 1000), // https://prodmon.app.sysdig.com/secure/#/scanning/scan-results/quay.io%2Fsysdig%2Fsysdigcloud-backend%3A5.1.0.10598-sysdig-meerkat-collector/id/497c07ec287acc1800dc84a91ac1260e910c603cabc8febd754b909f406a6e26/summaries diff --git a/src/components/SysdigVMRegistryFetchComponent/SysdigVMRegistryFetchComponent.tsx b/src/components/SysdigVMRegistryFetchComponent/SysdigVMRegistryFetchComponent.tsx index d975b8c..4e42226 100644 --- a/src/components/SysdigVMRegistryFetchComponent/SysdigVMRegistryFetchComponent.tsx +++ b/src/components/SysdigVMRegistryFetchComponent/SysdigVMRegistryFetchComponent.tsx @@ -28,6 +28,7 @@ import { // methods getChips, + compareSeverities, getTitleWithBacklink, getBacklink } from '../../lib'; @@ -70,19 +71,21 @@ type DenseTableProps = { ... */ +// defined once: material-table resets its sort state when column definitions change between renders +const columns: TableColumn[] = [ + { title: 'Asset Name', field: 'asset', width: "45%" }, + { title: 'Image ID', field: 'imageId', width: "25%" }, + { title: 'Vulnerabilities', field: 'severity', width: "30%", render: (row: any) => getChips(row.severity), customSort: (a: any, b: any) => compareSeverities(a.severity, b.severity) }, +]; + export const DenseTable = ({ registryScans, title }: DenseTableProps) => { - const columns: TableColumn[] = [ - { title: 'Image ID', field: 'imageId', width: "20%" }, - { title: 'Asset Name', field: 'asset', width: "35%" }, - { title: 'Severity', field: 'severity', width: "30%" }, - ]; const data = registryScans.filter(scan => { return scan.imageId !== '' }) .flatMap(scan => { return { imageId: {scan.imageId}, asset: scan.pullString, - severity: getChips(scan.vulnTotalBySeverity) + severity: scan.vulnTotalBySeverity }; }); diff --git a/src/components/SysdigVMRuntimeFetchComponent/SysdigVMRuntimeFetchComponent.test.tsx b/src/components/SysdigVMRuntimeFetchComponent/SysdigVMRuntimeFetchComponent.test.tsx index 03d9e51..a551eaf 100644 --- a/src/components/SysdigVMRuntimeFetchComponent/SysdigVMRuntimeFetchComponent.test.tsx +++ b/src/components/SysdigVMRuntimeFetchComponent/SysdigVMRuntimeFetchComponent.test.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { screen } from '@testing-library/react'; +import { screen, fireEvent, within } from '@testing-library/react'; import { SysdigVMRuntimeFetchComponent } from './SysdigVMRuntimeFetchComponent'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; @@ -131,7 +131,61 @@ describe('SysdigVMRuntimeFetchComponent', () => { ); expect(await screen.findByText('nginx:latest')).toBeInTheDocument(); - expect(screen.getByText('failed')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('test-cluster')).toBeInTheDocument(); + expect(screen.getByText('test-namespace')).toBeInTheDocument(); + + fireEvent.mouseOver(screen.getByText('Policy Evaluation')); + expect(await screen.findByText(/Failed = at least one policy rule was violated/)).toBeInTheDocument(); + }); + + it('shows accepted results as passed with an exception, and past EOL dates', async () => { + const acceptedScan = { ...mockRuntimeScanV1, policyEvaluationResult: 'accepted', endOfLifeDate: '2018-05-01T00:00:00Z' }; + const apiWithData = { + ...mockSysdigApi, + fetchVulnRuntime: jest.fn().mockResolvedValue({ data: [acceptedScan] }), + }; + + await renderInTestApp( + + + + + + ); + + expect(await screen.findByText('Passed')).toBeInTheDocument(); + expect(screen.getByText('EOL')).toBeInTheDocument(); + expect(screen.getByLabelText(/Risk accepted/)).toBeInTheDocument(); + }); + + it('falls back to region when scope has no kubernetes cluster', async () => { + const hostScan = { + ...mockRuntimeScanV1, + scope: { 'asset.type': 'host', 'cloudProvider.region': 'phx' }, + }; + const apiWithData = { + ...mockSysdigApi, + fetchVulnRuntime: jest.fn().mockResolvedValue({ data: [hostScan] }), + }; + + await renderInTestApp( + + + + + + ); + + const row = (await screen.findByText('phx')).closest('tr')!; + // columns: Asset Name, Cluster, Namespace, ... + expect(within(row).getAllByRole('cell')[2]).toHaveTextContent(/^-$/); }); it('filters out rows with null policyEvaluationResult', async () => { diff --git a/src/components/SysdigVMRuntimeFetchComponent/SysdigVMRuntimeFetchComponent.tsx b/src/components/SysdigVMRuntimeFetchComponent/SysdigVMRuntimeFetchComponent.tsx index 6eafd69..cd7e2b1 100644 --- a/src/components/SysdigVMRuntimeFetchComponent/SysdigVMRuntimeFetchComponent.tsx +++ b/src/components/SysdigVMRuntimeFetchComponent/SysdigVMRuntimeFetchComponent.tsx @@ -30,16 +30,22 @@ import { SYSDIG_CUSTOM_FILTER_ANNOTATION, // methods - getStatusColorSpan, + getPolicyEvaluation, + getLifecycle, + getException, getChips, + IN_USE_SEVERITIES, + compareSeverities, getDetails, getTitleWithBacklink, + getHeaderWithTooltip, getBacklink } from '../../lib' import { sysdigApiRef } from '../../api'; type RuntimeScan = { + endOfLifeDate?: string, isRiskSpotlightEnabled: boolean, mainAssetName: string, policyEvaluationResult: string, @@ -55,11 +61,13 @@ type RuntimeScan = { sbomId: string, scope: { "asset.type": string, - "kubernetes.cluster.name": string, - "kubernetes.namespace.name": string, - "kubernetes.pod.container.name": string, - "kubernetes.workload.name": string, - "kubernetes.workload.type": string + "kubernetes.cluster.name"?: string, + "kubernetes.namespace.name"?: string, + "kubernetes.pod.container.name"?: string, + "kubernetes.workload.name"?: string, + "kubernetes.workload.type"?: string, + "agent.tag.cluster"?: string, + "cloudProvider.region"?: string }, vulnTotalBySeverity: { critical: number, @@ -110,25 +118,39 @@ type DenseTableProps = { ... */ -export const DenseTable = ({ runtimeScans, title }: DenseTableProps) => { - const columns: TableColumn[] = [ - { title: 'Status', field: 'policyEvalStatus', width: "2%" }, - { title: 'Asset Name', field: 'asset', width: "18%" }, - { title: 'Severity', field: 'severity', width: "35%" }, - { title: 'In Use', field: 'inUse', width: "35%" }, - { title: 'Details', field: 'details', width: "10%" }, +// Same image can run in several clusters/namespaces, each with its own in-use vulns +const getLocation = (scope: RuntimeScan['scope'] | undefined) => ({ + cluster: scope?.["kubernetes.cluster.name"] ?? scope?.["agent.tag.cluster"] ?? scope?.["cloudProvider.region"] ?? '-', + namespace: scope?.["kubernetes.namespace.name"] ?? '-', +}); + +// defined once: material-table resets its sort state when column definitions change between renders. +// no defaultSort: in material-table 3.x it breaks the sort cycle of other columns, data is pre-sorted instead +const columns: TableColumn[] = [ + { title: 'Asset Name', field: 'asset', width: "23%" }, + { title: 'Cluster', field: 'cluster', width: "12%" }, + { title: 'Namespace', field: 'namespace', width: "12%" }, + { title: getHeaderWithTooltip('In Use', 'Only vulnerabilities in packages loaded in memory at runtime.'), field: 'inUse', width: "14%", render: (row: any) => getChips(row.inUse, IN_USE_SEVERITIES), customSort: (a: any, b: any) => compareSeverities(a.inUse, b.inUse) }, + { title: getHeaderWithTooltip('Vulnerabilities', 'All vulnerabilities found in the image.'), field: 'severity', width: "14%", render: (row: any) => getChips(row.severity), customSort: (a: any, b: any) => compareSeverities(a.severity, b.severity) }, + { title: getHeaderWithTooltip('Policy Evaluation', 'Result of the vulnerability policy evaluation. Failed = at least one policy rule was violated.'), field: 'policyEvalStatus', width: "9%", render: (row: any) => getPolicyEvaluation(row.policyEvalStatus) }, + { title: getHeaderWithTooltip('Component Lifecycle', 'Active = still supported. EOL = the image base OS or runtime is past its end-of-life date and no longer receives security fixes.'), field: 'endOfLifeDate', width: "7%", render: (row: any) => getLifecycle(row.endOfLifeDate), customSort: (a: any, b: any) => (a.endOfLifeDate ?? '9999').localeCompare(b.endOfLifeDate ?? '9999') }, + { title: 'Exceptions', field: 'exception', width: "5%", render: (row: any) => getException(row.policyEvalStatus), customSort: (a: any, b: any) => Number(a.policyEvalStatus === 'accepted') - Number(b.policyEvalStatus === 'accepted') }, + { title: 'Details', field: 'details', width: "4%", sorting: false }, // { title: 'Last Evaluated At', field: 'lastEvaluatedAt', width: "15%" }, // { title: 'URL', field: "url", width: "10%" }, - ]; +]; + +export const DenseTable = ({ runtimeScans, title }: DenseTableProps) => { const data = runtimeScans.filter(scan => { return scan.policyEvaluationResult !== null && scan.policyEvaluationResult !== '' }) .flatMap(scan => { return { - policyEvalStatus: getStatusColorSpan(scan.policyEvaluationResult), + policyEvalStatus: scan.policyEvaluationResult, + endOfLifeDate: scan.endOfLifeDate, asset: scan.mainAssetName, -// scope: JSON.stringify(scan.scope), - severity: getChips(scan.vulnTotalBySeverity), - inUse: getChips(scan.runningVulnTotalBySeverity), + ...getLocation(scan.scope), + severity: scan.vulnTotalBySeverity, + inUse: scan.runningVulnTotalBySeverity, details: getDetails(scan) // convert image.lastEvaluatedAt to a date string // lastEvaluatedAt: getDate(image.lastEvaluatedAt * 1000), @@ -136,7 +158,9 @@ export const DenseTable = ({ runtimeScans, title }: DenseTableProps) => { // url: getUrl('https://prodmon.app.sysdig.com/api/scanning/v1/images/by_id/' + image.imageId + '?fulltag=' + image.repo + ':' + image.tag), // url: getUrl('https://prodmon.app.sysdig.com/secure/#/scanning/scan-results/' + urlEncode(image.repo + ':' + image.tag) +' /id/' + image.imageId + '/summaries'), }; - }); + }) + // same initial order as Sysdig Secure: most in-use vulnerabilities first + .sort((a, b) => compareSeverities(b.inUse, a.inUse)); return ( + ({ critical, high, medium, low, negligible }); + +describe('compareSeverities', () => { + it('orders by critical first, then lower severities as tie-breakers', () => { + const sorted = [counts(0, 50), counts(2, 0), counts(2, 5), counts(0, 50, 1)].sort(compareSeverities); + expect(sorted).toEqual([counts(0, 50), counts(0, 50, 1), counts(2, 0), counts(2, 5)]); + }); + + it('sorts missing data below any counts', () => { + expect(compareSeverities(undefined, counts(0))).toBeLessThan(0); + expect(compareSeverities(counts(0), undefined)).toBeGreaterThan(0); + }); +}); + +describe('getLifecycle', () => { + it('shows EOL for past dates, Active for future ones, nothing when unknown', () => { + const { rerender, container } = render(<>{getLifecycle('2018-05-01T00:00:00Z')}); + expect(screen.getByText('EOL')).toBeInTheDocument(); + rerender(<>{getLifecycle('2999-01-01T00:00:00Z')}); + expect(screen.getByText('Active')).toBeInTheDocument(); + rerender(<>{getLifecycle(undefined)}); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('getChips', () => { + it('only renders the requested severities', () => { + render(<>{getChips(counts(2, 17, 22, 4, 16), IN_USE_SEVERITIES)}); + expect(screen.getByText('22')).toBeInTheDocument(); + expect(screen.queryByText('4')).not.toBeInTheDocument(); + expect(screen.queryByText('16')).not.toBeInTheDocument(); + }); +}); diff --git a/src/lib/ui.tsx b/src/lib/ui.tsx index 7b00b43..4d4a9a7 100644 --- a/src/lib/ui.tsx +++ b/src/lib/ui.tsx @@ -1,3 +1,4 @@ +import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; @@ -5,34 +6,118 @@ import InfoIcon from '@mui/icons-material/Info'; import Typography from '@mui/material/Typography'; import { Gauge } from '@backstage/core-components'; import OpenInNew from '@mui/icons-material/OpenInNew'; +import InfoOutlined from '@mui/icons-material/InfoOutlined'; +import HighlightOff from '@mui/icons-material/HighlightOff'; +import CheckCircleOutline from '@mui/icons-material/CheckCircleOutline'; +import WbTwilight from '@mui/icons-material/WbTwilight'; +import GppGoodOutlined from '@mui/icons-material/GppGoodOutlined'; const interleave = (arr: any, thing: any) => [].concat(...arr.map((n: any) => [n, thing])).slice(0, -1) const getStatusColorSpan = (status: string) => { - let hStyle; + // theme palette colors adapt to light/dark themes + let color; if (status === 'failed' || status === 'false') { - hStyle = { color: 'red' }; + color = 'error.main'; } else if (status === 'passed' || status === 'true') { - hStyle = { color: 'green' }; + color = 'success.main'; } else { - hStyle = { color: 'gray' }; + color = 'text.secondary'; } - return {status}; + return {status}; }; -function getChips(severities: any) { - if (!severities) { return } +const badgeStyle = { display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 4, fontSize: 13, whiteSpace: 'nowrap' as const }; +const iconStyle = { fontSize: 16 }; + +// policy evaluation badge (like Sysdig Secure); "accepted" means an exception made the result pass +const getPolicyEvaluation = (status: string) => { + if (status === 'failed') { + return Failed; + } + if (status === 'passed' || status === 'accepted') { + return Passed; + } + return {status}; +}; + +// component lifecycle badge: EOL once past the end-of-life date, Active before it, nothing if unknown +const getLifecycle = (endOfLifeDate?: string) => { + if (!endOfLifeDate) return null; + const date = endOfLifeDate.slice(0, 10); + if (new Date(endOfLifeDate) <= new Date()) { + return ( + + EOL + + ); + } + return ( + + Active + + ); +}; + +const getException = (status: string) => { + if (status !== 'accepted') return null; + return ( + + + + ); +}; + +// fixed text color so chips stay readable in both light and dark themes +const chipStyle = (backgroundColor: string) => ({ backgroundColor, color: 'black' }); + +const SEVERITIES = [ + { key: 'critical', label: 'Critical', bg: '#A13CC4', fg: 'white' }, + { key: 'high', label: 'High', bg: '#D32F2F', fg: 'white' }, + { key: 'medium', label: 'Medium', bg: '#F28C28', fg: 'black' }, + { key: 'low', label: 'Low', bg: '#F5C518', fg: 'black' }, + { key: 'negligible', label: 'Negligible', bg: '#9E9E9E', fg: 'black' }, +]; + +const segmentStyle = { minWidth: 36, padding: '2px 6px', fontSize: 12, fontWeight: 600, textAlign: 'center' as const, whiteSpace: 'nowrap' as const }; + +// compact severity bar (like Sysdig Secure): counts only, severity name on hover, "-" for zero +// Sysdig Secure only shows critical, high and medium for in-use vulnerabilities +const IN_USE_SEVERITIES = ['critical', 'high', 'medium']; + +function getChips(severities: any, keys: string[] = SEVERITIES.map(sev => sev.key)) { + if (!severities) { + return ( + + N/A + + ); + } return ( - - - - - - + + {SEVERITIES.filter(sev => keys.includes(sev.key)).map(({ key, label, bg, fg }) => { + const count = severities[key] ?? 0; + return ( + + + {count || '-'} + + + ); + })} ); } +// sort by most severe first: critical, then high, medium, low, negligible +const compareSeverities = (a: any, b: any) => { + for (const { key } of SEVERITIES) { + const diff = (a?.[key] ?? -1) - (b?.[key] ?? -1); + if (diff !== 0) return diff; + } + return 0; +}; + function getScope(scope: any) { const textScope = []; for (const key in scope) { @@ -92,7 +177,7 @@ function truncate(str: string, n: number){ function getFailed(failed: any) { const result: JSX.Element[] = []; for (const policy of failed.filter((f: { [x: string]: any; }) => !f.pass)) { - result.push(); + result.push(); } return result; } @@ -100,7 +185,7 @@ function getFailed(failed: any) { function getPassed(passed: any) { const result: JSX.Element[] = []; for (const policy of passed.filter((f: { [x: string]: any; }) => f.pass)) { - result.push(); + result.push(); } return result; } @@ -160,6 +245,16 @@ const getUrl = (url: string) => { return Link to Scan Result; }; +// column header with an explanatory tooltip +const getHeaderWithTooltip = (title: string, help: string) => ( + + + {title} + + + +); + // URL encode a string const urlEncode = (str: string) => { return encodeURIComponent(str); @@ -167,7 +262,12 @@ const urlEncode = (str: string) => { export { getStatusColorSpan, + getPolicyEvaluation, + getLifecycle, + getException, getChips, + IN_USE_SEVERITIES, + compareSeverities, getDetails, getDate, getUrl, @@ -177,5 +277,6 @@ export { getPassed, getResourceName, getTitleWithBacklink, + getHeaderWithTooltip, urlEncode }; diff --git a/yarn.lock b/yarn.lock index 860c066..0879d2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20921,10 +20921,10 @@ __metadata: languageName: node linkType: hard -"undici@npm:7.29.0": - version: 7.29.0 - resolution: "undici@npm:7.29.0" - checksum: 10c0/85ea96e91e7f3de24de678cbbc54230fdd641a3b0643005f2aef044af1f1a2db8fbecb88b28e6fa78bb41ffeafda4479b57075b6b6ab92beac4b7b5ab53f9b10 +"undici@npm:^7.29.1": + version: 7.30.0 + resolution: "undici@npm:7.30.0" + checksum: 10c0/011a321a9198532b0f0504740faa43b9ab8fcc0e4f60104e5c9cb808a61faeb03e4be4aec4f0c65f47913d7c7d9d91591793a6cb9ab843f98ca2c3d711c4a34c languageName: node linkType: hard