Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ import {
SYSDIG_IMAGE_FREETEXT_ANNOTATION,

// methods
getStatusColorSpan,
getPolicyEvaluation,
getTitleWithBacklink,
getChips,
compareSeverities,
getBacklink
} from '../../lib'
import { sysdigApiRef } from '../../api';
Expand Down Expand Up @@ -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: <code>{scan.imageId}</code>,
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {

// methods
getChips,
compareSeverities,
getTitleWithBacklink,
getBacklink
} from '../../lib';
Expand Down Expand Up @@ -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: <code>{scan.imageId}</code>,
asset: scan.pullString,
severity: getChips(scan.vulnTotalBySeverity)
severity: scan.vulnTotalBySeverity
};
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<TestApiProvider apis={[
[sysdigApiRef, apiWithData],
[configApiRef, mockConfig],
]}>
<EntityProvider entity={mockEntity}>
<SysdigVMRuntimeFetchComponent />
</EntityProvider>
</TestApiProvider>
);

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(
<TestApiProvider apis={[
[sysdigApiRef, apiWithData],
[configApiRef, mockConfig],
]}>
<EntityProvider entity={mockEntity}>
<SysdigVMRuntimeFetchComponent />
</EntityProvider>
</TestApiProvider>
);

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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -110,33 +118,49 @@ 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),
// https://prodmon.app.sysdig.com/secure/#/scanning/scan-results/quay.io%2Fsysdig%2Fsysdigcloud-backend%3A5.1.0.10598-sysdig-meerkat-collector/id/497c07ec287acc1800dc84a91ac1260e910c603cabc8febd754b909f406a6e26/summaries
// 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 (
<Table
Expand Down
6 changes: 6 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ export {

export {
getStatusColorSpan,
getPolicyEvaluation,
getLifecycle,
getException,
getChips,
IN_USE_SEVERITIES,
compareSeverities,
getDetails,
getDate,
getUrl,
Expand All @@ -45,6 +50,7 @@ export {
getPassed,
getResourceName,
getTitleWithBacklink,
getHeaderWithTooltip,
urlEncode
} from './ui'

Expand Down
37 changes: 37 additions & 0 deletions src/lib/ui.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import { compareSeverities, getChips, getLifecycle, IN_USE_SEVERITIES } from './ui';

const counts = (critical: number, high = 0, medium = 0, low = 0, negligible = 0) =>
({ 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();
});
});
Loading
Loading