From a3fb2b94c093f45533b052363561d8384e850ec8 Mon Sep 17 00:00:00 2001 From: bmendonca3 <208517100+bmendonca3@users.noreply.github.com> Date: Thu, 28 May 2026 17:40:31 -0700 Subject: [PATCH] feat: factor private vulnerability reporting into security policy Signed-off-by: bmendonca3 <208517100+bmendonca3@users.noreply.github.com> --- checker/raw_result.go | 3 +- checks/evaluation/security_policy.go | 13 ++- checks/evaluation/security_policy_test.go | 106 ++++++++++++++++-- checks/raw/security_policy.go | 35 +++++- checks/raw/security_policy_test.go | 67 +++++++++++ checks/security_policy_test.go | 77 +++++++------ .../private_vulnerability_reporting.go | 47 ++++++++ docs/checks.md | 5 + docs/checks/internal/checks.yaml | 8 ++ docs/osps-baseline-coverage.md | 6 +- docs/probes.md | 15 +++ probes/entries.go | 2 + .../def.yml | 43 +++++++ .../impl.go | 67 +++++++++++ .../impl_test.go | 74 ++++++++++++ 15 files changed, 516 insertions(+), 52 deletions(-) create mode 100644 clients/githubrepo/private_vulnerability_reporting.go create mode 100644 probes/securityPolicyPrivateVulnerabilityReportingEnabled/def.yml create mode 100644 probes/securityPolicyPrivateVulnerabilityReportingEnabled/impl.go create mode 100644 probes/securityPolicyPrivateVulnerabilityReportingEnabled/impl_test.go diff --git a/checker/raw_result.go b/checker/raw_result.go index bf5132b7e45..f580fc89ba3 100644 --- a/checker/raw_result.go +++ b/checker/raw_result.go @@ -299,7 +299,8 @@ type SASTWorkflow struct { // SecurityPolicyData contains the raw results // for the Security-Policy check. type SecurityPolicyData struct { - PolicyFiles []SecurityPolicyFile + PolicyFiles []SecurityPolicyFile + PrivateVulnerabilityReportingEnabled *bool } // BinaryArtifactData contains the raw results diff --git a/checks/evaluation/security_policy.go b/checks/evaluation/security_policy.go index 7889aef1d4a..a6e2280638e 100644 --- a/checks/evaluation/security_policy.go +++ b/checks/evaluation/security_policy.go @@ -22,16 +22,18 @@ import ( "github.com/ossf/scorecard/v5/probes/securityPolicyContainsText" "github.com/ossf/scorecard/v5/probes/securityPolicyContainsVulnerabilityDisclosure" "github.com/ossf/scorecard/v5/probes/securityPolicyPresent" + "github.com/ossf/scorecard/v5/probes/securityPolicyPrivateVulnerabilityReportingEnabled" ) // SecurityPolicy applies the score policy for the Security-Policy check. func SecurityPolicy(name string, findings []finding.Finding, dl checker.DetailLogger) checker.CheckResult { - // We have 4 unique probes, each should have a finding. + // We have 5 unique probes, each should have a finding. expectedProbes := []string{ securityPolicyContainsVulnerabilityDisclosure.Probe, securityPolicyContainsLinks.Probe, securityPolicyContainsText.Probe, securityPolicyPresent.Probe, + securityPolicyPrivateVulnerabilityReportingEnabled.Probe, } if !finding.UniqueProbesEqual(findings, expectedProbes) { e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results") @@ -40,6 +42,7 @@ func SecurityPolicy(name string, findings []finding.Finding, dl checker.DetailLo score := 0 m := make(map[string]bool) + privateVulnerabilityReportingEnabled := false var logLevel checker.DetailType for i := range findings { f := &findings[i] @@ -56,6 +59,8 @@ func SecurityPolicy(name string, findings []finding.Finding, dl checker.DetailLo score += scoreProbeOnce(f.Probe, m, 3) case securityPolicyPresent.Probe: m[f.Probe] = true + case securityPolicyPrivateVulnerabilityReportingEnabled.Probe: + privateVulnerabilityReportingEnabled = true default: e := sce.WithMessage(sce.ErrScorecardInternal, "unknown probe results") return checker.CreateRuntimeErrorResult(name, e) @@ -73,8 +78,14 @@ func SecurityPolicy(name string, findings []finding.Finding, dl checker.DetailLo e := sce.WithMessage(sce.ErrScorecardInternal, "score calculation problem") return checker.CreateRuntimeErrorResult(name, e) } + if privateVulnerabilityReportingEnabled { + return checker.CreateResultWithScore(name, "private vulnerability reporting enabled", 8) + } return checker.CreateMinScoreResult(name, "security policy file not detected") } + if privateVulnerabilityReportingEnabled && score < 8 { + score = 8 + } return checker.CreateResultWithScore(name, "security policy file detected", score) } diff --git a/checks/evaluation/security_policy_test.go b/checks/evaluation/security_policy_test.go index 5caf7fa439d..b36ae0280e6 100644 --- a/checks/evaluation/security_policy_test.go +++ b/checks/evaluation/security_policy_test.go @@ -70,6 +70,10 @@ func TestSecurityPolicy(t *testing.T) { Probe: "securityPolicyPresent", Outcome: finding.OutcomeFalse, }, + { + Probe: "securityPolicyPrivateVulnerabilityReportingEnabled", + Outcome: finding.OutcomeFalse, + }, { Probe: "securityPolicyInvalidProbeName", Outcome: finding.OutcomeFalse, @@ -99,11 +103,16 @@ func TestSecurityPolicy(t *testing.T) { Probe: "securityPolicyPresent", Outcome: finding.OutcomeTrue, }, + { + Probe: "securityPolicyPrivateVulnerabilityReportingEnabled", + Outcome: finding.OutcomeNotApplicable, + }, }, result: scut.TestReturn{ - Score: checker.MinResultScore, - NumberOfInfo: 1, - NumberOfWarn: 3, + Score: checker.MinResultScore, + NumberOfInfo: 1, + NumberOfWarn: 3, + NumberOfDebug: 1, }, }, { @@ -125,12 +134,47 @@ func TestSecurityPolicy(t *testing.T) { Probe: "securityPolicyPresent", Outcome: finding.OutcomeFalse, }, + { + Probe: "securityPolicyPrivateVulnerabilityReportingEnabled", + Outcome: finding.OutcomeNotApplicable, + }, }, result: scut.TestReturn{ - Score: checker.InconclusiveResultScore, - Error: sce.ErrScorecardInternal, - NumberOfWarn: 1, - NumberOfInfo: 3, + Score: checker.InconclusiveResultScore, + Error: sce.ErrScorecardInternal, + NumberOfWarn: 1, + NumberOfInfo: 3, + NumberOfDebug: 1, + }, + }, + { + name: "file not found with private vulnerability reporting enabled", + findings: []finding.Finding{ + { + Probe: "securityPolicyContainsVulnerabilityDisclosure", + Outcome: finding.OutcomeFalse, + }, + { + Probe: "securityPolicyContainsLinks", + Outcome: finding.OutcomeFalse, + }, + { + Probe: "securityPolicyContainsText", + Outcome: finding.OutcomeFalse, + }, + { + Probe: "securityPolicyPresent", + Outcome: finding.OutcomeFalse, + }, + { + Probe: "securityPolicyPrivateVulnerabilityReportingEnabled", + Outcome: finding.OutcomeTrue, + }, + }, + result: scut.TestReturn{ + Score: 8, + NumberOfInfo: 1, + NumberOfWarn: 4, }, }, { @@ -152,11 +196,46 @@ func TestSecurityPolicy(t *testing.T) { Probe: "securityPolicyPresent", Outcome: finding.OutcomeTrue, }, + { + Probe: "securityPolicyPrivateVulnerabilityReportingEnabled", + Outcome: finding.OutcomeNotApplicable, + }, + }, + result: scut.TestReturn{ + Score: 6, + NumberOfInfo: 2, + NumberOfWarn: 2, + NumberOfDebug: 1, + }, + }, + { + name: "file found with private vulnerability reporting score floor", + findings: []finding.Finding{ + { + Probe: "securityPolicyContainsVulnerabilityDisclosure", + Outcome: finding.OutcomeFalse, + }, + { + Probe: "securityPolicyContainsLinks", + Outcome: finding.OutcomeFalse, + }, + { + Probe: "securityPolicyContainsText", + Outcome: finding.OutcomeFalse, + }, + { + Probe: "securityPolicyPresent", + Outcome: finding.OutcomeTrue, + }, + { + Probe: "securityPolicyPrivateVulnerabilityReportingEnabled", + Outcome: finding.OutcomeTrue, + }, }, result: scut.TestReturn{ - Score: 6, + Score: 8, NumberOfInfo: 2, - NumberOfWarn: 2, + NumberOfWarn: 3, }, }, { @@ -178,10 +257,15 @@ func TestSecurityPolicy(t *testing.T) { Probe: "securityPolicyPresent", Outcome: finding.OutcomeTrue, }, + { + Probe: "securityPolicyPrivateVulnerabilityReportingEnabled", + Outcome: finding.OutcomeNotApplicable, + }, }, result: scut.TestReturn{ - Score: checker.MaxResultScore, - NumberOfInfo: 4, + Score: checker.MaxResultScore, + NumberOfInfo: 4, + NumberOfDebug: 1, }, }, } diff --git a/checks/raw/security_policy.go b/checks/raw/security_policy.go index 0d88bff748b..24f15f05af8 100644 --- a/checks/raw/security_policy.go +++ b/checks/raw/security_policy.go @@ -34,13 +34,21 @@ type securityPolicyFilesWithURI struct { files []checker.SecurityPolicyFile } +type privateVulnerabilityReportingClient interface { + IsPrivateVulnerabilityReportingEnabled() (bool, error) +} + // SecurityPolicy checks for presence of security policy // and applicable content discovered by checkSecurityPolicyFileContent(). func SecurityPolicy(c *checker.CheckRequest) (checker.SecurityPolicyData, error) { data := securityPolicyFilesWithURI{ uri: "", files: make([]checker.SecurityPolicyFile, 0), } - err := fileparser.OnAllFilesDo(c.RepoClient, isSecurityPolicyFile, &data) + privateVulnerabilityReportingEnabled, err := privateVulnerabilityReportingStatus(c.RepoClient) + if err != nil { + return checker.SecurityPolicyData{}, err + } + err = fileparser.OnAllFilesDo(c.RepoClient, isSecurityPolicyFile, &data) if err != nil { return checker.SecurityPolicyData{}, err } @@ -55,7 +63,10 @@ func SecurityPolicy(c *checker.CheckRequest) (checker.SecurityPolicyData, error) return checker.SecurityPolicyData{}, err } } - return checker.SecurityPolicyData{PolicyFiles: data.files}, nil + return checker.SecurityPolicyData{ + PolicyFiles: data.files, + PrivateVulnerabilityReportingEnabled: privateVulnerabilityReportingEnabled, + }, nil } // Check if present in parent org. @@ -95,7 +106,25 @@ func SecurityPolicy(c *checker.CheckRequest) (checker.SecurityPolicyData, error) } } } - return checker.SecurityPolicyData{PolicyFiles: data.files}, nil + return checker.SecurityPolicyData{ + PolicyFiles: data.files, + PrivateVulnerabilityReportingEnabled: privateVulnerabilityReportingEnabled, + }, nil +} + +func privateVulnerabilityReportingStatus(repoClient clients.RepoClient) (*bool, error) { + client, ok := repoClient.(privateVulnerabilityReportingClient) + if !ok { + return nil, nil + } + enabled, err := client.IsPrivateVulnerabilityReportingEnabled() + if err != nil { + if errors.Is(err, clients.ErrUnsupportedFeature) { + return nil, nil + } + return nil, err + } + return &enabled, nil } // Check repository for repository-specific policy. diff --git a/checks/raw/security_policy_test.go b/checks/raw/security_policy_test.go index f8a43a173b2..931566dcb2c 100644 --- a/checks/raw/security_policy_test.go +++ b/checks/raw/security_policy_test.go @@ -15,6 +15,7 @@ package raw import ( + "errors" "io" "os" "strings" @@ -23,10 +24,25 @@ import ( "go.uber.org/mock/gomock" "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/clients" mockrepo "github.com/ossf/scorecard/v5/clients/mockclients" scut "github.com/ossf/scorecard/v5/utests" ) +type privateVulnerabilityReportingRepoClient struct { + clients.RepoClient + enabled bool + err error +} + +func (c privateVulnerabilityReportingRepoClient) IsPrivateVulnerabilityReportingEnabled() (bool, error) { + return c.enabled, c.err +} + +func boolPtr(v bool) *bool { + return &v +} + func Test_isSecurityPolicyFilename(t *testing.T) { t.Parallel() tests := []struct { @@ -60,6 +76,57 @@ func Test_isSecurityPolicyFilename(t *testing.T) { } } +func TestPrivateVulnerabilityReportingStatus(t *testing.T) { + t.Parallel() + errUnexpected := errors.New("unexpected") + tests := []struct { + name string + client privateVulnerabilityReportingRepoClient + want *bool + wantErr error + }{ + { + name: "enabled", + client: privateVulnerabilityReportingRepoClient{enabled: true}, + want: boolPtr(true), + }, + { + name: "disabled", + client: privateVulnerabilityReportingRepoClient{enabled: false}, + want: boolPtr(false), + }, + { + name: "error", + client: privateVulnerabilityReportingRepoClient{err: errUnexpected}, + wantErr: errUnexpected, + }, + { + name: "unsupported", + client: privateVulnerabilityReportingRepoClient{err: clients.ErrUnsupportedFeature}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := privateVulnerabilityReportingStatus(tt.client) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("privateVulnerabilityReportingStatus error = %v, want %v", err, tt.wantErr) + } + if tt.want == nil { + if got != nil { + t.Fatalf("privateVulnerabilityReportingStatus = %v, want nil", *got) + } + return + } + if got == nil || *got != *tt.want { + t.Fatalf("privateVulnerabilityReportingStatus = %v, want %v", got, tt.want) + } + }) + } +} + // TestSecurityPolicy tests the security policy. func TestSecurityPolicy(t *testing.T) { t.Parallel() diff --git a/checks/security_policy_test.go b/checks/security_policy_test.go index e82fb80d623..8eb3d5815a7 100644 --- a/checks/security_policy_test.go +++ b/checks/security_policy_test.go @@ -42,9 +42,10 @@ func TestSecurityPolicy(t *testing.T) { "security.md", }, want: scut.TestReturn{ - Score: 10, - NumberOfInfo: 4, - NumberOfWarn: 0, + Score: 10, + NumberOfInfo: 4, + NumberOfWarn: 0, + NumberOfDebug: 1, }, }, { @@ -54,9 +55,10 @@ func TestSecurityPolicy(t *testing.T) { ".github/security.md", }, want: scut.TestReturn{ - Score: 10, - NumberOfInfo: 4, - NumberOfWarn: 0, + Score: 10, + NumberOfInfo: 4, + NumberOfWarn: 0, + NumberOfDebug: 1, }, }, { @@ -66,9 +68,10 @@ func TestSecurityPolicy(t *testing.T) { "docs/security.md", }, want: scut.TestReturn{ - Score: 4, - NumberOfInfo: 3, - NumberOfWarn: 1, + Score: 4, + NumberOfInfo: 3, + NumberOfWarn: 1, + NumberOfDebug: 1, }, }, { @@ -78,9 +81,10 @@ func TestSecurityPolicy(t *testing.T) { "security.rst", }, want: scut.TestReturn{ - Score: 3, - NumberOfInfo: 2, - NumberOfWarn: 2, + Score: 3, + NumberOfInfo: 2, + NumberOfWarn: 2, + NumberOfDebug: 1, }, }, { @@ -90,9 +94,10 @@ func TestSecurityPolicy(t *testing.T) { ".github/security.rst", }, want: scut.TestReturn{ - Score: 6, - NumberOfInfo: 2, - NumberOfWarn: 2, + Score: 6, + NumberOfInfo: 2, + NumberOfWarn: 2, + NumberOfDebug: 1, }, }, { @@ -102,9 +107,10 @@ func TestSecurityPolicy(t *testing.T) { "docs/security.rst", }, want: scut.TestReturn{ - Score: 6, - NumberOfInfo: 2, - NumberOfWarn: 2, + Score: 6, + NumberOfInfo: 2, + NumberOfWarn: 2, + NumberOfDebug: 1, }, }, { @@ -114,9 +120,10 @@ func TestSecurityPolicy(t *testing.T) { "doc/security.rst", }, want: scut.TestReturn{ - Score: 6, - NumberOfInfo: 2, - NumberOfWarn: 2, + Score: 6, + NumberOfInfo: 2, + NumberOfWarn: 2, + NumberOfDebug: 1, }, }, { @@ -126,9 +133,10 @@ func TestSecurityPolicy(t *testing.T) { "security.adoc", }, want: scut.TestReturn{ - Score: 9, - NumberOfInfo: 3, - NumberOfWarn: 1, + Score: 9, + NumberOfInfo: 3, + NumberOfWarn: 1, + NumberOfDebug: 1, }, }, { @@ -138,9 +146,10 @@ func TestSecurityPolicy(t *testing.T) { ".github/security.adoc", }, want: scut.TestReturn{ - Score: 10, - NumberOfInfo: 4, - NumberOfWarn: 0, + Score: 10, + NumberOfInfo: 4, + NumberOfWarn: 0, + NumberOfDebug: 1, }, }, { @@ -150,9 +159,10 @@ func TestSecurityPolicy(t *testing.T) { "docs/security.adoc", }, want: scut.TestReturn{ - Score: 0, - NumberOfInfo: 1, - NumberOfWarn: 3, + Score: 0, + NumberOfInfo: 1, + NumberOfWarn: 3, + NumberOfDebug: 1, }, }, { @@ -162,9 +172,10 @@ func TestSecurityPolicy(t *testing.T) { "dOCs/SeCuRIty.rsT", }, want: scut.TestReturn{ - Score: 0, - NumberOfInfo: 1, - NumberOfWarn: 3, + Score: 0, + NumberOfInfo: 1, + NumberOfWarn: 3, + NumberOfDebug: 1, }, }, } diff --git a/clients/githubrepo/private_vulnerability_reporting.go b/clients/githubrepo/private_vulnerability_reporting.go new file mode 100644 index 00000000000..35e270b8c0b --- /dev/null +++ b/clients/githubrepo/private_vulnerability_reporting.go @@ -0,0 +1,47 @@ +// Copyright 2026 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 ( + "errors" + "fmt" + "net/http" + + "github.com/google/go-github/v82/github" + + "github.com/ossf/scorecard/v5/clients" +) + +// IsPrivateVulnerabilityReportingEnabled reports whether GitHub private +// vulnerability reporting is enabled for the repository. +func (client *Client) IsPrivateVulnerabilityReportingEnabled() (bool, error) { + enabled, _, err := client.repoClient.Repositories.IsPrivateReportingEnabled( + client.ctx, + client.repourl.owner, + client.repourl.repo, + ) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + switch ghErr.Response.StatusCode { + case http.StatusForbidden, http.StatusNotFound, http.StatusUnprocessableEntity: + return false, fmt.Errorf("%w: checking private vulnerability reporting: %w", + clients.ErrUnsupportedFeature, err) + } + } + return false, fmt.Errorf("checking private vulnerability reporting: %w", err) + } + return enabled, nil +} diff --git a/docs/checks.md b/docs/checks.md index 4b1173f44d8..753ab51cc58 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -592,6 +592,10 @@ This check examines the contents of the security policy file awarding points for those policies that express vulnerability process(es), disclosure timelines, and have links (e.g., URL(s) and email(s)) to support the users. +For GitHub repositories, this check also considers whether private +vulnerability reporting is enabled. If private vulnerability reporting is +enabled, the check receives at least 8/10 points. + Linking Requirements (one or more) (6/10 points): - A valid form of an email address to contact for vulnerabilities - A valid form of a http/https address to support vulnerability reporting @@ -615,6 +619,7 @@ Security Policy Specific Text (1/10 points): - Place a security policy file `SECURITY.md` in the root directory of your repository. This makes it easily discoverable by a vulnerability reporter. - The file should contain information on what constitutes a vulnerability and a way to report it securely (e.g. issue tracker with private issue support, encrypted email with a published public key). Follow the [coordinated vulnerability disclosure guidelines](https://github.com/ossf/oss-vulnerability-guide/blob/main/maintainer-guide.md) to respond to vulnerability disclosures. - For GitHub, see more information [here](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository). +- For GitHub, enable [private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository) so researchers can privately report suspected vulnerabilities. ## Signed-Releases diff --git a/docs/checks/internal/checks.yaml b/docs/checks/internal/checks.yaml index b7ff5c8c37e..77f41fed856 100644 --- a/docs/checks/internal/checks.yaml +++ b/docs/checks/internal/checks.yaml @@ -620,6 +620,10 @@ checks: for those policies that express vulnerability process(es), disclosure timelines, and have links (e.g., URL(s) and email(s)) to support the users. + For GitHub repositories, this check also considers whether private + vulnerability reporting is enabled. If private vulnerability reporting is + enabled, the check receives at least 8/10 points. + Linking Requirements (one or more) (6/10 points): - A valid form of an email address to contact for vulnerabilities - A valid form of a http/https address to support vulnerability reporting @@ -651,6 +655,10 @@ checks: - >- For GitHub, see more information [here](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository). + - >- + For GitHub, enable + [private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository) + so researchers can privately report suspected vulnerabilities. Signed-Releases: risk: High tags: supply-chain, security, releases diff --git a/docs/osps-baseline-coverage.md b/docs/osps-baseline-coverage.md index 33a79c13e39..7a1e6e8e646 100644 --- a/docs/osps-baseline-coverage.md +++ b/docs/osps-baseline-coverage.md @@ -79,7 +79,7 @@ coverage status and evidence columns accordingly. | OSPS-SA-02.01 | Docs describe external interfaces | GAP | None | Documentation control. Requires attestation. | | OSPS-SA-03.01 | Security assessment performed | GAP | None | Process control. Requires attestation with evidence link. | | OSPS-VM-01.01 | CVD policy with response timeframe | PARTIAL | `securityPolicyContainsVulnerabilityDisclosure`, `securityPolicyContainsText` | Security-Policy check detects disclosure language. Does not verify explicit timeframe commitment. | -| OSPS-VM-03.01 | Private vulnerability reporting method | PARTIAL | `securityPolicyContainsLinks` | Detects links in SECURITY.md. Does not verify private reporting is actually enabled (e.g., GitHub PSIRT feature). | +| OSPS-VM-03.01 | Private vulnerability reporting method | PARTIAL | `securityPolicyContainsLinks`, `securityPolicyPrivateVulnerabilityReportingEnabled` | Detects links in SECURITY.md and, for GitHub repositories, verifies whether private vulnerability reporting is enabled. | | OSPS-VM-04.01 | Publicly publish vulnerability data | GAP | None | No probe checks for GitHub Security Advisories, OSV entries, or CVE publication. | ## Level 3 controls (17) @@ -179,8 +179,8 @@ Direct match for Phase 2 deliverable. ### Private vulnerability reporting (OSPS-VM-03.01) - [#2465](https://github.com/ossf/scorecard/issues/2465) — Factor whether or not private vulnerability reporting is enabled into the scorecard -Direct match. GitHub's private vulnerability reporting API could provide -platform-level evidence. +Direct match. GitHub's private vulnerability reporting API provides +platform-level evidence for GitHub repositories. ### Vulnerability disclosure improvements (OSPS-VM-01.01, VM-04.01) - [#4192](https://github.com/ossf/scorecard/issues/4192) — Test for security policy in other places than SECURITY.md diff --git a/docs/probes.md b/docs/probes.md index 85878020809..7461244b21d 100644 --- a/docs/probes.md +++ b/docs/probes.md @@ -621,6 +621,21 @@ If no security policy is found, the probe returns one finding with OutcomeFalse. If no security file is found, one finding with OutcomeFalse is returned. +## securityPolicyPrivateVulnerabilityReportingEnabled + +**Lifecycle**: experimental + +**Description**: Check whether GitHub private vulnerability reporting is enabled. + +**Motivation**: Private vulnerability reporting gives researchers a built-in private channel for submitting suspected vulnerabilities without immediately disclosing them publicly. + +**Implementation**: For GitHub repositories, the implementation uses the REST API endpoint that reports whether private vulnerability reporting is enabled. For clients where that setting is unavailable, the probe returns OutcomeNotApplicable. + +**Outcomes**: If private vulnerability reporting is enabled, one finding with OutcomeTrue is returned. +If private vulnerability reporting is disabled, one finding with OutcomeFalse is returned. +If the setting is unavailable for the repository client, one finding with OutcomeNotApplicable is returned. + + ## testsRunInCI **Lifecycle**: stable diff --git a/probes/entries.go b/probes/entries.go index 2a8e5c07b68..c369ca1ff69 100644 --- a/probes/entries.go +++ b/probes/entries.go @@ -61,6 +61,7 @@ import ( "github.com/ossf/scorecard/v5/probes/securityPolicyContainsText" "github.com/ossf/scorecard/v5/probes/securityPolicyContainsVulnerabilityDisclosure" "github.com/ossf/scorecard/v5/probes/securityPolicyPresent" + "github.com/ossf/scorecard/v5/probes/securityPolicyPrivateVulnerabilityReportingEnabled" "github.com/ossf/scorecard/v5/probes/testsRunInCI" "github.com/ossf/scorecard/v5/probes/topLevelPermissions" "github.com/ossf/scorecard/v5/probes/unsafeblock" @@ -80,6 +81,7 @@ var ( // SecurityPolicy check. SecurityPolicy = []ProbeImpl{ securityPolicyPresent.Run, + securityPolicyPrivateVulnerabilityReportingEnabled.Run, securityPolicyContainsLinks.Run, securityPolicyContainsVulnerabilityDisclosure.Run, securityPolicyContainsText.Run, diff --git a/probes/securityPolicyPrivateVulnerabilityReportingEnabled/def.yml b/probes/securityPolicyPrivateVulnerabilityReportingEnabled/def.yml new file mode 100644 index 00000000000..76192ea69d7 --- /dev/null +++ b/probes/securityPolicyPrivateVulnerabilityReportingEnabled/def.yml @@ -0,0 +1,43 @@ +# Copyright 2026 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: securityPolicyPrivateVulnerabilityReportingEnabled +lifecycle: experimental +short: Check whether GitHub private vulnerability reporting is enabled. +motivation: > + Private vulnerability reporting gives researchers a built-in private channel + for submitting suspected vulnerabilities without immediately disclosing them + publicly. +implementation: > + For GitHub repositories, the implementation uses the REST API endpoint that + reports whether private vulnerability reporting is enabled. For clients where + that setting is unavailable, the probe returns OutcomeNotApplicable. +outcome: + - If private vulnerability reporting is enabled, one finding with OutcomeTrue is returned. + - If private vulnerability reporting is disabled, one finding with OutcomeFalse is returned. + - If the setting is unavailable for the repository client, one finding with OutcomeNotApplicable is returned. +remediation: + onOutcome: False + effort: Low + text: + - Enable private vulnerability reporting in your GitHub repository settings. + - Add reporting instructions to SECURITY.md so researchers can find the private reporting path. + markdown: + - Enable private vulnerability reporting in your [GitHub repository settings](https://docs.github.com/en/code-security/security-advisories/repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository). + - Add reporting instructions to SECURITY.md so researchers can find the private reporting path. +ecosystem: + languages: + - all + clients: + - github diff --git a/probes/securityPolicyPrivateVulnerabilityReportingEnabled/impl.go b/probes/securityPolicyPrivateVulnerabilityReportingEnabled/impl.go new file mode 100644 index 00000000000..e6c6acbd22c --- /dev/null +++ b/probes/securityPolicyPrivateVulnerabilityReportingEnabled/impl.go @@ -0,0 +1,67 @@ +// Copyright 2026 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 securityPolicyPrivateVulnerabilityReportingEnabled + +import ( + "embed" + "fmt" + + "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" +) + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecurityPolicy}) +} + +//go:embed *.yml +var fs embed.FS + +const Probe = "securityPolicyPrivateVulnerabilityReportingEnabled" + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil) + } + + enabled := raw.SecurityPolicyResults.PrivateVulnerabilityReportingEnabled + if enabled == nil { + f, err := finding.NewNotApplicable(fs, Probe, + "private vulnerability reporting status is not available", nil) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + return []finding.Finding{*f}, Probe, nil + } + + if *enabled { + f, err := finding.NewTrue(fs, Probe, + "private vulnerability reporting is enabled", nil) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + return []finding.Finding{*f}, Probe, nil + } + + f, err := finding.NewFalse(fs, Probe, + "private vulnerability reporting is not enabled", nil) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + return []finding.Finding{*f}, Probe, nil +} diff --git a/probes/securityPolicyPrivateVulnerabilityReportingEnabled/impl_test.go b/probes/securityPolicyPrivateVulnerabilityReportingEnabled/impl_test.go new file mode 100644 index 00000000000..6b2935b2ecc --- /dev/null +++ b/probes/securityPolicyPrivateVulnerabilityReportingEnabled/impl_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 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 securityPolicyPrivateVulnerabilityReportingEnabled + +import ( + "testing" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" +) + +func TestRun(t *testing.T) { + t.Parallel() + tests := []struct { + name string + enabled *bool + outcome finding.Outcome + }{ + { + name: "enabled", + enabled: boolPtr(true), + outcome: finding.OutcomeTrue, + }, + { + name: "disabled", + enabled: boolPtr(false), + outcome: finding.OutcomeFalse, + }, + { + name: "unavailable", + enabled: nil, + outcome: finding.OutcomeNotApplicable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + findings, probe, err := Run(&checker.RawResults{ + SecurityPolicyResults: checker.SecurityPolicyData{ + PrivateVulnerabilityReportingEnabled: tt.enabled, + }, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if probe != Probe { + t.Fatalf("probe = %q, want %q", probe, Probe) + } + if got := len(findings); got != 1 { + t.Fatalf("len(findings) = %d, want 1", got) + } + if findings[0].Outcome != tt.outcome { + t.Fatalf("outcome = %s, want %s", findings[0].Outcome, tt.outcome) + } + }) + } +} + +func boolPtr(v bool) *bool { + return &v +}