Commit 30a6da8c authored by David Pisek's avatar David Pisek Committed by Kushal Pandya

Add vulnerable projects to security dashboard

This commit adds a new sidebar item to the group security dashboard.

It consists of an accordion, showing projects grouped by the
most severe vulnerability within the project.
parent f288729b
...@@ -76,7 +76,7 @@ To the right of the filters, you should see a **Hide dismissed** toggle button. ...@@ -76,7 +76,7 @@ To the right of the filters, you should see a **Hide dismissed** toggle button.
NOTE: **Note:** NOTE: **Note:**
The dashboard only shows projects with [security reports](#supported-reports) enabled in a group. The dashboard only shows projects with [security reports](#supported-reports) enabled in a group.
![dashboard with action buttons and metrics](img/group_security_dashboard_v12_4.png) ![dashboard with action buttons and metrics](img/group_security_dashboard_v12_6.png)
Selecting one or more filters will filter the results in this page. Disabling the **Hide dismissed** Selecting one or more filters will filter the results in this page. Disabling the **Hide dismissed**
toggle button will let you also see vulnerabilities that have been dismissed. toggle button will let you also see vulnerabilities that have been dismissed.
...@@ -97,6 +97,17 @@ vulnerabilities your projects had at various points in time. You can filter amon ...@@ -97,6 +97,17 @@ vulnerabilities your projects had at various points in time. You can filter amon
90 days, with the default being 90. Hover over the chart to get more details about 90 days, with the default being 90. Hover over the chart to get more details about
the open vulnerabilities at a specific time. the open vulnerabilities at a specific time.
Below the timeline chart is a list of projects, grouped and sorted by the severity of the vulnerability found:
- F: 1 or more "critical"
- D: 1 or more "high" or "unknown"
- C: 1 or more "medium"
- B: 1 or more "low"
- A: 0 vulnerabilities
Projects with no vulnerability tests configured will not appear in the list. Additionally, dismissed
vulnerabilities are not included either.
Read more on how to [interact with the vulnerabilities](../index.md#interacting-with-the-vulnerabilities). Read more on how to [interact with the vulnerabilities](../index.md#interacting-with-the-vulnerabilities).
## Keeping the dashboards up to date ## Keeping the dashboards up to date
......
...@@ -6,6 +6,7 @@ import Filters from './filters.vue'; ...@@ -6,6 +6,7 @@ import Filters from './filters.vue';
import SecurityDashboardTable from './security_dashboard_table.vue'; import SecurityDashboardTable from './security_dashboard_table.vue';
import VulnerabilityChart from './vulnerability_chart.vue'; import VulnerabilityChart from './vulnerability_chart.vue';
import VulnerabilityCountList from './vulnerability_count_list.vue'; import VulnerabilityCountList from './vulnerability_count_list.vue';
import VulnerabilitySeverity from './vulnerability_severity.vue';
export default { export default {
name: 'SecurityDashboardApp', name: 'SecurityDashboardApp',
...@@ -15,6 +16,7 @@ export default { ...@@ -15,6 +16,7 @@ export default {
SecurityDashboardTable, SecurityDashboardTable,
VulnerabilityChart, VulnerabilityChart,
VulnerabilityCountList, VulnerabilityCountList,
VulnerabilitySeverity,
}, },
props: { props: {
vulnerabilitiesEndpoint: { vulnerabilitiesEndpoint: {
...@@ -35,6 +37,11 @@ export default { ...@@ -35,6 +37,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
vulnerableProjectsEndpoint: {
type: String,
required: false,
default: '',
},
lockToProject: { lockToProject: {
type: Object, type: Object,
required: false, required: false,
...@@ -68,9 +75,15 @@ export default { ...@@ -68,9 +75,15 @@ export default {
isLockedToProject() { isLockedToProject() {
return this.lockToProject !== null; return this.lockToProject !== null;
}, },
shouldShowAside() {
return this.shouldShowChart || this.shouldShowVulnerabilitySeverities;
},
shouldShowChart() { shouldShowChart() {
return Boolean(this.vulnerabilitiesHistoryEndpoint); return Boolean(this.vulnerabilitiesHistoryEndpoint);
}, },
shouldShowVulnerabilitySeverities() {
return Boolean(this.vulnerableProjectsEndpoint);
},
shouldShowCountList() { shouldShowCountList() {
return this.isLockedToProject && Boolean(this.vulnerabilitiesCountEndpoint); return this.isLockedToProject && Boolean(this.vulnerabilitiesCountEndpoint);
}, },
...@@ -140,8 +153,12 @@ export default { ...@@ -140,8 +153,12 @@ export default {
</security-dashboard-table> </security-dashboard-table>
</article> </article>
<aside v-if="shouldShowChart" class="col-xl-5"> <aside v-if="shouldShowAside" class="col-xl-5">
<vulnerability-chart /> <vulnerability-chart v-if="shouldShowChart" class="mb-3" />
<vulnerability-severity
v-if="shouldShowVulnerabilitySeverities"
:endpoint="vulnerableProjectsEndpoint"
/>
</aside> </aside>
</div> </div>
......
...@@ -38,6 +38,10 @@ export default { ...@@ -38,6 +38,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
vulnerableProjectsEndpoint: {
type: String,
required: true,
},
}, },
created() { created() {
this.setProjectsEndpoint(this.projectsEndpoint); this.setProjectsEndpoint(this.projectsEndpoint);
...@@ -55,6 +59,7 @@ export default { ...@@ -55,6 +59,7 @@ export default {
:vulnerabilities-count-endpoint="vulnerabilitiesCountEndpoint" :vulnerabilities-count-endpoint="vulnerabilitiesCountEndpoint"
:vulnerabilities-history-endpoint="vulnerabilitiesHistoryEndpoint" :vulnerabilities-history-endpoint="vulnerabilitiesHistoryEndpoint"
:vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath" :vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath"
:vulnerable-projects-endpoint="vulnerableProjectsEndpoint"
> >
<template #emptyState> <template #emptyState>
<gl-empty-state <gl-empty-state
......
...@@ -124,7 +124,7 @@ export default { ...@@ -124,7 +124,7 @@ export default {
:items="charts" :items="charts"
:borderless="true" :borderless="true"
thead-class="thead-white" thead-class="thead-white"
class="js-vulnerabilities-chart-severity-level-breakdown" class="js-vulnerabilities-chart-severity-level-breakdown mb-2"
> >
<template #HEAD_changeInPercent="{ label }"> <template #HEAD_changeInPercent="{ label }">
<span v-gl-tooltip :title="__('Difference between start date and now')">{{ label }}</span> <span v-gl-tooltip :title="__('Difference between start date and now')">{{ label }}</span>
......
<script>
import {
severityGroupTypes,
severityLevels,
severityLevelsTranslations,
} from 'ee/security_dashboard/store/modules/vulnerable_projects/constants';
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlAvatar, GlLink, GlTooltipDirective } from '@gitlab/ui';
import { Accordion, AccordionItem } from 'ee/vue_shared/components/accordion';
import Icon from '~/vue_shared/components/icon.vue';
export default {
css: {
severityGroups: {
[severityGroupTypes.F]: ['gl-bg-red-100', 'gl-text-red-700'],
[severityGroupTypes.D]: ['gl-bg-orange-100', 'gl-text-orange-700'],
[severityGroupTypes.C]: ['gl-bg-purple-light', 'gl-text-purple'],
[severityGroupTypes.B]: ['gl-bg-gray-100', 'gl-text-gray-800'],
[severityGroupTypes.A]: ['gl-bg-green-100', 'gl-text-green-700'],
},
severityLevels: {
[severityLevels.CRITICAL]: ['gl-text-red-700'],
[severityLevels.HIGH]: ['gl-text-orange-700'],
[severityLevels.UNKNOWN]: ['gl-text-gray-800'],
[severityLevels.MEDIUM]: ['gl-text-purple'],
[severityLevels.LOW]: ['gl-text-gray-800'],
[severityLevels.NONE]: ['gl-text-green-700'],
},
},
accordionItemsContentMaxHeight: '445px',
components: { Accordion, AccordionItem, GlLink, GlAvatar, Icon },
directives: {
'gl-tooltip': GlTooltipDirective,
},
props: {
endpoint: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState('vulnerableProjects', ['isLoading']),
...mapGetters('vulnerableProjects', ['severityGroups']),
},
created() {
this.fetchProjects(this.endpoint);
},
methods: {
...mapActions('vulnerableProjects', ['fetchProjects']),
shouldAccordionItemBeDisabled({ projects }) {
return projects && projects.length < 1;
},
cssForSeverityGroup({ type }) {
return this.$options.css.severityGroups[type];
},
cssForMostSevereVulnerability({ level }) {
return this.$options.css.severityLevels[level] || [];
},
severityText(severityLevel) {
return severityLevelsTranslations[severityLevel];
},
},
};
</script>
<template>
<section class="js-projects-security-status border rounded">
<header class="border-bottom p-3">
<h4 class="my-0">
{{ __('Project security status') }}
<gl-link
v-if="helpPagePath"
:href="helpPagePath"
:aria-label="__('Project security status help page')"
target="_blank"
><icon name="question"
/></gl-link>
</h4>
<p class="text-secondary m-0">
{{ __('Projects are graded based on the highest severity vulnerability present') }}
</p>
</header>
<accordion class="px-3">
<template #default="{ accordionId }">
<accordion-item
v-for="severityGroup in severityGroups"
:ref="`accordionItem${severityGroup.type}`"
:key="severityGroup.type"
:accordion-id="accordionId"
:is-loading="isLoading"
:disabled="shouldAccordionItemBeDisabled(severityGroup)"
:max-height="$options.accordionItemsContentMaxHeight"
>
<template #title="{ isExpanded, isDisabled }"
><h5 class="d-flex align-items-center font-weight-normal p-0 m-0">
<gl-avatar
v-gl-tooltip
:title="severityGroup.description"
:entity-name="severityGroup.type"
shape="circle"
:size="32"
class="mr-2 border-0 font-weight-bold"
:class="cssForSeverityGroup(severityGroup)"
/>
<span :class="{ 'font-weight-bold': isExpanded, 'text-secondary': isDisabled }">
{{ n__('%d project', '%d projects', severityGroup.projects.length) }}
</span>
</h5>
</template>
<template #subTitle>
<p class="m-0 ml-5 pb-1 text-secondary">{{ severityGroup.warning }}</p>
</template>
<div class="ml-5 pb-2">
<ul class="list-unstyled py-1">
<li v-for="project in severityGroup.projects" :key="project.id" class="py-2">
<gl-link target="_blank" :href="`${project.fullPath}/security/dashboard`">{{
project.fullName
}}</gl-link>
<span
v-if="project.mostSevereVulnerability"
ref="mostSevereCount"
class="d-block text-lowercase"
:class="cssForMostSevereVulnerability(project.mostSevereVulnerability)"
>{{ project.mostSevereVulnerability.count }}
{{ severityText(project.mostSevereVulnerability.level) }}
</span>
</li>
</ul>
</div>
</accordion-item>
</template>
</accordion>
</section>
</template>
...@@ -41,6 +41,7 @@ export default function() { ...@@ -41,6 +41,7 @@ export default function() {
vulnerabilitiesEndpoint: el.dataset.vulnerabilitiesEndpoint, vulnerabilitiesEndpoint: el.dataset.vulnerabilitiesEndpoint,
vulnerabilitiesCountEndpoint: el.dataset.vulnerabilitiesSummaryEndpoint, vulnerabilitiesCountEndpoint: el.dataset.vulnerabilitiesSummaryEndpoint,
vulnerabilitiesHistoryEndpoint: el.dataset.vulnerabilitiesHistoryEndpoint, vulnerabilitiesHistoryEndpoint: el.dataset.vulnerabilitiesHistoryEndpoint,
vulnerableProjectsEndpoint: el.dataset.vulnerableProjectsEndpoint,
}, },
}); });
}, },
......
...@@ -8,6 +8,7 @@ export const SEVERITY_LEVELS = { ...@@ -8,6 +8,7 @@ export const SEVERITY_LEVELS = {
unknown: s__('severity|Unknown'), unknown: s__('severity|Unknown'),
info: s__('severity|Info'), info: s__('severity|Info'),
undefined: s__('severity|Undefined'), undefined: s__('severity|Undefined'),
none: s__('severity|None'),
}; };
export const CONFIDENCE_LEVELS = { export const CONFIDENCE_LEVELS = {
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import mediator from './plugins/mediator'; import mediator from './plugins/mediator';
import filters from './modules/filters/index'; import filters from './modules/filters/index';
import vulnerabilities from './modules/vulnerabilities/index'; import vulnerabilities from './modules/vulnerabilities/index';
import vulnerableProjects from './modules/vulnerable_projects/index';
Vue.use(Vuex); Vue.use(Vuex);
export default ({ plugins = [] } = {}) => export default ({ plugins = [] } = {}) =>
new Vuex.Store({ new Vuex.Store({
modules: { modules: {
vulnerableProjects,
filters, filters,
vulnerabilities, vulnerabilities,
}, },
......
import axios from 'axios';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { SET_LOADING, SET_PROJECTS, SET_HAS_ERROR } from './mutation_types';
export const fetchProjects = ({ dispatch }, endpoint) => {
dispatch('requestProjects');
// in the future this will be moved to `ee/api.js`
// see https://gitlab.com/gitlab-org/gitlab/merge_requests/20892#note_253602076
return axios
.get(endpoint)
.then(({ data }) => data.map(convertObjectPropsToCamelCase))
.then(data => {
dispatch('receiveProjectsSuccess', data);
})
.catch(() => {
dispatch('receiveProjectsError');
});
};
export const requestProjects = ({ commit }) => {
commit(SET_LOADING, true);
commit(SET_HAS_ERROR, false);
};
export const receiveProjectsSuccess = ({ commit }, payload) => {
commit(SET_LOADING, false);
commit(SET_PROJECTS, payload);
};
export const receiveProjectsError = ({ commit }) => {
createFlash(__('Unable to fetch vulnerable projects'));
commit(SET_HAS_ERROR, true);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
// This is no longer needed after gitlab-foss#52179 is merged
export default () => {};
import { __, s__ } from '~/locale';
export const severityLevels = {
CRITICAL: 'critical',
HIGH: 'high',
UNKNOWN: 'unknown',
MEDIUM: 'medium',
LOW: 'low',
NONE: 'none',
};
export const severityLevelsTranslations = {
[severityLevels.CRITICAL]: s__('severity|Critical'),
[severityLevels.HIGH]: s__('severity|High'),
[severityLevels.UNKNOWN]: s__('severity|Unknown'),
[severityLevels.MEDIUM]: s__('severity|Medium'),
[severityLevels.LOW]: s__('severity|Low'),
[severityLevels.NONE]: s__('severity|None'),
};
export const SEVERITY_LEVELS_ORDERED_BY_SEVERITY = [
severityLevels.CRITICAL,
severityLevels.HIGH,
severityLevels.UNKNOWN,
severityLevels.MEDIUM,
severityLevels.LOW,
severityLevels.NONE,
];
export const severityGroupTypes = {
F: 'F',
D: 'D',
C: 'C',
B: 'B',
A: 'A',
};
export const SEVERITY_GROUPS = [
{
type: severityGroupTypes.F,
description: __('Projects with critical vulnerabilities'),
warning: __('Critical vulnerabilities present'),
severityLevels: [severityLevels.CRITICAL],
},
{
type: severityGroupTypes.D,
description: __('Projects with high or unknown vulnerabilities'),
warning: __('High or unknown vulnerabilities present'),
severityLevels: [severityLevels.HIGH, severityLevels.UNKNOWN],
},
{
type: severityGroupTypes.C,
description: __('Projects with medium vulnerabilities'),
warning: __('Medium vulnerabilities present'),
severityLevels: [severityLevels.MEDIUM],
},
{
type: severityGroupTypes.B,
description: __('Projects with low vulnerabilities'),
warning: __('Low vulnerabilities present'),
severityLevels: [severityLevels.LOW],
},
{
type: severityGroupTypes.A,
description: __('Projects with no vulnerabilities and security scanning enabled'),
warning: __('No vulnerabilities present'),
severityLevels: [severityLevels.NONE],
},
];
import { SEVERITY_GROUPS, SEVERITY_LEVELS_ORDERED_BY_SEVERITY } from './constants';
import { projectsForSeverityGroup, addMostSevereVulnerabilityInformation } from './utils';
export const severityGroups = ({ projects }) => {
// add data about it's most severe vulnerability to each project
const projectsWithSeverityInformation = projects.map(
addMostSevereVulnerabilityInformation(SEVERITY_LEVELS_ORDERED_BY_SEVERITY),
);
// return an array of severity groups, each containing an array of projects match the groups criteria
return SEVERITY_GROUPS.map(severityGroup => ({
...severityGroup,
projects: projectsForSeverityGroup(projectsWithSeverityInformation, severityGroup),
}));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
// This is no longer needed after gitlab-foss#52179 is merged
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_LOADING = 'SET_LOADING';
export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_HAS_ERROR = 'SET_HAS_ERROR';
import { SET_LOADING, SET_PROJECTS, SET_HAS_ERROR } from './mutation_types';
export default {
[SET_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[SET_PROJECTS](state, projects) {
state.projects = projects;
},
[SET_HAS_ERROR](state, hasError) {
state.hasError = hasError;
},
};
export default () => ({ isLoading: '', hasError: '', projects: [] });
import { severityLevels } from './constants';
/**
* Returns the count of a given severity level on a given project
*
* @param project
* @param severityLevel
* @returns {*|number}
*/
export const vulnerabilityCount = (project, severityLevel) =>
project[`${severityLevel}VulnerabilityCount`] || 0;
/**
* Returns "true" if a given project has at least one vulnerability with the given level, otherwise "false"
*
* @param project
* @returns {function(*=): boolean}
*/
export const hasVulnerabilityWithSeverityLevel = project => severityLevel =>
vulnerabilityCount(project, severityLevel) > 0;
/**
* Returns the name and count of a project's most severe vulnerability
*
* @param severityLevelsOrderedBySeverity
* @param project
* @returns {{level: *, count: *}}
*/
export const mostSevereVulnerability = (severityLevelsOrderedBySeverity, project) => {
const level =
severityLevelsOrderedBySeverity.find(hasVulnerabilityWithSeverityLevel(project)) ||
severityLevels.NONE;
const count = vulnerabilityCount(project, level) || null;
return {
level,
count,
};
};
/**
* Takes a project object and adds a property 'mostSevereVulnerability' that contains the 'level'
* and count of the given project's most severe vulnerability
*
* @param severityLevelsInOrder
* @returns {function(*=): {mostSevereVulnerability: *}}
*/
export const addMostSevereVulnerabilityInformation = severityLevelsInOrder => project => ({
...project,
mostSevereVulnerability: mostSevereVulnerability(severityLevelsInOrder, project),
});
/**
* Returns an array of projects that match the given severity group
*
* @param projects
* @param group
* @returns {*}
*/
export const projectsForSeverityGroup = (projects, group) =>
projects.filter(({ mostSevereVulnerability: { level } }) => group.severityLevels.includes(level));
...@@ -7,4 +7,5 @@ ...@@ -7,4 +7,5 @@
projects_endpoint: expose_url(api_v4_groups_projects_path(id: @group.id)), projects_endpoint: expose_url(api_v4_groups_projects_path(id: @group.id)),
vulnerability_feedback_help_path: help_page_path("user/application_security/index", anchor: "interacting-with-the-vulnerabilities"), vulnerability_feedback_help_path: help_page_path("user/application_security/index", anchor: "interacting-with-the-vulnerabilities"),
empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'), empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'),
dashboard_documentation: help_page_path('user/application_security/security_dashboard/index') } } dashboard_documentation: help_page_path('user/application_security/security_dashboard/index'),
vulnerable_projects_endpoint: group_security_vulnerable_projects_path(@group) } }
---
title: Add most affected projects to group security dashboard
merge_request: 20892
author:
type: added
...@@ -46,6 +46,9 @@ describe 'Group overview', :js do ...@@ -46,6 +46,9 @@ describe 'Group overview', :js do
expect(page).to have_content 'Vulnerabilities over time' expect(page).to have_content 'Vulnerabilities over time'
expect(page).to have_selector('.js-vulnerabilities-chart-time-info') expect(page).to have_selector('.js-vulnerabilities-chart-time-info')
expect(page).to have_selector('.js-vulnerabilities-chart-severity-level-breakdown') expect(page).to have_selector('.js-vulnerabilities-chart-severity-level-breakdown')
expect(page).to have_content 'Project security status'
expect(page).to have_selector('.js-projects-security-status')
end end
end end
end end
......
...@@ -7,6 +7,7 @@ import Filters from 'ee/security_dashboard/components/filters.vue'; ...@@ -7,6 +7,7 @@ import Filters from 'ee/security_dashboard/components/filters.vue';
import SecurityDashboardTable from 'ee/security_dashboard/components/security_dashboard_table.vue'; import SecurityDashboardTable from 'ee/security_dashboard/components/security_dashboard_table.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/vulnerability_chart.vue'; import VulnerabilityChart from 'ee/security_dashboard/components/vulnerability_chart.vue';
import VulnerabilityCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue'; import VulnerabilityCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import createStore from 'ee/security_dashboard/store'; import createStore from 'ee/security_dashboard/store';
import { getParameterValues } from '~/lib/utils/url_utility'; import { getParameterValues } from '~/lib/utils/url_utility';
...@@ -18,6 +19,7 @@ const pipelineId = 123; ...@@ -18,6 +19,7 @@ const pipelineId = 123;
const vulnerabilitiesEndpoint = `${TEST_HOST}/vulnerabilities`; const vulnerabilitiesEndpoint = `${TEST_HOST}/vulnerabilities`;
const vulnerabilitiesCountEndpoint = `${TEST_HOST}/vulnerabilities_summary`; const vulnerabilitiesCountEndpoint = `${TEST_HOST}/vulnerabilities_summary`;
const vulnerabilitiesHistoryEndpoint = `${TEST_HOST}/vulnerabilities_history`; const vulnerabilitiesHistoryEndpoint = `${TEST_HOST}/vulnerabilities_history`;
const vulnerableProjectsEndpoint = `${TEST_HOST}/vulnerable_projects`;
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockReturnValue([]), getParameterValues: jest.fn().mockReturnValue([]),
...@@ -51,6 +53,7 @@ describe('Security Dashboard app', () => { ...@@ -51,6 +53,7 @@ describe('Security Dashboard app', () => {
vulnerabilitiesEndpoint, vulnerabilitiesEndpoint,
vulnerabilitiesCountEndpoint, vulnerabilitiesCountEndpoint,
vulnerabilitiesHistoryEndpoint, vulnerabilitiesHistoryEndpoint,
vulnerableProjectsEndpoint,
pipelineId, pipelineId,
vulnerabilityFeedbackHelpPath: `${TEST_HOST}/vulnerabilities_feedback_help`, vulnerabilityFeedbackHelpPath: `${TEST_HOST}/vulnerabilities_feedback_help`,
...props, ...props,
...@@ -141,6 +144,7 @@ describe('Security Dashboard app', () => { ...@@ -141,6 +144,7 @@ describe('Security Dashboard app', () => {
endpointProp | Component endpointProp | Component
${'vulnerabilitiesCountEndpoint'} | ${VulnerabilityCountList} ${'vulnerabilitiesCountEndpoint'} | ${VulnerabilityCountList}
${'vulnerabilitiesHistoryEndpoint'} | ${VulnerabilityChart} ${'vulnerabilitiesHistoryEndpoint'} | ${VulnerabilityChart}
${'vulnerableProjectsEndpoint'} | ${VulnerabilitySeverity}
`('with an empty $endpointProp', ({ endpointProp, Component }) => { `('with an empty $endpointProp', ({ endpointProp, Component }) => {
beforeEach(() => { beforeEach(() => {
setup(); setup();
......
...@@ -14,6 +14,7 @@ const vulnerabilitiesEndpoint = '/vulnerabilities'; ...@@ -14,6 +14,7 @@ const vulnerabilitiesEndpoint = '/vulnerabilities';
const vulnerabilitiesCountEndpoint = '/vulnerabilities_summary'; const vulnerabilitiesCountEndpoint = '/vulnerabilities_summary';
const vulnerabilitiesHistoryEndpoint = '/vulnerabilities_history'; const vulnerabilitiesHistoryEndpoint = '/vulnerabilities_history';
const vulnerabilityFeedbackHelpPath = '/vulnerabilities_feedback_help'; const vulnerabilityFeedbackHelpPath = '/vulnerabilities_feedback_help';
const vulnerableProjectsEndpoint = '/vulnerable_projects';
describe('Group Security Dashboard component', () => { describe('Group Security Dashboard component', () => {
let store; let store;
...@@ -45,6 +46,7 @@ describe('Group Security Dashboard component', () => { ...@@ -45,6 +46,7 @@ describe('Group Security Dashboard component', () => {
vulnerabilitiesCountEndpoint, vulnerabilitiesCountEndpoint,
vulnerabilitiesHistoryEndpoint, vulnerabilitiesHistoryEndpoint,
vulnerabilityFeedbackHelpPath, vulnerabilityFeedbackHelpPath,
vulnerableProjectsEndpoint,
}, },
...options, ...options,
}); });
...@@ -75,6 +77,7 @@ describe('Group Security Dashboard component', () => { ...@@ -75,6 +77,7 @@ describe('Group Security Dashboard component', () => {
vulnerabilitiesCountEndpoint, vulnerabilitiesCountEndpoint,
vulnerabilitiesHistoryEndpoint, vulnerabilitiesHistoryEndpoint,
vulnerabilityFeedbackHelpPath, vulnerabilityFeedbackHelpPath,
vulnerableProjectsEndpoint,
}), }),
); );
}); });
......
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import { GlAvatar, GlLink } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import { Accordion, AccordionItem } from 'ee/vue_shared/components/accordion';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Vulnerability Severity component', () => {
let actions;
let getters;
let store;
let propsData;
let wrapper;
const getMockProject = () => ({
fullPath: '/foo/bar',
fullName: 'baz',
mostSevereVulnerability: { level: 'qux', count: 10 },
});
const getMockSeverityGroups = ({ projects = [] } = {}) => ({
A: { type: 'A', projects },
B: { type: 'B', projects },
D: { type: 'D', projects },
E: { type: 'E', projects },
F: { type: 'F', projects },
});
const factory = () => {
const state = {
isLoading: false,
projects: [],
};
propsData = {
endpoint: 'http://foo.com',
};
actions = {
fetchProjects: jest.fn(),
};
getters = {
severityGroups: getMockSeverityGroups,
};
store = new Vuex.Store({
modules: {
vulnerableProjects: {
namespaced: true,
actions,
getters,
state,
},
},
});
wrapper = mount(VulnerabilitySeverity, {
localVue,
store,
sync: false,
propsData,
});
};
const accordion = () => wrapper.find(Accordion);
const accordionItems = () => wrapper.findAll(AccordionItem);
const firstAccordionItem = () => accordionItems().at(0);
const accordionItemForSeverityGroup = groupName =>
wrapper.find({ ref: `accordionItem${groupName}` });
const hasAccordionItemForEachSeverityLevel = () =>
expect(accordionItems().length).toBe(Object.keys(getMockSeverityGroups()).length);
const hasEachAccordionItemDisabled = () =>
accordionItems().wrappers.every(item => item.props('disabled'));
beforeEach(factory);
afterEach(() => {
wrapper.destroy();
wrapper = null;
jest.restoreAllMocks();
});
describe('when being created', () => {
it('dispatches the "fetchProjects" action with the given endpoint as an argument', () => {
expect(actions.fetchProjects).toHaveBeenCalledTimes(1);
expect(actions.fetchProjects.mock.calls[0][1]).toBe(propsData.endpoint);
});
});
describe('while the data is being loaded', () => {
beforeEach(() => {
store.state.vulnerableProjects.isLoading = true;
return wrapper.vm.$nextTick();
});
it('contains an accordion item with a loading state for each of the severity levels', () => {
hasAccordionItemForEachSeverityLevel();
accordionItems().wrappers.forEach(itemWrapper => {
expect(itemWrapper.props('isLoading')).toBe(true);
});
});
});
describe('when the data has loaded', () => {
it('contains an accordion', () => {
expect(accordion().exists()).toBe(true);
});
it('contains an accordion item for each of the severity levels', () => {
hasAccordionItemForEachSeverityLevel();
});
it('sets accordion items to be disabled if its given severity level has no projects', () => {
store.state.vulnerableProjects.projects = [];
return wrapper.vm.$nextTick().then(() => {
expect(hasEachAccordionItemDisabled()).toBe(true);
});
});
it('does not set accordion items to be disabled if its given severity level has projects', () => {
store.state.vulnerableProjects.projects = [getMockProject()];
return wrapper.vm.$nextTick().then(() => {
expect(hasEachAccordionItemDisabled()).toBe(false);
});
});
it.each(['A', 'B', 'D', 'E', 'F'])('contains an avatar for severity group: "%s"', groupName => {
expect(
accordionItemForSeverityGroup(groupName)
.find(GlAvatar)
.props('entityName'),
).toBe(groupName);
});
it('links to a given project', () => {
const mockProject = getMockProject();
store.state.vulnerableProjects.projects = [mockProject];
return wrapper.vm.$nextTick().then(() => {
expect(
firstAccordionItem()
.find(GlLink)
.attributes('href'),
).toContain(mockProject.fullPath);
expect(firstAccordionItem().text()).toContain(mockProject.fullName);
});
});
it('shows a count for the most severe vulnerability level', () => {
const project = { mostSevereVulnerability: { level: 'critical', count: 10 } };
store.state.vulnerableProjects.projects = [project];
return wrapper.vm.$nextTick().then(() => {
expect(trimText(wrapper.find({ ref: 'mostSevereCount' }).text())).toBe('10 Critical');
});
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`vulnerable projects module getters severityGroups takes an array of projects containing vulnerability data and groups them by severity level 1`] = `
Array [
Object {
"description": "Projects with critical vulnerabilities",
"projects": Array [
Object {
"criticalVulnerabilityCount": 1,
"fullName": "full_name",
"fullPath": "full_path",
"highVulnerabilityCount": 0,
"id": "id",
"lowVulnerabilityCount": 0,
"mediumVulnerabilityCount": 0,
"mostSevereVulnerability": Object {
"count": 1,
"level": "critical",
},
"unknownVulnerabilityCount": 0,
},
],
"severityLevels": Array [
"critical",
],
"type": "F",
"warning": "Critical vulnerabilities present",
},
Object {
"description": "Projects with high or unknown vulnerabilities",
"projects": Array [
Object {
"criticalVulnerabilityCount": 0,
"fullName": "full_name",
"fullPath": "full_path",
"highVulnerabilityCount": 1,
"id": "id",
"lowVulnerabilityCount": 0,
"mediumVulnerabilityCount": 0,
"mostSevereVulnerability": Object {
"count": 1,
"level": "high",
},
"unknownVulnerabilityCount": 0,
},
Object {
"criticalVulnerabilityCount": 0,
"fullName": "full_name",
"fullPath": "full_path",
"highVulnerabilityCount": 0,
"id": "id",
"lowVulnerabilityCount": 0,
"mediumVulnerabilityCount": 0,
"mostSevereVulnerability": Object {
"count": 1,
"level": "unknown",
},
"unknownVulnerabilityCount": 1,
},
],
"severityLevels": Array [
"high",
"unknown",
],
"type": "D",
"warning": "High or unknown vulnerabilities present",
},
Object {
"description": "Projects with medium vulnerabilities",
"projects": Array [
Object {
"criticalVulnerabilityCount": 0,
"fullName": "full_name",
"fullPath": "full_path",
"highVulnerabilityCount": 0,
"id": "id",
"lowVulnerabilityCount": 0,
"mediumVulnerabilityCount": 1,
"mostSevereVulnerability": Object {
"count": 1,
"level": "medium",
},
"unknownVulnerabilityCount": 0,
},
],
"severityLevels": Array [
"medium",
],
"type": "C",
"warning": "Medium vulnerabilities present",
},
Object {
"description": "Projects with low vulnerabilities",
"projects": Array [
Object {
"criticalVulnerabilityCount": 0,
"fullName": "full_name",
"fullPath": "full_path",
"highVulnerabilityCount": 0,
"id": "id",
"lowVulnerabilityCount": 1,
"mediumVulnerabilityCount": 0,
"mostSevereVulnerability": Object {
"count": 1,
"level": "low",
},
"unknownVulnerabilityCount": 0,
},
],
"severityLevels": Array [
"low",
],
"type": "B",
"warning": "Low vulnerabilities present",
},
Object {
"description": "Projects with no vulnerabilities and security scanning enabled",
"projects": Array [
Object {
"criticalVulnerabilityCount": 0,
"fullName": "full_name",
"fullPath": "full_path",
"highVulnerabilityCount": 0,
"id": "id",
"lowVulnerabilityCount": 0,
"mediumVulnerabilityCount": 0,
"mostSevereVulnerability": Object {
"count": null,
"level": "none",
},
"unknownVulnerabilityCount": 0,
},
],
"severityLevels": Array [
"none",
],
"type": "A",
"warning": "No vulnerabilities present",
},
]
`;
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import createState from 'ee/security_dashboard/store/modules/vulnerable_projects/state';
import * as types from 'ee/security_dashboard/store/modules/vulnerable_projects/mutation_types';
import * as actions from 'ee/security_dashboard/store/modules/vulnerable_projects/actions';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
jest.mock('~/flash');
describe('Vulnerable Projects actions', () => {
const mockEndpoint = 'mock-list-endpoint';
const mockResponse = [{ key_foo: 'valueFoo' }];
let mockAxios;
let mockDispatchContext;
let state;
beforeEach(() => {
mockDispatchContext = { dispatch: () => {}, commit: () => {}, state };
state = createState();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('fetchProjects', () => {
beforeEach(() => {
mockAxios = new MockAdapter(axios);
});
afterEach(() => {
mockAxios.restore();
});
it('calls the vulnerable projects endpoint and transforms the response keys into camel case', () => {
mockAxios.onGet(mockEndpoint).replyOnce(200, mockResponse);
const mockResponseCamelCased = [{ keyFoo: 'valueFoo' }];
return testAction(
actions.fetchProjects,
mockEndpoint,
state,
[],
[
{ type: 'requestProjects' },
{ type: 'receiveProjectsSuccess', payload: mockResponseCamelCased },
],
);
});
it('handles an API error by dispatching "receiveProjectsError"', () => {
mockAxios.onGet(mockEndpoint).replyOnce(500);
return testAction(
actions.fetchProjects,
mockEndpoint,
state,
[],
[{ type: 'requestProjects' }, { type: 'receiveProjectsError' }],
);
});
});
describe('request projects', () => {
it('commits the SET_LOADING and SET_HAS_ERROR mutations', () =>
testAction(
actions.requestProjects,
null,
state,
[
{
type: types.SET_LOADING,
payload: true,
},
{
type: types.SET_HAS_ERROR,
payload: false,
},
],
[],
));
});
describe('receiveProjectsSuccess', () => {
it('commits the SET_PROJECTS mutation', () => {
const projects = [];
return testAction(
actions.receiveProjectsSuccess,
projects,
state,
[
{
type: types.SET_LOADING,
payload: false,
},
{
type: types.SET_PROJECTS,
payload: projects,
},
],
[],
);
});
});
describe('receiveProjectsError', () => {
it('commits the SET_HAS_ERROR mutation', () => {
const projects = [];
return testAction(
actions.receiveProjectsError,
projects,
state,
[
{
type: types.SET_HAS_ERROR,
payload: true,
},
],
[],
);
});
it('creates a flash error message', () => {
actions.receiveProjectsError(mockDispatchContext);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith('Unable to fetch vulnerable projects');
});
});
});
import * as getters from 'ee/security_dashboard/store/modules/vulnerable_projects/getters';
import {
createProjectWithOneVulnerability,
createProjectWithZeroVulnerabilities,
} from './mock_data';
describe('vulnerable projects module getters', () => {
describe('severityGroups', () => {
it('takes an array of projects containing vulnerability data and groups them by severity level', () => {
const mockProjects = [
createProjectWithOneVulnerability('critical'),
createProjectWithOneVulnerability('high'),
createProjectWithOneVulnerability('unknown'),
createProjectWithOneVulnerability('medium'),
createProjectWithOneVulnerability('low'),
createProjectWithZeroVulnerabilities(),
];
const state = { projects: mockProjects };
const projectsGroupedBySeverityLevel = getters.severityGroups(state);
expect(projectsGroupedBySeverityLevel).toMatchSnapshot();
});
});
});
export const createProjectWithZeroVulnerabilities = () => ({
id: 'id',
fullName: 'full_name',
fullPath: 'full_path',
criticalVulnerabilityCount: 0,
highVulnerabilityCount: 0,
mediumVulnerabilityCount: 0,
lowVulnerabilityCount: 0,
unknownVulnerabilityCount: 0,
});
// in the future this will be replaced by generated fixtures
// see https://gitlab.com/gitlab-org/gitlab/merge_requests/20892#note_253602093
export const createProjectWithVulnerabilities = count => (...severityLevels) => ({
...createProjectWithZeroVulnerabilities(),
...(severityLevels
? severityLevels.reduce(
(levels, level) => ({
...levels,
[`${level}VulnerabilityCount`]: count,
}),
{},
)
: {}),
});
export const createProjectWithOneVulnerability = createProjectWithVulnerabilities(1);
import createState from 'ee/security_dashboard/store/modules/project_selector/state';
import mutations from 'ee/security_dashboard/store/modules/vulnerable_projects/mutations';
import * as types from 'ee/security_dashboard/store/modules/vulnerable_projects/mutation_types';
describe('projectsSelector mutations', () => {
let state;
beforeEach(() => {
state = createState();
state.isLoading = false;
state.hasError = false;
});
describe('SET_LOADING', () => {
it('sets state.isLoading to be "true"', () => {
expect(state.hasError).toBe(false);
mutations[types.SET_LOADING](state, true);
expect(state.isLoading).toBe(true);
});
});
describe('SET_PROJECTS', () => {
it('sets state.projects to the given payload', () => {
const payload = [];
mutations[types.SET_PROJECTS](state, payload);
expect(state.projects).toBe(payload);
});
});
describe('SET_HAS_ERROR', () => {
it('sets state.projects to be "true"', () => {
expect(state.hasError).toBe(false);
mutations[types.SET_HAS_ERROR](state, true);
expect(state.hasError).toBe(true);
});
});
});
import {
addMostSevereVulnerabilityInformation,
hasVulnerabilityWithSeverityLevel,
mostSevereVulnerability,
projectsForSeverityGroup,
vulnerabilityCount,
} from 'ee/security_dashboard/store/modules/vulnerable_projects/utils';
import { createProjectWithOneVulnerability, createProjectWithVulnerabilities } from './mock_data';
describe('Vulnerable Projects store utils', () => {
describe('addMostSevereVulnerabilityInformation', () => {
it.each(['critical', 'medium', 'high'])(
'takes a project and adds a property containing information about its most severe vulnerability',
severityLevel => {
const mockProject = createProjectWithOneVulnerability(severityLevel);
const mockSeverityLevelsInOrder = [severityLevel, 'foo', 'bar'];
expect(
addMostSevereVulnerabilityInformation(mockSeverityLevelsInOrder)(mockProject),
).toEqual({
...mockProject,
mostSevereVulnerability: {
level: severityLevel,
count: 1,
},
});
},
);
});
describe('hasAtLeastOneVulnerabilityWithSeverityLevel', () => {
it('returns true if the given project has at least one vulnerability of the given severity level', () => {
const project = createProjectWithOneVulnerability('critical');
expect(hasVulnerabilityWithSeverityLevel(project)('critical')).toBe(true);
});
it.each(['high', 'medium', 'low'])(
'returns false if the given project does not contain at least one vulnerability of the given severity level',
severityLevel => {
const project = createProjectWithOneVulnerability(severityLevel);
expect(hasVulnerabilityWithSeverityLevel(project)('critical')).toBe(false);
},
);
});
describe('mostSevereVulnerability', () => {
it.each`
severityLevelsInProjects | mostSevereLevel
${['critical', 'high', 'unknown', 'medium', 'low']} | ${'critical'}
${['high', 'unknown', 'medium', 'low']} | ${'high'}
${['unknown', 'medium', 'low']} | ${'unknown'}
${['medium', 'low']} | ${'medium'}
${['low']} | ${'low'}
${['none']} | ${'none'}
`(
'given $severityLevelsInProjects returns an object containing the name and type of the most severe vulnerability',
({ severityLevelsInProjects, mostSevereLevel }) => {
const severityLevelsInOrder = ['critical', 'high', 'unknown', 'medium', 'low', 'none'];
const mockProject = createProjectWithOneVulnerability(...severityLevelsInProjects);
expect(mostSevereVulnerability(severityLevelsInOrder, mockProject)).toEqual({
level: mostSevereLevel,
count: 1,
});
},
);
});
describe('vulnerabilityCount', () => {
it.each`
severityLevel | count
${'critical'} | ${1}
${'high'} | ${2}
${'medium'} | ${3}
${'low'} | ${4}
${'unknown'} | ${5}
`(
"returns the correct count for '$severityLevel' vulnerabilities",
({ severityLevel, count }) => {
const project = createProjectWithVulnerabilities(count)(severityLevel);
expect(vulnerabilityCount(project, severityLevel)).toBe(count);
},
);
});
describe('projectsForSeverityGroup', () => {
it.each`
severityLevelsForGroup | expectedProjectsInGroup
${['critical']} | ${['A']}
${['high', 'unknown']} | ${['B', 'C']}
${['low']} | ${['D']}
`(
'returns all projects that fall under the given severity group',
({ severityLevelsForGroup, expectedProjectsInGroup }) => {
const mockProjects = {
A: { mostSevereVulnerability: { level: 'critical' } },
B: { mostSevereVulnerability: { level: 'high' } },
C: { mostSevereVulnerability: { level: 'unknown' } },
D: { mostSevereVulnerability: { level: 'low' } },
};
const mockGroup = { severityLevels: severityLevelsForGroup };
expect(projectsForSeverityGroup(Object.values(mockProjects), mockGroup)).toStrictEqual(
expectedProjectsInGroup.map(project => mockProjects[project]),
);
},
);
});
});
...@@ -153,6 +153,11 @@ msgid_plural "%d more comments" ...@@ -153,6 +153,11 @@ msgid_plural "%d more comments"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d project"
msgid_plural "%d projects"
msgstr[0] ""
msgstr[1] ""
msgid "%d request with warnings" msgid "%d request with warnings"
msgid_plural "%d requests with warnings" msgid_plural "%d requests with warnings"
msgstr[0] "" msgstr[0] ""
...@@ -5139,6 +5144,9 @@ msgstr "" ...@@ -5139,6 +5144,9 @@ msgstr ""
msgid "Creation date" msgid "Creation date"
msgstr "" msgstr ""
msgid "Critical vulnerabilities present"
msgstr ""
msgid "Cron Timezone" msgid "Cron Timezone"
msgstr "" msgstr ""
...@@ -9205,6 +9213,9 @@ msgstr "" ...@@ -9205,6 +9213,9 @@ msgstr ""
msgid "Hiding all labels" msgid "Hiding all labels"
msgstr "" msgstr ""
msgid "High or unknown vulnerabilities present"
msgstr ""
msgid "Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0." msgid "Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0."
msgstr "" msgstr ""
...@@ -10638,6 +10649,9 @@ msgstr "" ...@@ -10638,6 +10649,9 @@ msgstr ""
msgid "Logs|To see the pod logs, deploy your code to an environment." msgid "Logs|To see the pod logs, deploy your code to an environment."
msgstr "" msgstr ""
msgid "Low vulnerabilities present"
msgstr ""
msgid "MB" msgid "MB"
msgstr "" msgstr ""
...@@ -10887,6 +10901,9 @@ msgstr "" ...@@ -10887,6 +10901,9 @@ msgstr ""
msgid "Median" msgid "Median"
msgstr "" msgstr ""
msgid "Medium vulnerabilities present"
msgstr ""
msgid "Member lock" msgid "Member lock"
msgstr "" msgstr ""
...@@ -11818,6 +11835,9 @@ msgstr "" ...@@ -11818,6 +11835,9 @@ msgstr ""
msgid "No vulnerabilities found for this project" msgid "No vulnerabilities found for this project"
msgstr "" msgstr ""
msgid "No vulnerabilities present"
msgstr ""
msgid "No, directly import the existing email addresses and usernames." msgid "No, directly import the existing email addresses and usernames."
msgstr "" msgstr ""
...@@ -13575,6 +13595,12 @@ msgstr "" ...@@ -13575,6 +13595,12 @@ msgstr ""
msgid "Project path" msgid "Project path"
msgstr "" msgstr ""
msgid "Project security status"
msgstr ""
msgid "Project security status help page"
msgstr ""
msgid "Project slug" msgid "Project slug"
msgstr "" msgstr ""
...@@ -13944,6 +13970,9 @@ msgstr "" ...@@ -13944,6 +13970,9 @@ msgstr ""
msgid "Projects Successfully Retrieved" msgid "Projects Successfully Retrieved"
msgstr "" msgstr ""
msgid "Projects are graded based on the highest severity vulnerability present"
msgstr ""
msgid "Projects shared with %{group_name}" msgid "Projects shared with %{group_name}"
msgstr "" msgstr ""
...@@ -13953,6 +13982,21 @@ msgstr "" ...@@ -13953,6 +13982,21 @@ msgstr ""
msgid "Projects to index" msgid "Projects to index"
msgstr "" msgstr ""
msgid "Projects with critical vulnerabilities"
msgstr ""
msgid "Projects with high or unknown vulnerabilities"
msgstr ""
msgid "Projects with low vulnerabilities"
msgstr ""
msgid "Projects with medium vulnerabilities"
msgstr ""
msgid "Projects with no vulnerabilities and security scanning enabled"
msgstr ""
msgid "Projects with write access" msgid "Projects with write access"
msgstr "" msgstr ""
...@@ -18903,6 +18947,9 @@ msgstr "" ...@@ -18903,6 +18947,9 @@ msgstr ""
msgid "Unable to connect to server: %{error}" msgid "Unable to connect to server: %{error}"
msgstr "" msgstr ""
msgid "Unable to fetch vulnerable projects"
msgstr ""
msgid "Unable to generate new instance ID" msgid "Unable to generate new instance ID"
msgstr "" msgstr ""
...@@ -21756,6 +21803,9 @@ msgstr "" ...@@ -21756,6 +21803,9 @@ msgstr ""
msgid "severity|Medium" msgid "severity|Medium"
msgstr "" msgstr ""
msgid "severity|None"
msgstr ""
msgid "severity|Undefined" msgid "severity|Undefined"
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