From 1efbad20f396ce85beee14fb266bdb757bf3d767 Mon Sep 17 00:00:00 2001 From: Laurentiu Niculae Date: Thu, 1 Feb 2024 10:57:56 +0200 Subject: [PATCH] feat(cve): add compare cves feature for 2 images Signed-off-by: Laurentiu Niculae --- playwright.config.js | 11 +- .../TagPage/VulnerabilitiesDetails.test.js | 2 +- src/api.js | 18 + src/components/Header/SearchSuggestion.jsx | 15 +- src/components/Tag/Tabs/CompareImages.jsx | 154 +++++++ src/components/Tag/Tabs/ImageSelector.jsx | 395 ++++++++++++++++++ .../Tag/Tabs/VulnerabilitiesDetails.jsx | 6 +- src/host.js | 2 +- 8 files changed, 590 insertions(+), 13 deletions(-) create mode 100644 src/components/Tag/Tabs/CompareImages.jsx create mode 100644 src/components/Tag/Tabs/ImageSelector.jsx diff --git a/playwright.config.js b/playwright.config.js index c88309c9..b03b9c48 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -7,7 +7,6 @@ const { devices } = require('@playwright/test'); */ // require('dotenv').config(); - /** * @see https://playwright.dev/docs/test-configuration * @type {import('@playwright/test').PlaywrightTestConfig} @@ -53,7 +52,7 @@ const config = { use: { ...devices['Desktop Chrome'], ignoreHTTPSErrors: true - }, + } }, { @@ -61,7 +60,7 @@ const config = { use: { ...devices['Desktop Firefox'], ignoreHTTPSErrors: true - }, + } }, { @@ -69,8 +68,8 @@ const config = { use: { ...devices['Desktop Safari'], ignoreHTTPSErrors: true - }, - }, + } + } /* Test against mobile viewports. */ // { @@ -102,7 +101,7 @@ const config = { ], /* Folder for test artifacts such as screenshots, videos, traces, etc. */ - outputDir: 'test-results/', + outputDir: 'test-results/' /* Run your local dev server before starting the tests */ // webServer: { diff --git a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js index 5c980ca5..3f9805c7 100644 --- a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js +++ b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js @@ -28,7 +28,7 @@ const mockCVEList = { LowCount: 1, MediumCount: 1, HighCount: 1, - CriticalCount: 1, + CriticalCount: 1 }, CVEList: [ { diff --git a/src/api.js b/src/api.js index 0589e992..9589fd93 100644 --- a/src/api.js +++ b/src/api.js @@ -104,6 +104,19 @@ const endpoints = { }, allVulnerabilitiesForRepo: (name) => `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`, + cveDiffForImages: (minuend = {}, subtrahend = {}, { pageNumber = 1, pageSize = 3 }) => { + let imageInput = (img) => { + let digest = img.digest ? `, digest: ${img.digest}` : ''; + let platform = img.platform ? `, platform: {os: ${img.platform.os}, arch: ${img.platform.arch}}` : ''; + return `{repo: "${img.repo}", tag: "${img.tag}"${digest}${platform}}`; + }; + let query = `/v2/_zot/ext/search?query={CVEDiffListForImages(minuend: ${imageInput( + minuend + )}, subtrahend: ${imageInput(subtrahend)}, requestedPage: {limit:${pageSize} offset:${ + (pageNumber - 1) * pageSize + }}) {Minuend Subtrahend CVEList{Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount} Page {TotalCount ItemCount}}}`; + return query; + }, imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }, filter = {}) => { let filterParam = ''; if (filter.Os || filter.Arch) { @@ -150,6 +163,11 @@ const endpoints = { const paginationParam = `requestedPage: {limit:${pageSize} offset:${(pageNumber - 1) * pageSize} sortBy:RELEVANCE}`; return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam}) {Images {RepoName Tag}}}`; }, + imageSuggestionsWithPlatforms: ({ searchQuery = '""', pageNumber = 1, pageSize = 15 }) => { + const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`; + const paginationParam = `requestedPage: {limit:${pageSize} offset:${(pageNumber - 1) * pageSize} sortBy:RELEVANCE}`; + return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam}) {Images {RepoName Tag Manifests {Platform {Os Arch}}}}}`; + }, referrers: ({ repo, digest, type = '' }) => `/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`, bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark`, diff --git a/src/components/Header/SearchSuggestion.jsx b/src/components/Header/SearchSuggestion.jsx index 0bf2f97f..90e5bd8d 100644 --- a/src/components/Header/SearchSuggestion.jsx +++ b/src/components/Header/SearchSuggestion.jsx @@ -21,6 +21,14 @@ const useStyles = makeStyles(() => ({ position: 'relative', zIndex: 1150 }, + searchContainerUnstretched: { + display: 'inline-block', + backgroundColor: '#2B3A4E', + boxShadow: '0 0.313rem 0.625rem rgba(131, 131, 131, 0.08)', + borderRadius: '0.625rem', + position: 'relative', + zIndex: 1150 + }, searchContainerFocused: { backgroundColor: '#FFFFFF' }, @@ -107,7 +115,7 @@ const useStyles = makeStyles(() => ({ } })); -function SearchSuggestion({ setSearchCurrentValue = () => {} }) { +function SearchSuggestion({ setSearchCurrentValue = () => {}, stretch = true }) { const [searchQuery, setSearchQuery] = useState(''); const [suggestionData, setSuggestionData] = useState([]); const [queryParams] = useSearchParams(); @@ -269,7 +277,10 @@ function SearchSuggestion({ setSearchCurrentValue = () => {} }) { }; return ( -
+
({ + imagesInputBox: { + padding: '0.5rem', + marginBottom: '0.5rem' + }, + input: { + color: '#464141', + '&::placeholder': { + opacity: '1' + } + }, + searchInputBase: { + width: '90%', + paddingLeft: '1rem', + border: '1px solid black', + height: 40, + color: 'rgba(0, 0, 0, 0.6)' + }, + compareImagesPopup: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + backgroundColor: 'white', + border: '2px solid #000', + padding: '1rem' + }, + compareButton: { + paddingLeft: '0.5rem' + } +})); + +function CompareImages({ name, tag, platform }) { + const classes = useStyles(); + const [open, setOpen] = useState(false); + const [minuend, setMinuend] = useState(''); + const [subtrahend, setSubtrahend] = useState(''); + const [cveData, setCVEData] = useState([]); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + const handleMinuendInput = (value) => { + setMinuend(value); + }; + const handleSubtrahendInput = (value) => { + setSubtrahend(value); + }; + const abortController = useMemo(() => new AbortController(), []); + + const imageCVECompare = (minuend, subtrahend) => { + api + .get( + `${host()}${endpoints.cveDiffForImages(minuend, subtrahend, { pageNumber: 1, pageSize: 9 })}`, + abortController.signal + ) + .then((diffResponse) => { + const cveListData = mapCVEInfo(diffResponse.data.data.CVEDiffListForImages.CVEList); + setCVEData(cveListData); + }) + .catch((e) => { + console.error(e); + }); + }; + + const getImageRefComponents = (image) => { + if (image.includes('@')) { + let components = image.split('@'); + return [components[0], '', components[1]]; + } else if (image.includes(':')) { + let components = image.split(':'); + return [components[0], components[1], '']; + } + + return [image, '', '']; + }; + + const handleCompare = () => { + let [minuendRepo, minuendTag] = getImageRefComponents(minuend); + let [subtrahendRepo, subtrahendTag] = getImageRefComponents(subtrahend); + imageCVECompare({ repo: minuendRepo, tag: minuendTag }, { repo: subtrahendRepo, tag: subtrahendTag }); + }; + + const renderCVEs = () => { + return !isEmpty(cveData) ? ( + cveData.map((cve, index) => { + return ; + }) + ) : ( + <> + ); + }; + + return ( +
+ + + + + Compare the vulnerabilities of 2 images + + + + + + + + + + {renderCVEs()} + + + +
+ ); +} + +export default CompareImages; diff --git a/src/components/Tag/Tabs/ImageSelector.jsx b/src/components/Tag/Tabs/ImageSelector.jsx new file mode 100644 index 00000000..7ef42422 --- /dev/null +++ b/src/components/Tag/Tabs/ImageSelector.jsx @@ -0,0 +1,395 @@ +import { + Avatar, + FormControl, + FormHelperText, + InputBase, + InputLabel, + List, + ListItem, + MenuItem, + Select, + Stack, + Typography +} from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import PhotoIcon from '@mui/icons-material/Photo'; +import React, { useEffect, useMemo, useState } from 'react'; +import { api, endpoints } from 'api'; +import { host } from 'host'; +import { mapToImage, mapToRepo } from 'utilities/objectModels'; +import { useSearchParams } from 'react-router-dom'; +import { debounce, isEmpty } from 'lodash'; +import { useCombobox } from 'downshift'; +import { HEADER_SEARCH_PAGE_SIZE } from 'utilities/paginationConstants'; + +const useStyles = makeStyles(() => ({ + searchContainer: { + display: 'inline-block', + backgroundColor: '#FFFFFF', + boxShadow: '0 0.313rem 0.625rem rgba(131, 131, 131, 0.08)', + borderRadius: '0.625rem', + position: 'relative', + zIndex: 1150 + }, + searchContainerFocused: { + backgroundColor: '#FFFFFF' + }, + search: { + position: 'relative', + flexDirection: 'row', + boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)', + border: '0.063rem solid #8A96A8', + borderRadius: '0.625rem', + zIndex: 1155 + }, + searchFocused: { + border: '0.125rem solid #E0E5EB', + backgroundColor: '#FFFFF' + }, + searchFailed: { + border: '0.125rem solid #ff0303' + }, + resultsWrapper: { + margin: '0', + marginTop: '-5%', + paddingTop: '5%', + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#FFFFFF', + boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)', + borderBottomLeftRadius: '0.625rem', + borderBottomRightRadius: '0.625rem', + // border: '0.125rem solid #E7E7E7', + borderTop: 0, + width: '100%', + overflowY: 'auto', + zIndex: 1 + }, + resultsWrapperFocused: { + backgroundColor: '#FFFFFF' + }, + resultsWrapperHidden: { + display: 'none' + }, + input: { + marginLeft: 1, + width: '90%', + paddingLeft: 10, + height: '40px', + fontSize: '1rem', + backgroundColor: '#FFFFFF', + borderRadius: '0.625rem', + color: '#8A96A8' + }, + inputFocused: { + backgroundColor: '#FFFFFF', + borderRadius: '0.625rem', + color: 'rgba(0, 0, 0, 0.6);' + }, + searchItem: { + alignItems: 'center', + justifyContent: 'flex-start', + color: '#000000', + height: '2.75rem', + padding: '0 5%', + cursor: 'pointer' + }, + searchItemIconBg: { + backgroundColor: '#FFFFFF', + height: '1.5rem', + width: '1.5rem', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden' + }, + searchItemIcon: { + color: '#0000008A', + minHeight: '100%', + minWidth: '100%', + objectFit: 'fill' + } +})); + +function ImageSelector({ setSearchCurrentValue = () => {}, name, tag }) { + // digest, selectedPlatform + const [inputHelpText, setInputHelpText] = useState('Specify a repo:tag'); + const [activePlatformSelection, setActivePlatformSelection] = useState(false); + const [platformOptions, setPlatformOptions] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [platform, setPlatform] = useState(''); + const [suggestionData, setSuggestionData] = useState([]); + const [queryParams] = useSearchParams(); + const search = queryParams.get('search') || ''; + const [isLoading, setIsLoading] = useState(false); + const [isFailedSearch, setIsFailedSearch] = useState(false); + const [isComponentFocused, setIsComponentFocused] = useState(false); + const abortController = useMemo(() => new AbortController(), []); + + const classes = useStyles(); + + const handleSuggestionSelected = (event) => { + let name = event.selectedItem?.name; + if (!name?.includes(':')) { + name += ':'; + setInputHelpText('Specify a :tag'); + } else { + setInputHelpText('Image Selected'); + setActivePlatformSelection(true); + let platforms = getImagePlatforms(event.selectedItem); + setPlatformOptions(platforms); + } + }; + + const handleSearchChange = (event) => { + const value = event?.inputValue; + setSearchQuery(value); + // used to lift up the state for pages that need to know the current value of the search input (currently only Explore) not used in other cases + // one way binding, other components shouldn't set the value of the search input, but using this prop can read it + setSearchCurrentValue(value); + setIsFailedSearch(false); + setIsLoading(true); + setSuggestionData([]); + if (value === '') { + setInputHelpText('Specify a repo:tag'); + } + }; + + const handleSearch = () => { + console.log(inputValue ? '' : inputValue); + }; + + const repoSearch = (value) => { + api + .get( + `${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: HEADER_SEARCH_PAGE_SIZE })}`, + abortController.signal + ) + .then((suggestionResponse) => { + if (suggestionResponse.data.data.GlobalSearch.Repos) { + const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Repos.map((el) => mapToRepo(el)); + setSuggestionData(suggestionParsedData); + setInputHelpText('Specify a repo:tag'); + if (suggestionParsedData.length === 1 && suggestionParsedData[0].repo === value) { + setInputHelpText('Specify a :tag'); + } else if (isEmpty(suggestionParsedData)) { + setIsFailedSearch(true); + setInputHelpText('Repo not found'); + } + } + setIsLoading(false); + }) + .catch((e) => { + console.error(e); + setIsLoading(false); + setIsFailedSearch(true); + }); + }; + + const getImagePlatforms = (image) => { + return image.manifests.map((it) => ({ os: it.platform.Os, arch: it.platform.Arch })); + }; + + const imageSearch = (value) => { + let tag = value.split(':')[1]; + api + .get( + `${host()}${endpoints.imageSuggestionsWithPlatforms({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`, + abortController.signal + ) + .then((suggestionResponse) => { + if (suggestionResponse.data.data.GlobalSearch.Images) { + const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Images.map((el) => mapToImage(el)); + setSuggestionData(suggestionParsedData); + setActivePlatformSelection(false); + setActivePlatformSelection(false); + if (suggestionParsedData.length === 1 && suggestionParsedData[0].tag === tag) { + setInputHelpText('Image Selected'); // if the current text matches a valid repo-tag + } else if (isEmpty(suggestionParsedData)) { + setIsFailedSearch(true); + setInputHelpText('Tag not found'); + } else { + setInputHelpText('Specify a :tag'); + } + } + setIsLoading(false); + }) + .catch((e) => { + console.error(e); + setIsLoading(false); + setIsFailedSearch(true); + }); + }; + + const searchCall = (value) => { + if (value !== '') { + // if search term inclused the ':' character, search for images, if not, search repos + if (value?.includes(':')) { + imageSearch(value); + } else { + repoSearch(value); + } + } + }; + + const debounceSuggestions = useMemo(() => { + return debounce(searchCall, 300); + }, []); + + useEffect(() => { + debounceSuggestions(searchQuery); + }, [searchQuery]); + + useEffect(() => { + return () => { + debounceSuggestions.cancel(); + abortController.abort(); + }; + }, []); + + const { + // selectedItem, + inputValue, + getInputProps, + getMenuProps, + getItemProps, + highlightedIndex, + getComboboxProps, + isOpen, + openMenu + } = useCombobox({ + items: suggestionData, + onInputValueChange: handleSearchChange, + onSelectedItemChange: handleSuggestionSelected, + initialInputValue: !isEmpty(searchQuery) ? searchQuery : search, + itemToString: (item) => item?.name || item + }); + + useEffect(() => { + setIsComponentFocused(isOpen); + }, [isOpen]); + + const renderSuggestions = () => { + return suggestionData.map((suggestion, index) => ( + + + + + + {suggestion.name} + + + )); + }; + + const renderPlatformOptions = () => { + return platformOptions.map((it, index) => ( + + {`${it.os}/${it.arch}`} + + )); + }; + + const renderHelpText = () => { + return {inputHelpText}; + }; + + return ( + + {renderHelpText()} + + openMenu()} + {...getInputProps()} + /> + + Platform + + + + + {isOpen && suggestionData?.length > 0 && renderSuggestions()} + {isOpen && isLoading && !isEmpty(searchQuery) && isEmpty(suggestionData) && ( + <> + + + Loading... + + + + )} + {isOpen && isEmpty(searchQuery) && isEmpty(suggestionData) && ( + <> + {}} + > + + Use the ':' character to search for tags + + + + )} + + + ); +} + +export default ImageSelector; diff --git a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx index 38c1a614..bf3ea1de 100644 --- a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx +++ b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx @@ -35,6 +35,7 @@ import Collapse from '@mui/material/Collapse'; import VulnerabilitiyCard from '../../Shared/VulnerabilityCard'; import VulnerabilityCountCard from '../../Shared/VulnerabilityCountCard'; +import CompareImages from './CompareImages'; const useStyles = makeStyles((theme) => ({ searchAndDisplayBar: { @@ -98,7 +99,7 @@ const useStyles = makeStyles((theme) => ({ }, viewModes: { position: 'relative', - alignItems: 'baseline', + alignItems: 'center', maxWidth: '100%', flexDirection: 'row', justifyContent: 'right' @@ -352,8 +353,6 @@ function VulnerabilitiesDetails(props) { return; } - console.log('Test'); - return !isEmpty(cveSummary) ? ( + diff --git a/src/host.js b/src/host.js index 582b6ac4..18d5bf52 100644 --- a/src/host.js +++ b/src/host.js @@ -1,5 +1,5 @@ const hostConfig = { - auto: true, + auto: false, default: 'http://localhost:5000' };