From 371db9ca6878b7a5e98f034c83cab52973efc885 Mon Sep 17 00:00:00 2001 From: Travis Truman Date: Sun, 21 Sep 2025 15:30:32 -0400 Subject: [PATCH 1/2] feat: add support for branch protections via rules This commit adds support for reading and interpreting the rules applied to the default branch of the repo. Evaluations that previously only considered the state of branch protection rules will now also consider the state of branch rules. Signed-off-by: Travis Truman --- data/repository_metadata.go | 61 +++++++++++++++++-- .../osps/access_control/evaluations.go | 4 +- evaluation_plans/osps/access_control/steps.go | 34 ++++++++--- 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/data/repository_metadata.go b/data/repository_metadata.go index 5fcd6ed..9ce7821 100644 --- a/data/repository_metadata.go +++ b/data/repository_metadata.go @@ -11,13 +11,16 @@ type RepositoryMetadata interface { IsPublic() bool OrganizationBlogURL() *string IsMFARequiredForAdministrativeActions() *bool + IsDefaultBranchProtected() *bool + DefaultBranchRequiresPRReviews() *bool + IsDefaultBranchProtectedFromDeletion() *bool } type GitHubRepositoryMetadata struct { - Releases []ReleaseData - Rulesets []Ruleset - ghRepo *github.Repository - ghOrg *github.Organization + Releases []ReleaseData + defaultBranchRules *github.BranchRules + ghRepo *github.Repository + ghOrg *github.Organization } func (r *GitHubRepositoryMetadata) IsActive() bool { @@ -28,6 +31,30 @@ func (r *GitHubRepositoryMetadata) IsPublic() bool { return !r.ghRepo.GetPrivate() } +func (r *GitHubRepositoryMetadata) IsDefaultBranchProtected() *bool { + if r.defaultBranchRules == nil { + return nil + } + updateBlockedByRule := r.defaultBranchRules != nil && len(r.defaultBranchRules.Update) > 0 + return &updateBlockedByRule +} + +func (r *GitHubRepositoryMetadata) IsDefaultBranchProtectedFromDeletion() *bool { + if r.defaultBranchRules == nil { + return nil + } + deletionBlockedByRule := r.defaultBranchRules != nil && len(r.defaultBranchRules.Deletion) > 0 + return &deletionBlockedByRule +} + +func (r *GitHubRepositoryMetadata) DefaultBranchRequiresPRReviews() *bool { + if r.defaultBranchRules == nil { + return nil + } + requiresReviews := r.defaultBranchRules != nil && r.defaultBranchRules.PullRequest != nil && len(r.defaultBranchRules.PullRequest) > 0 && r.defaultBranchRules.PullRequest[0].Parameters.RequiredApprovingReviewCount > 0 + return &requiresReviews +} + func (r *GitHubRepositoryMetadata) OrganizationBlogURL() *string { if r.ghOrg != nil { return r.ghOrg.Blog @@ -53,8 +80,30 @@ func loadRepositoryMetadata(ghClient *github.Client, owner, repo string) (ghRepo ghRepo: repository, }, nil } + branchRules, err := getRuleset(ghClient, owner, repo, repository.GetDefaultBranch()) + if err != nil { + return repository, &GitHubRepositoryMetadata{ + ghRepo: repository, + ghOrg: organization, + }, nil + } return repository, &GitHubRepositoryMetadata{ - ghRepo: repository, - ghOrg: organization, + ghRepo: repository, + ghOrg: organization, + defaultBranchRules: branchRules, }, nil } + +func getRuleset(ghClient *github.Client, owner, repo string, branchName string) (*github.BranchRules, error) { + branchRules, _, err := ghClient.Repositories.GetRulesForBranch( + context.Background(), + owner, + repo, + branchName, + nil, + ) + if err != nil { + return nil, err + } + return branchRules, nil +} diff --git a/evaluation_plans/osps/access_control/evaluations.go b/evaluation_plans/osps/access_control/evaluations.go index 9276b6c..008d1fd 100644 --- a/evaluation_plans/osps/access_control/evaluations.go +++ b/evaluation_plans/osps/access_control/evaluations.go @@ -64,7 +64,7 @@ func OSPS_AC_03() (evaluation *layer4.ControlEvaluation) { "Maturity Level 3", }, []layer4.AssessmentStep{ - branchProtectionRestrictsPushes, // This checks branch protection, but not rulesets yet + defaultBranchRestrictsPushes, }, ) @@ -77,7 +77,7 @@ func OSPS_AC_03() (evaluation *layer4.ControlEvaluation) { "Maturity Level 3", }, []layer4.AssessmentStep{ - branchProtectionPreventsDeletion, // This checks branch protection, but not rulesets yet + defaultBranchPreventsDeletion, }, ) diff --git a/evaluation_plans/osps/access_control/steps.go b/evaluation_plans/osps/access_control/steps.go index 99add37..e2f3b38 100644 --- a/evaluation_plans/osps/access_control/steps.go +++ b/evaluation_plans/osps/access_control/steps.go @@ -22,7 +22,7 @@ func orgRequiresMFA(payloadData any, _ map[string]*layer4.Change) (result layer4 return layer4.Failed, "Two-factor authentication is NOT configured as required by the parent organization" } -func branchProtectionRestrictsPushes(payloadData any, _ map[string]*layer4.Change) (result layer4.Result, message string) { +func defaultBranchRestrictsPushes(payloadData any, _ map[string]*layer4.Change) (result layer4.Result, message string) { payload, message := reusable_steps.VerifyPayload(payloadData) if message != "" { return layer4.Unknown, message @@ -36,28 +36,42 @@ func branchProtectionRestrictsPushes(payloadData any, _ map[string]*layer4.Chang result = layer4.Passed message = "Branch protection rule requires approving reviews" } else { - result = layer4.NeedsReview - message = "Branch protection rule does not restrict pushes or require approving reviews; Rulesets not yet evaluated." + if payload.RepositoryMetadata.IsDefaultBranchProtected() != nil && *payload.RepositoryMetadata.IsDefaultBranchProtected() { + result = layer4.Passed + message = "Branch rule restricts pushes" + } else if payload.RepositoryMetadata.DefaultBranchRequiresPRReviews() != nil && *payload.RepositoryMetadata.DefaultBranchRequiresPRReviews() { + result = layer4.Passed + message = "Branch rule requires approving reviews" + } else { + result = layer4.Failed + message = "Default branch is not protected" + } } - return + return result, message } -func branchProtectionPreventsDeletion(payloadData any, _ map[string]*layer4.Change) (result layer4.Result, message string) { +func defaultBranchPreventsDeletion(payloadData any, _ map[string]*layer4.Change) (result layer4.Result, message string) { payload, message := reusable_steps.VerifyPayload(payloadData) if message != "" { return layer4.Unknown, message } - allowsDeletion := payload.Repository.DefaultBranchRef.RefUpdateRule.AllowsDeletions + branchProtectionAllowsDeletion := payload.Repository.DefaultBranchRef.RefUpdateRule.AllowsDeletions + deletionRule := payload.RepositoryMetadata.IsDefaultBranchProtectedFromDeletion() + branchRulesAllowDeletion := deletionRule == nil || !*deletionRule - if allowsDeletion { + if branchProtectionAllowsDeletion && branchRulesAllowDeletion { result = layer4.Failed - message = "Branch protection rule allows deletions" + message = "Default branch is not protected from deletions" } else { result = layer4.Passed - message = "Branch protection rule prevents deletions" + if *deletionRule { + message = "Default branch is protected from deletions by rulesets" + } else { + message = "Default branch is protected from deletions by branch protection rules" + } } - return + return result, message } func workflowDefaultReadPermissions(payloadData any, _ map[string]*layer4.Change) (result layer4.Result, message string) { From 3d1dd9c74a14262be4694ef8b9a8e0b26136ddfb Mon Sep 17 00:00:00 2001 From: Travis Truman Date: Sun, 21 Sep 2025 19:12:55 -0400 Subject: [PATCH 2/2] test(integration): add IaC to manage integration testing repository This commit adds OpenTofu infrastructure as code to manage the GitHub repo `revanit-io/example-osps-baseline-level-1`. Because this repository can be used for integration testing changes in the project, any changes to the repository should be visible and fail a CI run. Signed-off-by: Travis Truman --- .github/workflows/integration-test.yml | 47 ++++++++++++++++++++++++++ .gitignore | 4 ++- iac/.terraform.lock.hcl | 27 +++++++++++++++ iac/README.md | 8 +++++ iac/main.tf | 12 +++++++ iac/repo.tf | 36 ++++++++++++++++++++ iac/terraform.tfstate | 1 + 7 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/integration-test.yml create mode 100644 iac/.terraform.lock.hcl create mode 100644 iac/README.md create mode 100644 iac/main.tf create mode 100644 iac/repo.tf create mode 100644 iac/terraform.tfstate diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..01782c3 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,47 @@ +name: Integration Test + +on: + workflow_dispatch: + pull_request: + paths: + - 'iac/**' + - '.github/workflows/integration-test.yml' + +permissions: + contents: read + +jobs: + tofu-plan: + runs-on: ubuntu-latest + defaults: + run: + working-directory: iac + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Tofu + uses: opentofu/setup-opentofu@v1 + + - name: Initialize Tofu + run: tofu init + + - name: Run Tofu Plan + id: plan + run: | + tofu plan -detailed-exitcode -no-color > plan.txt + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + + - name: Check for changes + run: | + if [ "${{ steps.plan.outcome }}" != "success" ]; then + echo "Tofu plan detected changes or failed." + cat plan.txt + exit 1 + fi + shell: bash \ No newline at end of file diff --git a/.gitignore b/.gitignore index abf645d..1b01bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ config.yml output # go test coverage output -coverage.out \ No newline at end of file +coverage.out + +.terraform/ \ No newline at end of file diff --git a/iac/.terraform.lock.hcl b/iac/.terraform.lock.hcl new file mode 100644 index 0000000..00d661a --- /dev/null +++ b/iac/.terraform.lock.hcl @@ -0,0 +1,27 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/integrations/github" { + version = "6.6.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:Fp0RrNe+w167AQkVUWC1WRAsyjhhHN7aHWUky7VkKW8=", + "h1:P4SRG4605PvPKASeDu1lW49TTz1cCGsjQ7qbOBgNd6I=", + "h1:Yq0DZYKVFwPdc+v5NnXYcgTYWKInSkjv5WjyWMODj9U=", + "zh:0b1b5342db6a17de7c71386704e101be7d6761569e03fb3ff1f3d4c02c32d998", + "zh:2fb663467fff76852126b58315d9a1a457e3b04bec51f04bf1c0ddc9dfbb3517", + "zh:4183e557a1dfd413dae90ca4bac37dbbe499eae5e923567371f768053f977800", + "zh:48b2979f88fb55cdb14b7e4c37c44e0dfbc21b7a19686ce75e339efda773c5c2", + "zh:5d803fb06625e0bcf83abb590d4235c117fa7f4aa2168fa3d5f686c41bc529ec", + "zh:6f1dd094cbab36363583cda837d7ca470bef5f8abf9b19f23e9cd8b927153498", + "zh:772edb5890d72b32868f9fdc0a9a1d4f4701d8e7f8acb37a7ac530d053c776e3", + "zh:798f443dbba6610431dcef832047f6917fb5a4e184a3a776c44e6213fb429cc6", + "zh:cc08dfcc387e2603f6dbaff8c236c1254185450d6cadd6bad92879fe7e7dbce9", + "zh:d5e2c8d7f50f91d6847ddce27b10b721bdfce99c1bbab42a68fa271337d73d63", + "zh:e69a0045440c706f50f84a84ff8b1df520ec9bf757de4b8f9959f2ed20c3f440", + "zh:efc5358573a6403cbea3a08a2fcd2407258ac083d9134c641bdcb578966d8bdf", + "zh:f627a255e5809ec2375f79949c79417847fa56b9e9222ea7c45a463eb663f137", + "zh:f7c02f762e4cf1de7f58bde520798491ccdd54a5bd52278d579c146d1d07d4f0", + "zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25", + ] +} diff --git a/iac/README.md b/iac/README.md new file mode 100644 index 0000000..a62a7a2 --- /dev/null +++ b/iac/README.md @@ -0,0 +1,8 @@ +# Example README for OpenTofu-managed repository + +This repository is managed by OpenTofu (Terraform alternative) via Infrastructure as Code. + +- Repository: revanite-io/example-osps-baseline-level-1 +- Managed resources: repository settings, topics, README file + +Feel free to update this file as needed. diff --git a/iac/main.tf b/iac/main.tf new file mode 100644 index 0000000..8dacb3b --- /dev/null +++ b/iac/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = ">= 5.0.0" + } + } +} + +provider "github" { + owner = "revanite-io" +} diff --git a/iac/repo.tf b/iac/repo.tf new file mode 100644 index 0000000..dfd6171 --- /dev/null +++ b/iac/repo.tf @@ -0,0 +1,36 @@ +# OpenTofu configuration for managing the revanite-io/example-osps-baseline-level-1 repository + +resource "github_repository" "example_osps_baseline_level_1" { + name = "example-osps-baseline-level-1" + description = "Example repository for integration testing of pvtr-github-repo" + visibility = "public" + has_issues = true + has_wiki = true + has_projects = true + has_downloads = true + vulnerability_alerts = true +} + +resource "github_repository_ruleset" "default_branch_protection" { + name = "default" + repository = github_repository.example_osps_baseline_level_1.name + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + creation = false + update = true + deletion = true + non_fast_forward = true + pull_request { + required_approving_review_count = 1 + } + } +} diff --git a/iac/terraform.tfstate b/iac/terraform.tfstate new file mode 100644 index 0000000..0f76842 --- /dev/null +++ b/iac/terraform.tfstate @@ -0,0 +1 @@ +{"version":4,"terraform_version":"1.10.6","serial":2,"lineage":"7bc637ea-e7ff-1f51-242c-395719e69890","outputs":{},"resources":[{"mode":"managed","type":"github_repository","name":"example_osps_baseline_level_1","provider":"provider[\"registry.opentofu.org/integrations/github\"]","instances":[{"schema_version":1,"attributes":{"allow_auto_merge":false,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_squash_merge":true,"allow_update_branch":false,"archive_on_destroy":null,"archived":false,"auto_init":false,"default_branch":"main","delete_branch_on_merge":false,"description":"Example repository for integration testing of pvtr-github-repo","etag":"W/\"a18fbc4b4371fb3fd92f89c85eff300333be99f12c796276ec71f4a170512568\"","full_name":"revanite-io/example-osps-baseline-level-1","git_clone_url":"git://github.com/revanite-io/example-osps-baseline-level-1.git","gitignore_template":null,"has_discussions":false,"has_downloads":true,"has_issues":true,"has_projects":true,"has_wiki":true,"homepage_url":"","html_url":"https://github.com/revanite-io/example-osps-baseline-level-1","http_clone_url":"https://github.com/revanite-io/example-osps-baseline-level-1.git","id":"example-osps-baseline-level-1","ignore_vulnerability_alerts_during_read":null,"is_template":false,"license_template":null,"merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE","name":"example-osps-baseline-level-1","node_id":"R_kgDOP0IKkg","pages":[],"primary_language":"Python","private":false,"repo_id":1061292690,"security_and_analysis":[{"advanced_security":[],"secret_scanning":[{"status":"disabled"}],"secret_scanning_push_protection":[{"status":"disabled"}]}],"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","ssh_clone_url":"git@github.com:revanite-io/example-osps-baseline-level-1.git","svn_url":"https://github.com/revanite-io/example-osps-baseline-level-1","template":[],"topics":[],"visibility":"public","vulnerability_alerts":true,"web_commit_signoff_required":false},"sensitive_attributes":[],"private":"eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="}]},{"mode":"managed","type":"github_repository_ruleset","name":"default_branch_protection","provider":"provider[\"registry.opentofu.org/integrations/github\"]","instances":[{"schema_version":1,"attributes":{"bypass_actors":[],"conditions":[{"ref_name":[{"exclude":[],"include":["~DEFAULT_BRANCH"]}]}],"enforcement":"active","etag":"W/\"4354911cc42f114f622f032d1fedae8c238317d7459bfaca137e33e449db18ab\"","id":"8281062","name":"default","node_id":"RRS_lACqUmVwb3NpdG9yec4_QgqSzgB-W-Y","repository":"example-osps-baseline-level-1","rules":[{"branch_name_pattern":[],"commit_author_email_pattern":[],"commit_message_pattern":[],"committer_email_pattern":[],"creation":false,"deletion":true,"merge_queue":[],"non_fast_forward":true,"pull_request":[{"dismiss_stale_reviews_on_push":false,"require_code_owner_review":false,"require_last_push_approval":false,"required_approving_review_count":1,"required_review_thread_resolution":false}],"required_code_scanning":[],"required_deployments":[],"required_linear_history":false,"required_signatures":false,"required_status_checks":[],"tag_name_pattern":[],"update":true,"update_allows_fetch_and_merge":false}],"ruleset_id":8281062,"target":"branch"},"sensitive_attributes":[],"private":"eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="}]}],"check_results":null}