diff --git a/README.md b/README.md index 523da99..47a9461 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ This plugin is intended to be run as part of an aggregate agent, and will execut ## Authentication -To authenticate this plugin, you must provide a token which has at minimum the following permissions: +The plugin authenticates with a token (a fine-grained personal access token, for example) or as a +[GitHub App](#github-app-authentication) installation. Either needs at minimum the following +read-only permissions: - Actions (read-only) - Used to pull workflow jobs and success - Administration (read-only) - Used to check configuration and rulesets for a repository @@ -29,6 +31,10 @@ When dependency health collection is enabled, the token also uses repository con plugins: github_repos: token: "gh-pat-abc123" + # Or authenticate as a GitHub App instead of setting token (see below): + # app_id: "123456" + # installation_id: "78901234" + # private_key_file: /etc/ccf/github-app.pem # Organization which you want to check the repos of organization: octocat # The following items are mutually exclusive, so cannot be set together. If neither are set, all repos are @@ -50,6 +56,33 @@ Dependency policies are now evaluated using policy behavior metadata from the re Policy input migration: use request `policy_data` for new policy-specific inputs. The legacy plugin config key `policy_input` is still accepted as a JSON string fallback when request `policy_data` is not provided, and repository policy evaluation exposes the same data under both `input.policy_data` and the legacy `input.policy_input` key for compatibility. If both `policy_data` and `policy_input` are provided, `policy_data` is used. + +### GitHub App authentication + +Instead of a token, the plugin can authenticate as a GitHub App installed on the organization. +Organizations can govern apps centrally, and the plugin only ever holds short-lived +installation tokens, which it renews as they expire. + +1. Create a GitHub App (organization *Settings → Developer settings → GitHub Apps*) with the + read-only repository permissions listed above, and generate a private key for it. +2. Install the app on the organization, for all repositories or the ones the plugin checks. +3. Configure the plugin with the app's ID, the installation's ID (the number at the end of the + installation's settings URL) and the private key: + +```yaml +plugins: + github_repos: + app_id: "123456" + installation_id: "78901234" + # The PEM file the app's "Generate a private key" downloads (preferred), or + private_key_file: /etc/ccf/github-app.pem + # the key inline, as PEM or base64-encoded PEM (e.g. from an environment variable) + # private_key: "LS0tLS1CRUdJTi..." + organization: octocat +``` + +A `token`, when set, takes precedence over the app settings. + ## Integration testing This plugin contains unit tests as well as integration tests. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..187254d --- /dev/null +++ b/auth.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/bradleyfalzon/ghinstallation/v2" + "golang.org/x/oauth2" +) + +// AuthConfig is how the plugin authenticates to GitHub: a token (personal access token or +// any other bearer token) or a GitHub App installation. A token wins when both are set. +type AuthConfig struct { + Token string `mapstructure:"token"` + + // GitHub App authentication. The plugin exchanges the app's private key for short-lived + // installation tokens, renewed as they expire. + AppID string `mapstructure:"app_id"` + InstallationID string `mapstructure:"installation_id"` + // The PEM private key, from a file (preferred: keeps the key out of the config) or inline, + // base64-encoded or as plain PEM. + PrivateKeyFile string `mapstructure:"private_key_file"` + PrivateKey string `mapstructure:"private_key"` +} + +// validateAuth requires a token or a complete set of GitHub App credentials. +func (c *AuthConfig) validateAuth() error { + if c.Token != "" { + return nil + } + if c.AppID == "" && c.InstallationID == "" && c.PrivateKeyFile == "" && c.PrivateKey == "" { + return errors.New("token or GitHub App credentials (app_id, installation_id and private_key_file or private_key) are required") + } + var missing []string + if c.AppID == "" { + missing = append(missing, "app_id") + } + if c.InstallationID == "" { + missing = append(missing, "installation_id") + } + if c.PrivateKeyFile == "" && c.PrivateKey == "" { + missing = append(missing, "private_key_file or private_key") + } + if len(missing) > 0 { + return fmt.Errorf("GitHub App authentication needs %s", strings.Join(missing, ", ")) + } + if c.PrivateKeyFile != "" && c.PrivateKey != "" { + return errors.New("only one of private_key_file or private_key may be set") + } + return nil +} + +// httpClient returns an HTTP client that authenticates every request to GitHub (REST and +// GraphQL alike). +func (c *AuthConfig) httpClient(ctx context.Context) (*http.Client, error) { + if c.Token != "" { + return oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token})), nil + } + + appID, err := strconv.ParseInt(c.AppID, 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing app_id: %w", err) + } + installationID, err := strconv.ParseInt(c.InstallationID, 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing installation_id: %w", err) + } + key, err := c.privateKey() + if err != nil { + return nil, err + } + transport, err := ghinstallation.New(http.DefaultTransport, appID, installationID, key) + if err != nil { + return nil, fmt.Errorf("creating GitHub App installation transport: %w", err) + } + return &http.Client{Transport: transport}, nil +} + +func (c *AuthConfig) privateKey() ([]byte, error) { + if c.PrivateKeyFile != "" { + key, err := os.ReadFile(c.PrivateKeyFile) + if err != nil { + return nil, fmt.Errorf("reading private_key_file: %w", err) + } + return key, nil + } + key := strings.TrimSpace(c.PrivateKey) + if strings.HasPrefix(key, "-----BEGIN") { + return []byte(key), nil + } + decoded, err := base64.StdEncoding.DecodeString(key) + if err != nil { + return nil, fmt.Errorf("decoding private_key (expected PEM or base64-encoded PEM): %w", err) + } + return decoded, nil +} diff --git a/auth_grpc_test.go b/auth_grpc_test.go new file mode 100644 index 0000000..9d1ab0d --- /dev/null +++ b/auth_grpc_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "strings" + "testing" + + "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/agent/runner/proto" + "github.com/hashicorp/go-hclog" + goplugin "github.com/hashicorp/go-plugin" +) + +// The agent sends plugin config over gRPC as a flat map; the plugin decodes it +// into PluginConfig on its side. These tests send Configure through a real go-plugin gRPC +// connection, so they cover the embedded (squashed) AuthConfig the way the agent uses it. +func configureOverGRPC(t *testing.T, config map[string]string) (*GithubReposPlugin, error) { + t.Helper() + impl := &GithubReposPlugin{Logger: hclog.NewNullLogger()} + client, _ := goplugin.TestPluginGRPCConn(t, false, map[string]goplugin.Plugin{ + "runner": &runner.RunnerV2GRPCPlugin{Impl: impl}, + }) + t.Cleanup(func() { _ = client.Close() }) + + raw, err := client.Dispense("runner") + if err != nil { + t.Fatal(err) + } + _, err = raw.(runner.Runner).Configure(&proto.ConfigureRequest{Config: config}) + return impl, err +} + +func TestConfigureOverGRPCWithToken(t *testing.T) { + // The config every existing deployment sends. + impl, err := configureOverGRPC(t, map[string]string{"token": "ghp_abc", "organization": "octocat"}) + if err != nil { + t.Fatal(err) + } + if impl.config.Token != "ghp_abc" || impl.githubClient == nil || impl.graphqlClient == nil { + t.Fatalf("token config not applied: token=%q", impl.config.Token) + } +} + +func TestConfigureOverGRPCWithGitHubApp(t *testing.T) { + impl, err := configureOverGRPC(t, map[string]string{ + "app_id": "1", + "installation_id": "2", + "private_key": string(testPrivateKey(t)), + "organization": "octocat", + // Empty keys, as agent configs declare them for environment injection. + "token": "", + "private_key_file": "", + }) + if err != nil { + t.Fatal(err) + } + if impl.config.AppID != "1" || impl.config.InstallationID != "2" || impl.githubClient == nil { + t.Fatalf("app config not applied: %+v", impl.config.AuthConfig) + } +} + +func TestConfigureOverGRPCRejectsIncompleteApp(t *testing.T) { + _, err := configureOverGRPC(t, map[string]string{"app_id": "1", "organization": "octocat"}) + if err == nil || !strings.Contains(err.Error(), "installation_id") { + t.Fatalf("error = %v, want one naming installation_id", err) + } +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..0804df6 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/mitchellh/mapstructure" + "golang.org/x/oauth2" +) + +func testPrivateKey(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) +} + +func TestAuthConfigValidate(t *testing.T) { + tests := []struct { + name string + cfg AuthConfig + wantErr string + }{ + {name: "token", cfg: AuthConfig{Token: "ghp_abc"}}, + {name: "token wins over partial app credentials", cfg: AuthConfig{Token: "ghp_abc", AppID: "1"}}, + {name: "app with key file", cfg: AuthConfig{AppID: "1", InstallationID: "2", PrivateKeyFile: "/key.pem"}}, + {name: "app with inline key", cfg: AuthConfig{AppID: "1", InstallationID: "2", PrivateKey: "abc"}}, + {name: "nothing", cfg: AuthConfig{}, wantErr: "token or GitHub App credentials"}, + {name: "app without installation", cfg: AuthConfig{AppID: "1", PrivateKey: "abc"}, wantErr: "needs installation_id"}, + {name: "app without key", cfg: AuthConfig{AppID: "1", InstallationID: "2"}, wantErr: "needs private_key_file or private_key"}, + {name: "two keys", cfg: AuthConfig{AppID: "1", InstallationID: "2", PrivateKeyFile: "/key.pem", PrivateKey: "abc"}, wantErr: "only one of"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.validateAuth() + if tt.wantErr == "" && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { + t.Fatalf("error = %v, want one containing %q", err, tt.wantErr) + } + }) + } +} + +func TestAuthConfigHTTPClient(t *testing.T) { + pemKey := testPrivateKey(t) + keyFile := filepath.Join(t.TempDir(), "key.pem") + if err := os.WriteFile(keyFile, pemKey, 0o600); err != nil { + t.Fatal(err) + } + + t.Run("token", func(t *testing.T) { + c, err := (&AuthConfig{Token: "ghp_abc"}).httpClient(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, ok := c.Transport.(*oauth2.Transport); !ok { + t.Fatalf("transport = %T, want *oauth2.Transport", c.Transport) + } + }) + + for name, cfg := range map[string]AuthConfig{ + "app with key file": {AppID: "1", InstallationID: "2", PrivateKeyFile: keyFile}, + "app with base64 key": {AppID: "1", InstallationID: "2", PrivateKey: base64.StdEncoding.EncodeToString(pemKey)}, + "app with plain PEM key": {AppID: "1", InstallationID: "2", PrivateKey: string(pemKey)}, + } { + t.Run(name, func(t *testing.T) { + c, err := cfg.httpClient(context.Background()) + if err != nil { + t.Fatal(err) + } + tr, ok := c.Transport.(*ghinstallation.Transport) + if !ok { + t.Fatalf("transport = %T, want *ghinstallation.Transport", c.Transport) + } + if tr.AppID() != 1 || tr.InstallationID() != 2 { + t.Fatalf("app %d installation %d, want 1 and 2", tr.AppID(), tr.InstallationID()) + } + }) + } + + for name, cfg := range map[string]AuthConfig{ + "non-numeric app_id": {AppID: "abc", InstallationID: "2", PrivateKey: string(pemKey)}, + "missing key file": {AppID: "1", InstallationID: "2", PrivateKeyFile: filepath.Join(t.TempDir(), "nope.pem")}, + "key is not base64": {AppID: "1", InstallationID: "2", PrivateKey: "not base64!"}, + "key is not a PEM key": {AppID: "1", InstallationID: "2", PrivateKey: base64.StdEncoding.EncodeToString([]byte("nope"))}, + } { + t.Run(name, func(t *testing.T) { + if _, err := cfg.httpClient(context.Background()); err == nil { + t.Fatal("expected an error") + } + }) + } +} + +// The agent passes plugin config as a flat map; the auth keys must land in the embedded struct. +func TestPluginConfigDecodesAuthKeys(t *testing.T) { + var cfg PluginConfig + err := mapstructure.Decode(map[string]string{ + "app_id": "1", "installation_id": "2", "private_key_file": "/key.pem", "organization": "octocat", + }, &cfg) + if err != nil { + t.Fatal(err) + } + if cfg.AppID != "1" || cfg.InstallationID != "2" || cfg.PrivateKeyFile != "/key.pem" { + t.Fatalf("auth keys not decoded: %+v", cfg.AuthConfig) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } +} diff --git a/go.mod b/go.mod index cb36e44..69bbb11 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/compliance-framework/plugin-github-repositories go 1.26.1 require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 github.com/compliance-framework/agent v0.9.4 github.com/google/go-github/v93 v93.0.0 github.com/hashicorp/go-hclog v1.6.3 @@ -24,8 +25,10 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v1.0.0 // indirect github.com/goccy/go-json v0.10.6 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-containerregistry v0.22.1 // indirect + github.com/google/go-github/v88 v88.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect diff --git a/go.sum b/go.sum index 7a7bcab..16a24a1 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= @@ -151,6 +153,8 @@ github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg= github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -162,6 +166,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84= github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0= +github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= +github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= github.com/google/go-github/v93 v93.0.0 h1:qElXPLv+XbSz1qrLdWyb0t3gSYGsWBTzr9T4/PZTCfk= github.com/google/go-github/v93 v93.0.0/go.mod h1:l8I3rt4/mdjANKpg4Y84LEy9HJQCetUkZ8WTl2bPU8M= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= diff --git a/main.go b/main.go index 3186aec..6e0da35 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,6 @@ import ( goplugin "github.com/hashicorp/go-plugin" "github.com/mitchellh/mapstructure" "github.com/shurcooL/githubv4" - "golang.org/x/oauth2" ) // Validator is implemented by configuration values that can validate themselves. @@ -28,7 +27,7 @@ type Validator interface { // PluginConfig contains user-provided and parsed runtime configuration for the plugin. type PluginConfig struct { - Token string `mapstructure:"token"` + AuthConfig `mapstructure:",squash"` Organization string `mapstructure:"organization"` IncludedRepositories string `mapstructure:"included_repositories"` ExcludedRepositories string `mapstructure:"excluded_repositories"` @@ -58,8 +57,8 @@ type PluginConfig struct { // Validate checks required configuration fields and mutually exclusive repository filters. func (c *PluginConfig) Validate() error { - if c.Token == "" { - return fmt.Errorf("token is required") + if err := c.validateAuth(); err != nil { + return err } if c.Organization == "" { return fmt.Errorf("organization is required") @@ -267,9 +266,10 @@ func (l *GithubReposPlugin) Configure(req *proto.ConfigureRequest) (*proto.Confi ) l.config = config - httpClient := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{ - AccessToken: config.Token, - })) + httpClient, err := config.httpClient(context.Background()) + if err != nil { + return nil, fmt.Errorf("creating github http client: %w", err) + } githubClient, err := github.NewClient(github.WithHTTPClient(httpClient)) if err != nil { return nil, fmt.Errorf("creating github client: %w", err)