-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrules.go
More file actions
299 lines (268 loc) · 14.3 KB
/
rules.go
File metadata and controls
299 lines (268 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
package scanner
import (
"strings"
"time"
)
// RuleCategory classifies a rule as either a *scored* rule (contributes to
// the org-level score) or an *additional* check (informational only).
type RuleCategory string
const (
CategoryScored RuleCategory = "scored"
CategoryAdditional RuleCategory = "additional"
)
// Rule defines a named check that produces a pass/fail result for a repo.
// Description supplies the per-rule text used by the Markdown scorecard's
// Rule reference section: a single self-contained paragraph that names
// what's checked, every detection path the rule walks, and how to fix it.
// Category determines whether the rule feeds into the org-level score or
// appears in the informational-only "Additional checks" section.
type Rule interface {
Name() string
Category() RuleCategory
Check(repo Repo) bool
Description() string
}
// RuleResult holds the outcome of a single rule check for a single repo.
type RuleResult struct {
RuleName string
Passed bool
}
// AllRules returns the ordered list of rules the scanner evaluates. The
// order is fixed and meaningful: scored rules first (by importance), then
// additional checks (by importance). Callers that want only one category
// can use ScoredRules or AdditionalRules.
func AllRules() []Rule {
return []Rule{
// Scored rules - drive the org-level score.
HasBranchProtection{},
HasRequiredReviewers{},
HasRequiredChecks{},
HasCodeowners{},
HasCIWorkflow{},
// Additional checks - informational only.
HasReadme{},
HasLicense{},
HasRepoDescription{},
HasActivity{},
HasSecurityMd{},
}
}
// ScoredRules returns just the rules with CategoryScored, in AllRules order.
func ScoredRules() []Rule {
return filterByCategory(CategoryScored)
}
// AdditionalRules returns just the rules with CategoryAdditional, in AllRules order.
func AdditionalRules() []Rule {
return filterByCategory(CategoryAdditional)
}
func filterByCategory(c RuleCategory) []Rule {
var out []Rule
for _, r := range AllRules() {
if r.Category() == c {
out = append(out, r)
}
}
return out
}
// HasBranchProtection checks that the default branch has protection rules enabled.
type HasBranchProtection struct{}
func (r HasBranchProtection) Name() string { return "Has branch protection" }
func (r HasBranchProtection) Category() RuleCategory { return CategoryScored }
func (r HasBranchProtection) Check(repo Repo) bool {
return repo.BranchProtection != nil
}
func (r HasBranchProtection) Description() string {
return "Checks that the default branch has a protection rule in place. Detected via any of three GitHub APIs: the modern repository rulesets (Settings > Rules > Rulesets), the legacy classic branch-protection rules (Settings > Branches > Branch protection rules), or the `protected` flag on the public branch endpoint. To fix: add a rule for the default branch via either Rulesets or classic Branch protection rules. [GitHub docs](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches)."
}
// HasRequiredReviewers checks that at least one approving review is required.
//
// This rule is admin-only: the required-approving-reviewer count on a
// classic per-repo branch protection is exposed only via the admin API
// (GET /repos/{o}/{r}/branches/{br}/protection, returns 404 to non-admins).
// Rulesets surface the count publicly, so repos using rulesets are still
// counted in non-admin scans, but most classic-protected repos can't be
// distinguished from "no protection." Rather than fail those silently,
// the scanner skips this rule entirely on non-admin scans (see
// WithAdmin in scanner.go).
type HasRequiredReviewers struct{}
func (r HasRequiredReviewers) Name() string { return "Has required reviewers" }
func (r HasRequiredReviewers) Category() RuleCategory { return CategoryScored }
func (r HasRequiredReviewers) RequiresAdmin() bool { return true }
func (r HasRequiredReviewers) Check(repo Repo) bool {
return repo.BranchProtection != nil && repo.BranchProtection.RequiredReviewers >= 1
}
func (r HasRequiredReviewers) Description() string {
return "Checks that the default branch's protection requires at least one approving review before a PR can be merged. The reviewer count is read from both modern repository rulesets (a `pull_request` rule with `required_approving_review_count >= 1`) and legacy classic branch protection (the `required_pull_request_reviews.required_approving_review_count` field). To fix: edit the default-branch rule (or ruleset) and enable the pull-request review requirement with at least 1 required reviewer."
}
// HasRequiredChecks checks that the default branch's protection requires
// at least one programmatic check to pass before a PR can be merged. The
// "checks" being required can come from any of five rulesets rule types
// (required_status_checks, workflows, code_scanning, code_quality,
// required_deployments) or from classic branch protection's contexts list -
// all of which the merge step in scanWithClient unions into the single
// BranchProtection.RequiredStatusChecks slice.
type HasRequiredChecks struct{}
func (r HasRequiredChecks) Name() string { return "Has required checks" }
func (r HasRequiredChecks) Category() RuleCategory { return CategoryScored }
func (r HasRequiredChecks) Check(repo Repo) bool {
return repo.BranchProtection != nil && len(repo.BranchProtection.RequiredStatusChecks) > 0
}
func (r HasRequiredChecks) Description() string {
return "Checks that the default branch's protection requires at least one programmatic check to pass before a PR can be merged. Detected from any of three sources: modern repository rulesets (rule types `required_status_checks`, `workflows`, `code_scanning`, `code_quality`, or `required_deployments`), legacy classic branch protection (`required_status_checks.contexts`), or the public branch endpoint's `protection.required_status_checks.contexts` field. To fix: in Rulesets or Branch protection rules, add any check-passing requirement on the default branch."
}
// HasCodeowners checks that a CODEOWNERS file exists in root, docs/, or .github/.
type HasCodeowners struct{}
func (r HasCodeowners) Name() string { return "Has CODEOWNERS" }
func (r HasCodeowners) Category() RuleCategory { return CategoryScored }
func (r HasCodeowners) Check(repo Repo) bool {
return hasFile(repo.Files, "CODEOWNERS") ||
hasFile(repo.Files, "docs/CODEOWNERS") ||
hasFile(repo.Files, ".github/CODEOWNERS")
}
func (r HasCodeowners) Description() string {
return "Checks for a CODEOWNERS file in any of the three locations GitHub honors: the repo root, `.github/`, or `docs/`. To fix: add a CODEOWNERS file in one of those locations mapping paths to GitHub users or teams. [GitHub docs](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners)."
}
// HasCIWorkflow checks that the repo has a CI workflow configured for any
// of the well-known CI providers, not just GitHub Actions. Detected via
// the presence of one of these signals at the repo root or under their
// canonical directory:
//
// - GitHub Actions: .github/workflows/*.yml or *.yaml
// - CircleCI: .circleci/config.yml
// - GitLab CI: .gitlab-ci.yml
// - Travis CI: .travis.yml
// - Buildkite: any file under .buildkite/
// - Azure Pipelines: azure-pipelines.yml
// - Jenkins: Jenkinsfile
//
// Repos using a CI integration that lives entirely server-side (e.g.,
// CircleCI without a checked-in config) are still missed; this is a
// best-effort signal based on what's visible in the repo.
type HasCIWorkflow struct{}
func (r HasCIWorkflow) Name() string { return "Has CI workflow" }
func (r HasCIWorkflow) Category() RuleCategory { return CategoryScored }
func (r HasCIWorkflow) Check(repo Repo) bool {
for _, f := range repo.Files {
// GitHub Actions workflows under .github/workflows/<anything>.yml|yaml.
if strings.HasPrefix(f.Path, ".github/workflows/") &&
(strings.HasSuffix(f.Path, ".yml") || strings.HasSuffix(f.Path, ".yaml")) {
return true
}
// Buildkite uses a directory; any file inside counts.
if strings.HasPrefix(f.Path, ".buildkite/") {
return true
}
// Single-file CI configs at known paths.
switch f.Path {
case ".circleci/config.yml",
".gitlab-ci.yml",
".travis.yml",
"azure-pipelines.yml",
"Jenkinsfile":
return true
}
}
return false
}
func (r HasCIWorkflow) Description() string {
return "Checks for a checked-in CI configuration file from any of the major providers: GitHub Actions (any `.yml` or `.yaml` file under `.github/workflows/`), CircleCI (`.circleci/config.yml`), GitLab CI (`.gitlab-ci.yml`), Travis (`.travis.yml`), Buildkite (any file under `.buildkite/`), Azure Pipelines (`azure-pipelines.yml`), or Jenkins (`Jenkinsfile`). Setups whose configuration lives entirely server-side (no checked-in file) are not detected. To fix: add a workflow file for the provider you use. The simplest path on GitHub is a YAML workflow under `.github/workflows/`. [GitHub Actions quickstart](https://docs.github.com/en/actions/quickstart)."
}
// HasReadme checks that some form of README file exists at the repo root.
// Matches case-insensitively on the filename and accepts any extension
// (or no extension), so README.md, readme.rst, README.txt, Readme,
// README.markdown all pass. Subdirectory READMEs (e.g., docs/README.md)
// don't count - the rule is about a top-level project README.
//
// (No size threshold - the previous "substantial" variant was dropped
// because 2 KB is too low to discriminate quality and too high to reward
// minimal but useful READMEs.)
type HasReadme struct{}
func (r HasReadme) Name() string { return "Has README" }
func (r HasReadme) Category() RuleCategory { return CategoryAdditional }
func (r HasReadme) Check(repo Repo) bool {
for _, f := range repo.Files {
if strings.Contains(f.Path, "/") {
continue // not at root
}
lower := strings.ToLower(f.Path)
if lower == "readme" || strings.HasPrefix(lower, "readme.") {
return true
}
}
return false
}
func (r HasReadme) Description() string {
return "Checks for a README file at the repository root. The match is case-insensitive and accepts any extension or none, so `README.md`, `README.rst`, `README.txt`, `Readme`, `readme.markdown` all pass. READMEs in subdirectories don't count. To fix: add a top-level README that explains what the project is, how to install it, and how to use it."
}
// HasLicense uses GitHub's auto-detected license (Licensee) instead of
// a path-pattern match, so any conventionally-named license file works:
// LICENSE, LICENSE.md, LICENSE.txt, LICENCE (British), COPYING (GNU),
// MIT-LICENSE, etc. - anything GitHub recognizes and surfaces as the
// repo's `license.spdx_id` in the listing payload.
//
// Custom-text licenses GitHub can't auto-detect won't pass even though
// the file may be present. That's a known false negative; the trade-off
// is worth it for the much broader correct-positive coverage.
type HasLicense struct{}
func (r HasLicense) Name() string { return "Has LICENSE" }
func (r HasLicense) Category() RuleCategory { return CategoryAdditional }
func (r HasLicense) Check(repo Repo) bool {
return repo.License != ""
}
func (r HasLicense) Description() string {
return "Checks GitHub's auto-detected license field, which GitHub populates by running the Licensee gem against the repo and recognizing conventionally-named license files: `LICENSE`, `LICENSE.md`, `LICENSE.txt`, `LICENCE`, `COPYING`, `MIT-LICENSE`, and similar variants. Custom-text licenses Licensee can't classify won't pass even if a file is present. To fix: pick a license at [choosealicense.com](https://choosealicense.com) and add it to your repo root using one of the recognized filenames. GitHub will detect it automatically."
}
// HasRepoDescription checks that the repo description field is not blank.
type HasRepoDescription struct{}
func (r HasRepoDescription) Name() string { return "Has repo description" }
func (r HasRepoDescription) Category() RuleCategory { return CategoryAdditional }
func (r HasRepoDescription) Check(repo Repo) bool {
return strings.TrimSpace(repo.Description) != ""
}
func (r HasRepoDescription) Description() string {
return "Checks that the repository's description field (set via the About panel, shown at the top of the GitHub repo page) is non-empty. To fix: edit the repo's About panel and add a one-line description."
}
// HasActivity checks that the repo has had a commit (push) within the last
// 12 months. Set Now to a fixed time for deterministic testing; the zero
// value means time.Now() is used at check time.
type HasActivity struct {
Now time.Time
}
func (r HasActivity) Name() string { return "Has activity" }
func (r HasActivity) Category() RuleCategory { return CategoryAdditional }
func (r HasActivity) Check(repo Repo) bool {
now := r.Now
if now.IsZero() {
now = time.Now()
}
return repo.PushedAt.After(now.AddDate(-1, 0, 0))
}
func (r HasActivity) Description() string {
return "Checks that the repository has had a commit (push) within the last 12 months, based on GitHub's `pushed_at` timestamp on the repo. To fix: push a commit, or archive the repository if it's no longer maintained."
}
// HasSecurityMd checks that SECURITY.md exists in any of the three
// locations GitHub recognizes for security policies: repo root,
// .github/, or docs/.
type HasSecurityMd struct{}
func (r HasSecurityMd) Name() string { return "Has SECURITY.md" }
func (r HasSecurityMd) Category() RuleCategory { return CategoryAdditional }
func (r HasSecurityMd) Check(repo Repo) bool {
return hasFile(repo.Files, "SECURITY.md") ||
hasFile(repo.Files, ".github/SECURITY.md") ||
hasFile(repo.Files, "docs/SECURITY.md")
}
func (r HasSecurityMd) Description() string {
return "Checks for a SECURITY.md file in any of the three locations GitHub recognizes for security policies: the repo root, `.github/`, or `docs/`. To fix: add a SECURITY.md describing how to report vulnerabilities. [GitHub's template](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository)."
}
func findFile(files []FileEntry, path string) (FileEntry, bool) {
for _, f := range files {
if f.Path == path {
return f, true
}
}
return FileEntry{}, false
}
func hasFile(files []FileEntry, path string) bool {
_, ok := findFile(files, path)
return ok
}