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/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) { 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}