Commit d0aa7709 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '321730-enforce-json-schema-validation-for-generic-security-report-fe' into 'master'

Enforce JSON schema validation for generic security report - FE

See merge request gitlab-org/gitlab!62971
parents d55bcecc 5b21719b
...@@ -6,6 +6,7 @@ import { fetchPolicies } from '~/lib/graphql'; ...@@ -6,6 +6,7 @@ import { fetchPolicies } from '~/lib/graphql';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import VulnerabilityReport from '../vulnerability_report.vue'; import VulnerabilityReport from '../vulnerability_report.vue';
import ScanErrorsAlert from './scan_errors_alert.vue';
import SecurityDashboard from './security_dashboard_vuex.vue'; import SecurityDashboard from './security_dashboard_vuex.vue';
import SecurityReportsSummary from './security_reports_summary.vue'; import SecurityReportsSummary from './security_reports_summary.vue';
...@@ -13,11 +14,32 @@ export default { ...@@ -13,11 +14,32 @@ export default {
name: 'PipelineSecurityDashboard', name: 'PipelineSecurityDashboard',
components: { components: {
GlEmptyState, GlEmptyState,
ScanErrorsAlert,
SecurityReportsSummary, SecurityReportsSummary,
SecurityDashboard, SecurityDashboard,
VulnerabilityReport, VulnerabilityReport,
}, },
mixins: [glFeatureFlagMixin()], mixins: [glFeatureFlagMixin()],
inject: ['projectFullPath', 'pipeline', 'dashboardDocumentation', 'emptyStateSvgPath'],
props: {
projectId: {
type: Number,
required: true,
},
vulnerabilitiesEndpoint: {
type: String,
required: true,
},
loadingErrorIllustrations: {
type: Object,
required: true,
},
},
data() {
return {
securityReportSummary: {},
};
},
apollo: { apollo: {
securityReportSummary: { securityReportSummary: {
query: pipelineSecurityReportSummaryQuery, query: pipelineSecurityReportSummaryQuery,
...@@ -34,21 +56,6 @@ export default { ...@@ -34,21 +56,6 @@ export default {
}, },
}, },
}, },
inject: ['projectFullPath', 'pipeline', 'dashboardDocumentation', 'emptyStateSvgPath'],
props: {
projectId: {
type: Number,
required: true,
},
vulnerabilitiesEndpoint: {
type: String,
required: true,
},
loadingErrorIllustrations: {
type: Object,
required: true,
},
},
computed: { computed: {
shouldShowGraphqlVulnerabilityReport() { shouldShowGraphqlVulnerabilityReport() {
return this.glFeatures.pipelineSecurityDashboardGraphql; return this.glFeatures.pipelineSecurityDashboardGraphql;
...@@ -64,6 +71,20 @@ export default { ...@@ -64,6 +71,20 @@ export default {
primaryButtonText: s__('SecurityReports|Learn more about setting up your dashboard'), primaryButtonText: s__('SecurityReports|Learn more about setting up your dashboard'),
}; };
}, },
scansWithErrors() {
const getScans = (reportSummary) => reportSummary?.scans || [];
const hasErrors = (scan) => Boolean(scan.errors?.length);
return this.securityReportSummary
? Object.values(this.securityReportSummary)
// generate flat array of all scans
.flatMap(getScans)
.filter(hasErrors)
: [];
},
hasScansWithErrors() {
return this.scansWithErrors.length > 0;
},
}, },
created() { created() {
this.setSourceBranch(this.pipeline.sourceBranch); this.setSourceBranch(this.pipeline.sourceBranch);
...@@ -79,11 +100,10 @@ export default { ...@@ -79,11 +100,10 @@ export default {
<template> <template>
<div> <div>
<security-reports-summary <div v-if="securityReportSummary" class="gl-my-5">
v-if="securityReportSummary" <scan-errors-alert v-if="hasScansWithErrors" :scans="scansWithErrors" class="gl-mb-5" />
:summary="securityReportSummary" <security-reports-summary :summary="securityReportSummary" />
class="gl-my-5" </div>
/>
<security-dashboard <security-dashboard
v-if="!shouldShowGraphqlVulnerabilityReport" v-if="!shouldShowGraphqlVulnerabilityReport"
:vulnerabilities-endpoint="vulnerabilitiesEndpoint" :vulnerabilities-endpoint="vulnerabilitiesEndpoint"
......
<script>
import { GlAccordion, GlAccordionItem, GlAlert, GlButton, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlAccordion,
GlAccordionItem,
GlAlert,
GlButton,
GlSprintf,
},
inject: ['securityReportHelpPageLink'],
props: {
scans: {
type: Array,
required: true,
},
},
computed: {
scansWithTitles() {
return this.scans.map((scan) => ({
...scan,
title: `${scan.name} (${scan.errors.length})`,
}));
},
},
i18n: {
title: s__('SecurityReports|Error parsing security reports'),
description: s__(
'SecurityReports|The security reports below contain one or more vulnerability findings that could not be parsed and were not recorded. Download the artifacts in the job output to investigate. Ensure any security report created conforms to the relevant %{helpPageLinkStart}JSON schema%{helpPageLinkEnd}.',
),
},
};
</script>
<template>
<gl-alert variant="danger" :dismissible="false">
<strong role="heading">
{{ $options.i18n.title }}
</strong>
<p class="gl-mt-3">
<gl-sprintf :message="$options.i18n.description" data-testid="description">
<template #helpPageLink="{ content }">
<gl-button
variant="link"
icon="external-link"
:href="securityReportHelpPageLink"
target="_blank"
>
{{ content }}
</gl-button>
</template>
</gl-sprintf>
</p>
<gl-accordion :header-level="3">
<gl-accordion-item
v-for="{ name, errors, title } in scansWithTitles"
:key="name"
:title="title"
>
<ul class="gl-pl-4">
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
</gl-accordion-item>
</gl-accordion>
</gl-alert>
</template>
fragment SecurityReportSummaryScans on SecurityReportSummarySection {
scans {
nodes {
name
errors
}
}
}
query($fullPath: ID!, $pipelineIid: ID!) { #import "../fragments/security_report_scans.fragment.graphql"
query pipelineSecuritySummary($fullPath: ID!, $pipelineIid: ID!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
pipeline(iid: $pipelineIid) { pipeline(iid: $pipelineIid) {
securityReportSummary { securityReportSummary {
dast { dast {
vulnerabilitiesCount vulnerabilitiesCount
scannedResourcesCsvPath scannedResourcesCsvPath
...SecurityReportSummaryScans
# The following fields will be added in # The following fields will be added in
# https://gitlab.com/gitlab-org/gitlab/-/issues/321586 # https://gitlab.com/gitlab-org/gitlab/-/issues/321586
# scannedResourcesCount # scannedResourcesCount
...@@ -17,18 +20,23 @@ query($fullPath: ID!, $pipelineIid: ID!) { ...@@ -17,18 +20,23 @@ query($fullPath: ID!, $pipelineIid: ID!) {
} }
sast { sast {
vulnerabilitiesCount vulnerabilitiesCount
...SecurityReportSummaryScans
} }
containerScanning { containerScanning {
vulnerabilitiesCount vulnerabilitiesCount
...SecurityReportSummaryScans
} }
dependencyScanning { dependencyScanning {
vulnerabilitiesCount vulnerabilitiesCount
...SecurityReportSummaryScans
} }
apiFuzzing { apiFuzzing {
vulnerabilitiesCount vulnerabilitiesCount
...SecurityReportSummaryScans
} }
coverageFuzzing { coverageFuzzing {
vulnerabilitiesCount vulnerabilitiesCount
...SecurityReportSummaryScans
} }
} }
} }
......
...@@ -26,6 +26,7 @@ export default () => { ...@@ -26,6 +26,7 @@ export default () => {
projectFullPath, projectFullPath,
pipelineJobsPath, pipelineJobsPath,
canAdminVulnerability, canAdminVulnerability,
securityReportHelpPageLink,
} = el.dataset; } = el.dataset;
const loadingErrorIllustrations = { const loadingErrorIllustrations = {
...@@ -51,6 +52,7 @@ export default () => { ...@@ -51,6 +52,7 @@ export default () => {
jobsPath: pipelineJobsPath, jobsPath: pipelineJobsPath,
sourceBranch, sourceBranch,
}, },
securityReportHelpPageLink,
}, },
render(createElement) { render(createElement) {
return createElement(PipelineSecurityDashboard, { return createElement(PipelineSecurityDashboard, {
......
...@@ -21,7 +21,8 @@ ...@@ -21,7 +21,8 @@
empty_state_unauthorized_svg_path: image_path('illustrations/user-not-logged-in.svg'), empty_state_unauthorized_svg_path: image_path('illustrations/user-not-logged-in.svg'),
empty_state_forbidden_svg_path: image_path('illustrations/lock_promotion.svg'), empty_state_forbidden_svg_path: image_path('illustrations/lock_promotion.svg'),
project_full_path: project.path_with_namespace, project_full_path: project.path_with_namespace,
can_admin_vulnerability: can?(current_user, :admin_vulnerability, project).to_s } } can_admin_vulnerability: can?(current_user, :admin_vulnerability, project).to_s,
security_report_help_page_link: help_page_path('user/application_security/index', anchor: 'security-report-validation') } }
- if pipeline.expose_license_scanning_data? - if pipeline.expose_license_scanning_data?
#js-tab-licenses.tab-pane #js-tab-licenses.tab-pane
......
...@@ -2,6 +2,7 @@ import { GlEmptyState } from '@gitlab/ui'; ...@@ -2,6 +2,7 @@ import { GlEmptyState } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import PipelineSecurityDashboard from 'ee/security_dashboard/components/pipeline/pipeline_security_dashboard.vue'; import PipelineSecurityDashboard from 'ee/security_dashboard/components/pipeline/pipeline_security_dashboard.vue';
import ScanErrorsAlert from 'ee/security_dashboard/components/pipeline/scan_errors_alert.vue';
import SecurityDashboard from 'ee/security_dashboard/components/pipeline/security_dashboard_vuex.vue'; import SecurityDashboard from 'ee/security_dashboard/components/pipeline/security_dashboard_vuex.vue';
import SecurityReportsSummary from 'ee/security_dashboard/components/pipeline/security_reports_summary.vue'; import SecurityReportsSummary from 'ee/security_dashboard/components/pipeline/security_reports_summary.vue';
import VulnerabilityReport from 'ee/security_dashboard/components/vulnerability_report.vue'; import VulnerabilityReport from 'ee/security_dashboard/components/vulnerability_report.vue';
...@@ -28,6 +29,7 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -28,6 +29,7 @@ describe('Pipeline Security Dashboard component', () => {
const findSecurityDashboard = () => wrapper.findComponent(SecurityDashboard); const findSecurityDashboard = () => wrapper.findComponent(SecurityDashboard);
const findVulnerabilityReport = () => wrapper.findComponent(VulnerabilityReport); const findVulnerabilityReport = () => wrapper.findComponent(VulnerabilityReport);
const findScanErrorsAlert = () => wrapper.findComponent(ScanErrorsAlert);
const factory = ({ data, stubs, provide } = {}) => { const factory = ({ data, stubs, provide } = {}) => {
store = new Vuex.Store({ store = new Vuex.Store({
...@@ -136,7 +138,7 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -136,7 +138,7 @@ describe('Pipeline Security Dashboard component', () => {
}); });
it('renders empty state component with correct props', () => { it('renders empty state component with correct props', () => {
const emptyState = wrapper.find(GlEmptyState); const emptyState = wrapper.findComponent(GlEmptyState);
expect(emptyState.props()).toMatchObject({ expect(emptyState.props()).toMatchObject({
svgPath: '/svgs/empty/svg', svgPath: '/svgs/empty/svg',
...@@ -148,6 +150,69 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -148,6 +150,69 @@ describe('Pipeline Security Dashboard component', () => {
}); });
}); });
describe('scans error alert', () => {
describe('with errors', () => {
const securityReportSummary = {
scanner_1: {
// this scan contains errors
scans: [
{ errors: ['scanner 1 - error 1', 'scanner 1 - error 2'], name: 'foo' },
{ errors: ['scanner 1 - error 3', 'scanner 1 - error 4'], name: 'bar' },
],
},
scanner_2: null,
scanner_3: {
// this scan contains errors
scans: [{ errors: ['scanner 3 - error 1', 'scanner 3 - error 2'], name: 'baz' }],
},
scanner_4: {
scans: [{ errors: [], name: 'quz' }],
},
};
const scansWithErrors = [
...securityReportSummary.scanner_1.scans,
...securityReportSummary.scanner_3.scans,
];
beforeEach(() => {
factory({
data: {
securityReportSummary,
},
});
});
it('shows an alert with information about each scan with errors', () => {
expect(findScanErrorsAlert().props('scans')).toEqual(scansWithErrors);
});
});
describe('without errors', () => {
const securityReportSummary = {
dast: {
scans: [
{
name: 'dast',
errors: [],
},
],
},
};
beforeEach(() => {
factory({
data: {
securityReportSummary,
},
});
});
it('does not show the alert', () => {
expect(findScanErrorsAlert().exists()).toBe(false);
});
});
});
describe('security reports summary', () => { describe('security reports summary', () => {
const securityReportSummary = { const securityReportSummary = {
dast: { dast: {
...@@ -161,7 +226,7 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -161,7 +226,7 @@ describe('Pipeline Security Dashboard component', () => {
securityReportSummary, securityReportSummary,
}, },
}); });
expect(wrapper.find(SecurityReportsSummary).exists()).toBe(true); expect(wrapper.findComponent(SecurityReportsSummary).exists()).toBe(true);
}); });
it('does not show the summary if it is empty', () => { it('does not show the summary if it is empty', () => {
...@@ -170,7 +235,7 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -170,7 +235,7 @@ describe('Pipeline Security Dashboard component', () => {
securityReportSummary: null, securityReportSummary: null,
}, },
}); });
expect(wrapper.find(SecurityReportsSummary).exists()).toBe(false); expect(wrapper.findComponent(SecurityReportsSummary).exists()).toBe(false);
}); });
}); });
}); });
import { GlAccordion, GlAccordionItem, GlAlert, GlButton, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PipelineScanErrorsAlert from 'ee/security_dashboard/components/pipeline/scan_errors_alert.vue';
import { trimText } from 'helpers/text_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
const TEST_HELP_PAGE_LINK = 'http://help.com';
const TEST_SCANS_WITH_ERRORS = [
{ errors: ['scanner 1 - error 1', 'scanner 1 - error 2'], name: 'foo' },
{ errors: ['scanner 1 - error 3', 'scanner 1 - error 4'], name: 'bar' },
{ errors: ['scanner 3 - error 1', 'scanner 3 - error 2'], name: 'baz' },
];
describe('ee/security_dashboard/components/pipeline_scan_errors_alert.vue', () => {
let wrapper;
const createWrapper = () =>
extendedWrapper(
shallowMount(PipelineScanErrorsAlert, {
propsData: {
scans: TEST_SCANS_WITH_ERRORS,
},
provide: {
securityReportHelpPageLink: TEST_HELP_PAGE_LINK,
},
stubs: {
GlSprintf,
},
}),
);
const findAccordion = () => wrapper.findComponent(GlAccordion);
const findAllAccordionItems = () => wrapper.findAllComponents(GlAccordionItem);
const findAccordionItemsWithTitle = (title) =>
findAllAccordionItems().filter((item) => item.props('title') === title);
const findAlert = () => wrapper.findComponent(GlAlert);
const findErrorList = () => wrapper.findByRole('list');
const findHelpPageLink = () => wrapper.findComponent(GlButton);
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
wrapper = createWrapper();
});
it('shows a non-dismissible error alert', () => {
expect(findAlert().props()).toMatchObject({
variant: 'danger',
dismissible: false,
});
});
it('shows the correct title for the error alert', () => {
expect(findAlert().text()).toContain('Error parsing security reports');
});
it('shows the correct description for the error-alert', () => {
expect(trimText(findAlert().text())).toContain(
'The security reports below contain one or more vulnerability findings that could not be parsed and were not recorded. Download the artifacts in the job output to investigate. Ensure any security report created conforms to the relevant JSON schema',
);
});
it('links to the security-report help page', () => {
expect(findHelpPageLink().attributes('href')).toBe(TEST_HELP_PAGE_LINK);
});
describe('errors details', () => {
it('shows an accordion containing a list of scans with errors', () => {
expect(findAccordion().exists()).toBe(true);
expect(findAllAccordionItems()).toHaveLength(TEST_SCANS_WITH_ERRORS.length);
});
it('shows a list containing details about each error', () => {
expect(findErrorList().exists()).toBe(true);
});
describe.each(TEST_SCANS_WITH_ERRORS)('scan errors', (scan) => {
const currentScanTitle = `${scan.name} (${scan.errors.length})`;
const findAllAccordionItemsForCurrentScan = () =>
findAccordionItemsWithTitle(currentScanTitle);
const findAccordionItemForCurrentScan = () => findAllAccordionItemsForCurrentScan().at(0);
it(`contains an accordion item with the correct title for scan "${scan.name}"`, () => {
expect(findAllAccordionItemsForCurrentScan()).toHaveLength(1);
});
it(`contains a detailed list of errors for scan "${scan.name}}"`, () => {
expect(findAccordionItemForCurrentScan().find('ul').exists()).toBe(true);
expect(findAccordionItemForCurrentScan().findAll('li')).toHaveLength(scan.errors.length);
});
});
});
});
...@@ -29150,6 +29150,9 @@ msgstr "" ...@@ -29150,6 +29150,9 @@ msgstr ""
msgid "SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again." msgid "SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again."
msgstr "" msgstr ""
msgid "SecurityReports|Error parsing security reports"
msgstr ""
msgid "SecurityReports|Failed to get security report information. Please reload the page or try again later." msgid "SecurityReports|Failed to get security report information. Please reload the page or try again later."
msgstr "" msgstr ""
...@@ -29255,6 +29258,9 @@ msgstr "" ...@@ -29255,6 +29258,9 @@ msgstr ""
msgid "SecurityReports|Take survey" msgid "SecurityReports|Take survey"
msgstr "" msgstr ""
msgid "SecurityReports|The security reports below contain one or more vulnerability findings that could not be parsed and were not recorded. Download the artifacts in the job output to investigate. Ensure any security report created conforms to the relevant %{helpPageLinkStart}JSON schema%{helpPageLinkEnd}."
msgstr ""
msgid "SecurityReports|There was an error adding the comment." msgid "SecurityReports|There was an error adding the comment."
msgstr "" msgstr ""
......
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