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
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
102 changes: 102 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
@@ -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
}
66 changes: 66 additions & 0 deletions auth_grpc_test.go
Original file line number Diff line number Diff line change
@@ -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<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) (*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)
}
}
123 changes: 123 additions & 0 deletions auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down
Loading
Loading