Commit eced4b8e authored by Savas Vedova's avatar Savas Vedova

Merge branch '338790-add-project-report' into 'master'

Add new project vulnerability report

See merge request gitlab-org/gitlab!74498
parents c9202be3 27e752d2
<script>
import Cookies from 'js-cookie';
import { difference } from 'lodash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import {
API_FUZZING_NAME,
CLUSTER_IMAGE_SCANNING_NAME,
CONTAINER_SCANNING_NAME,
COVERAGE_FUZZING_NAME,
DEPENDENCY_SCANNING_NAME,
SECRET_DETECTION_NAME,
} from '~/security_configuration/components/constants';
import ReportNotConfiguredProject from '../shared/empty_states/report_not_configured_project.vue';
import VulnerabilityReportTabs from '../shared/vulnerability_report/vulnerability_report_tabs.vue';
import projectVulnerabilitiesQuery from '../../graphql/queries/project_vulnerabilities.query.graphql';
import AutoFixUserCallout from '../shared/auto_fix_user_callout.vue';
import ProjectPipelineStatus from '../shared/project_pipeline_status.vue';
import securityScannersQuery from '../../graphql/queries/project_security_scanners.query.graphql';
import SecurityScannerAlert from './security_scanner_alert.vue';
export default {
components: {
ReportNotConfiguredProject,
LocalStorageSync,
SecurityScannerAlert,
AutoFixUserCallout,
ProjectPipelineStatus,
VulnerabilityReportTabs,
},
mixins: [glFeatureFlagsMixin()],
inject: ['fullPath', 'pipeline', 'autoFixDocumentation'],
data() {
return {
scannerAlertDismissed: false,
securityScanners: {},
shouldShowAutoFixUserCallout:
this.glFeatures.securityAutoFix && !Cookies.get(this.$options.autoFixUserCalloutCookieName),
};
},
apollo: {
securityScanners: {
query: securityScannersQuery,
variables() {
return { fullPath: this.fullPath };
},
update({ project = {} }) {
const { available = [], enabled = [], pipelineRun = [] } = project?.securityScanners || {};
const translateScannerName = (scannerName) =>
this.$options.i18n[scannerName] || scannerName;
return {
available: available.map(translateScannerName),
enabled: enabled.map(translateScannerName),
pipelineRun: pipelineRun.map(translateScannerName),
};
},
},
},
computed: {
isReportConfigured() {
return this.pipeline?.id;
},
notEnabledSecurityScanners() {
const { available = [], enabled = [] } = this.securityScanners;
return difference(available, enabled);
},
noPipelineRunSecurityScanners() {
const { enabled = [], pipelineRun = [] } = this.securityScanners;
return difference(enabled, pipelineRun);
},
shouldShowScannersAlert() {
return (
!this.scannerAlertDismissed &&
(this.notEnabledSecurityScanners.length > 0 ||
this.noPipelineRunSecurityScanners.length > 0)
);
},
},
methods: {
closeAutoFixUserCallout() {
Cookies.set(this.$options.autoFixUserCalloutCookieName, 'true');
this.shouldShowAutoFixUserCallout = false;
},
setScannerAlertDismissed(value) {
this.scannerAlertDismissed = parseBoolean(value);
},
},
projectVulnerabilitiesQuery,
autoFixUserCalloutCookieName: 'auto_fix_user_callout_dismissed',
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY: 'vulnerability_list_scanner_alert_dismissed',
i18n: {
API_FUZZING: API_FUZZING_NAME,
CONTAINER_SCANNING: CONTAINER_SCANNING_NAME,
CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME,
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
},
};
</script>
<template>
<report-not-configured-project v-if="!isReportConfigured" />
<div v-else>
<local-storage-sync
:value="String(scannerAlertDismissed)"
:storage-key="$options.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY"
@input="setScannerAlertDismissed"
/>
<security-scanner-alert
v-if="shouldShowScannersAlert"
:not-enabled-scanners="notEnabledSecurityScanners"
:no-pipeline-run-scanners="noPipelineRunSecurityScanners"
@dismiss="setScannerAlertDismissed('true')"
/>
<auto-fix-user-callout
v-if="shouldShowAutoFixUserCallout"
:help-page-path="autoFixDocumentation"
@close="closeAutoFixUserCallout"
/>
<vulnerability-report-tabs :query="$options.projectVulnerabilitiesQuery">
<template #header-development>
<project-pipeline-status :pipeline="pipeline" />
</template>
</vulnerability-report-tabs>
</div>
</template>
......@@ -20,7 +20,7 @@ const deepFindVulnerabilities = (data) => {
export default {
components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList },
inject: ['fullPath', 'canViewFalsePositive'],
inject: ['fullPath', 'canViewFalsePositive', 'hasJiraVulnerabilitiesIntegrationEnabled'],
props: {
query: {
type: Object,
......@@ -59,6 +59,7 @@ export default {
fullPath: this.fullPath,
sort: this.sort,
vetEnabled: this.canViewFalsePositive,
includeExternalIssueLinks: this.hasJiraVulnerabilitiesIntegrationEnabled,
...this.filters,
};
},
......
......@@ -46,6 +46,8 @@ export default {
<gl-tabs class="gl-mt-5" content-class="gl-pt-0" sync-active-tab-with-query-params>
<gl-tab :title="$options.i18n.developmentTab" lazy>
<slot name="header-development"></slot>
<vulnerability-report
:type="$options.REPORT_TAB.DEVELOPMENT"
:query="query"
......@@ -60,6 +62,8 @@ export default {
>
<gl-card body-class="gl-p-6">{{ $options.i18n.operationalTabMessage }}</gl-card>
<slot name="header-operational"></slot>
<vulnerability-report
:type="$options.REPORT_TAB.OPERATIONAL"
:query="query"
......
#import "~/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql"
#import "../fragments/vulnerability.fragment.graphql"
query instanceVulnerabilities(
$after: String
$first: Int
$first: Int = 20
$projectId: [ID!]
$severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!]
......@@ -32,7 +31,8 @@ query instanceVulnerabilities(
...VulnerabilityFragment
}
pageInfo {
...PageInfo
endCursor
hasNextPage
}
}
}
#import "~/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql"
#import "../fragments/vulnerability.fragment.graphql"
query projectVulnerabilities(
$fullPath: ID!
$after: String
$first: Int
$first: Int = 20
$severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!]
$scanner: [String!]
......@@ -53,7 +52,8 @@ query projectVulnerabilities(
}
}
pageInfo {
...PageInfo
endCursor
hasNextPage
}
}
}
......
......@@ -6,11 +6,14 @@ import VulnerabilityReport from './components/shared/vulnerability_report.vue';
import apolloProvider from './graphql/provider';
import createRouter from './router';
import createStore from './store';
import ProjectVulnerabilityReport from './components/project/project_vulnerability_report.vue';
import GroupVulnerabilityReport from './components/group/group_vulnerability_report.vue';
import InstanceVulnerabilityReport from './components/instance/instance_vulnerability_report.vue';
const getVulnerabilityComponent = (dashboardType) => {
switch (dashboardType) {
case DASHBOARD_TYPES.PROJECT:
return ProjectVulnerabilityReport;
case DASHBOARD_TYPES.GROUP:
return GroupVulnerabilityReport;
case DASHBOARD_TYPES.INSTANCE:
......
......@@ -197,7 +197,7 @@ describe('Instance Security Dashboard Vulnerabilities Component', () => {
data: {
vulnerabilities: {
nodes: [],
pageInfo: { startCursor: '', endCursor: '' },
pageInfo: { endCursor: '', hasNextPage: false },
},
},
});
......
import { nextTick } from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Cookies from 'js-cookie';
import ProjectVulnerabilityReport from 'ee/security_dashboard/components/project/project_vulnerability_report.vue';
import ReportNotConfiguredProject from 'ee/security_dashboard/components/shared/empty_states/report_not_configured_project.vue';
import VulnerabilityReportTabs from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_report_tabs.vue';
import ProjectPipelineStatus from 'ee/security_dashboard/components/shared/project_pipeline_status.vue';
import SecurityScannerAlert from 'ee/security_dashboard/components/project/security_scanner_alert.vue';
import securityScannersQuery from 'ee/security_dashboard/graphql/queries/project_security_scanners.query.graphql';
import AutoFixUserCallout from 'ee/security_dashboard/components/shared/auto_fix_user_callout.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Project vulnerability report app component', () => {
useLocalStorageSpy();
let wrapper;
const securityScannersHandler = ({ available = [], enabled = [], pipelineRun = [] } = {}) =>
jest.fn().mockResolvedValue({
data: {
project: {
id: 1,
securityScanners: { available, enabled, pipelineRun },
},
},
});
const createWrapper = ({
pipeline = { id: 1 },
securityScanners,
securityAutoFix = false,
} = {}) => {
wrapper = shallowMount(ProjectVulnerabilityReport, {
localVue,
apolloProvider: createMockApollo([
[securityScannersQuery, securityScannersHandler(securityScanners)],
]),
provide: {
fullPath: '#',
autoFixDocumentation: '#',
pipeline,
glFeatures: { securityAutoFix },
},
stubs: {
VulnerabilityReportTabs,
LocalStorageSync,
},
});
};
const findReportNotConfiguredProject = () => wrapper.find(ReportNotConfiguredProject);
const findVulnerabilityReportTabs = () => wrapper.findComponent(VulnerabilityReportTabs);
const findAutoFixUserCallout = () => wrapper.findComponent(AutoFixUserCallout);
const findProjectPipelineStatus = () => wrapper.findComponent(ProjectPipelineStatus);
const findSecurityScannerAlert = (root = wrapper) => root.findComponent(SecurityScannerAlert);
afterEach(() => {
wrapper.destroy();
});
describe('report not configured component', () => {
it('shows the report not configured component if there are no projects', () => {
createWrapper({ pipeline: null });
expect(findReportNotConfiguredProject().exists()).toBe(true);
expect(findVulnerabilityReportTabs().exists()).toBe(false);
});
it('shows the vulnerability report tabs and project pipeline status components if there are projects', () => {
const pipeline = { id: 1 };
createWrapper({ pipeline });
expect(findReportNotConfiguredProject().exists()).toBe(false);
expect(findVulnerabilityReportTabs().exists()).toBe(true);
expect(findProjectPipelineStatus().props('pipeline')).toBe(pipeline);
});
});
describe('security scanner alerts component', () => {
describe.each`
available | enabled | pipelineRun | expectAlertShown
${['DAST']} | ${[]} | ${[]} | ${true}
${['DAST']} | ${['DAST']} | ${[]} | ${true}
${['DAST']} | ${[]} | ${['DAST']} | ${true}
${['DAST']} | ${['DAST']} | ${['DAST']} | ${false}
${[]} | ${[]} | ${[]} | ${false}
`('visibility', ({ available, enabled, pipelineRun, expectAlertShown }) => {
it(`should${expectAlertShown ? '' : ' not'} show the alert`, async () => {
createWrapper({ securityScanners: { available, enabled, pipelineRun } });
await nextTick();
expect(findSecurityScannerAlert().exists()).toBe(expectAlertShown);
});
it('should never show the alert once it has been dismissed', () => {
window.localStorage.setItem(
ProjectVulnerabilityReport.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
'true',
);
createWrapper({ securityScanners: { available, enabled, pipelineRun } });
expect(findSecurityScannerAlert().exists()).toBe(false);
});
});
describe('dismissal', () => {
beforeEach(() => {
createWrapper({
securityScanners: { available: ['DAST'], enabled: [], pipelineRun: [] },
});
});
it('should hide the alert when it is dismissed', async () => {
const scannerAlert = findSecurityScannerAlert();
expect(scannerAlert.exists()).toBe(true);
scannerAlert.vm.$emit('dismiss');
await nextTick();
expect(scannerAlert.exists()).toBe(false);
});
it('should remember the dismissal state', async () => {
findSecurityScannerAlert().vm.$emit('dismiss');
await nextTick();
expect(window.localStorage.setItem).toHaveBeenCalledWith(
ProjectVulnerabilityReport.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
'true',
);
});
});
});
describe('auto fix user callout component', () => {
it('does not show user callout when feature flag is disabled', () => {
createWrapper({ securityAutoFix: false });
expect(findAutoFixUserCallout().exists()).toBe(false);
});
it('shows user callout when the cookie is not set and hides it when dismissed', async () => {
jest.spyOn(Cookies, 'set');
createWrapper({ securityAutoFix: true });
const autoFixUserCallOut = findAutoFixUserCallout();
expect(autoFixUserCallOut.exists()).toBe(true);
autoFixUserCallOut.vm.$emit('close');
await nextTick();
expect(autoFixUserCallOut.exists()).toBe(false);
expect(Cookies.set).toHaveBeenCalledWith(
wrapper.vm.$options.autoFixUserCalloutCookieName,
'true',
);
});
it('does not show user callout when the cookie is set', () => {
jest.doMock('js-cookie', () => ({ get: jest.fn().mockReturnValue(true) }));
createWrapper({ securityAutoFix: true });
expect(findAutoFixUserCallout().exists()).toBe(false);
});
});
});
......@@ -38,6 +38,7 @@ describe('Vulnerability list GraphQL component', () => {
vulnerabilitiesHandler = vulnerabilitiesRequestHandler,
canViewFalsePositive = false,
showProjectNamespace = false,
hasJiraVulnerabilitiesIntegrationEnabled = false,
filters = {},
fields = [],
} = {}) => {
......@@ -47,6 +48,7 @@ describe('Vulnerability list GraphQL component', () => {
provide: {
fullPath,
canViewFalsePositive,
hasJiraVulnerabilitiesIntegrationEnabled,
},
propsData: {
query: vulnerabilitiesQuery,
......@@ -86,6 +88,19 @@ describe('Vulnerability list GraphQL component', () => {
},
);
it.each([true, false])(
'calls the query with the expected includeExternalIssueLinks property when hasJiraVulnerabilitiesIntegrationEnabled is %s',
(hasJiraVulnerabilitiesIntegrationEnabled) => {
createWrapper({ hasJiraVulnerabilitiesIntegrationEnabled });
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({
includeExternalIssueLinks: hasJiraVulnerabilitiesIntegrationEnabled,
}),
);
},
);
it('does not call the query if filters are not ready', () => {
createWrapper({ filters: null });
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment