diff --git a/checker/raw_result.go b/checker/raw_result.go index bf5132b7e45..011a687f301 100644 --- a/checker/raw_result.go +++ b/checker/raw_result.go @@ -48,6 +48,7 @@ type RawResults struct { TokenPermissionsResults TokenPermissionsData VulnerabilitiesResults VulnerabilitiesData WebhookResults WebhooksData + SecretScanningResults SecretScanningData } type MetadataData struct { @@ -493,3 +494,73 @@ func (e *ElementError) Error() string { func (e *ElementError) Unwrap() error { return e.Err } + +// SecretScanningData contains the raw results for the Secret-Scanning check. +// +//nolint:govet +type SecretScanningData struct { + // CI run statistics for third-party tools. + ThirdPartyCIInfo map[string]*ToolCIStats + ThirdPartyDetectSecretsPaths []string + Evidence []string + ThirdPartyRepoSupervisorPaths []string + ThirdPartyShhGitPaths []string + ThirdPartyGitleaksPaths []string + ThirdPartyGGShieldPaths []string + ThirdPartyTruffleHogPaths []string + ThirdPartyGitSecretsPaths []string + Platform string + GHNativeEnabled TriState + GHPushProtectionEnabled TriState + GLPushRulesPreventSecrets bool + ThirdPartyGitSecrets bool + ThirdPartyDetectSecrets bool + ThirdPartyGGShield bool + ThirdPartyTruffleHog bool + ThirdPartyShhGit bool + ThirdPartyGitleaks bool + ThirdPartyRepoSupervisor bool + GLSecretPushProtection bool + GLPipelineSecretDetection bool +} + +// ToolCIStats tracks CI execution statistics for a specific secret scanning tool. +type ToolCIStats struct { + // ToolName is the name of the scanning tool (e.g., "gitleaks", "trufflehog") + ToolName string + // ExecutionPattern indicates whether the tool runs "periodic" or "commit-based" + ExecutionPattern string + // LastRunDate is the most recent date the tool executed (if known) + LastRunDate string + // TotalCommitsAnalyzed is the number of recent commits examined (up to 100) + TotalCommitsAnalyzed int + // CommitsWithToolRun is count of commits where this specific tool ran + CommitsWithToolRun int + // HasRecentRuns indicates if the tool ran in the last 30 days + HasRecentRuns bool +} + +// TriState represents a three-valued logic state. +type TriState int + +const ( + // TriUnknown indicates the state is unknown or not determined. + TriUnknown TriState = iota + // TriFalse indicates a false/negative state. + TriFalse + // TriTrue indicates a true/positive state. + TriTrue +) + +// Bool converts TriState to a boolean with a validity flag. +// Returns (value, valid) where valid is true only for TriTrue and TriFalse. +func (t TriState) Bool() (bool, bool) { + switch t { + case TriTrue: + return true, true + case TriFalse: + return false, true + default: + return false, false + } +} diff --git a/checks/evaluation/secret_scanning.go b/checks/evaluation/secret_scanning.go new file mode 100644 index 00000000000..4180930b28f --- /dev/null +++ b/checks/evaluation/secret_scanning.go @@ -0,0 +1,387 @@ +// 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 evaluation + +import ( + "fmt" + "strings" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + ghpp "github.com/ossf/scorecard/v5/probes/hasGitHubPushProtectionEnabled" + ghe "github.com/ossf/scorecard/v5/probes/hasGitHubSecretScanningEnabled" + glpd "github.com/ossf/scorecard/v5/probes/hasGitLabPipelineSecretDetection" + glpr "github.com/ossf/scorecard/v5/probes/hasGitLabPushRulesPreventSecrets" + glspp "github.com/ossf/scorecard/v5/probes/hasGitLabSecretPushProtection" + tpds "github.com/ossf/scorecard/v5/probes/hasThirdPartyDetectSecrets" + tpgg "github.com/ossf/scorecard/v5/probes/hasThirdPartyGGShield" + tpgs "github.com/ossf/scorecard/v5/probes/hasThirdPartyGitSecrets" + tpgl "github.com/ossf/scorecard/v5/probes/hasThirdPartyGitleaks" + tprs "github.com/ossf/scorecard/v5/probes/hasThirdPartyRepoSupervisor" + tpsh "github.com/ossf/scorecard/v5/probes/hasThirdPartyShhGit" + tpth "github.com/ossf/scorecard/v5/probes/hasThirdPartyTruffleHog" +) + +const thirdPartyScannerPresent = "; third-party scanner present" + +// SecretScanning applies scoring policy for the Secret-Scanning check. +// +// This function evaluates whether a repository has secret scanning enabled to detect +// accidentally committed credentials, API keys, tokens, and other sensitive data. +// The scoring differs between GitHub and GitLab platforms and considers both native +// platform features and third-party scanning tools. +// +// Policy summary: +// +// GitHub repositories: +// - Native secret scanning enabled → score 10 (maximum) +// GitHub's native secret scanning provides comprehensive coverage and is the gold standard. +// - Native disabled + 3rd-party tool present + actively running in CI → score based on CI coverage +// Third-party tools are evaluated on how frequently they run (see third-party scoring below). +// - Native disabled + 3rd-party tool present + no CI run data → score 1 +// Tool is configured but we cannot verify it's actually running. +// - Native disabled + no 3rd-party tools detected → score 0 +// No secret scanning protection whatsoever. +// - Native status inconclusive → fall through to GitLab-style additive scoring +// When we can't determine GitHub native status, use component-based scoring. +// +// GitLab repositories (additive scoring model): +// - Secret Push Protection enabled → +4 points +// - Pipeline Secret Detection configured → +4 points +// - Push Rules prevent secrets enabled → +1 point +// - Third-party scanner present and running → +1 to +10 points (see third-party scoring below) +// +// Third-party CI scoring (applies to both platforms): +// +// Periodic scanners (shhgit, repo-supervisor): +// These tools run on a schedule (daily/weekly) rather than per-commit. +// - Ran in last 30 days → +10 points +// - Present but no recent runs → +1 point +// +// Commit-based scanners (Gitleaks, TruffleHog, detect-secrets, git-secrets, GGShield): +// These tools typically run on every push or pull request. +// Scored based on coverage of last 100 commits: +// - 100% coverage → +10 points (runs on every commit) +// - 70-99% coverage → +7 points (runs on most commits) +// - 50-69% coverage → +5 points (runs on half of commits) +// - <50% coverage → +3 points (runs on some commits) +// - Tool present but not running → +1 point (configured but inactive) +// +//nolint:gocognit,nestif // Complex scoring logic requires nested conditions +func SecretScanning( + name string, + findings []finding.Finding, + dl checker.DetailLogger, + rawData *checker.SecretScanningData, +) checker.CheckResult { + // Map probeID -> outcome (each probe returns exactly one finding). + po := map[string]finding.Outcome{} + for _, f := range findings { + po[f.Probe] = f.Outcome + } + + // ----------------------- + // GitHub evaluation path + // ----------------------- + // Take the GitHub path only when the native probe is definitively True or False. + if ghOutcome, ok := po[ghe.Probe]; ok && + (ghOutcome == finding.OutcomeTrue || ghOutcome == finding.OutcomeFalse) { + switch ghOutcome { + case finding.OutcomeTrue: + reason := "GitHub native secret scanning is enabled" + if po[ghpp.Probe] == finding.OutcomeTrue { + reason += " (push protection enabled)" + } + if tpPresent(po) { + details := tpDetails(findings) + if len(details) > 0 { + reason += "; " + strings.Join(details, "; ") + } else { + reason += thirdPartyScannerPresent + } + } + return checker.CreateMaxScoreResult(name, reason) + + case finding.OutcomeFalse: + reason := "GitHub native secret scanning is disabled" + if po[ghpp.Probe] == finding.OutcomeTrue { + reason += " (push protection enabled)" + } + if tpPresent(po) { + // Calculate score based on CI run frequency + tpScore := calculateThirdPartyScore(rawData) + details := tpDetails(findings) + if len(details) > 0 { + reason += "; " + strings.Join(details, "; ") + } else { + reason += thirdPartyScannerPresent + } + // Add per-tool CI coverage details to reason + if rawData != nil && len(rawData.ThirdPartyCIInfo) > 0 { + reason += formatCICoverageDetails(rawData.ThirdPartyCIInfo) + } + return checker.CreateResultWithScore(name, reason, tpScore) + } + return checker.CreateResultWithScore(name, reason, 0) + default: + // Handle other outcomes (NotAvailable, Error, NotSupported, NotApplicable) + // Fall through to GitLab path or return minimal score + } + } + + // Check if we're on GitHub but couldn't determine native status due to permissions + if rawData != nil && rawData.Platform == "github" { + for _, evidence := range rawData.Evidence { + if strings.Contains(evidence, "permission_denied") { + // Token doesn't have permissions to check GitHub native secret scanning + reason := "Token has insufficient permissions to get information about native GitHub secret scanning" + if tpPresent(po) { + details := tpDetails(findings) + if len(details) > 0 { + reason += "; " + strings.Join(details, "; ") + } else { + reason += thirdPartyScannerPresent + } + if rawData != nil && len(rawData.ThirdPartyCIInfo) > 0 { + reason += formatCICoverageDetails(rawData.ThirdPartyCIInfo) + } + } + return checker.CreateInconclusiveResult(name, reason) + } + } + } + + // ----------------------- + // GitLab evaluation path + // ----------------------- + score := 0 + var bits []string + + add := func(probe string, on string, off string, pts int) { + if po[probe] == finding.OutcomeTrue { + score += pts + bits = append(bits, on) + } else { + bits = append(bits, off) + } + } + + // Native GitLab knobs + add(glspp.Probe, "Secret Push Protection: on", "Secret Push Protection: off", 4) + add(glpd.Probe, "Pipeline Secret Detection: on", "Pipeline Secret Detection: off", 4) + add(glpr.Probe, "Push rules prevent_secrets: on", "Push rules prevent_secrets: off", 1) + + // Third-party scanner with CI-aware scoring + if tpPresent(po) { + tpScore := calculateThirdPartyScore(rawData) + score += tpScore + details := tpDetails(findings) + if len(details) > 0 { + bits = append(bits, "3rd-party scanner: "+strings.Join(details, "; ")) + } else { + bits = append(bits, "3rd-party scanner: present") + } + // Add per-tool CI coverage details + if rawData != nil && len(rawData.ThirdPartyCIInfo) > 0 { + coverageDetails := formatCICoverageDetails(rawData.ThirdPartyCIInfo) + if coverageDetails != "" { + bits = append(bits, "CI stats:"+coverageDetails) + } + } + } else { + bits = append(bits, "3rd-party scanner: not found") + } + + if score > checker.MaxResultScore { + score = checker.MaxResultScore + } + reason := "GitLab secret scanning posture — " + strings.Join(bits, "; ") + return checker.CreateResultWithScore(name, reason, score) +} + +// tpPresent returns true if any third-party secret scanner +// probe is OutcomeTrue. +func tpPresent(po map[string]finding.Outcome) bool { + return po[tpgl.Probe] == finding.OutcomeTrue || + po[tpth.Probe] == finding.OutcomeTrue || + po[tpds.Probe] == finding.OutcomeTrue || + po[tpgs.Probe] == finding.OutcomeTrue || + po[tpgg.Probe] == finding.OutcomeTrue || + po[tpsh.Probe] == finding.OutcomeTrue || + po[tprs.Probe] == finding.OutcomeTrue +} + +// tpDetails gathers human-readable messages from the third-party probes, +// which are already formatted as " found at " when a path is known. +func tpDetails(findings []finding.Finding) []string { + var out []string + want := map[string]struct{}{ + tpgl.Probe: {}, + tpth.Probe: {}, + tpds.Probe: {}, + tpgs.Probe: {}, + tpgg.Probe: {}, + tpsh.Probe: {}, + tprs.Probe: {}, + } + for _, f := range findings { + if _, ok := want[f.Probe]; !ok { + continue + } + if f.Outcome != finding.OutcomeTrue { + continue + } + if f.Message != "" { + out = append(out, f.Message) + } + } + return out +} + +// calculateThirdPartyScore calculates the score contribution from third-party +// secret scanning tools based on their CI execution frequency and patterns. +// +// This function analyzes how actively third-party tools are used, not just whether +// they're configured. The scoring differs by tool type because different tools have +// different intended execution patterns. +// +// Scoring policy: +// +// Periodic scanners (shhgit, repo-supervisor): +// +// These tools are designed to run on a regular schedule (e.g., nightly or weekly) +// rather than on every commit. We check the last 30 days of activity: +// - Tool ran within last 30 days → 10 points +// This indicates active, regular scanning on a reasonable schedule. +// - Tool configured but no runs in last 30 days → 1 point +// Tool exists but appears inactive or misconfigured. +// +// Commit-based scanners (Gitleaks, TruffleHog, detect-secrets, git-secrets, GGShield): +// +// These tools are typically configured to run on every push or pull request. +// We analyze the last 100 commits to calculate coverage percentage: +// - 100% coverage (ran on every commit) → 10 points +// Excellent: every code change is scanned before merge. +// - 70-99% coverage → 7 points +// Good: most commits are scanned, minor gaps acceptable. +// - 50-69% coverage → 5 points +// Fair: tool runs regularly but with significant gaps. +// - <50% coverage → 3 points +// Poor: tool runs occasionally but not consistently. +// - Tool configured but no CI run data available → 1 point +// We cannot verify the tool is actually running. +// +// Returns the highest score from all detected third-party tools. +func calculateThirdPartyScore(rawData *checker.SecretScanningData) int { + if rawData == nil || len(rawData.ThirdPartyCIInfo) == 0 { + return 1 // Tool present, but no data + } + + // Calculate the highest score from all detected tools + maxScore := 1 + for toolName, stats := range rawData.ThirdPartyCIInfo { + score := scoreForTool(toolName, stats) + if score > maxScore { + maxScore = score + } + } + + return maxScore +} + +// scoreForTool calculates the score for a specific tool +// based on its execution pattern. +func scoreForTool(toolName string, stats *checker.ToolCIStats) int { + if stats == nil || stats.TotalCommitsAnalyzed == 0 { + return 1 // Tool present but no CI data + } + + if stats.ExecutionPattern == "periodic" { + // Periodic tools: check if they ran in last 30 days + if stats.HasRecentRuns { + return 10 // Ran recently + } + return 1 // Present but not running recently + } + + // Commit-based tools: score based on coverage percentage + var coverage float64 + if stats.CommitsWithToolRun > 0 { + coverage = float64(stats.CommitsWithToolRun) / + float64(stats.TotalCommitsAnalyzed) + } + + return scoreFromCoverage(coverage) +} + +// scoreFromCoverage returns a score based on commit coverage +// percentage for commit-based tools. +func scoreFromCoverage(coverage float64) int { + switch { + case coverage >= 1.0: + return 10 // 100% coverage + case coverage >= 0.70: + return 7 // 70-99% coverage + case coverage >= 0.50: + return 5 // 50-69% coverage + case coverage > 0: + return 3 // Some coverage but less than 50% + default: + return 1 // Tool present but not running + } +} + +// formatCICoverageDetails formats CI coverage information +// for display in the reason string. +func formatCICoverageDetails(ciInfo map[string]*checker.ToolCIStats) string { + if len(ciInfo) == 0 { + return "" + } + + var details []string + for toolName, stats := range ciInfo { + if stats == nil || stats.TotalCommitsAnalyzed == 0 { + continue + } + + if stats.ExecutionPattern == "periodic" { + if stats.HasRecentRuns { + details = append( + details, + fmt.Sprintf("%s: ran recently", toolName), + ) + } else { + details = append( + details, + fmt.Sprintf("%s: no recent runs", toolName), + ) + } + } else { + // Commit-based tool + coverage := float64(stats.CommitsWithToolRun) / + float64(stats.TotalCommitsAnalyzed) * 100 + details = append( + details, + fmt.Sprintf("%s: %.0f%% coverage", toolName, coverage), + ) + } + } + + if len(details) == 0 { + return "" + } + + return " (" + strings.Join(details, ", ") + ")" +} diff --git a/checks/evaluation/secret_scanning_test.go b/checks/evaluation/secret_scanning_test.go new file mode 100644 index 00000000000..c64d5a1bc74 --- /dev/null +++ b/checks/evaluation/secret_scanning_test.go @@ -0,0 +1,1214 @@ +// 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 evaluation + +import ( + "strings" + "testing" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/hasGitHubPushProtectionEnabled" + "github.com/ossf/scorecard/v5/probes/hasGitHubSecretScanningEnabled" + "github.com/ossf/scorecard/v5/probes/hasGitLabPipelineSecretDetection" + "github.com/ossf/scorecard/v5/probes/hasGitLabPushRulesPreventSecrets" + "github.com/ossf/scorecard/v5/probes/hasGitLabSecretPushProtection" + "github.com/ossf/scorecard/v5/probes/hasThirdPartyDetectSecrets" + "github.com/ossf/scorecard/v5/probes/hasThirdPartyGGShield" + "github.com/ossf/scorecard/v5/probes/hasThirdPartyGitSecrets" + "github.com/ossf/scorecard/v5/probes/hasThirdPartyGitleaks" + "github.com/ossf/scorecard/v5/probes/hasThirdPartyRepoSupervisor" + "github.com/ossf/scorecard/v5/probes/hasThirdPartyShhGit" + "github.com/ossf/scorecard/v5/probes/hasThirdPartyTruffleHog" + scut "github.com/ossf/scorecard/v5/utests" +) + +func TestSecretScanning_GitHub(t *testing.T) { + t.Parallel() + tests := []struct { + name string + findings []finding.Finding + raw *checker.SecretScanningData + result scut.TestReturn + }{ + { + name: "GitHub native enabled, no third party", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + }, + result: scut.TestReturn{ + Score: 10, + }, + }, + { + name: "GitHub native enabled with push protection", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + result: scut.TestReturn{ + Score: 10, + }, + }, + { + name: "GitHub native enabled with third party tools", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + Message: "Gitleaks found at .github/workflows/security.yml", + }, + }, + result: scut.TestReturn{ + Score: 10, + }, + }, + { + name: "GitHub native disabled, no third party", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + }, + result: scut.TestReturn{ + Score: 0, + }, + }, + { + name: "GitHub native disabled, third party present", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + Message: "Gitleaks found at .github/workflows/security.yml", + }, + }, + result: scut.TestReturn{ + Score: 1, // Third-party tool present but no CI run data + }, + }, + { + name: "GitHub native disabled, multiple third party tools", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + Message: "Gitleaks found at .github/workflows/scan1.yml", + }, + { + Probe: hasThirdPartyTruffleHog.Probe, + Outcome: finding.OutcomeTrue, + Message: "TruffleHog found at .github/workflows/scan2.yml", + }, + }, + result: scut.TestReturn{ + Score: 1, // Multiple third-party tools present but no CI run data + }, + }, + { + name: "GitHub permission denied, no third party", + findings: []finding.Finding{ + // No GitHub native probes because we couldn't check + }, + raw: &checker.SecretScanningData{ + Platform: "github", + Evidence: []string{"source:permission_denied"}, + }, + result: scut.TestReturn{ + Score: checker.InconclusiveResultScore, + }, + }, + { + name: "GitHub permission denied, with third party", + findings: []finding.Finding{ + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + Message: "Gitleaks found at .github/workflows/security.yml", + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + Evidence: []string{"source:permission_denied"}, + ThirdPartyGitleaks: true, + }, + result: scut.TestReturn{ + Score: checker.InconclusiveResultScore, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dl := &scut.TestDetailLogger{} + got := SecretScanning("SecretScanning", tt.findings, dl, tt.raw) + scut.ValidateTestReturn(t, tt.name, &tt.result, &got, dl) + validatePermissionDenied(t, tt.raw, &got) + }) + } +} + +// validatePermissionDenied checks permission denied cases have correct messaging. +func validatePermissionDenied(t *testing.T, raw *checker.SecretScanningData, got *checker.CheckResult) { + t.Helper() + if raw == nil || raw.Platform != "github" { + return + } + for _, evidence := range raw.Evidence { + if !strings.Contains(evidence, "permission_denied") { + continue + } + // Verify the message is about insufficient permissions, not "disabled" + if !strings.Contains(got.Reason, "insufficient permissions") { + t.Errorf( + "Expected reason to mention 'insufficient permissions', got: %s", + got.Reason, + ) + } + if strings.Contains(got.Reason, "disabled") { + t.Errorf( + "Reason should not say 'disabled' for permission errors, got: %s", + got.Reason, + ) + } + if got.Score != checker.InconclusiveResultScore { + t.Errorf( + "Expected score %d for permission denied, got: %d", + checker.InconclusiveResultScore, + got.Score, + ) + } + } +} + +func TestSecretScanning_GitLab(t *testing.T) { + t.Parallel() + tests := []struct { + name string + findings []finding.Finding + result scut.TestReturn + }{ + { + name: "GitLab all enabled", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + Message: "Gitleaks found at .gitlab-ci.yml", + }, + }, + result: scut.TestReturn{ + Score: 10, // 4 + 4 + 1 + 1 = 10 (capped) + }, + }, + { + name: "GitLab only SPP", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeFalse, + }, + }, + result: scut.TestReturn{ + Score: 4, + }, + }, + { + name: "GitLab only Pipeline", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeFalse, + }, + }, + result: scut.TestReturn{ + Score: 4, + }, + }, + { + name: "GitLab SPP and Pipeline", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeFalse, + }, + }, + result: scut.TestReturn{ + Score: 8, + }, + }, + { + name: "GitLab only push rules", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + result: scut.TestReturn{ + Score: 1, + }, + }, + { + name: "GitLab only third party", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + result: scut.TestReturn{ + Score: 1, + }, + }, + { + name: "GitLab nothing enabled", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeFalse, + }, + }, + result: scut.TestReturn{ + Score: 0, + }, + }, + { + name: "GitLab multiple third party tools", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + Message: "Gitleaks found at .gitlab-ci.yml", + }, + { + Probe: hasThirdPartyTruffleHog.Probe, + Outcome: finding.OutcomeTrue, + Message: "TruffleHog found at .gitlab-ci.yml", + }, + }, + result: scut.TestReturn{ + Score: 1, // Still just 1 point for third party + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dl := &scut.TestDetailLogger{} + got := SecretScanning("SecretScanning", tt.findings, dl, nil) + scut.ValidateTestReturn(t, tt.name, &tt.result, &got, dl) + }) + } +} + +func TestSecretScanning_AllThirdPartyTools(t *testing.T) { + t.Parallel() + // Test that all seven third-party tools are detected + findings := []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyTruffleHog.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyDetectSecrets.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyGitSecrets.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyGGShield.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyShhGit.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyRepoSupervisor.Probe, + Outcome: finding.OutcomeTrue, + }, + } + + dl := &scut.TestDetailLogger{} + got := SecretScanning("SecretScanning", findings, dl, nil) + + // All seven tools present = +1 point (on GitLab path) + if got.Score != 1 { + t.Errorf( + "Expected score 1 with all third-party tools, got %d", + got.Score, + ) + } +} + +func TestSecretScanning_ScoreCapping(t *testing.T) { + t.Parallel() + // Test that GitLab score caps at 10 + findings := []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeTrue, // 4 points + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeTrue, // 4 points + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeTrue, // 1 point + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, // 1 point = 10 total + }, + } + + dl := &scut.TestDetailLogger{} + got := SecretScanning("SecretScanning", findings, dl, nil) + + if got.Score != checker.MaxResultScore { + t.Errorf( + "Expected score %d (capped), got %d", + checker.MaxResultScore, + got.Score, + ) + } +} + +func TestSecretScanning_ReasonMessages(t *testing.T) { + t.Parallel() + tests := []struct { + name string + findings []finding.Finding + wantReasonContains []string + }{ + { + name: "GitHub native enabled", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + }, + wantReasonContains: []string{"GitHub native secret scanning is enabled"}, + }, + { + name: "GitHub with push protection", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + wantReasonContains: []string{"push protection enabled"}, + }, + { + name: "GitHub disabled with third party", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + Message: "Gitleaks found at .github/workflows/security.yml", + }, + }, + wantReasonContains: []string{"disabled", "Gitleaks found"}, + }, + { + name: "GitLab all features", + findings: []finding.Finding{ + { + Probe: hasGitLabSecretPushProtection.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitLabPipelineSecretDetection.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasGitLabPushRulesPreventSecrets.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + wantReasonContains: []string{ + "Secret Push Protection: on", + "Pipeline Secret Detection: on", + "Push rules prevent_secrets: on", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dl := &scut.TestDetailLogger{} + got := SecretScanning("SecretScanning", tt.findings, dl, nil) + + for _, want := range tt.wantReasonContains { + if !stringContains(got.Reason, want) { + t.Errorf("Expected reason to contain %q, got: %s", want, got.Reason) + } + } + }) + } +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// TestSecretScanning_ThirdPartyWithCIStats tests scoring with +// CI statistics and execution patterns. +func TestSecretScanning_ThirdPartyWithCIStats(t *testing.T) { + t.Parallel() + tests := []struct { + name string + findings []finding.Finding + raw *checker.SecretScanningData + result scut.TestReturn + }{ + { + name: "Commit-based tool with 100% coverage", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + ThirdPartyGitleaks: true, + ThirdPartyCIInfo: map[string]*checker.ToolCIStats{ + "gitleaks": { + ToolName: "gitleaks", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 100, + HasRecentRuns: true, + }, + }, + }, + result: scut.TestReturn{ + Score: 10, // 100% coverage = 10 points + }, + }, + { + name: "Commit-based tool with 70% coverage", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + ThirdPartyGitleaks: true, + ThirdPartyCIInfo: map[string]*checker.ToolCIStats{ + "gitleaks": { + ToolName: "gitleaks", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 70, + HasRecentRuns: true, + }, + }, + }, + result: scut.TestReturn{ + Score: 7, // 70-99% coverage = 7 points + }, + }, + { + name: "Commit-based tool with 50% coverage", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyTruffleHog.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + ThirdPartyTruffleHog: true, + ThirdPartyCIInfo: map[string]*checker.ToolCIStats{ + "trufflehog": { + ToolName: "trufflehog", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 50, + HasRecentRuns: true, + }, + }, + }, + result: scut.TestReturn{ + Score: 5, // 50-69% coverage = 5 points + }, + }, + { + name: "Commit-based tool with 30% coverage", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyDetectSecrets.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + ThirdPartyDetectSecrets: true, + ThirdPartyCIInfo: map[string]*checker.ToolCIStats{ + "detect-secrets": { + ToolName: "detect-secrets", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 30, + HasRecentRuns: true, + }, + }, + }, + result: scut.TestReturn{ + Score: 3, // Less than 50% = 3 points + }, + }, + { + name: "Periodic tool with recent runs", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyShhGit.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + ThirdPartyShhGit: true, + ThirdPartyCIInfo: map[string]*checker.ToolCIStats{ + "shhgit": { + ToolName: "shhgit", + ExecutionPattern: "periodic", + TotalCommitsAnalyzed: 100, + HasRecentRuns: true, + }, + }, + }, + result: scut.TestReturn{ + Score: 10, // Periodic with recent runs = 10 points + }, + }, + { + name: "Periodic tool without recent runs", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyRepoSupervisor.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + ThirdPartyRepoSupervisor: true, + ThirdPartyCIInfo: map[string]*checker.ToolCIStats{ + "repo-supervisor": { + ToolName: "repo-supervisor", + ExecutionPattern: "periodic", + TotalCommitsAnalyzed: 100, + HasRecentRuns: false, + }, + }, + }, + result: scut.TestReturn{ + Score: 1, // Periodic without recent runs = 1 point + }, + }, + { + name: "Multiple tools with mixed execution patterns", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + }, + { + Probe: hasThirdPartyShhGit.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + ThirdPartyGitleaks: true, + ThirdPartyShhGit: true, + ThirdPartyCIInfo: map[string]*checker.ToolCIStats{ + "gitleaks": { + ToolName: "gitleaks", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 80, + HasRecentRuns: true, + }, + "shhgit": { + ToolName: "shhgit", + ExecutionPattern: "periodic", + TotalCommitsAnalyzed: 100, + HasRecentRuns: true, + }, + }, + }, + result: scut.TestReturn{ + Score: 10, // Best tool wins: max(7 from gitleaks, 10 from shhgit) = 10 + }, + }, + { + name: "Tool present but no CI stats", + findings: []finding.Finding{ + { + Probe: hasGitHubSecretScanningEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasGitHubPushProtectionEnabled.Probe, + Outcome: finding.OutcomeFalse, + }, + { + Probe: hasThirdPartyGitleaks.Probe, + Outcome: finding.OutcomeTrue, + }, + }, + raw: &checker.SecretScanningData{ + Platform: "github", + ThirdPartyGitleaks: true, + ThirdPartyCIInfo: map[string]*checker.ToolCIStats{}, // Empty CI stats + }, + result: scut.TestReturn{ + Score: 1, // Tool present but no CI data = 1 point + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dl := &scut.TestDetailLogger{} + got := SecretScanning("SecretScanning", tt.findings, dl, tt.raw) + scut.ValidateTestReturn(t, tt.name, &tt.result, &got, dl) + }) + } +} + +// TestScoreForTool tests the scoreForTool function +// with different execution patterns. +func TestScoreForTool(t *testing.T) { + t.Parallel() + tests := []struct { //nolint:govet // Test struct alignment not critical + name string + toolName string + stats *checker.ToolCIStats + expectedScore int + }{ + { + name: "Nil stats returns 1", + toolName: "gitleaks", + stats: nil, + expectedScore: 1, + }, + { + name: "Zero commits analyzed returns 1", + toolName: "gitleaks", + stats: &checker.ToolCIStats{ + ToolName: "gitleaks", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 0, + }, + expectedScore: 1, + }, + { + name: "Periodic with recent runs returns 10", + toolName: "shhgit", + stats: &checker.ToolCIStats{ + ToolName: "shhgit", + ExecutionPattern: "periodic", + TotalCommitsAnalyzed: 100, + HasRecentRuns: true, + }, + expectedScore: 10, + }, + { + name: "Periodic without recent runs returns 1", + toolName: "repo-supervisor", + stats: &checker.ToolCIStats{ + ToolName: "repo-supervisor", + ExecutionPattern: "periodic", + TotalCommitsAnalyzed: 100, + HasRecentRuns: false, + }, + expectedScore: 1, + }, + { + name: "Commit-based with 100% coverage returns 10", + toolName: "gitleaks", + stats: &checker.ToolCIStats{ + ToolName: "gitleaks", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 100, + }, + expectedScore: 10, + }, + { + name: "Commit-based with 90% coverage returns 7", + toolName: "trufflehog", + stats: &checker.ToolCIStats{ + ToolName: "trufflehog", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 90, + }, + expectedScore: 7, + }, + { + name: "Commit-based with 70% coverage returns 7", + toolName: "detect-secrets", + stats: &checker.ToolCIStats{ + ToolName: "detect-secrets", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 70, + }, + expectedScore: 7, + }, + { + name: "Commit-based with 65% coverage returns 5", + toolName: "git-secrets", + stats: &checker.ToolCIStats{ + ToolName: "git-secrets", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 65, + }, + expectedScore: 5, + }, + { + name: "Commit-based with 50% coverage returns 5", + toolName: "ggshield", + stats: &checker.ToolCIStats{ + ToolName: "ggshield", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 50, + }, + expectedScore: 5, + }, + { + name: "Commit-based with 25% coverage returns 3", + toolName: "gitleaks", + stats: &checker.ToolCIStats{ + ToolName: "gitleaks", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 25, + }, + expectedScore: 3, + }, + { + name: "Commit-based with 0% coverage returns 1", + toolName: "trufflehog", + stats: &checker.ToolCIStats{ + ToolName: "trufflehog", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 0, + }, + expectedScore: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + score := scoreForTool(tt.toolName, tt.stats) + if score != tt.expectedScore { + t.Errorf( + "Expected score %d, got %d", + tt.expectedScore, + score, + ) + } + }) + } +} + +// TestScoreFromCoverage tests the coverage-to-score mapping. +func TestScoreFromCoverage(t *testing.T) { + t.Parallel() + tests := []struct { + name string + coverage float64 + expectedScore int + }{ + { + name: "100% coverage", + coverage: 1.0, + expectedScore: 10, + }, + { + name: "99% coverage", + coverage: 0.99, + expectedScore: 7, + }, + { + name: "70% coverage", + coverage: 0.70, + expectedScore: 7, + }, + { + name: "69% coverage", + coverage: 0.69, + expectedScore: 5, + }, + { + name: "50% coverage", + coverage: 0.50, + expectedScore: 5, + }, + { + name: "49% coverage", + coverage: 0.49, + expectedScore: 3, + }, + { + name: "25% coverage", + coverage: 0.25, + expectedScore: 3, + }, + { + name: "1% coverage", + coverage: 0.01, + expectedScore: 3, + }, + { + name: "0% coverage", + coverage: 0.0, + expectedScore: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + score := scoreFromCoverage(tt.coverage) + if score != tt.expectedScore { + t.Errorf("For coverage %.2f%%, expected score %d, got %d", + tt.coverage*100, tt.expectedScore, score) + } + }) + } +} + +// TestFormatCICoverageDetails tests the formatting +// of CI coverage details. +func TestFormatCICoverageDetails(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ciInfo map[string]*checker.ToolCIStats + expectedSubstr []string + }{ + { + name: "Empty CI info returns empty string", + ciInfo: map[string]*checker.ToolCIStats{}, + expectedSubstr: []string{}, + }, + { + name: "Periodic tool with recent runs", + ciInfo: map[string]*checker.ToolCIStats{ + "shhgit": { + ToolName: "shhgit", + ExecutionPattern: "periodic", + TotalCommitsAnalyzed: 100, + HasRecentRuns: true, + }, + }, + expectedSubstr: []string{"shhgit: ran recently"}, + }, + { + name: "Periodic tool without recent runs", + ciInfo: map[string]*checker.ToolCIStats{ + "repo-supervisor": { + ToolName: "repo-supervisor", + ExecutionPattern: "periodic", + TotalCommitsAnalyzed: 100, + HasRecentRuns: false, + }, + }, + expectedSubstr: []string{"repo-supervisor: no recent runs"}, + }, + { + name: "Commit-based tool with coverage", + ciInfo: map[string]*checker.ToolCIStats{ + "gitleaks": { + ToolName: "gitleaks", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 75, + }, + }, + expectedSubstr: []string{"gitleaks: 75% coverage"}, + }, + { + name: "Multiple tools mixed", + ciInfo: map[string]*checker.ToolCIStats{ + "gitleaks": { + ToolName: "gitleaks", + ExecutionPattern: "commit-based", + TotalCommitsAnalyzed: 100, + CommitsWithToolRun: 80, + }, + "shhgit": { + ToolName: "shhgit", + ExecutionPattern: "periodic", + TotalCommitsAnalyzed: 100, + HasRecentRuns: true, + }, + }, + expectedSubstr: []string{"coverage", "ran recently"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := formatCICoverageDetails(tt.ciInfo) + + if len(tt.expectedSubstr) == 0 && result != "" { + t.Errorf("Expected empty string, got %q", result) + return + } + + for _, substr := range tt.expectedSubstr { + if !stringContains(result, substr) { + t.Errorf("Expected result to contain %q, got: %s", substr, result) + } + } + }) + } +} diff --git a/checks/raw/secret_scanning.go b/checks/raw/secret_scanning.go new file mode 100644 index 00000000000..4329ca1cf20 --- /dev/null +++ b/checks/raw/secret_scanning.go @@ -0,0 +1,267 @@ +// 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 ( + "fmt" + "strings" + "time" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/clients" + sce "github.com/ossf/scorecard/v5/errors" +) + +func toCheckerTri(t clients.TriState) checker.TriState { + switch t { + case clients.TriTrue: + return checker.TriTrue + case clients.TriFalse: + return checker.TriFalse + default: + return checker.TriUnknown + } +} + +func SecretScanning(c *checker.CheckRequest) (checker.SecretScanningData, error) { + s, err := c.RepoClient.GetSecretScanningSignals() + if err != nil { + return checker.SecretScanningData{}, sce.WithMessage(sce.ErrScorecardInternal, + fmt.Sprintf("GetSecretScanningSignals: %v", err)) + } + + data := checker.SecretScanningData{ + Platform: string(s.Platform), + GHNativeEnabled: toCheckerTri(s.GHNativeEnabled), + GHPushProtectionEnabled: toCheckerTri(s.GHPushProtectionEnabled), + GLPipelineSecretDetection: s.GLPipelineSecretDetection, + GLSecretPushProtection: s.GLSecretPushProtection, + GLPushRulesPreventSecrets: s.GLPushRulesPreventSecrets, + + ThirdPartyGitleaks: s.ThirdPartyGitleaks, + ThirdPartyGitleaksPaths: append([]string{}, s.ThirdPartyGitleaksPaths...), + ThirdPartyTruffleHog: s.ThirdPartyTruffleHog, + ThirdPartyTruffleHogPaths: append([]string{}, s.ThirdPartyTruffleHogPaths...), + ThirdPartyDetectSecrets: s.ThirdPartyDetectSecrets, + ThirdPartyDetectSecretsPaths: append([]string{}, s.ThirdPartyDetectSecretsPaths...), + ThirdPartyGitSecrets: s.ThirdPartyGitSecrets, + ThirdPartyGitSecretsPaths: append([]string{}, s.ThirdPartyGitSecretsPaths...), + ThirdPartyGGShield: s.ThirdPartyGGShield, + ThirdPartyGGShieldPaths: append([]string{}, s.ThirdPartyGGShieldPaths...), + ThirdPartyShhGit: s.ThirdPartyShhGit, + ThirdPartyShhGitPaths: append([]string{}, s.ThirdPartyShhGitPaths...), + ThirdPartyRepoSupervisor: s.ThirdPartyRepoSupervisor, + ThirdPartyRepoSupervisorPaths: append([]string{}, s.ThirdPartyRepoSupervisorPaths...), + + Evidence: s.Evidence, + } + + // Collect CI run statistics for third-party tools + ciStats, err := collectThirdPartyCIStats(c.RepoClient, &data) + if err != nil { + // Log but don't fail the check if CI stats collection fails + c.Dlogger.Debug(&checker.LogMessage{ + Text: fmt.Sprintf("Failed to collect third-party CI stats: %v", err), + }) + } + data.ThirdPartyCIInfo = ciStats + + return data, nil +} + +// collectThirdPartyCIStats analyzes CI runs for each third-party +// secret scanning tool to determine execution patterns +// (periodic vs commit-based) and run frequency. +func collectThirdPartyCIStats( + c clients.RepoClient, + data *checker.SecretScanningData, +) (map[string]*checker.ToolCIStats, error) { + // Initialize stats for detected tools + stats := initializeToolStats(data) + if len(stats) == 0 { + return stats, nil + } + + // Get recent commits (up to 100) + commits, err := c.ListCommits() + if err != nil { + return stats, fmt.Errorf("ListCommits: %w", err) + } + + if len(commits) == 0 { + return stats, nil + } + + // Limit to last 100 commits + if len(commits) > 100 { + commits = commits[:100] + } + + // Analyze commits for tool runs + analyzeCommitsForToolRuns(c, commits, stats) + + return stats, nil +} + +// initializeToolStats creates ToolCIStats for each +// detected third-party tool. +func initializeToolStats( + data *checker.SecretScanningData, +) map[string]*checker.ToolCIStats { + stats := make(map[string]*checker.ToolCIStats) + + // Periodic tools are expected to run on a schedule (e.g., daily/weekly) + periodicTools := map[string]bool{ + "shhgit": true, + "repo-supervisor": true, + } + + // Map detected tools to their names + detectedTools := getDetectedTools(data) + + // Initialize stats for each detected tool + for toolName := range detectedTools { + pattern := "commit-based" + if periodicTools[toolName] { + pattern = "periodic" + } + stats[toolName] = &checker.ToolCIStats{ + ToolName: toolName, + ExecutionPattern: pattern, + } + } + + return stats +} + +// getDetectedTools returns a set of detected third-party +// tools from the data. +func getDetectedTools(data *checker.SecretScanningData) map[string]bool { + detectedTools := make(map[string]bool) + if data.ThirdPartyGitleaks { + detectedTools["gitleaks"] = true + } + if data.ThirdPartyTruffleHog { + detectedTools["trufflehog"] = true + } + if data.ThirdPartyDetectSecrets { + detectedTools["detect-secrets"] = true + } + if data.ThirdPartyGitSecrets { + detectedTools["git-secrets"] = true + } + if data.ThirdPartyGGShield { + detectedTools["ggshield"] = true + } + if data.ThirdPartyShhGit { + detectedTools["shhgit"] = true + } + if data.ThirdPartyRepoSupervisor { + detectedTools["repo-supervisor"] = true + } + return detectedTools +} + +// analyzeCommitsForToolRuns checks each commit for tool runs +// and updates stats. +func analyzeCommitsForToolRuns( + c clients.RepoClient, + commits []clients.Commit, + stats map[string]*checker.ToolCIStats, +) { + thirtyDaysAgo := time.Now().AddDate(0, 0, -30) + + // Set total commits analyzed for all tools + for _, toolStats := range stats { + toolStats.TotalCommitsAnalyzed = len(commits) + } + + // Analyze each commit for tool-specific runs + for i := range commits { + commit := &commits[i] + + // Skip commits without associated merge requests + if commit.AssociatedMergeRequest.MergedAt.IsZero() { + continue + } + + checkRuns, err := c.ListCheckRunsForRef(commit.AssociatedMergeRequest.HeadSHA) + if err != nil { + // Continue on error - just skip this commit + continue + } + + updateStatsForCommit(commit, checkRuns, stats, thirtyDaysAgo) + } +} + +// updateStatsForCommit updates tool statistics based on +// check runs for a single commit. +func updateStatsForCommit( + commit *clients.Commit, + checkRuns []clients.CheckRun, + stats map[string]*checker.ToolCIStats, + thirtyDaysAgo time.Time, +) { + for toolName, toolStats := range stats { + if !hasToolInCheckRuns(toolName, checkRuns) { + continue + } + + toolStats.CommitsWithToolRun++ + + // Check if this run is within last 30 days + if commit.CommittedDate.After(thirtyDaysAgo) { + toolStats.HasRecentRuns = true + if toolStats.LastRunDate == "" || + commit.CommittedDate.String() > toolStats.LastRunDate { + toolStats.LastRunDate = commit.CommittedDate.Format("2006-01-02") + } + } + } +} + +// hasToolInCheckRuns returns true if the specific tool +// appears in the check runs. +func hasToolInCheckRuns(toolName string, checkRuns []clients.CheckRun) bool { + // Map tool names to their possible CI identifiers + toolKeywords := map[string][]string{ + "gitleaks": {"gitleaks"}, + "trufflehog": {"trufflehog", "trufflesecurity"}, + "detect-secrets": {"detect-secrets", "detect_secrets"}, + "git-secrets": {"git-secrets", "git_secrets"}, + "ggshield": {"ggshield", "gitguardian"}, + "shhgit": {"shhgit"}, + "repo-supervisor": {"repo-supervisor", "repo_supervisor", "reposupervisor"}, + } + + keywords, ok := toolKeywords[toolName] + if !ok { + return false + } + + for i := range checkRuns { + appSlug := strings.ToLower(checkRuns[i].App.Slug) + url := strings.ToLower(checkRuns[i].URL) + + for _, keyword := range keywords { + if strings.Contains(appSlug, keyword) || strings.Contains(url, keyword) { + return true + } + } + } + + return false +} diff --git a/checks/raw/secret_scanning_test.go b/checks/raw/secret_scanning_test.go new file mode 100644 index 00000000000..801b6391f5d --- /dev/null +++ b/checks/raw/secret_scanning_test.go @@ -0,0 +1,295 @@ +// 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" + + "github.com/ossf/scorecard/v5/checker" +) + +func TestInitializeToolStats(t *testing.T) { + t.Parallel() + tests := []struct { //nolint:govet // Test struct alignment not critical + name string + data *checker.SecretScanningData + expected map[string]string // map of tool name to expected execution pattern + }{ + { + name: "Commit-based tools get commit-based pattern", + data: &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + ThirdPartyTruffleHog: true, + ThirdPartyDetectSecrets: true, + ThirdPartyGitSecrets: true, + ThirdPartyGGShield: true, + }, + expected: map[string]string{ + "gitleaks": "commit-based", + "trufflehog": "commit-based", + "detect-secrets": "commit-based", + "git-secrets": "commit-based", + "ggshield": "commit-based", + }, + }, + { + name: "Periodic tools get periodic pattern", + data: &checker.SecretScanningData{ + ThirdPartyShhGit: true, + ThirdPartyRepoSupervisor: true, + }, + expected: map[string]string{ + "shhgit": "periodic", + "repo-supervisor": "periodic", + }, + }, + { + name: "Mixed tools get correct patterns", + data: &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + ThirdPartyShhGit: true, + ThirdPartyRepoSupervisor: true, + ThirdPartyTruffleHog: true, + }, + expected: map[string]string{ + "gitleaks": "commit-based", + "shhgit": "periodic", + "repo-supervisor": "periodic", + "trufflehog": "commit-based", + }, + }, + { + name: "No tools detected returns empty map", + data: &checker.SecretScanningData{}, + expected: map[string]string{}, + }, + { + name: "Single commit-based tool", + data: &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + }, + expected: map[string]string{ + "gitleaks": "commit-based", + }, + }, + { + name: "Single periodic tool", + data: &checker.SecretScanningData{ + ThirdPartyShhGit: true, + }, + expected: map[string]string{ + "shhgit": "periodic", + }, + }, + { + name: "All seven tools", + data: &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + ThirdPartyTruffleHog: true, + ThirdPartyDetectSecrets: true, + ThirdPartyGitSecrets: true, + ThirdPartyGGShield: true, + ThirdPartyShhGit: true, + ThirdPartyRepoSupervisor: true, + }, + expected: map[string]string{ + "gitleaks": "commit-based", + "trufflehog": "commit-based", + "detect-secrets": "commit-based", + "git-secrets": "commit-based", + "ggshield": "commit-based", + "shhgit": "periodic", + "repo-supervisor": "periodic", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stats := initializeToolStats(tt.data) + + // Check that we got the expected number of tools + if len(stats) != len(tt.expected) { + t.Errorf("Expected %d tools, got %d", len(tt.expected), len(stats)) + } + + // Check each tool has the correct execution pattern + for toolName, expectedPattern := range tt.expected { + toolStats, found := stats[toolName] + if !found { + t.Errorf("Tool %q not found in stats", toolName) + continue + } + + if toolStats.ExecutionPattern != expectedPattern { + t.Errorf("Tool %q: expected execution pattern %q, got %q", + toolName, expectedPattern, toolStats.ExecutionPattern) + } + + if toolStats.ToolName != toolName { + t.Errorf("Tool %q: expected ToolName to be %q, got %q", + toolName, toolName, toolStats.ToolName) + } + } + }) + } +} + +func TestGetDetectedTools(t *testing.T) { + t.Parallel() + tests := []struct { + name string + data *checker.SecretScanningData + expectedTools []string + }{ + { + name: "No tools detected", + data: &checker.SecretScanningData{}, + expectedTools: []string{}, + }, + { + name: "Only gitleaks", + data: &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + }, + expectedTools: []string{"gitleaks"}, + }, + { + name: "All commit-based tools", + data: &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + ThirdPartyTruffleHog: true, + ThirdPartyDetectSecrets: true, + ThirdPartyGitSecrets: true, + ThirdPartyGGShield: true, + }, + expectedTools: []string{ + "gitleaks", "trufflehog", "detect-secrets", + "git-secrets", "ggshield", + }, + }, + { + name: "All periodic tools", + data: &checker.SecretScanningData{ + ThirdPartyShhGit: true, + ThirdPartyRepoSupervisor: true, + }, + expectedTools: []string{"shhgit", "repo-supervisor"}, + }, + { + name: "All seven tools", + data: &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + ThirdPartyTruffleHog: true, + ThirdPartyDetectSecrets: true, + ThirdPartyGitSecrets: true, + ThirdPartyGGShield: true, + ThirdPartyShhGit: true, + ThirdPartyRepoSupervisor: true, + }, + expectedTools: []string{ + "gitleaks", "trufflehog", "detect-secrets", + "git-secrets", "ggshield", "shhgit", "repo-supervisor", + }, + }, + { + name: "Mixed tools", + data: &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + ThirdPartyTruffleHog: true, + ThirdPartyShhGit: true, + }, + expectedTools: []string{"gitleaks", "trufflehog", "shhgit"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + detectedTools := getDetectedTools(tt.data) + + if len(detectedTools) != len(tt.expectedTools) { + t.Errorf( + "Expected %d tools, got %d", + len(tt.expectedTools), + len(detectedTools), + ) + } + + for _, expectedTool := range tt.expectedTools { + if !detectedTools[expectedTool] { + t.Errorf("Expected tool %q to be detected", expectedTool) + } + } + }) + } +} + +func TestPeriodicToolsMap(t *testing.T) { + t.Parallel() + // This test verifies that the periodicTools map contains exactly the expected tools + // It's a documentation test to ensure the map doesn't drift from expectations + + expectedPeriodic := map[string]bool{ + "shhgit": true, + "repo-supervisor": true, + } + + expectedCommitBased := []string{ + "gitleaks", + "trufflehog", + "detect-secrets", + "git-secrets", + "ggshield", + } + + // Test that periodic tools are correctly identified + data := &checker.SecretScanningData{ + ThirdPartyShhGit: true, + ThirdPartyRepoSupervisor: true, + } + stats := initializeToolStats(data) + + for toolName := range expectedPeriodic { + if stats[toolName].ExecutionPattern != "periodic" { + t.Errorf( + "Tool %q should be periodic, got %q", + toolName, + stats[toolName].ExecutionPattern, + ) + } + } + + // Test that commit-based tools are correctly identified + data2 := &checker.SecretScanningData{ + ThirdPartyGitleaks: true, + ThirdPartyTruffleHog: true, + ThirdPartyDetectSecrets: true, + ThirdPartyGitSecrets: true, + ThirdPartyGGShield: true, + } + stats2 := initializeToolStats(data2) + + for _, toolName := range expectedCommitBased { + if stats2[toolName].ExecutionPattern != "commit-based" { + t.Errorf( + "Tool %q should be commit-based, got %q", + toolName, + stats2[toolName].ExecutionPattern, + ) + } + } +} diff --git a/checks/secret_scanning.go b/checks/secret_scanning.go new file mode 100644 index 00000000000..ef682b696cd --- /dev/null +++ b/checks/secret_scanning.go @@ -0,0 +1,66 @@ +// 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 checks + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/checks/evaluation" + "github.com/ossf/scorecard/v5/checks/raw" + sce "github.com/ossf/scorecard/v5/errors" + "github.com/ossf/scorecard/v5/probes" + "github.com/ossf/scorecard/v5/probes/zrunner" +) + +// CheckSecretScanning is the registered name for the Secret-Scanning check. +const CheckSecretScanning = "SecretScanning" + +//nolint:gochecknoinits +func init() { + if err := registerCheck(CheckSecretScanning, SecretScanning, nil); err != nil { + // this should never happen + panic(err) + } +} + +// SecretScanning runs the Secret-Scanning check. +func SecretScanning(c *checker.CheckRequest) checker.CheckResult { + // Collect raw data for this check. + rawData, err := raw.SecretScanning(c) + if err != nil { + e := sce.WithMessage(sce.ErrScorecardInternal, err.Error()) + return checker.CreateRuntimeErrorResult(CheckSecretScanning, e) + } + + // Store raw results so probes can read them. + pRawResults := getRawResults(c) + pRawResults.SecretScanningResults = rawData + + // Run the probes associated with this check. + findings, err := zrunner.Run(pRawResults, probes.SecretScanning) + if err != nil { + e := sce.WithMessage(sce.ErrScorecardInternal, err.Error()) + return checker.CreateRuntimeErrorResult(CheckSecretScanning, e) + } + + // Evaluate findings into a 0–10 score and reason. + ret := evaluation.SecretScanning( + CheckSecretScanning, + findings, + c.Dlogger, + &rawData, + ) + ret.Findings = findings + return ret +} diff --git a/checks/secret_scanning_test.go b/checks/secret_scanning_test.go new file mode 100644 index 00000000000..e5c34c47e99 --- /dev/null +++ b/checks/secret_scanning_test.go @@ -0,0 +1,293 @@ +// 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 checks + +import ( + "context" + "io" + "testing" + "time" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/clients" + scut "github.com/ossf/scorecard/v5/utests" +) + +func TestSecretScanning_E2E(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mockData checker.SecretScanningData + wantScore int + }{ + { + name: "GitHub native enabled", + mockData: checker.SecretScanningData{ + Platform: "github", + GHNativeEnabled: checker.TriTrue, + }, + wantScore: 10, + }, + { + name: "GitHub native disabled, gitleaks present", + mockData: checker.SecretScanningData{ + Platform: "github", + GHNativeEnabled: checker.TriFalse, + ThirdPartyGitleaks: true, + ThirdPartyGitleaksPaths: []string{".github/workflows/security.yml"}, + }, + wantScore: 1, // Third-party tool present but no CI run data + }, + { + name: "GitHub native disabled, no third party", + mockData: checker.SecretScanningData{ + Platform: "github", + GHNativeEnabled: checker.TriFalse, + }, + wantScore: 0, + }, + { + name: "GitLab all features enabled", + mockData: checker.SecretScanningData{ + Platform: "gitlab", + GLSecretPushProtection: true, + GLPipelineSecretDetection: true, + GLPushRulesPreventSecrets: true, + ThirdPartyGitleaks: true, + }, + wantScore: 10, // 4+4+1+1 capped at 10 + }, + { + name: "GitLab only SPP", + mockData: checker.SecretScanningData{ + Platform: "gitlab", + GLSecretPushProtection: true, + }, + wantScore: 4, + }, + { + name: "GitLab only pipeline", + mockData: checker.SecretScanningData{ + Platform: "gitlab", + GLPipelineSecretDetection: true, + }, + wantScore: 4, + }, + { + name: "GitLab only push rules", + mockData: checker.SecretScanningData{ + Platform: "gitlab", + GLPushRulesPreventSecrets: true, + }, + wantScore: 1, + }, + { + name: "GitLab only third party", + mockData: checker.SecretScanningData{ + Platform: "gitlab", + ThirdPartyGitleaks: true, + }, + wantScore: 1, + }, + { + name: "GitLab nothing enabled", + mockData: checker.SecretScanningData{ + Platform: "gitlab", + }, + wantScore: 0, + }, + { + name: "GitLab SPP and Pipeline", + mockData: checker.SecretScanningData{ + Platform: "gitlab", + GLSecretPushProtection: true, + GLPipelineSecretDetection: true, + }, + wantScore: 8, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockRepo := &mockedSecretScanningRepo{ + mockData: tt.mockData, + } + + req := &checker.CheckRequest{ + Ctx: context.Background(), + RepoClient: mockRepo, + Dlogger: &scut.TestDetailLogger{}, + } + + result := SecretScanning(req) + + if result.Score != tt.wantScore { + t.Errorf("Score = %d, want %d. Reason: %s", result.Score, tt.wantScore, result.Reason) + } + }) + } +} + +// mockedSecretScanningRepo implements the minimal RepoClient interface for testing. +type mockedSecretScanningRepo struct { + mockData checker.SecretScanningData +} + +func (m *mockedSecretScanningRepo) InitRepo(repo clients.Repo, commitSHA string, commitDepth int) error { + return nil +} + +func (m *mockedSecretScanningRepo) URI() string { + return "https://github.com/test/repo" +} + +func (m *mockedSecretScanningRepo) IsArchived() (bool, error) { + return false, nil +} + +func (m *mockedSecretScanningRepo) ListFiles(predicate func(string) (bool, error)) ([]string, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) GetFileReader(filename string) (io.ReadCloser, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListCommits() ([]clients.Commit, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListIssues() ([]clients.Issue, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListReleases() ([]clients.Release, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListContributors() ([]clients.User, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListCheckRunsForRef(ref string) ([]clients.CheckRun, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListStatuses(ref string) ([]clients.Status, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListWebhooks() ([]clients.Webhook, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListProgrammingLanguages() ([]clients.Language, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) ListLicenses() ([]clients.License, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) GetDefaultBranch() (*clients.BranchRef, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) GetDefaultBranchName() (string, error) { + return "main", nil +} + +func (m *mockedSecretScanningRepo) GetBranch(branch string) (*clients.BranchRef, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) GetCreatedAt() (time.Time, error) { + return time.Now(), nil +} + +func (m *mockedSecretScanningRepo) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) Search(request clients.SearchRequest) (clients.SearchResponse, error) { + return clients.SearchResponse{}, nil +} + +func (m *mockedSecretScanningRepo) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) Close() error { + return nil +} + +func (m *mockedSecretScanningRepo) LocalPath() (string, error) { + return "", nil +} + +func (m *mockedSecretScanningRepo) ListMergeRequests() ([]clients.PullRequest, error) { + return nil, nil +} + +func (m *mockedSecretScanningRepo) GetFileContent(filename string) ([]byte, error) { + return nil, nil +} + +// This is the key method we're mocking. +func (m *mockedSecretScanningRepo) GetSecretScanningSignals() ( + clients.SecretScanningSignals, + error, +) { + return clients.SecretScanningSignals{ + Platform: clients.PlatformType(m.mockData.Platform), + GHNativeEnabled: toClientsTri(m.mockData.GHNativeEnabled), + GHPushProtectionEnabled: toClientsTri(m.mockData.GHPushProtectionEnabled), + GLPipelineSecretDetection: m.mockData.GLPipelineSecretDetection, + GLSecretPushProtection: m.mockData.GLSecretPushProtection, + GLPushRulesPreventSecrets: m.mockData.GLPushRulesPreventSecrets, + ThirdPartyGitleaks: m.mockData.ThirdPartyGitleaks, + ThirdPartyGitleaksPaths: m.mockData.ThirdPartyGitleaksPaths, + ThirdPartyTruffleHog: m.mockData.ThirdPartyTruffleHog, + ThirdPartyTruffleHogPaths: m.mockData.ThirdPartyTruffleHogPaths, + ThirdPartyDetectSecrets: m.mockData.ThirdPartyDetectSecrets, + ThirdPartyDetectSecretsPaths: m.mockData.ThirdPartyDetectSecretsPaths, + ThirdPartyGitSecrets: m.mockData.ThirdPartyGitSecrets, + ThirdPartyGitSecretsPaths: m.mockData.ThirdPartyGitSecretsPaths, + ThirdPartyGGShield: m.mockData.ThirdPartyGGShield, + ThirdPartyGGShieldPaths: m.mockData.ThirdPartyGGShieldPaths, + ThirdPartyShhGit: m.mockData.ThirdPartyShhGit, + ThirdPartyShhGitPaths: m.mockData.ThirdPartyShhGitPaths, + ThirdPartyRepoSupervisor: m.mockData.ThirdPartyRepoSupervisor, + ThirdPartyRepoSupervisorPaths: m.mockData.ThirdPartyRepoSupervisorPaths, + Evidence: m.mockData.Evidence, + }, nil +} + +func toClientsTri(t checker.TriState) clients.TriState { + switch t { + case checker.TriTrue: + return clients.TriTrue + case checker.TriFalse: + return clients.TriFalse + default: + return clients.TriUnknown + } +} diff --git a/clients/azuredevopsrepo/client.go b/clients/azuredevopsrepo/client.go index bae822d3ce6..4c313003dda 100644 --- a/clients/azuredevopsrepo/client.go +++ b/clients/azuredevopsrepo/client.go @@ -238,6 +238,10 @@ func CreateAzureDevOpsClient(ctx context.Context, repo clients.Repo) (*Client, e return CreateAzureDevOpsClientWithToken(ctx, token, repo) } +func (client *Client) GetSecretScanningSignals() (clients.SecretScanningSignals, error) { + return clients.SecretScanningSignals{}, fmt.Errorf("GetSecretScanningSignals: %w", clients.ErrUnsupportedFeature) +} + func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo clients.Repo) (*Client, error) { // https://dev.azure.com/ url := "https://" + repo.Host() + "/" + strings.Split(repo.Path(), "/")[0] diff --git a/clients/git/client.go b/clients/git/client.go index 4d6812a40b7..90dce23dee0 100644 --- a/clients/git/client.go +++ b/clients/git/client.go @@ -381,3 +381,7 @@ func (c *Client) SearchCommits(request clients.SearchCommitsOptions) ([]clients. func (c *Client) LocalPath() (string, error) { return c.tempDir, nil } + +func (client *Client) GetSecretScanningSignals() (clients.SecretScanningSignals, error) { + return clients.SecretScanningSignals{}, fmt.Errorf("GetSecretScanningSignals: %w", clients.ErrUnsupportedFeature) +} diff --git a/clients/githubrepo/client.go b/clients/githubrepo/client.go index a2527db07a7..3c59c653305 100644 --- a/clients/githubrepo/client.go +++ b/clients/githubrepo/client.go @@ -45,26 +45,27 @@ type Option func(*repoClientConfig) error // Client is GitHub-specific implementation of RepoClient. type Client struct { - repourl *Repo - repo *github.Repository - repoClient *github.Client - graphClient *graphqlHandler - contributors *contributorsHandler - branches *branchesHandler - releases *releasesHandler - workflows *workflowsHandler - checkruns *checkrunsHandler - statuses *statusesHandler - search *searchHandler - searchCommits *searchCommitsHandler - webhook *webhookHandler - languages *languagesHandler - licenses *licensesHandler - git *gitfile.Handler - ctx context.Context - tarball tarballHandler - commitDepth int - gitMode bool + repourl *Repo + repo *github.Repository + repoClient *github.Client + graphClient *graphqlHandler + contributors *contributorsHandler + branches *branchesHandler + releases *releasesHandler + workflows *workflowsHandler + checkruns *checkrunsHandler + statuses *statusesHandler + search *searchHandler + searchCommits *searchCommitsHandler + webhook *webhookHandler + languages *languagesHandler + licenses *licensesHandler + git *gitfile.Handler + secretScanning *secretScanningHandler + ctx context.Context + tarball tarballHandler + commitDepth int + gitMode bool } // WithFileModeGit configures the repo client to fetch files using git. @@ -157,6 +158,8 @@ func (client *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitD // Setup licensesHandler. client.licenses.init(client.ctx, client.repourl) + client.secretScanning.init(client.ctx, client.repourl, client.repoClient) + return nil } @@ -342,6 +345,36 @@ func CreateGithubRepoClientWithTransport(ctx context.Context, rt http.RoundTripp return rc } +// GetSecretScanningSignals implements RepoClient. +func (client *Client) GetSecretScanningSignals() (clients.SecretScanningSignals, error) { + s, err := client.secretScanning.get() + if err != nil { + return clients.SecretScanningSignals{}, err + } + return clients.SecretScanningSignals{ + Platform: clients.PlatformGitHub, + GHNativeEnabled: s.NativeEnabled, + GHPushProtectionEnabled: s.PushProtectionEnabled, + + ThirdPartyGitleaks: s.TpGitleaks, + ThirdPartyTruffleHog: s.TpTruffleHog, + ThirdPartyDetectSecrets: s.TpDetectSecrets, + ThirdPartyGitSecrets: s.TpGitSecrets, + ThirdPartyGGShield: s.TpGGShield, + ThirdPartyShhGit: s.TpShhGit, + ThirdPartyRepoSupervisor: s.TpRepoSupervisor, + ThirdPartyGitleaksPaths: s.TpGitleaksPaths, + ThirdPartyTruffleHogPaths: s.TpTruffleHogPaths, + ThirdPartyDetectSecretsPaths: s.TpDetectSecretsPaths, + ThirdPartyGitSecretsPaths: s.TpGitSecretsPaths, + ThirdPartyGGShieldPaths: s.TpGGShieldPaths, + ThirdPartyShhGitPaths: s.TpShhGitPaths, + ThirdPartyRepoSupervisorPaths: s.TpRepoSupervisorPaths, + + Evidence: s.Evidence, + }, nil +} + // NewRepoClient returns a Client which implements RepoClient interface. // It can be configured with various [Option]s. func NewRepoClient(ctx context.Context, opts ...Option) (clients.RepoClient, error) { @@ -426,8 +459,9 @@ func NewRepoClient(ctx context.Context, opts ...Option) (clients.RepoClient, err tarball: tarballHandler{ httpClient: httpClient, }, - gitMode: config.gitMode, - git: &gitfile.Handler{}, + gitMode: config.gitMode, + git: &gitfile.Handler{}, + secretScanning: &secretScanningHandler{}, }, nil } diff --git a/clients/githubrepo/secret_scanning.go b/clients/githubrepo/secret_scanning.go new file mode 100644 index 00000000000..643fa5041be --- /dev/null +++ b/clients/githubrepo/secret_scanning.go @@ -0,0 +1,310 @@ +// 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 githubrepo + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/google/go-github/v53/github" + + "github.com/ossf/scorecard/v5/clients" + sce "github.com/ossf/scorecard/v5/errors" +) + +const ( + githubFileType = "file" +) + +type secretScanningHandler struct { + errSetup error + ctx context.Context + gh *github.Client + repourl *Repo + tpGitleaksPaths []string + evidence []string + tpRepoSupervisorPaths []string + tpShhGitPaths []string + tpGGShieldPaths []string + tpGitSecretsPaths []string + tpDetectSecretsPaths []string + tpTruffleHogPaths []string + nativeEnabled clients.TriState + pushProtectionEnabled clients.TriState + once sync.Once + tpRepoSupervisor bool + tpShhGit bool + tpGGShield bool + tpGitSecrets bool + tpDetectSecrets bool + tpTruffleHog bool + tpGitleaks bool +} + +func (h *secretScanningHandler) init(ctx context.Context, repourl *Repo, gh *github.Client) { + h.ctx = ctx + h.repourl = repourl + h.gh = gh +} + +func (h *secretScanningHandler) setup() error { + h.once.Do(func() { + if err := h.fetchRepoSecurityAndAnalysis(); err != nil { + h.errSetup = err + return + } + if err := h.scanActionsWorkflows(); err != nil { + h.errSetup = err + return + } + }) + return h.errSetup +} + +func (h *secretScanningHandler) get() (struct { + TpTruffleHogPaths []string + Evidence []string + TpRepoSupervisorPaths []string + TpShhGitPaths []string + TpGGShieldPaths []string + TpGitSecretsPaths []string + TpDetectSecretsPaths []string + TpGitleaksPaths []string + PushProtectionEnabled clients.TriState + NativeEnabled clients.TriState + TpDetectSecrets bool + TpRepoSupervisor bool + TpShhGit bool + TpGGShield bool + TpGitSecrets bool + TpTruffleHog bool + TpGitleaks bool +}, error, +) { + if err := h.setup(); err != nil { + return struct { + TpTruffleHogPaths []string + Evidence []string + TpRepoSupervisorPaths []string + TpShhGitPaths []string + TpGGShieldPaths []string + TpGitSecretsPaths []string + TpDetectSecretsPaths []string + TpGitleaksPaths []string + PushProtectionEnabled clients.TriState + NativeEnabled clients.TriState + TpDetectSecrets bool + TpRepoSupervisor bool + TpShhGit bool + TpGGShield bool + TpGitSecrets bool + TpTruffleHog bool + TpGitleaks bool + }{}, err + } + return struct { + TpTruffleHogPaths []string + Evidence []string + TpRepoSupervisorPaths []string + TpShhGitPaths []string + TpGGShieldPaths []string + TpGitSecretsPaths []string + TpDetectSecretsPaths []string + TpGitleaksPaths []string + PushProtectionEnabled clients.TriState + NativeEnabled clients.TriState + TpDetectSecrets bool + TpRepoSupervisor bool + TpShhGit bool + TpGGShield bool + TpGitSecrets bool + TpTruffleHog bool + TpGitleaks bool + }{ + NativeEnabled: h.nativeEnabled, + PushProtectionEnabled: h.pushProtectionEnabled, + + TpGitleaks: h.tpGitleaks, + TpTruffleHog: h.tpTruffleHog, + TpDetectSecrets: h.tpDetectSecrets, + TpGitSecrets: h.tpGitSecrets, + TpGGShield: h.tpGGShield, + TpShhGit: h.tpShhGit, + TpRepoSupervisor: h.tpRepoSupervisor, + + TpGitleaksPaths: append([]string{}, h.tpGitleaksPaths...), + TpTruffleHogPaths: append([]string{}, h.tpTruffleHogPaths...), + TpDetectSecretsPaths: append([]string{}, h.tpDetectSecretsPaths...), + TpGitSecretsPaths: append([]string{}, h.tpGitSecretsPaths...), + TpGGShieldPaths: append([]string{}, h.tpGGShieldPaths...), + TpShhGitPaths: append([]string{}, h.tpShhGitPaths...), + TpRepoSupervisorPaths: append([]string{}, h.tpRepoSupervisorPaths...), + + Evidence: append([]string{}, h.evidence...), + }, nil +} + +// --- internals --- + +//nolint:nestif // Unavoidable nested logic for checking security settings +func (h *secretScanningHandler) fetchRepoSecurityAndAnalysis() error { + repo, _, err := h.gh.Repositories.Get(h.ctx, h.repourl.owner, h.repourl.repo) + if err != nil { + // Check if this is a permission error + if strings.Contains(err.Error(), "403") || + strings.Contains(err.Error(), "forbidden") { + // Token doesn't have permissions - leave as unknown but don't fail + h.evidence = append(h.evidence, "source:permission_denied") + return nil + } + return sce.WithMessage( + sce.ErrScorecardInternal, + fmt.Sprintf("Repositories.Get: %v", err), + ) + } + if sa := repo.GetSecurityAndAnalysis(); sa != nil { + if sa.SecretScanning != nil && sa.SecretScanning.Status != nil { + if sa.SecretScanning.GetStatus() == "enabled" { + h.nativeEnabled = clients.TriTrue + } else { + h.nativeEnabled = clients.TriFalse + } + } + if sa.SecretScanningPushProtection != nil && + sa.SecretScanningPushProtection.Status != nil { + if sa.SecretScanningPushProtection.GetStatus() == "enabled" { + h.pushProtectionEnabled = clients.TriTrue + } else { + h.pushProtectionEnabled = clients.TriFalse + } + } + if h.nativeEnabled != clients.TriUnknown || + h.pushProtectionEnabled != clients.TriUnknown { + h.evidence = append(h.evidence, "source:security_and_analysis") + return nil + } + } + + // Fallback inference: check if secret scanning alerts endpoint is accessible + // This helps detect if secret scanning is enabled when SecurityAndAnalysis is not available + _, resp, err := h.gh.SecretScanning.ListAlertsForRepo( + h.ctx, + h.repourl.owner, + h.repourl.repo, + &github.SecretScanningAlertListOptions{ + ListOptions: github.ListOptions{PerPage: 1}, + }, + ) + if resp != nil && resp.StatusCode == http.StatusOK { + h.nativeEnabled = clients.TriTrue + h.evidence = append(h.evidence, "source:alerts-200") + } else if resp != nil && h.nativeEnabled == clients.TriUnknown { + h.evidence = append(h.evidence, fmt.Sprintf("source:alerts-%d", resp.StatusCode)) + } + // Ignore errors from alerts endpoint - treat as unknown + _ = err + return nil +} + +func (h *secretScanningHandler) scanActionsWorkflows() error { + // List .github/workflows directory + _, directoryContent, _, err := h.gh.Repositories.GetContents( + h.ctx, + h.repourl.owner, + h.repourl.repo, + ".github/workflows", + &github.RepositoryContentGetOptions{}, + ) + if err != nil { + // 404/no workflows directory is fine + return nil //nolint:nilerr // Intentionally ignoring error for missing workflows + } + + for _, e := range directoryContent { + if e == nil || e.GetType() != githubFileType { + continue + } + rc, _, _, err := h.gh.Repositories.GetContents( + h.ctx, + h.repourl.owner, + h.repourl.repo, + e.GetPath(), + &github.RepositoryContentGetOptions{}, + ) + if err != nil || rc == nil { + continue + } + content, err := rc.GetContent() + if err != nil { + continue + } + low := strings.ToLower(content) + p := e.GetPath() + + setIfContains( + &h.tpGitleaks, &h.tpGitleaksPaths, low, p, + []string{"gitleaks", "gitleaks/gitleaks", "gitleaks-action"}, + &h.evidence, "workflow:gitleaks", + ) + setIfContains( + &h.tpTruffleHog, &h.tpTruffleHogPaths, low, p, + []string{"trufflehog", "trufflesecurity/trufflehog"}, + &h.evidence, "workflow:trufflehog", + ) + setIfContains( + &h.tpDetectSecrets, &h.tpDetectSecretsPaths, low, p, + []string{"detect-secrets"}, + &h.evidence, "workflow:detect-secrets", + ) + setIfContains( + &h.tpGitSecrets, &h.tpGitSecretsPaths, low, p, + []string{"git-secrets"}, + &h.evidence, "workflow:git-secrets", + ) + setIfContains( + &h.tpGGShield, &h.tpGGShieldPaths, low, p, + []string{"ggshield", "gitguardian/ggshield"}, + &h.evidence, "workflow:ggshield", + ) + setIfContains( + &h.tpShhGit, &h.tpShhGitPaths, low, p, + []string{"shhgit"}, + &h.evidence, "workflow:shhgit", + ) + setIfContains( + &h.tpRepoSupervisor, &h.tpRepoSupervisorPaths, low, p, + []string{"repo-supervisor"}, + &h.evidence, "workflow:repo-supervisor", + ) + } + return nil +} + +func setIfContains( + flag *bool, paths *[]string, hay, filePath string, + needles []string, ev *[]string, tag string, +) { + for _, n := range needles { + if strings.Contains(hay, strings.ToLower(n)) { + *flag = true + *paths = append(*paths, filePath) + *ev = append(*ev, tag+"@"+filePath) + return + } + } +} diff --git a/clients/githubrepo/secret_scanning_test.go b/clients/githubrepo/secret_scanning_test.go new file mode 100644 index 00000000000..218a454408d --- /dev/null +++ b/clients/githubrepo/secret_scanning_test.go @@ -0,0 +1,579 @@ +// 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 githubrepo + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-github/v53/github" + + "github.com/ossf/scorecard/v5/clients" +) + +type secretScanningRoundTripper struct { + securityAnalysis *github.SecurityAndAnalysis + workflowFiles map[string]string + alertsStatus int +} + +func (s secretScanningRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Handle repository get request + if strings.Contains(req.URL.Path, "/repos/") && + req.Method == http.MethodGet && + !strings.Contains(req.URL.Path, "contents") && + !strings.Contains(req.URL.Path, "secret-scanning") { + repo := &github.Repository{ + SecurityAndAnalysis: s.securityAnalysis, + } + jsonResp, err := json.Marshal(repo) + if err != nil { + return nil, err + } + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(jsonResp)), + Header: make(http.Header), + }, nil + } + + // Handle secret-scanning alerts endpoint + if strings.Contains(req.URL.Path, "secret-scanning/alerts") { + resp := &http.Response{ + Status: fmt.Sprintf("%d", s.alertsStatus), + StatusCode: s.alertsStatus, + Body: io.NopCloser(bytes.NewReader([]byte("[]"))), + Header: make(http.Header), + } + // Return the response - the handler checks the status code to determine + // if secret scanning is enabled (200 = enabled) + return resp, nil + } + + // Handle individual workflow file contents (must come before directory listing) + if strings.Contains(req.URL.Path, "contents/.github/workflows/") && + req.Method == http.MethodGet { + for name, content := range s.workflowFiles { + if !strings.HasSuffix(req.URL.Path, name) { + continue + } + fileType := "file" + path := ".github/workflows/" + name + // Encode content as base64 as GitHub API does + encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) + rc := &github.RepositoryContent{ + Type: &fileType, + Path: &path, + Content: &encodedContent, + Encoding: github.String("base64"), + } + jsonResp, err := json.Marshal(rc) + if err != nil { + return nil, err + } + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(jsonResp)), + Header: make(http.Header), + }, nil + } + } + + // Handle .github/workflows directory listing + if strings.HasSuffix(req.URL.Path, "contents/.github/workflows") && + req.Method == http.MethodGet { + var entries []*github.RepositoryContent + for name := range s.workflowFiles { + fileType := "file" + path := ".github/workflows/" + name + entries = append(entries, &github.RepositoryContent{ + Type: &fileType, + Path: &path, + Name: &name, + }) + } + jsonResp, err := json.Marshal(entries) + if err != nil { + return nil, err + } + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(jsonResp)), + Header: make(http.Header), + }, nil + } + + return &http.Response{ + Status: "404 Not Found", + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte("{}"))), + Header: make(http.Header), + }, nil +} + +func TestSecretScanningHandler_NativeEnabled(t *testing.T) { + t.Parallel() + tests := []struct { + securityAnalysis *github.SecurityAndAnalysis + name string + alertsStatus int + wantNative clients.TriState + wantPushProtect clients.TriState + }{ + { + name: "both enabled", + securityAnalysis: &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: github.String("enabled"), + }, + SecretScanningPushProtection: &github.SecretScanningPushProtection{ + Status: github.String("enabled"), + }, + }, + wantNative: clients.TriTrue, + wantPushProtect: clients.TriTrue, + }, + { + name: "scanning enabled, push protection disabled", + securityAnalysis: &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: github.String("enabled"), + }, + SecretScanningPushProtection: &github.SecretScanningPushProtection{ + Status: github.String("disabled"), + }, + }, + wantNative: clients.TriTrue, + wantPushProtect: clients.TriFalse, + }, + { + name: "both disabled", + securityAnalysis: &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: github.String("disabled"), + }, + SecretScanningPushProtection: &github.SecretScanningPushProtection{ + Status: github.String("disabled"), + }, + }, + wantNative: clients.TriFalse, + wantPushProtect: clients.TriFalse, + }, + { + name: "nil security analysis, alerts 200", + securityAnalysis: nil, + alertsStatus: http.StatusOK, + wantNative: clients.TriTrue, + wantPushProtect: clients.TriUnknown, + }, + { + name: "nil security analysis, alerts 404", + securityAnalysis: nil, + alertsStatus: http.StatusNotFound, + wantNative: clients.TriUnknown, + wantPushProtect: clients.TriUnknown, + }, + { + name: "empty status strings", + securityAnalysis: &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{}, + SecretScanningPushProtection: &github.SecretScanningPushProtection{}, + }, + alertsStatus: http.StatusNotFound, + wantNative: clients.TriUnknown, + wantPushProtect: clients.TriUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + httpClient := &http.Client{ + Transport: secretScanningRoundTripper{ + securityAnalysis: tt.securityAnalysis, + alertsStatus: tt.alertsStatus, + workflowFiles: map[string]string{}, + }, + } + ghClient := github.NewClient(httpClient) + ctx := context.Background() + + handler := &secretScanningHandler{ + ctx: ctx, + gh: ghClient, + repourl: &Repo{ + owner: "test-owner", + repo: "test-repo", + }, + } + + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.NativeEnabled != tt.wantNative { + t.Errorf( + "NativeEnabled = %v, want %v", + result.NativeEnabled, + tt.wantNative, + ) + } + if result.PushProtectionEnabled != tt.wantPushProtect { + t.Errorf( + "PushProtectionEnabled = %v, want %v", + result.PushProtectionEnabled, + tt.wantPushProtect, + ) + } + }) + } +} + +//nolint:gocognit // Comprehensive test with multiple scenarios +func TestSecretScanningHandler_ThirdPartyTools(t *testing.T) { + t.Parallel() + tests := []struct { + workflowFiles map[string]string + wantPaths map[string][]string + name string + wantGitleaks bool + wantTruffleHog bool + wantDetectSecrets bool + wantGitSecrets bool + wantGGShield bool + wantShhGit bool + wantRepoSupervisor bool + }{ + { + name: "gitleaks in workflow", + workflowFiles: map[string]string{ + "security.yml": ` +name: Security +on: [push] +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: gitleaks/gitleaks-action@v2 +`, + }, + wantGitleaks: true, + wantPaths: map[string][]string{ + "gitleaks": {".github/workflows/security.yml"}, + }, + }, + { + name: "trufflehog in workflow", + workflowFiles: map[string]string{ + "scan.yml": ` +name: Scan +jobs: + trufflehog: + runs-on: ubuntu-latest + steps: + - uses: trufflesecurity/trufflehog@v3 +`, + }, + wantTruffleHog: true, + wantPaths: map[string][]string{ + "trufflehog": {".github/workflows/scan.yml"}, + }, + }, + { + name: "multiple tools", + workflowFiles: map[string]string{ + "security.yml": ` +name: Security +jobs: + secrets: + runs-on: ubuntu-latest + steps: + - uses: gitleaks/gitleaks-action@v2 + - run: detect-secrets scan +`, + }, + wantGitleaks: true, + wantDetectSecrets: true, + wantPaths: map[string][]string{ + "gitleaks": {".github/workflows/security.yml"}, + "detect-secrets": {".github/workflows/security.yml"}, + }, + }, + { + name: "case insensitive matching", + workflowFiles: map[string]string{ + "test.yml": ` +jobs: + scan: + steps: + - uses: GITLEAKS/gitleaks-action@v2 + - run: TRUFFLEHOG scan +`, + }, + wantGitleaks: true, + wantTruffleHog: true, + }, + { + name: "all tools present", + workflowFiles: map[string]string{ + "scan.yml": ` +jobs: + scan: + steps: + - uses: gitleaks/gitleaks-action@v2 + - uses: trufflesecurity/trufflehog@v3 + - run: detect-secrets scan + - run: git-secrets --scan + - uses: gitguardian/ggshield@v1 + - run: shhgit + - run: repo-supervisor scan +`, + }, + wantGitleaks: true, + wantTruffleHog: true, + wantDetectSecrets: true, + wantGitSecrets: true, + wantGGShield: true, + wantShhGit: true, + wantRepoSupervisor: true, + }, + { + name: "tool in comment should still match", + workflowFiles: map[string]string{ + "test.yml": ` +jobs: + scan: + steps: + # Using gitleaks for scanning + - run: echo "test" +`, + }, + wantGitleaks: true, + }, + { + name: "no workflows", + workflowFiles: map[string]string{}, + wantGitleaks: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + httpClient := &http.Client{ + Transport: secretScanningRoundTripper{ + securityAnalysis: &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: github.String("disabled"), + }, + }, + alertsStatus: http.StatusNotFound, + workflowFiles: tt.workflowFiles, + }, + } + ghClient := github.NewClient(httpClient) + ctx := context.Background() + + handler := &secretScanningHandler{ + ctx: ctx, + gh: ghClient, + repourl: &Repo{ + owner: "test-owner", + repo: "test-repo", + }, + } + + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.TpGitleaks != tt.wantGitleaks { + t.Errorf( + "TpGitleaks = %v, want %v", + result.TpGitleaks, + tt.wantGitleaks, + ) + } + if result.TpTruffleHog != tt.wantTruffleHog { + t.Errorf( + "TpTruffleHog = %v, want %v", + result.TpTruffleHog, + tt.wantTruffleHog, + ) + } + if result.TpDetectSecrets != tt.wantDetectSecrets { + t.Errorf( + "TpDetectSecrets = %v, want %v", + result.TpDetectSecrets, + tt.wantDetectSecrets, + ) + } + if result.TpGitSecrets != tt.wantGitSecrets { + t.Errorf( + "TpGitSecrets = %v, want %v", + result.TpGitSecrets, + tt.wantGitSecrets, + ) + } + if result.TpGGShield != tt.wantGGShield { + t.Errorf( + "TpGGShield = %v, want %v", + result.TpGGShield, + tt.wantGGShield, + ) + } + if result.TpShhGit != tt.wantShhGit { + t.Errorf( + "TpShhGit = %v, want %v", + result.TpShhGit, + tt.wantShhGit, + ) + } + if result.TpRepoSupervisor != tt.wantRepoSupervisor { + t.Errorf( + "TpRepoSupervisor = %v, want %v", + result.TpRepoSupervisor, + tt.wantRepoSupervisor, + ) + } + + // Verify paths if specified + for tool, expectedPaths := range tt.wantPaths { + var actualPaths []string + switch tool { + case "gitleaks": + actualPaths = result.TpGitleaksPaths + case "trufflehog": + actualPaths = result.TpTruffleHogPaths + case "detect-secrets": + actualPaths = result.TpDetectSecretsPaths + } + if !cmp.Equal(actualPaths, expectedPaths) { + t.Errorf("Paths for %s = %v, want %v", tool, actualPaths, expectedPaths) + } + } + }) + } +} + +func TestSecretScanningHandler_MultipleWorkflows(t *testing.T) { + t.Parallel() + httpClient := &http.Client{ + Transport: secretScanningRoundTripper{ + securityAnalysis: &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: github.String("enabled"), + }, + }, + alertsStatus: http.StatusOK, + workflowFiles: map[string]string{ + "scan1.yml": "steps:\n - uses: gitleaks/gitleaks-action@v2", + "scan2.yml": "steps:\n - run: trufflehog scan", + }, + }, + } + ghClient := github.NewClient(httpClient) + ctx := context.Background() + + handler := &secretScanningHandler{ + ctx: ctx, + gh: ghClient, + repourl: &Repo{ + owner: "test-owner", + repo: "test-repo", + }, + } + + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.TpGitleaks { + t.Error("Expected gitleaks to be detected") + } + if !result.TpTruffleHog { + t.Error("Expected trufflehog to be detected") + } + if result.NativeEnabled != clients.TriTrue { + t.Errorf( + "Expected native scanning enabled, got %v", + result.NativeEnabled, + ) + } +} + +func TestSecretScanningHandler_Evidence(t *testing.T) { + t.Parallel() + httpClient := &http.Client{ + Transport: secretScanningRoundTripper{ + securityAnalysis: &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: github.String("enabled"), + }, + }, + alertsStatus: http.StatusOK, + workflowFiles: map[string]string{ + "scan.yml": "steps:\n - uses: gitleaks/gitleaks-action@v2", + }, + }, + } + ghClient := github.NewClient(httpClient) + ctx := context.Background() + + handler := &secretScanningHandler{ + ctx: ctx, + gh: ghClient, + repourl: &Repo{ + owner: "test-owner", + repo: "test-repo", + }, + } + + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Evidence) == 0 { + t.Error("Expected evidence to be collected") + } + + // Should have evidence about security analysis + hasSecurityEvidence := false + for _, e := range result.Evidence { + if strings.Contains(e, "security_and_analysis") { + hasSecurityEvidence = true + break + } + } + if !hasSecurityEvidence { + t.Error("Expected security_and_analysis evidence") + } +} diff --git a/clients/gitlabrepo/client.go b/clients/gitlabrepo/client.go index 40289969a97..c007e04e655 100644 --- a/clients/gitlabrepo/client.go +++ b/clients/gitlabrepo/client.go @@ -36,27 +36,28 @@ var ( ) type Client struct { - repourl *Repo - repo *gitlab.Project - glClient *gitlab.Client - contributors *contributorsHandler - branches *branchesHandler - releases *releasesHandler - workflows *workflowsHandler - checkruns *checkrunsHandler - commits *commitsHandler - issues *issuesHandler - project *projectHandler - statuses *statusesHandler - search *searchHandler - searchCommits *searchCommitsHandler - webhook *webhookHandler - languages *languagesHandler - licenses *licensesHandler - tarball *tarballHandler - graphql *graphqlHandler - ctx context.Context - commitDepth int + repourl *Repo + repo *gitlab.Project + glClient *gitlab.Client + contributors *contributorsHandler + branches *branchesHandler + releases *releasesHandler + workflows *workflowsHandler + checkruns *checkrunsHandler + commits *commitsHandler + issues *issuesHandler + project *projectHandler + statuses *statusesHandler + search *searchHandler + searchCommits *searchCommitsHandler + webhook *webhookHandler + languages *languagesHandler + licenses *licensesHandler + tarball *tarballHandler + graphql *graphqlHandler + ctx context.Context + secretScanning *secretScanningHandler + commitDepth int } var errRepoAccess = errors.New("repo inaccessible") @@ -159,6 +160,8 @@ func (client *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitD // Init graphqlHandler client.graphql.init(client.ctx, client.repourl) + client.secretScanning.init(client.ctx, client.glClient, client.repourl) + return nil } @@ -331,9 +334,10 @@ func CreateGitlabClientWithToken(ctx context.Context, token, host string) (clien languages: &languagesHandler{ glClient: client, }, - licenses: &licensesHandler{}, - tarball: &tarballHandler{}, - graphql: &graphqlHandler{}, + licenses: &licensesHandler{}, + tarball: &tarballHandler{}, + graphql: &graphqlHandler{}, + secretScanning: &secretScanningHandler{}, }, nil } @@ -341,3 +345,26 @@ func CreateGitlabClientWithToken(ctx context.Context, token, host string) (clien func CreateOssFuzzRepoClient(ctx context.Context, logger *log.Logger) (clients.RepoClient, error) { return nil, fmt.Errorf("%w, oss fuzz currently only supported for github repos", clients.ErrUnsupportedFeature) } + +func (c *Client) GetSecretScanningSignals() (clients.SecretScanningSignals, error) { + r, err := c.secretScanning.get() + if err != nil { + return clients.SecretScanningSignals{}, err + } + return clients.SecretScanningSignals{ + Platform: clients.PlatformGitLab, + GLSecretPushProtection: r.SecretPushProtection, + GLPushRulesPreventSecrets: r.PushRulesPreventSecrets, + GLPipelineSecretDetection: r.PipelineSecretDetection, + + ThirdPartyGitleaks: r.TpGitleaks, + ThirdPartyTruffleHog: r.TpTruffleHog, + ThirdPartyDetectSecrets: r.TpDetectSecrets, + ThirdPartyGitSecrets: r.TpGitSecrets, + ThirdPartyGGShield: r.TpGGShield, + ThirdPartyShhGit: r.TpShhGit, + ThirdPartyRepoSupervisor: r.TpRepoSupervisor, + + Evidence: r.Evidence, + }, nil +} diff --git a/clients/gitlabrepo/secret_scanning.go b/clients/gitlabrepo/secret_scanning.go new file mode 100644 index 00000000000..d0a6bec3105 --- /dev/null +++ b/clients/gitlabrepo/secret_scanning.go @@ -0,0 +1,380 @@ +// 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 gitlabrepo + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "strings" + "sync" + + gitlab "gitlab.com/gitlab-org/api/client-go" + "gopkg.in/yaml.v3" + + sce "github.com/ossf/scorecard/v5/errors" +) + +// secretScanningHandler aggregates all GitLab-side signals related to secret scanning: +// - Project Security Settings: Secret Push Protection +// - Push rules: prevent_secrets +// - CI config (.gitlab-ci.yml): native pipeline secret detection + third-party scanners +// +// It fetches once (sync.Once), organizes the data into simple booleans and path slices, +// and exposes them via get(). +type secretScanningHandler struct { + errSetup error + ctx context.Context + repourl *Repo + glClient *gitlab.Client + tpGGShieldPaths []string + evidence []string + tpRepoSupervisorPaths []string + tpShhGitPaths []string + tpGitleaksPaths []string + tpGitSecretsPaths []string + tpDetectSecretsPaths []string + tpTruffleHogPaths []string + once sync.Once + secretPushProtection bool + tpRepoSupervisor bool + tpShhGit bool + tpGGShield bool + tpGitSecrets bool + tpDetectSecrets bool + tpTruffleHog bool + tpGitleaks bool + pipelineSecretDetection bool + pushRulesPreventSecrets bool +} + +func (h *secretScanningHandler) init(ctx context.Context, gl *gitlab.Client, repourl *Repo) { + h.ctx = ctx + h.glClient = gl + h.repourl = repourl +} + +func (h *secretScanningHandler) setup() error { + h.once.Do(func() { + if err := h.fetchProjectSPP(); err != nil { + h.errSetup = err + return + } + if err := h.fetchPushRules(); err != nil { + h.errSetup = err + return + } + if err := h.fetchCIConfig(); err != nil { + h.errSetup = err + return + } + }) + return h.errSetup +} + +func (h *secretScanningHandler) get() (struct { + TpGitleaksPaths []string + Evidence []string + TpRepoSupervisorPaths []string + TpShhGitPaths []string + TpGGShieldPaths []string + TpGitSecretsPaths []string + TpDetectSecretsPaths []string + TpTruffleHogPaths []string + TpTruffleHog bool + TpRepoSupervisor bool + TpShhGit bool + TpGGShield bool + TpGitSecrets bool + TpDetectSecrets bool + SecretPushProtection bool + TpGitleaks bool + PipelineSecretDetection bool + PushRulesPreventSecrets bool +}, error, +) { + if err := h.setup(); err != nil { + return struct { + TpGitleaksPaths []string + Evidence []string + TpRepoSupervisorPaths []string + TpShhGitPaths []string + TpGGShieldPaths []string + TpGitSecretsPaths []string + TpDetectSecretsPaths []string + TpTruffleHogPaths []string + TpTruffleHog bool + TpRepoSupervisor bool + TpShhGit bool + TpGGShield bool + TpGitSecrets bool + TpDetectSecrets bool + SecretPushProtection bool + TpGitleaks bool + PipelineSecretDetection bool + PushRulesPreventSecrets bool + }{}, err + } + + return struct { + TpGitleaksPaths []string + Evidence []string + TpRepoSupervisorPaths []string + TpShhGitPaths []string + TpGGShieldPaths []string + TpGitSecretsPaths []string + TpDetectSecretsPaths []string + TpTruffleHogPaths []string + TpTruffleHog bool + TpRepoSupervisor bool + TpShhGit bool + TpGGShield bool + TpGitSecrets bool + TpDetectSecrets bool + SecretPushProtection bool + TpGitleaks bool + PipelineSecretDetection bool + PushRulesPreventSecrets bool + }{ + SecretPushProtection: h.secretPushProtection, + PushRulesPreventSecrets: h.pushRulesPreventSecrets, + PipelineSecretDetection: h.pipelineSecretDetection, + + TpGitleaks: h.tpGitleaks, + TpTruffleHog: h.tpTruffleHog, + TpDetectSecrets: h.tpDetectSecrets, + TpGitSecrets: h.tpGitSecrets, + TpGGShield: h.tpGGShield, + TpShhGit: h.tpShhGit, + TpRepoSupervisor: h.tpRepoSupervisor, + + TpGitleaksPaths: append([]string{}, h.tpGitleaksPaths...), + TpTruffleHogPaths: append([]string{}, h.tpTruffleHogPaths...), + TpDetectSecretsPaths: append([]string{}, h.tpDetectSecretsPaths...), + TpGitSecretsPaths: append([]string{}, h.tpGitSecretsPaths...), + TpGGShieldPaths: append([]string{}, h.tpGGShieldPaths...), + TpShhGitPaths: append([]string{}, h.tpShhGitPaths...), + TpRepoSupervisorPaths: append([]string{}, h.tpRepoSupervisorPaths...), + + Evidence: append([]string{}, h.evidence...), + }, nil +} + +// --- internals --- + +// fetchProjectSPP queries the Project Security Settings API +// to read Secret Push Protection. +func (h *secretScanningHandler) fetchProjectSPP() error { + settings, resp, err := h.glClient.ProjectSecuritySettings.ListProjectSecuritySettings( + h.repourl.projectID, + ) + if err != nil { + if resp != nil && resp.Response != nil && resp.StatusCode == http.StatusNotFound { + // Feature not available / not set; not an error. + return nil + } + return sce.WithMessage(sce.ErrScorecardInternal, + fmt.Sprintf("ProjectSecuritySettings.ListProjectSecuritySettings: %v", err)) + } + if settings != nil && settings.SecretPushProtectionEnabled { + h.secretPushProtection = true + h.evidence = append(h.evidence, "project.security_settings.secret_push_protection_enabled=true") + } + return nil +} + +// fetchPushRules reads project push rules and picks +// the prevent_secrets boolean. +func (h *secretScanningHandler) fetchPushRules() error { + r, resp, err := h.glClient.Projects.GetProjectPushRules(h.repourl.projectID) + if err != nil { + if resp != nil && resp.Response != nil && resp.StatusCode == http.StatusNotFound { + // No push rules configured. + return nil + } + return sce.WithMessage(sce.ErrScorecardInternal, + fmt.Sprintf("Projects.GetProjectPushRules: %v", err)) + } + if r != nil && r.PreventSecrets { + h.pushRulesPreventSecrets = true + h.evidence = append(h.evidence, "push_rule.prevent_secrets=true") + } + return nil +} + +// fetchCIConfig downloads `.gitlab-ci.yml`, detects native secret detection include/job, +// and scans for popular third-party secret scanners. It also records the CI path. +// +//nolint:gocognit // Complex CI config parsing requires multiple checks +func (h *secretScanningHandler) fetchCIConfig() error { + ref := h.repourl.defaultBranch + file, resp, err := h.glClient.RepositoryFiles.GetFile( + h.repourl.projectID, + ".gitlab-ci.yml", + &gitlab.GetFileOptions{Ref: &ref}, + ) + if err != nil { + if resp != nil && resp.Response != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("RepositoryFiles.GetFile: %v", err)) + } + + content, err := base64.StdEncoding.DecodeString(file.Content) + if err != nil { + // If base64 decoding fails, treat as if file doesn't exist + return nil //nolint:nilerr // Intentionally ignoring decode error + } + const ciPath = ".gitlab-ci.yml" + + type anyMap = map[string]any + var root anyMap + if err := yaml.Unmarshal(content, &root); err != nil { + // Invalid YAML: ignore for detection purposes. + return nil //nolint:nilerr // Intentionally ignoring YAML parse error + } + + // Native template include. + if inc, ok := root["include"]; ok { + switch v := inc.(type) { + case []any: + for _, it := range v { + if m, ok := it.(anyMap); ok { + if tpl, ok := m["template"].(string); ok && isSecretDetectionTemplate(tpl) { + h.pipelineSecretDetection = true + h.evidence = append(h.evidence, "include.template="+tpl) + } + } + } + case anyMap: + if tpl, ok := v["template"].(string); ok && isSecretDetectionTemplate(tpl) { + h.pipelineSecretDetection = true + h.evidence = append(h.evidence, "include.template="+tpl) + } + } + } + + // Walk jobs; detect third-party tools and the native conventional job. + for k, v := range root { + // Skip non-job top-level keys. + if k == "stages" || k == "default" || k == "variables" || k == "include" || k == "workflow" { + continue + } + job, ok := v.(anyMap) + if !ok { + continue + } + + // Native job name found. + if k == "secret_detection" { + h.pipelineSecretDetection = true + h.evidence = append(h.evidence, "job:name=secret_detection") + } + + // Third-party scanners: script and image + if scripts, ok := job["script"]; ok { + setFromScripts( + ciPath, &h.tpGitleaks, &h.tpGitleaksPaths, + scripts, "gitleaks", &h.evidence, "script:gitleaks", + ) + setFromScripts( + ciPath, &h.tpTruffleHog, &h.tpTruffleHogPaths, + scripts, "trufflehog", &h.evidence, "script:trufflehog", + ) + setFromScripts( + ciPath, &h.tpDetectSecrets, &h.tpDetectSecretsPaths, + scripts, "detect-secrets", &h.evidence, "script:detect-secrets", + ) + setFromScripts( + ciPath, &h.tpGitSecrets, &h.tpGitSecretsPaths, + scripts, "git-secrets", &h.evidence, "script:git-secrets", + ) + setFromScripts( + ciPath, &h.tpGGShield, &h.tpGGShieldPaths, + scripts, "ggshield", &h.evidence, "script:ggshield", + ) + setFromScripts( + ciPath, &h.tpShhGit, &h.tpShhGitPaths, + scripts, "shhgit", &h.evidence, "script:shhgit", + ) + setFromScripts( + ciPath, &h.tpRepoSupervisor, &h.tpRepoSupervisorPaths, + scripts, "repo-supervisor", &h.evidence, "script:repo-supervisor", + ) + } + if img, ok := job["image"].(string); ok { + l := strings.ToLower(img) + checkImage( + ciPath, &h.tpGitleaks, &h.tpGitleaksPaths, l, + []string{"zricethezav/gitleaks", "gitleaks/gitleaks"}, &h.evidence, + ) + checkImage( + ciPath, &h.tpTruffleHog, &h.tpTruffleHogPaths, l, + []string{"trufflesecurity/trufflehog"}, &h.evidence, + ) + checkImage( + ciPath, &h.tpGGShield, &h.tpGGShieldPaths, l, + []string{"gitguardian/ggshield"}, &h.evidence, + ) + } + } + + return nil +} + +func isSecretDetectionTemplate(s string) bool { + s = strings.TrimSpace(strings.ToLower(s)) + return s == "security/secret-detection.gitlab-ci.yml" || + s == "jobs/secret-detection.gitlab-ci.yml" +} + +func setFromScripts( + path string, flag *bool, paths *[]string, + scripts any, needle string, ev *[]string, tag string, +) { + low := strings.ToLower(needle) + switch v := scripts.(type) { + case []any: + for _, line := range v { + if str, ok := line.(string); ok && strings.Contains(strings.ToLower(str), low) { + *flag = true + *paths = append(*paths, path) + *ev = append(*ev, tag+"@"+path) + return + } + } + case string: + if strings.Contains(strings.ToLower(v), low) { + *flag = true + *paths = append(*paths, path) + *ev = append(*ev, tag+"@"+path) + } + } +} + +func checkImage( + path string, flag *bool, paths *[]string, + img string, needles []string, ev *[]string, +) { + for _, nd := range needles { + if strings.Contains(img, nd) { + *flag = true + *paths = append(*paths, path) + *ev = append(*ev, "image:"+nd+"@"+path) + return + } + } +} diff --git a/clients/gitlabrepo/secret_scanning_test.go b/clients/gitlabrepo/secret_scanning_test.go new file mode 100644 index 00000000000..9381087fd93 --- /dev/null +++ b/clients/gitlabrepo/secret_scanning_test.go @@ -0,0 +1,584 @@ +// 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 gitlabrepo + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +func TestSecretScanningHandler_GitLabNative(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ciConfig string + secretPushProtection bool + pushRulesPreventSecrets bool + wantSPP bool + wantPushRules bool + wantPipeline bool + }{ + { + name: "all enabled", + secretPushProtection: true, + pushRulesPreventSecrets: true, + ciConfig: ` +include: + - template: Security/Secret-Detection.gitlab-ci.yml +`, + wantSPP: true, + wantPushRules: true, + wantPipeline: true, + }, + { + name: "only SPP enabled", + secretPushProtection: true, + pushRulesPreventSecrets: false, + ciConfig: "", + wantSPP: true, + wantPushRules: false, + wantPipeline: false, + }, + { + name: "only push rules enabled", + secretPushProtection: false, + pushRulesPreventSecrets: true, + ciConfig: "", + wantSPP: false, + wantPushRules: true, + wantPipeline: false, + }, + { + name: "pipeline template only", + ciConfig: ` +include: + - template: Security/Secret-Detection.gitlab-ci.yml +`, + wantSPP: false, + wantPushRules: false, + wantPipeline: true, + }, + { + name: "alternative template name", + ciConfig: ` +include: + - template: Jobs/Secret-Detection.gitlab-ci.yml +`, + wantPipeline: true, + }, + { + name: "secret_detection job", + ciConfig: ` +secret_detection: + script: + - echo "scanning" +`, + wantPipeline: true, + }, + { + name: "all disabled", + ciConfig: "", + wantSPP: false, + wantPushRules: false, + wantPipeline: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v4/projects/123/security_settings": + settings := map[string]interface{}{ + "secret_push_protection_enabled": tt.secretPushProtection, + } + _ = json.NewEncoder(w).Encode(settings) //nolint:errcheck // Test mock + case "/api/v4/projects/123/push_rule": + if !tt.pushRulesPreventSecrets { + w.WriteHeader(http.StatusNotFound) + return + } + pushRule := map[string]interface{}{ + "prevent_secrets": tt.pushRulesPreventSecrets, + } + _ = json.NewEncoder(w).Encode(pushRule) //nolint:errcheck // Test mock + case "/api/v4/projects/123/repository/files/.gitlab-ci.yml": + if tt.ciConfig == "" { + w.WriteHeader(http.StatusNotFound) + return + } + encoded := base64.StdEncoding.EncodeToString([]byte(tt.ciConfig)) + file := map[string]interface{}{ + "content": encoded, + } + _ = json.NewEncoder(w).Encode(file) //nolint:errcheck // Test mock + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + glClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + handler := &secretScanningHandler{ + glClient: glClient, + ctx: context.Background(), + repourl: &Repo{ + projectID: "123", + defaultBranch: "main", + }, + } + + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.SecretPushProtection != tt.wantSPP { + t.Errorf("SecretPushProtection = %v, want %v", result.SecretPushProtection, tt.wantSPP) + } + if result.PushRulesPreventSecrets != tt.wantPushRules { + t.Errorf( + "PushRulesPreventSecrets = %v, want %v", + result.PushRulesPreventSecrets, + tt.wantPushRules, + ) + } + if result.PipelineSecretDetection != tt.wantPipeline { + t.Errorf( + "PipelineSecretDetection = %v, want %v", + result.PipelineSecretDetection, + tt.wantPipeline, + ) + } + }) + } +} + +//nolint:gocognit // Comprehensive test with multiple scenarios +func TestSecretScanningHandler_GitLabThirdParty(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ciConfig string + wantGitleaks bool + wantTruffleHog bool + wantDetectSecrets bool + wantGitSecrets bool + wantGGShield bool + wantShhGit bool + wantRepoSupervisor bool + }{ + { + name: "gitleaks in script", + ciConfig: ` +scan: + script: + - gitleaks detect +`, + wantGitleaks: true, + }, + { + name: "gitleaks in image", + ciConfig: ` +scan: + image: zricethezav/gitleaks:latest + script: + - echo "scan" +`, + wantGitleaks: true, + }, + { + name: "trufflehog in script", + ciConfig: ` +scan: + script: + - trufflehog filesystem . +`, + wantTruffleHog: true, + }, + { + name: "trufflehog in image", + ciConfig: ` +scan: + image: trufflesecurity/trufflehog:latest + script: + - echo "scan" +`, + wantTruffleHog: true, + }, + { + name: "multiple tools in scripts", + ciConfig: ` +scan: + script: + - gitleaks detect + - detect-secrets scan + - git-secrets --scan +`, + wantGitleaks: true, + wantDetectSecrets: true, + wantGitSecrets: true, + }, + { + name: "all tools present", + ciConfig: ` +scan1: + script: + - gitleaks detect + - trufflehog filesystem +scan2: + script: + - detect-secrets scan + - git-secrets --scan + - ggshield scan +scan3: + script: + - shhgit + - repo-supervisor scan +`, + wantGitleaks: true, + wantTruffleHog: true, + wantDetectSecrets: true, + wantGitSecrets: true, + wantGGShield: true, + wantShhGit: true, + wantRepoSupervisor: true, + }, + { + name: "case insensitive", + ciConfig: ` +scan: + script: + - GITLEAKS detect + - TRUFFLEHOG scan +`, + wantGitleaks: true, + wantTruffleHog: true, + }, + { + name: "script as string instead of array", + ciConfig: ` +scan: + script: gitleaks detect +`, + wantGitleaks: true, + }, + { + name: "ggshield image", + ciConfig: ` +scan: + image: gitguardian/ggshield:latest + script: + - echo test +`, + wantGGShield: true, + }, + { + name: "no tools", + ciConfig: `scan:\n script:\n - echo "test"`, + wantGitleaks: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v4/projects/123/security_settings": + w.WriteHeader(http.StatusNotFound) + case "/api/v4/projects/123/push_rule": + w.WriteHeader(http.StatusNotFound) + case "/api/v4/projects/123/repository/files/.gitlab-ci.yml": + encoded := base64.StdEncoding.EncodeToString([]byte(tt.ciConfig)) + file := map[string]interface{}{ + "content": encoded, + } + _ = json.NewEncoder(w).Encode(file) //nolint:errcheck // Test mock + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + glClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + handler := &secretScanningHandler{ + glClient: glClient, + ctx: context.Background(), + repourl: &Repo{ + projectID: "123", + defaultBranch: "main", + }, + } + + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.TpGitleaks != tt.wantGitleaks { + t.Errorf("TpGitleaks = %v, want %v", result.TpGitleaks, tt.wantGitleaks) + } + if result.TpTruffleHog != tt.wantTruffleHog { + t.Errorf("TpTruffleHog = %v, want %v", result.TpTruffleHog, tt.wantTruffleHog) + } + if result.TpDetectSecrets != tt.wantDetectSecrets { + t.Errorf("TpDetectSecrets = %v, want %v", result.TpDetectSecrets, tt.wantDetectSecrets) + } + if result.TpGitSecrets != tt.wantGitSecrets { + t.Errorf("TpGitSecrets = %v, want %v", result.TpGitSecrets, tt.wantGitSecrets) + } + if result.TpGGShield != tt.wantGGShield { + t.Errorf("TpGGShield = %v, want %v", result.TpGGShield, tt.wantGGShield) + } + if result.TpShhGit != tt.wantShhGit { + t.Errorf("TpShhGit = %v, want %v", result.TpShhGit, tt.wantShhGit) + } + if result.TpRepoSupervisor != tt.wantRepoSupervisor { + t.Errorf("TpRepoSupervisor = %v, want %v", result.TpRepoSupervisor, tt.wantRepoSupervisor) + } + }) + } +} + +func TestSecretScanningHandler_InvalidYAML(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v4/projects/123/repository/files/.gitlab-ci.yml": + invalidYAML := "this is: not: valid: yaml: [" + encoded := base64.StdEncoding.EncodeToString([]byte(invalidYAML)) + file := map[string]interface{}{ + "content": encoded, + } + _ = json.NewEncoder(w).Encode(file) //nolint:errcheck // Test mock + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + glClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + handler := &secretScanningHandler{ + glClient: glClient, + ctx: context.Background(), + repourl: &Repo{ + projectID: "123", + defaultBranch: "main", + }, + } + + // Should not error, just skip invalid YAML + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.PipelineSecretDetection { + t.Error("Should not detect pipeline secret detection in invalid YAML") + } +} + +func TestSecretScanningHandler_Paths(t *testing.T) { + t.Parallel() + ciConfig := ` +scan: + script: + - gitleaks detect + - trufflehog scan +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v4/projects/123/repository/files/.gitlab-ci.yml": + encoded := base64.StdEncoding.EncodeToString([]byte(ciConfig)) + file := map[string]interface{}{ + "content": encoded, + } + _ = json.NewEncoder(w).Encode(file) //nolint:errcheck // Test mock + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + glClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + handler := &secretScanningHandler{ + glClient: glClient, + ctx: context.Background(), + repourl: &Repo{ + projectID: "123", + defaultBranch: "main", + }, + } + + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedPath := []string{".gitlab-ci.yml"} + if !cmp.Equal(result.TpGitleaksPaths, expectedPath) { + t.Errorf("TpGitleaksPaths = %v, want %v", result.TpGitleaksPaths, expectedPath) + } + if !cmp.Equal(result.TpTruffleHogPaths, expectedPath) { + t.Errorf("TpTruffleHogPaths = %v, want %v", result.TpTruffleHogPaths, expectedPath) + } +} + +func TestSecretScanningHandler_Evidence(t *testing.T) { + t.Parallel() + ciConfig := ` +include: + - template: Security/Secret-Detection.gitlab-ci.yml +scan: + script: + - gitleaks detect +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v4/projects/123/security_settings": + settings := map[string]interface{}{ + "secret_push_protection_enabled": true, + } + _ = json.NewEncoder(w).Encode(settings) //nolint:errcheck // Test mock + case "/api/v4/projects/123/push_rule": + pushRule := map[string]interface{}{ + "prevent_secrets": true, + } + _ = json.NewEncoder(w).Encode(pushRule) //nolint:errcheck // Test mock + case "/api/v4/projects/123/repository/files/.gitlab-ci.yml": + encoded := base64.StdEncoding.EncodeToString([]byte(ciConfig)) + file := map[string]interface{}{ + "content": encoded, + } + _ = json.NewEncoder(w).Encode(file) //nolint:errcheck // Test mock + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + glClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + handler := &secretScanningHandler{ + glClient: glClient, + ctx: context.Background(), + repourl: &Repo{ + projectID: "123", + defaultBranch: "main", + }, + } + + result, err := handler.get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Evidence) == 0 { + t.Error("Expected evidence to be collected") + } + + // Should have evidence about various settings + foundSPP := false + foundPushRules := false + foundTemplate := false + foundGitleaks := false + + for _, e := range result.Evidence { + if e == "project.security_settings.secret_push_protection_enabled=true" { + foundSPP = true + } + if e == "push_rule.prevent_secrets=true" { + foundPushRules = true + } + // The handler preserves the original casing from the YAML + if strings.HasPrefix(strings.ToLower(e), "include.template=") && + strings.Contains(strings.ToLower(e), "secret-detection.gitlab-ci.yml") { + foundTemplate = true + } + if e == "script:gitleaks@.gitlab-ci.yml" { + foundGitleaks = true + } + } + + if !foundSPP { + t.Error("Expected SPP evidence") + } + if !foundPushRules { + t.Error("Expected push rules evidence") + } + if !foundTemplate { + t.Error("Expected template evidence") + } + if !foundGitleaks { + t.Error("Expected gitleaks evidence") + } +} + +func TestIsSecretDetectionTemplate(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want bool + }{ + {"Security/Secret-Detection.gitlab-ci.yml", true}, + {"security/secret-detection.gitlab-ci.yml", true}, + {"Jobs/Secret-Detection.gitlab-ci.yml", true}, + {"jobs/secret-detection.gitlab-ci.yml", true}, + {" Security/Secret-Detection.gitlab-ci.yml ", true}, + {"Other/Template.gitlab-ci.yml", false}, + {"", false}, + {"security/sast.gitlab-ci.yml", false}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("input=%q", tt.input), func(t *testing.T) { + t.Parallel() + got := isSecretDetectionTemplate(tt.input) + if got != tt.want { + t.Errorf("isSecretDetectionTemplate(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} diff --git a/clients/localdir/client.go b/clients/localdir/client.go index c5ecda23fe3..3e4eab459bc 100644 --- a/clients/localdir/client.go +++ b/clients/localdir/client.go @@ -198,6 +198,10 @@ func (client *Client) GetDefaultBranchName() (string, error) { return "", fmt.Errorf("GetDefaultBranchName: %w", clients.ErrUnsupportedFeature) } +func (client *Client) GetSecretScanningSignals() (clients.SecretScanningSignals, error) { + return clients.SecretScanningSignals{}, fmt.Errorf("GetSecretScanningSignals: %w", clients.ErrUnsupportedFeature) +} + // ListCommits implements RepoClient.ListCommits. func (client *Client) ListCommits() ([]clients.Commit, error) { return nil, fmt.Errorf("ListCommits: %w", clients.ErrUnsupportedFeature) diff --git a/clients/mockclients/repo_client.go b/clients/mockclients/repo_client.go index b028ba22864..b8e09101e3e 100644 --- a/clients/mockclients/repo_client.go +++ b/clients/mockclients/repo_client.go @@ -414,3 +414,18 @@ func (mr *MockRepoClientMockRecorder) URI() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "URI", reflect.TypeOf((*MockRepoClient)(nil).URI)) } + +// GetSecretScanningSignals mocks base method. +func (m *MockRepoClient) GetSecretScanningSignals() (clients.SecretScanningSignals, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSecretScanningSignals") + ret0, _ := ret[0].(clients.SecretScanningSignals) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSecretScanningSignals indicates an expected call of GetSecretScanningSignals. +func (mr *MockRepoClientMockRecorder) GetSecretScanningSignals() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSecretScanningSignals", reflect.TypeOf((*MockRepoClient)(nil).GetSecretScanningSignals)) +} diff --git a/clients/ossfuzz/client.go b/clients/ossfuzz/client.go index 916ff6021b7..1c5f03570db 100644 --- a/clients/ossfuzz/client.go +++ b/clients/ossfuzz/client.go @@ -242,6 +242,10 @@ func (c *client) ListStatuses(ref string) ([]clients.Status, error) { return nil, fmt.Errorf("ListStatuses: %w", clients.ErrUnsupportedFeature) } +func (client *client) GetSecretScanningSignals() (clients.SecretScanningSignals, error) { + return clients.SecretScanningSignals{}, fmt.Errorf("GetSecretScanningSignals: %w", clients.ErrUnsupportedFeature) +} + // ListWebhooks implements RepoClient.ListWebhooks. func (c *client) ListWebhooks() ([]clients.Webhook, error) { return nil, fmt.Errorf("ListWebhooks: %w", clients.ErrUnsupportedFeature) diff --git a/clients/repo_client.go b/clients/repo_client.go index d51661e9976..fe969e61402 100644 --- a/clients/repo_client.go +++ b/clients/repo_client.go @@ -57,5 +57,6 @@ type RepoClient interface { ListProgrammingLanguages() ([]Language, error) Search(request SearchRequest) (SearchResponse, error) SearchCommits(request SearchCommitsOptions) ([]Commit, error) + GetSecretScanningSignals() (SecretScanningSignals, error) Close() error } diff --git a/clients/types_secret_scanning.go b/clients/types_secret_scanning.go new file mode 100644 index 00000000000..14be1021d15 --- /dev/null +++ b/clients/types_secret_scanning.go @@ -0,0 +1,56 @@ +// 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 clients + +// PlatformType identifies the hosting platform. +type PlatformType string + +const ( + PlatformGitHub PlatformType = "github" + PlatformGitLab PlatformType = "gitlab" +) + +// TriState is a three-valued boolean. +type TriState int + +const ( + TriUnknown TriState = iota + TriFalse + TriTrue +) + +type SecretScanningSignals struct { + Platform PlatformType + ThirdPartyDetectSecretsPaths []string + Evidence []string + ThirdPartyRepoSupervisorPaths []string + ThirdPartyShhGitPaths []string + ThirdPartyGitleaksPaths []string + ThirdPartyGGShieldPaths []string + ThirdPartyTruffleHogPaths []string + ThirdPartyGitSecretsPaths []string + GHNativeEnabled TriState + GHPushProtectionEnabled TriState + GLPushRulesPreventSecrets bool + ThirdPartyGitSecrets bool + ThirdPartyDetectSecrets bool + ThirdPartyGGShield bool + ThirdPartyTruffleHog bool + ThirdPartyShhGit bool + ThirdPartyGitleaks bool + ThirdPartyRepoSupervisor bool + GLSecretPushProtection bool + GLPipelineSecretDetection bool +} diff --git a/docs/checks.md b/docs/checks.md index 9ef31232f46..63612f65e02 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -577,6 +577,55 @@ An SBOM is published as a release artifact (5/10 points): - For GitHub, see more information [here](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security). - Alternatively, there are other tools available to generate [CycloneDX](https://cyclonedx.org/tool-center/) and [SPDX](https://spdx.dev/use/tools/) SBOMs. +## SecretScanning + +Risk: `High` (exposed credentials may lead to data breaches) + +This check determines whether the project has secret scanning enabled to detect +accidentally committed credentials, API keys, tokens, and other sensitive data. + +For **GitHub repositories**, the check verifies: +- Native GitHub secret scanning is enabled (gives full score of 10) +- GitHub push protection is enabled (prevents secrets from being pushed) +- Third-party secret scanning tools are configured and actively running in CI + +For **GitLab repositories**, the check verifies: +- Secret Push Protection is enabled (4 points) +- Pipeline Secret Detection is configured (4 points) +- Push Rules prevent secrets is enabled (1 point) +- Third-party secret scanning tools are configured and actively running (1-10 points based on CI coverage) + +For **third-party secret scanning tools**, the check categorizes them by execution pattern +and scores them accordingly: + +**Commit-based scanners** (run on every push/PR): +- Gitleaks, TruffleHog, detect-secrets, git-secrets, GitGuardian ggshield +- Scored based on coverage of last 100 commits: + - 100% coverage → 10 points + - 70-99% coverage → 7 points + - 50-69% coverage → 5 points + - <50% coverage → 3 points + - Tool present but not running → 1 point + +**Periodic scanners** (run on a schedule): +- shhgit, Repo Supervisor +- Scored based on recency: + - Ran in last 30 days → 10 points + - Tool present but not running → 1 point + +Accidentally committing secrets is a common mistake that can lead to serious +security incidents. Secret scanning helps catch these mistakes before they +can be exploited by attackers. + + +**Remediation steps** +- For GitHub repositories, enable secret scanning in your repository settings under "Code security and analysis". This is available for free on public repositories and requires GitHub Advanced Security for private repositories. +- Enable push protection to prevent secrets from being pushed to the repository in the first place. This can be configured in repository settings. +- For GitLab repositories, configure Secret Push Protection, Pipeline Secret Detection, and Push Rules in your project settings. +- For additional protection, integrate third-party secret scanning tools into your CI/CD pipeline: - Gitleaks: https://github.com/gitleaks/gitleaks - TruffleHog: https://github.com/trufflesecurity/trufflehog - detect-secrets: https://github.com/Yelp/detect-secrets - git-secrets: https://github.com/awslabs/git-secrets - GitGuardian ggshield: https://github.com/GitGuardian/ggshield +- Ensure third-party tools run on every commit or at least weekly. Configure them to run as pre-commit hooks or as part of your CI/CD pipeline on pull requests and main branch commits. +- If you accidentally commit a secret, immediately rotate the exposed credential and use tools like BFG Repo-Cleaner or git-filter-repo to remove it from Git history. + ## Security-Policy Risk: `Medium` (possible insecure reporting of vulnerabilities) diff --git a/docs/checks/internal/checks.yaml b/docs/checks/internal/checks.yaml index 58e30dc3e5f..e1911796d71 100644 --- a/docs/checks/internal/checks.yaml +++ b/docs/checks/internal/checks.yaml @@ -876,3 +876,75 @@ checks: If there is support for token authentication, set the secret in the webhook configuration. See [Setting up a webhook](https://docs.github.com/en/developers/webhooks-and-events/webhooks/creating-webhooks#setting-up-a-webhook). - >- If there is no support for token authentication, request the webhook service implement token authentication functionality by following [these directions](https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks). + + + SecretScanning: + risk: High + tags: security, vulnerability + repos: GitHub, GitLab + short: Determines if the project uses secret scanning to detect and prevent secrets in code. + description: | + Risk: `High` (exposed credentials may lead to data breaches) + + This check determines whether the project has secret scanning enabled to detect + accidentally committed credentials, API keys, tokens, and other sensitive data. + + For **GitHub repositories**, the check verifies: + - Native GitHub secret scanning is enabled (gives full score of 10) + - GitHub push protection is enabled (prevents secrets from being pushed) + - Third-party secret scanning tools are configured and actively running in CI + + For **GitLab repositories**, the check verifies: + - Secret Push Protection is enabled (4 points) + - Pipeline Secret Detection is configured (4 points) + - Push Rules prevent secrets is enabled (1 point) + - Third-party secret scanning tools are configured and actively running (1-10 points based on CI coverage) + + For **third-party secret scanning tools**, the check categorizes them by execution pattern + and scores them accordingly: + + **Commit-based scanners** (run on every push/PR): + - Gitleaks, TruffleHog, detect-secrets, git-secrets, GitGuardian ggshield + - Scored based on coverage of last 100 commits: + - 100% coverage → 10 points + - 70-99% coverage → 7 points + - 50-69% coverage → 5 points + - <50% coverage → 3 points + - Tool present but not running → 1 point + + **Periodic scanners** (run on a schedule): + - shhgit, Repo Supervisor + - Scored based on recency: + - Ran in last 30 days → 10 points + - Tool present but not running → 1 point + + Accidentally committing secrets is a common mistake that can lead to serious + security incidents. Secret scanning helps catch these mistakes before they + can be exploited by attackers. + remediation: + - >- + For GitHub repositories, enable secret scanning in your repository settings + under "Code security and analysis". This is available for free on public + repositories and requires GitHub Advanced Security for private repositories. + - >- + Enable push protection to prevent secrets from being pushed to the repository + in the first place. This can be configured in repository settings. + - >- + For GitLab repositories, configure Secret Push Protection, Pipeline Secret + Detection, and Push Rules in your project settings. + - >- + For additional protection, integrate third-party secret scanning tools into + your CI/CD pipeline: + - Gitleaks: https://github.com/gitleaks/gitleaks + - TruffleHog: https://github.com/trufflesecurity/trufflehog + - detect-secrets: https://github.com/Yelp/detect-secrets + - git-secrets: https://github.com/awslabs/git-secrets + - GitGuardian ggshield: https://github.com/GitGuardian/ggshield + - >- + Ensure third-party tools run on every commit or at least weekly. Configure + them to run as pre-commit hooks or as part of your CI/CD pipeline on pull + requests and main branch commits. + - >- + If you accidentally commit a secret, immediately rotate the exposed credential + and use tools like BFG Repo-Cleaner or git-filter-repo to remove it from + Git history. diff --git a/docs/probes.md b/docs/probes.md index 58332f306ac..23d8ff58736 100644 --- a/docs/probes.md +++ b/docs/probes.md @@ -227,6 +227,71 @@ If a license file is missing the probe returns a single OutcomeNotApplicable. If the license is not of an approved format, the probe returns a single OutcomeFalse. +## hasGitHubPushProtectionEnabled + +**Lifecycle**: beta + +**Description**: GitHub push protection is enabled + +**Motivation**: Detect whether GitHub Push Protection is enabled for the repository. + +**Implementation**: Reads RawResults.SecretScanningResults.GHPushProtectionEnabled (TriState). + +**Outcomes**: The probe emits OutcomeTrue when push protection is enabled, OutcomeFalse when disabled, and OutcomeNegative when status is inconclusive. + + +## hasGitHubSecretScanningEnabled + +**Lifecycle**: beta + +**Description**: GitHub native secret scanning is enabled + +**Motivation**: Detect whether a GitHub repository has native secret scanning enabled. + +**Implementation**: Reads RawResults.SecretScanningResults.GHNativeEnabled (TriState) produced by the repo client. + +**Outcomes**: The probe emits OutcomeTrue when secret scanning is enabled, OutcomeFalse when disabled, and OutcomeNegative when status is inconclusive. + + +## hasGitLabPipelineSecretDetection + +**Lifecycle**: beta + +**Description**: GitLab pipeline secret detection is configured + +**Motivation**: Detect whether the project runs GitLab Secret Detection in CI. + +**Implementation**: Reads RawResults.SecretScanningResults.GLPipelineSecretDetection from parsed .gitlab-ci.yml. + +**Outcomes**: The probe emits OutcomeTrue when pipeline secret detection is configured, and OutcomeFalse otherwise. + + +## hasGitLabPushRulesPreventSecrets + +**Lifecycle**: beta + +**Description**: GitLab push rule "prevent_secrets" is enabled + +**Motivation**: Detect whether the project enforces push rule prevent_secrets. + +**Implementation**: Reads RawResults.SecretScanningResults.GLPushRulesPreventSecrets. + +**Outcomes**: The probe emits OutcomeTrue when the prevent_secrets push rule is enabled, and OutcomeFalse otherwise. + + +## hasGitLabSecretPushProtection + +**Lifecycle**: beta + +**Description**: GitLab Secret Push Protection is enabled + +**Motivation**: Detect whether GitLab Secret Push Protection is enabled at the project level. + +**Implementation**: Reads RawResults.SecretScanningResults.GLSecretPushProtection via GitLab Projects API. + +**Outcomes**: The probe emits OutcomeTrue when Secret Push Protection is enabled, and OutcomeFalse otherwise. + + ## hasLicenseFile **Lifecycle**: stable @@ -340,6 +405,104 @@ If an SBOM artifact is not found, the probe returns a single OutcomeFalse. If an SBOM file is not found, the probe returns a single OutcomeFalse. +## hasThirdPartyDetectSecrets + +**Lifecycle**: beta + +**Description**: detect-secrets runs in CI + +**Motivation**: Detect whether the project runs detect-secrets in CI to catch leaked credentials. detect-secrets is a **commit-based scanner** typically configured to run on every push or pull request. The SecretScanning check scores this probe based on commit coverage: 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. + +**Implementation**: Reads RawResults.SecretScanningResults.ThirdPartyDetectSecrets from parsed CI configs. Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to calculate what percentage of commits had detect-secrets run in CI. + +**Outcomes**: The probe emits OutcomeTrue if detect-secrets is configured in CI, and OutcomeFalse otherwise. +Score depends on commit coverage, not just tool presence. + + +## hasThirdPartyGGShield + +**Lifecycle**: beta + +**Description**: GGShield runs in CI + +**Motivation**: Detect whether the project runs GitGuardian GGShield in CI to catch leaked credentials. GGShield is a **commit-based scanner** typically configured to run on every push or pull request. The SecretScanning check scores this probe based on commit coverage: 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. + +**Implementation**: Reads RawResults.SecretScanningResults.ThirdPartyGGShield from parsed CI configs. Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to calculate what percentage of commits had GGShield run in CI. + +**Outcomes**: The probe emits OutcomeTrue if GitGuardian GGShield is configured in CI, and OutcomeFalse otherwise. +Score depends on commit coverage, not just tool presence. + + +## hasThirdPartyGitSecrets + +**Lifecycle**: beta + +**Description**: git-secrets runs in CI + +**Motivation**: Detect whether the project runs git-secrets in CI to catch leaked credentials. git-secrets is a **commit-based scanner** typically configured to run on every push or pull request. The SecretScanning check scores this probe based on commit coverage: 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. + +**Implementation**: Reads RawResults.SecretScanningResults.ThirdPartyGitSecrets from parsed CI configs. Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to calculate what percentage of commits had git-secrets run in CI. + +**Outcomes**: The probe emits OutcomeTrue if git-secrets is configured in CI, and OutcomeFalse otherwise. +Score depends on commit coverage, not just tool presence. + + +## hasThirdPartyGitleaks + +**Lifecycle**: beta + +**Description**: Gitleaks runs in CI + +**Motivation**: Detect whether the project runs Gitleaks in CI to catch leaked credentials. Gitleaks is a **commit-based scanner** typically configured to run on every push or pull request. The SecretScanning check scores this probe based on commit coverage: 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. + +**Implementation**: Reads RawResults.SecretScanningResults.ThirdPartyGitleaks from parsed CI configs (.gitlab-ci.yml for GitLab; .github/workflows/*.yml for GitHub). Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to calculate what percentage of commits had Gitleaks run in CI. + +**Outcomes**: The probe emits OutcomeTrue if Gitleaks is configured in CI, and OutcomeFalse otherwise. +Score depends on commit coverage, not just tool presence. + + +## hasThirdPartyRepoSupervisor + +**Lifecycle**: beta + +**Description**: repo-supervisor runs in CI + +**Motivation**: Detect whether the project runs repo-supervisor to catch leaked credentials. repo-supervisor is a **periodic scanner** typically configured to run on a schedule (e.g., daily/weekly). The SecretScanning check scores this probe as: 10 points if ran in last 30 days, 1 point otherwise. + +**Implementation**: Reads RawResults.SecretScanningResults.ThirdPartyRepoSupervisor from parsed CI configs. Checks CI run history via ListCommits() and ListCheckRunsForRef() to determine if repo-supervisor ran within the last 30 days. + +**Outcomes**: The probe emits OutcomeTrue if repo-supervisor is configured, and OutcomeFalse otherwise. +Score is 10 if ran recently (last 30 days), otherwise 1. + + +## hasThirdPartyShhGit + +**Lifecycle**: beta + +**Description**: shhgit runs in CI + +**Motivation**: Detect whether the project runs shhgit to catch leaked credentials. shhgit is a **periodic scanner** typically configured to run on a schedule (e.g., daily/weekly). The SecretScanning check scores this probe as: 10 points if ran in last 30 days, 1 point otherwise. + +**Implementation**: Reads RawResults.SecretScanningResults.ThirdPartyShhGit from parsed CI configs. Checks CI run history via ListCommits() and ListCheckRunsForRef() to determine if shhgit ran within the last 30 days. + +**Outcomes**: The probe emits OutcomeTrue if shhgit is configured, and OutcomeFalse otherwise. +Score is 10 if ran recently (last 30 days), otherwise 1. + + +## hasThirdPartyTruffleHog + +**Lifecycle**: beta + +**Description**: TruffleHog runs in CI + +**Motivation**: Detect whether the project runs TruffleHog in CI to catch leaked credentials. TruffleHog is a **commit-based scanner** typically configured to run on every push or pull request. The SecretScanning check scores this probe based on commit coverage: 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. + +**Implementation**: Reads RawResults.SecretScanningResults.ThirdPartyTruffleHog from parsed CI configs (.gitlab-ci.yml for GitLab; .github/workflows/*.yml for GitHub). Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to calculate what percentage of commits had TruffleHog run in CI. + +**Outcomes**: The probe emits OutcomeTrue if TruffleHog is configured in CI, and OutcomeFalse otherwise. +Score depends on commit coverage, not just tool presence. + + ## hasUnverifiedBinaryArtifacts **Lifecycle**: stable diff --git a/internal/checknames/checknames.go b/internal/checknames/checknames.go index aaf6bd48327..463f59f2d6f 100644 --- a/internal/checknames/checknames.go +++ b/internal/checknames/checknames.go @@ -38,6 +38,7 @@ const ( TokenPermissions CheckName = "Token-Permissions" Vulnerabilities CheckName = "Vulnerabilities" Webhooks CheckName = "Webhooks" + SecretScanning CheckName = "SecretScanning" ) var AllValidChecks []string = []string{ @@ -61,4 +62,5 @@ var AllValidChecks []string = []string{ TokenPermissions, Vulnerabilities, Webhooks, + SecretScanning, } diff --git a/pkg/scorecard/json_raw_results_test.go b/pkg/scorecard/json_raw_results_test.go index d757a252f51..1ec2ad895b0 100644 --- a/pkg/scorecard/json_raw_results_test.go +++ b/pkg/scorecard/json_raw_results_test.go @@ -1226,12 +1226,12 @@ func intPtr(i int32) *int32 { func TestScorecardResult_AsRawJSON(t *testing.T) { t.Parallel() type fields struct { - Repo RepoInfo Date time.Time + Repo RepoInfo Scorecard ScorecardInfo Checks []checker.CheckResult - RawResults checker.RawResults Metadata []string + RawResults checker.RawResults } tests := []struct { //nolint:govet name string diff --git a/pkg/scorecard/scorecard_result.go b/pkg/scorecard/scorecard_result.go index b64a4d56b46..7436480330f 100644 --- a/pkg/scorecard/scorecard_result.go +++ b/pkg/scorecard/scorecard_result.go @@ -55,14 +55,14 @@ type RepoInfo struct { // Result struct is returned on a successful Scorecard run. type Result struct { - Repo RepoInfo Date time.Time + Repo RepoInfo Scorecard ScorecardInfo Checks []checker.CheckResult - RawResults checker.RawResults Findings []finding.Finding Metadata []string Config config.Config + RawResults checker.RawResults } // AsStringResultOption provides configuration options for string Scorecard results. @@ -408,6 +408,12 @@ func assignRawData(probeCheckName string, request *checker.CheckRequest, ret *Re return sce.WithMessage(sce.ErrScorecardInternal, err.Error()) } ret.RawResults.WebhookResults = rawData + case checks.CheckSecretScanning: + rawData, err := raw.SecretScanning(request) + if err != nil { + return sce.WithMessage(sce.ErrScorecardInternal, err.Error()) + } + ret.RawResults.SecretScanningResults = rawData default: return sce.WithMessage(sce.ErrScorecardInternal, "unknown check") } diff --git a/probes/entries.go b/probes/entries.go index 2a8e5c07b68..32955a3e3e2 100644 --- a/probes/entries.go +++ b/probes/entries.go @@ -33,6 +33,11 @@ import ( "github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowScriptInjection" "github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowUntrustedCheckout" "github.com/ossf/scorecard/v5/probes/hasFSFOrOSIApprovedLicense" + ghpp "github.com/ossf/scorecard/v5/probes/hasGitHubPushProtectionEnabled" + ghe "github.com/ossf/scorecard/v5/probes/hasGitHubSecretScanningEnabled" + glpd "github.com/ossf/scorecard/v5/probes/hasGitLabPipelineSecretDetection" + glpr "github.com/ossf/scorecard/v5/probes/hasGitLabPushRulesPreventSecrets" + glspp "github.com/ossf/scorecard/v5/probes/hasGitLabSecretPushProtection" "github.com/ossf/scorecard/v5/probes/hasLicenseFile" "github.com/ossf/scorecard/v5/probes/hasNoGitHubWorkflowPermissionUnknown" "github.com/ossf/scorecard/v5/probes/hasOSVVulnerabilities" @@ -41,6 +46,13 @@ import ( "github.com/ossf/scorecard/v5/probes/hasRecentCommits" "github.com/ossf/scorecard/v5/probes/hasReleaseSBOM" "github.com/ossf/scorecard/v5/probes/hasSBOM" + tpds "github.com/ossf/scorecard/v5/probes/hasThirdPartyDetectSecrets" + tpggh "github.com/ossf/scorecard/v5/probes/hasThirdPartyGGShield" + tpgs "github.com/ossf/scorecard/v5/probes/hasThirdPartyGitSecrets" + tpgl "github.com/ossf/scorecard/v5/probes/hasThirdPartyGitleaks" + tprs "github.com/ossf/scorecard/v5/probes/hasThirdPartyRepoSupervisor" + tpsh "github.com/ossf/scorecard/v5/probes/hasThirdPartyShhGit" + tpth "github.com/ossf/scorecard/v5/probes/hasThirdPartyTruffleHog" "github.com/ossf/scorecard/v5/probes/hasUnverifiedBinaryArtifacts" "github.com/ossf/scorecard/v5/probes/issueActivityByProjectMember" "github.com/ossf/scorecard/v5/probes/jobLevelPermissions" @@ -177,6 +189,21 @@ var ( Independent = []IndependentProbeImpl{ unsafeblock.Run, } + + SecretScanning = []ProbeImpl{ + ghe.Run, + ghpp.Run, + glpd.Run, + glspp.Run, + glpr.Run, + tpgl.Run, + tpth.Run, + tpds.Run, + tpgs.Run, + tpggh.Run, + tpsh.Run, + tprs.Run, + } ) //nolint:gochecknoinits diff --git a/probes/hasGitHubPushProtectionEnabled/def.yml b/probes/hasGitHubPushProtectionEnabled/def.yml new file mode 100644 index 00000000000..e354882b300 --- /dev/null +++ b/probes/hasGitHubPushProtectionEnabled/def.yml @@ -0,0 +1,25 @@ +# 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. + +id: hasGitHubPushProtectionEnabled +lifecycle: beta +short: GitHub push protection is enabled +motivation: Detect whether GitHub Push Protection is enabled for the repository. +implementation: > + Reads RawResults.SecretScanningResults.GHPushProtectionEnabled (TriState). +outcome: + - The probe emits OutcomeTrue when push protection is enabled, OutcomeFalse when disabled, and OutcomeNegative when status is inconclusive. +ecosystem: + languages: [all] + clients: [github] diff --git a/probes/hasGitHubPushProtectionEnabled/impl.go b/probes/hasGitHubPushProtectionEnabled/impl.go new file mode 100644 index 00000000000..4318e828378 --- /dev/null +++ b/probes/hasGitHubPushProtectionEnabled/impl.go @@ -0,0 +1,64 @@ +// 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 hasGitHubPushProtectionEnabled + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasGitHubPushProtectionEnabled" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + // Check if this is a GitHub repository + if raw.SecretScanningResults.Platform != "github" { + return []finding.Finding{{ + Probe: Probe, + Outcome: finding.OutcomeNotApplicable, + Message: "Not a GitHub repository", + }}, Probe, nil + } + + outcome := finding.OutcomeFalse + msg := "GitHub push protection is disabled" + switch raw.SecretScanningResults.GHPushProtectionEnabled { + case checker.TriTrue: + outcome = finding.OutcomeTrue + msg = "GitHub push protection is enabled" + case checker.TriFalse: + outcome = finding.OutcomeFalse + msg = "GitHub push protection is disabled" + case checker.TriUnknown: + // No OutcomeNegative in Scorecard. Use OutcomeFalse with an "inconclusive" message. + outcome = finding.OutcomeFalse + msg = "GitHub push protection status is inconclusive" + } + return []finding.Finding{{ + Probe: Probe, + Outcome: outcome, + Message: msg, + }}, Probe, nil +} diff --git a/probes/hasGitHubPushProtectionEnabled/impl_test.go b/probes/hasGitHubPushProtectionEnabled/impl_test.go new file mode 100644 index 00000000000..17cebb92a07 --- /dev/null +++ b/probes/hasGitHubPushProtectionEnabled/impl_test.go @@ -0,0 +1,106 @@ +// 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 hasGitHubPushProtectionEnabled + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "not a GitHub repository", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "gitlab", + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + name: "GitHub push protection enabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + GHPushProtectionEnabled: checker.TriTrue, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + { + name: "GitHub push protection disabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + GHPushProtectionEnabled: checker.TriFalse, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "GitHub push protection status unknown", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + GHPushProtectionEnabled: checker.TriUnknown, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasGitHubSecretScanningEnabled/def.yml b/probes/hasGitHubSecretScanningEnabled/def.yml new file mode 100644 index 00000000000..a5770c6ebe1 --- /dev/null +++ b/probes/hasGitHubSecretScanningEnabled/def.yml @@ -0,0 +1,25 @@ +# 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. + +id: hasGitHubSecretScanningEnabled +lifecycle: beta +short: GitHub native secret scanning is enabled +motivation: Detect whether a GitHub repository has native secret scanning enabled. +implementation: > + Reads RawResults.SecretScanningResults.GHNativeEnabled (TriState) produced by the repo client. +outcome: + - The probe emits OutcomeTrue when secret scanning is enabled, OutcomeFalse when disabled, and OutcomeNegative when status is inconclusive. +ecosystem: + languages: [all] + clients: [github] diff --git a/probes/hasGitHubSecretScanningEnabled/impl.go b/probes/hasGitHubSecretScanningEnabled/impl.go new file mode 100644 index 00000000000..60102e802c0 --- /dev/null +++ b/probes/hasGitHubSecretScanningEnabled/impl.go @@ -0,0 +1,64 @@ +// 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 hasGitHubSecretScanningEnabled + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasGitHubSecretScanningEnabled" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + // Check if this is a GitHub repository + if raw.SecretScanningResults.Platform != "github" { + return []finding.Finding{{ + Probe: Probe, + Outcome: finding.OutcomeNotApplicable, + Message: "Not a GitHub repository", + }}, Probe, nil + } + + outcome := finding.OutcomeFalse + msg := "GitHub secret scanning is disabled" + switch raw.SecretScanningResults.GHNativeEnabled { + case checker.TriTrue: + outcome = finding.OutcomeTrue + msg = "GitHub secret scanning is enabled" + case checker.TriFalse: + outcome = finding.OutcomeFalse + msg = "GitHub secret scanning is disabled" + case checker.TriUnknown: + // No OutcomeNegative in Scorecard. Use OutcomeFalse with an "inconclusive" message. + outcome = finding.OutcomeFalse + msg = "GitHub secret scanning status is inconclusive" + } + return []finding.Finding{{ + Probe: Probe, + Outcome: outcome, + Message: msg, + }}, Probe, nil +} diff --git a/probes/hasGitHubSecretScanningEnabled/impl_test.go b/probes/hasGitHubSecretScanningEnabled/impl_test.go new file mode 100644 index 00000000000..2822dd90f8c --- /dev/null +++ b/probes/hasGitHubSecretScanningEnabled/impl_test.go @@ -0,0 +1,106 @@ +// 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 hasGitHubSecretScanningEnabled + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "not a GitHub repository", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "gitlab", + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + name: "GitHub secret scanning enabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + GHNativeEnabled: checker.TriTrue, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + { + name: "GitHub secret scanning disabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + GHNativeEnabled: checker.TriFalse, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "GitHub secret scanning status unknown", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + GHNativeEnabled: checker.TriUnknown, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasGitLabPipelineSecretDetection/def.yml b/probes/hasGitLabPipelineSecretDetection/def.yml new file mode 100644 index 00000000000..2c1a90b2f43 --- /dev/null +++ b/probes/hasGitLabPipelineSecretDetection/def.yml @@ -0,0 +1,25 @@ +# 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. + +id: hasGitLabPipelineSecretDetection +lifecycle: beta +short: GitLab pipeline secret detection is configured +motivation: Detect whether the project runs GitLab Secret Detection in CI. +implementation: > + Reads RawResults.SecretScanningResults.GLPipelineSecretDetection from parsed .gitlab-ci.yml. +outcome: + - The probe emits OutcomeTrue when pipeline secret detection is configured, and OutcomeFalse otherwise. +ecosystem: + languages: [all] + clients: [gitlab] diff --git a/probes/hasGitLabPipelineSecretDetection/impl.go b/probes/hasGitLabPipelineSecretDetection/impl.go new file mode 100644 index 00000000000..1455cb89fc8 --- /dev/null +++ b/probes/hasGitLabPipelineSecretDetection/impl.go @@ -0,0 +1,56 @@ +// 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 hasGitLabPipelineSecretDetection + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasGitLabPipelineSecretDetection" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + // Check if this is a GitLab repository + if raw.SecretScanningResults.Platform != "gitlab" { + return []finding.Finding{{ + Probe: Probe, + Outcome: finding.OutcomeNotApplicable, + Message: "Not a GitLab repository", + }}, Probe, nil + } + + outcome := finding.OutcomeFalse + msg := "GitLab pipeline secret detection is not configured" + if raw.SecretScanningResults.GLPipelineSecretDetection { + outcome = finding.OutcomeTrue + msg = "GitLab pipeline secret detection is configured" + } + return []finding.Finding{{ + Probe: Probe, + Outcome: outcome, + Message: msg, + }}, Probe, nil +} diff --git a/probes/hasGitLabPipelineSecretDetection/impl_test.go b/probes/hasGitLabPipelineSecretDetection/impl_test.go new file mode 100644 index 00000000000..079bdb544f0 --- /dev/null +++ b/probes/hasGitLabPipelineSecretDetection/impl_test.go @@ -0,0 +1,94 @@ +// 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 hasGitLabPipelineSecretDetection + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "not a GitLab repository", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + name: "GitLab Pipeline Secret Detection enabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "gitlab", + GLPipelineSecretDetection: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + { + name: "GitLab Pipeline Secret Detection disabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "gitlab", + GLPipelineSecretDetection: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasGitLabPushRulesPreventSecrets/def.yml b/probes/hasGitLabPushRulesPreventSecrets/def.yml new file mode 100644 index 00000000000..30805100f32 --- /dev/null +++ b/probes/hasGitLabPushRulesPreventSecrets/def.yml @@ -0,0 +1,25 @@ +# 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. + +id: hasGitLabPushRulesPreventSecrets +lifecycle: beta +short: GitLab push rule "prevent_secrets" is enabled +motivation: Detect whether the project enforces push rule prevent_secrets. +implementation: > + Reads RawResults.SecretScanningResults.GLPushRulesPreventSecrets. +outcome: + - The probe emits OutcomeTrue when the prevent_secrets push rule is enabled, and OutcomeFalse otherwise. +ecosystem: + languages: [all] + clients: [gitlab] diff --git a/probes/hasGitLabPushRulesPreventSecrets/impl.go b/probes/hasGitLabPushRulesPreventSecrets/impl.go new file mode 100644 index 00000000000..3b375c872d4 --- /dev/null +++ b/probes/hasGitLabPushRulesPreventSecrets/impl.go @@ -0,0 +1,56 @@ +// 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 hasGitLabPushRulesPreventSecrets + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasGitLabPushRulesPreventSecrets" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + // Check if this is a GitLab repository + if raw.SecretScanningResults.Platform != "gitlab" { + return []finding.Finding{{ + Probe: Probe, + Outcome: finding.OutcomeNotApplicable, + Message: "Not a GitLab repository", + }}, Probe, nil + } + + outcome := finding.OutcomeFalse + msg := "GitLab push rules prevent_secrets is not enabled" + if raw.SecretScanningResults.GLPushRulesPreventSecrets { + outcome = finding.OutcomeTrue + msg = "GitLab push rules prevent_secrets is enabled" + } + return []finding.Finding{{ + Probe: Probe, + Outcome: outcome, + Message: msg, + }}, Probe, nil +} diff --git a/probes/hasGitLabPushRulesPreventSecrets/impl_test.go b/probes/hasGitLabPushRulesPreventSecrets/impl_test.go new file mode 100644 index 00000000000..5e007503d03 --- /dev/null +++ b/probes/hasGitLabPushRulesPreventSecrets/impl_test.go @@ -0,0 +1,94 @@ +// 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 hasGitLabPushRulesPreventSecrets + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "not a GitLab repository", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + name: "GitLab Push Rules Prevent Secrets enabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "gitlab", + GLPushRulesPreventSecrets: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + { + name: "GitLab Push Rules Prevent Secrets disabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "gitlab", + GLPushRulesPreventSecrets: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasGitLabSecretPushProtection/def.yml b/probes/hasGitLabSecretPushProtection/def.yml new file mode 100644 index 00000000000..024ef759f9f --- /dev/null +++ b/probes/hasGitLabSecretPushProtection/def.yml @@ -0,0 +1,25 @@ +# 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. + +id: hasGitLabSecretPushProtection +lifecycle: beta +short: GitLab Secret Push Protection is enabled +motivation: Detect whether GitLab Secret Push Protection is enabled at the project level. +implementation: > + Reads RawResults.SecretScanningResults.GLSecretPushProtection via GitLab Projects API. +outcome: + - The probe emits OutcomeTrue when Secret Push Protection is enabled, and OutcomeFalse otherwise. +ecosystem: + languages: [all] + clients: [gitlab] diff --git a/probes/hasGitLabSecretPushProtection/impl.go b/probes/hasGitLabSecretPushProtection/impl.go new file mode 100644 index 00000000000..038b89e9e2d --- /dev/null +++ b/probes/hasGitLabSecretPushProtection/impl.go @@ -0,0 +1,56 @@ +// 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 hasGitLabSecretPushProtection + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasGitLabSecretPushProtection" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + // Check if this is a GitLab repository + if raw.SecretScanningResults.Platform != "gitlab" { + return []finding.Finding{{ + Probe: Probe, + Outcome: finding.OutcomeNotApplicable, + Message: "Not a GitLab repository", + }}, Probe, nil + } + + outcome := finding.OutcomeFalse + msg := "GitLab Secret Push Protection is not enabled" + if raw.SecretScanningResults.GLSecretPushProtection { + outcome = finding.OutcomeTrue + msg = "GitLab Secret Push Protection is enabled" + } + return []finding.Finding{{ + Probe: Probe, + Outcome: outcome, + Message: msg, + }}, Probe, nil +} diff --git a/probes/hasGitLabSecretPushProtection/impl_test.go b/probes/hasGitLabSecretPushProtection/impl_test.go new file mode 100644 index 00000000000..debb7f9f0cf --- /dev/null +++ b/probes/hasGitLabSecretPushProtection/impl_test.go @@ -0,0 +1,94 @@ +// 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 hasGitLabSecretPushProtection + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "not a GitLab repository", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + name: "GitLab Secret Push Protection enabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "gitlab", + GLSecretPushProtection: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + { + name: "GitLab Secret Push Protection disabled", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "gitlab", + GLSecretPushProtection: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasThirdPartyDetectSecrets/def.yml b/probes/hasThirdPartyDetectSecrets/def.yml new file mode 100644 index 00000000000..7babc3b2b6c --- /dev/null +++ b/probes/hasThirdPartyDetectSecrets/def.yml @@ -0,0 +1,32 @@ +# 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. + +id: hasThirdPartyDetectSecrets +lifecycle: beta +short: detect-secrets runs in CI +motivation: > + Detect whether the project runs detect-secrets in CI to catch leaked credentials. + detect-secrets is a **commit-based scanner** typically configured to run on every push or pull request. + The SecretScanning check scores this probe based on commit coverage: + 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. +implementation: > + Reads RawResults.SecretScanningResults.ThirdPartyDetectSecrets from parsed CI configs. + Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to + calculate what percentage of commits had detect-secrets run in CI. +outcome: + - The probe emits OutcomeTrue if detect-secrets is configured in CI, and OutcomeFalse otherwise. + - Score depends on commit coverage, not just tool presence. +ecosystem: + languages: [all] + clients: [github, gitlab] diff --git a/probes/hasThirdPartyDetectSecrets/impl.go b/probes/hasThirdPartyDetectSecrets/impl.go new file mode 100644 index 00000000000..e1f9e6ed8af --- /dev/null +++ b/probes/hasThirdPartyDetectSecrets/impl.go @@ -0,0 +1,58 @@ +// 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 hasThirdPartyDetectSecrets + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasThirdPartyDetectSecrets" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + f := finding.Finding{ + Probe: Probe, + Outcome: finding.OutcomeFalse, + Message: "detect-secrets not detected in CI", + } + if raw.SecretScanningResults.ThirdPartyDetectSecrets { + f.Outcome = finding.OutcomeTrue + if p := first(raw.SecretScanningResults.ThirdPartyDetectSecretsPaths); p != "" { + f.Message = "detect-secrets found at " + p + f.Location = &finding.Location{Type: finding.FileTypeSource, Path: p} + } else { + f.Message = "detect-secrets detected in CI" + } + } + return []finding.Finding{f}, Probe, nil +} + +func first(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] +} diff --git a/probes/hasThirdPartyDetectSecrets/impl_test.go b/probes/hasThirdPartyDetectSecrets/impl_test.go new file mode 100644 index 00000000000..b103e2463bb --- /dev/null +++ b/probes/hasThirdPartyDetectSecrets/impl_test.go @@ -0,0 +1,83 @@ +// 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 hasThirdPartyDetectSecrets + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "detect-secrets not present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyDetectSecrets: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "detect-secrets present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyDetectSecrets: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasThirdPartyGGShield/def.yml b/probes/hasThirdPartyGGShield/def.yml new file mode 100644 index 00000000000..f6c7eb5240f --- /dev/null +++ b/probes/hasThirdPartyGGShield/def.yml @@ -0,0 +1,32 @@ +# 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. + +id: hasThirdPartyGGShield +lifecycle: beta +short: GGShield runs in CI +motivation: > + Detect whether the project runs GitGuardian GGShield in CI to catch leaked credentials. + GGShield is a **commit-based scanner** typically configured to run on every push or pull request. + The SecretScanning check scores this probe based on commit coverage: + 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. +implementation: > + Reads RawResults.SecretScanningResults.ThirdPartyGGShield from parsed CI configs. + Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to + calculate what percentage of commits had GGShield run in CI. +outcome: + - The probe emits OutcomeTrue if GitGuardian GGShield is configured in CI, and OutcomeFalse otherwise. + - Score depends on commit coverage, not just tool presence. +ecosystem: + languages: [all] + clients: [github, gitlab] diff --git a/probes/hasThirdPartyGGShield/impl.go b/probes/hasThirdPartyGGShield/impl.go new file mode 100644 index 00000000000..d4f0ec0680d --- /dev/null +++ b/probes/hasThirdPartyGGShield/impl.go @@ -0,0 +1,58 @@ +// 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 hasThirdPartyGGShield + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasThirdPartyGGShield" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + f := finding.Finding{ + Probe: Probe, + Outcome: finding.OutcomeFalse, + Message: "GGShield not detected in CI", + } + if raw.SecretScanningResults.ThirdPartyGGShield { + f.Outcome = finding.OutcomeTrue + if p := first(raw.SecretScanningResults.ThirdPartyGGShieldPaths); p != "" { + f.Message = "GGShield found at " + p + f.Location = &finding.Location{Type: finding.FileTypeSource, Path: p} + } else { + f.Message = "GGShield detected in CI" + } + } + return []finding.Finding{f}, Probe, nil +} + +func first(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] +} diff --git a/probes/hasThirdPartyGGShield/impl_test.go b/probes/hasThirdPartyGGShield/impl_test.go new file mode 100644 index 00000000000..158fa6ae8ee --- /dev/null +++ b/probes/hasThirdPartyGGShield/impl_test.go @@ -0,0 +1,83 @@ +// 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 hasThirdPartyGGShield + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "GGShield not present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyGGShield: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "GGShield present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyGGShield: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasThirdPartyGitSecrets/def.yml b/probes/hasThirdPartyGitSecrets/def.yml new file mode 100644 index 00000000000..0e8178abcea --- /dev/null +++ b/probes/hasThirdPartyGitSecrets/def.yml @@ -0,0 +1,32 @@ +# 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. + +id: hasThirdPartyGitSecrets +lifecycle: beta +short: git-secrets runs in CI +motivation: > + Detect whether the project runs git-secrets in CI to catch leaked credentials. + git-secrets is a **commit-based scanner** typically configured to run on every push or pull request. + The SecretScanning check scores this probe based on commit coverage: + 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. +implementation: > + Reads RawResults.SecretScanningResults.ThirdPartyGitSecrets from parsed CI configs. + Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to + calculate what percentage of commits had git-secrets run in CI. +outcome: + - The probe emits OutcomeTrue if git-secrets is configured in CI, and OutcomeFalse otherwise. + - Score depends on commit coverage, not just tool presence. +ecosystem: + languages: [all] + clients: [github, gitlab] diff --git a/probes/hasThirdPartyGitSecrets/impl.go b/probes/hasThirdPartyGitSecrets/impl.go new file mode 100644 index 00000000000..9c5e2b78841 --- /dev/null +++ b/probes/hasThirdPartyGitSecrets/impl.go @@ -0,0 +1,58 @@ +// 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 hasThirdPartyGitSecrets + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasThirdPartyGitSecrets" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + f := finding.Finding{ + Probe: Probe, + Outcome: finding.OutcomeFalse, + Message: "git-secrets not detected in CI", + } + if raw.SecretScanningResults.ThirdPartyGitSecrets { + f.Outcome = finding.OutcomeTrue + if p := first(raw.SecretScanningResults.ThirdPartyGitSecretsPaths); p != "" { + f.Message = "git-secrets found at " + p + f.Location = &finding.Location{Type: finding.FileTypeSource, Path: p} + } else { + f.Message = "git-secrets detected in CI" + } + } + return []finding.Finding{f}, Probe, nil +} + +func first(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] +} diff --git a/probes/hasThirdPartyGitSecrets/impl_test.go b/probes/hasThirdPartyGitSecrets/impl_test.go new file mode 100644 index 00000000000..845a2484d5c --- /dev/null +++ b/probes/hasThirdPartyGitSecrets/impl_test.go @@ -0,0 +1,83 @@ +// 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 hasThirdPartyGitSecrets + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "git-secrets not present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyGitSecrets: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "git-secrets present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyGitSecrets: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasThirdPartyGitleaks/def.yml b/probes/hasThirdPartyGitleaks/def.yml new file mode 100644 index 00000000000..2e7a17af37b --- /dev/null +++ b/probes/hasThirdPartyGitleaks/def.yml @@ -0,0 +1,33 @@ +# 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. + +id: hasThirdPartyGitleaks +lifecycle: beta +short: Gitleaks runs in CI +motivation: > + Detect whether the project runs Gitleaks in CI to catch leaked credentials. + Gitleaks is a **commit-based scanner** typically configured to run on every push or pull request. + The SecretScanning check scores this probe based on commit coverage: + 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. +implementation: > + Reads RawResults.SecretScanningResults.ThirdPartyGitleaks from parsed CI configs + (.gitlab-ci.yml for GitLab; .github/workflows/*.yml for GitHub). + Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to + calculate what percentage of commits had Gitleaks run in CI. +outcome: + - The probe emits OutcomeTrue if Gitleaks is configured in CI, and OutcomeFalse otherwise. + - Score depends on commit coverage, not just tool presence. +ecosystem: + languages: [all] + clients: [github, gitlab] diff --git a/probes/hasThirdPartyGitleaks/impl.go b/probes/hasThirdPartyGitleaks/impl.go new file mode 100644 index 00000000000..d5291139b40 --- /dev/null +++ b/probes/hasThirdPartyGitleaks/impl.go @@ -0,0 +1,58 @@ +// 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 hasThirdPartyGitleaks + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasThirdPartyGitleaks" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + f := finding.Finding{ + Probe: Probe, + Outcome: finding.OutcomeFalse, + Message: "Gitleaks not detected in CI", + } + if raw.SecretScanningResults.ThirdPartyGitleaks { + f.Outcome = finding.OutcomeTrue + if p := first(raw.SecretScanningResults.ThirdPartyGitleaksPaths); p != "" { + f.Message = "Gitleaks found at " + p + f.Location = &finding.Location{Type: finding.FileTypeSource, Path: p} + } else { + f.Message = "Gitleaks detected in CI" + } + } + return []finding.Finding{f}, Probe, nil +} + +func first(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] +} diff --git a/probes/hasThirdPartyGitleaks/impl_test.go b/probes/hasThirdPartyGitleaks/impl_test.go new file mode 100644 index 00000000000..b9c13d3b2c5 --- /dev/null +++ b/probes/hasThirdPartyGitleaks/impl_test.go @@ -0,0 +1,83 @@ +// 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 hasThirdPartyGitleaks + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "Gitleaks not present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyGitleaks: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "Gitleaks present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyGitleaks: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasThirdPartyRepoSupervisor/def.yml b/probes/hasThirdPartyRepoSupervisor/def.yml new file mode 100644 index 00000000000..0b1d9122a2f --- /dev/null +++ b/probes/hasThirdPartyRepoSupervisor/def.yml @@ -0,0 +1,31 @@ +# 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. + +id: hasThirdPartyRepoSupervisor +lifecycle: beta +short: repo-supervisor runs in CI +motivation: > + Detect whether the project runs repo-supervisor to catch leaked credentials. + repo-supervisor is a **periodic scanner** typically configured to run on a schedule (e.g., daily/weekly). + The SecretScanning check scores this probe as: 10 points if ran in last 30 days, 1 point otherwise. +implementation: > + Reads RawResults.SecretScanningResults.ThirdPartyRepoSupervisor from parsed CI configs. + Checks CI run history via ListCommits() and ListCheckRunsForRef() to determine if + repo-supervisor ran within the last 30 days. +outcome: + - The probe emits OutcomeTrue if repo-supervisor is configured, and OutcomeFalse otherwise. + - Score is 10 if ran recently (last 30 days), otherwise 1. +ecosystem: + languages: [all] + clients: [github, gitlab] diff --git a/probes/hasThirdPartyRepoSupervisor/impl.go b/probes/hasThirdPartyRepoSupervisor/impl.go new file mode 100644 index 00000000000..7f0277256ce --- /dev/null +++ b/probes/hasThirdPartyRepoSupervisor/impl.go @@ -0,0 +1,58 @@ +// 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 hasThirdPartyRepoSupervisor + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasThirdPartyRepoSupervisor" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + f := finding.Finding{ + Probe: Probe, + Outcome: finding.OutcomeFalse, + Message: "repo-supervisor not detected in CI", + } + if raw.SecretScanningResults.ThirdPartyRepoSupervisor { + f.Outcome = finding.OutcomeTrue + if p := first(raw.SecretScanningResults.ThirdPartyRepoSupervisorPaths); p != "" { + f.Message = "repo-supervisor found at " + p + f.Location = &finding.Location{Type: finding.FileTypeSource, Path: p} + } else { + f.Message = "repo-supervisor detected in CI" + } + } + return []finding.Finding{f}, Probe, nil +} + +func first(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] +} diff --git a/probes/hasThirdPartyRepoSupervisor/impl_test.go b/probes/hasThirdPartyRepoSupervisor/impl_test.go new file mode 100644 index 00000000000..8303ad7d3fe --- /dev/null +++ b/probes/hasThirdPartyRepoSupervisor/impl_test.go @@ -0,0 +1,83 @@ +// 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 hasThirdPartyRepoSupervisor + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "Repo Supervisor not present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyRepoSupervisor: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "Repo Supervisor present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyRepoSupervisor: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasThirdPartyShhGit/def.yml b/probes/hasThirdPartyShhGit/def.yml new file mode 100644 index 00000000000..136cd2ebf7a --- /dev/null +++ b/probes/hasThirdPartyShhGit/def.yml @@ -0,0 +1,31 @@ +# 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. + +id: hasThirdPartyShhGit +lifecycle: beta +short: shhgit runs in CI +motivation: > + Detect whether the project runs shhgit to catch leaked credentials. + shhgit is a **periodic scanner** typically configured to run on a schedule (e.g., daily/weekly). + The SecretScanning check scores this probe as: 10 points if ran in last 30 days, 1 point otherwise. +implementation: > + Reads RawResults.SecretScanningResults.ThirdPartyShhGit from parsed CI configs. + Checks CI run history via ListCommits() and ListCheckRunsForRef() to determine if + shhgit ran within the last 30 days. +outcome: + - The probe emits OutcomeTrue if shhgit is configured, and OutcomeFalse otherwise. + - Score is 10 if ran recently (last 30 days), otherwise 1. +ecosystem: + languages: [all] + clients: [github, gitlab] diff --git a/probes/hasThirdPartyShhGit/impl.go b/probes/hasThirdPartyShhGit/impl.go new file mode 100644 index 00000000000..d49d67add58 --- /dev/null +++ b/probes/hasThirdPartyShhGit/impl.go @@ -0,0 +1,58 @@ +// 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 hasThirdPartyShhGit + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasThirdPartyShhGit" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + f := finding.Finding{ + Probe: Probe, + Outcome: finding.OutcomeFalse, + Message: "shhgit not detected in CI", + } + if raw.SecretScanningResults.ThirdPartyShhGit { + f.Outcome = finding.OutcomeTrue + if p := first(raw.SecretScanningResults.ThirdPartyShhGitPaths); p != "" { + f.Message = "shhgit found at " + p + f.Location = &finding.Location{Type: finding.FileTypeSource, Path: p} + } else { + f.Message = "shhgit detected in CI" + } + } + return []finding.Finding{f}, Probe, nil +} + +func first(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] +} diff --git a/probes/hasThirdPartyShhGit/impl_test.go b/probes/hasThirdPartyShhGit/impl_test.go new file mode 100644 index 00000000000..2a4ec0a7a75 --- /dev/null +++ b/probes/hasThirdPartyShhGit/impl_test.go @@ -0,0 +1,83 @@ +// 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 hasThirdPartyShhGit + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "ShhGit not present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyShhGit: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "ShhGit present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyShhGit: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasThirdPartyTruffleHog/def.yml b/probes/hasThirdPartyTruffleHog/def.yml new file mode 100644 index 00000000000..39ef475c6e2 --- /dev/null +++ b/probes/hasThirdPartyTruffleHog/def.yml @@ -0,0 +1,33 @@ +# 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. + +id: hasThirdPartyTruffleHog +lifecycle: beta +short: TruffleHog runs in CI +motivation: > + Detect whether the project runs TruffleHog in CI to catch leaked credentials. + TruffleHog is a **commit-based scanner** typically configured to run on every push or pull request. + The SecretScanning check scores this probe based on commit coverage: + 100% coverage=10 points, 70-99%=7, 50-69%=5, <50%=3, tool present but not running=1. +implementation: > + Reads RawResults.SecretScanningResults.ThirdPartyTruffleHog from parsed CI configs + (.gitlab-ci.yml for GitLab; .github/workflows/*.yml for GitHub). + Analyzes the last 100 commits via ListCommits() and ListCheckRunsForRef() to + calculate what percentage of commits had TruffleHog run in CI. +outcome: + - The probe emits OutcomeTrue if TruffleHog is configured in CI, and OutcomeFalse otherwise. + - Score depends on commit coverage, not just tool presence. +ecosystem: + languages: [all] + clients: [github, gitlab] diff --git a/probes/hasThirdPartyTruffleHog/impl.go b/probes/hasThirdPartyTruffleHog/impl.go new file mode 100644 index 00000000000..57fd8ede510 --- /dev/null +++ b/probes/hasThirdPartyTruffleHog/impl.go @@ -0,0 +1,58 @@ +// 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 hasThirdPartyTruffleHog + +import ( + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +const Probe = "hasThirdPartyTruffleHog" + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecretScanning}) +} + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", uerror.ErrNil + } + + f := finding.Finding{ + Probe: Probe, + Outcome: finding.OutcomeFalse, + Message: "TruffleHog not detected in CI", + } + if raw.SecretScanningResults.ThirdPartyTruffleHog { + f.Outcome = finding.OutcomeTrue + if p := first(raw.SecretScanningResults.ThirdPartyTruffleHogPaths); p != "" { + f.Message = "TruffleHog found at " + p + f.Location = &finding.Location{Type: finding.FileTypeSource, Path: p} + } else { + f.Message = "TruffleHog detected in CI" + } + } + return []finding.Finding{f}, Probe, nil +} + +func first(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] +} diff --git a/probes/hasThirdPartyTruffleHog/impl_test.go b/probes/hasThirdPartyTruffleHog/impl_test.go new file mode 100644 index 00000000000..be342d75a31 --- /dev/null +++ b/probes/hasThirdPartyTruffleHog/impl_test.go @@ -0,0 +1,83 @@ +// 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 hasThirdPartyTruffleHog + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + tests := []struct { + err error + raw *checker.RawResults + name string + outcomes []finding.Outcome + }{ + { + name: "nil raw results", + raw: nil, + err: uerror.ErrNil, + }, + { + name: "TruffleHog not present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyTruffleHog: false, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "TruffleHog present", + raw: &checker.RawResults{ + SecretScanningResults: checker.SecretScanningData{ + Platform: "github", + ThirdPartyTruffleHog: true, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, s, err := Run(tt.raw) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +}