From 090ea728f9e04dd24d6ebd080ee4b242d0d8614b Mon Sep 17 00:00:00 2001 From: bmendonca3 <208517100+bmendonca3@users.noreply.github.com> Date: Thu, 28 May 2026 18:31:47 -0700 Subject: [PATCH] Add vulnerability report action Signed-off-by: bmendonca3 <208517100+bmendonca3@users.noreply.github.com> --- README.md | 4 + operator.md | 5 + pkg/enforce/enforce.go | 28 +-- pkg/enforce/enforce_test.go | 29 ++++ .../vulnerabilityreport.go | 164 ++++++++++++++++++ .../vulnerabilityreport_test.go | 116 +++++++++++++ whats-new.md | 3 + 7 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 pkg/vulnerabilityreport/vulnerabilityreport.go create mode 100644 pkg/vulnerabilityreport/vulnerabilityreport_test.go diff --git a/README.md b/README.md index 6c71e9f2..8d645b77 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,10 @@ detects a repository to be out of compliance. (not currently user configurable). If the policy result changes, a new comment will be left on the issue and linked in the issue body. Once the violation is addressed, the issue will be automatically closed by Allstar within 5-10 minutes. +- `vulnerability_report`: This action creates a GitHub private vulnerability + report for the repository. It is intended for policy failures that may be + exploitable and should not be opened as public issues. Allstar checks for an + existing report with the same policy summary before creating a new one. - `fix`: This action is policy specific. The policy will make the changes to the GitHub settings to correct the policy violation. Not all policies will be able to support this (see below). diff --git a/operator.md b/operator.md index 0d92d9df..a8e11890 100644 --- a/operator.md +++ b/operator.md @@ -23,6 +23,11 @@ to create a new app. > repository permission set to **Read & write**. This is required for uploading > SARIF results to the Code Scanning API. +> **Note:** If you plan to use the `vulnerability_report` action, your GitHub +> App also needs the **Repository security advisories** repository permission set +> to **Read & write**, and target repositories must support private vulnerability +> reporting. + > **Note:** As Allstar is developed, it may evolve the permissions needed or start > listening for webhooks, please follow along development in this repo. diff --git a/pkg/enforce/enforce.go b/pkg/enforce/enforce.go index b5c39029..662132be 100644 --- a/pkg/enforce/enforce.go +++ b/pkg/enforce/enforce.go @@ -34,6 +34,7 @@ import ( "github.com/ossf/allstar/pkg/policies" "github.com/ossf/allstar/pkg/policydef" "github.com/ossf/allstar/pkg/scorecard" + "github.com/ossf/allstar/pkg/vulnerabilityreport" ) type ( @@ -42,22 +43,24 @@ type ( ) var ( - doNothingOnOptOut = operator.DoNothingOnOptOut - policiesGetPolicies func() []policydef.Policy - issueEnsure func(context.Context, *github.Client, string, string, string, string) error - issueClose func(context.Context, *github.Client, string, string, string) error - configIsBotEnabled func(context.Context, *github.Client, string, string) bool - getAppInstallations func(context.Context, *github.Client) ([]*github.Installation, error) - getAppInstallationRepos func(context.Context, *github.Client) ([]*github.Repository, *github.Response, error) - runPolicies func(context.Context, *github.Client, string, string, bool, string) (EnforceRepoResults, error) - deleteInstallation func(context.Context, *github.Client, int64) (*github.Response, error) - listInstallations func(context.Context, *github.Client) ([]*github.Installation, error) + doNothingOnOptOut = operator.DoNothingOnOptOut + policiesGetPolicies func() []policydef.Policy + issueEnsure func(context.Context, *github.Client, string, string, string, string) error + issueClose func(context.Context, *github.Client, string, string, string) error + vulnerabilityReportEnsure func(context.Context, *github.Client, string, string, string, string) error + configIsBotEnabled func(context.Context, *github.Client, string, string) bool + getAppInstallations func(context.Context, *github.Client) ([]*github.Installation, error) + getAppInstallationRepos func(context.Context, *github.Client) ([]*github.Repository, *github.Response, error) + runPolicies func(context.Context, *github.Client, string, string, bool, string) (EnforceRepoResults, error) + deleteInstallation func(context.Context, *github.Client, int64) (*github.Response, error) + listInstallations func(context.Context, *github.Client) ([]*github.Installation, error) ) func init() { policiesGetPolicies = policies.GetPolicies issueEnsure = issue.Ensure issueClose = issue.Close + vulnerabilityReportEnsure = vulnerabilityreport.Ensure configIsBotEnabled = config.IsBotEnabled getAppInstallations = getAppInstallationsReal getAppInstallationRepos = getAppInstallationReposReal @@ -385,6 +388,11 @@ func runPoliciesReal(ctx context.Context, c *github.Client, owner, repo string, if err != nil { return nil, err } + case "vulnerability_report": + err := vulnerabilityReportEnsure(ctx, c, owner, repo, p.Name(), r.NotifyText) + if err != nil { + return nil, err + } case "email": log.Warn(). Str("org", owner). diff --git a/pkg/enforce/enforce_test.go b/pkg/enforce/enforce_test.go index 1900f320..1bb2b083 100644 --- a/pkg/enforce/enforce_test.go +++ b/pkg/enforce/enforce_test.go @@ -105,6 +105,11 @@ func TestRunPolicies(t *testing.T) { ensureCalled = true return nil } + reportCalled := false + vulnerabilityReportEnsure = func(ctx context.Context, c *github.Client, owner, repo, policy, text string) error { + reportCalled = true + return nil + } closeCalled := false issueClose = func(ctx context.Context, c *github.Client, owner, repo, policy string) error { closeCalled = true @@ -117,6 +122,7 @@ func TestRunPolicies(t *testing.T) { Action string ShouldFix bool ShouldEnsure bool + ShouldReport bool ShouldClose bool ExpEnforceResults EnforceRepoResults }{ @@ -141,6 +147,21 @@ func TestRunPolicies(t *testing.T) { Action: "issue", ShouldFix: false, ShouldEnsure: true, + ShouldReport: false, + ShouldClose: false, + ExpEnforceResults: EnforceRepoResults{ + "Test policy": false, + }, + }, + { + Name: "OpenVulnerabilityReport", + Res: policyRepoResults{ + "fake-repo": policydef.Result{Enabled: true, Pass: false}, + }, + Action: "vulnerability_report", + ShouldFix: false, + ShouldEnsure: false, + ShouldReport: true, ShouldClose: false, ExpEnforceResults: EnforceRepoResults{ "Test policy": false, @@ -201,6 +222,7 @@ func TestRunPolicies(t *testing.T) { t.Run(test.Name, func(t *testing.T) { fixCalled = false ensureCalled = false + reportCalled = false closeCalled = false policy1Results = test.Res action = test.Action @@ -223,6 +245,13 @@ func TestRunPolicies(t *testing.T) { t.Error("Ensure called unexpectedly.") } } + if test.ShouldReport != reportCalled { + if test.ShouldReport { + t.Error("Expected vulnerability report Ensure to be called") + } else { + t.Error("Vulnerability report Ensure called unexpectedly.") + } + } if test.ShouldClose != closeCalled { if test.ShouldClose { t.Error("Expected Close to be called") diff --git a/pkg/vulnerabilityreport/vulnerabilityreport.go b/pkg/vulnerabilityreport/vulnerabilityreport.go new file mode 100644 index 00000000..ba01fa3c --- /dev/null +++ b/pkg/vulnerabilityreport/vulnerabilityreport.go @@ -0,0 +1,164 @@ +// Copyright 2026 Allstar 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 vulnerabilityreport handles creating private vulnerability reports for +// Allstar policy failures. +package vulnerabilityreport + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/google/go-github/v84/github" + "github.com/rs/zerolog/log" +) + +const ( + reportTitleFormat = "Allstar Security Policy violation: %s" + + defaultSeverity = "high" +) + +type securityAdvisories interface { + ListRepositorySecurityAdvisories(context.Context, string, string, *github.ListRepositorySecurityAdvisoriesOptions) ( + []*github.SecurityAdvisory, *github.Response, error) +} + +type createPrivateVulnerabilityReportRequest struct { + Summary *string `json:"summary,omitempty"` + Description *string `json:"description,omitempty"` + Severity *string `json:"severity,omitempty"` +} + +var createReport = createReportReal + +// Ensure ensures a private vulnerability report exists for the provided repo +// and policy. Existing reports are matched by summary to avoid creating a new +// private report on every enforcement run. +func Ensure(ctx context.Context, c *github.Client, owner, repo, policy, text string) error { + return ensure(ctx, c, c.SecurityAdvisories, owner, repo, policy, text) +} + +func ensure(ctx context.Context, c *github.Client, advisories securityAdvisories, owner, repo, policy, text string) error { + summary := fmt.Sprintf(reportTitleFormat, policy) + existing, err := getPolicyReport(ctx, advisories, owner, repo, summary) + if err != nil { + if reportUnavailable(nil, err) { + logUnavailable(owner, repo, policy, err) + return nil + } + return err + } + if existing != nil { + log.Info(). + Str("org", owner). + Str("repo", repo). + Str("area", policy). + Str("report", existing.GetHTMLURL()). + Msg("Private vulnerability report already exists.") + return nil + } + + description := createReportDescription(text) + report, rsp, err := createReport(ctx, c, owner, repo, summary, description) + if err != nil && reportUnavailable(rsp, err) { + logUnavailable(owner, repo, policy, err) + return nil + } + if err != nil { + return err + } + log.Info(). + Str("org", owner). + Str("repo", repo). + Str("area", policy). + Str("report", report.GetHTMLURL()). + Msg("Created private vulnerability report.") + return nil +} + +func getPolicyReport(ctx context.Context, svc securityAdvisories, owner, repo, summary string) (*github.SecurityAdvisory, error) { + opt := &github.ListRepositorySecurityAdvisoriesOptions{ + ListCursorOptions: github.ListCursorOptions{ + PerPage: 100, + }, + } + for { + advisories, resp, err := svc.ListRepositorySecurityAdvisories(ctx, owner, repo, opt) + if err != nil { + return nil, err + } + for _, advisory := range advisories { + if advisory.GetSummary() == summary { + return advisory, nil + } + } + if resp == nil || resp.After == "" { + break + } + opt.After = resp.After + } + return nil, nil +} + +func createReportReal(ctx context.Context, c *github.Client, owner, repo, summary, description string) ( + *github.SecurityAdvisory, *github.Response, error, +) { + url := fmt.Sprintf("repos/%v/%v/security-advisories/reports", owner, repo) + req, err := c.NewRequest("POST", url, &createPrivateVulnerabilityReportRequest{ + Summary: github.Ptr(summary), + Description: github.Ptr(description), + Severity: github.Ptr(defaultSeverity), + }) + if err != nil { + return nil, nil, err + } + + report := new(github.SecurityAdvisory) + resp, err := c.Do(ctx, req, report) + if err != nil { + return nil, resp, err + } + return report, resp, nil +} + +func reportUnavailable(rsp *github.Response, err error) bool { + if rsp != nil && (rsp.StatusCode == http.StatusForbidden || rsp.StatusCode == http.StatusNotFound) { + return true + } + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + return ghErr.Response.StatusCode == http.StatusForbidden || ghErr.Response.StatusCode == http.StatusNotFound + } + return false +} + +func logUnavailable(owner, repo, policy string, err error) { + log.Warn(). + Str("org", owner). + Str("repo", repo). + Str("area", policy). + Err(err). + Msg("Action set to vulnerability_report, but private vulnerability reporting is unavailable.") +} + +func createReportDescription(text string) string { + return fmt.Sprintf(`Allstar detected a security policy violation. + +%s + +This private vulnerability report was created automatically so maintainers can triage potentially exploitable workflow or repository security issues without opening a public issue first.`, text) +} diff --git a/pkg/vulnerabilityreport/vulnerabilityreport_test.go b/pkg/vulnerabilityreport/vulnerabilityreport_test.go new file mode 100644 index 00000000..1d8a4746 --- /dev/null +++ b/pkg/vulnerabilityreport/vulnerabilityreport_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 Allstar 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 vulnerabilityreport + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/google/go-github/v84/github" +) + +var listRepositorySecurityAdvisories func(context.Context, string, string, + *github.ListRepositorySecurityAdvisoriesOptions) ([]*github.SecurityAdvisory, *github.Response, error) + +type mockSecurityAdvisories struct{} + +func (m mockSecurityAdvisories) ListRepositorySecurityAdvisories(ctx context.Context, owner, repo string, + opts *github.ListRepositorySecurityAdvisoriesOptions, +) ([]*github.SecurityAdvisory, *github.Response, error) { + return listRepositorySecurityAdvisories(ctx, owner, repo, opts) +} + +func TestEnsure(t *testing.T) { + t.Cleanup(func() { + createReport = createReportReal + }) + + t.Run("CreatesReport", func(t *testing.T) { + listRepositorySecurityAdvisories = func(ctx context.Context, owner, repo string, + opts *github.ListRepositorySecurityAdvisoriesOptions, + ) ([]*github.SecurityAdvisory, *github.Response, error) { + return []*github.SecurityAdvisory{}, &github.Response{}, nil + } + createCalled := false + createReport = func(ctx context.Context, c *github.Client, owner, repo, summary, description string) ( + *github.SecurityAdvisory, *github.Response, error, + ) { + createCalled = true + if owner != "octo-org" || repo != "octo-repo" { + t.Fatalf("unexpected repo: %s/%s", owner, repo) + } + if summary != "Allstar Security Policy violation: Dangerous Workflow" { + t.Fatalf("unexpected summary: %q", summary) + } + if !strings.Contains(description, "Status text") { + t.Fatalf("description missing policy text: %q", description) + } + return &github.SecurityAdvisory{HTMLURL: github.Ptr("https://github.com/octo-org/octo-repo/security/advisories/GHSA-test")}, nil, nil + } + + err := ensure(context.Background(), nil, mockSecurityAdvisories{}, "octo-org", "octo-repo", "Dangerous Workflow", "Status text") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !createCalled { + t.Error("Expected report to be created") + } + }) + + t.Run("SkipsExistingReport", func(t *testing.T) { + summary := "Allstar Security Policy violation: Dangerous Workflow" + listRepositorySecurityAdvisories = func(ctx context.Context, owner, repo string, + opts *github.ListRepositorySecurityAdvisoriesOptions, + ) ([]*github.SecurityAdvisory, *github.Response, error) { + return []*github.SecurityAdvisory{ + { + Summary: github.Ptr(summary), + }, + }, &github.Response{}, nil + } + createReport = func(ctx context.Context, c *github.Client, owner, repo, summary, description string) ( + *github.SecurityAdvisory, *github.Response, error, + ) { + t.Fatal("CreateReport called unexpectedly") + return nil, nil, nil + } + + err := ensure(context.Background(), nil, mockSecurityAdvisories{}, "octo-org", "octo-repo", "Dangerous Workflow", "Status text") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + }) + + t.Run("UnavailableReportActionDoesNotFailPolicy", func(t *testing.T) { + listRepositorySecurityAdvisories = func(ctx context.Context, owner, repo string, + opts *github.ListRepositorySecurityAdvisoriesOptions, + ) ([]*github.SecurityAdvisory, *github.Response, error) { + return []*github.SecurityAdvisory{}, &github.Response{}, nil + } + createReport = func(ctx context.Context, c *github.Client, owner, repo, summary, description string) ( + *github.SecurityAdvisory, *github.Response, error, + ) { + return nil, &github.Response{Response: &http.Response{StatusCode: http.StatusNotFound}}, errors.New("not found") + } + + err := ensure(context.Background(), nil, mockSecurityAdvisories{}, "octo-org", "octo-repo", "Dangerous Workflow", "Status text") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + }) +} diff --git a/whats-new.md b/whats-new.md index f4b6f248..c5c99b6c 100644 --- a/whats-new.md +++ b/whats-new.md @@ -5,6 +5,9 @@ Major features and changes added to Allstar. ## Added since last release - Dangerous Workflow policy will now be run for all branches. [Link](https://github.com/ossf/allstar/issues/569) +- Policies can now use the `vulnerability_report` action to create GitHub + private vulnerability reports for repository security policy failures. + [Link](https://github.com/ossf/allstar/issues/498) ## Release v3.0