diff --git a/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_details.vue b/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_details.vue index 18853e774e5d5d3d1fbac228ee5adda6a787ead3..84413c925160702eb790fc5720260967c6422f84 100644 --- a/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_details.vue +++ b/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_details.vue @@ -1,5 +1,12 @@ <script> -import { GlFriendlyWrap, GlLink, GlSprintf, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { + GlFriendlyWrap, + GlLink, + GlSprintf, + GlButton, + GlTooltipDirective, + GlLabel, +} from '@gitlab/ui'; import { isEmpty } from 'lodash'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; @@ -14,6 +21,7 @@ import { s__, __ } from '~/locale'; import CodeBlock from '~/vue_shared/components/code_block.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glAbilitiesMixin from '~/vue_shared/mixins/gl_abilities_mixin'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { setTabIndexForCodeFlowPage } from 'ee/vulnerabilities/helpers'; import DetailItem from './detail_item.vue'; import FalsePositiveAlert from './false_positive_alert.vue'; @@ -21,6 +29,16 @@ import VulnerabilityDetailSection from './vulnerability_detail_section.vue'; import VulnerabilityTraining from './vulnerability_training.vue'; import VulnerabilityFileContents from './vulnerability_file_contents.vue'; +// These colors are taken from: +// https://gitlab.com/gitlab-org/gitlab/-/issues/427441/designs/design_1730952836577.png +const COLORS = { + critical: '#8d1300', + high: '#c91c00', + medium: '#c17d10', + low: '#d99530', + info: '#428fdc', +}; + export default { name: 'VulnerabilityDetails', components: { @@ -36,6 +54,8 @@ export default { VulnerabilityFileContents, GlTooltipDirective, GlButton, + GlLabel, + HelpPopover, }, directives: { SafeHtml, @@ -53,6 +73,9 @@ export default { }, }, computed: { + isSecurityScoresEnabled() { + return this.glFeatures.vulnerabilityReportSecurityScores; + }, humanReadableReportType() { return convertReportType(this.vulnerability.reportType); }, @@ -221,6 +244,34 @@ export default { this.vulnerability?.details?.codeFlows && !isEmpty(this.vulnerability.details.codeFlows) ); }, + hasCVSSData() { + return this.vulnerability.cvss?.length > 0; + }, + hasCveEnrichmentData() { + return Boolean(this.vulnerability.cveEnrichment); + }, + epssColor() { + let color = COLORS.info; + const score = parseFloat(this.epssScore); + + if (score >= 76) { + color = COLORS.critical; + } else if (score >= 51) { + color = COLORS.high; + } else if (score >= 26) { + color = COLORS.medium; + } else if (score >= 1) { + color = COLORS.low; + } + + return color; + }, + epssScore() { + return `${Math.round(this.vulnerability.cveEnrichment.epssScore * 100)}%`; + }, + kevColor() { + return this.vulnerability.cveEnrichment.isKnownExploit ? COLORS.critical : COLORS.info; + }, }, mounted() { renderGFM(this.$refs.markdownContent); @@ -250,8 +301,44 @@ export default { redirectToCodeFlowTab() { setTabIndexForCodeFlowPage(this.$router, { path: '/', index: 1 }); }, + cvssColor(score) { + let color = COLORS.info; + + if (score >= 9) { + color = COLORS.critical; + } else if (score >= 7) { + color = COLORS.high; + } else if (score >= 4) { + color = COLORS.medium; + } else if (score >= 0.1) { + color = COLORS.low; + } + + return color; + }, + cvssVersion(cvss) { + return `v${cvss.version}`; + }, }, i18n: { + whatIsCvss: { + content: s__( + 'Vulnerability|The CVSS (Common Vulnerability Scoring System) is a standardized framework for assessing and communicating the severity of security vulnerabilities in software. It provides a numerical score (ranging from 0.0 to 10.0) to indicate the severity risk of the vulnerability.', + ), + title: s__('Vulnerability|What is CVSS?'), + }, + whatIsEpss: { + content: s__( + 'Vulnerability|The Exploit Prediction Scoring System model produces a probability score between 0 and 1 indicating the likelihood that a vulnerability will be exploited in the next 30 days.', + ), + title: s__('Vulnerability|What is EPSS?'), + }, + whatIsKev: { + content: s__( + 'Vulnerability|CISA (the Cybersecurity & Infrastructure Security Agency, a part of the U.S. Department of Homeland Security) maintains the Known Exploited Vulnerabilities (aka "KEV") catalog of vulnerabilities that have been exploited in the wild.', + ), + title: s__("Vulnerability|What is 'Has known exploit (KEV)'?"), + }, requestResponse: s__('Vulnerability|Request/Response'), unmodifiedResponse: s__( 'Vulnerability|The unmodified response is the original response that had no mutations done to the request', @@ -286,6 +373,39 @@ export default { <severity-badge :severity="vulnerability.severity" class="gl-ml-2 gl-inline" /> </detail-item> + <template v-if="isSecurityScoresEnabled"> + <detail-item + v-if="hasCVSSData" + :sprintf-message="__('%{labelStart}CVSS:%{labelEnd} %{cvss}')" + > + <div class="gl-inline-block"> + <b>{{ cvssVersion(vulnerability.cvss[0]) }}</b> + <gl-label + :background-color="cvssColor(vulnerability.cvss[0].overallScore)" + :title="vulnerability.cvss[0].overallScore.toString()" + /> + </div> + <help-popover class="gl-ml-2" :options="$options.i18n.whatIsCvss" /> + </detail-item> + + <template v-if="hasCveEnrichmentData"> + <detail-item :sprintf-message="__('%{labelStart}EPSS:%{labelEnd} %{epss}')"> + <gl-label :background-color="epssColor" :title="epssScore" /> + <help-popover class="gl-ml-2" :options="$options.i18n.whatIsEpss" /> + </detail-item> + + <detail-item + :sprintf-message="__('%{labelStart}Has Known Exploit (KEV):%{labelEnd} %{kev}')" + > + <gl-label + :background-color="kevColor" + :title="vulnerability.cveEnrichment.isKnownExploit ? __('Yes') : __('No')" + /> + <help-popover class="gl-ml-2" :options="$options.i18n.whatIsKev" /> + </detail-item> + </template> + </template> + <detail-item v-if="project" :sprintf-message="__('%{labelStart}Project:%{labelEnd} %{project}')" diff --git a/ee/app/controllers/projects/security/vulnerabilities_controller.rb b/ee/app/controllers/projects/security/vulnerabilities_controller.rb index 921f8629ca4c068d1796458ab743b1e8bfc1c8ad..ecf21f537ff95d51ad6010b315e12802a4cb5806 100644 --- a/ee/app/controllers/projects/security/vulnerabilities_controller.rb +++ b/ee/app/controllers/projects/security/vulnerabilities_controller.rb @@ -23,6 +23,7 @@ class VulnerabilitiesController < Projects::ApplicationController def show push_frontend_ability(ability: :explain_vulnerability_with_ai, resource: vulnerability, user: current_user) push_frontend_ability(ability: :resolve_vulnerability_with_ai, resource: vulnerability, user: current_user) + push_frontend_feature_flag(:vulnerability_report_security_scores, current_user, type: :beta) pipeline = vulnerability.finding.first_finding_pipeline @pipeline = pipeline if can?(current_user, :read_pipeline, pipeline) diff --git a/ee/spec/frontend/vulnerabilities/vulnerability_details_spec.js b/ee/spec/frontend/vulnerabilities/vulnerability_details_spec.js index 399b27da87b3259ddfd01f8537831d5f90b121a6..646de1cd3b0ce714423d38cd43f7e1f1d94f572d 100644 --- a/ee/spec/frontend/vulnerabilities/vulnerability_details_spec.js +++ b/ee/spec/frontend/vulnerabilities/vulnerability_details_spec.js @@ -1,4 +1,4 @@ -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlLabel } from '@gitlab/ui'; import { getAllByRole, getByTestId } from '@testing-library/dom'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; @@ -38,7 +38,11 @@ describe('Vulnerability Details', () => { const createWrapper = ( vulnerabilityOverrides, - { explainVulnerabilityWithAiAbility = false, mockVulnerabilityTrainingTemplate = false } = {}, + { + explainVulnerabilityWithAiAbility = false, + mockVulnerabilityTrainingTemplate = false, + vulnerabilityReportSecurityScores = true, + } = {}, ) => { const propsData = { vulnerability: { ...vulnerability, ...vulnerabilityOverrides }, @@ -49,6 +53,9 @@ describe('Vulnerability Details', () => { projectFullPath: TEST_PROJECT_FULL_PATH, canViewFalsePositive: true, glAbilities: { explainVulnerabilityWithAi: explainVulnerabilityWithAiAbility }, + glFeatures: { + vulnerabilityReportSecurityScores, + }, }, stubs: { VulnerabilityFileContents: true, @@ -73,6 +80,9 @@ describe('Vulnerability Details', () => { expect(wrapper.findComponent(SeverityBadge).props('severity')).toBe(vulnerability.severity); expect(getText('reportType')).toBe(`Tool: ${vulnerability.reportType}`); + expect(getById('cvss').exists()).toBe(false); + expect(getById('kev').exists()).toBe(false); + expect(getById('epss').exists()).toBe(false); expect(getById('project').exists()).toBe(false); expect(findFalsePositiveAlert().exists()).toBe(false); expect(getById('image').exists()).toBe(false); @@ -173,6 +183,76 @@ describe('Vulnerability Details', () => { expect(getText('method')).toBe(`Method: method name`); }); + describe('security scores', () => { + it('shows security scores', () => { + createWrapper({ + cveEnrichment: { epssScore: 0.2, isKnownExploit: true }, + cvss: [{ version: '3.1', overallScore: 9.07 }], + }); + + const cvssText = getById('cvss').text(); + + expect(cvssText).toContain('3.1'); + expect(cvssText).toContain('9.07'); + expect(cvssText).toContain('What is CVSS?'); + + const epssText = getById('epss').text(); + + expect(epssText).toContain('20%'); + expect(epssText).toContain('What is EPSS?'); + + expect(getById('kev').text()).toContain('Yes'); + }); + + it('does not show security scores when feature flag is disabled', () => { + createWrapper( + { + cveEnrichment: { epssScore: 0.2, isKnownExploit: true }, + cvss: [{ version: '3.1', overallScore: 9.07 }], + }, + { vulnerabilityReportSecurityScores: false }, + ); + + expect(getById('cvss').exists()).toBe(false); + expect(getById('epss').exists()).toBe(false); + expect(getById('kev').exists()).toBe(false); + }); + + it.each` + cvss | expectedColor + ${9.2} | ${'#8d1300'} + ${7.4} | ${'#c91c00'} + ${5.7} | ${'#c17d10'} + ${2.0} | ${'#d99530'} + ${1} | ${'#d99530'} + ${0} | ${'#428fdc'} + `('should display the correct color for cvss: $cvss', ({ cvss, expectedColor }) => { + createWrapper({ cvss: [{ version: '3.1', overallScore: cvss }] }); + expect(getById('cvss').findComponent(GlLabel).props('backgroundColor')).toBe(expectedColor); + }); + + it.each` + epss | expectedColor + ${0.77} | ${'#8d1300'} + ${0.52} | ${'#c91c00'} + ${0.28} | ${'#c17d10'} + ${0.1} | ${'#d99530'} + ${0} | ${'#428fdc'} + `('should display the correct color for epss: $epss', ({ epss, expectedColor }) => { + createWrapper({ cveEnrichment: { epssScore: epss } }); + expect(getById('epss').findComponent(GlLabel).props('backgroundColor')).toBe(expectedColor); + }); + + it.each` + kev | expectedColor + ${true} | ${'#8d1300'} + ${false} | ${'#428fdc'} + `('should display the correct color for kev: $kev', ({ kev, expectedColor }) => { + createWrapper({ cveEnrichment: { epssScore: 0.7, isKnownExploit: kev } }); + expect(getById('kev').findComponent(GlLabel).props('backgroundColor')).toBe(expectedColor); + }); + }); + it.each` description | vulnerabilityData ${"request's URL"} | ${{ request: { url: 'http://host.test/foo/bar' } }} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e55d0900cee46320abe3382e81776786bd4c8e51..7e5c0b03e76feb241244512334a42a1c02add26e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -979,6 +979,9 @@ msgstr "" msgid "%{labelStart}Assert:%{labelEnd} %{assertion}" msgstr "" +msgid "%{labelStart}CVSS:%{labelEnd} %{cvss}" +msgstr "" + msgid "%{labelStart}Class:%{labelEnd} %{class}" msgstr "" @@ -994,12 +997,18 @@ msgstr "" msgid "%{labelStart}Crash Type:%{labelEnd} %{crash_type}" msgstr "" +msgid "%{labelStart}EPSS:%{labelEnd} %{epss}" +msgstr "" + msgid "%{labelStart}Evidence:%{labelEnd} %{evidence}" msgstr "" msgid "%{labelStart}File:%{labelEnd} %{file}" msgstr "" +msgid "%{labelStart}Has Known Exploit (KEV):%{labelEnd} %{kev}" +msgstr "" + msgid "%{labelStart}Image:%{labelEnd} %{image}" msgstr "" @@ -63231,6 +63240,9 @@ msgstr "" msgid "Vulnerability|Bug Bounty" msgstr "" +msgid "Vulnerability|CISA (the Cybersecurity & Infrastructure Security Agency, a part of the U.S. Department of Homeland Security) maintains the Known Exploited Vulnerabilities (aka \"KEV\") catalog of vulnerabilities that have been exploited in the wild." +msgstr "" + msgid "Vulnerability|CVSS v3" msgstr "" @@ -63375,6 +63387,12 @@ msgstr "" msgid "Vulnerability|Steps" msgstr "" +msgid "Vulnerability|The CVSS (Common Vulnerability Scoring System) is a standardized framework for assessing and communicating the severity of security vulnerabilities in software. It provides a numerical score (ranging from 0.0 to 10.0) to indicate the severity risk of the vulnerability." +msgstr "" + +msgid "Vulnerability|The Exploit Prediction Scoring System model produces a probability score between 0 and 1 indicating the likelihood that a vulnerability will be exploited in the next 30 days." +msgstr "" + msgid "Vulnerability|The scanner determined this vulnerability to be a false positive. Verify the evaluation before changing its status. %{linkStart}Learn more about false positive detection.%{linkEnd}" msgstr "" @@ -63411,6 +63429,15 @@ msgstr "" msgid "Vulnerability|Vulnerable method:" msgstr "" +msgid "Vulnerability|What is 'Has known exploit (KEV)'?" +msgstr "" + +msgid "Vulnerability|What is CVSS?" +msgstr "" + +msgid "Vulnerability|What is EPSS?" +msgstr "" + msgid "Vulnerability|What is code flow?" msgstr ""