Skip to content

Commit a160096

Browse files
authored
feat: add support for ECR credentials suffixed by account ID (#208)
* feat: add support for ECR credentials suffixed by account and region Introduced an opt-in mechanism to use AWS credentials stored with account ID and region suffixes for ECR registries. This enhances flexibility, allowing users to specify credentials for multiple accounts and regions directly via environment variables. Updated documentation and added tests to validate the new functionality. * chore: golangci-lint pass * ci: add a [pre-commit-config.yaml] file * feat: simplified the custom AWS credentials provider to only suffix with AccountID * feat: enhance getRoleArn to support account-specific role ARNs Refactored `getRoleArn` to accept an account parameter, enabling retrieval of account-specific role ARNs through suffixed environment variables. Added unit tests to verify expected behavior and prioritize suffixed variables over default ones. This change improves flexibility for multi-account setups. * feat: add custom retryer to AWS SDK configuration Configured the AWS SDK with a custom retryer to handle retries more effectively. This includes setting a maximum of 10 attempts and a maximum backoff delay of 30 seconds for improved resilience. * feat: add debug mode control for diagnostic output Introduce a new environment variable `DOCKER_CREDENTIAL_ENV_DEBUG` to control debug logging. This ensures diagnostic output is only printed if explicitly enabled, improving clarity and reducing unnecessary noise. * docs: added docstrings to unexported funcs in `env.go` * feat: simplify ECR token retrieval by removing unused hostname. Refactored `getEcrToken` to exclude the unnecessary `hostname` parameter and updated relevant logic to focus on `account` and `region`. Improved error handling in `getRoleArn` and updated tests to reflect the changes. Updated README to remove mention of suffixed AWS credentials for ECR. * feat: refactor getRoleArn to simplify error handling. Removed unnecessary error handling in getRoleArn for cleaner code. Updated related logic and tests to reflect the changes, ensuring consistency and maintaining expected behavior. * feat: refactor to replace `accountEnv` with `ecrContext`. This change renames `accountEnv` to `ecrContext` to improve clarity and consistency in naming. It also updates related parameter structures and method calls, streamlining the handling of AWS credentials for ECR. Test cases and error messages
1 parent b1ef599 commit a160096

File tree

7 files changed

+462
-42
lines changed

7 files changed

+462
-42
lines changed

.pre-commit-config.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
repos:
3+
- repo: local
4+
hooks:
5+
- id: golangci-lint
6+
name: golangci-lint
7+
entry: golangci-lint run
8+
language: system
9+
pass_filenames: false
10+
- repo: local
11+
hooks:
12+
- id: sast
13+
name: sast
14+
entry: gosec ./...
15+
language: system
16+
pass_filenames: false
17+
- repo: local
18+
hooks:
19+
- id: go-test
20+
name: go-test
21+
entry: go test -v ./...
22+
language: system
23+
pass_filenames: false

README.md

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
# Docker Credentials from the Environment
23

34
A [Docker credential helper](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers) to streamline repository interactions in scenarios where the cacheing of credentials to `~/.docker/config.json` is undesirable, including CI/CD pipelines, or anywhere ephemeral credentials are used.
@@ -19,10 +20,25 @@ For the docker repository `https://repo.example.com/v1`, the credential helper e
1920
If no environment variables for the target repository's FQDN is found, then:
2021

2122
1. The helper will remove DNS labels from the FQDN one-at-a-time from the right, and look again, for example:
22-
`DOCKER_repo_example_com_USR` => `DOCKER_example_com_USR` => `DOCKER_com_USR` => `DOCKER__USR`.
23-
2. If the target repository is a private AWS ECR repository (FQDN matches the regex `^[0-9]+\.dkr\.ecr\.[-a-z0-9]+\.amazonaws\.com$`), it will attempt to exchange local AWS credentials (most likely exposed through `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables) for short-lived ECR login credentials, including automatic sts:AssumeRole if `role_arn` is specified (e.g. via `AWS_ROLE_ARN`).
23+
`DOCKER_repo_example_com_USR` => `DOCKER_example_com_USR` => `DOCKER_com_USR` => `DOCKER__USR`.
24+
2. If the target repository is a private AWS ECR repository (FQDN matches the regex `^[0-9]+\.dkr\.ecr\.[-a-z0-9]+\.amazonaws\.com$`):
25+
* By default, it will attempt to exchange local AWS credentials (most likely exposed through `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables) for short-lived ECR login credentials, including automatic sts:AssumeRole if `role_arn` is specified (e.g. via `AWS_ROLE_ARN`).
26+
* **Account Suffixed Credentials**: The helper can also use AWS credentials from environment variables suffixed with a specific AWS Account ID. These credentials are expected to be in the format:
27+
* `AWS_ACCESS_KEY_ID_<account_id>`
28+
* `AWS_SECRET_ACCESS_KEY_<account_id>`
29+
* `AWS_SESSION_TOKEN_<account_id>` (optional)
30+
* `AWS_ROLE_ARN_<account_id>` (optional)
31+
32+
Important note: The helper will first look for account-suffixed AWS credentials (e.g. AWS_ACCESS_KEY_ID_123456789012).
33+
If ANY account-suffixed credentials are found, even partially, the helper requires ALL mandatory credentials to be
34+
present with that account suffix. Only if NO account-suffixed credentials exist will the helper fall back to using
35+
standard AWS credentials (AWS_ACCESS_KEY_ID etc).
36+
37+
Hyphens within DNS labels are transformed to underscores (`s/-/_/g`) for credential lookup.
2438

25-
Hyphens within DNS labels are transformed to underscores (`s/-/_/g`) for the purposes of credential lookup.
39+
### Debug Mode
40+
41+
Set the environment variable `DOCKER_CREDENTIAL_ENV_DEBUG=true` to enable diagnostic output. When enabled, the helper will print information about credential sources to stderr, which can help troubleshoot authentication issues, especially with AWS ECR repositories.
2642

2743
## Configuration
2844

@@ -41,7 +57,8 @@ The `docker-credential-env` binary must be installed to `$PATH`, and is enabled
4157
```json
4258
{
4359
"credHelpers": {
44-
"artifactory.example.com": "env"
60+
"artifactory.example.com": "env",
61+
"123456789012.dkr.ecr.us-east-1.amazonaws.com": "env"
4562
}
4663
}
4764
```
@@ -72,28 +89,44 @@ stages {
7289
}
7390
}
7491
75-
stage('Push Image to AWS-ECR') {
92+
stage('Push Image to AWS-ECR (Standard Credentials)') {
7693
environment {
7794
// any standard AWS authentication mechanisms are supported
78-
AWS_ROLE_ARN = 'arn:aws:iam::123456789:role/jenkins-user' // triggers automatic sts:AssumeRole
79-
// AWS_CONFIG_FILE = file('AWS_CONFIG')
80-
// AWS_PROFILE = 'jenkins'
81-
AWS_ACCESS_KEY_ID = credentials('AWS_ACCESS_KEY_ID') // String credential
82-
AWS_SECRET_ACCESS_KEY = credentials('AWS_SECRET_ACCESS_KEY') // String credential
95+
AWS_ROLE_ARN = 'arn:aws:iam::123456789:role/jenkins-user' // triggers automatic sts:AssumeRole
96+
// AWS_CONFIG_FILE = file('AWS_CONFIG')
97+
// AWS_PROFILE = 'jenkins'
98+
AWS_ACCESS_KEY_ID = credentials('AWS_ACCESS_KEY_ID') // String credential
99+
AWS_SECRET_ACCESS_KEY = credentials('AWS_SECRET_ACCESS_KEY') // String credential
100+
DOCKER_CREDENTIAL_ENV_DEBUG = 'true' // Enable debug output for credential helper
83101
}
84102
steps {
85103
sh 'docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/example/example-image:1.0'
86104
}
87105
}
88106
89-
stage('Push Image to GHCR') {
107+
stage('Push Image to AWS-ECR (Account Suffixed Credentials)') {
108+
environment {
109+
// Make sure to include all required suffixed credentials
110+
AWS_ROLE_ARN_987654321098 = credentials('AWS_ROLE_ARN') // String credential
111+
AWS_ACCESS_KEY_ID_987654321098 = credentials('AWS_ACCESS_KEY_ID') // String credential
112+
AWS_SECRET_ACCESS_KEY_987654321098 = credentials('AWS_SECRET_ACCESS_KEY') // String credential
113+
// AWS_SESSION_TOKEN_987654321098 = credentials('AWS_SESSION_TOKEN') // Optional
114+
DOCKER_CREDENTIAL_ENV_DEBUG = 'true' // Enable debug output for credential helper
115+
}
116+
steps {
117+
sh '''
118+
docker push 987654321098.dkr.ecr.eu-west-1.amazonaws.com/another-example/another-image:2.0
119+
'''
120+
}
121+
}
122+
123+
stage('Push Image to GHCR') {
90124
environment {
91125
GITHUB_TOKEN = credentials('github') // String credential
92126
}
93127
steps {
94128
sh 'docker push ghcr.io/example/example-image:1.0'
95129
}
96130
}
97-
98131
}
99132
```

env.go

Lines changed: 114 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,24 @@ import (
88
"net/url"
99
"os"
1010
"regexp"
11+
"strconv"
1112
"strings"
13+
"time"
1214

1315
"github.com/aws/aws-sdk-go-v2/aws"
16+
"github.com/aws/aws-sdk-go-v2/aws/retry"
1417
"github.com/aws/aws-sdk-go-v2/config"
1518
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
1619
"github.com/aws/aws-sdk-go-v2/service/ecr"
1720
"github.com/aws/aws-sdk-go-v2/service/sts"
18-
docker_credentials "github.com/docker/docker-credential-helpers/credentials"
21+
22+
credhelpers "github.com/docker/docker-credential-helpers/credentials"
1923
)
2024

21-
var ecrHostname = regexp.MustCompile(`^[0-9]+\.dkr\.ecr\.[-a-z0-9]+\.amazonaws\.com$`)
22-
var ghcrHostname = regexp.MustCompile(`^ghcr\.io$`)
25+
var (
26+
ecrHostname = regexp.MustCompile(`^(?P<account>[0-9]+)\.dkr\.ecr\.(?P<region>[-a-z0-9]+)\.amazonaws\.com$`)
27+
ghcrHostname = regexp.MustCompile(`^ghcr\.io$`)
28+
)
2329

2430
const (
2531
defaultScheme = "https://"
@@ -28,8 +34,17 @@ const (
2834
envPasswordSuffix = "PSW"
2935
envSeparator = "_"
3036
envIgnoreLogin = "IGNORE_DOCKER_LOGIN"
37+
envDebugMode = "DOCKER_CREDENTIAL_ENV_DEBUG"
3138
)
3239

40+
const (
41+
envAwsAccessKeyID = "AWS_ACCESS_KEY_ID"
42+
envAwsSecretAccessKey = "AWS_SECRET_ACCESS_KEY" // #nosec G101
43+
envAwsSessionToken = "AWS_SESSION_TOKEN" // #nosec G101
44+
envAwsRoleArn = "AWS_ROLE_ARN"
45+
)
46+
47+
// NotSupportedError represents an error indicating that the operation is not supported.
3348
type NotSupportedError struct{}
3449

3550
func (m *NotSupportedError) Error() string {
@@ -39,8 +54,8 @@ func (m *NotSupportedError) Error() string {
3954
// Env implements the Docker credentials Helper interface.
4055
type Env struct{}
4156

42-
// Add implements the set verb
43-
func (*Env) Add(*docker_credentials.Credentials) error {
57+
// Add implements the set verb.
58+
func (*Env) Add(*credhelpers.Credentials) error {
4459
switch {
4560
case os.Getenv(envIgnoreLogin) != "":
4661
return nil
@@ -49,7 +64,7 @@ func (*Env) Add(*docker_credentials.Credentials) error {
4964
}
5065
}
5166

52-
// Delete implements the erase verb
67+
// Delete implements the erase verb.
5368
func (*Env) Delete(string) error {
5469
switch {
5570
case os.Getenv(envIgnoreLogin) != "":
@@ -59,12 +74,12 @@ func (*Env) Delete(string) error {
5974
}
6075
}
6176

62-
// List implements the list verb
77+
// List implements the list verb.
6378
func (*Env) List() (map[string]string, error) {
6479
return nil, fmt.Errorf("list: %w", &NotSupportedError{})
6580
}
6681

67-
// Get implements the get verb
82+
// Get implements the get verb.
6883
func (e *Env) Get(serverURL string) (username string, password string, err error) {
6984
var (
7085
hostname string
@@ -80,16 +95,20 @@ func (e *Env) Get(serverURL string) (username string, password string, err error
8095
return
8196
}
8297

83-
if ecrHostname.MatchString(hostname) {
84-
// This is an AWS ECR Docker Registry: <account-id>.dkr.ecr.<region>.amazonaws.com
85-
username, password, err = getEcrToken()
98+
submatches := ecrHostname.FindStringSubmatch(hostname)
99+
if submatches != nil {
100+
envProvider := &ecrContext{
101+
AccountID: submatches[ecrHostname.SubexpIndex("account")],
102+
Region: submatches[ecrHostname.SubexpIndex("region")],
103+
}
104+
username, password, err = getEcrToken(envProvider)
86105
return
87106
}
88107

89108
if ghcrHostname.MatchString(hostname) {
90109
// This is a GitHub Container Registry: ghcr.io
91110
if token, found := os.LookupEnv("GITHUB_TOKEN"); found {
92-
username = "github"
111+
username = "x-access-token"
93112
password = token
94113
}
95114
return
@@ -98,6 +117,7 @@ func (e *Env) Get(serverURL string) (username string, password string, err error
98117
return
99118
}
100119

120+
// getHostname extracts the hostname from the given server URL, adding a default scheme if missing, and returns it.
101121
func getHostname(serverURL string) (hostname string, err error) {
102122
var server *url.URL
103123
server, err = url.Parse(defaultScheme + strings.TrimPrefix(serverURL, defaultScheme))
@@ -110,12 +130,10 @@ func getHostname(serverURL string) (hostname string, err error) {
110130
return
111131
}
112132

133+
// getEnvVariables constructs environment variable names for username and password based on provided labels and offset.
134+
// Returns the constructed environment variable names for the username and password.
113135
func getEnvVariables(labels []string, offset int) (envUsername, envPassword string) {
114-
if offset < 0 {
115-
offset = 0
116-
} else if offset > len(labels) {
117-
offset = len(labels)
118-
}
136+
offset = max(0, min(offset, len(labels)))
119137

120138
envHostname := strings.Join(labels[offset:], envSeparator)
121139
envUsername = strings.Join([]string{envPrefix, envHostname, envUsernameSuffix}, envSeparator)
@@ -124,6 +142,9 @@ func getEnvVariables(labels []string, offset int) (envUsername, envPassword stri
124142
return
125143
}
126144

145+
// getEnvCredentials retrieves credentials from environment variables based on the provided hostname.
146+
// It parses the hostname, constructs environment variable names, and checks for corresponding values.
147+
// Returns the username, password, and a boolean indicating if credentials were found.
127148
func getEnvCredentials(hostname string) (username, password string, found bool) {
128149
hostname = strings.ReplaceAll(hostname, "-", "_")
129150
labels := strings.Split(hostname, ".")
@@ -140,14 +161,48 @@ func getEnvCredentials(hostname string) (username, password string, found bool)
140161
return
141162
}
142163

143-
func getEcrToken() (username, password string, err error) {
144-
ctx := context.TODO()
145-
cfg, err := config.LoadDefaultConfig(ctx)
164+
// getEcrToken retrieves ECR authentication credentials (username and password) for the specified AWS account and hostname.
165+
// It uses AWS SDK configuration with a custom retry mechanism (10 attempts max, 5 second max backoff)
166+
// and a custom credentials provider that checks for account-specific environment variables.
167+
// The ECR authorization token is retrieved with a 30 second timeout, decoded from base64,
168+
// and split into username:password format. Debug mode will log token expiration time.
169+
//
170+
// Parameters:
171+
//
172+
// account: The AWS account ID
173+
// region: The AWS region for the ECR repository
174+
//
175+
// Returns:
176+
//
177+
// username: The decoded username (typically "AWS")
178+
// password: The decoded password token
179+
// err: Any error encountered during the process
180+
func getEcrToken(provider *ecrContext) (username, password string, err error) {
181+
if provider == nil {
182+
return "", "", fmt.Errorf("ecr: provider must not be nil")
183+
}
184+
185+
// Set up the AWS SDK config with a custom retryer
186+
simpleRetryer := func() aws.Retryer {
187+
standardRetryer := retry.NewStandard(func(options *retry.StandardOptions) {
188+
options.MaxAttempts = 10
189+
options.MaxBackoff = time.Second * 5
190+
})
191+
return retry.AddWithMaxBackoffDelay(standardRetryer, time.Second)
192+
}
193+
194+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
195+
defer cancel()
196+
cfg, err := config.LoadDefaultConfig(ctx,
197+
config.WithRetryer(simpleRetryer),
198+
config.WithRegion(provider.Region),
199+
config.WithCredentialsProvider(aws.NewCredentialsCache(provider)))
146200
if err != nil {
147201
return
148202
}
149203

150-
if roleArn := getRoleArn(cfg.ConfigSources...); roleArn != "" {
204+
var roleArn string
205+
if roleArn = getRoleArn(provider.AccountID, cfg.ConfigSources...); roleArn != "" {
151206
stsSvc := sts.NewFromConfig(cfg)
152207
creds := stscreds.NewAssumeRoleProvider(stsSvc, roleArn)
153208
cfg.Credentials = aws.NewCredentialsCache(creds)
@@ -160,20 +215,55 @@ func getEcrToken() (username, password string, err error) {
160215
return
161216
}
162217
for _, authData := range output.AuthorizationData {
163-
// authData.AuthorizationToken is a base64-encoded username:password string,
164-
// where the username is always expected to be "AWS".
218+
if b, err := strconv.ParseBool(os.Getenv(envDebugMode)); err == nil && b {
219+
if authData.ExpiresAt != nil {
220+
expiration := authData.ExpiresAt.UTC().Format(time.RFC3339)
221+
_, _ = fmt.Fprintf(os.Stderr, "ECR token for %q will expire at %s (UTC)\n", provider.AccountID, expiration)
222+
}
223+
}
224+
225+
if authData.AuthorizationToken == nil {
226+
err = fmt.Errorf("ecr: authorization token for %q is nil", provider.AccountID)
227+
return
228+
}
229+
165230
var tokenBytes []byte
166231
tokenBytes, err = base64.StdEncoding.DecodeString(*authData.AuthorizationToken)
167232
if err != nil {
168233
return
169234
}
170235
token := bytes.SplitN(tokenBytes, []byte{':'}, 2)
236+
if len(token) != 2 {
237+
err = fmt.Errorf("ecr: invalid authorization token format for %q", provider.AccountID)
238+
return
239+
}
240+
171241
username, password = string(token[0]), string(token[1])
172242
}
173243
return
174244
}
175245

176-
func getRoleArn(configSources ...interface{}) (roleARN string) {
246+
// getRoleArn retrieves the AWS role ARN for a specific account by checking environment variables and AWS configurations.
247+
// It checks the account-specific role ARN environment variable (AWS_ROLE_ARN_<account>). If not found,
248+
// then checks the standard AWS role ARN environment variable (AWS_ROLE_ARN) when no config sources are provided.
249+
// Finally, checks config sources which may contain role ARNs in AWS environment config or shared config.
250+
// Returns role ARN string if found, empty string otherwise.
251+
func getRoleArn(account string, configSources ...any) (roleARN string) {
252+
val, found := os.LookupEnv(envAwsRoleArn + "_" + account)
253+
if found {
254+
return strings.TrimSpace(val)
255+
}
256+
257+
// Check if any account-specific AWS credentials exist
258+
_, hasSuffixedEnv := os.LookupEnv(envAwsAccessKeyID + "_" + account)
259+
if hasSuffixedEnv {
260+
return ""
261+
}
262+
263+
if len(configSources) == 0 {
264+
return os.Getenv(envAwsRoleArn)
265+
}
266+
177267
for _, x := range configSources {
178268
switch impl := x.(type) {
179269
case config.EnvConfig:

0 commit comments

Comments
 (0)