From 57ff980a34b0db247cec8e6735ac59ccb5ae847e Mon Sep 17 00:00:00 2001 From: James Salt Date: Fri, 29 May 2026 14:26:54 +0100 Subject: [PATCH] BCH-1290: Implement ACM DataFetcher with paginated multi-region certificate collection Replaces the stub FetchData() with a full implementation that calls ListCertificates (paginated), DescribeCertificate, and ListTagsForCertificate for every configured region. The ACMClient interface enables unit tests with a mock client; AWS_ENDPOINT_URL is honoured automatically by the SDK for LocalStack compatibility (BCH-1294). Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 14 ++- go.sum | 44 ++++---- internal/data.go | 167 ++++++++++++++++++++++++++++-- internal/data_test.go | 229 ++++++++++++++++++++++++++++++++++++++++++ main.go | 3 +- 5 files changed, 426 insertions(+), 31 deletions(-) create mode 100644 internal/data_test.go diff --git a/go.mod b/go.mod index 672369a..57566a2 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/container-solutions/plugin-aws-acm go 1.26.1 require ( + github.com/aws/aws-sdk-go-v2 v1.41.8 + github.com/aws/aws-sdk-go-v2/config v1.32.19 + github.com/aws/aws-sdk-go-v2/service/acm v1.39.1 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,9 +13,18 @@ require ( require ( github.com/agnivade/levenshtein v1.2.1 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.8 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/compliance-framework/api v0.16.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect diff --git a/go.sum b/go.sum index 62cc8c8..c264080 100644 --- a/go.sum +++ b/go.sum @@ -16,34 +16,34 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/aws/aws-sdk-go-v2 v1.41.8 h1:sRs7nG6/RiEBZ/K5UO2sNw0w40U02Nmz1VtARloTZXk= github.com/aws/aws-sdk-go-v2 v1.41.8/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -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/config v1.32.19 h1:qRhIJMbevHUvIE7X4TK8N8zye5+5AhapcslPrvB+qKE= +github.com/aws/aws-sdk-go-v2/config v1.32.19/go.mod h1:RbJ24nfoya63+Mf5VI+CGCGk9vEdv28xPeii+gojRYs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.18 h1:GcXQz2M/0ZvMo0v5DakUqbDBeBM1ZNaivkolEF4Esgw= +github.com/aws/aws-sdk-go-v2/credentials v1.19.18/go.mod h1:sHJ06tMGcD3ZpmMyJqV+VBsGilhSIZPIN+ZFy5Dg0C4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 h1:FQm5ApnyzkuJdXLGskPce83CK1CQKC4RUnIHKVe4BU4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24/go.mod h1:JsC7dqQc55MlZ5mvNsDMMge71u8pVcSzU3RNz2h/5yQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 h1:u6kJU2i0va1AgtJsH3RdWKWqHULlTh7zHwb35Womf74= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24/go.mod h1:7GY+xLcXOFUpCkNwDReft9qOAVg54A4/AnjHIU7sSAY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 h1:Xhbcf3KugX6vX7SDyUK205Oicyfg7EGuvoVNyP5L6DM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24/go.mod h1:rwDgb2HNOGZsnTHylOUedM7Vnl+bCfnXDqUNPsFWYfk= -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.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4= +github.com/aws/aws-sdk-go-v2/service/acm v1.39.1 h1:stmoDRUM6Qk70xvxD1aWyenMhmkZxN0hxFV95RJnOcQ= +github.com/aws/aws-sdk-go-v2/service/acm v1.39.1/go.mod h1:8xDGed8DLDYKJvrZjCtbCsT6TRwD0vQenj6pyRdcs8c= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw= 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= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= -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/aws-sdk-go-v2/service/signin v1.1.0 h1:yQo3eZ5qFaL1sJWqs1nL6j3yPHA2/R7c6tQ4T+0IO10= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.0/go.mod h1:3Zzou41Qt/ueXfIzHvTEjDNuR5IjCUBVF01SNhrt1e8= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 h1:ApLTFdAZfDhZSiY5uskwECKHkSNNF83y2Ru2r7SezWA= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.18/go.mod h1:A9K9qx2l6nK89hp+a350FdGfRkrkH5HdiEjHbiy/Q/c= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 h1:4VD7TIZOGzehrgQ8vDE+1c6BQW4ErZPGY8ohZT5LXEE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1/go.mod h1:er0SFJfdV89Rit5hIJu/EXtv+qC2XMnxoksLmcUFkqM= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 h1:XKnxlM4KZH1gktcsh3zSWc7GW4KivEv/OkifmHOhCUY= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.2/go.mod h1:KJYmkQaFB3SUW2j3aBkPsxNmAb4ZsSOvbvCpuxzHJA0= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/internal/data.go b/internal/data.go index 25a9607..55db458 100644 --- a/internal/data.go +++ b/internal/data.go @@ -1,22 +1,175 @@ package internal import ( + "context" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/acm" "github.com/hashicorp/go-hclog" ) +// ACMClient is the subset of the AWS ACM API used by DataFetcher. +type ACMClient interface { + ListCertificates(ctx context.Context, params *acm.ListCertificatesInput, optFns ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) + DescribeCertificate(ctx context.Context, params *acm.DescribeCertificateInput, optFns ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) + ListTagsForCertificate(ctx context.Context, params *acm.ListTagsForCertificateInput, optFns ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) +} + +// DomainValidationOption holds per-domain validation details for a certificate. +type DomainValidationOption struct { + DomainName string `json:"domain_name"` + ValidationMethod string `json:"validation_method"` +} + +// CertificateContext holds all fields required by the ACM compliance policies. +type CertificateContext struct { + Region string `json:"region"` + AccountID string `json:"account_id"` + CertificateArn string `json:"certificate_arn"` + DomainName string `json:"domain_name"` + Status string `json:"status"` + NotAfter *time.Time `json:"not_after,omitempty"` + KeyAlgorithm string `json:"key_algorithm"` + TransparencyLoggingPreference string `json:"transparency_logging_preference"` + DomainValidationOptions []DomainValidationOption `json:"domain_validation_options"` + InUseBy []string `json:"in_use_by"` + Tags map[string]string `json:"tags"` +} + +// DataFetcher retrieves ACM certificate data across configured regions. type DataFetcher struct { - logger hclog.Logger - config *PluginConfig + logger hclog.Logger + config *PluginConfig + newClient func(ctx context.Context, region string) (ACMClient, error) } -func NewDataFetcher(logger hclog.Logger, config *PluginConfig) *DataFetcher { +// NewDataFetcher returns a DataFetcher using the standard AWS credential chain. +// AWS_ENDPOINT_URL is honoured automatically by the SDK for LocalStack compatibility. +func NewDataFetcher(logger hclog.Logger, cfg *PluginConfig) *DataFetcher { return &DataFetcher{ logger: logger, - config: config, + config: cfg, + newClient: func(ctx context.Context, region string) (ACMClient, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, err + } + return acm.NewFromConfig(awsCfg), nil + }, + } +} + +// FetchData retrieves all ACM certificates across every configured region. +func (df *DataFetcher) FetchData(ctx context.Context) ([]CertificateContext, error) { + var all []CertificateContext + for _, region := range df.config.Regions { + certs, err := df.fetchRegion(ctx, region) + if err != nil { + return nil, fmt.Errorf("region %s: %w", region, err) + } + all = append(all, certs...) + } + return all, nil +} + +func (df *DataFetcher) fetchRegion(ctx context.Context, region string) ([]CertificateContext, error) { + client, err := df.newClient(ctx, region) + if err != nil { + return nil, fmt.Errorf("create ACM client: %w", err) + } + + var certs []CertificateContext + var nextToken *string + for { + out, err := client.ListCertificates(ctx, &acm.ListCertificatesInput{NextToken: nextToken}) + if err != nil { + return nil, fmt.Errorf("ListCertificates: %w", err) + } + for _, summary := range out.CertificateSummaryList { + arn := aws.ToString(summary.CertificateArn) + if arn == "" { + continue + } + cert, err := df.fetchCertificate(ctx, client, region, arn) + if err != nil { + df.logger.Warn("skipping certificate", "arn", arn, "error", err) + continue + } + certs = append(certs, cert) + } + if out.NextToken == nil { + break + } + nextToken = out.NextToken } + return certs, nil } -// FetchData retrieves ACM certificate data. Full implementation in task 005. -func (df *DataFetcher) FetchData() (map[string]any, error) { - return map[string]any{}, nil +func (df *DataFetcher) fetchCertificate(ctx context.Context, client ACMClient, region, arn string) (CertificateContext, error) { + descOut, err := client.DescribeCertificate(ctx, &acm.DescribeCertificateInput{ + CertificateArn: aws.String(arn), + }) + if err != nil { + return CertificateContext{}, fmt.Errorf("DescribeCertificate: %w", err) + } + + tagsOut, err := client.ListTagsForCertificate(ctx, &acm.ListTagsForCertificateInput{ + CertificateArn: aws.String(arn), + }) + if err != nil { + return CertificateContext{}, fmt.Errorf("ListTagsForCertificate: %w", err) + } + + detail := descOut.Certificate + + transparencyPref := "" + if detail.Options != nil { + transparencyPref = string(detail.Options.CertificateTransparencyLoggingPreference) + } + + dvos := make([]DomainValidationOption, 0, len(detail.DomainValidationOptions)) + for _, dvo := range detail.DomainValidationOptions { + dvos = append(dvos, DomainValidationOption{ + DomainName: aws.ToString(dvo.DomainName), + ValidationMethod: string(dvo.ValidationMethod), + }) + } + + tags := make(map[string]string, len(tagsOut.Tags)) + for _, tag := range tagsOut.Tags { + tags[aws.ToString(tag.Key)] = aws.ToString(tag.Value) + } + + inUseBy := detail.InUseBy + if inUseBy == nil { + inUseBy = []string{} + } + + return CertificateContext{ + Region: region, + AccountID: arnAccountID(arn), + CertificateArn: arn, + DomainName: aws.ToString(detail.DomainName), + Status: string(detail.Status), + NotAfter: detail.NotAfter, + KeyAlgorithm: string(detail.KeyAlgorithm), + TransparencyLoggingPreference: transparencyPref, + DomainValidationOptions: dvos, + InUseBy: inUseBy, + Tags: tags, + }, nil +} + +// arnAccountID extracts the 12-digit account ID from an ACM ARN. +// ARN format: arn:aws:acm:::certificate/ +func arnAccountID(arn string) string { + parts := strings.Split(arn, ":") + if len(parts) >= 5 { + return parts[4] + } + return "" } diff --git a/internal/data_test.go b/internal/data_test.go new file mode 100644 index 0000000..cfefdc7 --- /dev/null +++ b/internal/data_test.go @@ -0,0 +1,229 @@ +package internal + +import ( + "context" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/acm" + "github.com/aws/aws-sdk-go-v2/service/acm/types" + "github.com/hashicorp/go-hclog" +) + +type mockACMClient struct { + listCertificates func(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) + describeCertificate func(context.Context, *acm.DescribeCertificateInput, ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) + listTagsForCertificate func(context.Context, *acm.ListTagsForCertificateInput, ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) +} + +func (m *mockACMClient) ListCertificates(ctx context.Context, params *acm.ListCertificatesInput, optFns ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) { + return m.listCertificates(ctx, params, optFns...) +} + +func (m *mockACMClient) DescribeCertificate(ctx context.Context, params *acm.DescribeCertificateInput, optFns ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) { + return m.describeCertificate(ctx, params, optFns...) +} + +func (m *mockACMClient) ListTagsForCertificate(ctx context.Context, params *acm.ListTagsForCertificateInput, optFns ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) { + return m.listTagsForCertificate(ctx, params, optFns...) +} + +func newTestFetcher(regions []string, client ACMClient) *DataFetcher { + return &DataFetcher{ + logger: hclog.NewNullLogger(), + config: &PluginConfig{Regions: regions}, + newClient: func(_ context.Context, _ string) (ACMClient, error) { + return client, nil + }, + } +} + +func TestFetchData_Empty(t *testing.T) { + mock := &mockACMClient{ + listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) { + return &acm.ListCertificatesOutput{}, nil + }, + } + f := newTestFetcher([]string{"us-east-1"}, mock) + certs, err := f.FetchData(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(certs) != 0 { + t.Errorf("expected 0 certs, got %d", len(certs)) + } +} + +func TestFetchData_SingleCertificate(t *testing.T) { + arn := "arn:aws:acm:us-east-1:123456789012:certificate/abc-123" + notAfter := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + mock := &mockACMClient{ + listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) { + return &acm.ListCertificatesOutput{ + CertificateSummaryList: []types.CertificateSummary{ + {CertificateArn: aws.String(arn)}, + }, + }, nil + }, + describeCertificate: func(_ context.Context, _ *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) { + return &acm.DescribeCertificateOutput{ + Certificate: &types.CertificateDetail{ + CertificateArn: aws.String(arn), + DomainName: aws.String("example.com"), + Status: types.CertificateStatus("ISSUED"), + NotAfter: ¬After, + KeyAlgorithm: types.KeyAlgorithm("RSA_2048"), + Options: &types.CertificateOptions{ + CertificateTransparencyLoggingPreference: types.CertificateTransparencyLoggingPreference("ENABLED"), + }, + DomainValidationOptions: []types.DomainValidation{ + {DomainName: aws.String("example.com"), ValidationMethod: types.ValidationMethod("DNS")}, + }, + InUseBy: []string{"arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/foo/bar"}, + }, + }, nil + }, + listTagsForCertificate: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) { + return &acm.ListTagsForCertificateOutput{ + Tags: []types.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + }, + }, nil + }, + } + + f := newTestFetcher([]string{"us-east-1"}, mock) + certs, err := f.FetchData(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(certs) != 1 { + t.Fatalf("expected 1 cert, got %d", len(certs)) + } + + c := certs[0] + if c.CertificateArn != arn { + t.Errorf("arn: want %q, got %q", arn, c.CertificateArn) + } + if c.AccountID != "123456789012" { + t.Errorf("account_id: want %q, got %q", "123456789012", c.AccountID) + } + if c.Region != "us-east-1" { + t.Errorf("region: want %q, got %q", "us-east-1", c.Region) + } + if c.DomainName != "example.com" { + t.Errorf("domain_name: want %q, got %q", "example.com", c.DomainName) + } + if c.Status != "ISSUED" { + t.Errorf("status: want %q, got %q", "ISSUED", c.Status) + } + if c.NotAfter == nil || !c.NotAfter.Equal(notAfter) { + t.Errorf("not_after: want %v, got %v", notAfter, c.NotAfter) + } + if c.KeyAlgorithm != "RSA_2048" { + t.Errorf("key_algorithm: want %q, got %q", "RSA_2048", c.KeyAlgorithm) + } + if c.TransparencyLoggingPreference != "ENABLED" { + t.Errorf("transparency_logging_preference: want %q, got %q", "ENABLED", c.TransparencyLoggingPreference) + } + if len(c.DomainValidationOptions) != 1 || c.DomainValidationOptions[0].ValidationMethod != "DNS" { + t.Errorf("domain_validation_options: unexpected %v", c.DomainValidationOptions) + } + if len(c.InUseBy) != 1 { + t.Errorf("in_use_by: expected 1 entry, got %v", c.InUseBy) + } + if c.Tags["env"] != "prod" { + t.Errorf("tags: expected env=prod, got %v", c.Tags) + } +} + +func TestFetchData_Pagination(t *testing.T) { + arns := []string{ + "arn:aws:acm:us-east-1:123456789012:certificate/aaa", + "arn:aws:acm:us-east-1:123456789012:certificate/bbb", + } + callCount := 0 + + mock := &mockACMClient{ + listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) { + callCount++ + if callCount == 1 { + return &acm.ListCertificatesOutput{ + CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[0])}}, + NextToken: aws.String("page2"), + }, nil + } + return &acm.ListCertificatesOutput{ + CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[1])}}, + }, nil + }, + describeCertificate: func(_ context.Context, params *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) { + return &acm.DescribeCertificateOutput{ + Certificate: &types.CertificateDetail{ + CertificateArn: params.CertificateArn, + DomainName: aws.String("example.com"), + Status: types.CertificateStatus("ISSUED"), + }, + }, nil + }, + listTagsForCertificate: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) { + return &acm.ListTagsForCertificateOutput{}, nil + }, + } + + f := newTestFetcher([]string{"us-east-1"}, mock) + certs, err := f.FetchData(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(certs) != 2 { + t.Errorf("expected 2 certs, got %d", len(certs)) + } + if callCount != 2 { + t.Errorf("expected 2 ListCertificates calls, got %d", callCount) + } +} + +func TestFetchData_MultipleRegions(t *testing.T) { + var clientRegions []string + + f := &DataFetcher{ + logger: hclog.NewNullLogger(), + config: &PluginConfig{Regions: []string{"us-east-1", "eu-west-1"}}, + newClient: func(_ context.Context, region string) (ACMClient, error) { + clientRegions = append(clientRegions, region) + return &mockACMClient{ + listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) { + return &acm.ListCertificatesOutput{}, nil + }, + }, nil + }, + } + + _, err := f.FetchData(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(clientRegions) != 2 || clientRegions[0] != "us-east-1" || clientRegions[1] != "eu-west-1" { + t.Errorf("expected clients for us-east-1 and eu-west-1, got %v", clientRegions) + } +} + +func TestFetchData_AccountIDFromARN(t *testing.T) { + cases := []struct { + arn string + accountID string + }{ + {"arn:aws:acm:us-east-1:123456789012:certificate/abc", "123456789012"}, + {"arn:aws:acm:eu-west-1:999999999999:certificate/xyz", "999999999999"}, + {"invalid", ""}, + } + for _, tc := range cases { + got := arnAccountID(tc.arn) + if got != tc.accountID { + t.Errorf("arnAccountID(%q): want %q, got %q", tc.arn, tc.accountID, got) + } + } +} diff --git a/main.go b/main.go index 5535a05..4a4ad6d 100644 --- a/main.go +++ b/main.go @@ -64,7 +64,7 @@ func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.Api } dataFetcher := internal.NewDataFetcher(l.logger, l.config) - data, err := dataFetcher.FetchData() + certs, err := dataFetcher.FetchData(ctx) if err != nil { return &proto.EvalResponse{ Status: proto.ExecutionStatus_FAILURE, @@ -72,6 +72,7 @@ func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.Api } policyEvaluator := internal.NewPolicyEvaluator(ctx, l.logger, activities) + data := map[string]interface{}{"certificates": certs} evidences, err := policyEvaluator.Eval(ctx, data, request.PolicyPaths, l.policyData, l.config.PolicyLabels) if err != nil {