From 31dc599a7afc7c44675b6c1382d2ea1d2ff8e0c2 Mon Sep 17 00:00:00 2001 From: Philippe Perrin Date: Tue, 10 Dec 2019 10:10:35 +0100 Subject: [PATCH] SONAR-12718 Create the hotspot details section --- ...curityHotspots.ts => security-hotspots.ts} | 6 +- .../src/main/js/app/styles/init/misc.css | 4 + .../securityHotspots/SecurityHotspotsApp.tsx | 6 +- .../SecurityHotspotsAppRenderer.tsx | 11 +- .../__tests__/SecurityHotspotsApp-test.tsx | 8 +- .../SecurityHotspotsAppRenderer-test.tsx | 22 +- .../SecurityHotspotsAppRenderer-test.tsx.snap | 236 ++++++++++++++- .../securityHotspots/__tests__/utils-test.ts | 22 +- .../components/HotspotCategory.tsx | 2 +- .../components/HotspotList.tsx | 4 +- .../components/HotspotListItem.tsx | 2 +- .../components/HotspotViewer.tsx | 57 +++- .../components/HotspotViewerRenderer.tsx | 69 +++++ .../components/HotspotViewerTabs.tsx | 76 +++++ .../__tests__/HotspotCategory-test.tsx | 6 +- .../components/__tests__/HotspotList-test.tsx | 14 +- .../__tests__/HotspotListItem-test.tsx | 6 +- .../__tests__/HotspotViewer-test.tsx | 56 ++++ .../__tests__/HotspotViewerRenderer-test.tsx | 44 +++ .../__tests__/HotspotViewerTabs-test.tsx | 68 +++++ .../__snapshots__/HotspotViewer-test.tsx.snap | 32 ++ .../HotspotViewerRenderer-test.tsx.snap | 278 ++++++++++++++++++ .../HotspotViewerTabs-test.tsx.snap | 131 +++++++++ .../main/js/apps/securityHotspots/styles.css | 1 + .../main/js/apps/securityHotspots/utils.ts | 9 +- .../js/helpers/mocks/security-hotspots.ts | 50 +++- ...curityHotspots.ts => security-hotspots.ts} | 28 +- server/sonar-web/src/main/js/types/types.d.ts | 2 + .../resources/org/sonar/l10n/core.properties | 7 + 29 files changed, 1184 insertions(+), 73 deletions(-) rename server/sonar-web/src/main/js/api/{securityHotspots.ts => security-hotspots.ts} (80%) create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewer-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewer-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap rename server/sonar-web/src/main/js/types/{securityHotspots.ts => security-hotspots.ts} (70%) diff --git a/server/sonar-web/src/main/js/api/securityHotspots.ts b/server/sonar-web/src/main/js/api/security-hotspots.ts similarity index 80% rename from server/sonar-web/src/main/js/api/securityHotspots.ts rename to server/sonar-web/src/main/js/api/security-hotspots.ts index f6a0ab8a64c..d9ed71e170a 100644 --- a/server/sonar-web/src/main/js/api/securityHotspots.ts +++ b/server/sonar-web/src/main/js/api/security-hotspots.ts @@ -19,7 +19,7 @@ */ import { getJSON } from 'sonar-ui-common/helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; -import { HotspotSearchResponse } from '../types/securityHotspots'; +import { DetailedHotspot, HotspotSearchResponse } from '../types/security-hotspots'; export function getSecurityHotspots(data: { projectKey: string; @@ -28,3 +28,7 @@ export function getSecurityHotspots(data: { }): Promise { return getJSON('/api/hotspots/search', data).catch(throwGlobalError); } + +export function getSecurityHotspotDetails(securityHotspotKey: string): Promise { + return getJSON('/api/hotspots/show', { hotspot: securityHotspotKey }).catch(throwGlobalError); +} diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index 5f3b733f7d1..9c8de00be8e 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -132,6 +132,10 @@ th.hide-overflow { margin-top: 4px !important; } +.big-padded { + padding: calc(2 * var(--gridSize)); +} + td.little-spacer-left { padding-left: 4px !important; } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx index 9f889e176d7..f960ac80185 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx @@ -19,10 +19,10 @@ */ import * as React from 'react'; import { addNoFooterPageClass, removeNoFooterPageClass } from 'sonar-ui-common/helpers/pages'; -import { getSecurityHotspots } from '../../api/securityHotspots'; +import { getSecurityHotspots } from '../../api/security-hotspots'; import { getStandards } from '../../helpers/security-standard'; import { BranchLike } from '../../types/branch-like'; -import { RawHotspot } from '../../types/securityHotspots'; +import { RawHotspot } from '../../types/security-hotspots'; import SecurityHotspotsAppRenderer from './SecurityHotspotsAppRenderer'; import './styles.css'; import { sortHotspots } from './utils'; @@ -37,7 +37,7 @@ interface Props { interface State { hotspots: RawHotspot[]; loading: boolean; - securityCategories: T.Dict<{ title: string; description?: string }>; + securityCategories: T.StandardSecurityCategories; selectedHotspotKey: string | undefined; } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsAppRenderer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsAppRenderer.tsx index 60dcbb1644b..078a1d5b51b 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsAppRenderer.tsx @@ -26,7 +26,7 @@ import { getBaseUrl } from 'sonar-ui-common/helpers/urls'; import A11ySkipTarget from '../../app/components/a11y/A11ySkipTarget'; import Suggestions from '../../app/components/embed-docs-modal/Suggestions'; import ScreenPositionHelper from '../../components/common/ScreenPositionHelper'; -import { RawHotspot } from '../../types/securityHotspots'; +import { RawHotspot } from '../../types/security-hotspots'; import FilterBar from './components/FilterBar'; import HotspotList from './components/HotspotList'; import HotspotViewer from './components/HotspotViewer'; @@ -37,7 +37,7 @@ export interface SecurityHotspotsAppRendererProps { loading: boolean; onHotspotClick: (key: string) => void; selectedHotspotKey?: string; - securityCategories: T.Dict<{ title: string; description?: string }>; + securityCategories: T.StandardSecurityCategories; } export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRendererProps) { @@ -84,7 +84,12 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe />
- + {selectedHotspotKey && ( + + )}
)} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsApp-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsApp-test.tsx index 3cec33f41d7..b813bc82417 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsApp-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsApp-test.tsx @@ -21,9 +21,9 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { addNoFooterPageClass } from 'sonar-ui-common/helpers/pages'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { getSecurityHotspots } from '../../../api/securityHotspots'; +import { getSecurityHotspots } from '../../../api/security-hotspots'; import { mockMainBranch } from '../../../helpers/mocks/branch-like'; -import { mockHotspot } from '../../../helpers/mocks/security-hotspots'; +import { mockRawHotspot } from '../../../helpers/mocks/security-hotspots'; import { getStandards } from '../../../helpers/security-standard'; import { mockComponent } from '../../../helpers/testMocks'; import SecurityHotspotsApp from '../SecurityHotspotsApp'; @@ -33,7 +33,7 @@ jest.mock('sonar-ui-common/helpers/pages', () => ({ removeNoFooterPageClass: jest.fn() })); -jest.mock('../../../api/securityHotspots', () => ({ +jest.mock('../../../api/security-hotspots', () => ({ getSecurityHotspots: jest.fn().mockResolvedValue({ hotspots: [], rules: [] }) })); @@ -49,7 +49,7 @@ it('should load data correctly', async () => { const sonarsourceSecurity = { cat1: { title: 'cat 1' } }; (getStandards as jest.Mock).mockResolvedValue({ sonarsourceSecurity }); - const hotspots = [mockHotspot()]; + const hotspots = [mockRawHotspot()]; (getSecurityHotspots as jest.Mock).mockResolvedValue({ hotspots }); diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx index f3a3f832a20..8d7db84876b 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx @@ -19,19 +19,33 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { mockHotspot } from '../../../helpers/mocks/security-hotspots'; +import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; +import { mockRawHotspot } from '../../../helpers/mocks/security-hotspots'; import SecurityHotspotsAppRenderer, { SecurityHotspotsAppRendererProps } from '../SecurityHotspotsAppRenderer'; it('should render correctly', () => { expect(shallowRender()).toMatchSnapshot(); + expect( + shallowRender() + .find(ScreenPositionHelper) + .dive() + ).toMatchSnapshot(); }); it('should render correctly with hotspots', () => { - const hotspots = [mockHotspot({ key: 'h1' }), mockHotspot({ key: 'h2' })]; - expect(shallowRender({ hotspots })).toMatchSnapshot(); - expect(shallowRender({ hotspots, selectedHotspotKey: 'h2' })).toMatchSnapshot(); + const hotspots = [mockRawHotspot({ key: 'h1' }), mockRawHotspot({ key: 'h2' })]; + expect( + shallowRender({ hotspots }) + .find(ScreenPositionHelper) + .dive() + ).toMatchSnapshot(); + expect( + shallowRender({ hotspots, selectedHotspotKey: 'h2' }) + .find(ScreenPositionHelper) + .dive() + ).toMatchSnapshot(); }); function shallowRender(props: Partial = {}) { diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap index 67d90c34884..f73832adf08 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap @@ -11,24 +11,232 @@ exports[`should render correctly 1`] = ` `; +exports[`should render correctly 2`] = ` +
+
+ + + + +
+ hotspots.page +

+ hotspots.no_hotspots.title +

+
+ hotspots.no_hotspots.description +
+ + hotspots.learn_more + +
+
+
+
+`; + exports[`should render correctly with hotspots 1`] = ` -
- - - - +
+
+ + + + +
+
+ +
+
+
+ +
`; exports[`should render correctly with hotspots 2`] = ` -
- - - - +
+
+ + + + +
+
+ +
+
+ +
+
+
+
`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts index 1221e827d3f..3d4fa359524 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts @@ -17,60 +17,60 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { mockHotspot } from '../../../helpers/mocks/security-hotspots'; -import { RiskExposure } from '../../../types/securityHotspots'; +import { mockRawHotspot } from '../../../helpers/mocks/security-hotspots'; +import { RiskExposure } from '../../../types/security-hotspots'; import { groupByCategory, mapRules, sortHotspots } from '../utils'; const hotspots = [ - mockHotspot({ + mockRawHotspot({ key: '3', vulnerabilityProbability: RiskExposure.HIGH, securityCategory: 'object-injection', message: 'tfdh' }), - mockHotspot({ + mockRawHotspot({ key: '5', vulnerabilityProbability: RiskExposure.MEDIUM, securityCategory: 'xpath-injection', message: 'asdf' }), - mockHotspot({ + mockRawHotspot({ key: '1', vulnerabilityProbability: RiskExposure.HIGH, securityCategory: 'dos', message: 'a' }), - mockHotspot({ + mockRawHotspot({ key: '7', vulnerabilityProbability: RiskExposure.LOW, securityCategory: 'ssrf', message: 'rrrr' }), - mockHotspot({ + mockRawHotspot({ key: '2', vulnerabilityProbability: RiskExposure.HIGH, securityCategory: 'dos', message: 'b' }), - mockHotspot({ + mockRawHotspot({ key: '8', vulnerabilityProbability: RiskExposure.LOW, securityCategory: 'ssrf', message: 'sssss' }), - mockHotspot({ + mockRawHotspot({ key: '4', vulnerabilityProbability: RiskExposure.MEDIUM, securityCategory: 'log-injection', message: 'asdf' }), - mockHotspot({ + mockRawHotspot({ key: '9', vulnerabilityProbability: RiskExposure.LOW, securityCategory: 'xxe', message: 'aaa' }), - mockHotspot({ + mockRawHotspot({ key: '6', vulnerabilityProbability: RiskExposure.LOW, securityCategory: 'xss', diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx index ee9109cee46..e4c32574ac2 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx @@ -21,7 +21,7 @@ import * as classNames from 'classnames'; import * as React from 'react'; import ChevronDownIcon from 'sonar-ui-common/components/icons/ChevronDownIcon'; import ChevronUpIcon from 'sonar-ui-common/components/icons/ChevronUpIcon'; -import { RawHotspot } from '../../../types/securityHotspots'; +import { RawHotspot } from '../../../types/security-hotspots'; import HotspotListItem from './HotspotListItem'; export interface HotspotCategoryProps { diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.tsx index a33c3b84c35..d9a054f379e 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.tsx @@ -22,7 +22,7 @@ import { groupBy } from 'lodash'; import * as React from 'react'; import SecurityHotspotIcon from 'sonar-ui-common/components/icons/SecurityHotspotIcon'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; -import { RawHotspot, RiskExposure } from '../../../types/securityHotspots'; +import { RawHotspot, RiskExposure } from '../../../types/security-hotspots'; import { groupByCategory, RISK_EXPOSURE_LEVELS } from '../utils'; import HotspotCategory from './HotspotCategory'; import './HotspotList.css'; @@ -30,7 +30,7 @@ import './HotspotList.css'; export interface HotspotListProps { hotspots: RawHotspot[]; onHotspotClick: (key: string) => void; - securityCategories: T.Dict<{ title: string; description?: string }>; + securityCategories: T.StandardSecurityCategories; selectedHotspotKey: string | undefined; } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotListItem.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotListItem.tsx index 549c8e35bba..1bdfa7830a3 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotListItem.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotListItem.tsx @@ -20,7 +20,7 @@ import * as classNames from 'classnames'; import * as React from 'react'; import { translate } from 'sonar-ui-common/helpers/l10n'; -import { RawHotspot } from '../../../types/securityHotspots'; +import { RawHotspot } from '../../../types/security-hotspots'; export interface HotspotListItemProps { hotspot: RawHotspot; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx index c4e8c911f98..35192c96b62 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx @@ -17,14 +17,57 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import * as React from 'react'; +import { getSecurityHotspotDetails } from '../../../api/security-hotspots'; +import { DetailedHotspot } from '../../../types/security-hotspots'; +import HotspotViewerRenderer from './HotspotViewerRenderer'; + +interface Props { + hotspotKey: string; + securityCategories: T.StandardSecurityCategories; +} + +interface State { + hotspot?: DetailedHotspot; + loading: boolean; +} + +export default class HotspotViewer extends React.PureComponent { + mounted = false; + + componentWillMount() { + this.mounted = true; + this.fetchHotspot(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.hotspotKey !== this.props.hotspotKey) { + this.fetchHotspot(); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchHotspot() { + this.setState({ loading: true }); + return getSecurityHotspotDetails(this.props.hotspotKey) + .then(hotspot => this.mounted && this.setState({ hotspot })) + .finally(() => this.mounted && this.setState({ loading: false })); + } -export interface Props {} + render() { + const { securityCategories } = this.props; + const { hotspot, loading } = this.state; -export default function HotspotViewer(props: Props) { - return ( -
- Show hotspot details -
- ); + return ( + + ); + } } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx new file mode 100644 index 00000000000..9764ff4a34d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx @@ -0,0 +1,69 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import { DetailedHotspot } from '../../../types/security-hotspots'; +import HotspotViewerTabs from './HotspotViewerTabs'; + +export interface HotspotViewerRendererProps { + hotspot?: DetailedHotspot; + loading: boolean; + securityCategories: T.StandardSecurityCategories; +} + +export default function HotspotViewerRenderer(props: HotspotViewerRendererProps) { + const { hotspot, loading, securityCategories } = props; + + return ( + + {hotspot && ( +
+
+

{hotspot.message}

+
+ {translate('hotspot.category')} + + {securityCategories[hotspot.rule.securityCategory].title} + +
+
+
+ {translate('hotspot.status')} + + {translate('issue.status', hotspot.status)} + + {hotspot.assignee && hotspot.assignee.name && ( + <> + {translate('hotspot.assigned_to')} + + {hotspot.assignee.active + ? hotspot.assignee.name + : translateWithParameters('user.x_deleted', hotspot.assignee.name)} + + + )} +
+ +
+ )} +
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx new file mode 100644 index 00000000000..e02c11b164d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx @@ -0,0 +1,76 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { sanitize } from 'dompurify'; +import * as React from 'react'; +import BoxedTabs from 'sonar-ui-common/components/controls/BoxedTabs'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { DetailedHotspot } from '../../../types/security-hotspots'; + +export interface HotspotViewerTabsProps { + hotspot: DetailedHotspot; +} + +export enum Tabs { + RiskDescription = 'risk', + VulnerabilityDescription = 'vulnerability', + FixRecommendation = 'fix' +} + +export default function HotspotViewerTabs(props: HotspotViewerTabsProps) { + const { hotspot } = props; + const [currentTab, setCurrentTab] = React.useState(Tabs.RiskDescription); + + const tabs = { + [Tabs.RiskDescription]: { + title: translate('hotspot.tabs.risk_description'), + content: hotspot.rule.riskDescription || '' + }, + [Tabs.VulnerabilityDescription]: { + title: translate('hotspot.tabs.vulnerability_description'), + content: hotspot.rule.vulnerabilityDescription || '' + }, + [Tabs.FixRecommendation]: { + title: translate('hotspot.tabs.fix_recommendations'), + content: hotspot.rule.fixRecommendations || '' + } + }; + + const tabsToDisplay = Object.values(Tabs) + .filter(tab => Boolean(tabs[tab].content)) + .map(tab => ({ key: tab, label: tabs[tab].title })); + + if (tabsToDisplay.length === 0) { + return null; + } + + if (!tabsToDisplay.find(tab => tab.key === currentTab)) { + setCurrentTab(tabsToDisplay[0].key); + } + + return ( + <> + setCurrentTab(tab)} selected={currentTab} tabs={tabsToDisplay} /> +
+ + ); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotCategory-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotCategory-test.tsx index f1d3312579c..757850291aa 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotCategory-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotCategory-test.tsx @@ -19,7 +19,7 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { mockHotspot } from '../../../../helpers/mocks/security-hotspots'; +import { mockRawHotspot } from '../../../../helpers/mocks/security-hotspots'; import HotspotCategory, { HotspotCategoryProps } from '../HotspotCategory'; it('should render correctly', () => { @@ -27,12 +27,12 @@ it('should render correctly', () => { }); it('should render correctly with hotspots', () => { - const hotspots = [mockHotspot({ key: 'h1' }), mockHotspot({ key: 'h2' })]; + const hotspots = [mockRawHotspot({ key: 'h1' }), mockRawHotspot({ key: 'h2' })]; expect(shallowRender({ hotspots })).toMatchSnapshot(); }); it('should handle collapse and expand', () => { - const wrapper = shallowRender({ hotspots: [mockHotspot()] }); + const wrapper = shallowRender({ hotspots: [mockRawHotspot()] }); wrapper.find('.hotspot-category-header').simulate('click'); diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotList-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotList-test.tsx index b48e336cd19..217a182cccd 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotList-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotList-test.tsx @@ -19,8 +19,8 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { mockHotspot } from '../../../../helpers/mocks/security-hotspots'; -import { RiskExposure } from '../../../../types/securityHotspots'; +import { mockRawHotspot } from '../../../../helpers/mocks/security-hotspots'; +import { RiskExposure } from '../../../../types/security-hotspots'; import HotspotList, { HotspotListProps } from '../HotspotList'; it('should render correctly', () => { @@ -29,19 +29,19 @@ it('should render correctly', () => { it('should render correctly with hotspots', () => { const hotspots = [ - mockHotspot({ key: 'h1', securityCategory: 'cat2' }), - mockHotspot({ key: 'h2', securityCategory: 'cat1' }), - mockHotspot({ + mockRawHotspot({ key: 'h1', securityCategory: 'cat2' }), + mockRawHotspot({ key: 'h2', securityCategory: 'cat1' }), + mockRawHotspot({ key: 'h3', securityCategory: 'cat1', vulnerabilityProbability: RiskExposure.MEDIUM }), - mockHotspot({ + mockRawHotspot({ key: 'h4', securityCategory: 'cat1', vulnerabilityProbability: RiskExposure.MEDIUM }), - mockHotspot({ + mockRawHotspot({ key: 'h5', securityCategory: 'cat2', vulnerabilityProbability: RiskExposure.MEDIUM diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotListItem-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotListItem-test.tsx index e9a7b7d0082..d69518bed0f 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotListItem-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotListItem-test.tsx @@ -19,7 +19,7 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { mockHotspot } from '../../../../helpers/mocks/security-hotspots'; +import { mockRawHotspot } from '../../../../helpers/mocks/security-hotspots'; import { HotspotListItem, HotspotListItemProps } from '../HotspotListItem'; it('should render correctly', () => { @@ -28,7 +28,7 @@ it('should render correctly', () => { }); it('should handle click', () => { - const hotspot = mockHotspot({ key: 'hotspotKey' }); + const hotspot = mockRawHotspot({ key: 'hotspotKey' }); const onClick = jest.fn(); const wrapper = shallowRender({ hotspot, onClick }); @@ -39,6 +39,6 @@ it('should handle click', () => { function shallowRender(props: Partial = {}) { return shallow( - + ); } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewer-test.tsx new file mode 100644 index 00000000000..380d91bd3cc --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewer-test.tsx @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { getSecurityHotspotDetails } from '../../../../api/security-hotspots'; +import HotspotViewer from '../HotspotViewer'; + +const hotspotKey = 'hotspot-key'; + +jest.mock('../../../../api/security-hotspots', () => ({ + getSecurityHotspotDetails: jest.fn().mockResolvedValue({ id: `I am a detailled hotspot` }) +})); + +it('should render correctly', async () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot(); + + await waitAndUpdate(wrapper); + + expect(wrapper).toMatchSnapshot(); + expect(getSecurityHotspotDetails).toHaveBeenCalledWith(hotspotKey); + + const newHotspotKey = `new-${hotspotKey}`; + wrapper.setProps({ hotspotKey: newHotspotKey }); + + await waitAndUpdate(wrapper); + expect(getSecurityHotspotDetails).toHaveBeenCalledWith(newHotspotKey); +}); + +function shallowRender(props?: Partial) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx new file mode 100644 index 00000000000..700d0cf0aea --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx @@ -0,0 +1,44 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots'; +import { mockUser } from '../../../../helpers/testMocks'; +import HotspotViewerRenderer, { HotspotViewerRendererProps } from '../HotspotViewerRenderer'; + +it('should render correctly', () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot(); + expect(shallowRender({ hotspot: undefined })).toMatchSnapshot('no hotspot'); + expect( + shallowRender({ hotspot: mockDetailledHotspot({ assignee: mockUser({ active: false }) }) }) + ).toMatchSnapshot('deleted assignee'); +}); + +function shallowRender(props?: Partial) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx new file mode 100644 index 00000000000..12fa76200b9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import BoxedTabs from 'sonar-ui-common/components/controls/BoxedTabs'; +import { + mockDetailledHotspot, + mockDetailledHotspotRule +} from '../../../../helpers/mocks/security-hotspots'; +import HotspotViewerTabs, { HotspotViewerTabsProps, Tabs } from '../HotspotViewerTabs'; + +it('should render correctly', () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot('risk'); + + const onSelect = wrapper.find(BoxedTabs).prop('onSelect') as (tab: Tabs) => void; + + if (!onSelect) { + fail('onSelect should be defined'); + } else { + onSelect(Tabs.VulnerabilityDescription); + expect(wrapper).toMatchSnapshot('vulnerability'); + + onSelect(Tabs.FixRecommendation); + expect(wrapper).toMatchSnapshot('fix'); + } + + expect( + shallowRender({ + hotspot: mockDetailledHotspot({ + rule: mockDetailledHotspotRule({ riskDescription: undefined }) + }) + }) + ).toMatchSnapshot('empty tab'); + + expect( + shallowRender({ + hotspot: mockDetailledHotspot({ + rule: mockDetailledHotspotRule({ + riskDescription: undefined, + fixRecommendations: undefined, + vulnerabilityDescription: undefined + }) + }) + }) + ).toMatchSnapshot('no tabs'); +}); + +function shallowRender(props?: Partial) { + return shallow(); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewer-test.tsx.snap new file mode 100644 index 00000000000..edce37a1791 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewer-test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + +`; + +exports[`should render correctly 2`] = ` + +`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap new file mode 100644 index 00000000000..dc2f5811009 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap @@ -0,0 +1,278 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + +
+
+

+ '3' is a magic number. +

+
+ + hotspot.category + + + SQL injection + +
+
+
+ + hotspot.status + + + issue.status.RESOLVED + + + hotspot.assigned_to + + + John Doe + +
+ This a strong message about fixing !

", + "key": "squid:S2077", + "name": "That rule", + "riskDescription": "

This a strong message about risk !

", + "securityCategory": "sql-injection", + "vulnerabilityDescription": "

This a strong message about vulnerability !

", + "vulnerabilityProbability": "HIGH", + }, + "status": "RESOLVED", + "textRange": Object { + "endLine": 142, + "endOffset": 83, + "startLine": 142, + "startOffset": 26, + }, + "updateDate": "2013-05-13T17:55:42+0200", + } + } + /> +
+
+`; + +exports[`should render correctly: deleted assignee 1`] = ` + +
+
+

+ '3' is a magic number. +

+
+ + hotspot.category + + + SQL injection + +
+
+
+ + hotspot.status + + + issue.status.RESOLVED + + + hotspot.assigned_to + + + user.x_deleted.John Doe + +
+ This a strong message about fixing !

", + "key": "squid:S2077", + "name": "That rule", + "riskDescription": "

This a strong message about risk !

", + "securityCategory": "sql-injection", + "vulnerabilityDescription": "

This a strong message about vulnerability !

", + "vulnerabilityProbability": "HIGH", + }, + "status": "RESOLVED", + "textRange": Object { + "endLine": 142, + "endOffset": 83, + "startLine": 142, + "startOffset": 26, + }, + "updateDate": "2013-05-13T17:55:42+0200", + } + } + /> +
+
+`; + +exports[`should render correctly: no hotspot 1`] = ` + +`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap new file mode 100644 index 00000000000..5e0f68e43d9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap @@ -0,0 +1,131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: empty tab 1`] = ` + + +
This a strong message about vulnerability !

", + } + } + /> + +`; + +exports[`should render correctly: fix 1`] = ` + + +
This a strong message about fixing !

", + } + } + /> + +`; + +exports[`should render correctly: no tabs 1`] = `""`; + +exports[`should render correctly: risk 1`] = ` + + +
This a strong message about risk !

", + } + } + /> + +`; + +exports[`should render correctly: vulnerability 1`] = ` + + +
This a strong message about vulnerability !

", + } + } + /> + +`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/styles.css b/server/sonar-web/src/main/js/apps/securityHotspots/styles.css index b76b508053d..187f9e9a340 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/styles.css +++ b/server/sonar-web/src/main/js/apps/securityHotspots/styles.css @@ -48,4 +48,5 @@ #security_hotspots .main { flex: 1 0 70%; overflow-y: auto; + background-color: white; } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts b/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts index 147588deaa0..c02298354b5 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts +++ b/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { groupBy, sortBy } from 'lodash'; -import { RawHotspot, RiskExposure } from '../../types/securityHotspots'; +import { RawHotspot, RiskExposure } from '../../types/security-hotspots'; export const RISK_EXPOSURE_LEVELS = [RiskExposure.HIGH, RiskExposure.MEDIUM, RiskExposure.LOW]; @@ -31,7 +31,7 @@ export function mapRules(rules: Array<{ key: string; name: string }>): T.Dict + securityCategories: T.StandardSecurityCategories ) { const groups = groupBy(hotspots, h => h.securityCategory); @@ -56,9 +56,6 @@ export function sortHotspots( ]); } -function getCategoryTitle( - key: string, - securityCategories: T.Dict<{ title: string; description?: string }> -) { +function getCategoryTitle(key: string, securityCategories: T.StandardSecurityCategories) { return securityCategories[key] ? securityCategories[key].title : key; } diff --git a/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts b/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts index f1c4be442c9..007a7c29df2 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts @@ -17,9 +17,16 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { RawHotspot, RiskExposure } from '../../types/securityHotspots'; +import { ComponentQualifier } from '../../types/component'; +import { + DetailedHotspot, + DetailedHotspotRule, + RawHotspot, + RiskExposure +} from '../../types/security-hotspots'; +import { mockComponent, mockUser } from '../testMocks'; -export function mockHotspot(overrides: Partial = {}): RawHotspot { +export function mockRawHotspot(overrides: Partial = {}): RawHotspot { return { key: '01fc972e-2a3c-433e-bcae-0bd7f88f5123', component: 'com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest', @@ -37,3 +44,42 @@ export function mockHotspot(overrides: Partial = {}): RawHotspot { ...overrides }; } + +export function mockDetailledHotspot(overrides?: Partial): DetailedHotspot { + return { + assignee: mockUser(), + author: mockUser(), + component: mockComponent({ qualifier: ComponentQualifier.File }), + creationDate: '2013-05-13T17:55:41+0200', + key: '01fc972e-2a3c-433e-bcae-0bd7f88f5123', + line: 142, + message: "'3' is a magic number.", + project: mockComponent({ qualifier: ComponentQualifier.Project }), + resolution: 'FALSE-POSITIVE', + rule: mockDetailledHotspotRule(), + status: 'RESOLVED', + textRange: { + startLine: 142, + endLine: 142, + startOffset: 26, + endOffset: 83 + }, + updateDate: '2013-05-13T17:55:42+0200', + ...overrides + }; +} + +export function mockDetailledHotspotRule( + overrides?: Partial +): DetailedHotspotRule { + return { + key: 'squid:S2077', + name: 'That rule', + fixRecommendations: '

This a strong message about fixing !

', + riskDescription: '

This a strong message about risk !

', + vulnerabilityDescription: '

This a strong message about vulnerability !

', + vulnerabilityProbability: RiskExposure.HIGH, + securityCategory: 'sql-injection', + ...overrides + }; +} diff --git a/server/sonar-web/src/main/js/types/securityHotspots.ts b/server/sonar-web/src/main/js/types/security-hotspots.ts similarity index 70% rename from server/sonar-web/src/main/js/types/securityHotspots.ts rename to server/sonar-web/src/main/js/types/security-hotspots.ts index dc95ad98d43..76f816e19f9 100644 --- a/server/sonar-web/src/main/js/types/securityHotspots.ts +++ b/server/sonar-web/src/main/js/types/security-hotspots.ts @@ -35,10 +35,36 @@ export interface RawHotspot { resolution: string; rule: string; securityCategory: string; + status: string; + subProject?: string; updateDate: string; vulnerabilityProbability: RiskExposure; +} + +export interface DetailedHotspot { + assignee?: Pick; + author?: Pick; + component: T.Component; + creationDate: string; + key: string; + line?: number; + message: string; + project: T.Component; + resolution: string; + rule: DetailedHotspotRule; status: string; - subProject?: string; + textRange: T.TextRange; + updateDate: string; +} + +export interface DetailedHotspotRule { + fixRecommendations?: string; + key: string; + name: string; + riskDescription?: string; + securityCategory: string; + vulnerabilityDescription?: string; + vulnerabilityProbability: RiskExposure; } export interface HotspotSearchResponse { diff --git a/server/sonar-web/src/main/js/types/types.d.ts b/server/sonar-web/src/main/js/types/types.d.ts index d18eeabd447..0dc02ac6b69 100644 --- a/server/sonar-web/src/main/js/types/types.d.ts +++ b/server/sonar-web/src/main/js/types/types.d.ts @@ -849,6 +849,8 @@ declare namespace T { uuid: string; } + export type StandardSecurityCategories = T.Dict<{ title: string; description?: string }>; + export type Standards = { [key in StandardType]: T.Dict<{ title: string; description?: string }>; }; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 34eacaf3ec6..c565392d3ab 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -652,6 +652,13 @@ hotspots.list_title.TO_REVIEW={0} Security Hotspots to review hotspots.list_title.REVIEWED={0} reviewed Security Hotspots hotspots.risk_exposure=Review priority: +hotspot.category=Category: +hotspot.status=Status: +hotspot.assigned_to=Assigned to: +hotspot.tabs.risk_description=What's the risk? +hotspot.tabs.vulnerability_description=Are you vulnerable? +hotspot.tabs.fix_recommendations=How can you fix it? + #------------------------------------------------------------------------------ # # ISSUES -- 2.39.5