Repository: sa7mon/S3Scanner (language: python)

Issue:
bug: only anonymous permissions checked properly
**Bug**

The default AWS credentials file `~/.aws/credentials` is not being read, thus only checks for anonymous permissions are being made. `AuthUsers` in the output will likely always be `[]` - meaning "no permissions". This can be a false negative.

**Reproduction**

- Create a bucket in AWS S3 with no READ permissions (except implicit permission to the owner)
- Configure credentials with `aws configure`
- Run `s3scanner -bucket your-bucket-here`
- Observe the output `INFO exists    | your-bucket-here | us-east-1 | AuthUsers: [] | AllUsers: []`

**Expected output**

`INFO exists    | s3scanner-private | us-east-1 | AuthUsers: [READ, READACP] | AllUsers: []`

Thank you to Twitter user `@thaivd98` for reporting this.
bug: only anonymous permissions checked properly
**Bug**

The default AWS credentials file `~/.aws/credentials` is not being read, thus only checks for anonymous permissions are being made. `AuthUsers` in the output will likely always be `[]` - meaning "no permissions". This can be a false negative.

**Reproduction**

- Create a bucket in AWS S3 with no READ permissions (except implicit permission to the owner)
- Configure credentials with `aws configure`
- Run `s3scanner -bucket your-bucket-here`
- Observe the output `INFO exists    | your-bucket-here | us-east-1 | AuthUsers: [] | AllUsers: []`

**Expected output**

`INFO exists    | s3scanner-private | us-east-1 | AuthUsers: [READ, READACP] | AllUsers: []`

Thank you to Twitter user `@thaivd98` for reporting this.


Discussion / hints:
thank bro :D hope it will be fixed soon 🤪 
**Notes**

https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/auth/#identityresolver
https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/aws#AnonymousCredentials
https://github.com/aws/aws-sdk-go-v2/blob/main/aws/credentials.go#L119
https://stackoverflow.com/questions/72452261/changing-the-default-authentication-methods-in-the-aws-go-sdk-v2
https://github.com/aws/aws-sdk-go-v2/blob/aa796dc315dececa534dce6a2df7cd303307a0aa/config/env_config.go#L20
https://sourcegraph.com/github.com/aws/aws-sdk-go-v2@main/-/blob/internal/shareddefaults/shared_config.go?L16:6-16:31#tab=references
https://github.dev/aws/aws-sdk-go-v2/blob/aa796dc315dececa534dce6a2df7cd303307a0aa/internal/shareddefaults/shared_config.go#L14
https://github.com/aws/aws-sdk-go-v2/blob/main/config/shared_config.go

https://news.ycombinator.com/item?id=40531301
https://news.ycombinator.com/item?id=40533318


thank bro :D hope it will be fixed soon 🤪 
**Notes**

https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/auth/#identityresolver
https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/aws#AnonymousCredentials
https://github.com/aws/aws-sdk-go-v2/blob/main/aws/credentials.go#L119
https://stackoverflow.com/questions/72452261/changing-the-default-authentication-methods-in-the-aws-go-sdk-v2
https://github.com/aws/aws-sdk-go-v2/blob/aa796dc315dececa534dce6a2df7cd303307a0aa/config/env_config.go#L20
https://sourcegraph.com/github.com/aws/aws-sdk-go-v2@main/-/blob/internal/shareddefaults/shared_config.go?L16:6-16:31#tab=references
https://github.dev/aws/aws-sdk-go-v2/blob/aa796dc315dececa534dce6a2df7cd303307a0aa/internal/shareddefaults/shared_config.go#L14
https://github.com/aws/aws-sdk-go-v2/blob/main/config/shared_config.go

https://news.ycombinator.com/item?id=40531301
https://news.ycombinator.com/item?id=40533318



Fix patch:
diff --git a/.dev/docker-compose.yml b/.dev/docker-compose.yml
index d4b019c..3047061 100644
--- a/.dev/docker-compose.yml
+++ b/.dev/docker-compose.yml
@@ -23,6 +23,7 @@ services:
     container_name: app_dev
     volumes:
       - $PWD:/app
+      - ~/.aws:/root/.aws
     entrypoint: ["tail", "-f", "/dev/null"]
     profiles: ["default"]
 
diff --git a/bucket/bucket.go b/bucket/bucket.go
index a529e86..26e07a0 100644
--- a/bucket/bucket.go
+++ b/bucket/bucket.go
@@ -24,15 +24,8 @@ var PermissionAllowed = uint8(1)
 var PermissionDenied = uint8(0)
 var PermissionUnknown = uint8(2)
 
-// var bucketReIP = regexp.MustCompile(`^[0-9]{1-3}\.[0-9]{1-3}\.[0-9]{1-3}\.[0-9]{1-3}$`)
 var bucketRe = regexp.MustCompile(`[^.\-a-z0-9]`)
 
-// Pattern from https://blogs.easydynamics.com/2016/10/24/aws-s3-bucket-name-validation-regex/
-// Missing:
-// No xn-- prefix
-// No -s3alias suffix
-// https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
-
 type Bucket struct {
 	//gorm.Model
 	ID                uint           `gorm:"primarykey" json:",omitempty"`
@@ -192,9 +185,12 @@ func (bucket *Bucket) ParseAclOutputv2(aclOutput *s3.GetBucketAclOutput) error {
 	if aclOutput.Owner.DisplayName != nil {
 		bucket.OwnerDisplayName = *aclOutput.Owner.DisplayName
 	}
+	// Since we can read the permissions, there should be no unknowns. Set all to denied, then read each grant and
+	// set the corresponding permission to allowed.
+	bucket.DenyAll()
 
 	for _, b := range aclOutput.Grants {
-		if b.Grantee == groups.AllUsersv2 {
+		if b.Grantee != nil && b.Grantee.Type == "Group" && *b.Grantee.URI == groups.AllUsersGroup {
 			switch b.Permission {
 			case types.PermissionRead:
 				bucket.PermAllUsersRead = PermissionAllowed
@@ -210,7 +206,7 @@ func (bucket *Bucket) ParseAclOutputv2(aclOutput *s3.GetBucketAclOutput) error {
 				break
 			}
 		}
-		if b.Grantee == groups.AuthenticatedUsersv2 {
+		if b.Grantee != nil && b.Grantee.Type == "Group" && *b.Grantee.URI == groups.AuthUsersGroup {
 			switch b.Permission {
 			case types.PermissionRead:
 				bucket.PermAuthUsersRead = PermissionAllowed
@@ -277,3 +273,16 @@ func IsValidS3BucketName(bucketName string) bool {
 
 	return true
 }
+
+func (b *Bucket) DenyAll() {
+	b.PermAllUsersRead = PermissionDenied
+	b.PermAllUsersWrite = PermissionDenied
+	b.PermAllUsersReadACL = PermissionDenied
+	b.PermAllUsersWriteACL = PermissionDenied
+	b.PermAllUsersFullControl = PermissionDenied
+	b.PermAuthUsersRead = PermissionDenied
+	b.PermAuthUsersWrite = PermissionDenied
+	b.PermAuthUsersReadACL = PermissionDenied
+	b.PermAuthUsersWriteACL = PermissionDenied
+	b.PermAuthUsersFullControl = PermissionDenied
+}
diff --git a/groups/groups.go b/groups/groups.go
index efce294..1606431 100644
--- a/groups/groups.go
+++ b/groups/groups.go
@@ -5,13 +5,16 @@ import (
 	"github.com/aws/aws-sdk-go-v2/service/s3/types"
 )
 
+const AuthUsersGroup = "http://acs.amazonaws.com/groups/global/AuthenticatedUsers"
+const AllUsersGroup = "http://acs.amazonaws.com/groups/global/AllUsers"
+
 var AllUsersv2 = &types.Grantee{
 	Type: types.TypeGroup,
-	URI:  aws.String("http://acs.amazonaws.com/groups/global/AllUsers")}
+	URI:  aws.String(AllUsersGroup)}
 
 var AuthenticatedUsersv2 = &types.Grantee{
 	Type: types.TypeGroup,
-	URI:  aws.String("http://acs.amazonaws.com/groups/global/AllUsers")}
+	URI:  aws.String(AuthUsersGroup)}
 
-const ALL_USERS_URI = "uri=http://acs.amazonaws.com/groups/global/AllUsers"
-const AUTH_USERS_URI = "uri=http://acs.amazonaws.com/groups/global/AuthenticatedUsers"
+const AllUsersUri = "uri=" + AllUsersGroup
+const AuthUsersUri = "uri=" + AuthUsersGroup
diff --git a/permission/permission.go b/permission/permission.go
index 4af319b..a210ca1 100644
--- a/permission/permission.go
+++ b/permission/permission.go
@@ -46,35 +46,35 @@ func CheckPermWriteAcl(svc *s3.Client, b *Bucket) (bool, error) {
 
 	grants := map[string][]string{}
 	if b.PermAuthUsersFullControl == PermissionAllowed {
-		grants["FULL_CONTROL"] = append(grants["FULL_CONTROL"], AUTH_USERS_URI)
+		grants["FULL_CONTROL"] = append(grants["FULL_CONTROL"], AuthUsersUri)
 	}
 	if b.PermAuthUsersWriteACL == PermissionAllowed {
-		grants["WRITE_ACP"] = append(grants["WRITE_ACP"], AUTH_USERS_URI)
+		grants["WRITE_ACP"] = append(grants["WRITE_ACP"], AuthUsersUri)
 	}
 	if b.PermAuthUsersWrite == PermissionAllowed {
-		grants["WRITE"] = append(grants["WRITE"], AUTH_USERS_URI)
+		grants["WRITE"] = append(grants["WRITE"], AuthUsersUri)
 	}
 	if b.PermAuthUsersReadACL == PermissionAllowed {
-		grants["READ_ACP"] = append(grants["READ_ACP"], AUTH_USERS_URI)
+		grants["READ_ACP"] = append(grants["READ_ACP"], AuthUsersUri)
 	}
 	if b.PermAuthUsersRead == PermissionAllowed {
-		grants["READ"] = append(grants["READ"], AUTH_USERS_URI)
+		grants["READ"] = append(grants["READ"], AuthUsersUri)
 	}
 
 	if b.PermAllUsersFullControl == PermissionAllowed {
-		grants["FULL_CONTROL"] = append(grants["FULL_CONTROL"], ALL_USERS_URI)
+		grants["FULL_CONTROL"] = append(grants["FULL_CONTROL"], AllUsersUri)
 	}
 	if b.PermAllUsersWriteACL == PermissionAllowed {
-		grants["WRITE_ACP"] = append(grants["WRITE_ACP"], ALL_USERS_URI)
+		grants["WRITE_ACP"] = append(grants["WRITE_ACP"], AllUsersUri)
 	}
 	if b.PermAllUsersWrite == PermissionAllowed {
-		grants["WRITE"] = append(grants["WRITE"], ALL_USERS_URI)
+		grants["WRITE"] = append(grants["WRITE"], AllUsersUri)
 	}
 	if b.PermAllUsersReadACL == PermissionAllowed {
-		grants["READ_ACP"] = append(grants["READ_ACP"], ALL_USERS_URI)
+		grants["READ_ACP"] = append(grants["READ_ACP"], AllUsersUri)
 	}
 	if b.PermAllUsersRead == PermissionAllowed {
-		grants["READ"] = append(grants["READ"], ALL_USERS_URI)
+		grants["READ"] = append(grants["READ"], AllUsersUri)
 	}
 
 	_, err := svc.PutBucketAcl(context.TODO(), &s3.PutBucketAclInput{
diff --git a/provider/aws.go b/provider/aws.go
index 9d10009..2cd9877 100644
--- a/provider/aws.go
+++ b/provider/aws.go
@@ -3,9 +3,13 @@ package provider
 import (
 	"context"
 	"errors"
+	"fmt"
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
+	"github.com/sa7mon/s3scanner/permission"
 	"net/http"
+	"time"
 
-	"github.com/aws/aws-sdk-go-v2/aws"
 	awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
 	"github.com/aws/aws-sdk-go-v2/config"
 	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
@@ -16,8 +20,9 @@ import (
 )
 
 type providerAWS struct {
-	existsClient *s3.Client
-	clients      *clientmap.ClientMap
+	existsClient   *s3.Client
+	clients        *clientmap.ClientMap
+	hasCredentials bool
 }
 
 func (a *providerAWS) BucketExists(b *bucket.Bucket) (*bucket.Bucket, error) {
@@ -53,16 +58,27 @@ func (a *providerAWS) BucketExists(b *bucket.Bucket) (*bucket.Bucket, error) {
 }
 
 func (a *providerAWS) Scan(b *bucket.Bucket, doDestructiveChecks bool) error {
-	client, err := a.getRegionClient(b.Region)
-	if err != nil {
-		return err
+	anonClient, anonErr := a.getRegionClient(b.Region, false)
+	if anonErr != nil {
+		return anonErr
 	}
 
-	return checkPermissions(client, b, doDestructiveChecks)
+	if a.hasCredentials {
+		authClient, authClientErr := a.getRegionClient(b.Region, true)
+		if authClientErr != nil {
+			return authClientErr
+		}
+		return checkPermissionsWithAuth(anonClient, authClient, b, doDestructiveChecks)
+	}
+	return checkPermissionsWithAuth(anonClient, nil, b, doDestructiveChecks)
 }
 
 func (a *providerAWS) Enumerate(b *bucket.Bucket) error {
-	client, err := a.getRegionClient(b.Region)
+	useCreds := false
+	if b.PermAuthUsersRead == bucket.PermissionAllowed {
+		useCreds = true
+	}
+	client, err := a.getRegionClient(b.Region, useCreds)
 	if err != nil {
 		return err
 	}
@@ -76,19 +92,23 @@ func (a *providerAWS) Enumerate(b *bucket.Bucket) error {
 
 func NewProviderAWS() (*providerAWS, error) {
 	pa := new(providerAWS)
-	client, err := pa.newAnonClientNoRegion()
+	client, err := pa.newAnonClient("us-east-1")
 	if err != nil {
 		return nil, err
 	}
 	pa.existsClient = client
 
 	// Seed the clients map with a common region
-	usEastClient, usErr := pa.newClient("us-east-1")
+	usEastClient, usErr := pa.newClient("us-east-1", nil)
 	if usErr != nil {
 		return nil, usErr
 	}
+
+	// check if the user has properly configured credentials for scanning
+	clientHasCreds := ClientHasCredentials(usEastClient)
+	pa.hasCredentials = clientHasCreds
 	pa.clients = clientmap.New()
-	pa.clients.Set("us-east-1", usEastClient)
+	pa.clients.Set("us-east-1", clientHasCreds, usEastClient)
 	return pa, nil
 }
 
@@ -105,56 +125,141 @@ func (*providerAWS) Name() string {
 	return "aws"
 }
 
-func (*providerAWS) newAnonClientNoRegion() (*s3.Client, error) {
+func (*providerAWS) newAnonClient(region string) (*s3.Client, error) {
 	cfg, err := config.LoadDefaultConfig(
 		context.TODO(),
-		config.WithDefaultRegion("us-west-2"),
-		config.WithCredentialsProvider(aws.AnonymousCredentials{}),
 		config.WithHTTPClient(&http.Client{Transport: &http.Transport{
 			Proxy: http.ProxyFromEnvironment,
 		}}),
+		config.WithRegion(region),
+		config.WithCredentialsProvider(aws.AnonymousCredentials{}),
 	)
 	if err != nil {
 		return nil, err
 	}
 
-	cfg.Credentials = nil
-	s3ClientNoRegion := s3.NewFromConfig(cfg, func(o *s3.Options) {
-		o.UsePathStyle = false
-	})
-
-	return s3ClientNoRegion, nil
+	return s3.NewFromConfig(cfg), nil
 }
 
-func (a *providerAWS) newClient(region string) (*s3.Client, error) {
-	cfg, err := config.LoadDefaultConfig(
-		context.TODO(),
-		config.WithRegion(region),
-		config.WithCredentialsProvider(aws.AnonymousCredentials{}),
+func (a *providerAWS) newClient(region string, profile *string) (*s3.Client, error) {
+	logFields := log.Fields{"profile": profile, "method": "aws.newClient", "region": region}
+
+	configOpts := []func(*config.LoadOptions) error{
 		config.WithHTTPClient(&http.Client{Transport: &http.Transport{
 			Proxy: http.ProxyFromEnvironment,
-		}}))
+		}}),
+		config.WithRegion(region),
+		config.WithEC2IMDSClientEnableState(imds.ClientDisabled), // Otherwise we wait 4 seconds to IMDSv2 to timeout
+	}
+	if profile != nil {
+		configOpts = append(configOpts, config.WithSharedConfigProfile(*profile))
+	}
 
+	cfg, err := config.LoadDefaultConfig(
+		context.TODO(),
+		configOpts...,
+	)
 	if err != nil {
 		return nil, err
 	}
 
-	cfg.Credentials = nil
+	hasCreds, accessKeyID := HasCredentials(cfg)
+	if hasCreds {
+		log.WithFields(logFields).Debugf("using access key ID: %s", accessKeyID)
+	} else {
+		log.WithFields(logFields).Debugf("no credentials found")
+	}
+
 	return s3.NewFromConfig(cfg), nil
 }
 
 // TODO: This method is copied from providerLinode
-func (a *providerAWS) getRegionClient(region string) (*s3.Client, error) {
-	c := a.clients.Get(region)
+func (a *providerAWS) getRegionClient(region string, useCreds bool) (*s3.Client, error) {
+	c := a.clients.Get(region, useCreds)
 	if c != nil {
 		return c, nil
 	}
 
 	// No client for this region yet - create one
-	c, err := a.newClient(region)
+	var newClient *s3.Client
+	var newClientErr error
+	if useCreds {
+		newClient, newClientErr = a.newClient(region, nil)
+	} else {
+		newClient, newClientErr = a.newAnonClient(region)
+	}
+	if newClientErr != nil {
+		return nil, newClientErr
+	}
+	a.clients.Set(region, useCreds, newClient)
+	return newClient, nil
+}
+
+func checkPermissionsWithAuth(anonClient *s3.Client, authClient *s3.Client, b *bucket.Bucket, doDestructiveChecks bool) error {
+	/*
+		// 1. Check if b exists
+		// 2. Check for READ_ACP
+		// 3. If FullControl is allowed for either AllUsers or AuthorizedUsers, skip the remainder of those tests
+		// 4. Check for READ
+		// 5. If doing destructive checks:
+		// 5a. Check for Write
+		// 5b. Check for WriteACP
+	*/
+
+	b.DateScanned = time.Now()
+
+	// Check for anon READ_ACP permission. If allowed, exit
+	anonReadACL, err := permission.CheckPermReadACL(anonClient, b)
 	if err != nil {
-		return nil, err
+		return fmt.Errorf("error occurred while checking for anon ReadACL: %v", err.Error())
+	}
+	b.PermAllUsersReadACL = bucket.Permission(anonReadACL)
+	if b.PermAllUsersReadACL == bucket.PermissionAllowed {
+		return nil
 	}
-	a.clients.Set(region, c)
-	return c, nil
+
+	// Check for auth READ_ACP permission. If allowed, exit
+	if authClient != nil {
+		authReadACL, authAclErr := permission.CheckPermReadACL(authClient, b)
+		if authAclErr != nil {
+			return fmt.Errorf("error occured while checking for auth ReadACL: %v", authAclErr.Error())
+		}
+		b.PermAuthUsersReadACL = bucket.Permission(authReadACL)
+		if b.PermAuthUsersReadACL == bucket.PermissionAllowed {
+			return nil
+		}
+	}
+
+	// Check for anon READ
+	canRead, err := permission.CheckPermRead(anonClient, b)
+	if err != nil {
+		return fmt.Errorf("error occured while checking for anon READ: %v", err.Error())
+	}
+	b.PermAllUsersRead = bucket.Permission(canRead)
+
+	// Check for auth READ
+	if authClient != nil {
+		authCanRead, authReadErr := permission.CheckPermRead(authClient, b)
+		if authReadErr != nil {
+			return fmt.Errorf("error occured while checking for auth READ: %v", authReadErr.Error())
+		}
+		b.PermAuthUsersRead = bucket.Permission(authCanRead)
+	}
+
+	if doDestructiveChecks {
+		// Check for WRITE permission
+		permWrite, writeErr := permission.CheckPermWrite(anonClient, b)
+		if writeErr != nil {
+			return fmt.Errorf("%v | error occured while checking for WRITE: %v", b.Name, writeErr.Error())
+		}
+		b.PermAllUsersWrite = bucket.Permission(permWrite)
+
+		// Check for WRITE_ACP permission
+		permWriteAcl, writeAclErr := permission.CheckPermWriteAcl(anonClient, b)
+		if writeAclErr != nil {
+			return fmt.Errorf("error occured while checking for WriteACL: %v", writeAclErr.Error())
+		}
+		b.PermAllUsersWriteACL = bucket.Permission(permWriteAcl)
+	}
+	return nil
 }
diff --git a/provider/clientmap/clientmap.go b/provider/clientmap/clientmap.go
index dced224..d7afdd5 100644
--- a/provider/clientmap/clientmap.go
+++ b/provider/clientmap/clientmap.go
@@ -5,37 +5,42 @@ import (
 	"sync"
 )
 
+type ClientKey struct {
+	Region      string
+	Credentials bool
+}
+
 type ClientMap struct {
 	sync.Mutex
-	inner map[string]*s3.Client
+	inner map[ClientKey]*s3.Client
 }
 
 func New() *ClientMap {
 	return &ClientMap{
 		Mutex: sync.Mutex{},
-		inner: make(map[string]*s3.Client),
+		inner: make(map[ClientKey]*s3.Client),
 	}
 }
 
 func WithCapacity(cap int) *ClientMap {
 	return &ClientMap{
 		Mutex: sync.Mutex{},
-		inner: make(map[string]*s3.Client, cap),
+		inner: make(map[ClientKey]*s3.Client, cap),
 	}
 }
 
-func (m *ClientMap) Get(key string) *s3.Client {
+func (m *ClientMap) Get(region string, credentials bool) *s3.Client {
 	m.Lock()
 	defer m.Unlock()
-	if v, ok := m.inner[key]; ok {
+	if v, ok := m.inner[ClientKey{Region: region, Credentials: credentials}]; ok {
 		return v
 	}
 	return nil
 }
 
-func (m *ClientMap) Set(key string, value *s3.Client) {
+func (m *ClientMap) Set(region string, credentials bool, value *s3.Client) {
 	m.Lock()
-	m.inner[key] = value
+	m.inner[ClientKey{Region: region, Credentials: credentials}] = value
 	m.Unlock()
 }
 
@@ -45,10 +50,10 @@ func (m *ClientMap) Len() int {
 	return len(m.inner)
 }
 
-func (m *ClientMap) Each(fn func(region string, client *s3.Client)) {
+func (m *ClientMap) Each(fn func(region string, credentials bool, client *s3.Client)) {
 	m.Lock()
-	for region, client := range m.inner {
-		fn(region, client)
+	for key, client := range m.inner {
+		fn(key.Region, key.Credentials, client)
 	}
 	m.Unlock()
 }
diff --git a/provider/credentials.go b/provider/credentials.go
new file mode 100644
index 0000000..abe1468
--- /dev/null
+++ b/provider/credentials.go
@@ -0,0 +1,42 @@
+package provider
+
+import (
+	"context"
+	"errors"
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+	"github.com/aws/smithy-go"
+	log "github.com/sirupsen/logrus"
+)
+
+// TODO: If user explicitly set profile name and we don't find creds, probably blow up
+// returns:
+//   - bool    - if there are credentials configured
+//   - string - AccessKeyID if credentials loaded
+func HasCredentials(cfg aws.Config) (bool, string) {
+	credentials, credsErr := cfg.Credentials.Retrieve(context.TODO())
+	if credsErr != nil {
+		var oe *smithy.OperationError
+		if errors.As(credsErr, &oe) {
+			if !(oe.ServiceID == "ec2imds" && oe.OperationName == "GetMetadata") {
+				log.WithFields(log.Fields{"method": "provider.HasCredentials"}).Error(oe.Error())
+			}
+			return false, ""
+		}
+	}
+	return true, credentials.AccessKeyID
+}
+
+func ClientHasCredentials(client *s3.Client) bool {
+	_, credsErr := client.Options().Credentials.Retrieve(context.TODO())
+	if credsErr != nil {
+		var oe *smithy.OperationError
+		if errors.As(credsErr, &oe) {
+			if !(oe.ServiceID == "ec2imds" && oe.OperationName == "GetMetadata") {
+				log.WithFields(log.Fields{"method": "provider.ClientHasCredentials"}).Error(oe.Error())
+			}
+			return false
+		}
+	}
+	return true
+}
diff --git a/provider/custom.go b/provider/custom.go
index fd1ef22..0f7b5fe 100644
--- a/provider/custom.go
+++ b/provider/custom.go
@@ -68,7 +68,7 @@ func (cp CustomProvider) Enumerate(b *bucket.Bucket) error {
 }
 
 func (cp *CustomProvider) getRegionClient(region string) *s3.Client {
-	return cp.clients.Get(region)
+	return cp.clients.Get(region, false)
 }
 
 /*
@@ -104,7 +104,7 @@ func (cp *CustomProvider) newClients() (*clientmap.ClientMap, error) {
 		if err != nil {
 			return nil, err
 		}
-		clients.Set(r, client)
+		clients.Set(r, false, client)
 	}
 
 	return clients, nil
diff --git a/provider/digitalocean.go b/provider/digitalocean.go
index 7260102..15d6e4f 100644
--- a/provider/digitalocean.go
+++ b/provider/digitalocean.go
@@ -66,14 +66,14 @@ func (pdo *providerDO) newClients() (*clientmap.ClientMap, error) {
 		if err != nil {
 			return nil, err
 		}
-		clients.Set(r, client)
+		clients.Set(r, false, client)
 	}
 
 	return clients, nil
 }
 
 func (pdo *providerDO) getRegionClient(region string) *s3.Client {
-	return pdo.clients.Get(region)
+	return pdo.clients.Get(region, false)
 }
 
 func NewProviderDO() (*providerDO, error) {
diff --git a/provider/dreamhost.go b/provider/dreamhost.go
index 2cea22c..d5d71a4 100644
--- a/provider/dreamhost.go
+++ b/provider/dreamhost.go
@@ -58,7 +58,7 @@ func (p ProviderDreamhost) Scan(bucket *bucket.Bucket, doDestructiveChecks bool)
 }
 
 func (p ProviderDreamhost) getRegionClient(region string) *s3.Client {
-	return p.clients.Get(region)
+	return p.clients.Get(region, false)
 }
 
 func (p ProviderDreamhost) Enumerate(b *bucket.Bucket) error {
@@ -81,7 +81,7 @@ func (p *ProviderDreamhost) newClients() (*clientmap.ClientMap, error) {
 		if err != nil {
 			return nil, err
 		}
-		clients.Set(r, client)
+		clients.Set(r, false, client)
 	}
 
 	return clients, nil
diff --git a/provider/gcp.go b/provider/gcp.go
index 2a89a51..2fb6961 100644
--- a/provider/gcp.go
+++ b/provider/gcp.go
@@ -33,7 +33,7 @@ func (g GCP) BucketExists(b *bucket.Bucket) (*bucket.Bucket, error) {
 		return nil, errors.New("invalid bucket name")
 	}
 	clients := clientmap.New()
-	clients.Set("default", g.client)
+	clients.Set("default", false, g.client)
 	exists, region, err := bucketExists(clients, b)
 	if err != nil {
 		return b, err
diff --git a/provider/linode.go b/provider/linode.go
index 7cb4ad8..0232427 100644
--- a/provider/linode.go
+++ b/provider/linode.go
@@ -25,7 +25,7 @@ func NewProviderLinode() (*providerLinode, error) {
 }
 
 func (pl *providerLinode) getRegionClient(region string) *s3.Client {
-	return pl.clients.Get(region)
+	return pl.clients.Get(region, false)
 }
 
 func (pl *providerLinode) BucketExists(b *bucket.Bucket) (*bucket.Bucket, error) {
@@ -64,7 +64,7 @@ func (pl *providerLinode) newClients() (*clientmap.ClientMap, error) {
 		if err != nil {
 			return nil, err
 		}
-		clients.Set(r, client)
+		clients.Set(r, false, client)
 	}
 
 	return clients, nil
diff --git a/provider/providers.go b/provider/providers.go
index 329fc2c..a2d4cb5 100644
--- a/provider/providers.go
+++ b/provider/providers.go
@@ -212,7 +212,7 @@ func bucketExists(clients *clientmap.ClientMap, b *bucket.Bucket) (bool, string,
 	results := make(chan bucketCheckResult, clients.Len())
 	e := make(chan error, 1)
 
-	clients.Each(func(region string, client *s3.Client) {
+	clients.Each(func(region string, credentials bool, client *s3.Client) {
 		go func(bucketName string, client *s3.Client, region string) {
 			logFields := log.Fields{
 				"bucket_name": b.Name,
diff --git a/provider/scaleway.go b/provider/scaleway.go
index d077f69..4c3c944 100644
--- a/provider/scaleway.go
+++ b/provider/scaleway.go
@@ -29,14 +29,14 @@ func (sc *providerScaleway) newClients() (*clientmap.ClientMap, error) {
 		if err != nil {
 			return nil, err
 		}
-		clients.Set(r, client)
+		clients.Set(r, false, client)
 	}
 
 	return clients, nil
 }
 
 func (sc *providerScaleway) Scan(b *bucket.Bucket, doDestructiveChecks bool) error {
-	client := sc.clients.Get(b.Region)
+	client := sc.clients.Get(b.Region, false)
 	return checkPermissions(client, b, doDestructiveChecks)
 }
 
@@ -73,7 +73,7 @@ func (sc *providerScaleway) Enumerate(b *bucket.Bucket) error {
 		return errors.New("bucket might not exist")
 	}
 
-	client := sc.clients.Get(b.Region)
+	client := sc.clients.Get(b.Region, false)
 	enumErr := enumerateListObjectsV2(client, b)
 	if enumErr != nil {
 		return enumErr


Test patch:
diff --git a/.github/workflows/pr_test-coverage.yml b/.github/workflows/pr_test-coverage.yml
index 8ee7e60..76d4869 100644
--- a/.github/workflows/pr_test-coverage.yml
+++ b/.github/workflows/pr_test-coverage.yml
@@ -37,6 +37,9 @@ jobs:
       - name: Test
         run: TEST_DB=1 TEST_MQ=1 go test ./... -coverprofile=cover.out
         timeout-minutes: 5
+        env:
+          AWS_ACCESS_KEY_ID: ${{ secrets.TESTING_AWS_ACCESS_KEY }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.TESTING_AWS_SECRET_KEY }}
       - name: Install dependencies
         run: go install github.com/axw/gocov/gocov@v1.1.0 && go install github.com/AlekSi/gocov-xml@v1.1.0
       - run: gocov convert cover.out | gocov-xml > coverage.xml
diff --git a/.github/workflows/pr_test-matrix.yml b/.github/workflows/pr_test-matrix.yml
index 3196161..e5302da 100644
--- a/.github/workflows/pr_test-matrix.yml
+++ b/.github/workflows/pr_test-matrix.yml
@@ -37,6 +37,9 @@ jobs:
       - name: Test
         run: TEST_DB=1 TEST_MQ=1 go test ./...
         timeout-minutes: 5
+        env:
+          AWS_ACCESS_KEY_ID: ${{ secrets.TESTING_AWS_ACCESS_KEY }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.TESTING_AWS_SECRET_KEY }}
       - name: Build
         run: go build -v ./...
   windows-matrix:
@@ -53,5 +56,8 @@ jobs:
           go-version: ${{ matrix.go }}
       - name: Test
         run: go test ./...
+        env:
+          AWS_ACCESS_KEY_ID: ${{ secrets.TESTING_AWS_ACCESS_KEY }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.TESTING_AWS_SECRET_KEY }}
       - name: Build
         run: go build -v ./...
\ No newline at end of file
diff --git a/permission/permission_test.go b/permission/permission_test.go
index 06222c7..24ca65e 100644
--- a/permission/permission_test.go
+++ b/permission/permission_test.go
@@ -153,7 +153,7 @@ func TestCheckPermWrite(t *testing.T) {
 func TestCheckPermWriteACL(t *testing.T) {
 	t.Parallel()
 	if !doDestructiveChecks {
-		t.Skip("skipped destructive check TestCheckPermWrite")
+		t.Skip("skipped destructive check TestCheckPermWriteACL")
 	}
 
 	// Bucket with READ permission
diff --git a/provider/aws_test.go b/provider/aws_test.go
index 04542af..61b0896 100644
--- a/provider/aws_test.go
+++ b/provider/aws_test.go
@@ -46,6 +46,7 @@ type bucketPermissionTestCase struct {
 
 func TestProviderAWS_BucketExists(t *testing.T) {
 	t.Parallel()
+	p := providers["aws"]
 
 	testCases := []struct {
 		b           bucket.Bucket
@@ -57,11 +58,6 @@ func TestProviderAWS_BucketExists(t *testing.T) {
 		{bucket.NewBucket("asdfasdf.danthesalmon.com"), bucket.BucketNotExist}, // Bucket with dot that doesn't exist
 	}
 
-	p, perr := NewProviderAWS()
-	if perr != nil {
-		t.Error(perr)
-	}
-
 	for _, testCase := range testCases {
 		e, err := p.BucketExists(&testCase.b)
 		if err != nil {
@@ -81,51 +77,67 @@ func TestProviderAWS_BucketExists(t *testing.T) {
 
 func TestProviderAWS_Scan(t *testing.T) {
 	t.Parallel()
-	var testCases []bucketPermissionTestCase
-	testCases = append(testCases, bucketPermissionTestCase{ // Bucket exists but isn't open
-		b:                           bucket.NewBucket("test"),
-		ExpectedPermAllUsersRead:    bucket.PermissionDenied,
-		ExpectedPermAllUsersReadACL: bucket.PermissionDenied,
-	})
-	testCases = append(testCases, bucketPermissionTestCase{ // Bucket exists and has READ open for auth and all
-		b:                           bucket.NewBucket("s3scanner-bucketsize"),
-		ExpectedPermAllUsersRead:    bucket.PermissionAllowed,
-		ExpectedPermAllUsersReadACL: bucket.PermissionDenied,
-	})
-	testCases = append(testCases, bucketPermissionTestCase{ // Bucket exists and has READ and READ_ACL open for auth and all
-		b:                            bucket.NewBucket("s3scanner-all-read-readacl"),
-		ExpectedPermAllUsersRead:     bucket.PermissionAllowed,
-		ExpectedPermAllUsersReadACL:  bucket.PermissionAllowed,
-		ExpectedPermAuthUsersRead:    bucket.PermissionAllowed,
-		ExpectedPermAuthUsersReadACL: bucket.PermissionAllowed,
-	})
+	p := providers["aws"]
 
-	p, perr := NewProviderAWS()
-	if perr != nil {
-		t.Error(perr)
+	testCases := []bucketPermissionTestCase{
+		{
+			b:                            bucket.NewBucket("test"), // Bucket exists but isn't open
+			ExpectedPermAllUsersRead:     bucket.PermissionDenied,
+			ExpectedPermAllUsersReadACL:  bucket.PermissionDenied,
+			ExpectedPermAuthUsersRead:    bucket.PermissionDenied,
+			ExpectedPermAuthUsersReadACL: bucket.PermissionDenied,
+		},
+		{
+			b:                            bucket.NewBucket("s3scanner-bucketsize"), // Bucket exists and has READ open for auth and all
+			ExpectedPermAllUsersRead:     bucket.PermissionAllowed,
+			ExpectedPermAllUsersReadACL:  bucket.PermissionDenied,
+			ExpectedPermAuthUsersRead:    bucket.PermissionAllowed,
+			ExpectedPermAuthUsersReadACL: bucket.PermissionDenied,
+		},
+		{
+			b:                            bucket.NewBucket("s3scanner-all-read-readacl"), // Bucket exists and has READ and READ_ACL open for auth and all
+			ExpectedPermAllUsersRead:     bucket.PermissionAllowed,
+			ExpectedPermAllUsersReadACL:  bucket.PermissionAllowed,
+			ExpectedPermAuthUsersRead:    bucket.PermissionDenied,
+			ExpectedPermAuthUsersReadACL: bucket.PermissionDenied,
+		},
+		{
+			b:                            bucket.NewBucket("s3scanner-auth-read"), // AuthUsers: [READ] | AllUsers: []
+			ExpectedPermAllUsersRead:     bucket.PermissionDenied,
+			ExpectedPermAllUsersReadACL:  bucket.PermissionDenied,
+			ExpectedPermAuthUsersRead:    bucket.PermissionAllowed,
+			ExpectedPermAuthUsersReadACL: bucket.PermissionDenied,
+		},
+		{
+			b:                            bucket.NewBucket("s3scanner-auth-read-acl"), // AuthUsers: [READ_ACP] | AllUsers: []
+			ExpectedPermAllUsersRead:     bucket.PermissionDenied,
+			ExpectedPermAllUsersReadACL:  bucket.PermissionDenied,
+			ExpectedPermAuthUsersRead:    bucket.PermissionDenied,
+			ExpectedPermAuthUsersReadACL: bucket.PermissionAllowed,
+		},
 	}
 
 	for _, testCase := range testCases {
-		b, err := p.BucketExists(&testCase.b)
-		if err != nil {
-			t.Error(err)
-		}
-		scanErr := p.Scan(b, false)
-		if scanErr != nil {
-			t.Error(scanErr)
-		}
-		assert.Equal(t, testCase.ExpectedPermAllUsersRead, b.PermAllUsersRead)
-		assert.Equal(t, testCase.ExpectedPermAllUsersReadACL, b.PermAllUsersReadACL)
+		t.Run(testCase.b.Name, func(t2 *testing.T) {
+			b, err := p.BucketExists(&testCase.b)
+			if err != nil {
+				t2.Error(err)
+			}
+			scanErr := p.Scan(b, false)
+			if scanErr != nil {
+				t2.Error(scanErr)
+			}
+			assert.Equal(t2, testCase.ExpectedPermAllUsersRead, b.PermAllUsersRead)
+			assert.Equal(t2, testCase.ExpectedPermAllUsersReadACL, b.PermAllUsersReadACL)
+			assert.Equal(t2, testCase.ExpectedPermAuthUsersRead, b.PermAuthUsersRead)
+			assert.Equal(t2, testCase.ExpectedPermAuthUsersReadACL, b.PermAuthUsersReadACL)
+		})
 	}
 }
 
 func TestProviderAWS_Enumerate(t *testing.T) {
 	t.Parallel()
-
-	p, perr := NewProviderAWS()
-	if perr != nil {
-		t.Error(perr)
-	}
+	p := providers["aws"]
 
 	b := bucket.NewBucket("s3scanner-bucketsize")
 	b2, err := p.BucketExists(&b)
diff --git a/provider/providers_test.go b/provider/providers_test.go
index 39597aa..5ae49fd 100644
--- a/provider/providers_test.go
+++ b/provider/providers_test.go
@@ -71,7 +71,7 @@ func TestProvider_EnumerateListObjectsV2_short(t *testing.T) {
 	t.Parallel()
 	p, pErr := NewProviderAWS()
 	failIfError(t, pErr)
-	c, cErr := p.newClient("us-east-1")
+	c, cErr := p.newClient("us-east-1", nil)
 	failIfError(t, cErr)
 
 	// Bucket with "page" of objects (<1k keys)
@@ -91,7 +91,7 @@ func Test_EnumerateListObjectsV2_long(t *testing.T) {
 	t.Parallel()
 	p, pErr := NewProviderAWS()
 	failIfError(t, pErr)
-	c, cErr := p.newClient("us-east-1")
+	c, cErr := p.newClient("us-east-1", nil)
 	failIfError(t, cErr)
 
 	// Bucket with more than 1k objects
@@ -204,7 +204,7 @@ func Test_StorageProvider_Scan(t *testing.T) {
 		bucket      bucket.Bucket
 		permissions string
 	}{
-		{name: "AWS", provider: providers["aws"], bucket: bucket.NewBucket("s3scanner-empty"), permissions: "AuthUsers: [] | AllUsers: [READ]"},
+		{name: "AWS", provider: providers["aws"], bucket: bucket.NewBucket("s3scanner-bucketsize"), permissions: "AuthUsers: [READ] | AllUsers: [READ]"},
 		{name: "Custom public-read-write", provider: providers["custom"], bucket: bucket.NewBucket("nurse-virtual-assistants"), permissions: "AuthUsers: [] | AllUsers: []"},
 		{name: "Custom no public-read", provider: providers["custom"], bucket: bucket.NewBucket("assets"), permissions: "AuthUsers: [] | AllUsers: []"},
 		{name: "DO", provider: providers["digitalocean"], bucket: bucket.NewBucket("logo"), permissions: "AuthUsers: [] | AllUsers: [READ]"},