]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22914 Render CVE information in Issues and Security Hotspots pages
author7PH <b.raymond@protonmail.com>
Wed, 4 Sep 2024 12:01:21 +0000 (14:01 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 12 Sep 2024 20:02:55 +0000 (20:02 +0000)
23 files changed:
server/sonar-web/src/main/js/api/cves.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/CveServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts
server/sonar-web/src/main/js/api/mocks/data/issues.ts
server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/test-utils.tsx
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetHeader.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx
server/sonar-web/src/main/js/components/rules/CveDetails.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx
server/sonar-web/src/main/js/components/rules/RuleDescription.tsx
server/sonar-web/src/main/js/helpers/testMocks.ts
server/sonar-web/src/main/js/types/cves.ts [new file with mode: 0644]
server/sonar-web/src/main/js/types/issues.ts
server/sonar-web/src/main/js/types/security-hotspots.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/src/main/js/api/cves.ts b/server/sonar-web/src/main/js/api/cves.ts
new file mode 100644 (file)
index 0000000..a8e01d8
--- /dev/null
@@ -0,0 +1,27 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 axios from 'axios';
+import { Cve } from '../types/cves';
+
+const CVE_BASE_URL = '/api/v2/analysis/cves';
+
+export function getCve(cveId: string): Promise<Cve> {
+  return axios.get(`${CVE_BASE_URL}/${cveId}`);
+}
diff --git a/server/sonar-web/src/main/js/api/mocks/CveServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/CveServiceMock.ts
new file mode 100644 (file)
index 0000000..87e574c
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { cloneDeep } from 'lodash';
+import { mockCve } from '../../helpers/testMocks';
+import { Cve } from '../../types/cves';
+import { getCve } from '../cves';
+
+jest.mock('../../api/cves');
+
+export const DEFAULT_CVE_LIST = [
+  mockCve({ id: 'CVE-2021-12345' }),
+  mockCve({ id: 'CVE-2021-12346' }),
+];
+
+export default class CveServiceMock {
+  private cveList: Cve[];
+
+  constructor() {
+    this.cveList = cloneDeep(DEFAULT_CVE_LIST);
+    jest.mocked(getCve).mockImplementation(this.handleGetCve);
+  }
+
+  setCveList(cveList: Cve[]) {
+    this.cveList = cveList;
+  }
+
+  handleGetCve = (cveId: string) => {
+    const cve = this.cveList.find((cve) => cve.id === cveId);
+    if (!cve) {
+      return Promise.reject(new Error('Cve not found'));
+    }
+    return this.reply(cve);
+  };
+
+  reset = () => {
+    this.cveList = cloneDeep(DEFAULT_CVE_LIST);
+  };
+
+  reply<T>(response: T): Promise<T> {
+    return Promise.resolve(cloneDeep(response));
+  }
+}
index 50d11baafddca097793cc56d08391ac3be82d070..03633cb3297616f080acb9989d5c98c559df7553 100644 (file)
@@ -256,6 +256,7 @@ export default class SecurityHotspotServiceMock {
     return [
       mockRawHotspot({ assignee: 'John Doe', key: 'test-1' }),
       mockRawHotspot({ assignee: 'John Doe', key: 'test-2' }),
+      mockRawHotspot({ assignee: 'John Doe', key: 'test-cve', cveId: 'CVE-2021-12345' }),
     ];
   };
 
@@ -331,6 +332,13 @@ export default class SecurityHotspotServiceMock {
         message: "'2' is a magic number.",
         codeVariants: ['variant 1', 'variant 2'],
       }),
+      mockHotspot({
+        rule: mockHotspotRule({ key: 'rule2' }),
+        key: 'test-cve',
+        status: HotspotStatus.TO_REVIEW,
+        message: 'CVE on jackson',
+        cveId: 'CVE-2021-12345',
+      }),
     ];
     this.canChangeStatus = true;
   };
index af7ef225dd3141a4edf819d2de74b29bc7d5a7f3..ca1710ce1cda398660dbc08e9afd4a7aee919262 100644 (file)
@@ -322,6 +322,7 @@ export function mockIssuesList(baseComponentKey = PARENT_COMPONENT_KEY): IssueDa
         issueStatus: IssueStatus.Open,
         ruleDescriptionContextKey: 'spring',
         author: 'bob.marley@test.com',
+        cveId: 'CVE-2021-12345',
       }),
       snippets: keyBy(
         [
index 09cb5e4f844c3e66251f20fc3cb636802aa714bb..d341530762b2535d0cea28f2309993d12ff1b9d6 100644 (file)
@@ -24,12 +24,13 @@ import React from 'react';
 import { byRole, byText } from '~sonar-aligned/helpers/testSelector';
 import { ISSUE_101 } from '../../../api/mocks/data/ids';
 import { TabKeys } from '../../../components/rules/RuleTabViewer';
-import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks';
+import { mockCurrentUser, mockCve, mockLoggedInUser } from '../../../helpers/testMocks';
 import { Feature } from '../../../types/features';
 import { RestUserDetailed } from '../../../types/users';
 import {
   branchHandler,
   componentsHandler,
+  cveHandler,
   issuesHandler,
   renderIssueApp,
   renderProjectIssuesApp,
@@ -63,6 +64,7 @@ jest.mock('../../../components/common/ScreenPositionHelper', () => ({
 
 beforeEach(() => {
   issuesHandler.reset();
+  cveHandler.reset();
   componentsHandler.reset();
   branchHandler.reset();
   usersHandler.reset();
@@ -211,6 +213,52 @@ describe('issue app', () => {
     expect(screen.getByRole('heading', { name: 'Defense-In-Depth', level: 3 })).toBeInTheDocument();
   });
 
+  it('should render CVE details', async () => {
+    const user = userEvent.setup();
+    renderProjectIssuesApp('project/issues?issues=issue2&open=issue2&id=myproject');
+
+    await user.click(
+      await screen.findByRole('tab', { name: 'coding_rules.description_section.title.root_cause' }),
+    );
+
+    await user.click(screen.getByRole('radio', { name: 'coding_rules.description_context.other' }));
+
+    expect(await screen.findByRole('heading', { name: 'CVE-2021-12345' })).toBeInTheDocument();
+
+    const rows = byRole('row').getAll(ui.cveTable.get());
+    expect(rows).toHaveLength(4);
+    expect(byText('CWE-79, CWE-89').get(rows[0])).toBeInTheDocument();
+    expect(byText('rule.cve_details.epss_score.value.20.56').get(rows[1])).toBeInTheDocument();
+    expect(byText('0.3').get(rows[2])).toBeInTheDocument();
+    expect(byText('Oct 04, 2021').get(rows[3])).toBeInTheDocument();
+  });
+
+  it('should not render CVE CVSS and CWEs when not set', async () => {
+    const user = userEvent.setup();
+    cveHandler.setCveList([
+      mockCve({
+        cvssScore: undefined,
+        cwes: [],
+      }),
+    ]);
+    renderProjectIssuesApp('project/issues?issues=issue2&open=issue2&id=myproject');
+
+    await user.click(
+      await screen.findByRole('tab', { name: 'coding_rules.description_section.title.root_cause' }),
+    );
+
+    await user.click(
+      await screen.findByRole('radio', { name: 'coding_rules.description_context.other' }),
+    );
+
+    expect(await screen.findByRole('heading', { name: 'CVE-2021-12345' })).toBeInTheDocument();
+
+    const rows = byRole('row').getAll(ui.cveTable.get());
+    expect(rows).toHaveLength(2);
+    expect(byText('rule.cve_details.epss_score.value.20.56').get(rows[0])).toBeInTheDocument();
+    expect(byText('Oct 04, 2021').get(rows[1])).toBeInTheDocument();
+  });
+
   it('should be able to change the issue status', async () => {
     const user = userEvent.setup();
     issuesHandler.setIsAdmin(true);
index 2ce9343bb7665129ad173e794e730e3c85dca71d..30af209bcb4dc00ee2ca0c6688da59fc1012131c 100644 (file)
@@ -41,6 +41,7 @@ import { getBranchLikeQuery, isPullRequest } from '~sonar-aligned/helpers/branch
 import { isPortfolioLike } from '~sonar-aligned/helpers/component';
 import { ComponentQualifier } from '~sonar-aligned/types/component';
 import { Location, RawQuery, Router } from '~sonar-aligned/types/router';
+import { getCve } from '../../../api/cves';
 import { listIssues, searchIssues } from '../../../api/issues';
 import { getRuleDetails } from '../../../api/rules';
 import withComponentContext from '../../../app/components/componentContext/withComponentContext';
@@ -66,6 +67,7 @@ import { serializeDate } from '../../../helpers/query';
 import { withBranchLikes } from '../../../queries/branch';
 import { BranchLike } from '../../../types/branch-like';
 import { isProject } from '../../../types/component';
+import { Cve } from '../../../types/cves';
 import {
   ASSIGNEE_ME,
   Facet,
@@ -122,6 +124,7 @@ export interface State {
   bulkChangeModal: boolean;
   checkAll?: boolean;
   checked: string[];
+  cve?: Cve;
   effortTotal?: number;
   facets: Dict<Facet>;
   issues: Issue[];
@@ -383,8 +386,13 @@ export class App extends React.PureComponent<Props, State> {
       .then((response) => response.rule)
       .catch(() => undefined);
 
+    let cve: Cve | undefined;
+    if (typeof openIssue.cveId === 'string') {
+      cve = await getCve(openIssue.cveId);
+    }
+
     if (this.mounted) {
-      this.setState({ loadingRule: false, openRuleDetails });
+      this.setState({ loadingRule: false, openRuleDetails, cve });
     }
   }
 
@@ -1211,7 +1219,7 @@ export class App extends React.PureComponent<Props, State> {
   }
 
   renderPage() {
-    const { openRuleDetails, checkAll, issues, loading, openIssue, paging, loadingRule } =
+    const { openRuleDetails, cve, checkAll, issues, loading, openIssue, paging, loadingRule } =
       this.state;
 
     return (
@@ -1259,6 +1267,7 @@ export class App extends React.PureComponent<Props, State> {
                   onIssueChange={this.handleIssueChange}
                   ruleDescriptionContextKey={openIssue.ruleDescriptionContextKey}
                   ruleDetails={openRuleDetails}
+                  cve={cve}
                   selectedFlowIndex={this.state.selectedFlowIndex}
                   selectedLocationIndex={this.state.selectedLocationIndex}
                 />
index 995d63c8259a13347807713733c8383ae7a5d164..3918108f48b342d2844c147d34475208781f0009 100644 (file)
@@ -23,6 +23,7 @@ import { Outlet, Route } from 'react-router-dom';
 import { byPlaceholderText, byRole, byTestId, byText } from '~sonar-aligned/helpers/testSelector';
 import BranchesServiceMock from '../../api/mocks/BranchesServiceMock';
 import ComponentsServiceMock from '../../api/mocks/ComponentsServiceMock';
+import CveServiceMock from '../../api/mocks/CveServiceMock';
 import FixIssueServiceMock from '../../api/mocks/FixIssueServiceMock';
 import IssuesServiceMock from '../../api/mocks/IssuesServiceMock';
 import SourcesServiceMock from '../../api/mocks/SourcesServiceMock';
@@ -43,6 +44,7 @@ import { projectIssuesRoutes } from './routes';
 
 export const usersHandler = new UsersServiceMock();
 export const issuesHandler = new IssuesServiceMock(usersHandler);
+export const cveHandler = new CveServiceMock();
 export const componentsHandler = new ComponentsServiceMock();
 export const sourcesHandler = new SourcesServiceMock();
 export const branchHandler = new BranchesServiceMock();
@@ -142,6 +144,8 @@ export const ui = {
   vulnerabilityIssueTypeFilter: byRole('checkbox', { name: 'issue.type.VULNERABILITY' }),
   prioritizedRuleFilter: byRole('checkbox', { name: 'issues.facet.prioritized_rule' }),
 
+  cveTable: byRole('table', { name: 'rule.cve_details' }),
+
   bulkChangeComment: byRole('textbox', { name: /issue_bulk_change.resolution_comment/ }),
 
   clearAllFilters: byRole('button', { name: 'clear_all_filters' }),
index 0380c7563285faed22a2ca08116299cd3fa8c64d..9d64bf4a6340e0b347b42197af5d9541d4926bbe 100644 (file)
@@ -244,6 +244,7 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
                 <HotspotViewer
                   component={component}
                   hotspotKey={selectedHotspot.key}
+                  cveId={selectedHotspot.cveId}
                   hotspotsReviewedMeasure={hotspotsReviewedMeasure}
                   onLocationClick={props.onLocationClick}
                   onSwitchStatusFilter={props.onSwitchStatusFilter}
index 4fef8864a5fb0f5e38d6a458b2acb0967b44e737..8e4e74463e8906dfa4492d0ce7e834dd3568385d 100644 (file)
@@ -25,13 +25,14 @@ import { byDisplayValue, byRole, byTestId, byText } from '~sonar-aligned/helpers
 import { MetricKey } from '~sonar-aligned/types/metrics';
 import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock';
 import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock';
+import CveServiceMock from '../../../api/mocks/CveServiceMock';
 import SecurityHotspotServiceMock from '../../../api/mocks/SecurityHotspotServiceMock';
 import { getSecurityHotspots, setSecurityHotspotStatus } from '../../../api/security-hotspots';
 import { getUsers } from '../../../api/users';
 import { mockComponent } from '../../../helpers/mocks/component';
 import { openHotspot, probeSonarLintServers } from '../../../helpers/sonarlint';
 import { get, save } from '../../../helpers/storage';
-import { mockLoggedInUser } from '../../../helpers/testMocks';
+import { mockCve, mockLoggedInUser } from '../../../helpers/testMocks';
 import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils';
 import { ComponentContextShape } from '../../../types/component';
 import SecurityHotspotsApp from '../SecurityHotspotsApp';
@@ -85,6 +86,7 @@ const ui = {
   filterToReview: byRole('radio', { name: 'hotspot.filters.status.to_review' }),
   fixContent: byText('This is how to fix'),
   fixTab: byRole('tab', { name: /hotspots.tabs.fix_recommendations/ }),
+  cveTable: byRole('table', { name: 'rule.cve_details' }),
   hotpostListTitle: byText('hotspots.list_title'),
   hotspotCommentBox: byRole('textbox', { name: 'hotspots.comment.field' }),
   hotspotStatus: byRole('heading', { name: 'status: hotspots.status_option.FIXED' }),
@@ -107,6 +109,7 @@ const ui = {
 
 const originalScrollTo = window.scrollTo;
 const hotspotsHandler = new SecurityHotspotServiceMock();
+const cveHandler = new CveServiceMock();
 const rulesHandles = new CodingRulesServiceMock();
 const branchHandler = new BranchesServiceMock();
 
@@ -147,6 +150,7 @@ beforeEach(() => {
 
 afterEach(() => {
   hotspotsHandler.reset();
+  cveHandler.reset();
   rulesHandles.reset();
   branchHandler.reset();
 });
@@ -187,6 +191,46 @@ describe('rendering', () => {
 
     expect(await ui.reviewButton.findAll()).toHaveLength(2);
   });
+
+  it('should render CVE details', async () => {
+    const user = userEvent.setup();
+
+    renderSecurityHotspotsApp(
+      'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=test-cve',
+    );
+
+    await user.click(await ui.riskTab.find());
+    expect(await screen.findByRole('heading', { name: 'CVE-2021-12345' })).toBeInTheDocument();
+
+    const rows = byRole('row').getAll(ui.cveTable.get());
+    expect(rows).toHaveLength(4);
+    expect(byText('CWE-79, CWE-89').get(rows[0])).toBeInTheDocument();
+    expect(byText('rule.cve_details.epss_score.value.20.56').get(rows[1])).toBeInTheDocument();
+    expect(byText('0.3').get(rows[2])).toBeInTheDocument();
+    expect(byText('Oct 04, 2021').get(rows[3])).toBeInTheDocument();
+  });
+
+  it('should not render CVE CVSS and CWEs when not set', async () => {
+    const user = userEvent.setup();
+    cveHandler.setCveList([
+      mockCve({
+        cvssScore: undefined,
+        cwes: [],
+      }),
+    ]);
+
+    renderSecurityHotspotsApp(
+      'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=test-cve',
+    );
+
+    await user.click(await ui.riskTab.find());
+    expect(await screen.findByRole('heading', { name: 'CVE-2021-12345' })).toBeInTheDocument();
+
+    const rows = byRole('row').getAll(ui.cveTable.get());
+    expect(rows).toHaveLength(2);
+    expect(byText('rule.cve_details.epss_score.value.20.56').get(rows[0])).toBeInTheDocument();
+    expect(byText('Oct 04, 2021').get(rows[1])).toBeInTheDocument();
+  });
 });
 
 describe('CRUD', () => {
index 8f301649c649f0850691233bc8352f37b7ab4f95..247f2f0bd607a9ec3288c48cfdbee53b346ee609 100644 (file)
@@ -197,7 +197,7 @@ export default class HotspotSnippetContainer extends React.Component<Props, Stat
   };
 
   render() {
-    const { hotspot, selectedHotspotLocation } = this.props;
+    const { branchLike, component, hotspot, selectedHotspotLocation } = this.props;
     const { highlightedSymbols, lastLine, loading, sourceLines, secondaryLocations } = this.state;
 
     const locations = locationsByLine([hotspot]);
@@ -206,6 +206,8 @@ export default class HotspotSnippetContainer extends React.Component<Props, Stat
 
     return (
       <HotspotSnippetContainerRenderer
+        component={component}
+        branchLike={branchLike}
         highlightedSymbols={highlightedSymbols}
         hotspot={hotspot}
         loading={loading}
index 43179dd7c4410f5ac47acec8e2685e3b867d89b1..bca90e9df1bbff4785416b4960e9b263658744f3 100644 (file)
  */
 import { withTheme } from '@emotion/react';
 import styled from '@emotion/styled';
-import { Spinner, themeColor } from 'design-system';
+import { Spinner } from '@sonarsource/echoes-react';
+import { FlagMessage, themeColor } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
+import { BranchLike } from '../../../types/branch-like';
 import { Hotspot } from '../../../types/security-hotspots';
 import {
+  Component,
   ExpandDirection,
   FlowLocation,
   LinearIssueLocation,
@@ -32,8 +35,11 @@ import {
 } from '../../../types/types';
 import SnippetViewer from '../../issues/crossComponentSourceViewer/SnippetViewer';
 import HotspotPrimaryLocationBox from './HotspotPrimaryLocationBox';
+import HotspotSnippetHeader from './HotspotSnippetHeader';
 
 export interface HotspotSnippetContainerRendererProps {
+  branchLike?: BranchLike;
+  component: Component;
   highlightedSymbols: string[];
   hotspot: Hotspot;
   loading: boolean;
@@ -113,6 +119,8 @@ export default function HotspotSnippetContainerRenderer(
     selectedHotspotLocation,
     sourceLines,
     sourceViewerFile,
+    component,
+    branchLike,
   } = props;
 
   const scrollableRef = React.useRef<HTMLDivElement>(null);
@@ -139,37 +147,38 @@ export default function HotspotSnippetContainerRenderer(
       : undefined;
 
   return (
-    <>
-      {!loading && sourceLines.length === 0 && (
-        <p className="sw-my-4">{translate('hotspots.no_associated_lines')}</p>
+    <Spinner isLoading={loading}>
+      {sourceLines.length === 0 && (
+        <FlagMessage variant="info">{translate('hotspots.no_associated_lines')}</FlagMessage>
       )}
 
-      <SourceFileWrapper className="sw-box-border sw-w-full sw-rounded-1" ref={scrollableRef}>
-        <Spinner className="sw-m-4" loading={loading} />
-
-        {!loading && sourceLines.length > 0 && (
-          <SnippetViewer
-            component={sourceViewerFile}
-            displayLineNumberOptions={false}
-            displaySCM={false}
-            expandBlock={(_i, direction) =>
-              animateExpansion(scrollableRef, props.onExpandBlock, direction)
-            }
-            handleSymbolClick={props.onSymbolClick}
-            highlightedLocationMessage={highlightedLocation}
-            highlightedSymbols={highlightedSymbols}
-            index={0}
-            locations={secondaryLocations}
-            locationsByLine={primaryLocations}
-            onLocationSelect={props.onLocationSelect}
-            renderAdditionalChildInLine={renderHotspotBoxInLine}
-            renderDuplicationPopup={noop}
-            snippet={sourceLines}
-            hideLocationIndex={secondaryLocations.length !== 0}
-          />
-        )}
-      </SourceFileWrapper>
-    </>
+      {sourceLines.length > 0 && (
+        <>
+          <HotspotSnippetHeader hotspot={hotspot} component={component} branchLike={branchLike} />
+          <SourceFileWrapper className="sw-box-border sw-w-full sw-rounded-1" ref={scrollableRef}>
+            <SnippetViewer
+              component={sourceViewerFile}
+              displayLineNumberOptions={false}
+              displaySCM={false}
+              expandBlock={(_i, direction) =>
+                animateExpansion(scrollableRef, props.onExpandBlock, direction)
+              }
+              handleSymbolClick={props.onSymbolClick}
+              highlightedLocationMessage={highlightedLocation}
+              highlightedSymbols={highlightedSymbols}
+              index={0}
+              locations={secondaryLocations}
+              locationsByLine={primaryLocations}
+              onLocationSelect={props.onLocationSelect}
+              renderAdditionalChildInLine={renderHotspotBoxInLine}
+              renderDuplicationPopup={noop}
+              snippet={sourceLines}
+              hideLocationIndex={secondaryLocations.length !== 0}
+            />
+          </SourceFileWrapper>
+        </>
+      )}
+    </Spinner>
   );
 }
 
index 29add9012d1444b13ae1b01595ed39fbbc9dec8f..84db10765de5ddb6fbf5fb2890207d4521f7f542 100644 (file)
@@ -52,7 +52,7 @@ function HotspotSnippetHeader(props: HotspotSnippetHeaderProps) {
 
   return (
     <StyledHeader
-      className={`sw-box-border sw-flex sw-gap-2 sw-justify-between -sw-mb-4 sw-mt-6 sw-px-4
+      className={`sw-box-border sw-flex sw-gap-2 sw-justify-between sw-mt-6 sw-px-4
                   sw-py-3`}
     >
       <Note className="sw-flex sw-flex-1 sw-flex-wrap sw-gap-2 sw-items-center sw-my-1/2">
index 2940d29cd44c335dc82ba5f8f388b769f2a812e5..30efacc1fdd60a73652b23ecb06d1272543775de 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { getCve } from '../../../api/cves';
 import { getRuleDetails } from '../../../api/rules';
 import { getSecurityHotspotDetails } from '../../../api/security-hotspots';
 import { get } from '../../../helpers/storage';
+import { Cve } from '../../../types/cves';
 import { Standards } from '../../../types/security';
 import {
   Hotspot,
@@ -35,6 +37,7 @@ import HotspotViewerRenderer from './HotspotViewerRenderer';
 
 interface Props {
   component: Component;
+  cveId?: string;
   hotspotKey: string;
   hotspotsReviewedMeasure?: string;
   onLocationClick: (index: number) => void;
@@ -45,6 +48,7 @@ interface Props {
 }
 
 interface State {
+  cve?: Cve;
   hotspot?: Hotspot;
   lastStatusChangedTo?: HotspotStatusOption;
   loading: boolean;
@@ -83,6 +87,10 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
     try {
       const hotspot = await getSecurityHotspotDetails(this.props.hotspotKey);
       const ruleDetails = await getRuleDetails({ key: hotspot.rule.key }).then((r) => r.rule);
+      let cve;
+      if (typeof this.props.cveId === 'string') {
+        cve = await getCve(this.props.cveId);
+      }
 
       if (this.mounted) {
         this.setState({
@@ -90,6 +98,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
           loading: false,
           ruleLanguage: ruleDetails.lang,
           ruleDescriptionSections: ruleDetails.descriptionSections,
+          cve,
         });
       }
     } catch (error) {
@@ -132,6 +141,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
       hotspot,
       ruleDescriptionSections,
       ruleLanguage,
+      cve,
       loading,
       showStatusUpdateSuccessModal,
       lastStatusChangedTo,
@@ -150,6 +160,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
         onUpdateHotspot={this.handleHotspotUpdate}
         ruleDescriptionSections={ruleDescriptionSections}
         ruleLanguage={ruleLanguage}
+        cve={cve}
         selectedHotspotLocation={selectedHotspotLocation}
         showStatusUpdateSuccessModal={showStatusUpdateSuccessModal}
         standards={standards}
index 1ed1a6b62ec9fcff8a92bb0e2ce4e2fd5b2be048..acc3a9e4fdd0b4265af4c5e989ae4d46f7680347 100644 (file)
@@ -26,6 +26,7 @@ import { Component } from '../../../types/types';
 import { HotspotHeader } from './HotspotHeader';
 
 import { Spinner } from 'design-system';
+import { Cve } from '../../../types/cves';
 import { CurrentUser } from '../../../types/users';
 import { RuleDescriptionSection } from '../../coding-rules/rule';
 import HotspotReviewHistoryAndComments from './HotspotReviewHistoryAndComments';
@@ -37,6 +38,7 @@ import StatusUpdateSuccessModal from './StatusUpdateSuccessModal';
 export interface HotspotViewerRendererProps {
   component: Component;
   currentUser: CurrentUser;
+  cve?: Cve;
   hotspot?: Hotspot;
   hotspotsReviewedMeasure?: string;
   lastStatusChangedTo?: HotspotStatusOption;
@@ -62,6 +64,7 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
     loading,
     ruleDescriptionSections,
     ruleLanguage,
+    cve,
     selectedHotspotLocation,
     showStatusUpdateSuccessModal,
     standards,
@@ -99,8 +102,6 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
                 onCommentUpdate={props.onUpdateHotspot}
               />
             }
-            branchLike={branchLike}
-            component={component}
             codeTabContent={
               <HotspotSnippetContainer
                 branchLike={branchLike}
@@ -114,6 +115,7 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
             onUpdateHotspot={props.onUpdateHotspot}
             ruleDescriptionSections={ruleDescriptionSections}
             ruleLanguage={ruleLanguage}
+            cve={cve}
           />
         </div>
       )}
index 5044e1aba9750cafc409a91e336134d3304597d5..a6e43d589fa98599a1188eaa54fc648287144fc6 100644 (file)
@@ -34,19 +34,16 @@ import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardKeys } from '../../../helpers/keycodes';
 import { translate } from '../../../helpers/l10n';
 import { useRefreshBranchStatus } from '../../../queries/branch';
-import { BranchLike } from '../../../types/branch-like';
+import { Cve } from '../../../types/cves';
 import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
-import { Component } from '../../../types/types';
 import { RuleDescriptionSection, RuleDescriptionSections } from '../../coding-rules/rule';
 import useStickyDetection from '../hooks/useStickyDetection';
-import HotspotSnippetHeader from './HotspotSnippetHeader';
 import StatusReviewButton from './status/StatusReviewButton';
 
 interface Props {
   activityTabContent: React.ReactNode;
-  branchLike?: BranchLike;
   codeTabContent: React.ReactNode;
-  component: Component;
+  cve: Cve | undefined;
   hotspot: Hotspot;
   onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
   ruleDescriptionSections?: RuleDescriptionSection[];
@@ -76,8 +73,7 @@ export default function HotspotViewerTabs(props: Props) {
     hotspot,
     ruleDescriptionSections,
     ruleLanguage,
-    component,
-    branchLike,
+    cve,
   } = props;
 
   const refreshBranchStatus = useRefreshBranchStatus(component.key);
@@ -206,9 +202,6 @@ export default function HotspotViewerTabs(props: Props) {
           />
           {isSticky && <StatusReviewButton hotspot={hotspot} onStatusChange={handleStatusChange} />}
         </div>
-        {currentTab.value === TabKeys.Code && codeTabContent && (
-          <HotspotSnippetHeader hotspot={hotspot} component={component} branchLike={branchLike} />
-        )}
       </StickyTabs>
       <div
         aria-labelledby={getTabId(currentTab.value)}
@@ -219,7 +212,11 @@ export default function HotspotViewerTabs(props: Props) {
         {currentTab.value === TabKeys.Code && codeTabContent}
 
         {currentTab.value === TabKeys.RiskDescription && rootCauseDescriptionSections && (
-          <RuleDescription language={ruleLanguage} sections={rootCauseDescriptionSections} />
+          <RuleDescription
+            language={ruleLanguage}
+            sections={rootCauseDescriptionSections}
+            cve={cve}
+          />
         )}
 
         {currentTab.value === TabKeys.VulnerabilityDescription &&
diff --git a/server/sonar-web/src/main/js/components/rules/CveDetails.tsx b/server/sonar-web/src/main/js/components/rules/CveDetails.tsx
new file mode 100644 (file)
index 0000000..958b3e1
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { ContentCell, Table, TableRow } from 'design-system';
+import React from 'react';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import { Cve } from '../../types/cves';
+import DateFormatter from '../intl/DateFormatter';
+
+type Props = {
+  cve: Cve;
+};
+
+export function CveDetails({ cve }: Readonly<Props>) {
+  const { id, description, cwes, cvssScore, epssScore, epssPercentile, publishedAt } = cve;
+  return (
+    <>
+      <h2>{id}</h2>
+      <p>{description}</p>
+      <Table columnCount={2} aria-label={translate('rule.cve_details')}>
+        {cwes.length > 0 && (
+          <TableRow>
+            <ContentCell>{translate('rule.cve_details.cwes')}</ContentCell>
+            <ContentCell>{cwes.join(', ')}</ContentCell>
+          </TableRow>
+        )}
+        <TableRow>
+          <ContentCell>{translate('rule.cve_details.epss_score')}</ContentCell>
+          <ContentCell>
+            {translateWithParameters(
+              'rule.cve_details.epss_score.value',
+              Math.round(epssScore * 100),
+              Math.round(epssPercentile * 100),
+            )}
+          </ContentCell>
+        </TableRow>
+        {typeof cvssScore === 'number' && (
+          <TableRow>
+            <ContentCell>{translate('rule.cve_details.cvss_score')}</ContentCell>
+            <ContentCell>{cvssScore.toFixed(1)}</ContentCell>
+          </TableRow>
+        )}
+        <TableRow>
+          <ContentCell>{translate('rule.cve_details.published_date')}</ContentCell>
+          <ContentCell>
+            <DateFormatter date={publishedAt} />
+          </ContentCell>
+        </TableRow>
+      </Table>
+    </>
+  );
+}
index fa4fff30c061a7a7e59cfb3b345e0f7d05a7c438..a77a8c25839d4aa825b3b39f46eba19dd5fd721b 100644 (file)
@@ -32,6 +32,7 @@ import IssueHeader from '../../apps/issues/components/IssueHeader';
 import StyledHeader from '../../apps/issues/components/StyledHeader';
 import { fillBranchLike } from '../../helpers/branch-like';
 import { translate } from '../../helpers/l10n';
+import { Cve } from '../../types/cves';
 import { Feature } from '../../types/features';
 import { Issue, RuleDetails } from '../../types/types';
 import { CurrentUser, NoticeType } from '../../types/users';
@@ -45,6 +46,7 @@ interface IssueTabViewerProps extends CurrentUserContextInterface {
   activityTabContent?: React.ReactNode;
   codeTabContent?: React.ReactNode;
   currentUser: CurrentUser;
+  cve?: Cve;
   extendedDescription?: string;
   hasFeature: (feature: string) => boolean;
   issue: Issue;
@@ -197,6 +199,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
       ruleDescriptionContextKey,
       extendedDescription,
       activityTabContent,
+      cve,
       issue,
       suggestionTabContent,
       hasFeature,
@@ -240,6 +243,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
               descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ??
               descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]
             ).concat(descriptionSectionsByKey[RuleDescriptionSections.INTRODUCTION] ?? [])}
+            cve={cve}
           />
         ),
       },
index 2f0f9f34f49759012e351693e76158d37f59e6c3..6ed51b5592b580d47735141ccfce71291b2b2ca8 100644 (file)
@@ -32,12 +32,15 @@ import applyCodeDifferences from '../../helpers/code-difference';
 import { translate, translateWithParameters } from '../../helpers/l10n';
 import { sanitizeString } from '../../helpers/sanitize';
 import { isDefined } from '../../helpers/types';
+import { Cve as CveDetailsType } from '../../types/cves';
+import { CveDetails } from './CveDetails';
 import OtherContextOption from './OtherContextOption';
 
 const OTHERS_KEY = 'others';
 
 interface Props {
   className?: string;
+  cve?: CveDetailsType;
   defaultContextKey?: string;
   language?: string;
   sections: RuleDescriptionSection[];
@@ -118,7 +121,7 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const { className, language, sections } = this.props;
+    const { className, language, sections, cve } = this.props;
     const { contexts, defaultContext, selectedContext } = this.state;
 
     const introductionSection = sections?.find(
@@ -148,7 +151,6 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
               language={language}
             />
           )}
-
           {defaultContext && (
             <FlagMessage variant="info" className="sw-mb-4">
               {translateWithParameters(
@@ -157,7 +159,6 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
               )}
             </FlagMessage>
           )}
-
           <div className="sw-mb-4">
             <ToggleButton
               label={translate('coding_rules.description_context.title')}
@@ -175,7 +176,6 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
               </h2>
             )}
           </div>
-
           {selectedContext.key === OTHERS_KEY ? (
             <OtherContextOption />
           ) : (
@@ -184,6 +184,8 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
               language={language}
             />
           )}
+
+          {cve && <CveDetails cve={cve} />}
         </StyledHtmlFormatter>
       );
     }
@@ -207,6 +209,8 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
           htmlAsString={sanitizeString(sections[0].content)}
           language={language}
         />
+
+        {cve && <CveDetails cve={cve} />}
       </StyledHtmlFormatter>
     );
   }
index 255e7ebf9a2649cf74c9ea607d330f22d90e7903..7050f6de7e68cab149179492a16613493f11a99a 100644 (file)
@@ -35,6 +35,7 @@ import {
   SoftwareQuality,
 } from '../types/clean-code-taxonomy';
 import { RuleRepository } from '../types/coding-rules';
+import { Cve } from '../types/cves';
 import { EditionKey } from '../types/editions';
 import {
   IssueDeprecatedStatus,
@@ -409,6 +410,20 @@ export function mockIssue(withLocations = false, overrides: Partial<Issue> = {})
   };
 }
 
+export function mockCve(overrides: Partial<Cve> = {}): Cve {
+  return {
+    id: 'CVE-2021-12345',
+    epssPercentile: 0.56051,
+    cvssScore: 0.31051,
+    description: 'description',
+    cwes: ['CWE-79', 'CWE-89'],
+    epssScore: 0.2,
+    lastModifiedAt: '2021-10-04T14:00:00Z',
+    publishedAt: '2021-10-04T14:00:00Z',
+    ...overrides,
+  };
+}
+
 export function mockLocation(overrides: Partial<Location> = {}): Location {
   return {
     hash: '',
diff --git a/server/sonar-web/src/main/js/types/cves.ts b/server/sonar-web/src/main/js/types/cves.ts
new file mode 100644 (file)
index 0000000..560eea8
--- /dev/null
@@ -0,0 +1,29 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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.
+ */
+export type Cve = {
+  cvssScore?: number;
+  cwes: string[];
+  description: string;
+  epssPercentile: number;
+  epssScore: number;
+  id: string;
+  lastModifiedAt: string;
+  publishedAt: string;
+};
index ff6401e089ba6ec6eddc830389bf0e6751cf0866..0d91c16488b11aa74bf0e6484f9b30404ccebfa2 100644 (file)
@@ -128,6 +128,7 @@ export interface RawIssue {
   comments?: Comment[];
   component: string;
   creationDate: string;
+  cveId?: string;
   flows?: Array<{
     description?: string;
     locations?: RawFlowLocation[];
index 4c5c556ac75ee9bf9bec2d92e99918b716474fd4..249e73b9d2c586c80dcbe598ed8b6e3f32c9be86 100644 (file)
@@ -59,6 +59,7 @@ export interface RawHotspot {
   author?: string;
   component: string;
   creationDate: string;
+  cveId?: string;
   flows?: Array<{
     locations?: Array<Omit<FlowLocation, 'componentName'>>;
   }>;
@@ -86,6 +87,7 @@ export interface Hotspot {
   comment: HotspotComment[];
   component: HotspotComponent;
   creationDate: string;
+  cveId?: string;
   flows: { locations: FlowLocation[] }[];
   key: string;
   line?: number;
index d9d3219c06f420475d1944b473c43b3b6968ab4a..102fbef632ae1bafeb2d4e4a22206a1e74e9dd3a 100644 (file)
@@ -885,7 +885,7 @@ hotspots.status_option.FIXED.description=The code has been reviewed and modified
 hotspots.status_option.SAFE=Safe
 hotspots.status_option.SAFE.description=The code has been reviewed and does not pose a risk. It does not need to be modified.
 hotspots.get_permalink=Get Permalink
-hotspots.no_associated_lines=Security Hotspot raised on the following file:
+hotspots.no_associated_lines=This Security Hotspot is not associated with any specific lines of code.
 hotspots.congratulations=Congratulations!
 hotspots.find_in_status_filter_x= You can find it again by setting the status filter to {status_label}.
 hotspots.successful_status_change_to_x=The Security Hotspot was successfully changed to {0}.
@@ -2701,7 +2701,12 @@ rule.clean_code_attribute.TESTED.title=This is an adaptability rule, the code sh
 rule.clean_code_attribute.TRUSTWORTHY=Trustworthy
 rule.clean_code_attribute.TRUSTWORTHY.title=This is a responsibility rule, the code should be trustworthy.
 
-
+rule.cve_details=CVE details
+rule.cve_details.cwes=CWEs
+rule.cve_details.epss_score=EPSS Score
+rule.cve_details.epss_score.value={0}% ({1}th)
+rule.cve_details.cvss_score=CVSS Score
+rule.cve_details.published_date=Published Date
 
 #------------------------------------------------------------------------------
 #