1
0
mirror of https://github.com/SAP/jenkins-library.git synced 2025-09-16 09:26:22 +02:00

feat(sonar): add hotspot security report (#5365)

Co-authored-by: Yuriy.Tereshchuk <astro.lutsk.aa@gmail.com>
This commit is contained in:
yuriy-tereshchuk-sap
2025-08-01 11:37:45 +02:00
committed by GitHub
parent 15492ee833
commit fcd167b2e6
7 changed files with 405 additions and 15 deletions

View File

@@ -250,9 +250,25 @@ func runSonar(config sonarExecuteScanOptions, client piperhttp.Downloader, runne
if err != nil {
return err
}
err = getStaticCodeCheckResults(config, &taskReport, serverUrl, influx, apiClient)
if err != nil {
return err
}
err = getHotSpotSecurityCheckResults(config, &taskReport, serverUrl, apiClient)
if err != nil {
return err
}
return nil
}
func getStaticCodeCheckResults(config sonarExecuteScanOptions, taskReport *SonarUtils.TaskReportData, serverUrl string, influx *sonarExecuteScanInflux, apiClient SonarUtils.Sender) error {
// fetch number of issues by severity
issueService := SonarUtils.NewIssuesService(serverUrl, config.Token, taskReport.ProjectKey, config.Organization, config.BranchName, config.ChangeID, apiClient)
var categories []SonarUtils.Severity
var err error
influx.sonarqube_data.fields.blocker_issues, err = issueService.GetNumberOfBlockerIssues(&categories)
if err != nil {
return err
@@ -274,7 +290,7 @@ func runSonar(config sonarExecuteScanOptions, client piperhttp.Downloader, runne
return err
}
reportData := SonarUtils.ReportData{
reportData := SonarUtils.ReportCodeCheckData{
ServerURL: taskReport.ServerURL,
ProjectKey: taskReport.ProjectKey,
TaskID: taskReport.TaskID,
@@ -282,7 +298,7 @@ func runSonar(config sonarExecuteScanOptions, client piperhttp.Downloader, runne
BranchName: config.BranchName,
Organization: config.Organization,
Errors: categories[:],
NumberOfIssues: SonarUtils.Issues{
NumberOfIssues: &SonarUtils.Issues{
Blocker: influx.sonarqube_data.fields.blocker_issues,
Critical: influx.sonarqube_data.fields.critical_issues,
Major: influx.sonarqube_data.fields.major_issues,
@@ -307,12 +323,29 @@ func runSonar(config sonarExecuteScanOptions, client piperhttp.Downloader, runne
log.Entry().Debugf("Influx values: %v", influx.sonarqube_data.fields)
err = SonarUtils.WriteReport(reportData, sonar.workingDir, os.WriteFile)
return SonarUtils.WriteCodeCheckReport(reportData, sonar.workingDir, os.WriteFile)
}
func getHotSpotSecurityCheckResults(config sonarExecuteScanOptions, taskReport *SonarUtils.TaskReportData, serverUrl string, apiClient SonarUtils.Sender) error {
// fetch number of issues by severity
issueService := SonarUtils.NewIssuesService(serverUrl, config.Token, taskReport.ProjectKey, config.Organization, config.BranchName, config.ChangeID, apiClient)
var hotspotissues []SonarUtils.SecurityHotspot
err := issueService.GetHotSpotSecurityIssues(&hotspotissues)
if err != nil {
return err
}
return nil
reportData := SonarUtils.ReportHotSpotData{
ServerURL: taskReport.ServerURL,
ProjectKey: taskReport.ProjectKey,
TaskID: taskReport.TaskID,
ChangeID: config.ChangeID,
BranchName: config.BranchName,
Organization: config.Organization,
SecurityHotspots: hotspotissues[:],
}
return SonarUtils.WriteHotSpotReport(reportData, sonar.workingDir, os.WriteFile)
}
// isInOptions returns true, if the given property is already provided in config.Options.

View File

@@ -153,6 +153,7 @@ func TestRunSonar(t *testing.T) {
// add response handler
httpmock.RegisterResponder(http.MethodGet, sonarServerURL+"/api/"+SonarUtils.EndpointCeTask+"", httpmock.NewStringResponder(http.StatusOK, `{ "task": { "componentId": "AXERR2JBbm9IiM5TEST", "status": "SUCCESS" }}`))
httpmock.RegisterResponder(http.MethodGet, sonarServerURL+"/api/"+SonarUtils.EndpointIssuesSearch+"", httpmock.NewStringResponder(http.StatusOK, `{ "total": 0 }`))
httpmock.RegisterResponder(http.MethodGet, sonarServerURL+"/api/"+SonarUtils.EndpointHotSpotsSearch+"", httpmock.NewStringResponder(http.StatusOK, `{ "total": 0 }`))
httpmock.RegisterResponder(http.MethodGet, sonarServerURL+"/api/"+SonarUtils.EndpointMeasuresComponent+"", httpmock.NewStringResponder(http.StatusOK, measuresComponentResponse))
t.Run("default", func(t *testing.T) {

View File

@@ -12,6 +12,9 @@ import (
// EndpointIssuesSearch API endpoint for https://sonarcloud.io/web_api/api/issues/search
const EndpointIssuesSearch = "issues/search"
// EndpointHotSpotSearch API endpoint for https://sonarcloud.io/web_api/api/hotspots/search
const EndpointHotSpotsSearch = "hotspots/search"
// IssueService ...
type IssueService struct {
Organization string
@@ -50,6 +53,35 @@ func (service *IssueService) SearchIssues(options *IssuesSearchOption) (*sonargo
return result, response, nil
}
// SearchIssues ...
func (service *IssueService) SearchHotSpots(options *HotSpotSearchOption) (*HotSpotSearchObject, *http.Response, error) {
request, err := service.apiClient.create("GET", EndpointHotSpotsSearch, options)
if err != nil {
return nil, nil, err
}
// use custom HTTP client to send request
response, err := service.apiClient.send(request)
if err != nil {
return nil, nil, err
}
// reuse response verrification from sonargo
err = sonargo.CheckResponse(response)
if err != nil {
return nil, response, err
}
// log response
log.Entry().Debugf("HTTP Response: %v", func() string { rsp, _ := httputil.DumpResponse(response, true); return string(rsp) }())
// decode JSON response
result := new(HotSpotSearchObject)
err = service.apiClient.decode(response, result)
if err != nil {
return nil, response, err
}
return result, response, nil
}
func (service *IssueService) getIssueCount(severity issueSeverity, categories *[]Severity) (int, error) {
options := &IssuesSearchOption{
ComponentKeys: service.Project,
@@ -114,6 +146,34 @@ func (service *IssueService) GetNumberOfInfoIssues(categories *[]Severity) (int,
return service.getIssueCount(info, categories)
}
func (service *IssueService) GetHotSpotSecurityIssues(securityHotspots *[]SecurityHotspot) error {
options := &HotSpotSearchOption{
Project: service.Project,
Status: to_review,
}
result, _, err := service.SearchHotSpots(options)
if err != nil {
return errors.Wrapf(err, "failed to fetch the numer of hotspots.")
}
table := map[string]int{}
service.updateHotSpotTypesTable(&result.HotSpots, table)
for priority, hotspots := range table {
var hotspot SecurityHotspot
hotspot.Priority = priority
hotspot.Hotspots = hotspots
*securityHotspots = append(*securityHotspots, hotspot)
}
return nil
}
func (service *IssueService) updateHotSpotTypesTable(issues *[]HotSpot, table map[string]int) {
for _, issue := range *issues {
table[issue.VulnerabilityProbability]++
}
delete(table, "") // remove undefined key if any exists in response
}
// NewIssuesService returns a new instance of a service for the issues API endpoint.
func NewIssuesService(host, token, project, organization, branch, pullRequest string, client Sender) *IssueService {
return &IssueService{

View File

@@ -332,3 +332,213 @@ const responseIssueSearchBug = `{
],
"facets": []
}`
func TestHotSpotService(t *testing.T) {
testURL := "https://example.org"
t.Run("success", func(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
sender := &piperhttp.Client{}
sender.SetOptions(piperhttp.ClientOptions{MaxRetries: -1, UseDefaultTransport: true})
// add response handler
httpmock.RegisterResponder(http.MethodGet, testURL+"/api/"+EndpointHotSpotsSearch+"", httpmock.NewStringResponder(http.StatusOK, responseHotSpotSearchMedium))
// create service instance
serviceUnderTest := NewIssuesService(testURL, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, sender)
// Severities
var hotspots []SecurityHotspot
// test
err := serviceUnderTest.GetHotSpotSecurityIssues(&hotspots)
// assert
assert.Equal(t, []SecurityHotspot{{Priority: "MEDIUM", Hotspots: 1}}, hotspots)
assert.NoError(t, err)
assert.Equal(t, 1, httpmock.GetTotalCallCount(), "unexpected number of requests")
})
t.Run("error", func(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
sender := &piperhttp.Client{}
sender.SetOptions(piperhttp.ClientOptions{MaxRetries: -1, UseDefaultTransport: true})
// add response handler
httpmock.RegisterResponder(http.MethodGet, testURL+"/api/"+EndpointHotSpotsSearch+"", httpmock.NewStringResponder(http.StatusNotFound, responseHotSpotSearchError))
// create service instance
serviceUnderTest := NewIssuesService(testURL, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, sender)
// Severities
var hotspots []SecurityHotspot
// test
err := serviceUnderTest.GetHotSpotSecurityIssues(&hotspots)
// assert
assert.Error(t, err)
assert.Equal(t, 1, httpmock.GetTotalCallCount(), "unexpected number of requests")
})
t.Run("multiple severities", func(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
sender := &piperhttp.Client{}
sender.SetOptions(piperhttp.ClientOptions{MaxRetries: -1, UseDefaultTransport: true})
// add response handler
httpmock.RegisterResponder(http.MethodGet, testURL+"/api/"+EndpointHotSpotsSearch+"", httpmock.NewStringResponder(http.StatusOK, responseHotSpotSearchMultiple))
// create service instance
serviceUnderTest := NewIssuesService(testURL, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, sender)
// Severities
// Severities
var hotspots []SecurityHotspot
// test
err := serviceUnderTest.GetHotSpotSecurityIssues(&hotspots)
// assert
assert.Equal(t, []SecurityHotspot{
{Priority: "MEDIUM", Hotspots: 2},
{Priority: "LOW", Hotspots: 1},
}, hotspots)
assert.NoError(t, err)
assert.Equal(t, 1, httpmock.GetTotalCallCount(), "unexpected number of requests")
})
}
const responseHotSpotSearchMedium = `{
"paging": {
"pageIndex": 1,
"pageSize": 100,
"total": 1
},
"hotspots": [
{
"key": "d1502ebc-941a-4262-8081-04914834d75a",
"component": "java-camera-viewer:build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html",
"project": "java-camera-viewer",
"securityCategory": "weak-cryptography",
"vulnerabilityProbability": "MEDIUM",
"status": "TO_REVIEW",
"line": 658,
"message": "Make sure that using this pseudorandom number generator is safe here.",
"author": "",
"creationDate": "2025-03-31T18:16:09+0000",
"updateDate": "2025-04-23T11:02:47+0000",
"textRange": {
"startLine": 658,
"endLine": 658,
"startOffset": 16799,
"endOffset": 16812
},
"flows": [],
"ruleKey": "javascript:S2245",
"messageFormattings": []
}
],
"components": [
{
"key": "java-camera-viewer:build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html",
"qualifier": "FIL",
"name": "configuration-cache-report.html",
"longName": "build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html",
"path": "build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html"
},
{
"key": "java-camera-viewer",
"qualifier": "TRK",
"name": "java-camera-viewer",
"longName": "java-camera-viewer"
}
]
}`
const responseHotSpotSearchError = `{
"errors":[
{
"msg":"Project java-camera-viewer1 not found"
}
]
}`
const responseHotSpotSearchMultiple = `{
"paging": {
"pageIndex": 1,
"pageSize": 100,
"total": 1
},
"hotspots": [
{
"key": "d1502ebc-941a-4262-8081-04914834d75a",
"component": "java-camera-viewer:build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html",
"project": "java-camera-viewer",
"securityCategory": "weak-cryptography",
"vulnerabilityProbability": "MEDIUM",
"status": "TO_REVIEW",
"line": 658,
"message": "Make sure that using this pseudorandom number generator is safe here.",
"author": "",
"creationDate": "2025-03-31T18:16:09+0000",
"updateDate": "2025-04-23T11:02:47+0000",
"textRange": {
"startLine": 658,
"endLine": 658,
"startOffset": 16799,
"endOffset": 16812
},
"flows": [],
"ruleKey": "javascript:S2245",
"messageFormattings": []
},
{
"key": "d1502ebc-941a-4262-8081-04914834d75b",
"component": "java-camera-viewer:build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html",
"project": "java-camera-viewer",
"securityCategory": "weak-cryptography",
"vulnerabilityProbability": "MEDIUM",
"status": "TO_REVIEW",
"line": 658,
"message": "Make sure that using this pseudorandom number generator is safe here.",
"author": "",
"creationDate": "2025-03-31T18:16:09+0000",
"updateDate": "2025-04-23T11:02:47+0000",
"textRange": {
"startLine": 658,
"endLine": 658,
"startOffset": 16799,
"endOffset": 16812
},
"flows": [],
"ruleKey": "javascript:S2245",
"messageFormattings": []
},
{
"key": "d1502ebc-941a-4262-8081-04914834d75c",
"component": "java-camera-viewer:build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html",
"project": "java-camera-viewer",
"securityCategory": "weak-cryptography",
"vulnerabilityProbability": "LOW",
"status": "TO_REVIEW",
"line": 658,
"message": "Make sure that using this pseudorandom number generator is safe here.",
"author": "",
"creationDate": "2025-03-31T18:16:09+0000",
"updateDate": "2025-04-23T11:02:47+0000",
"textRange": {
"startLine": 658,
"endLine": 658,
"startOffset": 16799,
"endOffset": 16812
},
"flows": [],
"ruleKey": "javascript:S2245",
"messageFormattings": []
}
],
"components": [
{
"key": "java-camera-viewer:build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html",
"qualifier": "FIL",
"name": "configuration-cache-report.html",
"longName": "build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html",
"path": "build/reports/configuration-cache/cbcm7qev21o4weakgfsv12gen/9cfjq3uac12x26p527bjxudhn/configuration-cache-report.html"
},
{
"key": "java-camera-viewer",
"qualifier": "TRK",
"name": "java-camera-viewer",
"longName": "java-camera-viewer"
}
]
}`

View File

@@ -6,22 +6,40 @@ import (
"path/filepath"
)
const reportFileName = "sonarscan.json"
const reportCodeCheckFileName = "sonarscan.json"
const reportHotSpotFileName = "hotspot.json"
// ReportData is representing the data of the step report JSON
type ReportData struct {
// ReportCodeCheckData is representing the data of the step report JSON
type ReportCodeCheckData struct {
ServerURL string `json:"serverUrl"`
ProjectKey string `json:"projectKey"`
TaskID string `json:"taskId"`
ChangeID string `json:"changeID,omitempty"`
BranchName string `json:"branchName,omitempty"`
Organization string `json:"organization,omitempty"`
NumberOfIssues Issues `json:"numberOfIssues"`
NumberOfIssues *Issues `json:"numberOfIssues"`
Errors []Severity `json:"errors"`
Coverage *SonarCoverage `json:"coverage,omitempty"`
LinesOfCode *SonarLinesOfCode `json:"linesOfCode,omitempty"`
}
// ReportCodeCheckData is representing the data of the step report JSON
type ReportHotSpotData struct {
ServerURL string `json:"serverUrl"`
ProjectKey string `json:"projectKey"`
TaskID string `json:"taskId"`
ChangeID string `json:"changeID,omitempty"`
BranchName string `json:"branchName,omitempty"`
Organization string `json:"organization,omitempty"`
SecurityHotspots []SecurityHotspot `json:"securityHotspots"`
}
// HotSpot Security Issues
type SecurityHotspot struct {
Priority string `json:"priority"`
Hotspots int `json:"hotspots"`
}
// Issues ...
type Issues struct {
Blocker int `json:"blocker"`
@@ -38,10 +56,18 @@ type Severity struct {
}
// WriteReport ...
func WriteReport(data ReportData, reportPath string, writeToFile func(f string, d []byte, p os.FileMode) error) error {
func WriteCodeCheckReport(data ReportCodeCheckData, reportPath string, writeToFile func(f string, d []byte, p os.FileMode) error) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
return writeToFile(filepath.Join(reportPath, reportFileName), jsonData, 0644)
return writeToFile(filepath.Join(reportPath, reportCodeCheckFileName), jsonData, 0644)
}
func WriteHotSpotReport(data ReportHotSpotData, reportPath string, writeToFile func(f string, d []byte, p os.FileMode) error) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
return writeToFile(filepath.Join(reportPath, reportHotSpotFileName), jsonData, 0644)
}

View File

@@ -20,10 +20,10 @@ func writeToFileMock(f string, d []byte, p os.FileMode) error {
return nil
}
func TestWriteReport(t *testing.T) {
func TestWriteCodeCheckReport(t *testing.T) {
// init
const expected = `{"serverUrl":"https://sonarcloud.io","projectKey":"Piper-Validation/Golang","taskId":"mock.Anything","numberOfIssues":{"blocker":0,"critical":1,"major":2,"minor":3,"info":4},"errors":[{"severity":"CRITICAL","error_type":"CODE_SMELL","issues":10}],"coverage":{"coverage":13.7,"lineCoverage":37.1,"linesToCover":123,"uncoveredLines":23,"branchCoverage":42,"branchesToCover":30,"uncoveredBranches":3},"linesOfCode":{"total":327,"languageDistribution":[{"languageKey":"java","linesOfCode":327}]}}`
testData := ReportData{
testData := ReportCodeCheckData{
ServerURL: "https://sonarcloud.io",
ProjectKey: "Piper-Validation/Golang",
TaskID: mock.Anything,
@@ -34,7 +34,7 @@ func TestWriteReport(t *testing.T) {
IssueCount: 10,
},
},
NumberOfIssues: Issues{
NumberOfIssues: &Issues{
Critical: 1,
Major: 2,
Minor: 3,
@@ -55,9 +55,29 @@ func TestWriteReport(t *testing.T) {
},
}
// test
err := WriteReport(testData, "", writeToFileMock)
err := WriteCodeCheckReport(testData, "", writeToFileMock)
// assert
assert.NoError(t, err)
assert.Equal(t, expected, fileContent)
assert.Equal(t, reportFileName, fileName)
assert.Equal(t, reportCodeCheckFileName, fileName)
}
func TestWriteHotSpotReport(t *testing.T) {
// init
const expected = `{"serverUrl":"https://sonarcloud.io","projectKey":"Piper-Validation/Golang","taskId":"mock.Anything","securityHotspots":[{"priority":"HIGH","hotspots":1},{"priority":"LOW","hotspots":4}]}`
testData := ReportHotSpotData{
ServerURL: "https://sonarcloud.io",
ProjectKey: "Piper-Validation/Golang",
TaskID: mock.Anything,
SecurityHotspots: []SecurityHotspot{
{Priority: "HIGH", Hotspots: 1},
{Priority: "LOW", Hotspots: 4},
},
}
// test
err := WriteHotSpotReport(testData, "", writeToFileMock)
// assert
assert.NoError(t, err)
assert.Equal(t, expected, fileContent)
assert.Equal(t, reportHotSpotFileName, fileName)
}

View File

@@ -35,6 +35,35 @@ type IssuesSearchOption struct {
Types string `url:"types,omitempty"` // Description:"Comma-separated list of types.",ExampleValue:"CODE_SMELL,BUG"
}
type HotSpotSearchOption struct {
Project string `url:"project"` // Description:"Project name"
Status hotSpotStatus `url:"status"` // Security issue review status (TO_REVIEW | REVIEWED)
}
type HotSpotSearchObject struct {
Paging interface{} `json:"paging"`
HotSpots []HotSpot `json:"hotspots"`
Components []interface{} `json:"components"`
}
type HotSpot struct {
Key string `json:"key"`
Component string `json:"component"`
Project string `json:"project"`
SecurityCategory string `json:"securityCategory"`
VulnerabilityProbability string `json:"vulnerabilityProbability"`
Status string `json:"status"`
Line int `json:"line"`
Message string `json:"message"`
Author string `json:"author"`
CreationDate string `json:"creationDate"`
UpdateDate string `json:"updateDate"`
TextRange interface{} `json:"textRange"`
Flows []interface{} `json:"flows"`
RuleKey string `json:"ruleKey"`
MessageFormattings []interface{} `json:"messageFormattings"`
}
// MeasuresComponentOption is a copy from magicsong/sonargo plus the "internal" field branch.
type MeasuresComponentOption struct {
Branch string `url:"branch,omitempty"` // Description:"Branch key"
@@ -59,3 +88,14 @@ const (
minor issueSeverity = "MINOR"
info issueSeverity = "INFO"
)
type hotSpotStatus string
func (s hotSpotStatus) ToString() string {
return string(s)
}
const (
reviwed hotSpotStatus = "REVIEWED"
to_review hotSpotStatus = "TO_REVIEW"
)