diff --git a/go.mod b/go.mod index 00b2dc0eb..f69badcc8 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,8 @@ require ( github.com/jfrog/build-info-go v1.13.1-0.20260811071930-3b99d4a6c84b github.com/jfrog/froggit-go v1.23.1 github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260813123937-e7fa28b75506 - github.com/jfrog/jfrog-cli-security v1.34.0 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814125750-77deb9f6259c + github.com/jfrog/jfrog-cli-security v1.34.1 github.com/jfrog/jfrog-client-go v1.55.1-0.20260813100550-0f2168d02558 github.com/owenrumney/go-sarif/v3 v3.2.3 github.com/stretchr/testify v1.11.1 @@ -64,7 +64,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.8.3 // indirect github.com/jfrog/archiver/v3 v3.6.4 // indirect github.com/jfrog/jfrog-apps-config v1.0.1 // indirect - github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260812053557-d311dd344da9 // indirect + github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260816162502-d13e3b54f42a // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -130,7 +130,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -// replace github.com/jfrog/jfrog-cli-security => github.com/jfog/jfrog-cli-security dev +// replace github.com/jfrog/jfrog-cli-security => github.com/jfrog/jfrog-cli-security main // replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 dev diff --git a/go.sum b/go.sum index 3b7f9d30e..c67253a8d 100644 --- a/go.sum +++ b/go.sum @@ -150,12 +150,12 @@ github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260812053557-d311dd344da9 h1:6N87yf7y1Zce1DvqZ1zX1sWirdJ7lh04mFvjr1JhVL0= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260812053557-d311dd344da9/go.mod h1:eYyYY+GMdddy75/rCWiKg8EydGp/jRY+9+30QkGpbGw= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260813123937-e7fa28b75506 h1:LgAoLyXkXzQ3AaJ10YVq1HAl38vVy75kFElDb6nEgEM= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260813123937-e7fa28b75506/go.mod h1:gf7aUg/G9JyltCNhwMD5RVEsFzUCKPWKXRcTXSqMYBk= -github.com/jfrog/jfrog-cli-security v1.34.0 h1:M4av+ff+EfSTZ7pK85Mm2fWlllNbHS0ndB4tZRRNXc8= -github.com/jfrog/jfrog-cli-security v1.34.0/go.mod h1:J+GxgmNuyCZxb0BJnXbPQq1NEbtyWKMJR9kGwN2VJHE= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260816162502-d13e3b54f42a h1:yjP7YAAmoxgyZ4NXh6IDkStTs+SnFeRtBJR+g8OJbr4= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260816162502-d13e3b54f42a/go.mod h1:eYyYY+GMdddy75/rCWiKg8EydGp/jRY+9+30QkGpbGw= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814125750-77deb9f6259c h1:H77lybOSbPe2T70+l0Q9zy7eXWLbLj4ph2R2Svahf5A= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814125750-77deb9f6259c/go.mod h1:gf7aUg/G9JyltCNhwMD5RVEsFzUCKPWKXRcTXSqMYBk= +github.com/jfrog/jfrog-cli-security v1.34.1 h1:nQvgkYPDYd5jRgiu6Ik/MfnT+yLIxtao1tzJwxDRjzI= +github.com/jfrog/jfrog-cli-security v1.34.1/go.mod h1:w4TOF29WJugpKYPKjfWpSlyvN2xusUHnUEl7KC57LCI= github.com/jfrog/jfrog-client-go v1.55.1-0.20260813100550-0f2168d02558 h1:/4ayHXxzgyZ9f66EqImCyZr2SKUpygU1P36sDVAlskM= github.com/jfrog/jfrog-client-go v1.55.1-0.20260813100550-0f2168d02558/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= diff --git a/scanpullrequest/scanpullrequest.go b/scanpullrequest/scanpullrequest.go index da8d2970b..168d44888 100644 --- a/scanpullrequest/scanpullrequest.go +++ b/scanpullrequest/scanpullrequest.go @@ -4,17 +4,21 @@ import ( "context" "errors" "fmt" - "github.com/jfrog/gofrog/datastructures" "os" "path/filepath" + "regexp" + "slices" "strings" + "github.com/jfrog/gofrog/datastructures" + "github.com/jfrog/froggit-go/vcsclient" "github.com/jfrog/froggit-go/vcsutils" "github.com/jfrog/jfrog-cli-security/utils/formats" "github.com/jfrog/jfrog-cli-security/utils/jasutils" "github.com/jfrog/jfrog-cli-security/utils/results" "github.com/jfrog/jfrog-cli-security/utils/results/conversion" + "github.com/jfrog/jfrog-cli-security/utils/techutils" "github.com/jfrog/jfrog-cli-security/utils/xsc" "github.com/jfrog/jfrog-client-go/utils/log" @@ -28,6 +32,19 @@ const ( analyticsScanPrScanType = "PR" vulnerabilitiesFilteringErrorMessage = "%s scan has completed with errors. Vulnerabilities results will be removed from final report" violationsFilteringErrorMessage = "%s scan has completed with errors. Violations results will be removed from final report" + // Errors below are only surfaced when Maven or Gradle is detected (see blockRiskyTechsWithoutEnvironmentGuard) + noGitHubEnvErr = "frogbot did not scan this PR, because a GitHub Environment named 'frogbot' does not exist. Please refer to the Frogbot documentation for instructions on how to create the Environment" + noGitHubEnvReviewersErr = "frogbot did not scan this PR, because the existing GitHub Environment named 'frogbot' doesn't have reviewers selected. Please refer to the Frogbot documentation for instructions on how to create the Environment" + noGitHubEnvInWorkflowErr = "frogbot did not scan this PR, because the workflow file does not set 'environment: frogbot'. Please refer to the Frogbot documentation for instructions on how to configure the Environment" +) + +var ( + // Matches the inline string form: environment: frogbot / environment: "frogbot" / environment: 'frogbot' + frogbotInlineEnvPattern = regexp.MustCompile(`(?m)^\s*environment\s*:\s*['"]?frogbot['"]?\s*(#.*)?$`) + // Matches the object form, when 'name' is the line immediately following 'environment:': + // environment: + // name: frogbot + frogbotObjectEnvPattern = regexp.MustCompile(`(?m)^\s*environment\s*:\s*(#.*)?\n\s*name\s*:\s*['"]?frogbot['"]?\s*(#.*)?$`) ) // targetPair represents a matched pair of source and target scan results @@ -102,12 +119,101 @@ func createBaseScanDetails(repoConfig *utils.Repository, client vcsclient.VcsCli if err != nil { return } - return utils.NewScanDetails(client, &repoConfig.Server, &repoConfig.Params.Git). + scanDetails = utils.NewScanDetails(client, &repoConfig.Server, &repoConfig.Params.Git). SetJfrogVersions(repoConfig.Params.XrayVersion, repoConfig.Params.XscVersion). SetResultsContext(repositoryCloneUrl, repoConfig.Params.JFrogPlatform.JFrogProjectKey, false). SetConfigProfile(repoConfig.Params.ConfigProfile). SetDiffScan(true). - SetXscPRGitInfoContext(repoConfig.Params.Git.Project, client, repoConfig.Params.Git.PullRequestDetails), nil + SetXscPRGitInfoContext(repoConfig.Params.Git.Project, client, repoConfig.Params.Git.PullRequestDetails) + if repoConfig.Params.Git.GitProvider == vcsutils.GitHub { + scanDetails.SetRiskyTechEnvironmentGuard(blockRiskyTechsWithoutEnvironmentGuard(client, repoConfig)) + } + return scanDetails, nil +} + +func blockRiskyTechsWithoutEnvironmentGuard(client vcsclient.VcsClient, repoConfig *utils.Repository) func(detectedTechnologies []techutils.Technology) error { + return func(detectedTechnologies []techutils.Technology) error { + if !slices.Contains(detectedTechnologies, techutils.Maven) && !slices.Contains(detectedTechnologies, techutils.Gradle) { + return nil + } + return verifyGitHubFrogbotEnvironment(client, repoConfig) + } +} + +// Verify that the 'frogbot' GitHub environment was properly configured on the repository +func verifyGitHubFrogbotEnvironment(client vcsclient.VcsClient, repoConfig *utils.Repository) error { + if repoConfig.Params.Git.APIEndpoint != "" && repoConfig.Params.Git.APIEndpoint != "https://api.github.com" { + // Don't verify 'frogbot' environment on GitHub on-prem + return nil + } + if _, exist := os.LookupEnv(utils.GitHubActionsEnv); !exist { + // Don't verify 'frogbot' environment on non GitHub Actions CI + return nil + } + + // If the repository is not public, using 'frogbot' environment is not mandatory + repoInfo, err := client.GetRepositoryInfo(context.Background(), repoConfig.Params.Git.RepoOwner, repoConfig.Params.Git.RepoName) + if err != nil { + return err + } + if repoInfo.RepositoryVisibility != vcsclient.Public { + return nil + } + + // Get the 'frogbot' environment info and make sure it exists and includes reviewers + repoEnvInfo, err := client.GetRepositoryEnvironmentInfo(context.Background(), repoConfig.Params.Git.RepoOwner, repoConfig.Params.Git.RepoName, "frogbot") + if err != nil { + return errors.New(err.Error() + "\n" + noGitHubEnvErr) + } + if len(repoEnvInfo.Reviewers) == 0 { + return errors.New(noGitHubEnvReviewersErr) + } + + return verifyWorkflowContainsFrogbotEnvironment(client) +} + +// Fetches the workflow file that triggered Frogbot (via GITHUB_WORKFLOW_REF) and verifies it contains 'environment: frogbot'. +func verifyWorkflowContainsFrogbotEnvironment(client vcsclient.VcsClient) error { + workflowRef := os.Getenv(utils.GitHubWorkflowRefEnv) + if workflowRef == "" { + return nil + } + + // GITHUB_WORKFLOW_REF format: {owner}/{repo}/{path}@{ref} + // e.g. eranturgeman/jfrog-security-test-app/.github/workflows/frogbot-scan-pull-request.yml@refs/heads/main + // Note: the owner/repo here is the workflow's repo, which may differ from the scanned repo. + atIdx := strings.LastIndex(workflowRef, "@") + if atIdx == -1 { + return fmt.Errorf("failed verifying environment in workflow file: unexpected GITHUB_WORKFLOW_REF format, missing '@' separator: '%s'", workflowRef) + } + pathPart := workflowRef[:atIdx] + ref := workflowRef[atIdx+1:] + + // Parse owner, repo, and file path from "{owner}/{repo}/{path}" + parts := strings.SplitN(pathPart, "/", 3) + if len(parts) < 3 { + return fmt.Errorf("failed verifying environment in workflow file: unexpected GITHUB_WORKFLOW_REF format, expected '{owner}/{repo}/{path}' but got '%s'", pathPart) + } + owner, repo, filePath := parts[0], parts[1], parts[2] + + // Extract branch name from ref (e.g. "refs/heads/main" → "main") + branch := ref + if b, ok := strings.CutPrefix(ref, "refs/heads/"); ok { + branch = b + } else if b, ok := strings.CutPrefix(ref, "refs/tags/"); ok { + branch = b + } + + fileContent, _, err := client.DownloadFileFromRepo(context.Background(), owner, repo, branch, filePath) + if err != nil { + return fmt.Errorf("failed to fetch workflow file '%s' for environment verification: %s", filePath, err.Error()) + } + + content := string(fileContent) + if !frogbotInlineEnvPattern.MatchString(content) && !frogbotObjectEnvPattern.MatchString(content) { + return errors.New(noGitHubEnvInWorkflowErr) + } + return nil } func downloadSourceAndTarget(repoConfig *utils.Repository, scanDetails *utils.ScanDetails) (sourceBranchWd, targetBranchWd string, cleanup func() error, err error) { diff --git a/scanpullrequest/scanpullrequest_test.go b/scanpullrequest/scanpullrequest_test.go index e79fbfbc4..fcbc7420b 100644 --- a/scanpullrequest/scanpullrequest_test.go +++ b/scanpullrequest/scanpullrequest_test.go @@ -5,9 +5,6 @@ import ( "context" "errors" "fmt" - "github.com/CycloneDX/cyclonedx-go" - "github.com/jfrog/jfrog-cli-security/utils/formats/violationutils" - services2 "github.com/jfrog/jfrog-client-go/xsc/services" "net/http" "net/http/httptest" "os" @@ -16,10 +13,15 @@ import ( "testing" "time" + "github.com/CycloneDX/cyclonedx-go" + "github.com/jfrog/jfrog-cli-security/utils/formats/violationutils" + services2 "github.com/jfrog/jfrog-client-go/xsc/services" + "github.com/golang/mock/gomock" securityutils "github.com/jfrog/jfrog-cli-security/utils" "github.com/jfrog/jfrog-cli-security/utils/formats/sarifutils" "github.com/jfrog/jfrog-cli-security/utils/severityutils" + "github.com/jfrog/jfrog-cli-security/utils/techutils" "github.com/jfrog/jfrog-cli-security/utils/xsc" "github.com/jfrog/jfrog-client-go/xray/services" "github.com/owenrumney/go-sarif/v3/pkg/report/v210/sarif" @@ -69,6 +71,255 @@ func CreateMockVcsClient(t *testing.T) *testdata.MockVcsClient { return testdata.NewMockVcsClient(gomock.NewController(t)) } +var gitParams = &utils.Repository{ + OutputWriter: &outputwriter.SimplifiedOutput{}, + Params: utils.Params{ + Git: utils.Git{ + RepoOwner: "repo-owner", + Branches: []string{"master"}, + RepoName: "repo-name", + }, + }, +} + +func TestRiskyTechEnvironmentGuard(t *testing.T) { + expectEnvironmentConfigured := func(client *testdata.MockVcsClient) { + client.EXPECT().GetRepositoryInfo(context.Background(), gitParams.RepoOwner, gitParams.RepoName).Return(vcsclient.RepositoryInfo{}, nil) + client.EXPECT().GetRepositoryEnvironmentInfo(context.Background(), gitParams.RepoOwner, gitParams.RepoName, "frogbot").Return(vcsclient.RepositoryEnvironmentInfo{Reviewers: []string{"froggy"}}, nil) + } + + tests := []struct { + testName string + detected []techutils.Technology + setupMock func(client *testdata.MockVcsClient) + }{ + { + testName: "no maven or gradle detected - no-op", + detected: []techutils.Technology{techutils.Npm, techutils.Pip}, + }, + { + testName: "no technologies detected - no-op", + detected: nil, + }, + { + testName: "maven detected - delegates to environment verification", + detected: []techutils.Technology{techutils.Maven}, + setupMock: expectEnvironmentConfigured, + }, + { + testName: "gradle detected - delegates to environment verification", + detected: []techutils.Technology{techutils.Gradle}, + setupMock: expectEnvironmentConfigured, + }, + { + testName: "maven alongside other technologies - delegates to environment verification", + detected: []techutils.Technology{techutils.Npm, techutils.Maven}, + setupMock: expectEnvironmentConfigured, + }, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + // When setupMock is nil, no .EXPECT() is set on the mock: any VCS call fails the test, + // proving the guard is a pure no-op for non-Maven/Gradle technologies. + client := CreateMockVcsClient(t) + if test.setupMock != nil { + test.setupMock(client) + assert.NoError(t, os.Setenv(utils.GitHubActionsEnv, "true")) + assert.NoError(t, os.Unsetenv(utils.GitHubWorkflowRefEnv)) + } + + guard := blockRiskyTechsWithoutEnvironmentGuard(client, gitParams) + assert.NoError(t, guard(test.detected)) + }) + } +} + +func TestVerifyGitHubFrogbotEnvironment(t *testing.T) { + tests := []struct { + testName string + repoConfig *utils.Repository + setupMock func(client *testdata.MockVcsClient) + wantErrContains string + }{ + { + testName: "environment configured with reviewers", + repoConfig: gitParams, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().GetRepositoryInfo(context.Background(), gitParams.RepoOwner, gitParams.RepoName).Return(vcsclient.RepositoryInfo{}, nil) + client.EXPECT().GetRepositoryEnvironmentInfo(context.Background(), gitParams.RepoOwner, gitParams.RepoName, "frogbot").Return(vcsclient.RepositoryEnvironmentInfo{Reviewers: []string{"froggy"}}, nil) + }, + }, + { + testName: "environment does not exist", + repoConfig: gitParams, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().GetRepositoryInfo(context.Background(), gitParams.RepoOwner, gitParams.RepoName).Return(vcsclient.RepositoryInfo{}, nil) + client.EXPECT().GetRepositoryEnvironmentInfo(context.Background(), gitParams.RepoOwner, gitParams.RepoName, "frogbot").Return(vcsclient.RepositoryEnvironmentInfo{}, errors.New("404")) + }, + wantErrContains: noGitHubEnvErr, + }, + { + testName: "environment has no reviewers", + repoConfig: gitParams, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().GetRepositoryInfo(context.Background(), gitParams.RepoOwner, gitParams.RepoName).Return(vcsclient.RepositoryInfo{}, nil) + client.EXPECT().GetRepositoryEnvironmentInfo(context.Background(), gitParams.RepoOwner, gitParams.RepoName, "frogbot").Return(vcsclient.RepositoryEnvironmentInfo{}, nil) + }, + wantErrContains: noGitHubEnvReviewersErr, + }, + { + testName: "on-prem GitHub is skipped", + repoConfig: &utils.Repository{ + Params: utils.Params{Git: utils.Git{VcsInfo: vcsclient.VcsInfo{APIEndpoint: "https://acme.vcs.io"}}}, + }, + }, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + var client vcsclient.VcsClient + if test.setupMock != nil { + mockClient := CreateMockVcsClient(t) + test.setupMock(mockClient) + client = mockClient + } else { + client = &vcsclient.GitHubClient{} + } + assert.NoError(t, os.Setenv(utils.GitHubActionsEnv, "true")) + // Unset GITHUB_WORKFLOW_REF to avoid triggering the workflow file check + assert.NoError(t, os.Unsetenv(utils.GitHubWorkflowRefEnv)) + + err := verifyGitHubFrogbotEnvironment(client, test.repoConfig) + if test.wantErrContains != "" { + assert.ErrorContains(t, err, test.wantErrContains) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestVerifyWorkflowContainsFrogbotEnvironment(t *testing.T) { + workflowRef := "jfrog/frogbot/.github/workflows/frogbot.yml@refs/heads/main" + + tests := []struct { + testName string + workflowRefEnv string + setupMock func(client *testdata.MockVcsClient) + wantErrContains string + }{ + { + testName: "environment field present", + workflowRefEnv: workflowRef, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().DownloadFileFromRepo(context.Background(), "jfrog", "frogbot", "main", ".github/workflows/frogbot.yml"). + Return([]byte("jobs:\n scan:\n environment: frogbot\n"), 200, nil) + }, + }, + { + testName: "environment field missing", + workflowRefEnv: workflowRef, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().DownloadFileFromRepo(context.Background(), "jfrog", "frogbot", "main", ".github/workflows/frogbot.yml"). + Return([]byte("jobs:\n scan:\n runs-on: ubuntu-latest\n"), 200, nil) + }, + wantErrContains: noGitHubEnvInWorkflowErr, + }, + { + testName: "environment field with a different name is rejected, not treated as a prefix match", + workflowRefEnv: workflowRef, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().DownloadFileFromRepo(context.Background(), "jfrog", "frogbot", "main", ".github/workflows/frogbot.yml"). + Return([]byte("jobs:\n scan:\n environment: frogbot-staging\n"), 200, nil) + }, + wantErrContains: noGitHubEnvInWorkflowErr, + }, + { + testName: "environment field double-quoted", + workflowRefEnv: workflowRef, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().DownloadFileFromRepo(context.Background(), "jfrog", "frogbot", "main", ".github/workflows/frogbot.yml"). + Return([]byte(`jobs: + scan: + environment: "frogbot" +`), 200, nil) + }, + }, + { + testName: "environment field single-quoted", + workflowRefEnv: workflowRef, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().DownloadFileFromRepo(context.Background(), "jfrog", "frogbot", "main", ".github/workflows/frogbot.yml"). + Return([]byte("jobs:\n scan:\n environment: 'frogbot'\n"), 200, nil) + }, + }, + { + testName: "environment field with trailing comment", + workflowRefEnv: workflowRef, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().DownloadFileFromRepo(context.Background(), "jfrog", "frogbot", "main", ".github/workflows/frogbot.yml"). + Return([]byte("jobs:\n scan:\n environment: frogbot # requires approval\n"), 200, nil) + }, + }, + { + testName: "environment field in object form with name on the next line", + workflowRefEnv: workflowRef, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().DownloadFileFromRepo(context.Background(), "jfrog", "frogbot", "main", ".github/workflows/frogbot.yml"). + Return([]byte("jobs:\n scan:\n environment:\n name: frogbot\n"), 200, nil) + }, + }, + { + testName: "environment field in object form with quoted name on the next line", + workflowRefEnv: workflowRef, + setupMock: func(client *testdata.MockVcsClient) { + client.EXPECT().DownloadFileFromRepo(context.Background(), "jfrog", "frogbot", "main", ".github/workflows/frogbot.yml"). + Return([]byte(`jobs: + scan: + environment: + name: "frogbot" + url: https://internal-dashboard.example.com +`), 200, nil) + }, + }, + { + testName: "workflow ref not set", + workflowRefEnv: "", + }, + { + testName: "workflow ref missing '@' separator - fails closed", + workflowRefEnv: "jfrog/frogbot/.github/workflows/frogbot.yml", + wantErrContains: "failed verifying environment in workflow file", + }, + { + testName: "workflow ref missing owner/repo/path segments - fails closed", + workflowRefEnv: "frogbot@refs/heads/main", + wantErrContains: "failed verifying environment in workflow file", + }, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + // When setupMock is nil, no .EXPECT() is set on the mock: any VCS call fails the test, + // proving these ref formats are rejected before any file is fetched. + client := CreateMockVcsClient(t) + if test.setupMock != nil { + test.setupMock(client) + } + if test.workflowRefEnv == "" { + assert.NoError(t, os.Unsetenv(utils.GitHubWorkflowRefEnv)) + } else { + assert.NoError(t, os.Setenv(utils.GitHubWorkflowRefEnv, test.workflowRefEnv)) + } + + err := verifyWorkflowContainsFrogbotEnvironment(client) + if test.wantErrContains != "" { + assert.ErrorContains(t, err, test.wantErrContains) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestScanResultsToIssuesCollection(t *testing.T) { auditResults := &results.SecurityCommandResults{ResultsMetaData: results.ResultsMetaData{Entitlements: results.Entitlements{Jas: true}, ResultContext: results.ResultContext{IncludeVulnerabilities: true}}, Targets: []*results.TargetResults{{ ResultsStatus: results.ResultsStatus{ diff --git a/utils/consts.go b/utils/consts.go index c4c8d4291..b44c6b899 100644 --- a/utils/consts.go +++ b/utils/consts.go @@ -51,6 +51,11 @@ const ( GitlabScanResultsOutputDirEnv = "JF_SCAN_RESULTS_OUTPUT_DIR" GitWorkspaceEnv = "JF_GIT_WORKSPACE" + // The 'GITHUB_ACTIONS' environment variable exists when the CI is GitHub Actions + GitHubActionsEnv = "GITHUB_ACTIONS" + // The 'GITHUB_WORKFLOW_REF' environment variable contains the ref path to the workflow file, e.g. owner/repo/.github/workflows/frogbot.yml@refs/heads/main + GitHubWorkflowRefEnv = "GITHUB_WORKFLOW_REF" + // Placeholders for templates PackagePlaceHolder = "{IMPACTED_PACKAGE}" FixVersionPlaceHolder = "{FIX_VERSION}" diff --git a/utils/scandetails.go b/utils/scandetails.go index e3b83705c..45aac166b 100644 --- a/utils/scandetails.go +++ b/utils/scandetails.go @@ -15,6 +15,7 @@ import ( "github.com/jfrog/jfrog-cli-security/sca/bom/xrayplugin/plugin" "github.com/jfrog/jfrog-cli-security/sca/scan/enrich" "github.com/jfrog/jfrog-cli-security/utils/results" + "github.com/jfrog/jfrog-cli-security/utils/techutils" "github.com/jfrog/jfrog-client-go/utils/log" xscservices "github.com/jfrog/jfrog-client-go/xsc/services" ) @@ -29,6 +30,8 @@ type ScanDetails struct { diffScan bool ResultsToCompare *results.SecurityCommandResults ConfigProfile *xscservices.ConfigProfile + // Optional PR-scan-only guard, invoked with the technologies detected once per Audit() call. Set only for GitHub PR scans, nil everywhere else. + riskyTechEnvironmentGuard func(detectedTechnologies []techutils.Technology) error results.ResultContext MultiScanId string @@ -74,6 +77,11 @@ func (sc *ScanDetails) SetConfigProfile(configProfile *xscservices.ConfigProfile return sc } +func (sc *ScanDetails) SetRiskyTechEnvironmentGuard(guard func(detectedTechnologies []techutils.Technology) error) *ScanDetails { + sc.riskyTechEnvironmentGuard = guard + return sc +} + func (sc *ScanDetails) Client() vcsclient.VcsClient { return sc.client } @@ -116,7 +124,8 @@ func (sc *ScanDetails) Audit(baseDir string) (auditResults *results.SecurityComm SetMultiScanId(sc.MultiScanId). SetThreads(MaxConcurrentScanners). SetStartTime(sc.StartTime). - SetViolationGenerator(enforcer.NewPolicyEnforcerViolationGenerator()) + SetViolationGenerator(enforcer.NewPolicyEnforcerViolationGenerator()). + SetDetectedTechnologiesGuardCallback(sc.riskyTechEnvironmentGuard) return audit.RunAudit(auditParams) }