From 568a65c10d246a0ecb8761cf7d096fad55515f21 Mon Sep 17 00:00:00 2001 From: Andreea-Lupu Date: Tue, 12 Dec 2023 16:19:11 +0200 Subject: [PATCH] feat: export vulnerabilities list Signed-off-by: Andreea-Lupu --- package-lock.json | 103 ++++++++++++- package.json | 8 +- .../TagPage/VulnerabilitiesDetails.test.js | 28 ++++ src/api.js | 2 + .../Tag/Tabs/VulnerabilitiesDetails.jsx | 142 +++++++++++++++++- src/utilities/objectModels.js | 20 ++- 6 files changed, 293 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7728a198..226762f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@testing-library/user-event": "^13.5.0", "axios": "^0.24.0", "downshift": "^6.1.12", + "export-from-json": "^1.7.3", "lodash": "^4.17.21", "luxon": "^2.5.2", "markdown-to-jsx": "^7.1.7", @@ -26,7 +27,8 @@ "react-dom": "^17.0.2", "react-router-dom": "^6.2.1", "react-sticky-el": "^2.0.9", - "web-vitals": "^2.1.3" + "web-vitals": "^2.1.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.16.7", @@ -5592,6 +5594,14 @@ "node": ">=8.9" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -6547,6 +6557,18 @@ "node": ">=4" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6780,6 +6802,14 @@ "node": ">=4" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", @@ -7030,6 +7060,17 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -8926,6 +8967,11 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/export-from-json": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/export-from-json/-/export-from-json-1.7.3.tgz", + "integrity": "sha512-Xg0L0saYz+CBz2MnaZvSEAHr17hWtHAfFWXw/frllG9t6aijuQukiU40ElOeM9nDTrtQPhLJMLN0q8lo897FYg==" + }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -9423,6 +9469,14 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", @@ -16997,6 +17051,17 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -18746,6 +18811,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", @@ -19133,6 +19214,26 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index e8f008a4..aa2a0ddd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@testing-library/user-event": "^13.5.0", "axios": "^0.24.0", "downshift": "^6.1.12", + "export-from-json": "^1.7.3", "lodash": "^4.17.21", "luxon": "^2.5.2", "markdown-to-jsx": "^7.1.7", @@ -21,17 +22,18 @@ "react-dom": "^17.0.2", "react-router-dom": "^6.2.1", "react-sticky-el": "^2.0.9", - "web-vitals": "^2.1.3" + "web-vitals": "^2.1.3", + "xlsx": "^0.18.5" }, "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.16.7", "@playwright/test": "^1.28.1", "eslint": "^8.23.1", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.31.8", "prettier": "^2.7.1", - "react-scripts": "^5.0.1", - "@babel/plugin-proposal-private-property-in-object": "^7.16.7" + "react-scripts": "^5.0.1" }, "scripts": { "start": "react-scripts start", diff --git a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js index 8efbce6c..13d3cdff 100644 --- a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js +++ b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js @@ -6,6 +6,8 @@ import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +jest.mock('xlsx'); + const StateVulnerabilitiesWrapper = () => { return ( @@ -558,6 +560,32 @@ describe('Vulnerabilties page', () => { expect(await screen.findByText('latest')).toBeInTheDocument(); }); + it('should allow export of vulnerabilities list', async () => { + const xlsxMock = jest.createMockFromModule('xlsx'); + xlsxMock.writeFile = jest.fn(); + + jest + .spyOn(api, 'get') + .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) + .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); + render(); + await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); + const downloadBtn = await screen.findAllByTestId('DownloadIcon'); + fireEvent.click(downloadBtn[0]); + expect(await screen.findByTestId('export-csv-menuItem')).toBeInTheDocument(); + expect(await screen.findByTestId('export-excel-menuItem')).toBeInTheDocument(); + const exportAsCSVBtn = screen.getByText(/csv/i); + expect(exportAsCSVBtn).toBeInTheDocument(); + global.URL.createObjectURL = jest.fn(); + await fireEvent.click(exportAsCSVBtn); + expect(await screen.findByTestId('export-csv-menuItem')).not.toBeInTheDocument(); + fireEvent.click(downloadBtn[0]); + const exportAsExcelBtn = screen.getByText(/MS Excel/i); + expect(exportAsExcelBtn).toBeInTheDocument(); + await fireEvent.click(exportAsExcelBtn); + expect(await screen.findByTestId('export-excel-menuItem')).not.toBeInTheDocument(); + }); + it('should handle fixed CVE query errors', async () => { jest .spyOn(api, 'get') diff --git a/src/api.js b/src/api.js index 8d367389..0596edf9 100644 --- a/src/api.js +++ b/src/api.js @@ -99,6 +99,8 @@ const endpoints = { } return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`; }, + allVulnerabilitiesForRepo: (name) => + `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`, imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }, filter = {}) => { let filterParam = ''; if (filter.Os || filter.Arch) { diff --git a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx index aa05ec86..1872f6a0 100644 --- a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx +++ b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx @@ -4,14 +4,28 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; import { api, endpoints } from '../../../api'; // components -import { Stack, Typography, InputBase } from '@mui/material'; +import { + IconButton, + Stack, + Typography, + InputBase, + Menu, + MenuItem, + Divider, + Snackbar, + CircularProgress +} from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; import { host } from '../../../host'; import { debounce, isEmpty } from 'lodash'; import Loading from '../../Shared/Loading'; -import { mapCVEInfo } from 'utilities/objectModels'; +import { mapCVEInfo, mapAllCVEInfo } from 'utilities/objectModels'; import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants'; import SearchIcon from '@mui/icons-material/Search'; +import DownloadIcon from '@mui/icons-material/Download'; + +import * as XLSX from 'xlsx'; +import exportFromJSON from 'export-from-json'; import VulnerabilitiyCard from '../../Shared/VulnerabilityCard'; @@ -40,6 +54,13 @@ const useStyles = makeStyles((theme) => ({ fontSize: '1.4rem', fontWeight: '600' }, + vulnerabilities: { + position: 'relative', + maxWidth: '100%', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + }, search: { position: 'relative', maxWidth: '100%', @@ -65,13 +86,25 @@ const useStyles = makeStyles((theme) => ({ '&::placeholder': { opacity: '1' } + }, + export: { + alignContent: 'right' + }, + popper: { + width: '100%', + overflow: 'hidden', + padding: '0.3rem', + display: 'flex', + justifyContent: 'center' } })); function VulnerabilitiesDetails(props) { const classes = useStyles(); const [cveData, setCveData] = useState([]); + const [allCveData, setAllCveData] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isLoadingAllCve, setIsLoadingAllCve] = useState(true); const abortController = useMemo(() => new AbortController(), []); const { name, tag, digest, platform } = props; @@ -81,6 +114,9 @@ function VulnerabilitiesDetails(props) { const [isEndOfList, setIsEndOfList] = useState(false); const listBottom = useRef(null); + const [anchorExport, setAnchorExport] = useState(null); + const openExport = Boolean(anchorExport); + const getCVERequestName = () => { return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`; }; @@ -114,6 +150,24 @@ function VulnerabilitiesDetails(props) { }); }; + const getAllCVEs = () => { + api + .get(`${host()}${endpoints.allVulnerabilitiesForRepo(getCVERequestName())}`, abortController.signal) + .then((response) => { + if (response.data && response.data.data) { + const cveInfo = response.data.data.CVEListForImage?.CVEList; + const cveListData = mapAllCVEInfo(cveInfo); + setAllCveData(cveListData); + } + setIsLoadingAllCve(false); + }) + .catch((e) => { + console.error(e); + setAllCveData([]); + setIsLoadingAllCve(false); + }); + }; + const resetPagination = () => { setIsLoading(true); setIsEndOfList(false); @@ -124,11 +178,39 @@ function VulnerabilitiesDetails(props) { } }; + const handleOnExportExcel = () => { + const wb = XLSX.utils.book_new(), + ws = XLSX.utils.json_to_sheet(allCveData); + + XLSX.utils.book_append_sheet(wb, ws, name + '_' + tag); + + XLSX.writeFile(wb, `${name}:${tag}-vulnerabilities.xlsx`); + + handleCloseExport(); + }; + + const handleOnExportCSV = () => { + const fileName = `${name}:${tag}-vulnerabilities`; + const exportType = exportFromJSON.types.csv; + + exportFromJSON({ data: allCveData, fileName, exportType }); + + handleCloseExport(); + }; + const handleCveFilterChange = (e) => { const { value } = e.target; setCveFilter(value); }; + const handleClickExport = (event) => { + setAnchorExport(event.currentTarget); + }; + + const handleCloseExport = () => { + setAnchorExport(null); + }; + const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300)); useEffect(() => { @@ -172,6 +254,12 @@ function VulnerabilitiesDetails(props) { }; }, []); + useEffect(() => { + if (openExport && isEmpty(allCveData)) { + getAllCVEs(); + } + }, [openExport]); + const renderCVEs = () => { return !isEmpty(cveData) ? ( cveData.map((cve, index) => { @@ -194,9 +282,53 @@ function VulnerabilitiesDetails(props) { return ( - - Vulnerabilities - + + + Vulnerabilities + + + + + } + /> + + + csv + + + + MS Excel + + + { return cveList; }; +const mapAllCVEInfo = (cveInfo) => { + const cveList = cveInfo.flatMap((cve) => { + return cve.PackageList.map((packageInfo) => { + return { + id: cve.Id, + severity: cve.Severity, + title: cve.Title, + description: cve.Description, + reference: cve.Reference, + packageName: packageInfo.Name, + packageInstalledVersion: packageInfo.InstalledVersion, + packageFixedVersion: packageInfo.FixedVersion + }; + }); + }); + return cveList; +}; + const mapSignatureInfo = (signatureInfo) => { return signatureInfo ? { @@ -124,4 +142,4 @@ const mapReferrer = (referrer) => ({ annotations: referrer.Annotations?.map((annotation) => ({ key: annotation.Key, value: annotation.Value })) }); -export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapReferrer, mapToManifest }; +export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapAllCVEInfo, mapReferrer, mapToManifest };