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