diff --git a/checks/code_review_test.go b/checks/code_review_test.go index 4c536cf6430..db25e5477a8 100644 --- a/checks/code_review_test.go +++ b/checks/code_review_test.go @@ -121,6 +121,55 @@ func TestCodereview(t *testing.T) { Score: 10, }, }, + { + name: "Prow PR with both lgtm and approved labels - 2 approvals", + commits: []clients.Commit{ + { + SHA: "sha", + Committer: clients.User{ + Login: "alice", + }, + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: time.Now(), + Labels: []clients.Label{ + { + Name: "lgtm", + }, + { + Name: "approved", + }, + }, + }, + }, + }, + expected: scut.TestReturn{ + Score: 10, + }, + }, + { + name: "Prow PR with only approved label", + commits: []clients.Commit{ + { + SHA: "sha", + Committer: clients.User{ + Login: "bob", + }, + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: time.Now(), + Labels: []clients.Label{ + { + Name: "approved", + }, + }, + }, + }, + }, + expected: scut.TestReturn{ + Score: 10, + }, + }, { name: "Valid PR's and commits with merged by someone else", commits: []clients.Commit{ diff --git a/checks/raw/code_review.go b/checks/raw/code_review.go index c343bf16c18..cc1ff2429c8 100644 --- a/checks/raw/code_review.go +++ b/checks/raw/code_review.go @@ -62,6 +62,42 @@ func getGithubReviews(c *clients.Commit) (reviews []clients.Review) { return reviews } +func getProwReviews(c *clients.Commit) (reviews []clients.Review) { + reviews = []clients.Review{} + mr := c.AssociatedMergeRequest + + // Count Prow labels as approvals + // In Prow: lgtm = code review approval, approved = maintainer approval + hasLGTM := false + hasApproved := false + + for _, label := range mr.Labels { + if label.Name == "lgtm" { + hasLGTM = true + } + if label.Name == "approved" { + hasApproved = true + } + } + + // Create synthetic reviews from Prow labels + // This allows existing review counting logic to work with Prow + if hasLGTM { + reviews = append(reviews, clients.Review{ + Author: &clients.User{Login: "prow-lgtm"}, + State: "APPROVED", + }) + } + if hasApproved { + reviews = append(reviews, clients.Review{ + Author: &clients.User{Login: "prow-approved"}, + State: "APPROVED", + }) + } + + return reviews +} + func getGithubAuthor(c *clients.Commit) (author clients.User) { return c.AssociatedMergeRequest.Author } @@ -70,7 +106,7 @@ func getProwRevisionID(c *clients.Commit) string { mr := c.AssociatedMergeRequest if !c.AssociatedMergeRequest.MergedAt.IsZero() { for _, l := range c.AssociatedMergeRequest.Labels { - if l.Name == "lgtm" || l.Name == "approved" && mr.Number != 0 { + if (l.Name == "lgtm" || l.Name == "approved") && mr.Number != 0 { return strconv.Itoa(mr.Number) } } @@ -161,9 +197,13 @@ func getChangesets(commits []clients.Commit) []checker.Changeset { Commits: []clients.Commit{commits[i]}, } - if rev.Platform == checker.ReviewPlatformGitHub { + switch rev.Platform { + case checker.ReviewPlatformGitHub: newChangeset.Reviews = getGithubReviews(&commits[i]) newChangeset.Author = getGithubAuthor(&commits[i]) + case checker.ReviewPlatformProw: + newChangeset.Reviews = getProwReviews(&commits[i]) + newChangeset.Author = getGithubAuthor(&commits[i]) } changesetsByRevInfo[rev] = newChangeset diff --git a/checks/raw/prow_config.go b/checks/raw/prow_config.go new file mode 100644 index 00000000000..4a0b5cbe900 --- /dev/null +++ b/checks/raw/prow_config.go @@ -0,0 +1,183 @@ +// Copyright 2025 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package raw + +import ( + "errors" + "fmt" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/checks/fileparser" + "github.com/ossf/scorecard/v5/finding" +) + +var errProwInvalidArgs = errors.New("invalid arguments") + +// ProwConfig represents a Prow configuration file. +type ProwConfig struct { + Presubmits map[string][]ProwJob `yaml:"presubmits"` + Postsubmits map[string][]ProwJob `yaml:"postsubmits"` + Periodics []ProwJob `yaml:"periodics"` +} + +// ProwJob represents a single Prow job definition. +type ProwJob struct { + Name string `yaml:"name"` + Command []string `yaml:"command"` + Args []string `yaml:"args"` +} + +// CommandContainsSASTTool checks if a command/args contains SAST tool indicators. +// Uses the same pattern list as checkRun/status detection for consistency. +func CommandContainsSASTTool(command []string) bool { + commandStr := strings.ToLower(strings.Join(command, " ")) + // Reuse sastToolPatterns from sast.go for consistency + for _, pattern := range sastToolPatterns { + if strings.Contains(commandStr, pattern) { + return true + } + } + // Also check for generic "lint" which is common in commands + if strings.Contains(commandStr, "lint") { + return true + } + return false +} + +// logDebugProwf logs debug messages for Prow detection. +func logDebugProwf(c *checker.CheckRequest, format string, args ...interface{}) { + if c.Dlogger != nil { + c.Dlogger.Debug(&checker.LogMessage{ + Text: fmt.Sprintf("[Prow] "+format, args...), + }) + } +} + +// getProwSASTJobs scans local Prow config files for SAST tools. +// This mirrors the GitHub workflow scanning approach. +func getProwSASTJobs(c *checker.CheckRequest) ([]checker.SASTWorkflow, error) { + var configPaths []string + var sastWorkflows []checker.SASTWorkflow + + logDebugProwf(c, "Scanning for Prow configuration files...") + + // Scan common Prow config file patterns + patterns := []string{".prow.yaml", ".prow/*.yaml", "prow/*.yaml"} + + for _, pattern := range patterns { + logDebugProwf(c, "Scanning pattern: %s", pattern) + err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{ + Pattern: pattern, + CaseSensitive: false, + }, searchProwConfigForSAST, &configPaths) + if err != nil { + return sastWorkflows, err + } + } + + if len(configPaths) > 0 { + logDebugProwf(c, "Found %d Prow config file(s) with SAST tools:", len(configPaths)) + for _, path := range configPaths { + logDebugProwf(c, " ✓ %s", path) + } + } else { + logDebugProwf(c, "No Prow config files with SAST tools found") + } + + // Convert paths to SASTWorkflow objects + for _, path := range configPaths { + sastWorkflow := checker.SASTWorkflow{ + File: checker.File{ + Path: path, + Offset: checker.OffsetDefault, + Type: finding.FileTypeSource, + }, + Type: "Prow", + } + sastWorkflows = append(sastWorkflows, sastWorkflow) + } + + return sastWorkflows, nil +} + +// searchProwConfigForSAST searches a Prow config file for SAST tools. +var searchProwConfigForSAST fileparser.DoWhileTrueOnFileContent = func(path string, + content []byte, + args ...interface{}, +) (bool, error) { + if len(args) != 1 { + return false, fmt.Errorf("searchProwConfigForSAST requires exactly 1 argument: %w", errProwInvalidArgs) + } + + paths, ok := args[0].(*[]string) + if !ok { + return false, fmt.Errorf("searchProwConfigForSAST expects arg[0] of type *[]string: %w", errProwInvalidArgs) + } + + var config ProwConfig + if err := yaml.Unmarshal(content, &config); err != nil { + // Skip files that aren't valid Prow configs + return true, nil + } + + // Check all job types for SAST tools + hasSAST := false + + // Check presubmits + for _, jobs := range config.Presubmits { + for _, job := range jobs { + if jobContainsSAST(job) { + hasSAST = true + break + } + } + } + + // Check postsubmits + if !hasSAST { + for _, jobs := range config.Postsubmits { + for _, job := range jobs { + if jobContainsSAST(job) { + hasSAST = true + break + } + } + } + } + + // Check periodics + if !hasSAST { + for _, job := range config.Periodics { + if jobContainsSAST(job) { + hasSAST = true + break + } + } + } + + if hasSAST { + *paths = append(*paths, path) + } + + return true, nil +} + +// jobContainsSAST checks if a Prow job contains SAST tools. +func jobContainsSAST(job ProwJob) bool { + return CommandContainsSASTTool(job.Command) || CommandContainsSASTTool(job.Args) +} diff --git a/checks/raw/prow_config_test.go b/checks/raw/prow_config_test.go new file mode 100644 index 00000000000..5fbeddddf45 --- /dev/null +++ b/checks/raw/prow_config_test.go @@ -0,0 +1,192 @@ +// Copyright 2025 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package raw + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestCommandContainsSASTTool(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + command []string + expected bool + }{ + { + name: "golangci-lint command", + command: []string{"golangci-lint", "run"}, + expected: true, + }, + { + name: "codeql command", + command: []string{"codeql", "database", "analyze"}, + expected: true, + }, + { + name: "trivy security scan", + command: []string{"trivy", "fs", "--security-checks", "vuln", "."}, + expected: true, + }, + { + name: "regular build command", + command: []string{"make", "build"}, + expected: false, + }, + { + name: "test command", + command: []string{"go", "test", "./..."}, + expected: false, + }, + { + name: "shellcheck", + command: []string{"shellcheck", "scripts/*.sh"}, + expected: true, + }, + { + name: "gosec", + command: []string{"gosec", "./..."}, + expected: true, + }, + { + name: "empty command", + command: []string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := CommandContainsSASTTool(tt.command) + if result != tt.expected { + t.Errorf("CommandContainsSASTTool(%v) = %v, want %v", tt.command, result, tt.expected) + } + }) + } +} + +func TestJobContainsSAST(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + job ProwJob + expected bool + }{ + { + name: "job with SAST in command", + job: ProwJob{ + Name: "lint", + Command: []string{"golangci-lint", "run"}, + Args: []string{}, + }, + expected: true, + }, + { + name: "job with SAST in args", + job: ProwJob{ + Name: "security-scan", + Command: []string{"sh", "-c"}, + Args: []string{"trivy fs ."}, + }, + expected: true, + }, + { + name: "job without SAST", + job: ProwJob{ + Name: "build", + Command: []string{"make", "build"}, + Args: []string{}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := jobContainsSAST(tt.job) + if result != tt.expected { + t.Errorf("jobContainsSAST(%v) = %v, want %v", tt.job, result, tt.expected) + } + }) + } +} + +func TestProwConfigParsing(t *testing.T) { + t.Parallel() + + yamlContent := ` +presubmits: + org/repo: + - name: pull-lint + command: + - golangci-lint + - run + - name: pull-test + command: + - make + - test + +postsubmits: + org/repo: + - name: post-scan + command: + - trivy + - fs + - . + +periodics: + - name: nightly-check + command: + - codeql + - analyze +` + + var config ProwConfig + if err := yaml.Unmarshal([]byte(yamlContent), &config); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + // Check presubmits + if len(config.Presubmits["org/repo"]) != 2 { + t.Errorf("Expected 2 presubmits, got %d", len(config.Presubmits["org/repo"])) + } + + // Check postsubmits + if len(config.Postsubmits["org/repo"]) != 1 { + t.Errorf("Expected 1 postsubmit, got %d", len(config.Postsubmits["org/repo"])) + } + + // Check periodics + if len(config.Periodics) != 1 { + t.Errorf("Expected 1 periodic, got %d", len(config.Periodics)) + } + + // Check if SAST tools are detected + lintJob := config.Presubmits["org/repo"][0] + if !jobContainsSAST(lintJob) { + t.Error("Expected lint job to contain SAST tool") + } + + testJob := config.Presubmits["org/repo"][1] + if jobContainsSAST(testJob) { + t.Error("Expected test job to NOT contain SAST tool") + } +} diff --git a/checks/raw/sast.go b/checks/raw/sast.go index 77f11e9e1ae..143e6a150b0 100644 --- a/checks/raw/sast.go +++ b/checks/raw/sast.go @@ -17,12 +17,16 @@ package raw import ( "bufio" "bytes" + "context" "errors" "fmt" "io" + "net/http" "path" "regexp" "strings" + "sync" + "time" "github.com/rhysd/actionlint" @@ -45,12 +49,194 @@ var sastTools = map[string]bool{ "sonarqubecloud": true, } +// Common SAST tool name patterns to detect in CheckRun/Status names +// This enables detection of SAST tools running in any CI system (Prow, Jenkins, etc.) +var sastToolPatterns = []string{ + "codeql", + "sonar", + "snyk", + "semgrep", + "gosec", + "staticcheck", + "golangci", + "pylint", + "eslint", + "tslint", + "bandit", + "brakeman", + "flawfinder", + "shellcheck", + "trivy", + "grype", + "checkov", + "tfsec", + "kubesec", + "hadolint", + "safety", + "osv-scanner", + "govulncheck", + "bearer", + "horusec", + "fortify", + "checkmarx", + "veracode", + "coverity", + "insider", + "security-scan", + "static-analysis", + "sast", + "code-scanning", + "code-analysis", +} + +// sastToolExecutionSignatures maps tool names to their specific execution signatures. +// These are exact patterns that indicate the tool is actually running (not just mentioned). +// This significantly reduces false positives from test data, documentation, or logs. +var sastToolExecutionSignatures = map[string][]string{ + "codeql": { + "codeql database create", + "codeql database analyze", + "running codeql", + "+ codeql", + "codeql/analyze", + }, + "sonar": { + "sonar-scanner", + "mvn sonar:sonar", + "gradle sonar", + "sonarqube scanner", + "running sonar", + }, + "snyk": { + "snyk test", + "snyk monitor", + "snyk container test", + "snyk iac test", + "snyk code test", + "running snyk", + "+ snyk", + }, + "semgrep": { + "semgrep scan", + "semgrep ci", + "semgrep --config", + "running semgrep", + "+ semgrep", + }, + "gosec": { + "gosec ./...", + "gosec -fmt", + "running gosec", + "+ gosec", + "go run github.com/securego/gosec", + }, + "staticcheck": { + "staticcheck ./...", + "running staticcheck", + "+ staticcheck", + }, + "golangci": { + "golangci-lint run", + "running golangci-lint", + "+ golangci-lint", + "golangci/golangci-lint", + }, + "pylint": { + "pylint ", + "running pylint", + "+ pylint", + "python -m pylint", + }, + "eslint": { + "eslint .", + "eslint src", + "npm run eslint", + "running eslint", + "+ eslint", + }, + "bandit": { + "bandit -r", + "running bandit", + "+ bandit", + "python -m bandit", + }, + "brakeman": { + "brakeman -", + "bundle exec brakeman", + "running brakeman", + "+ brakeman", + }, + "shellcheck": { + "shellcheck ", + "running shellcheck", + "+ shellcheck", + }, + "trivy": { + "trivy scan", + "trivy fs", + "trivy image", + "trivy config", + "running trivy", + "+ trivy", + }, + "hadolint": { + "hadolint ", + "running hadolint", + "+ hadolint", + }, + "checkov": { + "checkov -d", + "checkov --directory", + "running checkov", + "+ checkov", + }, + "tfsec": { + "tfsec .", + "running tfsec", + "+ tfsec", + }, + "grype": { + "grype scan", + "grype dir:", + "running grype", + "+ grype", + }, + "osv-scanner": { + "osv-scanner ", + "running osv-scanner", + "+ osv-scanner", + }, + "govulncheck": { + "govulncheck ./...", + "running govulncheck", + "+ govulncheck", + "go run golang.org/x/vuln/cmd/govulncheck", + }, + "bearer": { + "bearer scan", + "running bearer", + "+ bearer", + // Explicitly NOT: "authorization: bearer" or "bearer token" + }, + "horusec": { + "horusec start", + "running horusec", + "+ horusec", + }, +} + var allowedConclusions = map[string]bool{"success": true, "neutral": true} // SAST checks for presence of static analysis tools. func SAST(c *checker.CheckRequest) (checker.SASTData, error) { var data checker.SASTData + if c.Dlogger != nil { + c.Dlogger.Debug(&checker.LogMessage{ + Text: "Starting SAST check - looking for static analysis tools...", + }) + } + commits, err := sastToolInCheckRuns(c) if err != nil { return data, err @@ -93,9 +279,372 @@ func SAST(c *checker.CheckRequest) (checker.SASTData, error) { } data.Workflows = append(data.Workflows, hadolintWorkflows...) + // Check Prow config files for SAST tools + prowJobs, err := getProwSASTJobs(c) + if err != nil { + return data, err + } + data.Workflows = append(data.Workflows, prowJobs...) + + if c.Dlogger != nil { + c.Dlogger.Debug(&checker.LogMessage{ + Text: fmt.Sprintf("SAST check complete: found %d workflow(s), analyzed %d commit(s)", + len(data.Workflows), len(data.Commits)), + }) + } + return data, nil } +// isSASTToolName checks if the given string contains a known SAST tool name pattern. +// This enables detection of SAST tools in CI systems like Prow, Jenkins, etc. +func isSASTToolName(s string) bool { + lower := strings.ToLower(s) + for _, pattern := range sastToolPatterns { + if strings.Contains(lower, pattern) { + return true + } + } + return false +} + +// findMatchingSASTPattern returns the matched pattern if found. +func findMatchingSASTPattern(s string) string { + lower := strings.ToLower(s) + for _, pattern := range sastToolPatterns { + if strings.Contains(lower, pattern) { + return pattern + } + } + return "" +} + +const ( + // Maximum size to fetch from Prow logs (1MB should be enough to detect tool signatures). + maxProwLogBytes = 1 * 1024 * 1024 + // HTTP timeout for fetching Prow logs. + prowLogTimeout = 10 * time.Second +) + +var errProwLogHTTP = errors.New("HTTP error fetching Prow log") + +// isProwJobURL checks if a URL points to an actual Prow job (with logs). +// This filters out Prow status contexts like /tide, /pr-history that don't have logs. +func isProwJobURL(url string) bool { + lower := strings.ToLower(url) + + // Must contain prow-related patterns + hasProw := strings.Contains(lower, "prow.") || + strings.Contains(lower, "/gs/") || + strings.Contains(lower, "storage.googleapis.com") + + if !hasProw { + return false + } + + // Exclude known non-job Prow URLs that don't have logs + excludePatterns := []string{ + "/tide", // Prow's merge automation (status only) + "/pr-history", // PR status history page + "/command-help", // Help pages + "/plugin-help", // Plugin documentation + } + + for _, pattern := range excludePatterns { + if strings.Contains(lower, pattern) { + return false + } + } + + // Include URLs that look like actual job runs + // These typically have: /view/gs/, /pr-logs/, /logs/, or job names + includePatterns := []string{ + "/view/gs/", + "/pr-logs/", + "/logs/", + "storage.googleapis.com", + } + + for _, pattern := range includePatterns { + if strings.Contains(lower, pattern) { + return true + } + } + + // If it has "prow." but doesn't match include patterns, it's likely not a job + return false +} + +// convertToLogURL attempts to convert a Prow view URL to direct log file URLs. +// Returns a list of potential log file URLs to try. +func convertToLogURL(prowURL string) []string { + var urls []string + + // If it's already a storage URL, try appending log files + if strings.Contains(prowURL, "storage.googleapis.com") { + urls = append(urls, prowURL+"/build-log.txt", prowURL+"/finished.json") + return urls + } + + // Convert prow.k8s.io/view/gs/... to storage.googleapis.com/... + if strings.Contains(prowURL, "/view/gs/") { + parts := strings.Split(prowURL, "/view/gs/") + if len(parts) == 2 { + storageURL := "https://storage.googleapis.com/" + parts[1] + urls = append(urls, storageURL+"/build-log.txt", storageURL+"/finished.json") + } + } + + // If no conversion worked, try the original URL with log file paths + if len(urls) == 0 { + urls = append(urls, prowURL+"/build-log.txt") + } + + return urls +} + +// fetchProwLog attempts to fetch and read a Prow log file with size limits and timeout. +func fetchProwLog(ctx context.Context, url string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, prowLogTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + client := &http.Client{ + Timeout: prowLogTimeout, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching log: %w", err) + } + defer resp.Body.Close() + + // Only accept 200 OK + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: status %d", errProwLogHTTP, resp.StatusCode) + } + + // Read with size limit + limitedReader := io.LimitReader(resp.Body, maxProwLogBytes) + content, err := io.ReadAll(limitedReader) + if err != nil { + return nil, fmt.Errorf("reading log: %w", err) + } + + return content, nil +} + +// isSASTToolExecution checks if a line indicates actual SAST tool execution. +// Uses tool-specific execution signatures for high accuracy. +func isSASTToolExecution(line, tool string) bool { + lower := strings.ToLower(line) + lowerTool := strings.ToLower(tool) + + // Skip lines that are clearly not executions + // 1. Test data (Authorization: Bearer, token examples, etc.) + if strings.Contains(lower, "authorization:") || + strings.Contains(lower, "bearer token") || + strings.Contains(lower, "example") || + strings.Contains(lower, "test-token") { + return false + } + + // 2. Error messages + if strings.Contains(lower, "failed to") || + strings.Contains(lower, "not found") || + strings.Contains(lower, "error:") || + strings.Contains(lower, "could not find") || + strings.Contains(lower, "unable to") { + return false + } + + // 3. Use tool-specific signatures if available + if signatures, ok := sastToolExecutionSignatures[lowerTool]; ok { + for _, sig := range signatures { + if strings.Contains(lower, strings.ToLower(sig)) { + return true + } + } + return false + } + + // 4. Fallback: generic execution patterns for tools without specific signatures + executionPatterns := []string{ + "+" + lowerTool, // + gosec (shell trace) + "running " + lowerTool, // Running gosec + lowerTool + " run", // gosec run + lowerTool + " scan", // trivy scan + lowerTool + " --", // Tool with flags + } + + for _, pattern := range executionPatterns { + if strings.Contains(lower, pattern) { + return true + } + } + + return false +} + +// scanLogForSASTTools scans log content for SAST tool execution patterns. +func scanLogForSASTTools(content []byte, prowURL string, c *checker.CheckRequest) bool { + scanner := bufio.NewScanner(bytes.NewReader(content)) + for scanner.Scan() { + line := scanner.Text() + for _, tool := range sastToolPatterns { + if strings.Contains(strings.ToLower(line), tool) { + if isSASTToolExecution(line, tool) { + if c.Dlogger != nil { + c.Dlogger.Debug(&checker.LogMessage{ + Path: prowURL, + Type: finding.FileTypeURL, + Text: fmt.Sprintf("[Prow Logs] SAST tool '%s' detected in job output", tool), + }) + } + return true + } + } + } + } + return false +} + +// scanLogForSecurityLinting scans log content for security linting patterns. +func scanLogForSecurityLinting(content []byte, prowURL string, c *checker.CheckRequest) bool { + scanner := bufio.NewScanner(bytes.NewReader(content)) + for scanner.Scan() { + line := strings.ToLower(scanner.Text()) + if (strings.Contains(line, "running") || strings.Contains(line, "+") || strings.Contains(line, "executing")) && + strings.Contains(line, "security") && strings.Contains(line, "lint") { + if c.Dlogger != nil { + c.Dlogger.Debug(&checker.LogMessage{ + Path: prowURL, + Type: finding.FileTypeURL, + Text: "[Prow Logs] Security linting detected in job output", + }) + } + return true + } + } + return false +} + +// hasSASTInLogs fetches and scans Prow log content for SAST tool signatures. +// Uses execution pattern matching to reduce false positives. +func hasSASTInLogs(c *checker.CheckRequest, prowURL string) bool { + logURLs := convertToLogURL(prowURL) + + for _, logURL := range logURLs { + content, err := fetchProwLog(c.Ctx, logURL) + if err != nil { + if c.Dlogger != nil { + c.Dlogger.Debug(&checker.LogMessage{ + Text: fmt.Sprintf("[Prow Logs] Could not fetch %s: %v", logURL, err), + }) + } + continue + } + + if scanLogForSASTTools(content, prowURL, c) { + return true + } + + if scanLogForSecurityLinting(content, prowURL, c) { + return true + } + } + + return false +} + +// checkStatusesForSAST checks commit statuses for SAST tool execution via pattern matching. +// Only logs when a SAST tool is found to reduce noise. +// Fetches Prow logs in parallel for improved performance. +func checkStatusesForSAST(c *checker.CheckRequest, statuses []clients.Status) bool { + // Skip logging if no statuses to check (reduces noise for repos without statuses) + if len(statuses) == 0 { + return false + } + + // First pass: check pattern matching (fast, synchronous) + for _, status := range statuses { + if status.State != "success" { + continue + } + // Check if status context or URL contains a SAST tool name + matchedInContext := findMatchingSASTPattern(status.Context) + matchedInURL := findMatchingSASTPattern(status.TargetURL) + + if matchedInContext != "" || matchedInURL != "" { + if c.Dlogger != nil { + matchInfo := "" + if matchedInContext != "" { + matchInfo = fmt.Sprintf("pattern '%s' in context '%s'", matchedInContext, status.Context) + } else { + matchInfo = fmt.Sprintf("pattern '%s' in URL", matchedInURL) + } + c.Dlogger.Debug(&checker.LogMessage{ + Path: status.TargetURL, + Type: finding.FileTypeURL, + Text: fmt.Sprintf("[CI Status Check] SAST tool detected: %s", matchInfo), + }) + } + return true + } + } + + // Second pass: collect Prow URLs for parallel log scanning + var prowURLs []string + for _, status := range statuses { + if status.State == "success" && isProwJobURL(status.TargetURL) { + prowURLs = append(prowURLs, status.TargetURL) + } + } + + if len(prowURLs) == 0 { + return false + } + + // Parallel log scanning using goroutines + type result struct { + url string + found bool + } + resultChan := make(chan result, len(prowURLs)) + var wg sync.WaitGroup + + for _, url := range prowURLs { + wg.Add(1) + go func(prowURL string) { + defer wg.Done() + found := hasSASTInLogs(c, prowURL) + resultChan <- result{url: prowURL, found: found} + }(url) + } + + // Wait for all goroutines to complete + go func() { + wg.Wait() + close(resultChan) + }() + + // Collect results + for res := range resultChan { + if res.found { + return true + } + } + + // Only log once if we checked statuses but found nothing + // (This still happens per commit, but only when statuses exist) + return false +} + +//nolint:gocognit // TODO: refactor to reduce complexity func sastToolInCheckRuns(c *checker.CheckRequest) ([]checker.SASTCommit, error) { var sastCommits []checker.SASTCommit commits, err := c.RepoClient.ListCommits() @@ -108,6 +657,12 @@ func sastToolInCheckRuns(c *checker.CheckRequest) ([]checker.SASTCommit, error) sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("RepoClient.ListCommits: %v", err)) } + if c.Dlogger != nil { + c.Dlogger.Debug(&checker.LogMessage{ + Text: fmt.Sprintf("[CheckRuns] Analyzing %d commit(s) for SAST tools", len(commits)), + }) + } + for i := range commits { pr := commits[i].AssociatedMergeRequest // TODO(#575): We ignore associated PRs if Scorecard is being run on a fork @@ -131,6 +686,7 @@ func sastToolInCheckRuns(c *checker.CheckRequest) ([]checker.SASTCommit, error) if !allowedConclusions[cr.Conclusion] { continue } + // Check for known SAST tool app slugs (GitHub-specific) if sastTools[cr.App.Slug] { if c.Dlogger != nil { c.Dlogger.Debug(&checker.LogMessage{ @@ -142,6 +698,31 @@ func sastToolInCheckRuns(c *checker.CheckRequest) ([]checker.SASTCommit, error) checked = true break } + // Check for SAST tool patterns in app slug or URL (works for Prow, Jenkins, etc.) + if isSASTToolName(cr.App.Slug) || isSASTToolName(cr.URL) { + if c.Dlogger != nil { + c.Dlogger.Debug(&checker.LogMessage{ + Path: cr.URL, + Type: finding.FileTypeURL, + Text: fmt.Sprintf("SAST tool pattern detected in: %v", cr.App.Slug), + }) + } + checked = true + break + } + } + + // If not found in CheckRuns, also check commit Statuses (used by Prow, Jenkins, etc.) + if !checked { + statuses, err := c.RepoClient.ListStatuses(pr.HeadSHA) + if err != nil { + // Ignore error if statuses are not supported + if !errors.Is(err, clients.ErrUnsupportedFeature) { + return sastCommits, + sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("Client.ListStatuses: %v", err)) + } + } + checked = checkStatusesForSAST(c, statuses) } sastCommit := checker.SASTCommit{ CommittedDate: commits[i].CommittedDate, diff --git a/checks/raw/sast_statuses_test.go b/checks/raw/sast_statuses_test.go new file mode 100644 index 00000000000..5533889a7c9 --- /dev/null +++ b/checks/raw/sast_statuses_test.go @@ -0,0 +1,79 @@ +// Copyright 2025 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package raw + +import ( + "testing" + "time" + + "go.uber.org/mock/gomock" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/clients" + mockrepo "github.com/ossf/scorecard/v5/clients/mockclients" +) + +func TestSASTWithStatuses(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepoClient := mockrepo.NewMockRepoClient(ctrl) + + mergedAt := time.Now().Add(time.Hour * time.Duration(-1)) + + // Prow job with govulncheck in status context name + mockRepoClient.EXPECT().ListCommits().Return([]clients.Commit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + HeadSHA: "sha1", + MergedAt: mergedAt, + }, + }, + }, nil) + + // No CheckRuns + mockRepoClient.EXPECT().ListCheckRunsForRef("sha1").Return([]clients.CheckRun{}, nil) + + // But has Statuses with govulncheck + mockRepoClient.EXPECT().ListStatuses("sha1").Return([]clients.Status{ + { + State: "success", + Context: "govulncheck_myproject", + URL: "https://prow.example.com/view/govulncheck", + }, + }, nil) + + // No workflow files + mockRepoClient.EXPECT().ListFiles(gomock.Any()).Return([]string{}, nil).AnyTimes() + + req := &checker.CheckRequest{ + RepoClient: mockRepoClient, + } + + result, err := SAST(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Commits) != 1 { + t.Errorf("expected 1 commit, got %d", len(result.Commits)) + } + + if !result.Commits[0].Compliant { + t.Error("expected commit to be compliant (SAST tool detected in status)") + } +} diff --git a/checks/raw/sast_test.go b/checks/raw/sast_test.go index d93348558de..19abea883b2 100644 --- a/checks/raw/sast_test.go +++ b/checks/raw/sast_test.go @@ -248,6 +248,132 @@ func TestSAST(t *testing.T) { }, }, }, + { + name: "Prow job with CodeQL in name - detected via pattern", + commits: []clients.Commit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: mergedOneHourAgo, + }, + }, + }, + checkRuns: []clients.CheckRun{ + { + Status: "completed", + Conclusion: "success", + App: clients.CheckRunApp{ + Slug: "pull-ci-org-repo-master-codeql", + }, + URL: "https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/12345", + }, + }, + expected: checker.SASTData{ + Commits: []checker.SASTCommit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: mergedOneHourAgo, + }, + Compliant: true, + }, + }, + }, + }, + { + name: "Prow job with Sonar in URL - detected via pattern", + commits: []clients.Commit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: mergedOneHourAgo, + }, + }, + }, + checkRuns: []clients.CheckRun{ + { + Status: "completed", + Conclusion: "success", + App: clients.CheckRunApp{ + Slug: "prow-job", + }, + URL: "https://prow.example.com/sonarqube-scan/results", + }, + }, + expected: checker.SASTData{ + Commits: []checker.SASTCommit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: mergedOneHourAgo, + }, + Compliant: true, + }, + }, + }, + }, + { + name: "Jenkins job with Snyk in name - detected via pattern", + commits: []clients.Commit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: mergedOneHourAgo, + }, + }, + }, + checkRuns: []clients.CheckRun{ + { + Status: "completed", + Conclusion: "success", + App: clients.CheckRunApp{ + Slug: "jenkins-snyk-security-scan", + }, + }, + }, + expected: checker.SASTData{ + Commits: []checker.SASTCommit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: mergedOneHourAgo, + }, + Compliant: true, + }, + }, + }, + }, + { + name: "Multiple SAST tools - Semgrep in CheckRun name", + commits: []clients.Commit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: mergedOneHourAgo, + }, + }, + }, + checkRuns: []clients.CheckRun{ + { + Status: "completed", + Conclusion: "success", + App: clients.CheckRunApp{ + Slug: "semgrep-ci-check", + }, + }, + }, + expected: checker.SASTData{ + Commits: []checker.SASTCommit{ + { + AssociatedMergeRequest: clients.PullRequest{ + Number: 1, + MergedAt: mergedOneHourAgo, + }, + Compliant: true, + }, + }, + }, + }, { name: "Has Hadolint", files: []string{".github/workflows/github-hadolint-workflow.yaml"}, diff --git a/checks/raw/testdata/.prow-no-sast.yaml b/checks/raw/testdata/.prow-no-sast.yaml new file mode 100644 index 00000000000..0439f9c46cb --- /dev/null +++ b/checks/raw/testdata/.prow-no-sast.yaml @@ -0,0 +1,17 @@ +presubmits: + org/repo: + - name: pull-build + decorate: true + always_run: true + command: + - make + - build + args: + - -j4 + + - name: pull-unit-test + decorate: true + command: + - go + - test + - ./... diff --git a/checks/raw/testdata/.prow.yaml b/checks/raw/testdata/.prow.yaml new file mode 100644 index 00000000000..d29a25e8ac2 --- /dev/null +++ b/checks/raw/testdata/.prow.yaml @@ -0,0 +1,36 @@ +presubmits: + org/repo: + - name: pull-lint + decorate: true + always_run: true + command: + - golangci-lint + - run + args: + - --timeout=5m + + - name: pull-test + decorate: true + command: + - make + - test + +postsubmits: + org/repo: + - name: post-security-scan + decorate: true + command: + - trivy + - fs + - --security-checks + - vuln + - . + +periodics: + - name: nightly-codeql + interval: 24h + decorate: true + command: + - codeql + - database + - analyze diff --git a/checks/sast_test.go b/checks/sast_test.go index 990a8f3e017..f0fa896e101 100644 --- a/checks/sast_test.go +++ b/checks/sast_test.go @@ -50,8 +50,9 @@ func Test_SAST(t *testing.T) { searchresult: clients.SearchResponse{}, checkRuns: []clients.CheckRun{}, expected: scut.TestReturn{ - Score: checker.MinResultScore, - NumberOfWarn: 1, + Score: checker.MinResultScore, + NumberOfWarn: 1, + NumberOfDebug: 8, }, }, { @@ -61,8 +62,9 @@ func Test_SAST(t *testing.T) { searchresult: clients.SearchResponse{}, checkRuns: []clients.CheckRun{}, expected: scut.TestReturn{ - Score: checker.InconclusiveResultScore, - Error: sce.ErrScorecardInternal, + Score: checker.InconclusiveResultScore, + Error: sce.ErrScorecardInternal, + NumberOfDebug: 1, }, }, { @@ -87,7 +89,7 @@ func Test_SAST(t *testing.T) { expected: scut.TestReturn{ Score: checker.MaxResultScore, NumberOfInfo: 1, - NumberOfDebug: 1, + NumberOfDebug: 9, }, }, { @@ -112,7 +114,7 @@ func Test_SAST(t *testing.T) { expected: scut.TestReturn{ Score: checker.MaxResultScore, NumberOfInfo: 1, - NumberOfDebug: 1, + NumberOfDebug: 9, }, }, { @@ -138,7 +140,7 @@ func Test_SAST(t *testing.T) { expected: scut.TestReturn{ Score: checker.MaxResultScore, NumberOfInfo: 1, - NumberOfDebug: 1, + NumberOfDebug: 9, }, }, { @@ -163,7 +165,7 @@ func Test_SAST(t *testing.T) { expected: scut.TestReturn{ Score: checker.MaxResultScore, NumberOfInfo: 1, - NumberOfDebug: 1, + NumberOfDebug: 9, }, }, { @@ -179,9 +181,10 @@ func Test_SAST(t *testing.T) { searchresult: clients.SearchResponse{}, path: ".github/workflows/airflow-codeql-workflow.yaml", expected: scut.TestReturn{ - Score: 7, - NumberOfWarn: 1, - NumberOfInfo: 1, + Score: 7, + NumberOfWarn: 1, + NumberOfInfo: 1, + NumberOfDebug: 8, }, }, { @@ -215,7 +218,7 @@ func Test_SAST(t *testing.T) { expected: scut.TestReturn{ Score: checker.MaxResultScore, NumberOfInfo: 2, - NumberOfDebug: 1, + NumberOfDebug: 9, }, }, { @@ -250,7 +253,7 @@ func Test_SAST(t *testing.T) { expected: scut.TestReturn{ Score: checker.MaxResultScore, NumberOfInfo: 2, - NumberOfDebug: 1, + NumberOfDebug: 9, }, }, { @@ -288,9 +291,10 @@ func Test_SAST(t *testing.T) { }, path: ".github/workflows/airflow-codeql-workflow.yaml", expected: scut.TestReturn{ - Score: 7, - NumberOfWarn: 1, - NumberOfInfo: 1, + Score: 7, + NumberOfWarn: 1, + NumberOfInfo: 1, + NumberOfDebug: 8, }, }, { @@ -312,9 +316,10 @@ func Test_SAST(t *testing.T) { searchresult: clients.SearchResponse{}, path: ".github/workflows/github-hadolint-workflow.yaml", expected: scut.TestReturn{ - Score: checker.MaxResultScore, - NumberOfWarn: 1, - NumberOfInfo: 1, + Score: checker.MaxResultScore, + NumberOfWarn: 1, + NumberOfInfo: 1, + NumberOfDebug: 8, }, }, } @@ -334,6 +339,7 @@ func Test_SAST(t *testing.T) { return tt.commits, tt.err }) mockRepoClient.EXPECT().ListCheckRunsForRef("").Return(tt.checkRuns, nil).AnyTimes() + mockRepoClient.EXPECT().ListStatuses("").Return([]clients.Status{}, nil).AnyTimes() mockRepoClient.EXPECT().Search(searchRequest).Return(tt.searchresult, nil).AnyTimes() mockRepoClient.EXPECT().ListFiles(gomock.Any()).DoAndReturn( func(predicate func(string) (bool, error)) ([]string, error) { diff --git a/docs/checks.md b/docs/checks.md index 3c9325711cb..ac59827bf8f 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -529,11 +529,23 @@ SAST is testing run on source code before the application is run. Using SAST tools can prevent known classes of bugs from being inadvertently introduced in the codebase. -The checks currently looks for known GitHub apps such as -[CodeQL](https://codeql.github.com/) (github-code-scanning) or -[SonarCloud](https://sonarcloud.io/) in the recent (~30) merged PRs, or the use -of "github/codeql-action" in a GitHub workflow. It also checks for the deprecated -[LGTM](https://lgtm.com/) service until its forthcoming shutdown. +The check uses multiple detection methods: + +1.GitHub Apps and Actions: Looks for known GitHub apps such as + [CodeQL](https://codeql.github.com/) (github-code-scanning) or + [SonarCloud](https://sonarcloud.io/) in the recent (~30) merged PRs, or the use + of "github/codeql-action" in a GitHub workflow. It also checks for the deprecated + [LGTM](https://lgtm.com/) service until its forthcoming shutdown. + +2. Prow CI Integration: For projects using [Prow](https://docs.prow.k8s.io/), + the check provides comprehensive SAST detection through: + - Pattern matching: Detects 30+ SAST tools (gosec, golangci-lint, semgrep, etc.) in commit status names + - Config file scanning: Analyzes `.prow.yaml` and `prow/*.yaml` files for SAST tool commands + - Log analysis: Fetches and scans Prow job logs using execution signatures to verify actual tool runs + - Parallel processing: Uses concurrent log fetching for improved performance on large projects + +3. Check Runs and Statuses: Analyzes GitHub check runs and commit statuses to detect + SAST tool execution across various CI systems including Prow, Jenkins, and others. Note: A project that fulfills this criterion with other tools may still receive a low score on this test. There are many ways to implement SAST, and it is @@ -543,6 +555,7 @@ is therefore not a definitive indication that the project is at risk. **Remediation steps** - Run CodeQL checks in your CI/CD by following the instructions [here](https://github.com/github/codeql-action#usage). +- For Prow-based projects, configure SAST tools in your `.prow.yaml` or Prow job configurations. Popular tools include gosec, golangci-lint, staticcheck, and semgrep. ## SBOM diff --git a/docs/checks/internal/checks.yaml b/docs/checks/internal/checks.yaml index bab6591a16f..a3cd844ba21 100644 --- a/docs/checks/internal/checks.yaml +++ b/docs/checks/internal/checks.yaml @@ -546,11 +546,23 @@ checks: tools can prevent known classes of bugs from being inadvertently introduced in the codebase. - The checks currently looks for known GitHub apps such as - [CodeQL](https://codeql.github.com/) (github-code-scanning) or - [SonarCloud](https://sonarcloud.io/) in the recent (~30) merged PRs, or the use - of "github/codeql-action" in a GitHub workflow. It also checks for the deprecated - [LGTM](https://lgtm.com/) service until its forthcoming shutdown. + The check uses multiple detection methods: + + 1.GitHub Apps and Actions: Looks for known GitHub apps such as + [CodeQL](https://codeql.github.com/) (github-code-scanning) or + [SonarCloud](https://sonarcloud.io/) in the recent (~30) merged PRs, or the use + of "github/codeql-action" in a GitHub workflow. It also checks for the deprecated + [LGTM](https://lgtm.com/) service until its forthcoming shutdown. + + 2. Prow CI Integration: For projects using [Prow](https://docs.prow.k8s.io/), + the check provides comprehensive SAST detection through: + - Pattern matching: Detects 30+ SAST tools (gosec, golangci-lint, semgrep, etc.) in commit status names + - Config file scanning: Analyzes `.prow.yaml` and `prow/*.yaml` files for SAST tool commands + - Log analysis: Fetches and scans Prow job logs using execution signatures to verify actual tool runs + - Parallel processing: Uses concurrent log fetching for improved performance on large projects + + 3. Check Runs and Statuses: Analyzes GitHub check runs and commit statuses to detect + SAST tool execution across various CI systems including Prow, Jenkins, and others. Note: A project that fulfills this criterion with other tools may still receive a low score on this test. There are many ways to implement SAST, and it is @@ -560,6 +572,9 @@ checks: - >- Run CodeQL checks in your CI/CD by following the instructions [here](https://github.com/github/codeql-action#usage). + - >- + For Prow-based projects, configure SAST tools in your `.prow.yaml` or Prow job + configurations. Popular tools include gosec, golangci-lint, staticcheck, and semgrep. SBOM: risk: Medium diff --git a/docs/probes.md b/docs/probes.md index 58332f306ac..768d9286ea8 100644 --- a/docs/probes.md +++ b/docs/probes.md @@ -555,7 +555,7 @@ If the project does not use a SAST tool, or uses a tool we dont currently detect **Motivation**: SAST is testing run on source code before the application is run. Using SAST tools can prevent known classes of bugs from being inadvertently introduced in the codebase. -**Implementation**: The implementation iterates through the projects commits and checks whether any of the check runs for the commits associated merge request was any of the SAST tools that Scorecard supports. +**Implementation**: The implementation iterates through the projects commits and checks whether any of the check runs or statuses for the commits associated merge request indicate SAST tool execution. For GitHub-hosted projects, it checks GitHub Apps and Actions. For Prow-based CI systems, it analyzes commit statuses, configuration files, and optionally fetches job logs to verify tool execution using signature-based pattern matching. The probe supports detection of 30+ SAST tools including CodeQL, gosec, golangci-lint, semgrep, snyk, and others. **Outcomes**: If the project had no commits merged, the probe returns a finding with OutcomeNotApplicable. If the project runs SAST tools successfully on every pull request before merging, the probe returns one finding with OutcomeTrue (1). In addition, the finding will include two values. 1) How many commits were tested by a SAST tool, and 2) How many commits in total were merged. diff --git a/probes/sastToolRunsOnAllCommits/def.yml b/probes/sastToolRunsOnAllCommits/def.yml index 1e8ab22a26a..72528492538 100644 --- a/probes/sastToolRunsOnAllCommits/def.yml +++ b/probes/sastToolRunsOnAllCommits/def.yml @@ -18,7 +18,7 @@ short: Checks that a SAST tool runs on all commits in the projects CI. motivation: > SAST is testing run on source code before the application is run. Using SAST tools can prevent known classes of bugs from being inadvertently introduced in the codebase. implementation: > - The implementation iterates through the projects commits and checks whether any of the check runs for the commits associated merge request was any of the SAST tools that Scorecard supports. + The implementation iterates through the projects commits and checks whether any of the check runs or statuses for the commits associated merge request indicate SAST tool execution. For GitHub-hosted projects, it checks GitHub Apps and Actions. For Prow-based CI systems, it analyzes commit statuses, configuration files, and optionally fetches job logs to verify tool execution using signature-based pattern matching. The probe supports detection of 30+ SAST tools including CodeQL, gosec, golangci-lint, semgrep, snyk, and others. outcome: - If the project had no commits merged, the probe returns a finding with OutcomeNotApplicable. - If the project runs SAST tools successfully on every pull request before merging, the probe returns one finding with OutcomeTrue (1). In addition, the finding will include two values. 1) How many commits were tested by a SAST tool, and 2) How many commits in total were merged. diff --git a/probes/testsRunInCI/impl.go b/probes/testsRunInCI/impl.go index 55853ad7e2b..20855a4b8ca 100644 --- a/probes/testsRunInCI/impl.go +++ b/probes/testsRunInCI/impl.go @@ -165,7 +165,7 @@ func isTest(s string) bool { "appveyor", "buildkite", "circleci", "e2e", "github-actions", "jenkins", "mergeable", "packit-as-a-service", "semaphoreci", "test", "travis-ci", "flutter-dashboard", "cirrus-ci", "Cirrus CI", "azure-pipelines", "ci/woodpecker", - "vstfs:///build/build", + "vstfs:///build/build", "prow", } { if strings.Contains(l, pattern) { return true diff --git a/probes/testsRunInCI/impl_test.go b/probes/testsRunInCI/impl_test.go index 2613856a2ef..256d1b53014 100644 --- a/probes/testsRunInCI/impl_test.go +++ b/probes/testsRunInCI/impl_test.go @@ -243,6 +243,20 @@ func Test_isTest(t *testing.T) { }, want: true, }, + { + name: "prow", + args: args{ + s: "prow", + }, + want: true, + }, + { + name: "prow-job-name", + args: args{ + s: "pull-ci-org-repo-master-unit-tests", + }, + want: true, // matches "test" pattern + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {