Skip to content

Commit 6a2f945

Browse files
authored
Merge pull request #76 from liamstevens/feat/add-multiple-secret-file-support
Feat/add multiple secret file support
2 parents b2b33e0 + e49b509 commit 6a2f945

File tree

4 files changed

+148
-17
lines changed

4 files changed

+148
-17
lines changed

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Different types of secrets are supported and exposed to your builds in appropria
77
- `ssh-agent` for SSH Private Keys
88
- Environment Variables for strings
99
- `git-credential` via git's credential.helper
10+
- Other secrets, which must be suffixed with one of the following:
11+
- `_SECRET`
12+
- `_SECRET_KEY`
13+
- `_PASSWORD`
14+
- `_TOKEN`
15+
- `_ACCESS_KEY`
1016

1117
## Installation
1218

@@ -55,9 +61,10 @@ When run via the agent environment and pre-exit hook, your builds will check in
5561
- `s3://{bucket_name}/private_ssh_key`
5662
- `s3://{bucket_name}/environment` or `s3://{bucket_name}/env`
5763
- `s3://{bucket_name}/git-credentials`
64+
- `s3://{bucket_name}/secret-files/`
5865

5966
The private key is exposed to both the checkout and the command as an ssh-agent instance.
60-
The secrets in the env file are exposed as environment variables.
67+
The secrets in the env file are exposed as environment variables, as are individual secret files.
6168
The locations of git-credentials are passed via `GIT_CONFIG_PARAMETERS` environment to git.
6269

6370
## Uploading Secrets
@@ -100,6 +107,16 @@ Key values pairs can also be uploaded.
100107
aws s3 cp --acl private --sse aws:kms <(echo "MY_SECRET=blah") "s3://${secrets_bucket}/environment"
101108
```
102109

110+
### Individual Secrets
111+
112+
Individual secrets with a suffix of `_SECRET`, `_SECRET_KEY`, `_PASSWORD`, `_TOKEN`, or `_ACCESS_KEY` can be uploaded to the same location as the rest of your configuration, under an additional prefix of `/secret-files/`.
113+
114+
The file contents should be the secret value, and the object key becomes the environment variable name. For example:
115+
116+
```bash
117+
aws s3 cp --acl private --sse aws:kms <(echo "<SECRET_VALUE>") "s3://${secrets_bucket}/secret-files/SPECIAL_SECRET"
118+
```
119+
103120
## Options
104121

105122
### `bucket`

s3secrets-helper/s3/s3.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"log"
87
"io/ioutil"
8+
"log"
99
"os"
10+
"strings"
1011

1112
"github.com/aws/aws-sdk-go-v2/aws"
1213
"github.com/aws/aws-sdk-go-v2/config"
@@ -20,7 +21,7 @@ import (
2021
)
2122

2223
type Client struct {
23-
s3 *s3.Client
24+
s3 *s3.Client
2425
bucket string
2526
region string
2627
}
@@ -82,17 +83,17 @@ func New(log *log.Logger, bucket string, regionHint string) (*Client, error) {
8283
}
8384

8485
return &Client{
85-
s3: s3.NewFromConfig(awsConfig),
86+
s3: s3.NewFromConfig(awsConfig),
8687
bucket: bucket,
8788
region: awsConfig.Region,
8889
}, nil
8990
}
9091

91-
func (c *Client) Bucket() (string) {
92+
func (c *Client) Bucket() string {
9293
return c.bucket
9394
}
9495

95-
func (c *Client) Region() (string) {
96+
func (c *Client) Region() string {
9697
return c.region
9798
}
9899

@@ -128,6 +129,32 @@ func (c *Client) Get(key string) ([]byte, error) {
128129
return ioutil.ReadAll(out.Body)
129130
}
130131

132+
// ListSuffix returns a list of keys in the bucket that have the given prefix and suffix.
133+
// This has a maximum of 1000 keys, for now. This can be expanded by using the continuation token.
134+
func (c *Client) ListSuffix(prefix string, suffixes []string) ([]string, error) {
135+
var resp *s3.ListObjectsV2Output
136+
var keys []string
137+
resp, err := c.s3.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
138+
Bucket: &c.bucket,
139+
Prefix: &prefix,
140+
})
141+
142+
// Iterate over all objects at the prefix and find those who match our suffix
143+
for _, object := range resp.Contents {
144+
for _, suffix := range suffixes {
145+
if strings.HasSuffix(*object.Key, suffix) {
146+
keys = append(keys, *object.Key)
147+
break //break out of the suffix loop
148+
}
149+
}
150+
}
151+
152+
if err != nil {
153+
return nil, fmt.Errorf("Could not ListObjectsV2 (%s) in bucket (%s). Ensure your IAM Identity has s3:ListBucket permission for this bucket. (%v)", prefix, c.bucket, err)
154+
}
155+
return keys, nil
156+
}
157+
131158
// BucketExists returns whether the bucket exists.
132159
// 200 OK returns true without error.
133160
// 404 Not Found and 403 Forbidden return false without error.

s3secrets-helper/secrets/secrets.go

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import (
1212

1313
// Client represents interaction with AWS S3
1414
type Client interface {
15-
Bucket() (string)
16-
Region() (string)
15+
Bucket() string
16+
Region() string
1717
Get(key string) ([]byte, error)
18+
ListSuffix(prefix string, suffix []string) ([]string, error)
1819
BucketExists() (bool, error)
1920
}
2021

@@ -52,6 +53,10 @@ type Config struct {
5253

5354
// GitCredentialHelper is the path to git-credential-s3-secrets
5455
GitCredentialHelper string
56+
57+
// Secret suffixes to look for in S3.
58+
// Defaults to "_SECRET", "_SECRET_KEY", "_PASSWORD", "_TOKEN", and "_ACCESS_KEY"
59+
SecretSuffixes []string
5560
}
5661

5762
// Run is the programmatic (as opposed to CLI) entrypoint to all
@@ -81,6 +86,9 @@ func Run(conf Config) error {
8186
resultsGit := make(chan getResult)
8287
getGitCredentials(conf, resultsGit)
8388

89+
resultsSecrets := make(chan getResult)
90+
getSecrets(conf, resultsSecrets)
91+
8492
if err := handleSSHKeys(conf, resultsSSH); err != nil {
8593
return err
8694
}
@@ -90,6 +98,9 @@ func Run(conf Config) error {
9098
if err := handleGitCredentials(conf, resultsGit); err != nil {
9199
return err
92100
}
101+
if err := handleSecrets(conf, resultsSecrets); err != nil {
102+
return err
103+
}
93104
return nil
94105
}
95106

@@ -121,6 +132,22 @@ func getEnvs(conf Config, results chan<- getResult) {
121132
go GetAll(conf.Client, conf.Client.Bucket(), keys, results)
122133
}
123134

135+
func getSecrets(conf Config, results chan<- getResult) {
136+
suffixes := append(conf.SecretSuffixes, []string{
137+
"_SECRET",
138+
"_SECRET_KEY",
139+
"_PASSWORD",
140+
"_TOKEN",
141+
"_ACCESS_KEY",
142+
}...)
143+
secretPrefix := conf.Prefix + "/secret-files"
144+
keys, err := conf.Client.ListSuffix(secretPrefix, suffixes)
145+
if err != nil {
146+
fmt.Errorf("listing matching secrets: %w", err)
147+
}
148+
go GetAll(conf.Client, conf.Client.Bucket(), keys, results)
149+
}
150+
124151
func getGitCredentials(conf Config, results chan<- getResult) {
125152
keys := []string{
126153
"git-credentials",
@@ -227,7 +254,7 @@ func handleGitCredentials(conf Config, results <-chan getResult) error {
227254
// Replace backslash '\' with double backslash '\\'
228255
helper = strings.ReplaceAll(helper, "\\", "\\\\")
229256

230-
singleQuotedHelpers = append(singleQuotedHelpers, "'" + helper + "'")
257+
singleQuotedHelpers = append(singleQuotedHelpers, "'"+helper+"'")
231258
}
232259
env := "GIT_CONFIG_PARAMETERS=\"" + strings.Join(singleQuotedHelpers, " ") + "\"\n"
233260

@@ -237,6 +264,40 @@ func handleGitCredentials(conf Config, results <-chan getResult) error {
237264
return nil
238265
}
239266

267+
// handleSecrets loads secrets into the environment.
268+
// The key is the last part of the S3 key.
269+
func handleSecrets(conf Config, results <-chan getResult) error {
270+
log := conf.Logger
271+
// Build an environment variable for interpretation by a shell
272+
var singleQuotedSecrets []string
273+
var envString string
274+
for r := range results {
275+
if r.err != nil {
276+
if r.err != sentinel.ErrNotFound && r.err != sentinel.ErrForbidden {
277+
log.Printf("+++ :warning: Failed to download secret %s/%s: %v", r.bucket, r.key, r.err)
278+
}
279+
continue
280+
}
281+
log.Printf("Adding secret %s/%s to environment", r.bucket, r.key)
282+
envKey := strings.Split(r.key, "/")[len(strings.Split(r.key, "/"))-1]
283+
284+
// Replace backslash '\' with double backslash '\\'
285+
value := strings.ReplaceAll(string(r.data), "\\", "\\\\")
286+
287+
singleQuotedSecrets = append(singleQuotedSecrets, envKey+"='"+value+"'")
288+
289+
}
290+
if len(singleQuotedSecrets) == 0 {
291+
log.Printf("No secrets found in %q", conf.Prefix)
292+
return nil
293+
}
294+
envString = strings.Join(singleQuotedSecrets, "\n") + "\n"
295+
if _, err := io.WriteString(conf.EnvSink, envString); err != nil {
296+
return fmt.Errorf("writing SECRETS to env: %w", err)
297+
}
298+
return nil
299+
}
300+
240301
type getResult struct {
241302
bucket string
242303
key string

s3secrets-helper/secrets/secrets_test.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,19 @@ import (
1616
)
1717

1818
type FakeClient struct {
19-
t *testing.T
20-
data map[string]FakeObject
19+
t *testing.T
20+
data map[string]FakeObject
21+
bucket string
2122
}
2223

2324
type FakeObject struct {
2425
data []byte
2526
err error
2627
}
2728

28-
func (c *FakeClient) Get(bucket, key string) ([]byte, error) {
29+
func (c *FakeClient) Get(key string) ([]byte, error) {
2930
time.Sleep(time.Duration(rand.Int()%100) * time.Millisecond)
30-
path := bucket + "/" + key
31+
path := c.bucket + "/" + key
3132
if result, ok := c.data[path]; ok {
3233
c.t.Logf("FakeClient Get %s: %d bytes, error: %v", path, len(result.data), result.err)
3334
return result.data, result.err
@@ -36,10 +37,23 @@ func (c *FakeClient) Get(bucket, key string) ([]byte, error) {
3637
return nil, sentinel.ErrNotFound
3738
}
3839

39-
func (c *FakeClient) BucketExists(bucket string) (bool, error) {
40+
func (c *FakeClient) BucketExists() (bool, error) {
4041
return true, nil
4142
}
4243

44+
func (c *FakeClient) Bucket() string {
45+
return c.bucket
46+
}
47+
48+
func (c *FakeClient) ListSuffix(prefix string, suffix []string) ([]string, error) {
49+
fakeSecrets := []string{"pipeline/secret-files/BUILDKITE_ACCESS_KEY", "pipeline/secret-files/DATABASE_SECRET", "pipeline/secret-files/EXTERNAL_API_SECRET_KEY", "pipeline/secret-files/PRIVILEGED_PASSWORD", "pipeline/secret-files/SERVICE_TOKEN"}
50+
return fakeSecrets, nil
51+
}
52+
53+
func (c *FakeClient) Region() string {
54+
return "us-west-2"
55+
}
56+
4357
type FakeAgent struct {
4458
t *testing.T
4559
keys []string
@@ -91,6 +105,12 @@ func TestRun(t *testing.T) {
91105

92106
"bkt/git-credentials": {[]byte("general git key"), nil},
93107
"bkt/pipeline/git-credentials": {[]byte("pipeline git key"), nil},
108+
109+
"bkt/pipeline/secret-files/BUILDKITE_ACCESS_KEY": {[]byte("buildkite access key"), nil},
110+
"bkt/pipeline/secret-files/DATABASE_SECRET": {[]byte("database secret"), nil},
111+
"bkt/pipeline/secret-files/EXTERNAL_API_SECRET_KEY": {[]byte("external api secret"), nil},
112+
"bkt/pipeline/secret-files/PRIVILEGED_PASSWORD": {[]byte("privileged password"), nil},
113+
"bkt/pipeline/secret-files/SERVICE_TOKEN": {[]byte("service token"), nil},
94114
}
95115
logbuf := &bytes.Buffer{}
96116
fakeAgent := &FakeAgent{t: t}
@@ -100,7 +120,7 @@ func TestRun(t *testing.T) {
100120
Repo: "[email protected]:buildkite/bash-example.git",
101121
Bucket: "bkt",
102122
Prefix: "pipeline",
103-
Client: &FakeClient{t: t, data: fakeData},
123+
Client: &FakeClient{t: t, data: fakeData, bucket: "bkt"},
104124
Logger: log.New(logbuf, "", log.LstdFlags),
105125
SSHAgent: fakeAgent,
106126
EnvSink: envSink,
@@ -115,8 +135,8 @@ func TestRun(t *testing.T) {
115135

116136
// verify env
117137
gitCredentialHelpers := strings.Join([]string{
118-
`'credential.helper=/path/to/git-credential-s3-secrets bkt git-credentials'`,
119-
`'credential.helper=/path/to/git-credential-s3-secrets bkt pipeline/git-credentials'`,
138+
`'credential.helper=/path/to/git-credential-s3-secrets bkt us-west-2 git-credentials'`,
139+
`'credential.helper=/path/to/git-credential-s3-secrets bkt us-west-2 pipeline/git-credentials'`,
120140
}, " ")
121141
expected := strings.Join([]string{
122142
// because an SSH key was found, ssh-agent was started:
@@ -130,7 +150,13 @@ func TestRun(t *testing.T) {
130150
// because git-credentials were found:
131151
// (wrap in double quotes so that bash eval doesn't consume the inner single quote.
132152
`GIT_CONFIG_PARAMETERS="` + gitCredentialHelpers + `"`,
153+
"BUILDKITE_ACCESS_KEY='buildkite access key'",
154+
"DATABASE_SECRET='database secret'",
155+
"EXTERNAL_API_SECRET_KEY='external api secret'",
156+
"PRIVILEGED_PASSWORD='privileged password'",
157+
"SERVICE_TOKEN='service token'",
133158
}, "\n") + "\n"
159+
134160
if actual := envSink.String(); expected != actual {
135161
t.Errorf("unexpected env written:\n-%q\n+%q", expected, actual)
136162
}

0 commit comments

Comments
 (0)