From e220af21d7196e64ab2b1b948bbec864e7c718c4 Mon Sep 17 00:00:00 2001 From: Reece Bedding Date: Fri, 5 Jun 2026 15:14:50 +0100 Subject: [PATCH 1/4] feat: add full bucket data collection --- .gitignore | 2 + Makefile | 2 +- go.mod | 21 +- go.sum | 36 +-- internal/bucket_apis.go | 217 ++++++++++++++++++ internal/buckets.go | 402 ++++++++++++++++++++++++++++++++++ internal/buckets_test.go | 35 +++ internal/evidence.go | 91 ++++++++ internal/policy_evaluation.go | 124 +++++++++++ internal/util.go | 43 ++++ internal/util_test.go | 20 ++ main.go | 232 +++++++++----------- main_test.go | 19 ++ 13 files changed, 1103 insertions(+), 141 deletions(-) create mode 100644 internal/bucket_apis.go create mode 100644 internal/buckets.go create mode 100644 internal/buckets_test.go create mode 100644 internal/evidence.go create mode 100644 internal/policy_evaluation.go create mode 100644 internal/util_test.go create mode 100644 main_test.go diff --git a/.gitignore b/.gitignore index d6f94c4..1bf0283 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ .DS_Store # TODO: Change this to match the specific plugin name /plugin-* + +.ai/ diff --git a/Makefile b/Makefile index fe7b156..fefdba5 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ clean: # Cleanup build artifacts build: clean ## Build the plugin package @mkdir -p dist/ - @go build -o dist/plugin main.go + @go build -o dist/plugin . run: build ## Execute the Concom agent with the built plugin @../agent/dist/./concom agent --config ./.config/config.yaml \ No newline at end of file diff --git a/go.mod b/go.mod index 65fa2c4..b4f7968 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,12 @@ -module github.com/compliance-framework/plugin-template +module github.com/compliance-framework/plugin-aws-s3 go 1.26.1 require ( + github.com/aws/aws-sdk-go-v2 v1.41.11 + github.com/aws/aws-sdk-go-v2/config v1.32.10 + github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1 + github.com/aws/smithy-go v1.27.0 github.com/compliance-framework/agent v0.7.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 @@ -10,6 +14,21 @@ require ( require ( github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/compliance-framework/api v0.16.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect diff --git a/go.sum b/go.sum index 5a33f2c..76602b0 100644 --- a/go.sum +++ b/go.sum @@ -14,26 +14,34 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= -github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= -github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= +github.com/aws/aws-sdk-go-v2 v1.41.11 h1:9PRf7jyTMEUM6fuNRAJa2mO/skJfrF50rENJwf2LXqw= +github.com/aws/aws-sdk-go-v2 v1.41.11/go.mod h1:iiUX27gOXRuYaoeUVXhUpPwjJHzISfPAjjcuhUbLSVs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 h1:oRtsqWgxbpeXrOlxOoQStx2M9WNbIkPq4C4Xn1or6bc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12/go.mod h1:Zg0Oe9qT+9wcezlm1a64wGJp2qZdRElVxo/seJf7jYU= github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 h1:8sPbKi1/KRHwl5oR3qN9mUXestCeHuaRutxylnr/eVY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27/go.mod h1:QV9IVIopJ1dpQUno0f9VYDUwOEjj8u0iEJ4JiZVre3Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 h1:9d8AoASQY9UwrOSmiJ7uSM0MGUPFhnenwSvpaFfat2c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27/go.mod h1:x0rldpsnUQaQIs4Rh+Vwm9Z/0vI6BxadGtsgJfZFb8s= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28 h1:eaS9vwQ5ym4Y9S6+G/K3d3lgZhxs9Sldcn/YS7cmdKY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28/go.mod h1:oTdbDr+BMs7gAYrNpD0LDTyqQfv6yOYgTDv46+xbwFY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11 h1:rFSsqDfCMPAmG70JOsYqFZCHXkyatoGa1K4YEt/BggQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11/go.mod h1:XG68qW+YLLFH0vnSDCou43Cgj5TeAG83O5NRSJgt04Y= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20 h1:yt2fjgev3Hqm33zPw0ZWtki3sZ0SLcr+PkuvXDAAf/8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20/go.mod h1:wnPjCjPJ6x5GBhrER8f0QakaQ2LokfhCVYxmAZBpPjY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27 h1:2/pUo42hhVmQcM21ttZoBOLHQymyUH8qEnZGTIuGBT8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27/go.mod h1:p7hwgbwompjCRNTdB3ytlldddNt1rDBgVVMqWEVG1II= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27 h1:JEXSW4wztrl1MoL5EMvJMO7lc/TRZloztrJKNl96SW8= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27/go.mod h1:8eL+YgEqy6IYqjwW6PG0Ubn59a2xtCzbz7Pi18JBu04= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1 h1:WkX5IXwcxgO/WPTvhEqoSW2L1GB1OyIxk0vuzzdTftc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1/go.mod h1:9Q9ZHyiTItraw8BXpO48pk398Mou0YCSI+xvFcaGgxU= github.com/aws/aws-sdk-go-v2/service/sesv2 v1.59.0 h1:HQYog9wJM8D9aF0bOVzzWbjpWZ7exyjc3rLb7P8Qb8E= github.com/aws/aws-sdk-go-v2/service/sesv2 v1.59.0/go.mod h1:p0iz0in3/mt3aS2Ovk3aKeOq5vwM/V3prQG9nlBO/OM= github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= @@ -44,8 +52,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= -github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= -github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.27.0 h1:ZoFioDKJxkSIW2otF9T0aPtNlUwhdVCcuZh/rzH9Hus= +github.com/aws/smithy-go v1.27.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= diff --git a/internal/bucket_apis.go b/internal/bucket_apis.go new file mode 100644 index 0000000..eb8d13b --- /dev/null +++ b/internal/bucket_apis.go @@ -0,0 +1,217 @@ +package internal + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +func getBucketTags(ctx context.Context, client *s3.Client, bucketName string) (map[string]string, error) { + output, err := client.GetBucketTagging(ctx, &s3.GetBucketTaggingInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "NoSuchTagSet") { + return map[string]string{}, nil + } + return nil, err + } + + tags := make(map[string]string, len(output.TagSet)) + for _, tag := range output.TagSet { + key := aws.ToString(tag.Key) + if key == "" { + continue + } + tags[key] = aws.ToString(tag.Value) + } + return tags, nil +} + +func getBucketEncryption(ctx context.Context, client *s3.Client, bucketName string) (BucketEncryption, error) { + output, err := client.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "ServerSideEncryptionConfigurationNotFoundError") { + return BucketEncryption{}, nil + } + return BucketEncryption{}, err + } + + rules := make([]BucketEncryptionRule, 0, len(output.ServerSideEncryptionConfiguration.Rules)) + for _, rule := range output.ServerSideEncryptionConfiguration.Rules { + rules = append(rules, BucketEncryptionRule{ + Algorithm: string(rule.ApplyServerSideEncryptionByDefault.SSEAlgorithm), + KmsKeyArn: aws.ToString(rule.ApplyServerSideEncryptionByDefault.KMSMasterKeyID), + BucketKeyEnabled: aws.ToBool(rule.BucketKeyEnabled), + }) + } + return BucketEncryption{Rules: rules}, nil +} + +func getBucketPublicAccessBlock(ctx context.Context, client *s3.Client, bucketName string) (BucketPublicAccessBlock, error) { + output, err := client.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "NoSuchPublicAccessBlockConfiguration") { + return BucketPublicAccessBlock{}, nil + } + return BucketPublicAccessBlock{}, err + } + pab := output.PublicAccessBlockConfiguration + return BucketPublicAccessBlock{ + BlockPublicAcls: aws.ToBool(pab.BlockPublicAcls), + IgnorePublicAcls: aws.ToBool(pab.IgnorePublicAcls), + BlockPublicPolicy: aws.ToBool(pab.BlockPublicPolicy), + RestrictPublicBuckets: aws.ToBool(pab.RestrictPublicBuckets), + }, nil +} + +func getBucketPolicy(ctx context.Context, client *s3.Client, bucketName string) (BucketPolicyDocument, string, error) { + output, err := client.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "NoSuchBucketPolicy") { + return BucketPolicyDocument{}, "", nil + } + return BucketPolicyDocument{}, "", err + } + policy := aws.ToString(output.Policy) + return BucketPolicyDocument{Raw: policy}, SHA256Hex(policy), nil +} + +func getBucketPolicyStatus(ctx context.Context, client *s3.Client, bucketName string) (BucketPolicyStatus, error) { + output, err := client.GetBucketPolicyStatus(ctx, &s3.GetBucketPolicyStatusInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "NoSuchBucketPolicy") { + return BucketPolicyStatus{}, nil + } + return BucketPolicyStatus{}, err + } + return BucketPolicyStatus{IsPublic: aws.ToBool(output.PolicyStatus.IsPublic)}, nil +} + +func getBucketOwnershipControls(ctx context.Context, client *s3.Client, bucketName string) (BucketOwnershipControls, error) { + output, err := client.GetBucketOwnershipControls(ctx, &s3.GetBucketOwnershipControlsInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "OwnershipControlsNotFoundError") { + return BucketOwnershipControls{}, nil + } + return BucketOwnershipControls{}, err + } + if output.OwnershipControls == nil || len(output.OwnershipControls.Rules) == 0 { + return BucketOwnershipControls{}, nil + } + return BucketOwnershipControls{ObjectOwnership: string(output.OwnershipControls.Rules[0].ObjectOwnership)}, nil +} + +func getBucketVersioning(ctx context.Context, client *s3.Client, bucketName string) (BucketVersioning, error) { + output, err := client.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: aws.String(bucketName)}) + if err != nil { + return BucketVersioning{}, err + } + return BucketVersioning{ + Status: string(output.Status), + MFADelete: string(output.MFADelete), + }, nil +} + +func getBucketObjectLock(ctx context.Context, client *s3.Client, bucketName string) (BucketObjectLock, error) { + output, err := client.GetObjectLockConfiguration(ctx, &s3.GetObjectLockConfigurationInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "ObjectLockConfigurationNotFoundError") { + return BucketObjectLock{}, nil + } + return BucketObjectLock{}, err + } + if output.ObjectLockConfiguration == nil { + return BucketObjectLock{}, nil + } + + result := BucketObjectLock{ + Enabled: output.ObjectLockConfiguration.ObjectLockEnabled == s3types.ObjectLockEnabledEnabled, + } + if output.ObjectLockConfiguration.Rule != nil && output.ObjectLockConfiguration.Rule.DefaultRetention != nil { + retention := output.ObjectLockConfiguration.Rule.DefaultRetention + result.DefaultRetentionMode = string(retention.Mode) + result.DefaultRetentionDays = int(aws.ToInt32(retention.Days)) + result.DefaultRetentionYears = int(aws.ToInt32(retention.Years)) + } + return result, nil +} + +func getBucketLifecycle(ctx context.Context, client *s3.Client, bucketName string) (BucketLifecycle, error) { + output, err := client.GetBucketLifecycleConfiguration(ctx, &s3.GetBucketLifecycleConfigurationInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "NoSuchLifecycleConfiguration") { + return BucketLifecycle{}, nil + } + return BucketLifecycle{}, err + } + + rules := make([]BucketLifecycleRule, 0, len(output.Rules)) + for _, rule := range output.Rules { + entry := BucketLifecycleRule{ + ID: aws.ToString(rule.ID), + Status: string(rule.Status), + } + if rule.Expiration != nil { + entry.ExpirationDays = int(aws.ToInt32(rule.Expiration.Days)) + entry.ExpiredObjectDeleteMarker = aws.ToBool(rule.Expiration.ExpiredObjectDeleteMarker) + } + if rule.NoncurrentVersionExpiration != nil { + entry.NoncurrentVersionExpirationDays = int(aws.ToInt32(rule.NoncurrentVersionExpiration.NoncurrentDays)) + } + if rule.AbortIncompleteMultipartUpload != nil { + entry.AbortIncompleteMultipartUploadAfterDays = int(aws.ToInt32(rule.AbortIncompleteMultipartUpload.DaysAfterInitiation)) + } + rules = append(rules, entry) + } + return BucketLifecycle{Rules: rules}, nil +} + +func getBucketReplication(ctx context.Context, client *s3.Client, bucketName string) (BucketReplication, error) { + output, err := client.GetBucketReplication(ctx, &s3.GetBucketReplicationInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "ReplicationConfigurationNotFoundError") { + return BucketReplication{}, nil + } + return BucketReplication{}, err + } + + rules := make([]BucketReplicationRule, 0, len(output.ReplicationConfiguration.Rules)) + for _, rule := range output.ReplicationConfiguration.Rules { + rules = append(rules, BucketReplicationRule{ + ID: aws.ToString(rule.ID), + Status: string(rule.Status), + DestinationBucketArn: aws.ToString(rule.Destination.Bucket), + }) + } + return BucketReplication{Role: aws.ToString(output.ReplicationConfiguration.Role), Rules: rules}, nil +} + +func getBucketLogging(ctx context.Context, client *s3.Client, bucketName string) (BucketLogging, error) { + output, err := client.GetBucketLogging(ctx, &s3.GetBucketLoggingInput{Bucket: aws.String(bucketName)}) + if err != nil { + return BucketLogging{}, err + } + if output.LoggingEnabled == nil { + return BucketLogging{}, nil + } + return BucketLogging{ + TargetBucket: aws.ToString(output.LoggingEnabled.TargetBucket), + TargetPrefix: aws.ToString(output.LoggingEnabled.TargetPrefix), + }, nil +} + +func getBucketWebsite(ctx context.Context, client *s3.Client, bucketName string) (BucketWebsite, error) { + output, err := client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{Bucket: aws.String(bucketName)}) + if err != nil { + if isAPIErrorCode(err, "NoSuchWebsiteConfiguration") { + return BucketWebsite{}, nil + } + return BucketWebsite{}, err + } + website := BucketWebsite{HostingEnabled: true} + if output.IndexDocument != nil { + website.IndexSuffix = aws.ToString(output.IndexDocument.Suffix) + } + return website, nil +} diff --git a/internal/buckets.go b/internal/buckets.go new file mode 100644 index 0000000..ae2405f --- /dev/null +++ b/internal/buckets.go @@ -0,0 +1,402 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" + "github.com/hashicorp/go-hclog" +) + +type BucketResource struct { + Name string `json:"name"` + Arn string `json:"arn"` + Region string `json:"region"` + CreationDate string `json:"creation_date,omitempty"` +} + +type BucketDataset struct { + Bucket BucketResource `json:"bucket"` + Context BucketPolicyContext `json:"bucket_context"` +} + +type BucketPolicyContext struct { + Current BucketCurrent `json:"current"` + BucketHomeRegion string `json:"bucket_home_region"` + Tags map[string]string `json:"tags,omitempty"` + Encryption BucketEncryption `json:"encryption,omitempty"` + PublicAccessBlock BucketPublicAccessBlock `json:"public_access_block,omitempty"` + Policy BucketPolicyDocument `json:"policy,omitempty"` + PolicyHash string `json:"policy_hash,omitempty"` + PolicyStatus BucketPolicyStatus `json:"policy_status,omitempty"` + OwnershipControls BucketOwnershipControls `json:"ownership_controls,omitempty"` + Versioning BucketVersioning `json:"versioning,omitempty"` + ObjectLock BucketObjectLock `json:"object_lock,omitempty"` + Lifecycle BucketLifecycle `json:"lifecycle,omitempty"` + Replication BucketReplication `json:"replication,omitempty"` + Logging BucketLogging `json:"logging,omitempty"` + Website BucketWebsite `json:"website,omitempty"` +} + +type BucketCurrent struct { + BucketName string `json:"bucket_name"` + BucketArn string `json:"bucket_arn"` + Region string `json:"region"` + TagsPresent bool `json:"tags_present"` + IsPublic bool `json:"is_public"` + EncryptionEnabled bool `json:"encryption_enabled"` + UsesKms bool `json:"uses_kms"` + OwnershipEnforced bool `json:"ownership_enforced"` + IsVersioned bool `json:"is_versioned"` + MfaDeleteEnabled bool `json:"mfa_delete_enabled"` + ObjectLockEnabled bool `json:"object_lock_enabled"` + HasLifecycleRules bool `json:"has_lifecycle_rules"` + LifecycleMinExpirationDays int `json:"lifecycle_min_expiration_days"` + HasNoncurrentVersionExpiration bool `json:"has_noncurrent_version_expiration"` + LoggingEnabled bool `json:"logging_enabled"` + ReplicationEnabled bool `json:"replication_enabled"` + WebsiteHostingEnabled bool `json:"website_hosting_enabled"` +} + +type BucketEncryption struct { + Rules []BucketEncryptionRule `json:"rules,omitempty"` +} + +type BucketEncryptionRule struct { + Algorithm string `json:"algorithm,omitempty"` + KmsKeyArn string `json:"kms_key_arn,omitempty"` + BucketKeyEnabled bool `json:"bucket_key_enabled"` +} + +type BucketPublicAccessBlock struct { + BlockPublicAcls bool `json:"block_public_acls"` + IgnorePublicAcls bool `json:"ignore_public_acls"` + BlockPublicPolicy bool `json:"block_public_policy"` + RestrictPublicBuckets bool `json:"restrict_public_buckets"` +} + +type BucketPolicyDocument struct { + Raw string `json:"raw,omitempty"` +} + +type BucketPolicyStatus struct { + IsPublic bool `json:"is_public"` +} + +type BucketOwnershipControls struct { + ObjectOwnership string `json:"object_ownership,omitempty"` +} + +type BucketVersioning struct { + Status string `json:"status,omitempty"` + MFADelete string `json:"mfa_delete,omitempty"` +} + +type BucketObjectLock struct { + Enabled bool `json:"enabled"` + DefaultRetentionMode string `json:"default_retention_mode,omitempty"` + DefaultRetentionDays int `json:"default_retention_days,omitempty"` + DefaultRetentionYears int `json:"default_retention_years,omitempty"` +} + +type BucketLifecycle struct { + Rules []BucketLifecycleRule `json:"rules,omitempty"` +} + +type BucketLifecycleRule struct { + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + ExpirationDays int `json:"expiration_days,omitempty"` + NoncurrentVersionExpirationDays int `json:"noncurrent_version_expiration_days,omitempty"` + AbortIncompleteMultipartUploadAfterDays int `json:"abort_incomplete_multipart_upload_after_days,omitempty"` + ExpiredObjectDeleteMarker bool `json:"expired_object_delete_marker"` +} + +type BucketReplication struct { + Role string `json:"role,omitempty"` + Rules []BucketReplicationRule `json:"rules,omitempty"` +} + +type BucketReplicationRule struct { + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + DestinationBucketArn string `json:"destination_bucket_arn,omitempty"` +} + +type BucketLogging struct { + TargetBucket string `json:"target_bucket,omitempty"` + TargetPrefix string `json:"target_prefix,omitempty"` +} + +type BucketWebsite struct { + HostingEnabled bool `json:"hosting_enabled"` + IndexSuffix string `json:"index_suffix,omitempty"` +} + +func CollectBucketDatasets(ctx context.Context, logger hclog.Logger, cfg aws.Config, allowedRegions []string) ([]BucketDataset, error) { + client := s3.NewFromConfig(cfg) + output, err := client.ListBuckets(ctx, &s3.ListBucketsInput{}) + if err != nil { + return nil, err + } + + clientsByRegion := map[string]*s3.Client{cfg.Region: client} + allowed := make(map[string]bool) + for _, region := range allowedRegions { + allowed[region] = true + } + + datasets := make([]BucketDataset, 0, len(output.Buckets)) + var accumulatedErrors error + for _, bucket := range output.Buckets { + bucketName := aws.ToString(bucket.Name) + if bucketName == "" { + continue + } + + bucketRegion, err := getBucketRegion(ctx, client, bucketName) + if err != nil { + logger.Error("unable to resolve bucket region", "bucket_name", bucketName, "error", err) + accumulatedErrors = errors.Join(accumulatedErrors, err) + continue + } + if len(allowed) > 0 && !allowed[bucketRegion] { + continue + } + + regionClient := clientsByRegion[bucketRegion] + if regionClient == nil { + regionCfg := cfg + regionCfg.Region = bucketRegion + regionClient = s3.NewFromConfig(regionCfg) + clientsByRegion[bucketRegion] = regionClient + } + + dataset, err := collectBucketDataset(ctx, regionClient, bucketName, bucketRegion, aws.ToTime(bucket.CreationDate)) + if err != nil { + logger.Error("unable to collect bucket dataset", "bucket_name", bucketName, "region", bucketRegion, "error", err) + accumulatedErrors = errors.Join(accumulatedErrors, err) + continue + } + datasets = append(datasets, dataset) + } + + return datasets, accumulatedErrors +} + +func BuildBucketPolicyInput(bucket BucketDataset) (map[string]interface{}, error) { + bucketValue, err := toInterfaceMap(bucket.Bucket) + if err != nil { + return nil, err + } + + contextValue, err := toInterfaceMap(bucket.Context) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "bucket": bucketValue, + "bucket_context": contextValue, + }, nil +} + +func BucketDisplayName(bucket BucketDataset) string { + return bucket.Bucket.Name +} + +func collectBucketDataset(ctx context.Context, client *s3.Client, bucketName string, region string, creationDate time.Time) (BucketDataset, error) { + tags, err := getBucketTags(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + encryption, err := getBucketEncryption(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + publicAccessBlock, err := getBucketPublicAccessBlock(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + policy, policyHash, err := getBucketPolicy(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + policyStatus, err := getBucketPolicyStatus(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + ownershipControls, err := getBucketOwnershipControls(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + versioning, err := getBucketVersioning(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + objectLock, err := getBucketObjectLock(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + lifecycle, err := getBucketLifecycle(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + replication, err := getBucketReplication(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + logging, err := getBucketLogging(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + website, err := getBucketWebsite(ctx, client, bucketName) + if err != nil { + return BucketDataset{}, err + } + + bucketArn := fmt.Sprintf("arn:aws:s3:::%s", bucketName) + current := BucketCurrent{ + BucketName: bucketName, + BucketArn: bucketArn, + Region: region, + TagsPresent: len(tags) > 0, + IsPublic: policyStatus.IsPublic, + EncryptionEnabled: len(encryption.Rules) > 0, + UsesKms: bucketUsesKMS(encryption), + OwnershipEnforced: ownershipControls.ObjectOwnership == string(s3types.ObjectOwnershipBucketOwnerEnforced), + IsVersioned: versioning.Status == string(s3types.BucketVersioningStatusEnabled), + MfaDeleteEnabled: versioning.MFADelete == string(s3types.MFADeleteStatusEnabled), + ObjectLockEnabled: objectLock.Enabled, + HasLifecycleRules: len(lifecycle.Rules) > 0, + LifecycleMinExpirationDays: minLifecycleExpirationDays(lifecycle), + HasNoncurrentVersionExpiration: hasNoncurrentVersionExpiration(lifecycle), + LoggingEnabled: strings.TrimSpace(logging.TargetBucket) != "", + ReplicationEnabled: hasEnabledReplication(replication), + WebsiteHostingEnabled: website.HostingEnabled, + } + + return BucketDataset{ + Bucket: BucketResource{ + Name: bucketName, + Arn: bucketArn, + Region: region, + CreationDate: formatTime(creationDate), + }, + Context: BucketPolicyContext{ + Current: current, + BucketHomeRegion: region, + Tags: tags, + Encryption: encryption, + PublicAccessBlock: publicAccessBlock, + Policy: policy, + PolicyHash: policyHash, + PolicyStatus: policyStatus, + OwnershipControls: ownershipControls, + Versioning: versioning, + ObjectLock: objectLock, + Lifecycle: lifecycle, + Replication: replication, + Logging: logging, + Website: website, + }, + }, nil +} + +func toInterfaceMap(value interface{}) (map[string]interface{}, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + decoded := make(map[string]interface{}) + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, err + } + return decoded, nil +} + +func getBucketRegion(ctx context.Context, client *s3.Client, bucketName string) (string, error) { + output, err := client.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: aws.String(bucketName)}) + if err != nil { + return "", err + } + return normalizeBucketRegion(output.LocationConstraint), nil +} + +func normalizeBucketRegion(location s3types.BucketLocationConstraint) string { + region := string(location) + if region == "" { + return "us-east-1" + } + if region == "EU" { + return "eu-west-1" + } + return region +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + +func bucketUsesKMS(encryption BucketEncryption) bool { + for _, rule := range encryption.Rules { + if rule.Algorithm == string(s3types.ServerSideEncryptionAwsKms) { + return true + } + } + return false +} + +func minLifecycleExpirationDays(lifecycle BucketLifecycle) int { + minDays := 0 + for _, rule := range lifecycle.Rules { + if rule.ExpirationDays <= 0 { + continue + } + if minDays == 0 || rule.ExpirationDays < minDays { + minDays = rule.ExpirationDays + } + } + return minDays +} + +func hasNoncurrentVersionExpiration(lifecycle BucketLifecycle) bool { + for _, rule := range lifecycle.Rules { + if rule.NoncurrentVersionExpirationDays > 0 { + return true + } + } + return false +} + +func hasEnabledReplication(replication BucketReplication) bool { + for _, rule := range replication.Rules { + if strings.EqualFold(rule.Status, string(s3types.ReplicationRuleStatusEnabled)) { + return true + } + } + return false +} + +func isAPIErrorCode(err error, codes ...string) bool { + var apiErr smithy.APIError + if !errors.As(err, &apiErr) { + return false + } + for _, code := range codes { + if apiErr.ErrorCode() == code { + return true + } + } + return false +} diff --git a/internal/buckets_test.go b/internal/buckets_test.go new file mode 100644 index 0000000..15ab5f7 --- /dev/null +++ b/internal/buckets_test.go @@ -0,0 +1,35 @@ +package internal + +import "testing" + +func TestBuildBucketPolicyInputIncludesBucketAndContext(t *testing.T) { + input, err := BuildBucketPolicyInput(BucketDataset{ + Bucket: BucketResource{ + Name: "example-bucket", + Arn: "arn:aws:s3:::example-bucket", + Region: "eu-west-2", + }, + Context: BucketPolicyContext{ + Current: BucketCurrent{ + BucketName: "example-bucket", + BucketArn: "arn:aws:s3:::example-bucket", + Region: "eu-west-2", + EncryptionEnabled: true, + IsVersioned: true, + HasLifecycleRules: true, + LoggingEnabled: true, + ReplicationEnabled: true, + }, + Tags: map[string]string{"evidence": "true"}, + }, + }) + if err != nil { + t.Fatalf("BuildBucketPolicyInput() error = %v", err) + } + if _, ok := input["bucket"]; !ok { + t.Fatalf("BuildBucketPolicyInput() missing bucket key: %v", input) + } + if _, ok := input["bucket_context"]; !ok { + t.Fatalf("BuildBucketPolicyInput() missing bucket_context key: %v", input) + } +} diff --git a/internal/evidence.go b/internal/evidence.go new file mode 100644 index 0000000..18eb698 --- /dev/null +++ b/internal/evidence.go @@ -0,0 +1,91 @@ +package internal + +import ( + "fmt" + + "github.com/compliance-framework/agent/runner/proto" +) + +type BucketEvidenceContext struct { + Labels map[string]string + Components []*proto.Component + Inventory []*proto.InventoryItem + Subjects []*proto.Subject +} + +func BuildBucketEvidenceContext(bucket BucketResource) BucketEvidenceContext { + labels := map[string]string{ + "provider": "aws", + "type": "bucket", + "bucket_name": bucket.Name, + "region": bucket.Region, + "resource_arn": bucket.Arn, + } + + components := []*proto.Component{ + { + Identifier: "common-components/amazon-s3", + Type: "service", + Title: "Amazon S3", + Description: "Amazon Simple Storage Service (S3) provides scalable object storage in AWS.", + Purpose: "To provide durable object storage evaluated for storage compliance posture.", + }, + } + + inventoryID := fmt.Sprintf("aws-s3/%s/%s", bucket.Region, bucket.Name) + inventory := []*proto.InventoryItem{ + { + Identifier: inventoryID, + Type: "object-storage", + Title: fmt.Sprintf("Amazon S3 bucket [%s]", bucket.Name), + Description: fmt.Sprintf("Amazon S3 bucket %s in region %s.", bucket.Name, bucket.Region), + Props: []*proto.Property{ + {Name: "bucket-name", Value: bucket.Name}, + {Name: "region", Value: bucket.Region}, + {Name: "resource-arn", Value: bucket.Arn}, + }, + ImplementedComponents: []*proto.InventoryItemImplementedComponent{ + {Identifier: "common-components/amazon-s3"}, + }, + }, + } + + subjects := []*proto.Subject{ + { + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Identifier: "common-components/amazon-s3", + }, + { + Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Identifier: inventoryID, + }, + } + + return BucketEvidenceContext{ + Labels: labels, + Components: components, + Inventory: inventory, + Subjects: subjects, + } +} + +func EvaluateBucketPolicies(deps EvaluationDependencies, policyPaths []string, buckets []BucketDataset) ResourceEvaluationErrors { + return evaluateResources( + deps, + policyPaths, + buckets, + func(bucket BucketDataset) ResourceEvidenceContext { + bucketCtx := BuildBucketEvidenceContext(bucket.Bucket) + return newResourceEvidenceContext(bucketCtx.Labels, bucketCtx.Subjects, bucketCtx.Components, bucketCtx.Inventory) + }, + func(bucket BucketDataset) (interface{}, error) { + return BuildBucketPolicyInput(bucket) + }, + func(bucket BucketDataset, err error) { + deps.Logger.Error("unable to build bucket policy input", "bucket_name", bucket.Bucket.Name, "region", bucket.Bucket.Region, "error", err) + }, + func(evidences []*proto.Evidence, bucket BucketDataset) { + PrefixEvidenceTitles(evidences, BucketDisplayName(bucket)) + }, + ) +} diff --git a/internal/policy_evaluation.go b/internal/policy_evaluation.go new file mode 100644 index 0000000..598abcf --- /dev/null +++ b/internal/policy_evaluation.go @@ -0,0 +1,124 @@ +package internal + +import ( + "context" + "errors" + "strings" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/agent/runner/proto" + "github.com/hashicorp/go-hclog" +) + +type EvaluationDependencies struct { + Context context.Context + Logger hclog.Logger + ApiHelper runner.ApiHelper + Actors []*proto.OriginActor + PolicyData map[string]interface{} +} + +type ResourceEvidenceContext struct { + Labels map[string]string + Components []*proto.Component + Inventory []*proto.InventoryItem + Subjects []*proto.Subject +} + +type ResourceEvaluationErrors struct { + Fatal error + NonFatal error + InputBuildFailure bool +} + +func evaluateResources[T any](deps EvaluationDependencies, policyPaths []string, resources []T, buildContext func(T) ResourceEvidenceContext, buildInput func(T) (interface{}, error), onInputError func(T, error), afterGenerate func([]*proto.Evidence, T)) ResourceEvaluationErrors { + var accumulatedErrors error + inputBuildFailure := false + + for _, resource := range resources { + resourceCtx := buildContext(resource) + input, err := buildInput(resource) + if err != nil { + inputBuildFailure = true + if onInputError != nil { + onInputError(resource, err) + } + accumulatedErrors = errors.Join(accumulatedErrors, err) + continue + } + + evidences, err := generateResourceEvidences(deps.Context, deps.Logger, deps.Actors, policyPaths, input, resourceCtx.Labels, resourceCtx.Subjects, resourceCtx.Components, resourceCtx.Inventory, deps.PolicyData) + if afterGenerate != nil { + afterGenerate(evidences, resource) + } + if err != nil { + accumulatedErrors = errors.Join(accumulatedErrors, err) + } + if len(evidences) == 0 { + continue + } + if err = deps.ApiHelper.CreateEvidence(deps.Context, evidences); err != nil { + deps.Logger.Error("failed to send evidences", "error", err) + return ResourceEvaluationErrors{Fatal: err, NonFatal: accumulatedErrors, InputBuildFailure: inputBuildFailure} + } + } + + return ResourceEvaluationErrors{NonFatal: accumulatedErrors, InputBuildFailure: inputBuildFailure} +} + +func newResourceEvidenceContext(labels map[string]string, subjects []*proto.Subject, components []*proto.Component, inventory []*proto.InventoryItem) ResourceEvidenceContext { + return ResourceEvidenceContext{ + Labels: labels, + Components: components, + Inventory: inventory, + Subjects: subjects, + } +} + +func generateResourceEvidences(ctx context.Context, logger hclog.Logger, actors []*proto.OriginActor, policyPaths []string, input interface{}, labels map[string]string, subjects []*proto.Subject, components []*proto.Component, inventory []*proto.InventoryItem, policyData map[string]interface{}) ([]*proto.Evidence, error) { + activities := make([]*proto.Activity, 0) + evidences := make([]*proto.Evidence, 0) + var accumulatedErrors error + + for _, policyPath := range policyPaths { + processor := policyManager.NewPolicyProcessor( + logger, + labels, + subjects, + components, + inventory, + actors, + activities, + policyData, + ) + evidence, err := processor.GenerateResults(ctx, policyPath, input) + evidences = append(evidences, evidence...) + if err != nil { + accumulatedErrors = errors.Join(accumulatedErrors, err) + } + } + + return evidences, accumulatedErrors +} + +func PrefixEvidenceTitles(evidences []*proto.Evidence, prefix string) { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + return + } + + for _, evidence := range evidences { + if evidence == nil { + continue + } + + title := strings.TrimSpace(evidence.GetTitle()) + if title == "" { + evidence.Title = prefix + continue + } + + evidence.Title = prefix + " | " + title + } +} diff --git a/internal/util.go b/internal/util.go index 87d29f1..94f6641 100644 --- a/internal/util.go +++ b/internal/util.go @@ -1,5 +1,12 @@ package internal +import ( + "crypto/sha256" + "encoding/hex" + "os" + "strings" +) + func StringAddressed(str string) *string { return &str } @@ -13,3 +20,39 @@ func MergeMaps(maps ...map[string]string) map[string]string { } return result } + +func ResolveRegions(config map[string]string) []string { + if regionsStr, ok := config["regions"]; ok && regionsStr != "" { + regionParts := strings.Split(regionsStr, ",") + regions := make([]string, 0, len(regionParts)) + seen := make(map[string]bool) + for _, r := range regionParts { + r = strings.TrimSpace(r) + if r != "" && !seen[r] { + seen[r] = true + regions = append(regions, r) + } + } + if len(regions) > 0 { + return regions + } + } + + if regionStr, ok := config["region"]; ok { + region := strings.TrimSpace(regionStr) + if region != "" { + return []string{region} + } + } + + if regionEnv := strings.TrimSpace(os.Getenv("AWS_REGION")); regionEnv != "" { + return []string{regionEnv} + } + + return []string{"us-east-1"} +} + +func SHA256Hex(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/util_test.go b/internal/util_test.go new file mode 100644 index 0000000..b4e1e9e --- /dev/null +++ b/internal/util_test.go @@ -0,0 +1,20 @@ +package internal + +import "testing" + +func TestResolveRegionsIgnoresWhitespaceOnlyRegionConfig(t *testing.T) { + t.Setenv("AWS_REGION", "eu-west-2") + + regions := ResolveRegions(map[string]string{"region": " \t "}) + if len(regions) != 1 || regions[0] != "eu-west-2" { + t.Fatalf("ResolveRegions() = %v, want [eu-west-2]", regions) + } +} + +func TestSHA256HexIsStable(t *testing.T) { + first := SHA256Hex("bucket-policy") + second := SHA256Hex("bucket-policy") + if first == "" || first != second { + t.Fatalf("SHA256Hex() produced unstable output: %q vs %q", first, second) + } +} diff --git a/main.go b/main.go index 809d491..fe011e5 100644 --- a/main.go +++ b/main.go @@ -2,63 +2,32 @@ package main import ( "context" + "errors" "fmt" + awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/agent/runner/proto" - "github.com/compliance-framework/plugin-template/internal" + "github.com/compliance-framework/plugin-aws-s3/internal" "github.com/hashicorp/go-hclog" goplugin "github.com/hashicorp/go-plugin" ) type CompliancePlugin struct { logger hclog.Logger + rawConfig map[string]string config *internal.PluginConfig policyData map[string]interface{} } -// Configure, Init, and Eval are called at different times during the plugin execution lifecycle, -// and are responsible for different tasks: -// -// Configure is called on plugin startup. It is primarily used to configure a plugin for its lifetime. -// Here you should store any configurations like usernames and password required by the plugin. -// -// Init is called once for each scheduled execution with a list of policy paths and it is responsible -// for initializing any resources or registering any additional information regarding the plugin such as -// riskTemplates and subjectTemplates -// -// Eval is called once for each scheduled execution with a list of policy paths and it is responsible -// for evaluating each of these policy paths against the data it requires to evaluate those policies. -// The plugin is responsible for collecting the data it needs to evaluate the policies in the Eval -// method and then running the policies against that data. -// -// The simplest way to handle multiple policies is to do an initial lookup of all the data that may -// be required for all policies in the method, and then run the policies against that data. This, -// however, may not be the most efficient way to run policies, and you may want to optimize this -// while writing plugins to reduce the amount of data you need to collect and store in memory. It -// is the plugins responsibility to ensure that it is (reasonably) efficient in its use of -// resources. -// -// A user starts the agent, and passes the plugin and any policy bundles. -// -// The agent will: -// - Start the plugin -// - Call Configure() with the required config -// - Call Init() with the required init data -// - Call Eval() with the first policy bundles (one by one, in turn), -// so the plugin can report any violations against the configuration func (l *CompliancePlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { - // Configure is used to set up any configuration needed by this plugin over its lifetime. - // This will likely only be called once on plugin startup, which may then run for an extended period of time. - - // In this method, you should save any configuration values to your plugin struct, so you can later - // re-use them in PrepareForEval and Eval. rawConfig := req.GetConfig() parsedConfig, err := internal.ParseConfig(rawConfig) if err != nil { return nil, err } + l.rawConfig = rawConfig l.config = parsedConfig // Maps policy data for policy data.xx evaluation. This lets the agent set configuration for policy @@ -72,118 +41,131 @@ func (l *CompliancePlugin) Configure(req *proto.ConfigureRequest) (*proto.Config return &proto.ConfigureResponse{}, nil } -// Init prepares plugin metadata for a scheduled execution. -// -// The agent calls Init after Configure and before Eval, passing the policy paths -// that will be evaluated for the current run. Use this method to register any -// subject templates, risk templates, or other execution-scoped metadata the -// agent needs before policy evaluation starts. -// -// When building subject templates, every label you reference in -// TitleTemplate, DescriptionTemplate, PurposeTemplate, or IdentityLabelKeys -// must also be declared in LabelSchema. If a templated key is not present in -// the schema, the agent cannot populate it correctly. -// -// The agent also adds `_plugin` automatically to the subject schema, and it -// should be treated as part of the subject identity. In practice, make sure -// your subject identity accounts for `_plugin` together with the resource- -// specific labels that uniquely identify the subject. -// -// For automation to trigger correctly, the subject Type must be -// proto.SubjectType_SUBJECT_TYPE_COMPONENT. Do not use other subject types here -// as of today, even if they appear semantically closer, because automation is -// currently built around component subjects. -// -// In this template, Init builds and returns the subject templates used by the -// policies so the agent can create consistent subjects for any evidence emitted -// during Eval. func (l *CompliancePlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { - ctx := context.Background() - subjectTemplates := []*proto.SubjectTemplate{ - { - Name: "ec2-instance", - Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, - TitleTemplate: "EC2 Instance {{ .resource_id }} in {{ .account_id }}/{{ .region }}", - DescriptionTemplate: "AWS EC2 Instance {{ .resource_id }}.", - PurposeTemplate: "Represents an AWS EC2 instance evaluated for compliance posture.", - IdentityLabelKeys: []string{"account_id", "region", "resource_id"}, - LabelSchema: []*proto.SubjectLabelSchema{ - {Key: "account_id", Description: "AWS account ID"}, - {Key: "region", Description: "AWS region"}, - {Key: "resource_id", Description: "EC2 Instance ID"}, - {Key: "resource_arn", Description: "AWS resource ARN"}, - {Key: "resource_type", Description: "EC2 normalized resource type"}, - }, - }, - } - return runner.InitWithSubjectsAndRisksFromPolicies(ctx, l.logger, req, apiHelper, subjectTemplates) + return &proto.InitResponse{}, nil } -// Eval collects the data required for the requested policies and evaluates them. -// -// The agent calls Eval once per matching policy bundle during a scheduled -// execution. The request includes the policy paths to run, and the plugin is -// responsible for fetching the relevant source data, evaluating those policies, -// and sending any resulting evidence back through the provided API helper. -// -// In practice, this is the main execution step for the plugin: fetch data, -// evaluate `request.PolicyPaths`, and persist the produced evidence. func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { - // Eval is used to run policies against the data you've collected. - // Eval will be called N times for every scheduled plugin execution where N is the amount of matching policies - // passed to the agent. - - // When a user passes multiple policy bundles to the agent, each will be passed to Eval in turn to run against the - // same data collected in PrepareForEval. - ctx := context.Background() - activities := make([]*proto.Activity, 0) + evalStatus := proto.ExecutionStatus_SUCCESS + var accumulatedErrors error if request == nil { return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("eval request is nil") } - dataFetcher := internal.NewDataFetcher(l.logger, l.config) - data, err := dataFetcher.FetchData() + regions := internal.ResolveRegions(l.rawConfig) + actors := []*proto.OriginActor{ + { + Title: "The Continuous Compliance Framework", + Type: "assessment-platform", + Links: []*proto.Link{ + { + Href: "https://compliance-framework.github.io/docs/", + Rel: internal.StringAddressed("reference"), + Text: internal.StringAddressed("The Continuous Compliance Framework"), + }, + }, + }, + { + Title: "Continuous Compliance Framework - AWS S3 Plugin", + Type: "tool", + Links: []*proto.Link{ + { + Href: "https://github.com/compliance-framework/plugin-aws-s3", + Rel: internal.StringAddressed("reference"), + Text: internal.StringAddressed("The Continuous Compliance Framework AWS S3 Plugin"), + }, + }, + }, + } + + defaultBehaviorMapping := map[string][]string{ + "aws-s3-policies": {"bucket"}, + } + policyEval := request.WithDefaultPolicyBehavior(defaultBehaviorMapping) + policyPathsByBehavior := buildPolicyPathsByBehavior(policyEval) + requiredDatasets := buildRequiredDatasets(policyPathsByBehavior) + + if !requiredDatasets["buckets"] { + return &proto.EvalResponse{Status: evalStatus}, nil + } + + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(regions[0])) if err != nil { return &proto.EvalResponse{ Status: proto.ExecutionStatus_FAILURE, - }, fmt.Errorf("failed to fetch data: %w", err) + }, fmt.Errorf("failed to load AWS SDK config: %w", err) } - policyEvaluator := internal.NewPolicyEvaluator(ctx, l.logger, activities) + buckets, err := internal.CollectBucketDatasets(ctx, l.logger, cfg, regions) + if err != nil { + evalStatus = proto.ExecutionStatus_FAILURE + accumulatedErrors = errors.Join(accumulatedErrors, err) + } - // Simple use case for evaluating all policies against the data collected - evidences, err := policyEvaluator.Eval(ctx, data, request.PolicyPaths, l.policyData, l.config.PolicyLabels) + deps := internal.EvaluationDependencies{ + Context: ctx, + Logger: l.logger, + ApiHelper: apiHelper, + Actors: actors, + PolicyData: l.policyData, + } - // Advanced use case where policy bundles are filtered based on behavior - // - // - // The default mapping maps plugin defined behaviours, can be extended / overwritten via agent config - // defaultBehaviorMapping := map[string][]string{ - // "first-behavior-policies": {"first"}, - // "second-behavior-policies": {"second"}, - // } - // policyEval := request.WithDefaultPolicyBehavior(defaultBehaviorMapping).WithUndefinedMappedTo([]string{"first"}) - // policyPathsByBehavior := policyEval.PolicyPathsForBehavior("first") - // evidences, err = policyEvaluator.Eval(ctx, data, policyPathsByBehavior, l.policyData, l.config.PolicyLabels) + if bucketPolicyPaths := policyPathsByBehavior["bucket"]; len(bucketPolicyPaths) > 0 { + result := internal.EvaluateBucketPolicies(deps, bucketPolicyPaths, buckets) + if fatal := applyResourceEvaluationErrors(result, &evalStatus, &accumulatedErrors, true); fatal != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fatal + } + } - if err != nil { - return &proto.EvalResponse{ - Status: proto.ExecutionStatus_FAILURE, - }, fmt.Errorf("failed to evaluate policies: %w", err) + return &proto.EvalResponse{Status: evalStatus}, accumulatedErrors +} + +func applyResourceEvaluationErrors(result internal.ResourceEvaluationErrors, evalStatus *proto.ExecutionStatus, accumulatedErrors *error, failOnInputBuild bool) error { + if result.NonFatal != nil { + *accumulatedErrors = errors.Join(*accumulatedErrors, result.NonFatal) + } + if failOnInputBuild && result.InputBuildFailure { + *evalStatus = proto.ExecutionStatus_FAILURE } + return result.Fatal +} - if err := apiHelper.CreateEvidence(ctx, evidences); err != nil { - l.logger.Error("Error creating evidence", "error", err) - return nil, err +func supportedPolicyBehaviors() []string { + return []string{"bucket"} +} + +func buildPolicyPathsByBehavior(request *proto.EvalRequest) map[string][]string { + policyPathsByBehavior := make(map[string][]string) + for _, behavior := range supportedPolicyBehaviors() { + policyPaths := request.PolicyPathsForBehavior(behavior) + if len(policyPaths) > 0 { + policyPathsByBehavior[behavior] = policyPaths + } } + return policyPathsByBehavior +} - resp := &proto.EvalResponse{ - Status: proto.ExecutionStatus_SUCCESS, +func buildRequiredDatasets(policyPathsByBehavior map[string][]string) map[string]bool { + requiredDatasets := make(map[string]bool) + for behavior, policyPaths := range policyPathsByBehavior { + if len(policyPaths) == 0 { + continue + } + + switch behavior { + case "bucket": + markRequiredDatasets(requiredDatasets, "buckets") + } } + return requiredDatasets +} - return resp, nil +func markRequiredDatasets(requiredDatasets map[string]bool, datasetNames ...string) { + for _, datasetName := range datasetNames { + requiredDatasets[datasetName] = true + } } func main() { @@ -196,7 +178,7 @@ func main() { logger: logger, } // pluginMap is the map of plugins we can dispense. - logger.Debug("initiating plugin") + logger.Debug("Initiating AWS S3 plugin") goplugin.Serve(&goplugin.ServeConfig{ HandshakeConfig: runner.HandshakeConfig, diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..526a265 --- /dev/null +++ b/main_test.go @@ -0,0 +1,19 @@ +package main + +import "testing" + +func TestSupportedPolicyBehaviors(t *testing.T) { + behaviors := supportedPolicyBehaviors() + if len(behaviors) != 1 || behaviors[0] != "bucket" { + t.Fatalf("supportedPolicyBehaviors() = %v, want [bucket]", behaviors) + } +} + +func TestBuildRequiredDatasetsForBucketPolicies(t *testing.T) { + required := buildRequiredDatasets(map[string][]string{ + "bucket": {"/tmp/bundle.tar.gz"}, + }) + if !required["buckets"] { + t.Fatalf("buildRequiredDatasets() = %v, want buckets dataset to be required", required) + } +} From 679a95d2b650e725df553905426c3954f17953ca Mon Sep 17 00:00:00 2001 From: Reece Bedding Date: Fri, 5 Jun 2026 15:49:11 +0100 Subject: [PATCH 2/4] feat: add subject templates --- main.go | 3 ++- subject_templates.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 subject_templates.go diff --git a/main.go b/main.go index fe011e5..8567b1b 100644 --- a/main.go +++ b/main.go @@ -42,7 +42,8 @@ func (l *CompliancePlugin) Configure(req *proto.ConfigureRequest) (*proto.Config } func (l *CompliancePlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { - return &proto.InitResponse{}, nil + ctx := context.Background() + return runner.InitWithSubjectsAndRisksFromPolicies(ctx, l.logger, req, apiHelper, buildSubjectTemplates()) } func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { diff --git a/subject_templates.go b/subject_templates.go new file mode 100644 index 0000000..b1bbdcf --- /dev/null +++ b/subject_templates.go @@ -0,0 +1,44 @@ +package main + +import "github.com/compliance-framework/agent/runner/proto" + +func buildSubjectTemplates() []*proto.SubjectTemplate { + return []*proto.SubjectTemplate{ + { + Name: "aws-s3-bucket", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: `AWS S3 bucket {{ .bucket_name }} in {{ .region }}`, + DescriptionTemplate: `Amazon S3 bucket {{ .bucket_name }} in AWS region {{ .region }}.`, + PurposeTemplate: "Represents an Amazon S3 bucket evaluated for storage compliance posture.", + IdentityLabelKeys: []string{"provider", "region", "bucket_name"}, + SelectorLabels: selectorLabelsForType("bucket"), + LabelSchema: labelSchema( + label("provider", "Cloud provider for the evaluated resource"), + label("type", "S3 plugin resource type"), + label("bucket_name", "Amazon S3 bucket name"), + label("region", "AWS region containing the bucket"), + label("resource_arn", "AWS resource ARN for the bucket"), + ), + }, + } +} + +func selectorLabelsForType(resourceType string) []*proto.SubjectLabelSelector { + return []*proto.SubjectLabelSelector{ + { + Key: "type", + Value: resourceType, + }, + } +} + +func label(key string, description string) *proto.SubjectLabelSchema { + return &proto.SubjectLabelSchema{ + Key: key, + Description: description, + } +} + +func labelSchema(labels ...*proto.SubjectLabelSchema) []*proto.SubjectLabelSchema { + return labels +} From 30dc55d0a5278ced743e181fa094eaba71caa6a2 Mon Sep 17 00:00:00 2001 From: Reece Bedding Date: Fri, 5 Jun 2026 15:55:54 +0100 Subject: [PATCH 3/4] chore: update README --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b5a1dd2..985cf15 100644 --- a/README.md +++ b/README.md @@ -1 +1,76 @@ -# AWS S3 plugin \ No newline at end of file +# AWS S3 CCF Plugin + +This plugin collects read-only Amazon S3 bucket configuration, evaluates CCF Rego policy bundles, and emits evidence back through the CCF agent. + +## Supported resource families + +The collector can evaluate policies for: + +- S3 buckets + +## How it fits in CCF + +The CCF agent starts this binary through HashiCorp go-plugin, passes configuration and policy paths over gRPC, and receives generated evidence through the runner callback. This repository does not call the CCF API directly. + +During plugin initialisation, subject templates and risk templates declared by the configured policy bundle are registered through the agent API helper. + +## Default policy bundle mapping + +| Repository | Behavior | Primary input | +| --- | --- | --- | +| plugin-aws-s3-policies | bucket | input.bucket + input.bucket_context | + +## Configuration + +The plugin expects: + +- AWS credentials through the default AWS SDK credential chain +- target regions from config.regions or config.region +- AWS_REGION as a fallback when plugin config does not provide a region + +Any agent-supplied policy_data is passed through to Rego as data.*. + +## Data collected + +For each in-scope bucket, the plugin collects and normalises: + +- bucket name, ARN, creation date, and home region +- bucket tags +- server-side encryption settings +- public access block settings +- bucket policy document, policy hash, and public policy status +- ownership controls +- versioning and MFA delete status +- object lock configuration +- lifecycle rules and retention summary +- replication configuration +- server access logging configuration +- static website hosting configuration + +The policy input contains both the raw bucket resource under input.bucket and the evaluated context under input.bucket_context. + +## Development + +Run the local test suite with: + +~~~shell +go test ./... +~~~ + +Or use the Makefile wrapper: + +~~~shell +make test +~~~ + +Build the plugin binary with: + +~~~shell +make build +~~~ + +This writes the compiled plugin to dist/plugin. + +## Related repositories + +- [plugin-aws-s3-policies](https://github.com/compliance-framework/plugin-aws-s3-policies) From 94c28d54d16067e4fcd2a208e4cc3c9fd77bb7c5 Mon Sep 17 00:00:00 2001 From: Reece Bedding Date: Fri, 5 Jun 2026 17:19:23 +0100 Subject: [PATCH 4/4] fix: ai review issues --- internal/bucket_apis.go | 48 ++++++++++++++++++-- internal/bucket_apis_test.go | 87 ++++++++++++++++++++++++++++++++++++ main.go | 11 ++++- main_test.go | 20 ++++++++- 4 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 internal/bucket_apis_test.go diff --git a/internal/bucket_apis.go b/internal/bucket_apis.go index eb8d13b..eb56759 100644 --- a/internal/bucket_apis.go +++ b/internal/bucket_apis.go @@ -37,15 +37,26 @@ func getBucketEncryption(ctx context.Context, client *s3.Client, bucketName stri return BucketEncryption{}, err } + return bucketEncryptionFromOutput(output), nil +} + +func bucketEncryptionFromOutput(output *s3.GetBucketEncryptionOutput) BucketEncryption { + if output == nil || output.ServerSideEncryptionConfiguration == nil { + return BucketEncryption{} + } + rules := make([]BucketEncryptionRule, 0, len(output.ServerSideEncryptionConfiguration.Rules)) for _, rule := range output.ServerSideEncryptionConfiguration.Rules { + if rule.ApplyServerSideEncryptionByDefault == nil { + continue + } rules = append(rules, BucketEncryptionRule{ Algorithm: string(rule.ApplyServerSideEncryptionByDefault.SSEAlgorithm), KmsKeyArn: aws.ToString(rule.ApplyServerSideEncryptionByDefault.KMSMasterKeyID), BucketKeyEnabled: aws.ToBool(rule.BucketKeyEnabled), }) } - return BucketEncryption{Rules: rules}, nil + return BucketEncryption{Rules: rules} } func getBucketPublicAccessBlock(ctx context.Context, client *s3.Client, bucketName string) (BucketPublicAccessBlock, error) { @@ -56,13 +67,22 @@ func getBucketPublicAccessBlock(ctx context.Context, client *s3.Client, bucketNa } return BucketPublicAccessBlock{}, err } + + return bucketPublicAccessBlockFromOutput(output), nil +} + +func bucketPublicAccessBlockFromOutput(output *s3.GetPublicAccessBlockOutput) BucketPublicAccessBlock { + if output == nil || output.PublicAccessBlockConfiguration == nil { + return BucketPublicAccessBlock{} + } + pab := output.PublicAccessBlockConfiguration return BucketPublicAccessBlock{ BlockPublicAcls: aws.ToBool(pab.BlockPublicAcls), IgnorePublicAcls: aws.ToBool(pab.IgnorePublicAcls), BlockPublicPolicy: aws.ToBool(pab.BlockPublicPolicy), RestrictPublicBuckets: aws.ToBool(pab.RestrictPublicBuckets), - }, nil + } } func getBucketPolicy(ctx context.Context, client *s3.Client, bucketName string) (BucketPolicyDocument, string, error) { @@ -85,7 +105,16 @@ func getBucketPolicyStatus(ctx context.Context, client *s3.Client, bucketName st } return BucketPolicyStatus{}, err } - return BucketPolicyStatus{IsPublic: aws.ToBool(output.PolicyStatus.IsPublic)}, nil + + return bucketPolicyStatusFromOutput(output), nil +} + +func bucketPolicyStatusFromOutput(output *s3.GetBucketPolicyStatusOutput) BucketPolicyStatus { + if output == nil || output.PolicyStatus == nil { + return BucketPolicyStatus{} + } + + return BucketPolicyStatus{IsPublic: aws.ToBool(output.PolicyStatus.IsPublic)} } func getBucketOwnershipControls(ctx context.Context, client *s3.Client, bucketName string) (BucketOwnershipControls, error) { @@ -176,15 +205,26 @@ func getBucketReplication(ctx context.Context, client *s3.Client, bucketName str return BucketReplication{}, err } + return bucketReplicationFromOutput(output), nil +} + +func bucketReplicationFromOutput(output *s3.GetBucketReplicationOutput) BucketReplication { + if output == nil || output.ReplicationConfiguration == nil { + return BucketReplication{} + } + rules := make([]BucketReplicationRule, 0, len(output.ReplicationConfiguration.Rules)) for _, rule := range output.ReplicationConfiguration.Rules { + if rule.Destination == nil { + continue + } rules = append(rules, BucketReplicationRule{ ID: aws.ToString(rule.ID), Status: string(rule.Status), DestinationBucketArn: aws.ToString(rule.Destination.Bucket), }) } - return BucketReplication{Role: aws.ToString(output.ReplicationConfiguration.Role), Rules: rules}, nil + return BucketReplication{Role: aws.ToString(output.ReplicationConfiguration.Role), Rules: rules} } func getBucketLogging(ctx context.Context, client *s3.Client, bucketName string) (BucketLogging, error) { diff --git a/internal/bucket_apis_test.go b/internal/bucket_apis_test.go new file mode 100644 index 0000000..a730ae6 --- /dev/null +++ b/internal/bucket_apis_test.go @@ -0,0 +1,87 @@ +package internal + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +func TestBucketEncryptionFromOutputHandlesNilConfiguration(t *testing.T) { + encryption := bucketEncryptionFromOutput(&s3.GetBucketEncryptionOutput{}) + if len(encryption.Rules) != 0 { + t.Fatalf("bucketEncryptionFromOutput() = %v, want no rules", encryption) + } +} + +func TestBucketEncryptionFromOutputSkipsIncompleteRules(t *testing.T) { + encryption := bucketEncryptionFromOutput(&s3.GetBucketEncryptionOutput{ + ServerSideEncryptionConfiguration: &s3types.ServerSideEncryptionConfiguration{ + Rules: []s3types.ServerSideEncryptionRule{ + {}, + { + ApplyServerSideEncryptionByDefault: &s3types.ServerSideEncryptionByDefault{ + SSEAlgorithm: s3types.ServerSideEncryptionAwsKms, + KMSMasterKeyID: aws.String("arn:aws:kms:eu-west-2:123456789012:key/example"), + }, + BucketKeyEnabled: aws.Bool(true), + }, + }, + }, + }) + + if len(encryption.Rules) != 1 { + t.Fatalf("bucketEncryptionFromOutput() rules = %v, want one complete rule", encryption.Rules) + } + if encryption.Rules[0].Algorithm != string(s3types.ServerSideEncryptionAwsKms) { + t.Fatalf("bucketEncryptionFromOutput() algorithm = %q, want %q", encryption.Rules[0].Algorithm, s3types.ServerSideEncryptionAwsKms) + } +} + +func TestBucketPublicAccessBlockFromOutputHandlesNilConfiguration(t *testing.T) { + pab := bucketPublicAccessBlockFromOutput(&s3.GetPublicAccessBlockOutput{}) + if pab != (BucketPublicAccessBlock{}) { + t.Fatalf("bucketPublicAccessBlockFromOutput() = %v, want zero value", pab) + } +} + +func TestBucketPolicyStatusFromOutputHandlesNilStatus(t *testing.T) { + status := bucketPolicyStatusFromOutput(&s3.GetBucketPolicyStatusOutput{}) + if status != (BucketPolicyStatus{}) { + t.Fatalf("bucketPolicyStatusFromOutput() = %v, want zero value", status) + } +} + +func TestBucketReplicationFromOutputHandlesNilConfiguration(t *testing.T) { + replication := bucketReplicationFromOutput(&s3.GetBucketReplicationOutput{}) + if len(replication.Rules) != 0 || replication.Role != "" { + t.Fatalf("bucketReplicationFromOutput() = %v, want zero value", replication) + } +} + +func TestBucketReplicationFromOutputSkipsIncompleteRules(t *testing.T) { + replication := bucketReplicationFromOutput(&s3.GetBucketReplicationOutput{ + ReplicationConfiguration: &s3types.ReplicationConfiguration{ + Role: aws.String("arn:aws:iam::123456789012:role/s3-replication"), + Rules: []s3types.ReplicationRule{ + { + Status: s3types.ReplicationRuleStatusEnabled, + }, + { + Status: s3types.ReplicationRuleStatusEnabled, + Destination: &s3types.Destination{ + Bucket: aws.String("arn:aws:s3:::destination-bucket"), + }, + }, + }, + }, + }) + + if len(replication.Rules) != 1 { + t.Fatalf("bucketReplicationFromOutput() rules = %v, want one complete rule", replication.Rules) + } + if replication.Rules[0].DestinationBucketArn != "arn:aws:s3:::destination-bucket" { + t.Fatalf("bucketReplicationFromOutput() destination = %q, want destination bucket ARN", replication.Rules[0].DestinationBucketArn) + } +} diff --git a/main.go b/main.go index 8567b1b..11d7694 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/compliance-framework/agent/runner" @@ -20,6 +21,12 @@ type CompliancePlugin struct { policyData map[string]interface{} } +const defaultEvalTimeout = 10 * time.Minute + +func newEvalContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), defaultEvalTimeout) +} + func (l *CompliancePlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { rawConfig := req.GetConfig() @@ -47,7 +54,9 @@ func (l *CompliancePlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelp } func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { - ctx := context.Background() + ctx, cancel := newEvalContext() + defer cancel() + evalStatus := proto.ExecutionStatus_SUCCESS var accumulatedErrors error diff --git a/main_test.go b/main_test.go index 526a265..c1f4b74 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "testing" + "time" +) func TestSupportedPolicyBehaviors(t *testing.T) { behaviors := supportedPolicyBehaviors() @@ -17,3 +20,18 @@ func TestBuildRequiredDatasetsForBucketPolicies(t *testing.T) { t.Fatalf("buildRequiredDatasets() = %v, want buckets dataset to be required", required) } } + +func TestNewEvalContextHasDefaultTimeout(t *testing.T) { + ctx, cancel := newEvalContext() + defer cancel() + + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("newEvalContext() deadline missing") + } + + remaining := time.Until(deadline) + if remaining <= 0 || remaining > defaultEvalTimeout { + t.Fatalf("newEvalContext() deadline remaining = %s, want within %s", remaining, defaultEvalTimeout) + } +}