From 8685d24fa520de97b45227ca900771536bad6b02 Mon Sep 17 00:00:00 2001 From: alpoi Date: Wed, 28 Oct 2026 14:40:32 +0000 Subject: [PATCH 1/3] feat: GitHub App authentication Besides a token, the plugin can now authenticate as a GitHub App installation (app-id, installation-id, and private-key-file or private-key), using short-lived installation tokens that ghinstallation renews. A token still wins when set, and with neither the plugin stays unauthenticated as before; only incomplete app credentials are rejected. Ports compliance-framework/plugin-github-repositories#43 (itself based on plugin-github-repositories#3), with this plugin's hyphenated config keys. Co-authored-by: Gustavo Carvalho Co-Authored-By: Claude Opus 5.5 --- README.md | 41 +++++++++++- auth.go | 107 ++++++++++++++++++++++++++++++++ auth_test.go | 130 +++++++++++++++++++++++++++++++++++++++ go.mod | 4 ++ go.sum | 6 ++ main.go | 19 ++++-- main_integration_test.go | 6 +- 7 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 auth.go create mode 100644 auth_test.go diff --git a/README.md b/README.md index 3691e77..0b7987b 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,50 @@ 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 installation. Either needs at minimum the following permissions: * Dependabot Alerts (read-only) - Used for reading alerts * Administration (read-only) - Used to check whether a repository has alerts enabled +With a token: + +```yaml +plugins: + dependabot: + config: + token: "github_pat_..." + organization: octocat +``` + +### 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 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: + dependabot: + config: + 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. With neither, the plugin calls +GitHub unauthenticated. + ## 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..3c96703 --- /dev/null +++ b/auth.go @@ -0,0 +1,107 @@ +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 rejects incomplete GitHub App credentials. With neither a token nor app +// credentials the plugin calls GitHub unauthenticated, as it always has. +func (c *AuthConfig) validateAuth() error { + if c.Token != "" || !c.hasAppCredentials() { + return nil + } + 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 +} + +func (c *AuthConfig) hasAppCredentials() bool { + return c.AppID != "" || c.InstallationID != "" || c.PrivateKeyFile != "" || c.PrivateKey != "" +} + +// httpClient returns an HTTP client that authenticates every request to GitHub, or nil (the +// default, unauthenticated client) when no credentials are configured. +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 + } + if !c.hasAppCredentials() { + return nil, 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_test.go b/auth_test.go new file mode 100644 index 0000000..19426a2 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,130 @@ +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 (unauthenticated, as before)", cfg: AuthConfig{}}, + {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("no credentials", func(t *testing.T) { + c, err := (&AuthConfig{}).httpClient(context.Background()) + if err != nil || c != nil { + t.Fatalf("client %v, error %v; want nil, nil", c, 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.validateAuth(); err != nil { + t.Fatalf("validateAuth: %v", err) + } +} diff --git a/go.mod b/go.mod index bfeb6ff..b36be8a 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/compliance-framework/plugin-dependabot 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 github.com/hashicorp/go-plugin v1.8.0 github.com/mitchellh/mapstructure v1.5.0 github.com/stretchr/testify v1.12.1 + golang.org/x/oauth2 v0.37.0 ) require ( @@ -21,8 +23,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 ee5b2d0..157e121 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 e9c6916..104a28a 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ const ( ) type PluginConfig struct { - Token string `mapstructure:"token"` + AuthConfig `mapstructure:",squash"` Organization *string `mapstructure:"organization"` IncludedRepositories *string `mapstructure:"included-repositories"` User *string `mapstructure:"user"` @@ -33,7 +33,7 @@ type PluginConfig struct { OperationalMode OperationalMode `mapstructure:"operational-mode"` } type ParsedConfig struct { - Token string `mapstructure:"token"` + Auth AuthConfig Organization *string `mapstructure:"organization"` IncludedRepositories []string `mapstructure:"included-repositories"` User *string `mapstructure:"user"` @@ -114,7 +114,7 @@ func (l *DependabotPlugin) ParseConfig() { if l.config.IncludedRepositories != nil { l.parsedConfig.IncludedRepositories = strings.Split(*l.config.IncludedRepositories, ",") } - l.parsedConfig.Token = l.config.Token + l.parsedConfig.Auth = l.config.AuthConfig l.parsedConfig.Organization = l.config.Organization l.parsedConfig.User = l.config.User l.parsedConfig.SecurityTeamName = l.config.SecurityTeamName @@ -154,7 +154,18 @@ func (l *DependabotPlugin) Configure(req *proto.ConfigureRequest) (*proto.Config ) l.ParseConfig() - client, err := github.NewClient(github.WithAuthToken(l.parsedConfig.Token)) + if err := l.parsedConfig.Auth.validateAuth(); err != nil { + return nil, err + } + httpClient, err := l.parsedConfig.Auth.httpClient(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to create github http client: %w", err) + } + var opts []github.ClientOptionsFunc + if httpClient != nil { + opts = append(opts, github.WithHTTPClient(httpClient)) + } + client, err := github.NewClient(opts...) if err != nil { return nil, fmt.Errorf("failed to create github client: %w", err) } diff --git a/main_integration_test.go b/main_integration_test.go index 5e06f7c..cb087f4 100644 --- a/main_integration_test.go +++ b/main_integration_test.go @@ -18,7 +18,7 @@ func TestDependabotPlugin_Integration_FetchRepositories(t *testing.T) { plugin := DependabotPlugin{ logger: hclog.NewNullLogger(), config: &PluginConfig{ - Token: os.Getenv("GITHUB_TOKEN"), + AuthConfig: AuthConfig{Token: os.Getenv("GITHUB_TOKEN")}, Organization: policy_manager.Pointer("compliance-framework"), }, githubClient: newTestGithubClient(t), @@ -58,7 +58,7 @@ func TestDependabotPlugin_Integration_FetchRepositoryDependabotAlerts(t *testing JSONFormat: true, }), config: &PluginConfig{ - Token: os.Getenv("GITHUB_TOKEN"), + AuthConfig: AuthConfig{Token: os.Getenv("GITHUB_TOKEN")}, Organization: policy_manager.Pointer("compliance-framework"), }, githubClient: newTestGithubClient(t), @@ -83,7 +83,7 @@ func TestDependabotPlugin_Integration_FetchTeamMembers(t *testing.T) { plugin := DependabotPlugin{ logger: hclog.NewNullLogger(), config: &PluginConfig{ - Token: os.Getenv("GITHUB_TOKEN"), + AuthConfig: AuthConfig{Token: os.Getenv("GITHUB_TOKEN")}, Organization: policy_manager.Pointer("compliance-framework"), SecurityTeamName: policy_manager.Pointer("security"), }, From 10baa188aa595850cf65b76ed2a03ab403783a8e Mon Sep 17 00:00:00 2001 From: alpoi Date: Wed, 28 Oct 2026 14:49:11 +0000 Subject: [PATCH 2/3] fix: snake_case GitHub App config keys The agent maps plugin config keys to CCF_* environment variables by uppercasing them and replacing dots, not hyphens, so app-id and friends could not be set from the environment (CCF_PLUGINS_DEPENDABOT_CONFIG_APP-ID is not a valid variable name). Use app_id, installation_id, private_key_file and private_key, the same keys as plugin-github-repositories and plugin-github-settings. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++++++++---- auth.go | 24 ++++++++++++------------ auth_test.go | 8 ++++---- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0b7987b..739809a 100644 --- a/README.md +++ b/README.md @@ -38,18 +38,23 @@ installation tokens, which it renews as they expire. plugins: dependabot: config: - app-id: "123456" - installation-id: "78901234" + 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 + 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..." + # private_key: "LS0tLS1CRUdJTi..." organization: octocat ``` A `token`, when set, takes precedence over the app settings. With neither, the plugin calls GitHub unauthenticated. +The app keys use underscores (unlike `included-repositories` and the other existing keys), so +the agent can fill them from environment variables such as +`CCF_PLUGINS_DEPENDABOT_CONFIG_PRIVATE_KEY`, when the agent's config file declares the key +(for example `private_key: ""`). + ## Integration testing This plugin contains unit tests as well as integration tests. diff --git a/auth.go b/auth.go index 3c96703..6c1cb12 100644 --- a/auth.go +++ b/auth.go @@ -21,12 +21,12 @@ type AuthConfig struct { // 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"` + 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"` + PrivateKeyFile string `mapstructure:"private_key_file"` + PrivateKey string `mapstructure:"private_key"` } // validateAuth rejects incomplete GitHub App credentials. With neither a token nor app @@ -37,19 +37,19 @@ func (c *AuthConfig) validateAuth() error { } var missing []string if c.AppID == "" { - missing = append(missing, "app-id") + missing = append(missing, "app_id") } if c.InstallationID == "" { - missing = append(missing, "installation-id") + missing = append(missing, "installation_id") } if c.PrivateKeyFile == "" && c.PrivateKey == "" { - missing = append(missing, "private-key-file or private-key") + 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 errors.New("only one of private_key_file or private_key may be set") } return nil } @@ -70,11 +70,11 @@ func (c *AuthConfig) httpClient(ctx context.Context) (*http.Client, error) { appID, err := strconv.ParseInt(c.AppID, 10, 64) if err != nil { - return nil, fmt.Errorf("parsing app-id: %w", err) + 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) + return nil, fmt.Errorf("parsing installation_id: %w", err) } key, err := c.privateKey() if err != nil { @@ -91,7 +91,7 @@ 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 nil, fmt.Errorf("reading private_key_file: %w", err) } return key, nil } @@ -101,7 +101,7 @@ func (c *AuthConfig) privateKey() ([]byte, error) { } 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 nil, fmt.Errorf("decoding private_key (expected PEM or base64-encoded PEM): %w", err) } return decoded, nil } diff --git a/auth_test.go b/auth_test.go index 19426a2..c2d8461 100644 --- a/auth_test.go +++ b/auth_test.go @@ -37,8 +37,8 @@ func TestAuthConfigValidate(t *testing.T) { {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 (unauthenticated, as before)", cfg: AuthConfig{}}, - {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: "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 { @@ -99,7 +99,7 @@ func TestAuthConfigHTTPClient(t *testing.T) { } for name, cfg := range map[string]AuthConfig{ - "non-numeric app-id": {AppID: "abc", InstallationID: "2", PrivateKey: string(pemKey)}, + "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"))}, @@ -116,7 +116,7 @@ func TestAuthConfigHTTPClient(t *testing.T) { 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", + "app_id": "1", "installation_id": "2", "private_key_file": "/key.pem", "organization": "octocat", }, &cfg) if err != nil { t.Fatal(err) From 6f770e43ee4ade1d7e2452fbe5f9ee033d1d5219 Mon Sep 17 00:00:00 2001 From: alpoi Date: Wed, 28 Oct 2026 15:05:23 +0000 Subject: [PATCH 3/3] test: Configure over a real go-plugin gRPC connection The agent sends plugin config over gRPC as a flat map, which the plugin decodes into PluginConfig with the embedded, squashed AuthConfig. These tests send Configure through go-plugin's in-process gRPC harness: the token config every deployment sends today, a GitHub App config (with the empty keys agent configs declare for environment injection), and incomplete app credentials. Co-Authored-By: Claude Opus 5.5 --- auth_grpc_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 auth_grpc_test.go diff --git a/auth_grpc_test.go b/auth_grpc_test.go new file mode 100644 index 0000000..0fc6ca4 --- /dev/null +++ b/auth_grpc_test.go @@ -0,0 +1,77 @@ +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) (*DependabotPlugin, error) { + t.Helper() + impl := &DependabotPlugin{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", "operational-mode": "Bundled"}) + if err != nil { + t.Fatal(err) + } + if impl.config.Token != "ghp_abc" || impl.parsedConfig.Auth.Token != "ghp_abc" || impl.githubClient == 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 TestConfigureOverGRPCWithoutCredentials(t *testing.T) { + // As before GitHub App support: no credentials means an unauthenticated client. + impl, err := configureOverGRPC(t, map[string]string{"token": "", "organization": "octocat"}) + if err != nil { + t.Fatal(err) + } + if impl.githubClient == nil { + t.Fatal("no github client") + } +} + +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) + } +}