Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,55 @@ 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.

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.
Expand Down
107 changes: 107 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 77 additions & 0 deletions auth_grpc_test.go
Original file line number Diff line number Diff line change
@@ -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<string, string>; 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)
}
}
130 changes: 130 additions & 0 deletions auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down
Loading
Loading