Commit 149d907c authored by Mark Florian's avatar Mark Florian

Merge branch '217151-fuzzing-download' into 'master'

UI for "Coverage-guided fuzzing results download"

See merge request gitlab-org/gitlab!36676
parents 20c359d2 507ce12d
......@@ -27,14 +27,17 @@ export default {
:filter="filter"
@setFilter="setFilter"
/>
<div class="ml-lg-auto p-2">
<strong>{{ s__('SecurityReports|Hide dismissed') }}</strong>
<gl-toggle-vuex
class="d-block mt-1 js-toggle"
store-module="filters"
state-property="hideDismissed"
set-action="setToggleValue"
/>
<div class="gl-display-flex ml-lg-auto p-2">
<slot name="buttons"></slot>
<div class="pl-md-6">
<strong>{{ s__('SecurityReports|Hide dismissed') }}</strong>
<gl-toggle-vuex
class="d-block mt-1 js-toggle"
store-module="filters"
state-property="hideDismissed"
set-action="setToggleValue"
/>
</div>
</div>
</div>
</div>
......
<script>
import { s__ } from '~/locale';
import { GlButton, GlNewDropdown, GlDropdownItem } from '@gitlab/ui';
export default {
i18n: {
FUZZING_ARTIFACTS: s__('SecurityReports|Fuzzing artifacts'),
},
components: {
GlButton,
GlNewDropdown,
GlDropdownItem,
},
props: {
jobs: {
type: Array,
required: true,
},
projectId: {
type: Number,
required: true,
},
},
computed: {
hasDropdown() {
return this.jobs.length > 1;
},
},
methods: {
artifactDownloadUrl(job) {
return `/api/v4/projects/${this.projectId}/jobs/artifacts/${
job.ref
}/download?job=${encodeURIComponent(job.name)}`;
},
},
};
</script>
<template>
<div>
<strong>{{ s__('SecurityReports|Download Report') }}</strong>
<gl-new-dropdown
v-if="hasDropdown"
class="d-block mt-1"
:text="$options.i18n.FUZZING_ARTIFACTS"
category="secondary"
variant="info"
size="small"
>
<gl-dropdown-item v-for="job in jobs" :key="job.id" :href="artifactDownloadUrl(job)">{{
job.name
}}</gl-dropdown-item>
</gl-new-dropdown>
<gl-button
v-else
class="d-block mt-1"
category="secondary"
variant="info"
size="small"
:href="artifactDownloadUrl(jobs[0])"
>
{{ $options.i18n.FUZZING_ARTIFACTS }}
</gl-button>
</div>
</template>
......@@ -77,6 +77,11 @@ export default {
required: false,
default: '',
},
pipelineJobsPath: {
type: String,
required: false,
default: '',
},
},
computed: {
emptyStateProps() {
......@@ -93,9 +98,12 @@ export default {
},
created() {
this.setSourceBranch(this.sourceBranch);
this.setPipelineJobsPath(this.pipelineJobsPath);
this.setProjectId(this.projectId);
},
methods: {
...mapActions('vulnerabilities', ['setSourceBranch']),
...mapActions('pipelineJobs', ['setPipelineJobsPath', 'setProjectId']),
},
};
</script>
......
......@@ -8,6 +8,7 @@ import SecurityDashboardTable from './security_dashboard_table.vue';
import VulnerabilityChart from './vulnerability_chart.vue';
import VulnerabilityCountList from './vulnerability_count_list_vuex.vue';
import VulnerabilitySeverity from './vulnerability_severity.vue';
import FuzzingArtifactsDownload from './fuzzing_artifacts_download.vue';
import LoadingError from './loading_error.vue';
export default {
......@@ -19,6 +20,7 @@ export default {
VulnerabilityChart,
VulnerabilityCountList,
VulnerabilitySeverity,
FuzzingArtifactsDownload,
LoadingError,
},
props: {
......@@ -71,8 +73,10 @@ export default {
'isDismissingVulnerability',
'isCreatingMergeRequest',
]),
...mapState('pipelineJobs', ['projectId']),
...mapGetters('filters', ['activeFilters']),
...mapGetters('vulnerabilities', ['loadingVulnerabilitiesFailedWithRecognizedErrorCode']),
...mapGetters('pipelineJobs', ['hasFuzzingArtifacts', 'fuzzingJobsWithArtifact']),
canCreateIssue() {
const path = this.vulnerability.create_vulnerability_feedback_issue_path;
return Boolean(path);
......@@ -122,6 +126,7 @@ export default {
this.fetchVulnerabilities({ ...this.activeFilters, page: this.pageInfo.page });
this.fetchVulnerabilitiesCount(this.activeFilters);
this.fetchVulnerabilitiesHistory(this.activeFilters);
this.fetchPipelineJobs();
},
methods: {
...mapActions('vulnerabilities', [
......@@ -144,6 +149,7 @@ export default {
'undoDismiss',
'downloadPatch',
]),
...mapActions('pipelineJobs', ['fetchPipelineJobs']),
...mapActions('filters', ['lockFilter', 'setHideDismissedToggleInitialState']),
emitVulnerabilitiesCountChanged(count) {
this.$emit('vulnerabilitiesCountChanged', count);
......@@ -163,7 +169,11 @@ export default {
<security-dashboard-layout>
<template #header>
<vulnerability-count-list v-if="shouldShowCountList" />
<filters />
<filters>
<template v-if="hasFuzzingArtifacts" #buttons>
<fuzzing-artifacts-download :jobs="fuzzingJobsWithArtifact" :project-id="projectId" />
</template>
</filters>
</template>
<security-dashboard-table>
......
......@@ -24,6 +24,7 @@ export default () => {
emptyStateUnauthorizedSvgPath,
emptyStateForbiddenSvgPath,
projectFullPath,
pipelineJobsPath,
} = el.dataset;
const loadingErrorIllustrations = {
......@@ -50,6 +51,7 @@ export default () => {
emptyStateSvgPath,
loadingErrorIllustrations,
projectFullPath,
pipelineJobsPath,
},
});
},
......
......@@ -8,6 +8,7 @@ import filters from './modules/filters/index';
import vulnerabilities from './modules/vulnerabilities/index';
import vulnerableProjects from './modules/vulnerable_projects/index';
import unscannedProjects from './modules/unscanned_projects/index';
import pipelineJobs from './modules/pipeline_jobs/index';
Vue.use(Vuex);
......@@ -21,6 +22,7 @@ export default ({ dashboardType = DASHBOARD_TYPES.PROJECT, plugins = [] } = {})
filters,
vulnerabilities,
unscannedProjects,
pipelineJobs,
},
plugins: [mediator, ...plugins],
});
import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
export const setPipelineJobsPath = ({ commit }, path) => commit(types.SET_PIPELINE_JOBS_PATH, path);
export const setProjectId = ({ commit }, id) => commit(types.SET_PROJECT_ID, id);
export const fetchPipelineJobs = ({ commit, state }) => {
if (!state.pipelineJobsPath) {
return commit(types.RECEIVE_PIPELINE_JOBS_ERROR);
}
commit(types.REQUEST_PIPELINE_JOBS);
return axios({
method: 'GET',
url: state.pipelineJobsPath,
})
.then(response => {
const { data } = response;
commit(types.RECEIVE_PIPELINE_JOBS_SUCCESS, data);
})
.catch(error => {
Sentry.captureException(error);
commit(types.RECEIVE_PIPELINE_JOBS_ERROR);
});
};
/* eslint-disable import/prefer-default-export */
export const FUZZING_STAGE = 'fuzz';
import { FUZZING_STAGE } from './constants';
export const hasFuzzingArtifacts = state => {
return state.pipelineJobs.some(job => {
return job.stage === FUZZING_STAGE && job.artifacts.length > 0;
});
};
export const fuzzingJobsWithArtifact = state => {
return state.pipelineJobs.filter(job => {
return job.stage === FUZZING_STAGE && job.artifacts.length > 0;
});
};
import state from './state';
import mutations from './mutations';
import * as getters from './getters';
import * as actions from './actions';
export default {
namespaced: true,
state,
mutations,
getters,
actions,
};
export const SET_PIPELINE_JOBS_PATH = 'SET_PIPELINE_JOBS_PATH';
export const SET_PROJECT_ID = 'SET_PROJECT_ID ';
export const REQUEST_PIPELINE_JOBS = 'REQUEST_PIPELINE_JOBS';
export const RECEIVE_PIPELINE_JOBS_SUCCESS = 'RECEIVE_PIPELINE_JOBS_SUCESS';
export const RECEIVE_PIPELINE_JOBS_ERROR = 'RECEIVE_PIPELINE_JOBS_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_PIPELINE_JOBS_PATH](state, payload) {
state.pipelineJobsPath = payload;
},
[types.SET_PROJECT_ID](state, payload) {
state.projectId = payload;
},
[types.REQUEST_PIPELINE_JOBS](state) {
state.isLoading = true;
},
[types.RECEIVE_PIPELINE_JOBS_SUCCESS](state, payload) {
state.isLoading = false;
state.pipelineJobs = payload;
},
[types.RECEIVE_PIPELINE_JOBS_ERROR](state) {
state.isLoading = false;
},
};
export default () => ({
projectId: undefined,
pipelineJobsPath: '',
isLoading: false,
pipelineJobs: [],
});
......@@ -25,7 +25,8 @@ module EE
batch_lookup_report_artifact_for_file_type(:secret_detection) ||
batch_lookup_report_artifact_for_file_type(:dependency_scanning) ||
batch_lookup_report_artifact_for_file_type(:dast) ||
batch_lookup_report_artifact_for_file_type(:container_scanning)
batch_lookup_report_artifact_for_file_type(:container_scanning) ||
batch_lookup_report_artifact_for_file_type(:coverage_fuzzing)
end
def degradation_threshold(file_type)
......
......@@ -15,6 +15,7 @@
pipeline_iid: pipeline.iid,
project_id: project.id,
source_branch: pipeline.source_ref,
pipeline_jobs_path: expose_path(api_v4_projects_pipelines_jobs_path(id: project.id, pipeline_id: pipeline.id)),
vulnerabilities_endpoint: vulnerabilities_endpoint_path,
vulnerability_exports_endpoint: vulnerability_exports_endpoint_path,
vulnerability_feedback_help_path: help_page_path('user/application_security/index'),
......
---
title: Add ability to download fuzzing artifacts from pipeline page
merge_request: 36676
author:
type: added
......@@ -17,6 +17,9 @@ describe('Filter component', () => {
propsData: {
...props,
},
slots: {
buttons: '<div class="button-slot"></div>',
},
});
};
......@@ -42,4 +45,11 @@ describe('Filter component', () => {
expect(wrapper.findAll('.js-toggle')).toHaveLength(1);
});
});
describe('buttons slot', () => {
it('should exist', () => {
createWrapper();
expect(wrapper.contains('.button-slot')).toBe(true);
});
});
});
import Vuex from 'vuex';
import FuzzingArtifactsDownload from 'ee/security_dashboard/components/fuzzing_artifacts_download.vue';
import createStore from 'ee/security_dashboard/store';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlNewDropdown, GlDropdownItem } from '@gitlab/ui';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Filter component', () => {
const projectId = 1;
const jobs = [{ ref: 'master', name: 'fuzz' }, { ref: 'master', name: 'fuzz 2' }];
let wrapper;
let store;
const createWrapper = (props = {}) => {
wrapper = shallowMount(FuzzingArtifactsDownload, {
localVue,
store,
propsData: {
projectId,
...props,
},
});
};
beforeEach(() => {
store = createStore();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with one fuzzing job with artifacts', () => {
beforeEach(() => {
createWrapper({ jobs: [jobs[0]] });
});
it('should render a download button', () => {
expect(wrapper.find(GlButton).exists()).toBe(true);
expect(wrapper.find(GlNewDropdown).exists()).toBe(false);
});
it('should render with href set to the correct filepath', () => {
const href = `/api/v4/projects/${projectId}/jobs/artifacts/${
jobs[0].ref
}/download?job=${encodeURIComponent(jobs[0].name)}`;
expect(wrapper.find(GlButton).attributes('href')).toBe(href);
});
});
describe('with several fuzzing jobs with artifacts', () => {
beforeEach(() => {
createWrapper({ jobs });
});
it('should render a dropdown button with several items', () => {
expect(wrapper.find(GlButton).exists()).toBe(false);
expect(wrapper.find(GlNewDropdown).exists()).toBe(true);
expect(wrapper.findAll(GlDropdownItem).length).toBe(2);
});
it('should render with href set to the correct filepath for every element', () => {
const wrapperArray = wrapper.findAll(GlDropdownItem);
wrapperArray.wrappers.forEach((_, index) => {
const href = `/api/v4/projects/${projectId}/jobs/artifacts/${
jobs[index].ref
}/download?job=${encodeURIComponent(jobs[index].name)}`;
expect(wrapperArray.at(index).attributes().href).toBe(href);
});
});
});
});
......@@ -34,6 +34,13 @@ describe('Pipeline Security Dashboard component', () => {
setSourceBranch() {},
},
},
pipelineJobs: {
namespaced: true,
actions: {
setPipelineJobsPath() {},
setProjectId() {},
},
},
},
});
jest.spyOn(store, 'dispatch').mockImplementation();
......@@ -74,6 +81,8 @@ describe('Pipeline Security Dashboard component', () => {
it('dispatches the expected actions', () => {
expect(store.dispatch.mock.calls).toEqual([
['vulnerabilities/setSourceBranch', sourceBranch],
['pipelineJobs/setPipelineJobsPath', ''],
['pipelineJobs/setProjectId', 5678],
]);
});
......
......@@ -32,6 +32,7 @@ describe('Security Dashboard component', () => {
let mock;
let lockFilterSpy;
let setPipelineIdSpy;
let fetchPipelineJobsSpy;
let store;
const createComponent = props => {
......@@ -43,6 +44,7 @@ describe('Security Dashboard component', () => {
methods: {
lockFilter: lockFilterSpy,
setPipelineId: setPipelineIdSpy,
fetchPipelineJobs: fetchPipelineJobsSpy,
},
propsData: {
dashboardDocumentation: '',
......@@ -61,6 +63,7 @@ describe('Security Dashboard component', () => {
mock = new MockAdapter(axios);
lockFilterSpy = jest.fn();
setPipelineIdSpy = jest.fn();
fetchPipelineJobsSpy = jest.fn();
store = createStore();
});
......@@ -104,6 +107,10 @@ describe('Security Dashboard component', () => {
expect(setPipelineIdSpy).toHaveBeenCalledWith(pipelineId);
});
it('fetchs the pipeline jobs', () => {
expect(fetchPipelineJobsSpy).toHaveBeenCalledWith();
});
describe('when the total number of vulnerabilities change', () => {
const newCount = 3;
......
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { TEST_HOST } from 'spec/test_constants';
import testAction from 'helpers/vuex_action_helper';
import createState from 'ee/security_dashboard/store/modules/pipeline_jobs/state';
import * as types from 'ee/security_dashboard/store/modules/pipeline_jobs/mutation_types';
import * as actions from 'ee/security_dashboard/store/modules/pipeline_jobs/actions';
describe('pipeling jobs actions', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('setPipelineJobsPath', () => {
const pipelineJobsPath = 123;
it('should commit the SET_PIPELINE_JOBS_PATH mutation', done => {
testAction(
actions.setPipelineJobsPath,
pipelineJobsPath,
state,
[
{
type: types.SET_PIPELINE_JOBS_PATH,
payload: pipelineJobsPath,
},
],
[],
done,
);
});
});
describe('setProjectId', () => {
const projectId = 123;
it('should commit the SET_PIPELINE_JOBS_PATH mutation', done => {
testAction(
actions.setProjectId,
projectId,
state,
[
{
type: types.SET_PROJECT_ID,
payload: projectId,
},
],
[],
done,
);
});
});
describe('fetchPipelineJobs', () => {
let mock;
const jobs = [{}, {}];
beforeEach(() => {
state.pipelineJobsPath = `${TEST_HOST}/pipelines/jobs.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
beforeEach(() => {
mock.onGet(state.pipelineJobsPath).replyOnce(200, jobs);
});
it('should commit the request and success mutations', done => {
testAction(
actions.fetchPipelineJobs,
{},
state,
[
{ type: types.REQUEST_PIPELINE_JOBS },
{
type: types.RECEIVE_PIPELINE_JOBS_SUCCESS,
payload: jobs,
},
],
[],
done,
);
});
});
describe('without pipelineJobsPath set', () => {
beforeEach(() => {
mock.onGet(state.pipelineJobsPath).replyOnce(200, jobs);
});
it('should commit RECEIVE_PIPELINE_JOBS_ERROR mutation', done => {
state.pipelineJobsPath = '';
testAction(
actions.fetchPipelineJobs,
{},
state,
[
{
type: types.RECEIVE_PIPELINE_JOBS_ERROR,
},
],
[],
done,
);
});
});
describe('with server error', () => {
beforeEach(() => {
mock.onGet(state.pipelineJobsPath).replyOnce(404);
});
it('should commit REQUEST_PIPELINE_JOBS and RECEIVE_PIPELINE_JOBS_ERROR mutation', done => {
testAction(
actions.fetchPipelineJobs,
{},
state,
[
{ type: types.REQUEST_PIPELINE_JOBS },
{
type: types.RECEIVE_PIPELINE_JOBS_ERROR,
},
],
[],
done,
);
});
});
});
});
// import createState from 'ee/security_dashboard/store/modules/pipeline_jobs/state';
import { FUZZING_STAGE } from 'ee/security_dashboard/store/modules/pipeline_jobs/constants';
import * as getters from 'ee/security_dashboard/store/modules/pipeline_jobs/getters';
describe('pipeline jobs module getters', () => {
describe('hasFuzzingArtifacts', () => {
it('should return true when the pipeline has at least one fuzzing job with at least one artifact', () => {
const pipelineJobs = [{ stage: FUZZING_STAGE, artifacts: [{}] }];
const state = { pipelineJobs };
const result = getters.hasFuzzingArtifacts(state);
expect(result).toBe(true);
});
it('should return true when the pipeline has many jobs and at least one fuzzing job with at least one artifact', () => {
const pipelineJobs = [
{ stage: 'other', artifacts: [] },
{ stage: FUZZING_STAGE, artifacts: [{}] },
];
const state = { pipelineJobs };
const result = getters.hasFuzzingArtifacts(state);
expect(result).toBe(true);
});
it('should return false when the pipeline has a fuzzing job with 0 artifacts', () => {
const pipelineJobs = [{ stage: FUZZING_STAGE, artifacts: [] }];
const state = { pipelineJobs };
const result = getters.hasFuzzingArtifacts(state);
expect(result).toBe(false);
});
it('should return false when the pipeline has no fuzzing job with 0 artifacts', () => {
const pipelineJobs = [{ stage: 'other', artifacts: [] }];
const state = { pipelineJobs };
const result = getters.hasFuzzingArtifacts(state);
expect(result).toBe(false);
});
it('should return false when the pipeline has no fuzzing job with 1 artifacts', () => {
const pipelineJobs = [{ stage: 'other', artifacts: [{}] }];
const state = { pipelineJobs };
const result = getters.hasFuzzingArtifacts(state);
expect(result).toBe(false);
});
it('should return false when the pipeline has many jobs and at least one fuzzing job with no fuzzing artifact', () => {
const pipelineJobs = [
{ stage: 'other', artifacts: [] },
{ stage: FUZZING_STAGE, artifacts: [] },
];
const state = { pipelineJobs };
const result = getters.hasFuzzingArtifacts(state);
expect(result).toBe(false);
});
});
describe('fuzzingJobsWithArtifact', () => {
it('should return a fuzzing job when the pipeline has at least one fuzzing job with at least one artifact', () => {
const pipelineJobs = [{ stage: FUZZING_STAGE, artifacts: [{}] }];
const state = { pipelineJobs };
const result = getters.fuzzingJobsWithArtifact(state);
expect(result).toEqual(pipelineJobs);
});
it('should return a fuzzing job when the pipeline has many jobs and at least one fuzzing job with at least one artifact', () => {
const pipelineJobs = [
{ stage: 'other', artifacts: [] },
{ stage: FUZZING_STAGE, artifacts: [{}] },
];
const state = { pipelineJobs };
const result = getters.fuzzingJobsWithArtifact(state);
expect(result).toEqual([pipelineJobs[1]]);
});
it('should not return a fuzzing job when the pipeline has a fuzzing job with 0 artifacts', () => {
const pipelineJobs = [{ stage: FUZZING_STAGE, artifacts: [] }];
const state = { pipelineJobs };
const result = getters.fuzzingJobsWithArtifact(state);
expect(result).toEqual([]);
});
it('should not return a fuzzing job when the pipeline has no fuzzing job with 0 artifacts', () => {
const pipelineJobs = [{ stage: 'other', artifacts: [] }];
const state = { pipelineJobs };
const result = getters.fuzzingJobsWithArtifact(state);
expect(result).toEqual([]);
});
it('should not return a fuzzing job when the pipeline has no fuzzing job with 1 artifacts', () => {
const pipelineJobs = [{ stage: 'other', artifacts: [{}] }];
const state = { pipelineJobs };
const result = getters.fuzzingJobsWithArtifact(state);
expect(result).toEqual([]);
});
it('should not return a fuzzing job when the pipeline has many jobs and at least one fuzzing job with no fuzzing artifact', () => {
const pipelineJobs = [
{ stage: 'other', artifacts: [] },
{ stage: FUZZING_STAGE, artifacts: [] },
];
const state = { pipelineJobs };
const result = getters.fuzzingJobsWithArtifact(state);
expect(result).toEqual([]);
});
});
});
import * as types from 'ee/security_dashboard/store/modules/pipeline_jobs/mutation_types';
import mutations from 'ee/security_dashboard/store/modules/pipeline_jobs/mutations';
describe('pipeline jobs module mutations', () => {
let state;
beforeEach(() => {
state = {};
});
describe('SET_PIPELINE_JOBS_PATH', () => {
const pipelineJobsPath = 123;
it(`should set the pipelineJobsPath to ${pipelineJobsPath}`, () => {
mutations[types.SET_PIPELINE_JOBS_PATH](state, pipelineJobsPath);
expect(state.pipelineJobsPath).toBe(pipelineJobsPath);
});
});
describe('SET_PROJECT_ID', () => {
const projectId = 123;
it(`should set the projectId to ${projectId}`, () => {
mutations[types.SET_PROJECT_ID](state, projectId);
expect(state.projectId).toBe(projectId);
});
});
describe('REQUEST_PIPELINE_JOBS', () => {
it('should set the isLoading to true', () => {
mutations[types.REQUEST_PIPELINE_JOBS](state);
expect(state.isLoading).toBe(true);
});
});
describe('RECEIVE_PIPELINE_JOBS_SUCCESS', () => {
it('should set the isLoading to false and pipelineJobs to the jobs array', () => {
const jobs = [{}, {}];
mutations[types.RECEIVE_PIPELINE_JOBS_SUCCESS](state, jobs);
expect(state.isLoading).toBe(false);
expect(state.pipelineJobs).toBe(jobs);
});
});
describe('RECEIVE_PIPELINE_JOBS_ERROR', () => {
it('should set the isLoading to false', () => {
mutations[types.RECEIVE_PIPELINE_JOBS_ERROR](state);
expect(state.isLoading).toBe(false);
});
});
});
......@@ -20820,6 +20820,9 @@ msgstr ""
msgid "SecurityReports|Dismissed '%{vulnerabilityName}'. Turn off the hide dismissed toggle to view."
msgstr ""
msgid "SecurityReports|Download Report"
msgstr ""
msgid "SecurityReports|Each vulnerability now has a unique page that can be directly linked to, shared, referenced, and tracked as the single source of truth. Vulnerability occurrences also persist across scanner runs, which improves tracking and visibility and reduces duplicates between scans."
msgstr ""
......@@ -20838,6 +20841,9 @@ msgstr ""
msgid "SecurityReports|False positive"
msgstr ""
msgid "SecurityReports|Fuzzing artifacts"
msgstr ""
msgid "SecurityReports|Group Security Dashboard"
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