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 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 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..4d0869f 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,37 @@ 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 + 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: allTeams, + }, 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"