7 Commits

10 changed files with 491 additions and 92 deletions

View File

@ -42,7 +42,8 @@ const config = {
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
ignoreHTTPSErrors: true ignoreHTTPSErrors: true,
screenshot: 'only-on-failure'
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
@ -101,7 +102,7 @@ const config = {
], ],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */ /* 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 */ /* Run your local dev server before starting the tests */
// webServer: { // webServer: {

View File

@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
const mockMgmtResponse = { const mockMgmtResponse = {
distSpecVersion: '1.1.0-dev', distSpecVersion: '1.1.0-dev',
binaryType: '-apikey-lint-metrics-mgmt-scrub-search-sync-ui-userprefs', binaryType: '-apikey-lint-metrics-mgmt-scrub-search-sync-ui-userprefs',
http: { auth: { htpasswd: {} } } http: { auth: { htpasswd: {}, openid: { providers: { github: {} } } } }
}; };
// useNavigate mock // useNavigate mock
@ -55,6 +55,7 @@ describe('Sign in form', () => {
fireEvent.change(passwordInput, { target: { value: 'test' } }); fireEvent.change(passwordInput, { target: { value: 'test' } });
expect(usernameInput).toHaveValue('test'); expect(usernameInput).toHaveValue('test');
expect(passwordInput).toHaveValue('test'); expect(passwordInput).toHaveValue('test');
expect(screen.getByTestId('openid-divider')).toBeInTheDocument();
}); });
it('should display error if username and password values are empty after change', async () => { it('should display error if username and password values are empty after change', async () => {

View File

@ -22,6 +22,14 @@ const mockCVEList = {
CVEListForImage: { CVEListForImage: {
Tag: '', Tag: '',
Page: { ItemCount: 20, TotalCount: 20 }, Page: { ItemCount: 20, TotalCount: 20 },
Summary: {
Count: 5,
UnknownCount: 1,
LowCount: 1,
MediumCount: 1,
HighCount: 1,
CriticalCount: 1,
},
CVEList: [ CVEList: [
{ {
Id: 'CVE-2020-16156', Id: 'CVE-2020-16156',
@ -475,6 +483,12 @@ const mockCVEFixed = {
} }
] ]
} }
},
pageNotFixed: {
ImageListWithCVEFixed: {
Page: { TotalCount: 0, ItemCount: 0 },
Results: []
}
} }
}; };
@ -496,14 +510,23 @@ afterEach(() => {
describe('Vulnerabilties page', () => { describe('Vulnerabilties page', () => {
it('renders the vulnerabilities if there are any', async () => { it('renders the vulnerabilities if there are any', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
for (let i=0; i<21; i++) {
getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } });
}
getCall.mockResolvedValue({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20)); await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText(/Fixed in/)).toHaveLength(20));
}); });
it('sends filtered query if user types in the search bar', async () => { it('sends filtered query if user types in the search bar', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
for (let i=0; i<21; i++) {
getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } });
}
getCall.mockResolvedValue({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
const cveSearchInput = screen.getByPlaceholderText(/search/i); const cveSearchInput = screen.getByPlaceholderText(/search/i);
jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } }); jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } });
@ -512,28 +535,41 @@ describe('Vulnerabilties page', () => {
expect((await screen.findAllByText(/2022/i)).length === 6); expect((await screen.findAllByText(/2022/i)).length === 6);
}); });
it('should have a collapsable search bar', async () => {
let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
for (let i=0; i<21; i++) {
getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } });
}
getCall.mockResolvedValue({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
const cveSearchInput = screen.getByPlaceholderText(/search/i);
const expandSearch = cveSearchInput.parentElement.parentElement.parentElement.parentElement.childNodes[0];
await fireEvent.click(expandSearch);
await waitFor(() =>
expect(screen.getAllByPlaceholderText("Exclude")).toHaveLength(1)
);
const excludeInput = screen.getByPlaceholderText("Exclude");
userEvent.type(excludeInput, '2022');
expect((await screen.findAllByText(/2022/i)).length === 0);
})
it('renders no vulnerabilities if there are not any', async () => { it('renders no vulnerabilities if there are not any', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ jest.spyOn(api, 'get').mockResolvedValue({
status: 200, status: 200,
data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [] } } } data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [], Summary: {} } } }
}); });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1));
}); });
it('should open and close description dropdown for vulnerabilities', async () => { it('should show description for vulnerabilities', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageNotFixed } });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText(/description/i)).toHaveLength(20)); await waitFor(() => expect(screen.getAllByText(/Description/)).toHaveLength(20));
const openText = screen.getAllByText(/description/i);
await fireEvent.click(openText[0]);
await waitFor(() => await waitFor(() =>
expect(screen.getAllByText(/CPAN 2.28 allows Signature Verification Bypass./i)).toHaveLength(1) expect(screen.getAllByText(/CPAN 2.28 allows Signature Verification Bypass./i)).toHaveLength(1)
); );
await fireEvent.click(openText[0]);
await waitFor(() =>
expect(screen.queryByText(/CPAN 2.28 allows Signature Verification Bypass./i)).not.toBeInTheDocument()
);
}); });
it("should log an error when data can't be fetched", async () => { it("should log an error when data can't be fetched", async () => {
@ -551,12 +587,12 @@ describe('Vulnerabilties page', () => {
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } }); .mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
await fireEvent.click(screen.getAllByText(/fixed in/i)[0]);
await waitFor(() => expect(screen.getByText('1.0.16')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('1.0.16')).toBeInTheDocument());
const loadMoreBtn = screen.getByText(/load more/i); await waitFor(() => expect(screen.getAllByText(/load more/i).length).toBeGreaterThan(0));
expect(loadMoreBtn).toBeInTheDocument(); const nrLoadButtons = screen.getAllByText(/load more/i).length
const loadMoreBtn = screen.getAllByText(/load more/i)[0];
await fireEvent.click(loadMoreBtn); await fireEvent.click(loadMoreBtn);
await waitFor(() => expect(loadMoreBtn).not.toBeInTheDocument()); await waitFor(() => expect(screen.getAllByText(/load more/i).length).toBe(nrLoadButtons-1));
expect(await screen.findByText('latest')).toBeInTheDocument(); expect(await screen.findByText('latest')).toBeInTheDocument();
}); });
@ -564,10 +600,11 @@ describe('Vulnerabilties page', () => {
const xlsxMock = jest.createMockFromModule('xlsx'); const xlsxMock = jest.createMockFromModule('xlsx');
xlsxMock.writeFile = jest.fn(); xlsxMock.writeFile = jest.fn();
jest let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
.spyOn(api, 'get') for (let i=0; i<21; i++) {
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } });
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); }
getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const downloadBtn = await screen.findAllByTestId('DownloadIcon'); const downloadBtn = await screen.findAllByTestId('DownloadIcon');
@ -580,22 +617,38 @@ describe('Vulnerabilties page', () => {
await fireEvent.click(exportAsCSVBtn); await fireEvent.click(exportAsCSVBtn);
expect(await screen.findByTestId('export-csv-menuItem')).not.toBeInTheDocument(); expect(await screen.findByTestId('export-csv-menuItem')).not.toBeInTheDocument();
fireEvent.click(downloadBtn[0]); fireEvent.click(downloadBtn[0]);
const exportAsExcelBtn = screen.getByText(/MS Excel/i); const exportAsExcelBtn = screen.getByText(/xlsx/i);
expect(exportAsExcelBtn).toBeInTheDocument(); expect(exportAsExcelBtn).toBeInTheDocument();
await fireEvent.click(exportAsExcelBtn); await fireEvent.click(exportAsExcelBtn);
expect(await screen.findByTestId('export-excel-menuItem')).not.toBeInTheDocument(); expect(await screen.findByTestId('export-excel-menuItem')).not.toBeInTheDocument();
}); });
it('should expand/collapse the list of CVEs', async () => {
let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
for (let i=0; i<21; i++) {
getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } });
}
getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText('Fixed in')).toHaveLength(20));
const collapseListBtn = await screen.findAllByTestId('ViewHeadlineIcon');
fireEvent.click(collapseListBtn[0]);
expect(await screen.findByText('Fixed in')).not.toBeVisible();
const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon');
fireEvent.click(expandListBtn[0]);
await waitFor(() => expect(screen.getAllByText('Fixed in')).toHaveLength(20));
});
it('should handle fixed CVE query errors', async () => { it('should handle fixed CVE query errors', async () => {
jest jest
.spyOn(api, 'get') .spyOn(api, 'get')
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockRejectedValue({ status: 500, data: {} }); .mockRejectedValueOnce({ status: 500, data: {} });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const error = jest.spyOn(console, 'error').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {});
await fireEvent.click(screen.getAllByText(/fixed in/i)[0]); await waitFor(() => expect(screen.getAllByText(/not fixed/i).length).toBeGreaterThan(0));
await waitFor(() => expect(screen.getByText(/not fixed/i)).toBeInTheDocument()); await waitFor(() => expect(error).toBeCalled());
await waitFor(() => expect(error).toBeCalledTimes(1));
}); });
}); });

View File

@ -90,14 +90,17 @@ const endpoints = {
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor IsDeletable } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`, `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor IsDeletable } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`,
detailedImageInfo: (name, tag) => detailedImageInfo: (name, tag) =>
`/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`, `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`,
vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => { vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '', excludedTerm = '') => {
let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${ let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize (pageNumber - 1) * pageSize
}}`; }}`;
if (!isEmpty(searchTerm)) { if (!isEmpty(searchTerm)) {
query += `, searchedCVE: "${searchTerm}"`; query += `, searchedCVE: "${searchTerm}"`;
} }
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`; if (!isEmpty(excludedTerm)) {
query += `, excludedCVE: "${excludedTerm}"`;
}
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount}}}`;
}, },
allVulnerabilitiesForRepo: (name) => allVulnerabilitiesForRepo: (name) =>
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`, `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`,

View File

@ -312,7 +312,13 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
Welcome back! Please login. Welcome back! Please login.
</Typography> </Typography>
{renderThirdPartyLoginMethods()} {renderThirdPartyLoginMethods()}
{Object.keys(authMethods).length > 1 && <Divider className={classes.divider}>or</Divider>} {Object.keys(authMethods).length > 1 &&
Object.keys(authMethods).includes('openid') &&
Object.keys(authMethods.openid.providers).length > 0 && (
<Divider className={classes.divider} data-testId="openid-divider">
or
</Divider>
)}
{Object.keys(authMethods).includes('htpasswd') && ( {Object.keys(authMethods).includes('htpasswd') && (
<Box component="form" onSubmit={null} noValidate autoComplete="off"> <Box component="form" onSubmit={null} noValidate autoComplete="off">
<TextField <TextField

View File

@ -29,18 +29,46 @@ const useStyles = makeStyles((theme) => ({
marginTop: '2rem', marginTop: '2rem',
marginBottom: '2rem' marginBottom: '2rem'
}, },
cardCollapsed: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
background: '#FFFFFF',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
border: '1px solid #E0E5EB',
borderRadius: '0.75rem',
flex: 'none',
alignSelf: 'stretch',
width: '100%'
},
content: { content: {
textAlign: 'left', textAlign: 'left',
color: '#606060', color: '#606060',
padding: '2% 3% 2% 3%', padding: '2% 3% 2% 3%',
width: '100%' width: '100%'
}, },
contentCollapsed: {
textAlign: 'left',
color: '#606060',
padding: '1% 3% 1% 3%',
width: '100%',
'&:last-child': {
paddingBottom: '1%'
}
},
cveId: { cveId: {
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontSize: '1rem', fontSize: '1rem',
fontWeight: 400, fontWeight: 400,
textDecoration: 'underline' textDecoration: 'underline'
}, },
cveIdCollapsed: {
color: theme.palette.primary.main,
fontSize: '0.75rem',
fontWeight: 500,
textDecoration: 'underline',
flexBasis: '19%'
},
cveSummary: { cveSummary: {
color: theme.palette.secondary.dark, color: theme.palette.secondary.dark,
fontSize: '0.75rem', fontSize: '0.75rem',
@ -48,6 +76,13 @@ const useStyles = makeStyles((theme) => ({
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
marginTop: '0.5rem' marginTop: '0.5rem'
}, },
cveSummaryCollapsed: {
color: theme.palette.secondary.dark,
fontSize: '0.75rem',
fontWeight: '600',
textOverflow: 'ellipsis',
flexBasis: '82%'
},
link: { link: {
color: '#52637A', color: '#52637A',
fontSize: '1rem', fontSize: '1rem',
@ -66,15 +101,21 @@ const useStyles = makeStyles((theme) => ({
cursor: 'pointer', cursor: 'pointer',
textAlign: 'center' textAlign: 'center'
}, },
dropdownCVE: {
color: '#1479FF',
cursor: 'pointer'
},
vulnerabilityCardDivider: { vulnerabilityCardDivider: {
margin: '1rem 0' margin: '1rem 0'
},
cveInfo: {
marginTop: '2%'
} }
})); }));
function VulnerabilitiyCard(props) { function VulnerabilitiyCard(props) {
const classes = useStyles(); const classes = useStyles();
const { cve, name, platform } = props; const { cve, name, platform, expand } = props;
const [openDesc, setOpenDesc] = useState(false); const [openCVE, setOpenCVE] = useState(expand);
const [openFixed, setOpenFixed] = useState(false);
const [loadingFixed, setLoadingFixed] = useState(true); const [loadingFixed, setLoadingFixed] = useState(true);
const [fixedInfo, setFixedInfo] = useState([]); const [fixedInfo, setFixedInfo] = useState([]);
const abortController = useMemo(() => new AbortController(), []); const abortController = useMemo(() => new AbortController(), []);
@ -84,7 +125,7 @@ function VulnerabilitiyCard(props) {
const [isEndOfList, setIsEndOfList] = useState(false); const [isEndOfList, setIsEndOfList] = useState(false);
const getPaginatedResults = () => { const getPaginatedResults = () => {
if (!openFixed || isEndOfList) { if (isEndOfList) {
return; return;
} }
setLoadingFixed(true); setLoadingFixed(true);
@ -120,7 +161,11 @@ function VulnerabilitiyCard(props) {
return () => { return () => {
abortController.abort(); abortController.abort();
}; };
}, [openFixed, pageNumber]); }, [pageNumber]);
useEffect(() => {
setOpenCVE(expand);
}, [expand]);
const loadMore = () => { const loadMore = () => {
if (loadingFixed || isEndOfList) return; if (loadingFixed || isEndOfList) return;
@ -163,27 +208,81 @@ function VulnerabilitiyCard(props) {
}; };
return ( return (
<Card className={classes.card} raised> <Card className={openCVE ? classes.card : classes.cardCollapsed} raised>
<CardContent className={classes.content}> <CardContent className={openCVE ? classes.content : classes.contentCollapsed}>
<Stack direction="row" spacing="1.25rem"> <Stack direction="row" spacing={openCVE ? '1.25rem' : '0.5rem'}>
<Typography variant="body1" align="left" className={classes.cveId}> {!openCVE ? (
<KeyboardArrowRight className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} />
) : (
<KeyboardArrowDown className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} />
)}
<Typography variant="body1" align="left" className={openCVE ? classes.cveId : classes.cveIdCollapsed}>
{cve.id} {cve.id}
</Typography> </Typography>
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} /> {openCVE ? (
</Stack> <VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
<Typography variant="body1" align="left" className={classes.cveSummary}>
{cve.title}
</Typography>
<Divider className={classes.vulnerabilityCardDivider} />
<Stack className={classes.dropdown} onClick={() => setOpenFixed(!openFixed)}>
{!openFixed ? (
<KeyboardArrowRight className={classes.dropdownText} />
) : ( ) : (
<KeyboardArrowDown className={classes.dropdownText} /> <Stack direction="row" spacing="0.5rem" flexBasis="90%">
<div style={{ transform: 'scale(0.8)', flexBasis: '18%', flexShrink: '0' }}>
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
</div>
<Typography variant="body1" align="left" className={classes.cveSummaryCollapsed}>
{cve.title}
</Typography>
</Stack>
)} )}
<Typography className={classes.dropdownText}>Fixed in</Typography>
</Stack> </Stack>
<Collapse in={openFixed} timeout="auto" unmountOnExit> <Collapse in={openCVE} timeout="auto" unmountOnExit>
<Typography variant="body1" align="left" className={classes.cveSummary}>
{cve.title}
</Typography>
<Divider className={classes.vulnerabilityCardDivider} />
<Typography variant="body2" align="left" className={classes.cveInfo}>
External reference
</Typography>
<Typography
variant="body2"
align="left"
sx={{ color: '#0F2139', fontSize: '1rem', textDecoration: 'underline' }}
component={Link}
to={cve.reference}
target="_blank"
rel="noreferrer"
>
{cve.reference}
</Typography>
<Typography variant="body2" align="left" className={classes.cveInfo}>
Packages
</Typography>
<Stack direction="column" sx={{ width: '100%', padding: '0.5rem 0' }}>
<Stack direction="row" spacing="1.25rem" display="flex">
<Typography variant="body1" flexBasis="33.33%">
Name
</Typography>
<Typography variant="body1" flexBasis="33.33%" textAlign="right">
Installed Version
</Typography>
<Typography variant="body1" flexBasis="33.33%" textAlign="right">
Fixed Version
</Typography>
</Stack>
{cve.packageList.map((el) => (
<Stack direction="row" key={cve.packageName} spacing="1.25rem" display="flex">
<Typography variant="body1" color="primary" flexBasis="33.33%">
{el.packageName}
</Typography>
<Typography variant="body1" color="primary" flexBasis="33.33%" textAlign="right">
{el.packageInstalledVersion}
</Typography>
<Typography variant="body1" color="primary" flexBasis="33.33%" textAlign="right">
{el.packageFixedVersion}
</Typography>
</Stack>
))}
</Stack>
<Typography variant="body2" align="left" className={classes.cveInfo}>
Fixed in
</Typography>
<Box sx={{ width: '100%', padding: '0.5rem 0' }}> <Box sx={{ width: '100%', padding: '0.5rem 0' }}>
{loadingFixed ? ( {loadingFixed ? (
'Loading...' 'Loading...'
@ -194,16 +293,9 @@ function VulnerabilitiyCard(props) {
</Stack> </Stack>
)} )}
</Box> </Box>
</Collapse> <Typography variant="body2" align="left" className={classes.cveInfo}>
<Stack className={classes.dropdown} onClick={() => setOpenDesc(!openDesc)}> Description
{!openDesc ? ( </Typography>
<KeyboardArrowRight className={classes.dropdownText} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
)}
<Typography className={classes.dropdownText}>Description</Typography>
</Stack>
<Collapse in={openDesc} timeout="auto" unmountOnExit>
<Box sx={{ padding: '0.5rem 0' }}> <Box sx={{ padding: '0.5rem 0' }}>
<Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}> <Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}>
{cve.description} {cve.description}

View File

@ -0,0 +1,92 @@
import React from 'react';
import makeStyles from '@mui/styles/makeStyles';
import { Stack, Tooltip } from '@mui/material';
const criticalColor = '#ff5c74';
const criticalBorderColor = '#f9546d';
const highColor = '#ff6840';
const highBorderColor = '#ee6b49';
const mediumColor = '#ffa052';
const mediumBorderColor = '#f19d5b';
const lowColor = '#f9f486';
const lowBorderColor = '#f0ed94';
const unknownColor = '#f2ffdd';
const unknownBorderColor = '#e9f4d7';
const fontSize = '0.75rem';
const useStyles = makeStyles((theme) => ({
cveCountCard: {
display: 'flex',
alignItems: 'center',
paddingLeft: '0.5rem',
paddingRight: '0.5rem',
color: theme.palette.primary.main,
fontSize: fontSize,
fontWeight: '600',
borderRadius: '3px',
marginBottom: '0'
},
severityList: {
fontSize: fontSize,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0.5em'
},
criticalSeverity: {
backgroundColor: criticalColor,
border: '1px solid ' + criticalBorderColor
},
highSeverity: {
backgroundColor: highColor,
border: '1px solid ' + highBorderColor
},
mediumSeverity: {
backgroundColor: mediumColor,
border: '1px solid ' + mediumBorderColor
},
lowSeverity: {
backgroundColor: lowColor,
border: '1px solid ' + lowBorderColor
},
unknownSeverity: {
backgroundColor: unknownColor,
border: '1px solid ' + unknownBorderColor
}
}));
function VulnerabilitiyCountCard(props) {
const classes = useStyles();
const { total, critical, high, medium, low, unknown } = props;
return (
<Stack direction="row" spacing="0.5em">
<div className={[classes.cveCountCard].join(' ')}>Total {total}</div>
<div className={classes.severityList}>
<Tooltip title="Critical">
<div className={[classes.cveCountCard, classes.criticalSeverity].join(' ')}>C {critical}</div>
</Tooltip>
<Tooltip title="High">
<div className={[classes.cveCountCard, classes.highSeverity].join(' ')}>H {high}</div>
</Tooltip>
<Tooltip title="Medium">
<div className={[classes.cveCountCard, classes.mediumSeverity].join(' ')}>M {medium}</div>
</Tooltip>
<Tooltip title="Low">
<div className={[classes.cveCountCard, classes.lowSeverity].join(' ')}>L {low}</div>
</Tooltip>
<Tooltip title="Unknown">
<div className={[classes.cveCountCard, classes.unknownSeverity].join(' ')}>U {unknown}</div>
</Tooltip>
</div>
</Stack>
);
}
export default VulnerabilitiyCountCard;

View File

@ -9,6 +9,7 @@ import {
Stack, Stack,
Typography, Typography,
InputBase, InputBase,
ToggleButton,
Menu, Menu,
MenuItem, MenuItem,
Divider, Divider,
@ -26,16 +27,32 @@ import DownloadIcon from '@mui/icons-material/Download';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import exportFromJSON from 'export-from-json'; import exportFromJSON from 'export-from-json';
import ViewHeadlineIcon from '@mui/icons-material/ViewHeadline';
import ViewAgendaIcon from '@mui/icons-material/ViewAgenda';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import Collapse from '@mui/material/Collapse';
import VulnerabilitiyCard from '../../Shared/VulnerabilityCard'; import VulnerabilitiyCard from '../../Shared/VulnerabilityCard';
import VulnerabilityCountCard from '../../Shared/VulnerabilityCountCard';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
searchAndDisplayBar: {
display: 'flex',
justifyContent: 'space-between'
},
title: { title: {
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontSize: '1.5rem', fontSize: '1.5rem',
fontWeight: '600', fontWeight: '600',
marginBottom: '0' marginBottom: '0'
}, },
cveCountSummary: {
color: theme.palette.primary.main,
fontSize: '1.5rem',
fontWeight: '600',
marginBottom: '0'
},
cveId: { cveId: {
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontSize: '1rem', fontSize: '1rem',
@ -64,6 +81,7 @@ const useStyles = makeStyles((theme) => ({
search: { search: {
position: 'relative', position: 'relative',
maxWidth: '100%', maxWidth: '100%',
flex: 0.95,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -71,6 +89,20 @@ const useStyles = makeStyles((theme) => ({
border: '0.063rem solid #E7E7E7', border: '0.063rem solid #E7E7E7',
borderRadius: '0.625rem' borderRadius: '0.625rem'
}, },
expandableSearchInput: {
flexGrow: 0.95
},
view: {
alignContent: 'right',
variant: 'outlined'
},
viewModes: {
position: 'relative',
alignItems: 'baseline',
maxWidth: '100%',
flexDirection: 'row',
justifyContent: 'right'
},
searchIcon: { searchIcon: {
color: '#52637A', color: '#52637A',
paddingRight: '3%' paddingRight: '3%'
@ -87,15 +119,27 @@ const useStyles = makeStyles((theme) => ({
opacity: '1' opacity: '1'
} }
}, },
export: {
alignContent: 'right'
},
popper: { popper: {
width: '100%', width: '100%',
overflow: 'hidden', overflow: 'hidden',
padding: '0.3rem', padding: '0.3rem',
display: 'flex', display: 'flex',
justifyContent: 'left'
},
dropdownArrowBox: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
},
dropdownText: {
color: '#1479FF',
fontSize: '1.5rem',
fontWeight: '600',
cursor: 'pointer',
textAlign: 'center'
},
test: {
width: '95%'
} }
})); }));
@ -103,13 +147,17 @@ function VulnerabilitiesDetails(props) {
const classes = useStyles(); const classes = useStyles();
const [cveData, setCveData] = useState([]); const [cveData, setCveData] = useState([]);
const [allCveData, setAllCveData] = useState([]); const [allCveData, setAllCveData] = useState([]);
const [cveSummary, setCVESummary] = useState({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isLoadingAllCve, setIsLoadingAllCve] = useState(true); const [isLoadingAllCve, setIsLoadingAllCve] = useState(true);
const abortController = useMemo(() => new AbortController(), []); const abortController = useMemo(() => new AbortController(), []);
const { name, tag, digest, platform } = props; const { name, tag, digest, platform } = props;
const [openExcludeSearch, setOpenExcludeSearch] = useState(false);
// pagination props // pagination props
const [cveFilter, setCveFilter] = useState(''); const [cveFilter, setCveFilter] = useState('');
const [cveExcludeFilter, setCveExcludeFilter] = useState('');
const [pageNumber, setPageNumber] = useState(1); const [pageNumber, setPageNumber] = useState(1);
const [isEndOfList, setIsEndOfList] = useState(false); const [isEndOfList, setIsEndOfList] = useState(false);
const listBottom = useRef(null); const listBottom = useRef(null);
@ -117,6 +165,8 @@ function VulnerabilitiesDetails(props) {
const [anchorExport, setAnchorExport] = useState(null); const [anchorExport, setAnchorExport] = useState(null);
const openExport = Boolean(anchorExport); const openExport = Boolean(anchorExport);
const [selectedViewMore, setSelectedViewMore] = useState(true);
const getCVERequestName = () => { const getCVERequestName = () => {
return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`; return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`;
}; };
@ -127,16 +177,31 @@ function VulnerabilitiesDetails(props) {
`${host()}${endpoints.vulnerabilitiesForRepo( `${host()}${endpoints.vulnerabilitiesForRepo(
getCVERequestName(), getCVERequestName(),
{ pageNumber, pageSize: EXPLORE_PAGE_SIZE }, { pageNumber, pageSize: EXPLORE_PAGE_SIZE },
cveFilter cveFilter,
cveExcludeFilter
)}`, )}`,
abortController.signal abortController.signal
) )
.then((response) => { .then((response) => {
if (response.data && response.data.data) { if (response.data && response.data.data) {
let cveInfo = response.data.data.CVEListForImage?.CVEList; let cveInfo = response.data.data.CVEListForImage?.CVEList;
let summary = response.data.data.CVEListForImage?.Summary;
let cveListData = mapCVEInfo(cveInfo); let cveListData = mapCVEInfo(cveInfo);
setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData])); setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData]));
setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE); setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE);
setCVESummary((previousState) => {
if (isEmpty(summary)) {
return previousState;
}
return {
Count: summary.Count,
UnknownCount: summary.UnknownCount,
LowCount: summary.LowCount,
MediumCount: summary.MediumCount,
HighCount: summary.HighCount,
CriticalCount: summary.CriticalCount
};
});
} else if (response.data.errors) { } else if (response.data.errors) {
setIsEndOfList(true); setIsEndOfList(true);
} }
@ -146,6 +211,7 @@ function VulnerabilitiesDetails(props) {
console.error(e); console.error(e);
setIsLoading(false); setIsLoading(false);
setCveData([]); setCveData([]);
setCVESummary(() => {});
setIsEndOfList(true); setIsEndOfList(true);
}); });
}; };
@ -182,7 +248,7 @@ function VulnerabilitiesDetails(props) {
const wb = XLSX.utils.book_new(), const wb = XLSX.utils.book_new(),
ws = XLSX.utils.json_to_sheet(allCveData); ws = XLSX.utils.json_to_sheet(allCveData);
XLSX.utils.book_append_sheet(wb, ws, name + '_' + tag); XLSX.utils.book_append_sheet(wb, ws, name.replaceAll('/', '_') + '_' + tag);
XLSX.writeFile(wb, `${name}:${tag}-vulnerabilities.xlsx`); XLSX.writeFile(wb, `${name}:${tag}-vulnerabilities.xlsx`);
@ -211,7 +277,17 @@ function VulnerabilitiesDetails(props) {
setAnchorExport(null); setAnchorExport(null);
}; };
const handleExpandCVESearch = () => {
setOpenExcludeSearch((openExcludeSearch) => !openExcludeSearch);
};
const handleCveExcludeFilterChange = (e) => {
const { value } = e.target;
setCveExcludeFilter(value);
};
const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300)); const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300));
const debouncedExcludeFilterChangeHandler = useMemo(() => debounce(handleCveExcludeFilterChange, 300));
useEffect(() => { useEffect(() => {
getPaginatedCVEs(); getPaginatedCVEs();
@ -245,12 +321,13 @@ function VulnerabilitiesDetails(props) {
useEffect(() => { useEffect(() => {
if (isLoading) return; if (isLoading) return;
resetPagination(); resetPagination();
}, [cveFilter]); }, [cveFilter, cveExcludeFilter]);
useEffect(() => { useEffect(() => {
return () => { return () => {
abortController.abort(); abortController.abort();
debouncedChangeHandler.cancel(); debouncedChangeHandler.cancel();
debouncedExcludeFilterChangeHandler.cancel();
}; };
}, []); }, []);
@ -263,13 +340,34 @@ function VulnerabilitiesDetails(props) {
const renderCVEs = () => { const renderCVEs = () => {
return !isEmpty(cveData) ? ( return !isEmpty(cveData) ? (
cveData.map((cve, index) => { cveData.map((cve, index) => {
return <VulnerabilitiyCard key={index} cve={cve} name={name} platform={platform} />; return <VulnerabilitiyCard key={index} cve={cve} name={name} platform={platform} expand={selectedViewMore} />;
}) })
) : ( ) : (
<div>{!isLoading && <Typography className={classes.none}> No Vulnerabilities </Typography>}</div> <div>{!isLoading && <Typography className={classes.none}> No Vulnerabilities </Typography>}</div>
); );
}; };
const renderCVESummary = () => {
if (cveSummary === undefined) {
return;
}
console.log('Test');
return !isEmpty(cveSummary) ? (
<VulnerabilityCountCard
total={cveSummary.Count}
critical={cveSummary.CriticalCount}
high={cveSummary.HighCount}
medium={cveSummary.MediumCount}
low={cveSummary.LowCount}
unknown={cveSummary.UnknownCount}
/>
) : (
<></>
);
};
const renderListBottom = () => { const renderListBottom = () => {
if (isLoading) { if (isLoading) {
return <Loading />; return <Loading />;
@ -286,14 +384,36 @@ function VulnerabilitiesDetails(props) {
<Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}> <Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}>
Vulnerabilities Vulnerabilities
</Typography> </Typography>
<IconButton disableRipple onClick={handleClickExport} className={classes.export}> <Stack direction="row" spacing="1rem" className={classes.viewModes}>
<DownloadIcon /> <IconButton disableRipple onClick={handleClickExport}>
</IconButton> <DownloadIcon />
<Snackbar </IconButton>
open={openExport && isLoadingAllCve} <Snackbar
message="Getting your data ready for export" open={openExport && isLoadingAllCve}
action={<CircularProgress size="2rem" sx={{ color: '#FFFFFF' }} />} message="Getting your data ready for export"
/> action={<CircularProgress size="2rem" sx={{ color: '#FFFFFF' }} />}
/>
<ToggleButton
value="viewLess"
title="Collapse list view"
size="small"
className={classes.view}
selected={!selectedViewMore}
onChange={() => setSelectedViewMore(false)}
>
<ViewHeadlineIcon />
</ToggleButton>
<ToggleButton
value="viewMore"
title="Expand list view"
size="small"
className={classes.view}
selected={selectedViewMore}
onChange={() => setSelectedViewMore(true)}
>
<ViewAgendaIcon />
</ToggleButton>
</Stack>
<Menu <Menu
anchorEl={anchorExport} anchorEl={anchorExport}
open={openExport} open={openExport}
@ -325,22 +445,46 @@ function VulnerabilitiesDetails(props) {
className={classes.popper} className={classes.popper}
data-testid="export-excel-menuItem" data-testid="export-excel-menuItem"
> >
MS Excel xlsx
</MenuItem> </MenuItem>
</Menu> </Menu>
</Stack> </Stack>
<Stack className={classes.search}> {renderCVESummary()}
<InputBase <Stack direction="row">
placeholder={'Search'} <div className={classes.dropdownArrowBox} onClick={handleExpandCVESearch}>
classes={{ root: classes.searchInputBase, input: classes.input }} {!openExcludeSearch ? (
onChange={debouncedChangeHandler} <KeyboardArrowRight className={classes.dropdownText} />
/> ) : (
<div className={classes.searchIcon}> <KeyboardArrowDown className={classes.dropdownText} />
<SearchIcon /> )}
</div> </div>
<Stack className={classes.test} direction="column" spacing="0.25em">
<Stack className={classes.search}>
<InputBase
placeholder={'Search'}
classes={{ root: classes.searchInputBase, input: classes.input }}
onChange={debouncedChangeHandler}
/>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
</Stack>
<Collapse in={openExcludeSearch} timeout="auto" unmountOnExit>
<Stack className={classes.search}>
<InputBase
placeholder={'Exclude'}
classes={{ root: classes.searchInputBase, input: classes.input }}
onChange={debouncedExcludeFilterChangeHandler}
/>
</Stack>
</Collapse>
</Stack>
</Stack>
<Stack direction="column" spacing={selectedViewMore ? '1rem' : '0.5rem'}>
{renderCVEs()}
{renderListBottom()}
</Stack> </Stack>
{renderCVEs()}
{renderListBottom()}
</Stack> </Stack>
); );
} }

View File

@ -96,7 +96,13 @@ const mapCVEInfo = (cveInfo) => {
id: cve.Id, id: cve.Id,
severity: cve.Severity, severity: cve.Severity,
title: cve.Title, title: cve.Title,
description: cve.Description description: cve.Description,
reference: cve.Reference,
packageList: cve.PackageList?.map((pkg) => ({
packageName: pkg.Name,
packageInstalledVersion: pkg.InstalledVersion,
packageFixedVersion: pkg.FixedVersion
}))
}; };
}); });
return cveList; return cveList;

View File

@ -37,7 +37,8 @@ test.describe('Tag page test', () => {
await page.goto(`${hosts.ui}/image/${tagWithVulnerabilities.title}/tag/${tagWithVulnerabilities.tag}`); await page.goto(`${hosts.ui}/image/${tagWithVulnerabilities.title}/tag/${tagWithVulnerabilities.tag}`);
await page.getByRole('tab', { name: 'Vulnerabilities' }).click(); await page.getByRole('tab', { name: 'Vulnerabilities' }).click();
await expect(page.getByTestId('vulnerability-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 }); await expect(page.getByTestId('vulnerability-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await expect(await page.getByText('CVE-').count()).toBeGreaterThan(0); await expect(page.getByText(/CVE-/).nth(0)).toBeVisible({ timeout: 100000 });
await expect(await page.getByText('CVE-').count()).toBeLessThanOrEqual(pageSizes.EXPLORE); await expect(await page.getByText(/CVE-/).count()).toBeGreaterThan(0);
await expect(await page.getByText(/CVE-/).count()).toBeLessThanOrEqual(pageSizes.EXPLORE);
}); });
}); });