diff --git a/README.md b/README.md index 6c71e9f2..2f3d5bb2 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,22 @@ the [GitHub tab](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository) that helps you commit a security policy to your repository. +### Secret Scanning + +This policy's config file is named `secret_scanning.yaml`, and the [config +definitions are +here](https://pkg.go.dev/github.com/ossf/allstar/pkg/policies/secretscanning#OrgConfig). + +This policy checks whether GitHub +[secret scanning](https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning) +is enabled for a repository. Secret scanning helps detect accidentally committed +tokens and credentials. If GitHub does not return the repository's secret +scanning setting, Allstar records the status as unavailable rather than treating +it as a disabled setting. + +The `fix` action will enable secret scanning for repositories where GitHub +reports the setting as disabled. + ### Dangerous Workflow This policy's config file is named `dangerous_workflow.yaml`, and the [config diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 284f8da0..ffa34016 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -24,6 +24,7 @@ import ( "github.com/ossf/allstar/pkg/policies/codeowners" "github.com/ossf/allstar/pkg/policies/outside" "github.com/ossf/allstar/pkg/policies/scorecard" + "github.com/ossf/allstar/pkg/policies/secretscanning" "github.com/ossf/allstar/pkg/policies/security" "github.com/ossf/allstar/pkg/policies/workflow" "github.com/ossf/allstar/pkg/policydef" @@ -38,6 +39,7 @@ func GetPolicies() []policydef.Policy { outside.NewOutside(), scorecard.NewScorecard(), security.NewSecurity(), + secretscanning.NewSecretScanning(), workflow.NewWorkflow(), action.NewAction(), admin.NewAdmin(), diff --git a/pkg/policies/secretscanning/secretscanning.go b/pkg/policies/secretscanning/secretscanning.go new file mode 100644 index 00000000..81082436 --- /dev/null +++ b/pkg/policies/secretscanning/secretscanning.go @@ -0,0 +1,316 @@ +// 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 secretscanning implements the GitHub Secret Scanning security policy. +package secretscanning + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/go-github/v84/github" + "github.com/rs/zerolog/log" + + "github.com/ossf/allstar/pkg/config" + "github.com/ossf/allstar/pkg/policydef" +) + +const ( + configFile = "secret_scanning.yaml" + polName = "Secret Scanning" + secretEnabled = "enabled" + secretDisabled = "disabled" + secretUnavailable = "unavailable" +) + +const notifyText = `GitHub secret scanning checks repositories for known secret formats and alerts maintainers when credentials or tokens are detected. + +To fix this, enable secret scanning in repository settings. Go to https://github.com/%v/%v/settings/security_analysis to enable. + +For more information, see https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning.` + +// OrgConfig is the org-level config definition for Secret Scanning. +type OrgConfig struct { + // OptConfig is the standard org-level opt in/out config, RepoOverride applies to all + // Secret Scanning config. + OptConfig config.OrgOptConfig `json:"optConfig"` + + // Action defines which action to take, default log, other: issue... + Action string `json:"action"` +} + +// RepoConfig is the repo-level config for Secret Scanning. +type RepoConfig struct { + // OptConfig is the standard repo-level opt in/out config. + OptConfig config.RepoOptConfig `json:"optConfig"` + + // Action overrides the same setting in org-level, only if present. + Action *string `json:"action"` +} + +type mergedConfig struct { + Action string +} + +type details struct { + Available bool + Status string + URL string +} + +var configFetchConfig func(context.Context, *github.Client, string, string, string, config.ConfigLevel, interface{}) error + +var configIsEnabled func(ctx context.Context, o config.OrgOptConfig, orc, r config.RepoOptConfig, c *github.Client, owner, repo string) (bool, error) + +func init() { + configFetchConfig = config.FetchConfig + configIsEnabled = config.IsEnabled +} + +type repositories interface { + Get(context.Context, string, string) (*github.Repository, *github.Response, error) + Edit(context.Context, string, string, *github.Repository) (*github.Repository, *github.Response, error) +} + +// SecretScanning is the Secret Scanning policy object, implements policydef.Policy. +type SecretScanning bool + +// NewSecretScanning returns a new Secret Scanning policy. +func NewSecretScanning() policydef.Policy { + var s SecretScanning + return s +} + +// Name returns the name of this policy, implementing policydef.Policy.Name(). +func (s SecretScanning) Name() string { + return polName +} + +// Check performs the policy check for Secret Scanning based on the +// configuration stored in the org/repo, implementing policydef.Policy.Check(). +func (s SecretScanning) Check(ctx context.Context, c *github.Client, owner, + repo string, +) (*policydef.Result, error) { + return check(ctx, c.Repositories, c, owner, repo) +} + +// Check whether this policy is enabled or not. +func (s SecretScanning) IsEnabled(ctx context.Context, c *github.Client, owner, repo string) (bool, error) { + oc, orc, rc := getConfig(ctx, c, owner, repo) + return configIsEnabled(ctx, oc.OptConfig, orc.OptConfig, rc.OptConfig, c, owner, repo) +} + +func check(ctx context.Context, rep repositories, c *github.Client, owner, + repo string, +) (*policydef.Result, error) { + oc, orc, rc := getConfig(ctx, c, owner, repo) + enabled, err := configIsEnabled(ctx, oc.OptConfig, orc.OptConfig, rc.OptConfig, c, owner, repo) + if err != nil { + return nil, err + } + + r, rsp, err := rep.Get(ctx, owner, repo) + if err != nil { + if rsp != nil && rsp.StatusCode == http.StatusForbidden { + log.Warn(). + Str("org", owner). + Str("repo", repo). + Str("area", polName). + Err(err). + Msg("Secret scanning status unavailable.") + return unavailableResult(enabled, owner, repo), nil + } + return nil, err + } + + status, available := secretScanningStatus(r) + if !available { + return unavailableResult(enabled, owner, repo), nil + } + + if status == secretDisabled { + return &policydef.Result{ + Enabled: enabled, + Pass: false, + NotifyText: "Secret scanning not enabled.\n" + fmt.Sprintf(notifyText, owner, repo), + Details: details{ + Available: true, + Status: status, + URL: securityAnalysisURL(owner, repo), + }, + }, nil + } + + return &policydef.Result{ + Enabled: enabled, + Pass: true, + NotifyText: "", + Details: details{ + Available: true, + Status: status, + URL: securityAnalysisURL(owner, repo), + }, + }, nil +} + +func unavailableResult(enabled bool, owner, repo string) *policydef.Result { + return &policydef.Result{ + Enabled: enabled, + Pass: true, + NotifyText: "", + Details: details{ + Available: false, + Status: secretUnavailable, + URL: securityAnalysisURL(owner, repo), + }, + } +} + +func secretScanningStatus(r *github.Repository) (string, bool) { + if r == nil || + r.SecurityAndAnalysis == nil || + r.SecurityAndAnalysis.SecretScanning == nil || + r.SecurityAndAnalysis.SecretScanning.Status == nil || + r.SecurityAndAnalysis.SecretScanning.GetStatus() == "" { + return secretUnavailable, false + } + return r.SecurityAndAnalysis.SecretScanning.GetStatus(), true +} + +func securityAnalysisURL(owner, repo string) string { + return fmt.Sprintf("https://github.com/%v/%v/settings/security_analysis", owner, repo) +} + +// Fix implementing policydef.Policy.Fix(). +func (s SecretScanning) Fix(ctx context.Context, c *github.Client, owner, repo string) error { + return fix(ctx, c.Repositories, c, owner, repo) +} + +func fix(ctx context.Context, rep repositories, c *github.Client, owner, repo string) error { + oc, orc, rc := getConfig(ctx, c, owner, repo) + enabled, err := configIsEnabled(ctx, oc.OptConfig, orc.OptConfig, rc.OptConfig, c, owner, repo) + if err != nil { + return err + } + if !enabled { + return nil + } + + r, rsp, err := rep.Get(ctx, owner, repo) + if err != nil { + if rsp != nil && rsp.StatusCode == http.StatusForbidden { + log.Warn(). + Str("org", owner). + Str("repo", repo). + Str("area", polName). + Err(err). + Msg("Action fix is configured, but secret scanning status is unavailable.") + return nil + } + return err + } + + status, available := secretScanningStatus(r) + if !available || status != secretDisabled { + return nil + } + + _, rsp, err = rep.Edit(ctx, owner, repo, &github.Repository{ + SecurityAndAnalysis: &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: github.Ptr(secretEnabled), + }, + }, + }) + if err != nil { + if rsp != nil && rsp.StatusCode == http.StatusForbidden { + log.Warn(). + Str("org", owner). + Str("repo", repo). + Str("area", polName). + Msg("Action fix is configured, but did not accept administration permissions update.") + return nil + } + return err + } + return nil +} + +// GetAction returns the configured action from Secret Scanning policy's +// configuration stored in the org-level repo, default log. Implementing +// policydef.Policy.GetAction(). +func (s SecretScanning) GetAction(ctx context.Context, c *github.Client, owner, repo string) string { + oc, orc, rc := getConfig(ctx, c, owner, repo) + mc := mergeConfig(oc, orc, rc, repo) + return mc.Action +} + +func getConfig(ctx context.Context, c *github.Client, owner, repo string) (*OrgConfig, *RepoConfig, *RepoConfig) { + oc := &OrgConfig{ // Fill out non-zero defaults + Action: "log", + } + if err := configFetchConfig(ctx, c, owner, "", configFile, config.OrgLevel, oc); err != nil { + log.Error(). + Str("org", owner). + Str("repo", repo). + Str("configLevel", "orgLevel"). + Str("area", polName). + Str("file", configFile). + Err(err). + Msg("Unexpected config error, using defaults.") + } + orc := &RepoConfig{} + if err := configFetchConfig(ctx, c, owner, repo, configFile, config.OrgRepoLevel, orc); err != nil { + log.Error(). + Str("org", owner). + Str("repo", repo). + Str("configLevel", "orgRepoLevel"). + Str("area", polName). + Str("file", configFile). + Err(err). + Msg("Unexpected config error, using defaults.") + } + rc := &RepoConfig{} + if err := configFetchConfig(ctx, c, owner, repo, configFile, config.RepoLevel, rc); err != nil { + log.Error(). + Str("org", owner). + Str("repo", repo). + Str("configLevel", "repoLevel"). + Str("area", polName). + Str("file", configFile). + Err(err). + Msg("Unexpected config error, using defaults.") + } + return oc, orc, rc +} + +func mergeConfig(oc *OrgConfig, orc *RepoConfig, rc *RepoConfig, repo string) *mergedConfig { + mc := &mergedConfig{ + Action: oc.Action, + } + mc = mergeInRepoConfig(mc, orc, repo) + + if !oc.OptConfig.DisableRepoOverride { + mc = mergeInRepoConfig(mc, rc, repo) + } + return mc +} + +func mergeInRepoConfig(mc *mergedConfig, rc *RepoConfig, repo string) *mergedConfig { + if rc.Action != nil { + mc.Action = *rc.Action + } + return mc +} diff --git a/pkg/policies/secretscanning/secretscanning_test.go b/pkg/policies/secretscanning/secretscanning_test.go new file mode 100644 index 00000000..da08e15f --- /dev/null +++ b/pkg/policies/secretscanning/secretscanning_test.go @@ -0,0 +1,418 @@ +// 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 secretscanning + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-github/v84/github" + + "github.com/ossf/allstar/pkg/config" + "github.com/ossf/allstar/pkg/policydef" +) + +var get func(context.Context, string, string) (*github.Repository, *github.Response, error) + +var edit func(context.Context, string, string, *github.Repository) (*github.Repository, *github.Response, error) + +type mockRepos struct{} + +func (m mockRepos) Get(ctx context.Context, o string, r string) ( + *github.Repository, *github.Response, error, +) { + return get(ctx, o, r) +} + +func (m mockRepos) Edit(ctx context.Context, o, r string, repo *github.Repository) ( + *github.Repository, *github.Response, error, +) { + return edit(ctx, o, r, repo) +} + +func TestConfigPrecedence(t *testing.T) { + tests := []struct { + Name string + Org OrgConfig + OrgRepo RepoConfig + Repo RepoConfig + ExpAction string + Exp mergedConfig + }{ + { + Name: "OrgOnly", + Org: OrgConfig{ + Action: "issue", + }, + OrgRepo: RepoConfig{}, + Repo: RepoConfig{}, + ExpAction: "issue", + Exp: mergedConfig{ + Action: "issue", + }, + }, + { + Name: "OrgRepoOverOrg", + Org: OrgConfig{ + Action: "issue", + }, + OrgRepo: RepoConfig{ + Action: github.Ptr("log"), + }, + Repo: RepoConfig{}, + ExpAction: "log", + Exp: mergedConfig{ + Action: "log", + }, + }, + { + Name: "RepoOverAllOrg", + Org: OrgConfig{ + Action: "issue", + }, + OrgRepo: RepoConfig{ + Action: github.Ptr("log"), + }, + Repo: RepoConfig{ + Action: github.Ptr("email"), + }, + ExpAction: "email", + Exp: mergedConfig{ + Action: "email", + }, + }, + { + Name: "RepoDisallowed", + Org: OrgConfig{ + OptConfig: config.OrgOptConfig{ + DisableRepoOverride: true, + }, + Action: "issue", + }, + OrgRepo: RepoConfig{ + Action: github.Ptr("log"), + }, + Repo: RepoConfig{ + Action: github.Ptr("email"), + }, + ExpAction: "log", + Exp: mergedConfig{ + Action: "log", + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configFetchConfig = func(ctx context.Context, c *github.Client, + owner, repo, path string, ol config.ConfigLevel, out interface{}, + ) error { + switch ol { + case config.RepoLevel: + rc := out.(*RepoConfig) + *rc = test.Repo + case config.OrgRepoLevel: + orc := out.(*RepoConfig) + *orc = test.OrgRepo + case config.OrgLevel: + oc := out.(*OrgConfig) + *oc = test.Org + } + return nil + } + + s := SecretScanning(true) + ctx := context.Background() + + action := s.GetAction(ctx, nil, "", "thisrepo") + if action != test.ExpAction { + t.Errorf("Unexpected results. want %s, got %s", test.ExpAction, action) + } + + oc, orc, rc := getConfig(ctx, nil, "", "thisrepo") + mc := mergeConfig(oc, orc, rc, "thisrepo") + if diff := cmp.Diff(&test.Exp, mc); diff != "" { + t.Errorf("Unexpected results. (-want +got):\n%s", diff) + } + }) + } +} + +func TestCheck(t *testing.T) { + tests := []struct { + Name string + Org OrgConfig + Repo RepoConfig + Status *string + Available bool + GetForbidden bool + cofigEnabled bool + Exp policydef.Result + }{ + { + Name: "Pass", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Repo: RepoConfig{}, + Status: github.Ptr(secretEnabled), + Available: true, + cofigEnabled: true, + Exp: policydef.Result{ + Enabled: true, + Pass: true, + NotifyText: "", + Details: details{ + Available: true, + Status: secretEnabled, + URL: securityAnalysisURL("", "thisrepo"), + }, + }, + }, + { + Name: "Fail", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Repo: RepoConfig{}, + Status: github.Ptr(secretDisabled), + Available: true, + cofigEnabled: true, + Exp: policydef.Result{ + Enabled: true, + Pass: false, + NotifyText: "Secret scanning not enabled.\nGitHub secret scanning checks repositories", + Details: details{ + Available: true, + Status: secretDisabled, + URL: securityAnalysisURL("", "thisrepo"), + }, + }, + }, + { + Name: "Unavailable", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Repo: RepoConfig{}, + Available: false, + cofigEnabled: true, + Exp: policydef.Result{ + Enabled: true, + Pass: true, + NotifyText: "", + Details: details{ + Available: false, + Status: secretUnavailable, + URL: securityAnalysisURL("", "thisrepo"), + }, + }, + }, + { + Name: "ForbiddenIsUnavailable", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Repo: RepoConfig{}, + GetForbidden: true, + cofigEnabled: true, + Exp: policydef.Result{ + Enabled: true, + Pass: true, + NotifyText: "", + Details: details{ + Available: false, + Status: secretUnavailable, + URL: securityAnalysisURL("", "thisrepo"), + }, + }, + }, + { + Name: "UnknownStatusPasses", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Repo: RepoConfig{}, + Status: github.Ptr("unsupported"), + Available: true, + cofigEnabled: true, + Exp: policydef.Result{ + Enabled: true, + Pass: true, + NotifyText: "", + Details: details{ + Available: true, + Status: "unsupported", + URL: securityAnalysisURL("", "thisrepo"), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configFetchConfig = func(ctx context.Context, c *github.Client, + owner, repo, path string, ol config.ConfigLevel, out interface{}, + ) error { + if repo == "thisrepo" && ol == config.RepoLevel { + rc := out.(*RepoConfig) + *rc = test.Repo + } else if ol == config.OrgLevel { + oc := out.(*OrgConfig) + *oc = test.Org + } + return nil + } + get = func(ctx context.Context, owner, repo string) (*github.Repository, *github.Response, error) { + if test.GetForbidden { + return nil, &github.Response{Response: &http.Response{StatusCode: http.StatusForbidden}}, errors.New("forbidden") + } + r := &github.Repository{} + if test.Available { + r.SecurityAndAnalysis = &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: test.Status, + }, + } + } + return r, nil, nil + } + configIsEnabled = func(ctx context.Context, o config.OrgOptConfig, orc, r config.RepoOptConfig, + c *github.Client, owner, repo string, + ) (bool, error) { + return test.cofigEnabled, nil + } + res, err := check(context.Background(), mockRepos{}, nil, "", "thisrepo") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + c := cmp.Comparer(func(x, y string) bool { return trunc(x, 40) == trunc(y, 40) }) + if diff := cmp.Diff(&test.Exp, res, c); diff != "" { + t.Errorf("Unexpected results. (-want +got):\n%s", diff) + } + }) + } +} + +func TestFix(t *testing.T) { + tests := []struct { + Name string + Org OrgConfig + Status *string + Available bool + GetForbidden bool + EditForbidden bool + cofigEnabled bool + ExpEdit bool + }{ + { + Name: "EnabledNoop", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Status: github.Ptr(secretEnabled), + Available: true, + cofigEnabled: true, + }, + { + Name: "DisabledEnables", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Status: github.Ptr(secretDisabled), + Available: true, + cofigEnabled: true, + ExpEdit: true, + }, + { + Name: "UnavailableNoop", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Available: false, + cofigEnabled: true, + }, + { + Name: "ForbiddenNoop", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + GetForbidden: true, + cofigEnabled: true, + }, + { + Name: "PolicyDisabledNoop", + Org: OrgConfig{}, + Status: github.Ptr(secretDisabled), + Available: true, + cofigEnabled: false, + }, + { + Name: "EditForbiddenNoError", + Org: OrgConfig{OptConfig: config.OrgOptConfig{OptOutStrategy: true}}, + Status: github.Ptr(secretDisabled), + Available: true, + EditForbidden: true, + cofigEnabled: true, + ExpEdit: true, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + configFetchConfig = func(ctx context.Context, c *github.Client, + owner, repo, path string, ol config.ConfigLevel, out interface{}, + ) error { + if ol == config.OrgLevel { + oc := out.(*OrgConfig) + *oc = test.Org + } + return nil + } + get = func(ctx context.Context, owner, repo string) (*github.Repository, *github.Response, error) { + if test.GetForbidden { + return nil, &github.Response{Response: &http.Response{StatusCode: http.StatusForbidden}}, errors.New("forbidden") + } + r := &github.Repository{} + if test.Available { + r.SecurityAndAnalysis = &github.SecurityAndAnalysis{ + SecretScanning: &github.SecretScanning{ + Status: test.Status, + }, + } + } + return r, nil, nil + } + var gotEdit bool + edit = func(ctx context.Context, owner, repo string, r *github.Repository) (*github.Repository, *github.Response, error) { + gotEdit = true + if r.SecurityAndAnalysis == nil || + r.SecurityAndAnalysis.SecretScanning == nil || + r.SecurityAndAnalysis.SecretScanning.GetStatus() != secretEnabled { + t.Fatalf("Edit() called without enabling secret scanning: %+v", r) + } + if test.EditForbidden { + return nil, &github.Response{Response: &http.Response{StatusCode: http.StatusForbidden}}, errors.New("forbidden") + } + return r, nil, nil + } + configIsEnabled = func(ctx context.Context, o config.OrgOptConfig, orc, r config.RepoOptConfig, + c *github.Client, owner, repo string, + ) (bool, error) { + return test.cofigEnabled, nil + } + + err := fix(context.Background(), mockRepos{}, nil, "", "thisrepo") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if gotEdit != test.ExpEdit { + t.Errorf("Unexpected edit call. want %v, got %v", test.ExpEdit, gotEdit) + } + }) + } +} + +func trunc(s string, n int) string { + if n >= len(s) { + return s + } + return s[:n] +}