From 9028219a7596cb053bf5b20179206fefaebbb9c7 Mon Sep 17 00:00:00 2001 From: Ben Moskovitz Date: Thu, 24 Nov 2022 18:16:38 +1300 Subject: [PATCH] Secret experiment --- bootstrap/bootstrap.go | 76 ++++++++++++++++++++ go.mod | 1 + go.sum | 2 + secrets/provider.go | 37 ++++++++++ secrets/provider_aws_ssm.go | 75 ++++++++++++++++++++ secrets/provider_registry.go | 131 +++++++++++++++++++++++++++++++++++ secrets/secret.go | 69 ++++++++++++++++++ secrets/secret_config.go | 38 ++++++++++ 8 files changed, 429 insertions(+) create mode 100644 secrets/provider.go create mode 100644 secrets/provider_aws_ssm.go create mode 100644 secrets/provider_registry.go create mode 100644 secrets/secret.go create mode 100644 secrets/secret_config.go diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 472a9d0696..1c13e5d234 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -23,6 +23,7 @@ import ( "github.com/buildkite/agent/v3/hook" "github.com/buildkite/agent/v3/process" "github.com/buildkite/agent/v3/redaction" + "github.com/buildkite/agent/v3/secrets" "github.com/buildkite/agent/v3/tracetools" "github.com/buildkite/agent/v3/utils" "github.com/buildkite/roko" @@ -113,6 +114,81 @@ func (b *Bootstrap) Run(ctx context.Context) (exitCode int) { return shell.GetExitCode(err) } + // Just pretend that these two jsons live in the pipeline.yml, and get passed through as env vars by the backend + secretProviderRegistryJSON := `[ + { + "id": "ssm", + "type": "aws-ssm", + "config": {} + }, + { + "id": "other-ssm", + "type": "aws-ssm", + "config": { + "role_arn": "arn:aws:iam::555555555555:role/benno-test-role-delete-after-2022-11-29" + } + } +]` + + secretProviderRegistry, err := secrets.NewProviderRegistryFromJSON(secrets.ProviderRegistryConfig{Shell: b.shell}, secretProviderRegistryJSON) + if err != nil { + b.shell.Errorf("Error creating secret provider registry: %v", err) + return 1 + } + + secretsJSON := []byte(`[ + { + "env_var": "SUPER_SECRET_ENV_VAR", + "key": "/benno/secret/envar", + "provider_id": "ssm" + }, + { + "file": "/Users/ben/secret-file", + "key": "/benno/secret/file", + "provider_id": "other-ssm" + } +]`) + + var secretConfigs []secrets.SecretConfig + err = json.Unmarshal(secretsJSON, &secretConfigs) + if err != nil { + b.shell.Errorf("Error unmarshalling secrets: %v", err) + return 1 + } + + validationErrs := make([]error, 0, len(secretConfigs)) + for _, c := range secretConfigs { + err := c.Validate() + validationErrs = append(validationErrs, err) + } + + for _, err := range validationErrs { + b.shell.Errorf("Error validating secret config: %v", err) + } + + if len(validationErrs) > 0 { + return 1 + } + + secrets, errors := secretProviderRegistry.FetchAll(secretConfigs) + if len(errors) > 0 { + b.shell.Errorf("Errors fetching secrets:") + for _, err := range errors { + b.shell.Errorf(" %v", err) + } + return 1 + } + + for _, secret := range secrets { + // TODO: Automatically add env secrets to the redactor + err := secret.Store() + if err != nil { + b.shell.Errorf("Error storing secret: %v", err) + } + + defer secret.Cleanup() + } + var includePhase = func(phase string) bool { if len(b.Phases) == 0 { return true diff --git a/go.mod b/go.mod index eb8b643fd5..833449a8f9 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,7 @@ require ( github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/philhofer/fwd v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/puzpuzpuz/xsync v1.5.2 github.com/qri-io/jsonpointer v0.0.0-20180309164927-168dd9e45cf2 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/sasha-s/go-deadlock v0.0.0-20180226215254-237a9547c8a5 // indirect diff --git a/go.sum b/go.sum index b55eff19b1..9bd65ec4de 100644 --- a/go.sum +++ b/go.sum @@ -228,6 +228,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/puzpuzpuz/xsync v1.5.2 h1:yRAP4wqSOZG+/4pxJ08fPTwrfL0IzE/LKQ/cw509qGY= +github.com/puzpuzpuz/xsync v1.5.2/go.mod h1:K98BYhX3k1dQ2M63t1YNVDanbwUPmBCAhNmVrrxfiGg= github.com/qri-io/jsonpointer v0.0.0-20180309164927-168dd9e45cf2 h1:C8RRfIlExwwrXw28G8LkrpWiHUVT4uLowfvjUYJ2Iec= github.com/qri-io/jsonpointer v0.0.0-20180309164927-168dd9e45cf2/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64= github.com/qri-io/jsonschema v0.0.0-20180607150648-d0d3b10ec792 h1:vwTGeGWCew89DI4ZwKCaobGAN7ExvZiBzgn4LZHMVOc= diff --git a/secrets/provider.go b/secrets/provider.go new file mode 100644 index 0000000000..a161ff28c1 --- /dev/null +++ b/secrets/provider.go @@ -0,0 +1,37 @@ +package secrets + +import ( + "encoding/json" + "fmt" +) + +type providerCandidate struct { + Type string `json:"type"` + ID string `json:"id"` + Config json.RawMessage `json:"config"` +} + +func (r providerCandidate) Initialize() (Provider, error) { + switch r.Type { + case "aws-ssm": + var conf AWSSSMProviderConfig + err := json.Unmarshal(r.Config, &conf) + if err != nil { + return nil, fmt.Errorf("unmarshalling config for aws-ssm provider %s: %v", r.ID, err) + } + + ssm, err := NewAWSSSMProvider(r.ID, conf) + if err != nil { + return nil, fmt.Errorf("creating aws-ssm provider %s: %w", r.ID, err) + } + + return ssm, nil + default: + return nil, fmt.Errorf("invalid provider type %s for provider %s", r.Type, r.ID) + } +} + +// A Provider is a source of secrets, and must provide a way to fetch a secret given some key. Providers must be goroutine-safe. +type Provider interface { + Fetch(key string) (string, error) +} diff --git a/secrets/provider_aws_ssm.go b/secrets/provider_aws_ssm.go new file mode 100644 index 0000000000..c327882f6e --- /dev/null +++ b/secrets/provider_aws_ssm.go @@ -0,0 +1,75 @@ +package secrets + +import ( + "fmt" + "os" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/credentials/stscreds" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/aws/aws-sdk-go/service/sts" +) + +type AWSSSMProviderConfig struct { + ID string `json:"id"` + RoleARN string `json:"role_arn"` + AssumeViaOIDC bool `json:"assume_via_oidc"` +} + +type AWSSSMProvider struct { + id string + ssmI *ssm.SSM +} + +func NewAWSSSMProvider(id string, config AWSSSMProviderConfig) (*AWSSSMProvider, error) { + sess, err := session.NewSession() + if err != nil { + return nil, fmt.Errorf("initialising AWS session: %w", err) + } + + return &AWSSSMProvider{ + id: id, + ssmI: generateSSMInstance(sess, config), + }, nil +} + +func (s *AWSSSMProvider) ID() string { + return s.id +} + +func (s *AWSSSMProvider) Type() string { + return "aws-ssm" +} + +func (s *AWSSSMProvider) Fetch(key string) (string, error) { + out, err := s.ssmI.GetParameter(&ssm.GetParameterInput{ + Name: aws.String(key), + WithDecryption: aws.Bool(true), + }) + + if err != nil { + return "", fmt.Errorf("retrieving secret %s from SSM Parameter Store: %w", key, err) + } + + return *out.Parameter.Value, nil +} + +func generateSSMInstance(sess *session.Session, config AWSSSMProviderConfig) *ssm.SSM { + if config.RoleARN == "" { + return ssm.New(sess) + } + + if config.AssumeViaOIDC { + stsClient := sts.New(sess) + sessionName := fmt.Sprintf("buildkite-agent-aws-ssm-secrets-provider-%s", os.Getenv("BUILDKITE_JOB_ID")) + // TODO: Use BK OIDC provider instead of some rando file + roleProvider := stscreds.NewWebIdentityRoleProviderWithOptions(stsClient, config.RoleARN, sessionName, stscreds.FetchTokenPath("/build/token")) + creds := credentials.NewCredentials(roleProvider) + return ssm.New(sess, &aws.Config{Credentials: creds}) + } + + creds := stscreds.NewCredentials(sess, config.RoleARN) + return ssm.New(sess, &aws.Config{Credentials: creds}) +} diff --git a/secrets/provider_registry.go b/secrets/provider_registry.go new file mode 100644 index 0000000000..3b712f830f --- /dev/null +++ b/secrets/provider_registry.go @@ -0,0 +1,131 @@ +package secrets + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/buildkite/agent/v3/bootstrap/shell" + "github.com/puzpuzpuz/xsync" +) + +type ProviderRegistry struct { + config ProviderRegistryConfig + // We have the two maps here because we only want to initialise a provider if a secret using that provider is used + // We shouldn't boot up any secrets providers that won't get used + candidates *xsync.MapOf[string, providerCandidate] // Candidates technically doesn't need to be a sync map as it's never altered after initialization, but i've made it one just for symmetry + providers *xsync.MapOf[string, Provider] +} + +type ProviderRegistryConfig struct { + Shell *shell.Shell +} + +// NewProviderRegistryFromJSON takes a JSON string representing a slice of secrets.RawProvider, and returns a ProviderRegistry, +// ready to be used to fetch secrets. +func NewProviderRegistryFromJSON(config ProviderRegistryConfig, jsonIn string) (*ProviderRegistry, error) { + var rawProviders []providerCandidate + err := json.Unmarshal([]byte(jsonIn), &rawProviders) + if err != nil { + return nil, fmt.Errorf("unmarshalling secret providers: %w", err) + } + + candidates := xsync.NewMapOf[providerCandidate]() + for _, provider := range rawProviders { + if _, ok := candidates.Load(provider.ID); ok { + return nil, fmt.Errorf("duplicate provider ID: %s. Provider IDs must be unique", provider.ID) + } + + candidates.Store(provider.ID, provider) + } + + return &ProviderRegistry{ + config: config, + candidates: candidates, + providers: xsync.NewMapOf[Provider](), + }, nil +} + +func (pr *ProviderRegistry) FetchAll(configs []SecretConfig) ([]Secret, []error) { + secrets := make([]Secret, 0, len(configs)) + secretsCh := make(chan Secret) + + errors := make([]error, 0, len(configs)) + errorsCh := make(chan error) + + var wg sync.WaitGroup + for _, c := range configs { + wg.Add(1) + go func(config SecretConfig) { + defer wg.Done() + + secret, err := pr.Fetch(config) + if err != nil { + + errorsCh <- err + return + } + secretsCh <- secret + }(c) + } + + go func() { + for err := range errorsCh { + errors = append(errors, err) + } + }() + + go func() { + for secret := range secretsCh { + secrets = append(secrets, secret) + } + }() + + wg.Wait() + close(secretsCh) + close(errorsCh) + + return secrets, errors +} + +// Fetch takes a SecretConfig, and attempts to fetch it from the provider specified in the config. +// This method is goroutine-safe. +func (pr *ProviderRegistry) Fetch(config SecretConfig) (Secret, error) { + if provider, ok := pr.providers.Load(config.ProviderID); ok { // We've used this provider before, it's already been initialized + value, err := provider.Fetch(config.Key) + if err != nil { + return nil, fmt.Errorf("fetching secret %s from provider %s: %w", config.Key, config.ProviderID, err) + } + + pr.config.Shell.Commentf("Secret %s fetched from provider %s", config.Key, config.ProviderID) + secret, err := newSecret(config, pr.config.Shell.Env, value) + if err != nil { + return nil, fmt.Errorf("creating secret %s from provider %s: %w", config.Key, config.ProviderID, err) + } + return secret, nil + } + + if candidate, ok := pr.candidates.Load(config.ProviderID); ok { // We haven't used this provider yet, so we need to initialize it + provider, err := candidate.Initialize() + if err != nil { + return nil, fmt.Errorf("initializing provider %s (type: %s) to fetch secret %s: %w", config.ProviderID, candidate.Type, config.Key, err) + } + + pr.providers.Store(config.ProviderID, provider) // Store the initialised provider + + value, err := provider.Fetch(config.Key) // Now fetch the actual secret. + if err != nil { + return nil, fmt.Errorf("fetching secret %s from provider %s: %w", config.Key, config.ProviderID, err) + } + + pr.config.Shell.Commentf("Secret %s fetched from provider %s", config.Key, config.ProviderID) + secret, err := newSecret(config, pr.config.Shell.Env, value) + if err != nil { + return nil, fmt.Errorf("creating secret %s from provider %s: %w", config.Key, config.ProviderID, err) + } + return secret, nil + } + + // If we've got to this point, the user has tried to use a provider ID that's not in the registry, so we can't give them their secret + return nil, fmt.Errorf("no secret provider with ID: %s", config.ProviderID) +} diff --git a/secrets/secret.go b/secrets/secret.go new file mode 100644 index 0000000000..c206613420 --- /dev/null +++ b/secrets/secret.go @@ -0,0 +1,69 @@ +package secrets + +import ( + "fmt" + "os" + + "github.com/buildkite/agent/v3/env" +) + +type Secret interface { + Store() error + Cleanup() func() +} + +func newSecret(config SecretConfig, environment env.Environment, value string) (Secret, error) { + switch config.Type() { + case "env": + return newEnvSecret(config, environment, value), nil + case "file": + return newFileSecret(config, value), nil + default: + return nil, fmt.Errorf("invalid secret type %s", config.Type()) + } +} + +type EnvSecret struct { + EnvVar string + Value string + Env env.Environment +} + +func newEnvSecret(config SecretConfig, environment env.Environment, value string) Secret { + return EnvSecret{ + EnvVar: config.EnvVar, + Value: value, + Env: environment, + } +} + +func (s EnvSecret) Store() error { + s.Env.Set(s.EnvVar, s.Value) + return nil +} + +func (s EnvSecret) Cleanup() func() { + return func() {} +} + +type FileSecret struct { + FilePath string + Value string +} + +func newFileSecret(config SecretConfig, value string) Secret { + return FileSecret{ + FilePath: config.File, + Value: value, + } +} + +func (s FileSecret) Store() error { + return os.WriteFile(s.FilePath, []byte(s.Value), 0777) +} + +func (s FileSecret) Cleanup() func() { + return func() { + os.Remove(s.FilePath) + } +} diff --git a/secrets/secret_config.go b/secrets/secret_config.go new file mode 100644 index 0000000000..2613db7ff1 --- /dev/null +++ b/secrets/secret_config.go @@ -0,0 +1,38 @@ +package secrets + +import "fmt" + +type SecretConfig struct { + Key string `json:"key"` + ProviderID string `json:"provider_id"` + EnvVar string `json:"env_var"` + File string `json:"file"` +} + +func (s SecretConfig) Validate() error { + if s.Key == "" { + return fmt.Errorf("secret: %s (provider: %s): key is required", s.Key, s.ProviderID) + } + + if s.ProviderID == "" { + return fmt.Errorf("secret: %s (provider: %s): provider_id is required", s.Key, s.ProviderID) + } + + if s.EnvVar == "" && s.File == "" { + return fmt.Errorf("secret: %s (provider: %s): must have either env_var or file set", s.Key, s.ProviderID) + } + + if s.EnvVar != "" && s.File != "" { + return fmt.Errorf("secret: %s (provider: %s): must only have one of env_var or file set", s.Key, s.ProviderID) + } + + return nil +} + +func (s SecretConfig) Type() string { + if s.EnvVar != "" { + return "env" + } + + return "file" +}