From fb4a61b66bfd4c2cffed45d08014a317e890bc6a Mon Sep 17 00:00:00 2001 From: Sam Sherar Date: Wed, 3 Sep 2025 10:56:57 +0100 Subject: [PATCH 1/4] feat: add makefile --- Makefile | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e9989bc --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk commands is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI catalog characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +# Check if OPA CLI is installed +OPA := $(shell command -v opa 2> /dev/null) +ifeq ($(OPA),) +$(error "opa CLI not found. Please install it: https://www.openpolicyagent.org/docs/latest/cli/") +endif + +##@ Help +help: ## Display this concise help, ie only the porcelain target + @awk 'BEGIN {FS = ":.*##"; printf "\033[1mUsage\033[0m\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-30s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +help-all: ## Display all help items, ie including plumbing targets + @awk 'BEGIN {FS = ":.*#"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?#/ { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^#@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Policies +test: ## Test policy files + @opa test examples + +validate: ## Validate policy files + @opa check examples + +clean: # Cleanup build artifacts + @rm -rf dist/* + +# Bundle the policies into a tarball for OCI registry +build: clean ## Build the policy bundle + @mkdir -p dist/ + @opa build -b examples -o dist/bundle.tar.gz \ No newline at end of file From 09712b8496e8ea2f6f793c6011bcd91881652f34 Mon Sep 17 00:00:00 2001 From: Sam Sherar Date: Wed, 3 Sep 2025 10:57:52 +0100 Subject: [PATCH 2/4] feat: wrap in structure to pass teams as well as settings --- examples/policies/gh_org_mfa_enabled.rego | 2 +- internal/data.go | 25 ++++- internal/eval.go | 19 ++-- internal/eval_test.go | 128 +++++++++++----------- main.go | 1 + 5 files changed, 99 insertions(+), 76 deletions(-) diff --git a/examples/policies/gh_org_mfa_enabled.rego b/examples/policies/gh_org_mfa_enabled.rego index fa1e6f0..39a572f 100644 --- a/examples/policies/gh_org_mfa_enabled.rego +++ b/examples/policies/gh_org_mfa_enabled.rego @@ -5,5 +5,5 @@ violation[{ "description": "Two factor authentication should be enabled and enforced for all users within the Github Organization to make it harder for malicious actors to gain access to the organizations settings and repositories & settings", "remarks": "More information from Github can be found here: https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/requiring-two-factor-authentication-in-your-organization" }] if { - input.organization.two_factor_requirement_enabled == false + input.settings.two_factor_requirement_enabled == false } \ No newline at end of file diff --git a/internal/data.go b/internal/data.go index ce39e94..35e12af 100644 --- a/internal/data.go +++ b/internal/data.go @@ -2,6 +2,7 @@ package internal import ( "context" + policy_manager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/agent/runner/proto" @@ -9,6 +10,11 @@ import ( "github.com/hashicorp/go-hclog" ) +type GithubData struct { + Settings *github.Organization `json:"settings"` + Teams []*github.Team `json:"teams"` +} + type DataFetcher struct { logger hclog.Logger client *github.Client @@ -21,7 +27,7 @@ func NewDataFetcher(logger hclog.Logger, client *github.Client) *DataFetcher { } } -func (df DataFetcher) FetchData(ctx context.Context, organization string) (*github.Organization, []*proto.Step, error) { +func (df DataFetcher) FetchData(ctx context.Context, organization string) (*GithubData, []*proto.Step, error) { steps := make([]*proto.Step, 0) steps = append(steps, &proto.Step{ @@ -35,11 +41,26 @@ func (df DataFetcher) FetchData(ctx context.Context, organization string) (*gith Remarks: policy_manager.Pointer("More information about data being sent back can be found here: https://docs.github.com/en/rest/orgs/orgs?apiVersion=2022-11-28#get-an-organization"), }) + steps = append(steps, &proto.Step{ + Title: "Get Teams", + Description: "Using the client's native APIs, Get all the information from the teams endpoint", + Remarks: policy_manager.Pointer("More information about data being sent back can be found here: https://docs.github.com/en/rest/teams/teams?apiVersion=2022-11-28#list-teams"), + }) + org, _, err := df.client.Organizations.Get(ctx, organization) if err != nil { df.logger.Error("Error getting organization information", "org", organization, "error", err) return nil, nil, err } - return org, steps, nil + teams, _, err := df.client.Teams.ListTeams(ctx, organization, nil) + if err != nil { + df.logger.Error("Error getting teams information", "org", organization, "error", err) + return nil, nil, err + } + + return &GithubData{ + Settings: org, + Teams: teams, + }, steps, nil } diff --git a/internal/eval.go b/internal/eval.go index 8d07086..93895b1 100644 --- a/internal/eval.go +++ b/internal/eval.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "github.com/google/go-github/v71/github" "slices" policyManager "github.com/compliance-framework/agent/policy-manager" @@ -34,7 +33,7 @@ func (pe *PolicyEvaluator) GetEvidences() []*proto.Evidence { // Eval is used to run policies against the data you've collected. You could also consider an // `EvalAndSend` by passing in the `apiHelper` that sends the observations directly to the API. -func (pe *PolicyEvaluator) Eval(organization *github.Organization, policyPaths []string) (proto.ExecutionStatus, error) { +func (pe *PolicyEvaluator) Eval(data *GithubData, policyPaths []string) (proto.ExecutionStatus, error) { var accumulatedErrors error evalStatus := proto.ExecutionStatus_SUCCESS @@ -97,22 +96,22 @@ func (pe *PolicyEvaluator) Eval(organization *github.Organization, policyPaths [ inventory := []*proto.InventoryItem{ { - Identifier: fmt.Sprintf("github-organization/%s", organization.GetLogin()), + Identifier: fmt.Sprintf("github-organization/%s", data.Settings.GetLogin()), Type: "github-organization", - Title: fmt.Sprintf("Github Organization [%s]", organization.GetName()), + Title: fmt.Sprintf("Github Organization [%s]", data.Settings.GetName()), Props: []*proto.Property{ { Name: "name", - Value: organization.GetName(), + Value: data.Settings.GetName(), }, { Name: "path", - Value: organization.GetLogin(), + Value: data.Settings.GetLogin(), }, }, Links: []*proto.Link{ { - Href: organization.GetURL(), + Href: data.Settings.GetURL(), Text: policyManager.Pointer("Organization URL"), }, }, @@ -130,7 +129,7 @@ func (pe *PolicyEvaluator) Eval(organization *github.Organization, policyPaths [ subjects := []*proto.Subject{ { Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, - Identifier: fmt.Sprintf("github-organization/%s", organization.GetLogin()), + Identifier: fmt.Sprintf("github-organization/%s", data.Settings.GetLogin()), }, { Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, @@ -155,7 +154,7 @@ func (pe *PolicyEvaluator) Eval(organization *github.Organization, policyPaths [ map[string]string{ "provider": "github", "type": "organization", - "organization": organization.GetLogin(), + "organization": data.Settings.GetLogin(), }, subjects, components, @@ -163,7 +162,7 @@ func (pe *PolicyEvaluator) Eval(organization *github.Organization, policyPaths [ actors, activities, ) - evidence, err := processor.GenerateResults(pe.ctx, policyPath, organization) + evidence, err := processor.GenerateResults(pe.ctx, policyPath, data) evidences = slices.Concat(evidences, evidence) if err != nil { accumulatedErrors = errors.Join(accumulatedErrors, err) diff --git a/internal/eval_test.go b/internal/eval_test.go index 386e88a..40eded2 100644 --- a/internal/eval_test.go +++ b/internal/eval_test.go @@ -6,71 +6,73 @@ import ( "testing" "github.com/compliance-framework/agent/runner/proto" - "github.com/google/go-github/v71/github" "github.com/hashicorp/go-hclog" ) -var test_org_data = ` +var test_response = ` { - "login": "test-org", - "id": 1234567, - "node_id": "O_abcdefg", - "url": "https://api.github.com/orgs/test-org", - "repos_url": "https://api.github.com/orgs/test-org/repos", - "events_url": "https://api.github.com/orgs/test-org/events", - "hooks_url": "https://api.github.com/orgs/test-org/hooks", - "issues_url": "https://api.github.com/orgs/test-org/issues", - "members_url": "https://api.github.com/orgs/test-org/members{/member}", - "public_members_url": "https://api.github.com/orgs/test-org/public_members{/member}", - "avatar_url": "https://avatars.githubusercontent.com/u/1234567?v=4", - "description": null, - "is_verified": false, - "has_organization_projects": true, - "has_repository_projects": true, - "public_repos": 0, - "public_gists": 0, - "followers": 0, - "following": 0, - "html_url": "https://github.com/test-org", - "created_at": "2025-04-09T15:36:21Z", - "updated_at": "2025-04-09T15:38:25Z", - "archived_at": null, - "type": "Organization", - "total_private_repos": 0, - "owned_private_repos": 0, - "private_gists": 0, - "disk_usage": 0, - "collaborators": 0, - "billing_email": "test@example.com", - "default_repository_permission": "read", - "members_can_create_repositories": true, - "two_factor_requirement_enabled": false, - "members_allowed_repository_creation_type": "all", - "members_can_create_public_repositories": true, - "members_can_create_private_repositories": true, - "members_can_create_internal_repositories": false, - "members_can_create_pages": true, - "members_can_fork_private_repositories": false, - "web_commit_signoff_required": false, - "deploy_keys_enabled_for_repositories": false, - "members_can_create_public_pages": true, - "members_can_create_private_pages": true, - "plan": { - "name": "free", - "space": 976562499, - "private_repos": 10000, - "filled_seats": 2, - "seats": 1 + "settings": { + "login": "test-org", + "id": 1234567, + "node_id": "O_abcdefg", + "url": "https://api.github.com/orgs/test-org", + "repos_url": "https://api.github.com/orgs/test-org/repos", + "events_url": "https://api.github.com/orgs/test-org/events", + "hooks_url": "https://api.github.com/orgs/test-org/hooks", + "issues_url": "https://api.github.com/orgs/test-org/issues", + "members_url": "https://api.github.com/orgs/test-org/members{/member}", + "public_members_url": "https://api.github.com/orgs/test-org/public_members{/member}", + "avatar_url": "https://avatars.githubusercontent.com/u/1234567?v=4", + "description": null, + "is_verified": false, + "has_organization_projects": true, + "has_repository_projects": true, + "public_repos": 0, + "public_gists": 0, + "followers": 0, + "following": 0, + "html_url": "https://github.com/test-org", + "created_at": "2025-04-09T15:36:21Z", + "updated_at": "2025-04-09T15:38:25Z", + "archived_at": null, + "type": "Organization", + "total_private_repos": 0, + "owned_private_repos": 0, + "private_gists": 0, + "disk_usage": 0, + "collaborators": 0, + "billing_email": "test@example.com", + "default_repository_permission": "read", + "members_can_create_repositories": true, + "two_factor_requirement_enabled": false, + "members_allowed_repository_creation_type": "all", + "members_can_create_public_repositories": true, + "members_can_create_private_repositories": true, + "members_can_create_internal_repositories": false, + "members_can_create_pages": true, + "members_can_fork_private_repositories": false, + "web_commit_signoff_required": false, + "deploy_keys_enabled_for_repositories": false, + "members_can_create_public_pages": true, + "members_can_create_private_pages": true, + "plan": { + "name": "free", + "space": 976562499, + "private_repos": 10000, + "filled_seats": 2, + "seats": 1 + }, + "advanced_security_enabled_for_new_repositories": false, + "dependabot_alerts_enabled_for_new_repositories": false, + "dependabot_security_updates_enabled_for_new_repositories": false, + "dependency_graph_enabled_for_new_repositories": false, + "secret_scanning_enabled_for_new_repositories": false, + "secret_scanning_push_protection_enabled_for_new_repositories": false, + "secret_scanning_push_protection_custom_link_enabled": false, + "secret_scanning_push_protection_custom_link": null, + "secret_scanning_validity_checks_enabled": false }, - "advanced_security_enabled_for_new_repositories": false, - "dependabot_alerts_enabled_for_new_repositories": false, - "dependabot_security_updates_enabled_for_new_repositories": false, - "dependency_graph_enabled_for_new_repositories": false, - "secret_scanning_enabled_for_new_repositories": false, - "secret_scanning_push_protection_enabled_for_new_repositories": false, - "secret_scanning_push_protection_custom_link_enabled": false, - "secret_scanning_push_protection_custom_link": null, - "secret_scanning_validity_checks_enabled": false + "teams": [] } ` @@ -78,13 +80,13 @@ func TestGithubOrg_EvaluatePolicies(t *testing.T) { logger := hclog.NewNullLogger() steps := make([]*proto.Activity, 0) - organization := &github.Organization{} - _ = json.Unmarshal([]byte(test_org_data), organization) + settings := &GithubData{} + _ = json.Unmarshal([]byte(test_response), settings) ctx := context.TODO() evaluator := NewPolicyEvaluator(ctx, logger, steps) - status, err := evaluator.Eval(organization, []string{"../examples/policies"}) + status, err := evaluator.Eval(settings, []string{"../examples/policies"}) if status != proto.ExecutionStatus_SUCCESS { t.Fail() diff --git a/main.go b/main.go index b4dd1b7..d4f9eca 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/agent/runner/proto" "github.com/compliance-framework/plugin-github-settings/internal" From 1358bebc8cd5598b2d2f5cf76899a5f7da666787 Mon Sep 17 00:00:00 2001 From: Sam Sherar Date: Wed, 3 Sep 2025 10:58:59 +0100 Subject: [PATCH 3/4] feat: update README --- README.md | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3e8ae75..ef55fe7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Compliance Framework Plugin For Github -This is the individual plugin for polling github settings for organizations and repositories to test for configuration flags that are going to fail compliance checks. +This is the individual plugin for polling github settings for organizations and repositories to test for configuration flags that are going to fail compliance checks. -For the moment, it is solely limited to authenticated Github organizations with a Github PAT, but in the future it should query security plans & repositories for specific settings +For the moment, it is solely limited to authenticated Github organizations with a Github PAT, but in the future it should query security plans & repositories for specific settings ## Prerequisites -* GoReleaser https://goreleaser.com/install/ -* Github Fine Grain Personal Access Token with the following scopes: - * `read:org` for the organization to be queried. Note - you *might* need to be an administrator of the GH Org to work correctly - +- GoReleaser https://goreleaser.com/install/ +- Github Fine Grain Personal Access Token with the following scopes: + - `read:org` for the organization to be queried. Note - you _might_ need to be an administrator of the GH Org to work correctly + - `read:members` to be able to read teams ## Building @@ -40,13 +40,12 @@ export CCF_PLUGINS_GITHUB_CONFIG_TOKEN="github_pat_1234..." ``` ```yaml -... +--- plugins: github: config: token: "" # Will be read from the CCF_PLUGINS_GITHUB_CONFIG_TOKEN environment variable - organization: test-org # The name of the organization -... + organization: test-org # The name of the organization ``` ## Releasing @@ -62,11 +61,3 @@ You can find the OCI implementations in the GitHub Packages page. ```shell concom agent --plugin=https://github.com/compliance-framework/plugin-template/releases/tag/0.0.1 ``` - -## Todo - -- [X] Pull Organization settings as an authenticated user -- [ ] Pull repository information for the listed Organization -- [ ] Populate Security Plans and map them to the repositories to ensure that settings are enabled -- [ ] Sensible defaults for the configuration -- [ ] Better error handling for sending issues back to the agent \ No newline at end of file From 7d4c8642b6c5f9f4640d0a62c6bb6945e729a408 Mon Sep 17 00:00:00 2001 From: Sam Sherar Date: Wed, 3 Sep 2025 11:07:45 +0100 Subject: [PATCH 4/4] fix: handle pagination for ListTeams --- internal/data.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/internal/data.go b/internal/data.go index 35e12af..4d0869f 100644 --- a/internal/data.go +++ b/internal/data.go @@ -53,14 +53,25 @@ func (df DataFetcher) FetchData(ctx context.Context, organization string) (*Gith return nil, nil, err } - teams, _, err := df.client.Teams.ListTeams(ctx, organization, nil) - if err != nil { - df.logger.Error("Error getting teams information", "org", organization, "error", err) - return nil, nil, err + var allTeams []*github.Team + paginationOpt := &github.ListOptions{PerPage: 100} + + for { + teams, resp, err := df.client.Teams.ListTeams(ctx, organization, paginationOpt) + if err != nil { + df.logger.Error("Error getting teams information", "org", organization, "error", err) + return nil, nil, err + } + + allTeams = append(allTeams, teams...) + if resp.NextPage == 0 { + break + } + paginationOpt.Page = resp.NextPage } return &GithubData{ Settings: org, - Teams: teams, + Teams: allTeams, }, steps, nil }