Commit 61418b8a authored by Dave Pisek's avatar Dave Pisek

Show security report scan errors on pipeline view

This commit adds an alert which shows if a pipeline contains
invalid JSON data related to the generic security report schema.

Changelog: added
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/62971
EE: true
parent e018b2b4
......@@ -6,6 +6,7 @@ import { fetchPolicies } from '~/lib/graphql';
import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import VulnerabilityReport from '../vulnerability_report.vue';
import ScanErrorsAlert from './scan_errors_alert.vue';
import SecurityDashboard from './security_dashboard_vuex.vue';
import SecurityReportsSummary from './security_reports_summary.vue';
......@@ -13,11 +14,32 @@ export default {
name: 'PipelineSecurityDashboard',
components: {
GlEmptyState,
ScanErrorsAlert,
SecurityReportsSummary,
SecurityDashboard,
VulnerabilityReport,
},
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: {
securityReportSummary: {
query: pipelineSecurityReportSummaryQuery,
......@@ -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: {
shouldShowGraphqlVulnerabilityReport() {
return this.glFeatures.pipelineSecurityDashboardGraphql;
......@@ -79,11 +86,10 @@ export default {
<template>
<div>
<security-reports-summary
v-if="securityReportSummary"
:summary="securityReportSummary"
class="gl-my-5"
/>
<div v-if="securityReportSummary" class="gl-my-5">
<scan-errors-alert :security-report-summary="securityReportSummary" class="gl-mb-5" />
<security-reports-summary :summary="securityReportSummary" />
</div>
<security-dashboard
v-if="!shouldShowGraphqlVulnerabilityReport"
:vulnerabilities-endpoint="vulnerabilitiesEndpoint"
......
<script>
import { GlAccordion, GlAccordionItem, GlAlert, GlButton, GlSprintf } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
export default {
components: {
GlAccordion,
GlAccordionItem,
GlAlert,
GlButton,
GlSprintf,
},
inject: ['securityReportHelpPageLink'],
props: {
securityReportSummary: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
scansWithErrors() {
const getScans = (reportSummary) => reportSummary?.scans || [];
const hasErrors = (scan) => Boolean(scan.errors?.length);
const addTitle = (scan) => ({
...scan,
title: sprintf(s__('SecurityReports|%{errorName} (%{errorCount})'), {
errorName: scan.name,
errorCount: scan.errors.length,
}),
});
return this.securityReportSummary
? Object.values(this.securityReportSummary)
// generate flat array of all scans
.flatMap(getScans)
.filter(hasErrors)
.map(addTitle)
: [];
},
hasScansWithErrors() {
return this.scansWithErrors.length > 0;
},
},
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 v-if="hasScansWithErrors" 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 scansWithErrors"
: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) {
pipeline(iid: $pipelineIid) {
securityReportSummary {
dast {
vulnerabilitiesCount
scannedResourcesCsvPath
...SecurityReportSummaryScans
# The following fields will be added in
# https://gitlab.com/gitlab-org/gitlab/-/issues/321586
# scannedResourcesCount
......@@ -17,18 +20,23 @@ query($fullPath: ID!, $pipelineIid: ID!) {
}
sast {
vulnerabilitiesCount
...SecurityReportSummaryScans
}
containerScanning {
vulnerabilitiesCount
...SecurityReportSummaryScans
}
dependencyScanning {
vulnerabilitiesCount
...SecurityReportSummaryScans
}
apiFuzzing {
vulnerabilitiesCount
...SecurityReportSummaryScans
}
coverageFuzzing {
vulnerabilitiesCount
...SecurityReportSummaryScans
}
}
}
......
......@@ -26,6 +26,7 @@ export default () => {
projectFullPath,
pipelineJobsPath,
canAdminVulnerability,
securityReportHelpPageLink,
} = el.dataset;
const loadingErrorIllustrations = {
......@@ -51,6 +52,7 @@ export default () => {
jobsPath: pipelineJobsPath,
sourceBranch,
},
securityReportHelpPageLink,
},
render(createElement) {
return createElement(PipelineSecurityDashboard, {
......
......@@ -21,7 +21,8 @@
empty_state_unauthorized_svg_path: image_path('illustrations/user-not-logged-in.svg'),
empty_state_forbidden_svg_path: image_path('illustrations/lock_promotion.svg'),
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?
#js-tab-licenses.tab-pane
......
......@@ -2,6 +2,7 @@ import { GlEmptyState } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
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 SecurityReportsSummary from 'ee/security_dashboard/components/pipeline/security_reports_summary.vue';
import VulnerabilityReport from 'ee/security_dashboard/components/vulnerability_report.vue';
......@@ -28,6 +29,7 @@ describe('Pipeline Security Dashboard component', () => {
const findSecurityDashboard = () => wrapper.findComponent(SecurityDashboard);
const findVulnerabilityReport = () => wrapper.findComponent(VulnerabilityReport);
const findScanErrorsAlert = () => wrapper.findComponent(ScanErrorsAlert);
const factory = ({ data, stubs, provide } = {}) => {
store = new Vuex.Store({
......@@ -136,7 +138,7 @@ describe('Pipeline Security Dashboard component', () => {
});
it('renders empty state component with correct props', () => {
const emptyState = wrapper.find(GlEmptyState);
const emptyState = wrapper.findComponent(GlEmptyState);
expect(emptyState.props()).toMatchObject({
svgPath: '/svgs/empty/svg',
......@@ -148,6 +150,35 @@ describe('Pipeline Security Dashboard component', () => {
});
});
describe('scan errors alert', () => {
const securityReportSummary = {
dast: {
scans: [
{
name: 'dast',
errors: [],
},
],
},
};
beforeEach(() => {
factory({
data: {
securityReportSummary,
},
});
});
it('includes the alert', () => {
expect(findScanErrorsAlert().exists()).toBe(true);
});
it('passes the security report summary to the alert', () => {
expect(findScanErrorsAlert().props('securityReportSummary')).toBe(securityReportSummary);
});
});
describe('security reports summary', () => {
const securityReportSummary = {
dast: {
......@@ -161,7 +192,7 @@ describe('Pipeline Security Dashboard component', () => {
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', () => {
......@@ -170,7 +201,7 @@ describe('Pipeline Security Dashboard component', () => {
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_SECURITY_REPORT_SUMMARY = {
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 TEST_HELP_PAGE_LINK = 'http://help.com';
const TEST_SCANS_WITH_ERRORS = [
...TEST_SECURITY_REPORT_SUMMARY.scanner_1.scans,
...TEST_SECURITY_REPORT_SUMMARY.scanner_3.scans,
];
describe('ee/security_dashboard/components/pipeline_scan_errors_alert.vue', () => {
let wrapper;
const createWrapper = (options) =>
extendedWrapper(
shallowMount(PipelineScanErrorsAlert, {
...options,
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();
});
describe('without scanner errors', () => {
beforeEach(() => {
wrapper = createWrapper({ propsData: { securityReportSummary: {} } });
});
it('does not show error alert', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe('with scanner errors', () => {
beforeEach(() => {
wrapper = createWrapper({
propsData: { securityReportSummary: TEST_SECURITY_REPORT_SUMMARY },
});
});
it('shows a non-dismissible error alert', () => {
expect(findAlert().exists()).toBe(true);
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().exists()).toBe(true);
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);
});
});
});
});
});
......@@ -29078,6 +29078,9 @@ msgstr ""
msgid "SecurityOrchestration|Security policy project"
msgstr ""
msgid "SecurityReports|%{errorName} (%{errorCount})"
msgstr ""
msgid "SecurityReports|%{firstProject} and %{secondProject}"
msgstr ""
......@@ -29153,6 +29156,9 @@ msgstr ""
msgid "SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again."
msgstr ""
msgid "SecurityReports|Error parsing security reports"
msgstr ""
msgid "SecurityReports|Failed to get security report information. Please reload the page or try again later."
msgstr ""
......@@ -29258,6 +29264,9 @@ msgstr ""
msgid "SecurityReports|Take survey"
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."
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