Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions checks/code_review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
44 changes: 42 additions & 2 deletions checks/raw/code_review.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down
183 changes: 183 additions & 0 deletions checks/raw/prow_config.go
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 57 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L57

Added line #L57 was not covered by tests
}
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

Check warning on line 89 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L89

Added line #L89 was not covered by tests
}
}

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)

Check warning on line 96 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L94-L96

Added lines #L94 - L96 were not covered by tests
}
} 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",

Check warning on line 110 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L104-L110

Added lines #L104 - L110 were not covered by tests
}
sastWorkflows = append(sastWorkflows, sastWorkflow)

Check warning on line 112 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L112

Added line #L112 was not covered by tests
}

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)

Check warning on line 124 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L124

Added line #L124 was not covered by tests
}

paths, ok := args[0].(*[]string)
if !ok {
return false, fmt.Errorf("searchProwConfigForSAST expects arg[0] of type *[]string: %w", errProwInvalidArgs)

Check warning on line 129 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L129

Added line #L129 was not covered by tests
}

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 warning on line 146 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L143-L146

Added lines #L143 - L146 were not covered by tests
}
}
}

// Check postsubmits
if !hasSAST {
for _, jobs := range config.Postsubmits {
for _, job := range jobs {
if jobContainsSAST(job) {
hasSAST = true
break

Check warning on line 157 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L154-L157

Added lines #L154 - L157 were not covered by tests
}
}
}
}

// Check periodics
if !hasSAST {
for _, job := range config.Periodics {
if jobContainsSAST(job) {
hasSAST = true
break

Check warning on line 168 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L166-L168

Added lines #L166 - L168 were not covered by tests
}
}
}

if hasSAST {
*paths = append(*paths, path)

Check warning on line 174 in checks/raw/prow_config.go

View check run for this annotation

Codecov / codecov/patch

checks/raw/prow_config.go#L174

Added line #L174 was not covered by tests
}

return true, nil
}

// jobContainsSAST checks if a Prow job contains SAST tools.
func jobContainsSAST(job ProwJob) bool {
return CommandContainsSASTTool(job.Command) || CommandContainsSASTTool(job.Args)
}
Loading
Loading