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

Upload Fortify scan results to GitHub issue (#3300)

* fix(fortifyExecuteScan): Propagate translation errors

Force translation related errors to stop the execution of the step.

* Extend testcase

* Update fortifyExecuteScan.go

* Fix fmt and test

* Fix code

* feat(fortifyExecuteScan): Create GitHub issue

* Fix expectation

* Fix fmt

* Fix fmt add test

* Added tests

* Go fmt

* Add switch

* Rewrite githubCreateIssue

* Fix tests

* Added switch

* Issue only in case of violations

* Fix CPE reference

* Add  debug message to issue creation/update

* Update fortifyExecuteScan.go

* Add credential for GH to groovy wrapper

* Update fortifyExecuteScan.go
This commit is contained in:
Sven Merk
2022-01-21 10:52:17 +01:00
committed by GitHub
parent af4bc6e989
commit 6520115950
10 changed files with 492 additions and 290 deletions

View File

@@ -158,9 +158,13 @@ func runFortifyScan(config fortifyExecuteScanOptions, sys fortify.System, utils
}
log.Entry().Debugf("Looked up / created project version with ID %v for PR %v", projectVersion.ID, fortifyProjectVersion)
} else {
prID := determinePullRequestMerge(config)
prID, prAuthor := determinePullRequestMerge(config)
if len(prID) > 0 {
log.Entry().Debugf("Determined PR ID '%v' for merge check", prID)
if len(prAuthor) > 0 && !piperutils.ContainsString(config.Assignees, prAuthor) {
log.Entry().Debugf("Determined PR Author '%v' for result assignment", prAuthor)
config.Assignees = append(config.Assignees, prAuthor)
}
pullRequestProjectName := fmt.Sprintf("PR-%v", prID)
err = sys.MergeProjectVersionStateOfPRIntoMaster(config.FprDownloadEndpoint, config.FprUploadEndpoint, project.ID, projectVersion.ID, pullRequestProjectName)
if err != nil {
@@ -294,12 +298,22 @@ func verifyFFProjectCompliance(config fortifyExecuteScanOptions, sys fortify.Sys
fortifyReportingData := prepareReportData(influx)
scanReport := fortify.CreateCustomReport(fortifyReportingData, issueGroups)
paths, err := fortify.WriteCustomReports(scanReport, influx.fortify_data.fields.projectName, influx.fortify_data.fields.projectVersion)
paths, err := fortify.WriteCustomReports(scanReport)
if err != nil {
return errors.Wrap(err, "failed to write custom reports"), reports
}
reports = append(reports, paths...)
log.Entry().Debug("Checking whether GitHub issue creation/update is active")
log.Entry().Debugf("%v, %v, %v, %v, %v, %v", config.CreateResultIssue, numberOfViolations > 0, len(config.GithubToken) > 0, len(config.GithubAPIURL) > 0, len(config.Owner) > 0, len(config.Repository) > 0)
if config.CreateResultIssue && numberOfViolations > 0 && len(config.GithubToken) > 0 && len(config.GithubAPIURL) > 0 && len(config.Owner) > 0 && len(config.Repository) > 0 {
log.Entry().Debug("Creating/updating GitHub issue with scan results")
err = fortify.UploadReportToGithub(scanReport, config.GithubToken, config.GithubAPIURL, config.Owner, config.Repository, config.Assignees)
if err != nil {
return errors.Wrap(err, "failed to upload scan results into GitHub"), reports
}
}
jsonReport := fortify.CreateJSONReport(fortifyReportingData, spotChecksCountByCategory, config.ServerURL)
paths, err = fortify.WriteJSONReport(jsonReport)
if err != nil {
@@ -330,6 +344,7 @@ func prepareReportData(influx *fortifyExecuteScanInflux) fortify.FortifyReportDa
output.Suppressed = input.suppressed
output.Suspicious = input.suspicious
output.ProjectVersionID = input.projectVersionID
output.Violations = input.violations
return output
}
@@ -805,7 +820,10 @@ func triggerFortifyScan(config fortifyExecuteScanOptions, utils fortifyUtils, bu
return fmt.Errorf("buildTool '%s' is not supported by this step", config.BuildTool)
}
translateProject(&config, utils, buildID, classpath)
err = translateProject(&config, utils, buildID, classpath)
if err != nil {
return err
}
return scanProject(&config, utils, buildID, buildLabel, buildProject)
}
@@ -851,7 +869,7 @@ func populateMavenTranslate(config *fortifyExecuteScanOptions, classpath string)
return string(translateJSON), err
}
func translateProject(config *fortifyExecuteScanOptions, utils fortifyUtils, buildID, classpath string) {
func translateProject(config *fortifyExecuteScanOptions, utils fortifyUtils, buildID, classpath string) error {
var translateList []map[string]string
json.Unmarshal([]byte(config.Translate), &translateList)
log.Entry().Debugf("Translating with options: %v", translateList)
@@ -859,8 +877,12 @@ func translateProject(config *fortifyExecuteScanOptions, utils fortifyUtils, bui
if len(classpath) > 0 {
translate["autoClasspath"] = classpath
}
handleSingleTranslate(config, utils, buildID, translate)
err := handleSingleTranslate(config, utils, buildID, translate)
if err != nil {
return err
}
}
return nil
}
func handleSingleTranslate(config *fortifyExecuteScanOptions, command fortifyUtils, buildID string, t map[string]string) error {
@@ -917,14 +939,15 @@ func scanProject(config *fortifyExecuteScanOptions, command fortifyUtils, buildI
return nil
}
func determinePullRequestMerge(config fortifyExecuteScanOptions) string {
func determinePullRequestMerge(config fortifyExecuteScanOptions) (string, string) {
author := ""
ctx, client, err := piperGithub.NewClient(config.GithubToken, config.GithubAPIURL, "")
if err == nil {
result, err := determinePullRequestMergeGithub(ctx, config, client.PullRequests)
prID, author, err := determinePullRequestMergeGithub(ctx, config, client.PullRequests)
if err != nil {
log.Entry().WithError(err).Warn("Failed to get PR metadata via GitHub client")
} else {
return result
return prID, author
}
}
@@ -932,18 +955,18 @@ func determinePullRequestMerge(config fortifyExecuteScanOptions) string {
r, _ := regexp.Compile(config.PullRequestMessageRegex)
matches := r.FindSubmatch([]byte(config.CommitMessage))
if matches != nil && len(matches) > 1 {
return string(matches[config.PullRequestMessageRegexGroup])
return string(matches[config.PullRequestMessageRegexGroup]), author
}
return ""
return "", ""
}
func determinePullRequestMergeGithub(ctx context.Context, config fortifyExecuteScanOptions, pullRequestServiceInstance pullRequestService) (string, error) {
func determinePullRequestMergeGithub(ctx context.Context, config fortifyExecuteScanOptions, pullRequestServiceInstance pullRequestService) (string, string, error) {
options := github.PullRequestListOptions{State: "closed", Sort: "updated", Direction: "desc"}
prList, _, err := pullRequestServiceInstance.ListPullRequestsWithCommit(ctx, config.Owner, config.Repository, config.CommitID, &options)
if err == nil && len(prList) > 0 {
return fmt.Sprintf("%v", prList[0].GetNumber()), nil
return fmt.Sprintf("%v", prList[0].GetNumber()), *prList[0].User.Email, nil
}
return "", err
return "", "", err
}
func appendToOptions(config *fortifyExecuteScanOptions, options []string, t map[string]string) []string {

View File

@@ -19,6 +19,7 @@ import (
type fortifyExecuteScanOptions struct {
AdditionalScanParameters []string `json:"additionalScanParameters,omitempty"`
Assignees []string `json:"assignees,omitempty"`
AuthToken string `json:"authToken,omitempty"`
BuildDescriptorExcludeList []string `json:"buildDescriptorExcludeList,omitempty"`
CustomScanVersion string `json:"customScanVersion,omitempty"`
@@ -72,6 +73,7 @@ type fortifyExecuteScanOptions struct {
M2Path string `json:"m2Path,omitempty"`
VerifyOnly bool `json:"verifyOnly,omitempty"`
InstallArtifacts bool `json:"installArtifacts,omitempty"`
CreateResultIssue bool `json:"createResultIssue,omitempty"`
}
type fortifyExecuteScanInflux struct {
@@ -246,6 +248,7 @@ Besides triggering a scan the step verifies the results after they have been upl
func addFortifyExecuteScanFlags(cmd *cobra.Command, stepConfig *fortifyExecuteScanOptions) {
cmd.Flags().StringSliceVar(&stepConfig.AdditionalScanParameters, "additionalScanParameters", []string{}, "List of additional scan parameters to be used for Fortify sourceanalyzer command execution.")
cmd.Flags().StringSliceVar(&stepConfig.Assignees, "assignees", []string{``}, "Defines the assignees for the Github Issue created/updated with the results of the scan.")
cmd.Flags().StringVar(&stepConfig.AuthToken, "authToken", os.Getenv("PIPER_authToken"), "The FortifyToken to use for authentication")
cmd.Flags().StringSliceVar(&stepConfig.BuildDescriptorExcludeList, "buildDescriptorExcludeList", []string{`unit-tests/pom.xml`, `integration-tests/pom.xml`}, "List of build descriptors and therefore modules to exclude from the scan and assessment activities.")
cmd.Flags().StringVar(&stepConfig.CustomScanVersion, "customScanVersion", os.Getenv("PIPER_customScanVersion"), "Custom version of the Fortify project used as source.")
@@ -299,6 +302,7 @@ func addFortifyExecuteScanFlags(cmd *cobra.Command, stepConfig *fortifyExecuteSc
cmd.Flags().StringVar(&stepConfig.M2Path, "m2Path", os.Getenv("PIPER_m2Path"), "Path to the location of the local repository that should be used.")
cmd.Flags().BoolVar(&stepConfig.VerifyOnly, "verifyOnly", false, "Whether the step shall only apply verification checks or whether it does a full scan and check cycle")
cmd.Flags().BoolVar(&stepConfig.InstallArtifacts, "installArtifacts", false, "If enabled, it will install all artifacts to the local maven repository to make them available before running Fortify. This is required if any maven module has dependencies to other modules in the repository and they were not installed before.")
cmd.Flags().BoolVar(&stepConfig.CreateResultIssue, "createResultIssue", false, "Whether the step creates a GitHub issue containing the scan results in the originating repo. Since optimized pipelines are headless the creation is implicitly activated for schedules runs.")
cmd.MarkFlagRequired("authToken")
cmd.MarkFlagRequired("serverUrl")
@@ -335,6 +339,15 @@ func fortifyExecuteScanMetadata() config.StepData {
Aliases: []config.Alias{},
Default: []string{},
},
{
Name: "assignees",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "[]string",
Mandatory: false,
Aliases: []config.Alias{},
Default: []string{``},
},
{
Name: "authToken",
ResourceRef: []config.ResourceReference{
@@ -870,6 +883,20 @@ func fortifyExecuteScanMetadata() config.StepData {
Aliases: []config.Alias{},
Default: false,
},
{
Name: "createResultIssue",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "custom/optimizedAndScheduled",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "bool",
Mandatory: false,
Aliases: []config.Alias{},
Default: false,
},
},
},
Containers: []config.Container{

View File

@@ -19,6 +19,7 @@ import (
"github.com/SAP/jenkins-library/pkg/fortify"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/piperutils"
"github.com/SAP/jenkins-library/pkg/versioning"
"github.com/google/go-github/v32/github"
@@ -27,6 +28,8 @@ import (
"github.com/piper-validation/fortify-client-go/models"
)
const author string = "john.doe@dummy.com"
type fortifyTestUtilsBundle struct {
*execRunnerMock
*mock.FilesMock
@@ -277,11 +280,13 @@ func (f *fortifyMock) DownloadResultFile(endpoint string, projectVersionID int64
type pullRequestServiceMock struct{}
func (prService pullRequestServiceMock) ListPullRequestsWithCommit(ctx context.Context, owner, repo, sha string, opts *github.PullRequestListOptions) ([]*github.PullRequest, *github.Response, error) {
authorString := author
user := github.User{Email: &authorString}
if owner == "A" {
result := 17
return []*github.PullRequest{{Number: &result}}, &github.Response{}, nil
return []*github.PullRequest{{Number: &result, User: &user}}, &github.Response{}, nil
} else if owner == "C" {
return []*github.PullRequest{}, &github.Response{}, errors.New("Test error")
return []*github.PullRequest{{User: &user}}, &github.Response{}, errors.New("Test error")
}
return []*github.PullRequest{}, &github.Response{}, nil
}
@@ -333,6 +338,9 @@ func (er *execRunnerMock) Stderr(err io.Writer) {
func (er *execRunnerMock) RunExecutable(e string, p ...string) error {
er.numExecutions++
er.currentExecution().executable = e
if len(p) > 0 && piperutils.ContainsString(p, "--failTranslate") {
return errors.New("Translate failed")
}
er.currentExecution().parameters = p
classpathPip := "/usr/lib/python35.zip;/usr/lib/python3.5;/usr/lib/python3.5/plat-x86_64-linux-gnu;/usr/lib/python3.5/lib-dynload;/home/piper/.local/lib/python3.5/site-packages;/usr/local/lib/python3.5/dist-packages;/usr/lib/python3/dist-packages;./lib"
classpathMaven := "some.jar;someother.jar"
@@ -386,6 +394,12 @@ func TestExecutions(t *testing.T) {
config: fortifyExecuteScanOptions{BuildTool: "golang", BuildDescriptorFile: "go.mod", VerifyOnly: true},
expectedReportsLength: 0,
},
{
nameOfRun: "maven scan and verify",
config: fortifyExecuteScanOptions{BuildTool: "maven", BuildDescriptorFile: "pom.xml", UpdateRulePack: true, Reporting: true, UploadResults: true},
expectedReportsLength: 2,
expectedReports: []string{"target/fortify-scan.*", "target/*.fpr"},
},
}
for _, data := range testData {
@@ -729,14 +743,16 @@ func TestDeterminePullRequestMerge(t *testing.T) {
config := fortifyExecuteScanOptions{CommitMessage: "Merge pull request #2462 from branch f-test", PullRequestMessageRegex: `(?m).*Merge pull request #(\d+) from.*`, PullRequestMessageRegexGroup: 1}
t.Run("success", func(t *testing.T) {
match := determinePullRequestMerge(config)
match, authorString := determinePullRequestMerge(config)
assert.Equal(t, "2462", match, "Expected different result")
assert.Equal(t, "", authorString, "Expected different result")
})
t.Run("no match", func(t *testing.T) {
config.CommitMessage = "Some test commit"
match := determinePullRequestMerge(config)
match, authorString := determinePullRequestMerge(config)
assert.Equal(t, "", match, "Expected different result")
assert.Equal(t, "", authorString, "Expected different result")
})
}
@@ -744,21 +760,24 @@ func TestDeterminePullRequestMergeGithub(t *testing.T) {
prServiceMock := pullRequestServiceMock{}
t.Run("success", func(t *testing.T) {
match, err := determinePullRequestMergeGithub(nil, fortifyExecuteScanOptions{Owner: "A"}, prServiceMock)
match, authorString, err := determinePullRequestMergeGithub(nil, fortifyExecuteScanOptions{Owner: "A"}, prServiceMock)
assert.NoError(t, err)
assert.Equal(t, "17", match, "Expected different result")
assert.Equal(t, author, authorString, "Expected different result")
})
t.Run("no match", func(t *testing.T) {
match, err := determinePullRequestMergeGithub(nil, fortifyExecuteScanOptions{Owner: "B"}, prServiceMock)
match, authorString, err := determinePullRequestMergeGithub(nil, fortifyExecuteScanOptions{Owner: "B"}, prServiceMock)
assert.NoError(t, err)
assert.Equal(t, "", match, "Expected different result")
assert.Equal(t, "", authorString, "Expected different result")
})
t.Run("error", func(t *testing.T) {
match, err := determinePullRequestMergeGithub(nil, fortifyExecuteScanOptions{Owner: "C"}, prServiceMock)
match, authorString, err := determinePullRequestMergeGithub(nil, fortifyExecuteScanOptions{Owner: "C"}, prServiceMock)
assert.EqualError(t, err, "Test error")
assert.Equal(t, "", match, "Expected different result")
assert.Equal(t, "", authorString, "Expected different result")
})
}
@@ -794,6 +813,14 @@ func TestTranslateProject(t *testing.T) {
assert.Equal(t, "sourceanalyzer", utils.executions[0].executable, "Expected different executable")
assert.Equal(t, []string{"-verbose", "-64", "-b", "/commit/7267658798797", "-Xmx2G", "-cp", "./WEB-INF/lib/*.jar", "-extdirs", "tmp/", "-source", "1.8", "-jdk", "1.8.0-21", "-sourcepath", "src/ext/", "./**/*"}, utils.executions[0].parameters, "Expected different parameters")
})
t.Run("failure propagated", func(t *testing.T) {
utils := newFortifyTestUtilsBundle()
config := fortifyExecuteScanOptions{BuildTool: "maven", Memory: "-Xmx2G", Translate: `[{"classpath":"./classes/*.jar", "extdirs":"tmp/","jdk":"1.8.0-21","source":"1.8","sourcepath":"src/ext/","src":"./**/*"}]`}
err := translateProject(&config, &utils, "--failTranslate", "./WEB-INF/lib/*.jar")
assert.Error(t, err)
assert.Equal(t, "failed to execute sourceanalyzer translate command with options [-verbose -64 -b --failTranslate -Xmx2G -cp ./WEB-INF/lib/*.jar -extdirs tmp/ -source 1.8 -jdk 1.8.0-21 -sourcepath src/ext/ ./**/*]: Translate failed", err.Error())
})
}
func TestScanProject(t *testing.T) {

View File

@@ -1,108 +1,53 @@
package cmd
import (
"context"
"fmt"
"io/ioutil"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/telemetry"
"github.com/google/go-github/v32/github"
"github.com/pkg/errors"
piperGithub "github.com/SAP/jenkins-library/pkg/github"
)
type githubCreateIssueService interface {
Create(ctx context.Context, owner string, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error)
}
type githubSearchIssuesService interface {
Issues(ctx context.Context, query string, opts *github.SearchOptions) (*github.IssuesSearchResult, *github.Response, error)
}
type githubCreateCommentService interface {
CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
}
func githubCreateIssue(config githubCreateIssueOptions, telemetryData *telemetry.CustomData) {
ctx, client, err := piperGithub.NewClient(config.Token, config.APIURL, "")
if err != nil {
log.Entry().WithError(err).Fatal("Failed to get GitHub client")
}
err = runGithubCreateIssue(ctx, &config, telemetryData, client.Issues, client.Search, client.Issues, ioutil.ReadFile)
err := runGithubCreateIssue(&config, telemetryData)
if err != nil {
log.Entry().WithError(err).Fatal("Failed to comment on issue")
}
}
func runGithubCreateIssue(ctx context.Context, config *githubCreateIssueOptions, _ *telemetry.CustomData, ghCreateIssueService githubCreateIssueService, ghSearchIssuesService githubSearchIssuesService, ghCreateCommentService githubCreateCommentService, readFile func(string) ([]byte, error)) error {
func runGithubCreateIssue(config *githubCreateIssueOptions, _ *telemetry.CustomData) error {
options := piperGithub.CreateIssueOptions{}
err := transformConfig(config, &options, ioutil.ReadFile)
if err != nil {
return err
}
return piperGithub.CreateIssue(&options)
}
func transformConfig(config *githubCreateIssueOptions, options *piperGithub.CreateIssueOptions, readFile func(string) ([]byte, error)) error {
options.Token = config.Token
options.APIURL = config.APIURL
options.Owner = config.Owner
options.Repository = config.Repository
options.Title = config.Title
options.Body = []byte(config.Body)
options.Assignees = config.Assignees
options.UpdateExisting = config.UpdateExisting
if len(config.Body)+len(config.BodyFilePath) == 0 {
return fmt.Errorf("either parameter `body` or parameter `bodyFilePath` is required")
}
issue := github.IssueRequest{
Title: &config.Title,
}
if len(config.Body) > 0 {
issue.Body = &config.Body
} else {
if len(config.Body) == 0 {
issueContent, err := readFile(config.BodyFilePath)
if err != nil {
return errors.Wrapf(err, "failed to read file '%v'", config.BodyFilePath)
}
body := string(issueContent)
issue.Body = &body
options.Body = issueContent
}
if len(config.Assignees) > 0 {
issue.Assignees = &config.Assignees
} else {
issue.Assignees = &[]string{}
}
var existingIssue *github.Issue = nil
if config.UpdateExisting {
queryString := fmt.Sprintf("is:open is:issue repo:%v/%v in:title %v", config.Owner, config.Repository, config.Title)
searchResult, resp, err := ghSearchIssuesService.Issues(ctx, queryString, nil)
if err != nil {
if resp != nil {
log.Entry().Errorf("GitHub response code %v", resp.Status)
}
return errors.Wrap(err, "error occurred when looking for existing issue")
} else {
for _, value := range searchResult.Issues {
if value != nil && *value.Title == config.Title {
existingIssue = value
}
}
}
if existingIssue != nil {
comment := &github.IssueComment{Body: issue.Body}
_, resp, err := ghCreateCommentService.CreateComment(ctx, config.Owner, config.Repository, *existingIssue.Number, comment)
if err != nil {
if resp != nil {
log.Entry().Errorf("GitHub response code %v", resp.Status)
}
return errors.Wrap(err, "error occurred when looking for existing issue")
}
}
}
if existingIssue == nil {
newIssue, resp, err := ghCreateIssueService.Create(ctx, config.Owner, config.Repository, &issue)
if err != nil {
if resp != nil {
log.Entry().Errorf("GitHub response code %v", resp.Status)
}
return errors.Wrap(err, "error occurred when creating issue")
}
log.Entry().Debugf("New issue created: %v", newIssue)
}
return nil
}

View File

@@ -1,114 +1,21 @@
package cmd
import (
"context"
"fmt"
"net/http"
"regexp"
"testing"
"github.com/SAP/jenkins-library/pkg/mock"
"github.com/google/go-github/v32/github"
"github.com/stretchr/testify/assert"
piperGithub "github.com/SAP/jenkins-library/pkg/github"
)
type ghCreateIssueMock struct {
issue *github.IssueRequest
issueID int64
issueError error
owner string
repo string
number int
assignees []string
}
func (g *ghCreateIssueMock) Create(ctx context.Context, owner string, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
g.issue = issue
g.owner = owner
g.repo = repo
g.assignees = *issue.Assignees
issueResponse := github.Issue{ID: &g.issueID, Title: issue.Title, Body: issue.Body}
ghRes := github.Response{Response: &http.Response{Status: "200"}}
if g.issueError != nil {
ghRes.Status = "401"
}
return &issueResponse, &ghRes, g.issueError
}
type ghSearchIssuesMock struct {
issueID int64
issueNumber int
issueTitle string
issueBody string
issuesSearchResult *github.IssuesSearchResult
issuesSearchError error
}
func (g *ghSearchIssuesMock) Issues(ctx context.Context, query string, opts *github.SearchOptions) (*github.IssuesSearchResult, *github.Response, error) {
regex := regexp.MustCompile(`.*in:title (?P<Title>(.*))`)
matches := regex.FindStringSubmatch(query)
g.issueTitle = matches[1]
issues := []*github.Issue{
{
ID: &g.issueID,
Number: &g.issueNumber,
Title: &g.issueTitle,
Body: &g.issueBody,
},
}
total := len(issues)
incompleteResults := false
g.issuesSearchResult = &github.IssuesSearchResult{
Issues: issues,
Total: &total,
IncompleteResults: &incompleteResults,
}
ghRes := github.Response{Response: &http.Response{Status: "200"}}
if g.issuesSearchError != nil {
ghRes.Status = "401"
}
return g.issuesSearchResult, &ghRes, g.issuesSearchError
}
type ghCreateCommentMock struct {
issueComment *github.IssueComment
issueCommentError error
}
func (g *ghCreateCommentMock) CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
g.issueComment = comment
ghRes := github.Response{Response: &http.Response{Status: "200"}}
if g.issueCommentError != nil {
ghRes.Status = "401"
}
return g.issueComment, &ghRes, g.issueCommentError
}
func TestRunGithubCreateIssue(t *testing.T) {
ctx := context.Background()
func TestTransformConfig(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
// init
filesMock := mock.FilesMock{}
ghCreateIssueService := ghCreateIssueMock{
issueID: 1,
}
ghSearchIssuesMock := ghSearchIssuesMock{
issueID: 1,
}
ghCreateCommentMock := ghCreateCommentMock{}
config := githubCreateIssueOptions{
Owner: "TEST",
Repository: "test",
@@ -116,119 +23,61 @@ func TestRunGithubCreateIssue(t *testing.T) {
Title: "This is my title",
Assignees: []string{"userIdOne", "userIdTwo"},
}
options := piperGithub.CreateIssueOptions{}
// test
err := runGithubCreateIssue(ctx, &config, nil, &ghCreateIssueService, &ghSearchIssuesMock, &ghCreateCommentMock, filesMock.FileRead)
err := transformConfig(&config, &options, filesMock.FileRead)
// assert
assert.NoError(t, err)
assert.Equal(t, config.Owner, ghCreateIssueService.owner)
assert.Equal(t, config.Repository, ghCreateIssueService.repo)
assert.Equal(t, config.Body, ghCreateIssueService.issue.GetBody())
assert.Equal(t, config.Title, ghCreateIssueService.issue.GetTitle())
assert.Equal(t, config.Assignees, ghCreateIssueService.issue.GetAssignees())
assert.Nil(t, ghSearchIssuesMock.issuesSearchResult)
assert.Nil(t, ghCreateCommentMock.issueComment)
assert.Equal(t, config.Token, options.Token)
assert.Equal(t, config.APIURL, options.APIURL)
assert.Equal(t, config.Owner, options.Owner)
assert.Equal(t, config.Repository, options.Repository)
assert.Equal(t, []byte(config.Body), options.Body)
assert.Equal(t, config.Title, options.Title)
assert.Equal(t, config.Assignees, options.Assignees)
assert.Equal(t, config.UpdateExisting, options.UpdateExisting)
})
t.Run("Success - body from file", func(t *testing.T) {
t.Run("Success bodyFilePath", func(t *testing.T) {
// init
filesMock := mock.FilesMock{}
filesMock.AddFile("test.md", []byte("Test markdown"))
ghCreateIssueService := ghCreateIssueMock{
issueID: 1,
}
config := githubCreateIssueOptions{
Owner: "TEST",
Repository: "test",
BodyFilePath: "test.md",
Title: "This is my title",
Assignees: []string{"userIdOne", "userIdTwo"},
}
options := piperGithub.CreateIssueOptions{}
// test
err := runGithubCreateIssue(ctx, &config, nil, &ghCreateIssueService, nil, nil, filesMock.FileRead)
err := transformConfig(&config, &options, filesMock.FileRead)
// assert
assert.NoError(t, err)
assert.Equal(t, config.Owner, ghCreateIssueService.owner)
assert.Equal(t, config.Repository, ghCreateIssueService.repo)
assert.Equal(t, "Test markdown", ghCreateIssueService.issue.GetBody())
assert.Equal(t, config.Title, ghCreateIssueService.issue.GetTitle())
assert.Empty(t, ghCreateIssueService.issue.GetAssignees())
})
t.Run("Success update existing", func(t *testing.T) {
// init
filesMock := mock.FilesMock{}
ghSearchIssuesMock := ghSearchIssuesMock{
issueID: 1,
}
ghCreateCommentMock := ghCreateCommentMock{}
config := githubCreateIssueOptions{
Owner: "TEST",
Repository: "test",
Body: "This is my test body",
Title: "This is my title",
Assignees: []string{"userIdOne", "userIdTwo"},
UpdateExisting: true,
}
// test
err := runGithubCreateIssue(ctx, &config, nil, nil, &ghSearchIssuesMock, &ghCreateCommentMock, filesMock.FileRead)
// assert
assert.NoError(t, err)
assert.NotNil(t, ghSearchIssuesMock.issuesSearchResult)
assert.NotNil(t, ghCreateCommentMock.issueComment)
assert.Equal(t, config.Title, ghSearchIssuesMock.issueTitle)
assert.Equal(t, config.Title, *ghSearchIssuesMock.issuesSearchResult.Issues[0].Title)
assert.Equal(t, config.Body, ghCreateCommentMock.issueComment.GetBody())
})
t.Run("Error", func(t *testing.T) {
// init
filesMock := mock.FilesMock{}
ghCreateIssueService := ghCreateIssueMock{
issueError: fmt.Errorf("error creating issue"),
}
config := githubCreateIssueOptions{
Body: "test content",
}
// test
err := runGithubCreateIssue(ctx, &config, nil, &ghCreateIssueService, nil, nil, filesMock.FileRead)
// assert
assert.EqualError(t, err, "error occurred when creating issue: error creating issue")
assert.Equal(t, config.Token, options.Token)
assert.Equal(t, config.APIURL, options.APIURL)
assert.Equal(t, config.Owner, options.Owner)
assert.Equal(t, config.Repository, options.Repository)
assert.Equal(t, []byte("Test markdown"), options.Body)
assert.Equal(t, config.Title, options.Title)
assert.Equal(t, config.Assignees, options.Assignees)
assert.Equal(t, config.UpdateExisting, options.UpdateExisting)
})
t.Run("Error - missing issue body", func(t *testing.T) {
// init
filesMock := mock.FilesMock{}
ghCreateIssueService := ghCreateIssueMock{}
ghSearchIssuesMock := ghSearchIssuesMock{}
ghCreateCommentMock := ghCreateCommentMock{}
config := githubCreateIssueOptions{}
options := piperGithub.CreateIssueOptions{}
// test
err := runGithubCreateIssue(ctx, &config, nil, &ghCreateIssueService, &ghSearchIssuesMock, &ghCreateCommentMock, filesMock.FileRead)
err := transformConfig(&config, &options, filesMock.FileRead)
// assert
assert.EqualError(t, err, "either parameter `body` or parameter `bodyFilePath` is required")
})
t.Run("Error - missing body file", func(t *testing.T) {
// init
filesMock := mock.FilesMock{}
ghCreateIssueService := ghCreateIssueMock{}
config := githubCreateIssueOptions{
BodyFilePath: "test.md",
}
// test
err := runGithubCreateIssue(ctx, &config, nil, &ghCreateIssueService, nil, nil, filesMock.FileRead)
// assert
assert.Contains(t, fmt.Sprint(err), "failed to read file 'test.md'")
})
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
piperGithub "github.com/SAP/jenkins-library/pkg/github"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/piperutils"
"github.com/SAP/jenkins-library/pkg/reporting"
@@ -129,7 +130,7 @@ func WriteJSONReport(jsonReport FortifyReportData) ([]piperutils.Path, error) {
return reportPaths, nil
}
func WriteCustomReports(scanReport reporting.ScanReport, projectName string, projectVersion string) ([]piperutils.Path, error) {
func WriteCustomReports(scanReport reporting.ScanReport) ([]piperutils.Path, error) {
utils := piperutils.Files{}
reportPaths := []piperutils.Path{}
@@ -146,24 +147,33 @@ func WriteCustomReports(scanReport reporting.ScanReport, projectName string, pro
}
reportPaths = append(reportPaths, piperutils.Path{Name: "Fortify Report", Target: htmlReportPath})
// JSON reports are used by step pipelineCreateSummary in order to e.g. prepare an issue creation in GitHub
// ignore JSON errors since structure is in our hands
jsonReport, _ := scanReport.ToJSON()
if exists, _ := utils.DirExists(reporting.StepReportDirectory); !exists {
err := utils.MkdirAll(reporting.StepReportDirectory, 0777)
if err != nil {
return reportPaths, errors.Wrap(err, "failed to create reporting directory")
}
}
if err := utils.FileWrite(filepath.Join(reporting.StepReportDirectory, fmt.Sprintf("fortifyExecuteScan_sast_%v.json", reportShaFortify([]string{projectName, projectVersion}))), jsonReport, 0666); err != nil {
return reportPaths, errors.Wrapf(err, "failed to write json report")
}
// we do not add the json report to the overall list of reports for now,
// since it is just an intermediary report used as input for later
// and there does not seem to be real benefit in archiving it.
return reportPaths, nil
}
func UploadReportToGithub(scanReport reporting.ScanReport, token, APIURL, owner, repository string, assignees []string) error {
// JSON reports are used by step pipelineCreateSummary in order to e.g. prepare an issue creation in GitHub
// ignore JSON errors since structure is in our hands
markdownReport, _ := scanReport.ToMarkdown()
options := piperGithub.CreateIssueOptions{
Token: token,
APIURL: APIURL,
Owner: owner,
Repository: repository,
Title: "Fortify SAST Results",
Body: markdownReport,
Assignees: assignees,
UpdateExisting: true,
}
err := piperGithub.CreateIssue(&options)
if err != nil {
return errors.Wrap(err, "failed to upload fortify results into GitHub issue")
}
return nil
}
func reportShaFortify(parts []string) string {
reportShaData := []byte(strings.Join(parts, ","))
return fmt.Sprintf("%x", sha1.Sum(reportShaData))

View File

@@ -2,13 +2,40 @@ package github
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/google/go-github/v32/github"
"github.com/pkg/errors"
"golang.org/x/oauth2"
)
type githubCreateIssueService interface {
Create(ctx context.Context, owner string, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error)
}
type githubSearchIssuesService interface {
Issues(ctx context.Context, query string, opts *github.SearchOptions) (*github.IssuesSearchResult, *github.Response, error)
}
type githubCreateCommentService interface {
CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
}
// CreateIssueOptions to configure the creation
type CreateIssueOptions struct {
APIURL string `json:"apiUrl,omitempty"`
Assignees []string `json:"assignees,omitempty"`
Body []byte `json:"body,omitempty"`
Owner string `json:"owner,omitempty"`
Repository string `json:"repository,omitempty"`
Title string `json:"title,omitempty"`
UpdateExisting bool `json:"updateExisting,omitempty"`
Token string `json:"token,omitempty"`
}
//NewClient creates a new GitHub client using an OAuth token for authentication
func NewClient(token, apiURL, uploadURL string) (context.Context, *github.Client, error) {
ctx := context.Background()
@@ -39,3 +66,73 @@ func NewClient(token, apiURL, uploadURL string) (context.Context, *github.Client
client.UploadURL = uploadTargetURL
return ctx, client, nil
}
func CreateIssue(ghCreateIssueOptions *CreateIssueOptions) error {
ctx, client, err := NewClient(ghCreateIssueOptions.Token, ghCreateIssueOptions.APIURL, "")
if err != nil {
return errors.Wrap(err, "failed to get GitHub client")
}
return createIssueLocal(ctx, ghCreateIssueOptions, client.Issues, client.Search, client.Issues)
}
func createIssueLocal(ctx context.Context, ghCreateIssueOptions *CreateIssueOptions, ghCreateIssueService githubCreateIssueService, ghSearchIssuesService githubSearchIssuesService, ghCreateCommentService githubCreateCommentService) error {
issue := github.IssueRequest{
Title: &ghCreateIssueOptions.Title,
}
var bodyString string
if len(ghCreateIssueOptions.Body) > 0 {
bodyString = string(ghCreateIssueOptions.Body)
} else {
bodyString = ""
}
issue.Body = &bodyString
if len(ghCreateIssueOptions.Assignees) > 0 {
issue.Assignees = &ghCreateIssueOptions.Assignees
} else {
issue.Assignees = &[]string{}
}
var existingIssue *github.Issue = nil
if ghCreateIssueOptions.UpdateExisting {
queryString := fmt.Sprintf("is:open is:issue repo:%v/%v in:title %v", ghCreateIssueOptions.Owner, ghCreateIssueOptions.Repository, ghCreateIssueOptions.Title)
searchResult, resp, err := ghSearchIssuesService.Issues(ctx, queryString, nil)
if err != nil {
if resp != nil {
log.Entry().Errorf("GitHub response code %v", resp.Status)
}
return errors.Wrap(err, "error occurred when looking for existing issue")
} else {
for _, value := range searchResult.Issues {
if value != nil && *value.Title == ghCreateIssueOptions.Title {
existingIssue = value
}
}
}
if existingIssue != nil {
comment := &github.IssueComment{Body: issue.Body}
_, resp, err := ghCreateCommentService.CreateComment(ctx, ghCreateIssueOptions.Owner, ghCreateIssueOptions.Repository, *existingIssue.Number, comment)
if err != nil {
if resp != nil {
log.Entry().Errorf("GitHub response code %v", resp.Status)
}
return errors.Wrap(err, "error occurred when looking for existing issue")
}
}
}
if existingIssue == nil {
newIssue, resp, err := ghCreateIssueService.Create(ctx, ghCreateIssueOptions.Owner, ghCreateIssueOptions.Repository, &issue)
if err != nil {
if resp != nil {
log.Entry().Errorf("GitHub response code %v", resp.Status)
}
return errors.Wrap(err, "error occurred when creating issue")
}
log.Entry().Debugf("New issue created: %v", newIssue)
}
return nil
}

203
pkg/github/github_test.go Normal file
View File

@@ -0,0 +1,203 @@
package github
import (
"context"
"fmt"
"net/http"
"regexp"
"testing"
"github.com/google/go-github/v32/github"
"github.com/stretchr/testify/assert"
)
type ghCreateIssueMock struct {
issue *github.IssueRequest
issueID int64
issueError error
owner string
repo string
number int
assignees []string
}
func (g *ghCreateIssueMock) Create(ctx context.Context, owner string, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
g.issue = issue
g.owner = owner
g.repo = repo
g.assignees = *issue.Assignees
issueResponse := github.Issue{ID: &g.issueID, Title: issue.Title, Body: issue.Body}
ghRes := github.Response{Response: &http.Response{Status: "200"}}
if g.issueError != nil {
ghRes.Status = "401"
}
return &issueResponse, &ghRes, g.issueError
}
type ghSearchIssuesMock struct {
issueID int64
issueNumber int
issueTitle string
issueBody string
issuesSearchResult *github.IssuesSearchResult
issuesSearchError error
}
func (g *ghSearchIssuesMock) Issues(ctx context.Context, query string, opts *github.SearchOptions) (*github.IssuesSearchResult, *github.Response, error) {
regex := regexp.MustCompile(`.*in:title (?P<Title>(.*))`)
matches := regex.FindStringSubmatch(query)
g.issueTitle = matches[1]
issues := []*github.Issue{
{
ID: &g.issueID,
Number: &g.issueNumber,
Title: &g.issueTitle,
Body: &g.issueBody,
},
}
total := len(issues)
incompleteResults := false
g.issuesSearchResult = &github.IssuesSearchResult{
Issues: issues,
Total: &total,
IncompleteResults: &incompleteResults,
}
ghRes := github.Response{Response: &http.Response{Status: "200"}}
if g.issuesSearchError != nil {
ghRes.Status = "401"
}
return g.issuesSearchResult, &ghRes, g.issuesSearchError
}
type ghCreateCommentMock struct {
issueComment *github.IssueComment
issueCommentError error
}
func (g *ghCreateCommentMock) CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
g.issueComment = comment
ghRes := github.Response{Response: &http.Response{Status: "200"}}
if g.issueCommentError != nil {
ghRes.Status = "401"
}
return g.issueComment, &ghRes, g.issueCommentError
}
func TestRunGithubCreateIssue(t *testing.T) {
ctx := context.Background()
t.Parallel()
t.Run("Success", func(t *testing.T) {
// init
ghCreateIssueService := ghCreateIssueMock{
issueID: 1,
}
ghSearchIssuesMock := ghSearchIssuesMock{
issueID: 1,
}
ghCreateCommentMock := ghCreateCommentMock{}
config := CreateIssueOptions{
Owner: "TEST",
Repository: "test",
Body: []byte("This is my test body"),
Title: "This is my title",
Assignees: []string{"userIdOne", "userIdTwo"},
}
// test
err := createIssueLocal(ctx, &config, &ghCreateIssueService, &ghSearchIssuesMock, &ghCreateCommentMock)
// assert
assert.NoError(t, err)
assert.Equal(t, config.Owner, ghCreateIssueService.owner)
assert.Equal(t, config.Repository, ghCreateIssueService.repo)
assert.Equal(t, "This is my test body", ghCreateIssueService.issue.GetBody())
assert.Equal(t, config.Title, ghCreateIssueService.issue.GetTitle())
assert.Equal(t, config.Assignees, ghCreateIssueService.issue.GetAssignees())
assert.Nil(t, ghSearchIssuesMock.issuesSearchResult)
assert.Nil(t, ghCreateCommentMock.issueComment)
})
t.Run("Success update existing", func(t *testing.T) {
// init
ghSearchIssuesMock := ghSearchIssuesMock{
issueID: 1,
}
ghCreateCommentMock := ghCreateCommentMock{}
config := CreateIssueOptions{
Owner: "TEST",
Repository: "test",
Body: []byte("This is my test body"),
Title: "This is my title",
Assignees: []string{"userIdOne", "userIdTwo"},
UpdateExisting: true,
}
// test
err := createIssueLocal(ctx, &config, nil, &ghSearchIssuesMock, &ghCreateCommentMock)
// assert
assert.NoError(t, err)
assert.NotNil(t, ghSearchIssuesMock.issuesSearchResult)
assert.NotNil(t, ghCreateCommentMock.issueComment)
assert.Equal(t, config.Title, ghSearchIssuesMock.issueTitle)
assert.Equal(t, config.Title, *ghSearchIssuesMock.issuesSearchResult.Issues[0].Title)
assert.Equal(t, "This is my test body", ghCreateCommentMock.issueComment.GetBody())
})
t.Run("Empty body", func(t *testing.T) {
// init
ghCreateIssueService := ghCreateIssueMock{
issueID: 1,
}
ghSearchIssuesMock := ghSearchIssuesMock{
issueID: 1,
}
ghCreateCommentMock := ghCreateCommentMock{}
config := CreateIssueOptions{
Owner: "TEST",
Repository: "test",
Body: []byte(""),
Title: "This is my title",
Assignees: []string{"userIdOne", "userIdTwo"},
UpdateExisting: true,
}
// test
err := createIssueLocal(ctx, &config, &ghCreateIssueService, &ghSearchIssuesMock, &ghCreateCommentMock)
// assert
assert.NoError(t, err)
assert.NotNil(t, ghSearchIssuesMock.issuesSearchResult)
assert.NotNil(t, ghCreateCommentMock.issueComment)
assert.Equal(t, config.Title, ghSearchIssuesMock.issueTitle)
assert.Equal(t, config.Title, *ghSearchIssuesMock.issuesSearchResult.Issues[0].Title)
assert.Equal(t, "", ghCreateCommentMock.issueComment.GetBody())
})
t.Run("Create error", func(t *testing.T) {
// init
ghCreateIssueService := ghCreateIssueMock{
issueError: fmt.Errorf("error creating issue"),
}
config := CreateIssueOptions{
Body: []byte("test content"),
}
// test
err := createIssueLocal(ctx, &config, &ghCreateIssueService, nil, nil)
// assert
assert.EqualError(t, err, "error occurred when creating issue: error creating issue")
})
}

View File

@@ -44,6 +44,15 @@ spec:
- PARAMETERS
- STAGES
- STEPS
- name: assignees
description: Defines the assignees for the Github Issue created/updated with the results of the scan.
scope:
- PARAMETERS
- STAGES
- STEPS
type: "[]string"
default: []
mandatory: false
- name: authToken
type: string
description: "The FortifyToken to use for authentication"
@@ -597,6 +606,18 @@ spec:
- STEPS
- STAGES
- PARAMETERS
- name: createResultIssue
type: bool
description: "Whether the step creates a GitHub issue containing the scan results in the originating repo.
Since optimized pipelines are headless the creation is implicitly activated for schedules runs."
resourceRef:
- name: commonPipelineEnvironment
param: custom/optimizedAndScheduled
scope:
- PARAMETERS
- STAGES
- STEPS
default: false
containers:
- image: ""
outputs:

View File

@@ -13,6 +13,6 @@ void call(Map parameters = [:]) {
final script = checkScript(this, parameters) ?: this
parameters = DownloadCacheUtils.injectDownloadCacheInParameters(script, parameters, BuildTool.MAVEN)
List credentials = [[type: 'token', id: 'fortifyCredentialsId', env: ['PIPER_authToken']]]
List credentials = [[type: 'token', id: 'fortifyCredentialsId', env: ['PIPER_authToken']], [type: 'token', id: 'githubTokenCredentialsId', env: ['PIPER_githubToken']]]
piperExecuteBin(parameters, STEP_NAME, METADATA_FILE, credentials)
}