Commit 447a11c4 authored by Fernando's avatar Fernando

Allow for fuzzing artifacts to be downloaded fom pipeline page

Add pipelineJobsPath

* Add Vuex Module, mutations, actions, mutation types
* Update presenter helper to retun TRUE if fuzzing job is run

Implement download button/dropdown for fuzzing artifacts

* Add mutations, actions, getters, state properties
* Add fuzzing download vue component
* Add conditional rendering logic

Run prettier and linter

Update translations and add unit tests

* Add unit tests for actions, getters, and mutations

Add fuzzing download vue component unit tests

* Add unit tests
* Fix up alignment when fuzzing is shown and hidden

Add chaneglog entry for fuzzing feature

* Add EE only change log

Tweak styling for mobile

* Restructure markup to be more mobile friendly
parent 7c9e82fa
......@@ -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="d-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, GlDropdown, GlDropdownItem } from '@gitlab/ui';
export default {
translations: {
FUZZING_ARTIFACTS: s__('SecurityReports|Fuzzing artifacts'),
},
components: {
GlButton,
GlDropdown,
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=${job.name}`;
},
},
};
</script>
<template>
<div>
<strong>{{ s__('SecurityReports|Download Report') }}</strong>
<gl-dropdown
v-if="hasDropdown"
class="d-block mt-1"
:text="$options.translations.FUZZING_ARTIFACTS"
variant="primary"
>
<gl-dropdown-item v-for="job in jobs" :key="job.id" :href="artifactDownloadUrl(job)">{{
job.name
}}</gl-dropdown-item>
</gl-dropdown>
<gl-button
v-else
class="d-block mt-1"
category="secondary"
variant="info"
:href="artifactDownloadUrl(jobs[0])"
>{{ $options.translations.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 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, new Error('pipelineJobsPath not defined'));
}
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 => {
commit(types.RECEIVE_PIPELINE_JOBS_ERROR, error);
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const FUZZING_STAGE = 'fuzz';
export default () => {};
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;
});
};
export default () => {};
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 downloadable_path_for_report_type(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 { mount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlDropdown, 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 = mount(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(GlDropdown).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=${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(GlDropdown).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);
let href;
wrapperArray.wrappers.forEach((_, index) => {
href = `/api/v4/projects/${projectId}/jobs/artifacts/${jobs[index].ref}/download?job=${jobs[index].name}`;
// wrapperArray.at(index).attributes('href') returns undefined for some reason
expect(wrapperArray.at(index).vm.$attrs.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();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('setPipelineJobsPath', () => {
const pipslineJobsPath = 123;
it('should commit the SET_PIPELINE_JOBS_PATH mutation', done => {
testAction(
actions.setPipelineJobsPath,
pipslineJobsPath,
state,
[
{
type: types.SET_PIPELINE_JOBS_PATH,
payload: pipslineJobsPath,
},
],
[],
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,
payload: new Error('pipelineJobsPath not defined'),
},
],
[],
done,
);
});
});
describe('without 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,
payload: new Error('Request failed with status code 404'),
},
],
[],
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 createState from 'ee/security_dashboard/store/modules/pipeline_jobs/state';
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 = createState();
});
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 true 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);
});
});
});
......@@ -20408,6 +20408,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 ""
......@@ -20426,6 +20429,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