Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cve): add more information #419

Merged
merged 1 commit into from
Feb 5, 2024
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
72 changes: 43 additions & 29 deletions src/__tests__/TagPage/VulnerabilitiesDetails.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,12 @@ const mockCVEFixed = {
}
]
}
},
pageNotFixed: {
ImageListWithCVEFixed: {
Page: { TotalCount: 0, ItemCount: 0 },
Results: []
}
}
};

Expand All @@ -504,15 +510,23 @@ afterEach(() => {

describe('Vulnerabilties page', () => {
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 />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20));
await waitFor(() => expect(screen.getAllByText(/Fixed in/)).toHaveLength(20));
});

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 />);
const cveSearchInput = screen.getByPlaceholderText(/search/i);
jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } });
Expand All @@ -522,7 +536,11 @@ describe('Vulnerabilties page', () => {
});

it('should have a collapsable 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 />);
const cveSearchInput = screen.getByPlaceholderText(/search/i);
const expandSearch = cveSearchInput.parentElement.parentElement.parentElement.parentElement.childNodes[0];
Expand All @@ -544,19 +562,14 @@ describe('Vulnerabilties page', () => {
await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1));
});

it('should open and close description dropdown for vulnerabilities', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
it('should show description for vulnerabilities', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageNotFixed } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText(/description/i)).toHaveLength(20));
const openText = screen.getAllByText(/description/i);
await fireEvent.click(openText[0]);
await waitFor(() => expect(screen.getAllByText(/Description/)).toHaveLength(20));
await waitFor(() =>
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 () => {
Expand All @@ -574,23 +587,24 @@ describe('Vulnerabilties page', () => {
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } });
render(<StateVulnerabilitiesWrapper />);
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());
const loadMoreBtn = screen.getByText(/load more/i);
expect(loadMoreBtn).toBeInTheDocument();
await waitFor(() => expect(screen.getAllByText(/load more/i).length).toBeGreaterThan(0));
const nrLoadButtons = screen.getAllByText(/load more/i).length
const loadMoreBtn = screen.getAllByText(/load more/i)[0];
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();
});

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 } });
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));
const downloadBtn = await screen.findAllByTestId('DownloadIcon');
Expand All @@ -610,10 +624,11 @@ describe('Vulnerabilties page', () => {
});

it('should expand/collapse the list of CVEs', async () => {
jest
.spyOn(api, 'get')
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValueOnce({ 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.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));
Expand All @@ -629,12 +644,11 @@ describe('Vulnerabilties page', () => {
jest
.spyOn(api, 'get')
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockRejectedValue({ status: 500, data: {} });
.mockRejectedValueOnce({ status: 500, data: {} });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
await fireEvent.click(screen.getAllByText(/fixed in/i)[0]);
await waitFor(() => expect(screen.getByText(/not fixed/i)).toBeInTheDocument());
await waitFor(() => expect(error).toBeCalledTimes(1));
await waitFor(() => expect(screen.getAllByText(/not fixed/i).length).toBeGreaterThan(0));
await waitFor(() => expect(error).toBeCalled());
});
});
2 changes: 1 addition & 1 deletion src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const endpoints = {
if (!isEmpty(excludedTerm)) {
query += `, excludedCVE: "${excludedTerm}"`;
}
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount}}}`;
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) =>
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`,
Expand Down
158 changes: 117 additions & 41 deletions src/components/Shared/VulnerabilityCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,60 @@ const useStyles = makeStyles((theme) => ({
marginTop: '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: {
textAlign: 'left',
color: '#606060',
padding: '2% 3% 2% 3%',
width: '100%'
},
contentCollapsed: {
textAlign: 'left',
color: '#606060',
padding: '1% 3% 1% 3%',
width: '100%',
'&:last-child': {
paddingBottom: '1%'
}
},
cveId: {
color: theme.palette.primary.main,
fontSize: '1rem',
fontWeight: 400,
textDecoration: 'underline'
},
cveIdCollapsed: {
color: theme.palette.primary.main,
fontSize: '0.75rem',
fontWeight: 500,
textDecoration: 'underline',
flexBasis: '19%'
},
cveSummary: {
color: theme.palette.secondary.dark,
fontSize: '0.75rem',
fontWeight: '600',
textOverflow: 'ellipsis',
marginTop: '0.5rem'
},
cveSummaryCollapsed: {
color: theme.palette.secondary.dark,
fontSize: '0.75rem',
fontWeight: '600',
textOverflow: 'ellipsis',
flexBasis: '82%'
},
link: {
color: '#52637A',
fontSize: '1rem',
Expand All @@ -72,14 +107,15 @@ const useStyles = makeStyles((theme) => ({
},
vulnerabilityCardDivider: {
margin: '1rem 0'
},
cveInfo: {
marginTop: '2%'
}
}));
function VulnerabilitiyCard(props) {
const classes = useStyles();
const { cve, name, platform, expand } = props;
const [openCVE, setOpenCVE] = useState(expand);
const [openDesc, setOpenDesc] = useState(false);
const [openFixed, setOpenFixed] = useState(false);
const [loadingFixed, setLoadingFixed] = useState(true);
const [fixedInfo, setFixedInfo] = useState([]);
const abortController = useMemo(() => new AbortController(), []);
Expand All @@ -89,7 +125,7 @@ function VulnerabilitiyCard(props) {
const [isEndOfList, setIsEndOfList] = useState(false);

const getPaginatedResults = () => {
if (!openFixed || isEndOfList) {
if (isEndOfList) {
return;
}
setLoadingFixed(true);
Expand Down Expand Up @@ -125,7 +161,7 @@ function VulnerabilitiyCard(props) {
return () => {
abortController.abort();
};
}, [openFixed, pageNumber]);
}, [pageNumber]);

useEffect(() => {
setOpenCVE(expand);
Expand Down Expand Up @@ -172,59 +208,99 @@ function VulnerabilitiyCard(props) {
};

return (
<Card className={classes.card} raised>
<CardContent className={classes.content}>
<Stack direction="row" spacing="1.25rem">
<Card className={openCVE ? classes.card : classes.cardCollapsed} raised>
<CardContent className={openCVE ? classes.content : classes.contentCollapsed}>
<Stack direction="row" spacing={openCVE ? '1.25rem' : '0.5rem'}>
{!openCVE ? (
<KeyboardArrowRight className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} />
) : (
<KeyboardArrowDown className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} />
)}
<Typography variant="body1" align="left" className={classes.cveId}>
<Typography variant="body1" align="left" className={openCVE ? classes.cveId : classes.cveIdCollapsed}>
{cve.id}
</Typography>
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
{openCVE ? (
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
) : (
<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>
)}
</Stack>
<Collapse in={openCVE} timeout="auto" unmountOnExit>
<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} />
)}
<Typography className={classes.dropdownText}>Fixed in</Typography>
<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>
<Collapse in={openFixed} timeout="auto" unmountOnExit>
<Box sx={{ width: '100%', padding: '0.5rem 0' }}>
{loadingFixed ? (
'Loading...'
) : (
<Stack direction="row" sx={{ flexWrap: 'wrap' }}>
{renderFixedVer()}
{renderLoadMore()}
</Stack>
)}
</Box>
</Collapse>
<Stack className={classes.dropdown} onClick={() => setOpenDesc(!openDesc)}>
{!openDesc ? (
<KeyboardArrowRight className={classes.dropdownText} />
<Typography variant="body2" align="left" className={classes.cveInfo}>
Fixed in
</Typography>
<Box sx={{ width: '100%', padding: '0.5rem 0' }}>
{loadingFixed ? (
'Loading...'
) : (
<KeyboardArrowDown className={classes.dropdownText} />
<Stack direction="row" sx={{ flexWrap: 'wrap' }}>
{renderFixedVer()}
{renderLoadMore()}
</Stack>
)}
<Typography className={classes.dropdownText}>Description</Typography>
</Stack>
<Collapse in={openDesc} timeout="auto" unmountOnExit>
<Box sx={{ padding: '0.5rem 0' }}>
<Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}>
{cve.description}
</Typography>
</Box>
</Collapse>
</Box>
<Typography variant="body2" align="left" className={classes.cveInfo}>
Description
</Typography>
<Box sx={{ padding: '0.5rem 0' }}>
<Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}>
{cve.description}
</Typography>
</Box>
</Collapse>
</CardContent>
</Card>
Expand Down
6 changes: 4 additions & 2 deletions src/components/Tag/Tabs/VulnerabilitiesDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,10 @@ function VulnerabilitiesDetails(props) {
</Collapse>
</Stack>
</Stack>
{renderCVEs()}
{renderListBottom()}
<Stack direction="column" spacing={selectedViewMore ? '1rem' : '0.5rem'}>
{renderCVEs()}
{renderListBottom()}
</Stack>
</Stack>
);
}
Expand Down
Loading
Loading