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..cc4ad98 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,8 @@ ifeq ($(OPA),) $(error "opa CLI not found. Please install it: https://www.openpolicyagent.org/docs/latest/cli/") endif +.PHONY: test clean build run + ##@ Help help: ## Display this concise help, ie only the porcelain target @awk 'BEGIN {FS = ":.*##"; printf "\033[1mUsage\033[0m\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-30s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) @@ -31,7 +33,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 + @../agent/dist/./concom agent --config ./.config/config.yaml diff --git a/README.md b/README.md index 54c3f0f..3e71e21 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,101 @@ -# Compliance Framework Plugin Template +# AWS EKS CCF Plugin -This is a template for building a compliance framework plugin. +This plugin collects read-only Amazon EKS data, evaluates CCF Rego policy bundles, and emits evidence back through the CCF agent. -Inspect main.go for a detailed description of how to build the plugin. +## Supported Resource Families -## Prerequisites +The collector evaluates policies for: -* GoReleaser https://goreleaser.com/install/ +- EKS clusters +- EKS managed node groups +- EKS managed add-ons -## Building +## How It Fits In CCF -Once you are ready to serve the plugin, you need to build the binaries which can be used by the agent. +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. -```shell -goreleaser release --snapshot --clean -``` +## Default Policy Bundle Mapping -## Usage +| Repository | Behavior | Primary input | +| --- | --- | --- | +| `plugin-aws-eks-policies` | `cluster` | `input.cluster` + `input.cluster_context` | +| `plugin-aws-eks-nodegroup-policies` | `nodegroup` | `input.nodegroup` + `input.nodegroup_context` | +| `plugin-aws-eks-addon-policies` | `addon` | `input.addon` + `input.addon_context` | -You can use this plugin by passing it to the compliance agent or by specifying it in the agent config +Each bundle evaluates one resource family at a time. Cluster context includes summarized related managed node groups and add-ons so cluster-level policies can check required add-ons without evaluating add-on policies against cluster input. -```shell -agent --plugin=[PATH_TO_YOUR_BINARY] -``` +## Configuration -```yaml -# AGENT CONFIG +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 -verbosity: 2 +Any agent-supplied `policy_data` is passed through to Rego as `data.*`. -api: - url: http://localhost:8080 +Example agent plugin config: +```yaml plugins: - # Plugin execution identifier - myplugin: - # Config mapping passed through to Configure lifecycle event - config: - anykey: "anyval" - policy_labels: "{\"my_key\":\"my_value\"}" - # Compatible protocol version: Defaults to 1, can also be determined a plugin image manifest annotation of "org.ccf.plugin.protocol.version=2" + aws-eks: protocol_version: 2 - # Source to plugin executable location. Can be an OCI image or local executable source: /path/to/dist/plugin - # List to all policies to pass to plugin, all may be processed or filtered later via policy_behavior + config: + regions: "eu-west-2,us-east-1" policies: - - /path/to/policy/bundle.tar.gz - # Policy behaviour can be defined to later filter policies to specific bundles per execution - # This is useful if your plugin proccesses more than 1 type of component - policy_behavior: - string-to-match-to-policy: - - "associated-behavior-1" - # Policy data is passed to the plugin for evaluation, can be used to customize evaluation parameters - policy_data: - policy_data_key: "policy_data_value" - + - /path/to/plugin-aws-eks-policies/dist/bundle.tar.gz + - /path/to/plugin-aws-eks-nodegroup-policies/dist/bundle.tar.gz + - /path/to/plugin-aws-eks-addon-policies/dist/bundle.tar.gz +``` + +## Data Collected + +Depending on the selected policy bundles, the plugin collects and reuses: + +- `ListClusters` and `DescribeCluster` +- `ListNodegroups` and `DescribeNodegroup` +- `ListAddons` and `DescribeAddon` + +The plugin collects shared regional datasets once and reuses them across resource-family evaluation to reduce AWS API calls. +## IAM Permissions + +The AWS principal used by the plugin needs read-only EKS permissions for the configured regions: + +```json +{ + "Effect": "Allow", + "Action": [ + "eks:ListClusters", + "eks:DescribeCluster", + "eks:ListNodegroups", + "eks:DescribeNodegroup", + "eks:ListAddons", + "eks:DescribeAddon" + ], + "Resource": "*" +} +``` + +## Development + +Run the local test suite with: + +```shell +go test ./... ``` -You can also use `make run` to build this plugin and execute against the agent, if the agent is located in the parent directory. See Makefile:run +Or use the Makefile wrapper: + +```shell +make test +``` + +Build the plugin binary with: + +```shell +make build +``` +This writes the compiled plugin to `dist/plugin`. diff --git a/go.mod b/go.mod index 65fa2c4..06e0acd 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,11 @@ -module github.com/compliance-framework/plugin-template +module github.com/compliance-framework/plugin-aws-eks go 1.26.1 require ( + github.com/aws/aws-sdk-go-v2 v1.41.12 + github.com/aws/aws-sdk-go-v2/config v1.32.23 + github.com/aws/aws-sdk-go-v2/service/eks v1.84.5 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 +13,18 @@ require ( require ( github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.22 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 // indirect + github.com/aws/smithy-go v1.27.1 // 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..0ff9abf 100644 --- a/go.sum +++ b/go.sum @@ -14,38 +14,38 @@ 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/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/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 v1.41.12 h1:DIKX2c31ekm9RA2D9FBj1EWXx++9AdAqRw+e78Tq2Ck= +github.com/aws/aws-sdk-go-v2 v1.41.12/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/config v1.32.23 h1:PYDobtcsJXK6bQe9I8RQk6s19Bz3xa3xRU08Hy1Em3Y= +github.com/aws/aws-sdk-go-v2/config v1.32.23/go.mod h1:QID4dqUQVgEOYPKsPWd1sNWCCR2c5g7o3jeEtIXPOZU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.22 h1:SHfH6wyPsEgG7fVsi5rQxWEt7tuIcN2PGhb1mTFv6tE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.22/go.mod h1:54nO8lKD4aQPOntM/VTWjnR+DYzTwx0YkSMZMhAgewQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 h1:b+kcDejJrXc30zU/w8Tc9klISwaO5wh+6T0sMBdDoHM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28/go.mod h1:LnI62O9GnSv6GcuLXxOYqlq0C8EmxMcgnF6m7LdYuOY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28 h1:Xf2j7NdVcUKomlZ4iihOP4AZ3Fzlr8h4yKpXeP+OFPg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28/go.mod h1:O8cDo1dW63jU7ki//kRe1z+tLGcpnD1jrouitsQddDw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28 h1:KqIfN9kpkKkcBqBbNpNGTIrXO6ExTUvFKvXkC+YAzVo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28/go.mod h1:uxtQiKvLtNS4iXVsH2McVD/ls8FKN/uUhe1hGxPjrw0= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29 h1:VkE9FuzTQVjBBrnj4+oCdxCLFIz7aqLYKUCjtvxVcOs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29/go.mod h1:H32Z2Qth9b+9LqjyBsCnozMQ8H2N7YBUDVXwbs0iggg= +github.com/aws/aws-sdk-go-v2/service/eks v1.84.5 h1:erqzVPMSmQkBWSovla49E0svTFhyLPRGljZy4hMisIo= +github.com/aws/aws-sdk-go-v2/service/eks v1.84.5/go.mod h1:UB1zkSdu6MppVpPr0PZc4uNEhQJvaq/icAN+gtUOVo8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28 h1:axj4mEDletwKmTm/9jR+DkIMmCfcn5vE4jBMAAN+3Vg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28/go.mod h1:3Aaz69M0jqfSHLKqxgolgUBFT4hpwSNc7DzC95orEi8= 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/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= -github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 h1:YcpVyIPLCbiypN6KSphijN5fC7DDjX114SqA7prnnxg= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.4/go.mod h1:5ZICS++oFTRPfa1GsBqFDWX/8WamZ/QQOcCzIuU/zLw= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.2 h1:ySNWu7TPmj5fKFIa1GYvX+Ddxd5ccruqC20aMNuyWDM= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.2/go.mod h1:A+U9luAOwFeB1kseyWCITVg7/NntoPebCFR9pQ4ch9A= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 h1:KSzGGqfk39O+WU3OEyYbx6F7sLDQCqxlOJ+2IksfK6U= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5/go.mod h1:ATs88lXDeQB6CZOgQ5BIl9JbYS+EsCWUSDyff6L/oVo= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 h1:RTO7mmGyedgnNmcPh3yQizNfc6GKoV5iqfdJavuf9vw= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.2/go.mod h1:fBhUZXDin9YYqhcpOMjIcpdik25rVwWyxLdPH1RZd9s= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/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/addon.go b/internal/addon.go new file mode 100644 index 0000000..1c9c99a --- /dev/null +++ b/internal/addon.go @@ -0,0 +1,118 @@ +package internal + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/compliance-framework/agent/runner/proto" +) + +func EvaluateAddonPolicies(deps EvaluationDependencies, policyPaths []string, addons []types.Addon, region string, datasets RegionDatasets) ResourceEvaluationErrors { + return EvaluateResources( + deps, + policyPaths, + addons, + func(addon types.Addon) ResourceEvidenceContext { + return BuildAddonEvidenceContext(addon, region) + }, + func(addon types.Addon) (interface{}, error) { + return BuildAddonPolicyInput(addon, region, datasets) + }, + func(addon types.Addon, err error) { + deps.Logger.Error("unable to build EKS add-on policy input", "cluster_name", aws.ToString(addon.ClusterName), "addon_name", aws.ToString(addon.AddonName), "region", region, "error", err) + }, + func(evidences []*proto.Evidence, addon types.Addon) { + PrefixEvidenceTitles(evidences, AddonDisplayName(addon)) + }, + ) +} + +func BuildAddonPolicyInput(addon types.Addon, region string, datasets RegionDatasets) (map[string]interface{}, error) { + addonValue, err := ToInterfaceMap(addon) + if err != nil { + return nil, err + } + + contextValue, err := ToInterfaceMap(buildAddonSupplementaryContext(addon, region, datasets)) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "addon": addonValue, + "addon_context": contextValue, + }, nil +} + +func buildAddonSupplementaryContext(addon types.Addon, region string, datasets RegionDatasets) map[string]interface{} { + clusterName := aws.ToString(addon.ClusterName) + + return map[string]interface{}{ + "current": map[string]interface{}{ + "cluster_name": clusterName, + "addon_name": aws.ToString(addon.AddonName), + "addon_arn": aws.ToString(addon.AddonArn), + "region": region, + "status": string(addon.Status), + "addon_version": aws.ToString(addon.AddonVersion), + "health_issue_count": addonHealthIssueCount(addon), + "owner": aws.ToString(addon.Owner), + "publisher": aws.ToString(addon.Publisher), + "tags_present": len(addon.Tags) > 0, + "has_service_account_role": aws.ToString(addon.ServiceAccountRoleArn) != "", + }, + "cluster": findClusterByName(datasets.Clusters, clusterName), + } +} + +func AddonDisplayName(addon types.Addon) string { + return aws.ToString(addon.AddonName) +} + +func addonHealthIssueCount(addon types.Addon) int { + if addon.Health == nil { + return 0 + } + return len(addon.Health.Issues) +} + +func filterAddonsByCluster(addons []types.Addon, clusterName string) []types.Addon { + filtered := make([]types.Addon, 0) + for _, addon := range addons { + if aws.ToString(addon.ClusterName) == clusterName { + filtered = append(filtered, addon) + } + } + return filtered +} + +func addonNames(addons []types.Addon) []string { + names := make([]string, 0, len(addons)) + for _, addon := range addons { + if name := aws.ToString(addon.AddonName); name != "" { + names = append(names, name) + } + } + return names +} + +func activeAddonNames(addons []types.Addon) []string { + names := make([]string, 0, len(addons)) + for _, addon := range addons { + if addon.Status == types.AddonStatusActive { + if name := aws.ToString(addon.AddonName); name != "" { + names = append(names, name) + } + } + } + return names +} + +func findClusterByName(clusters []types.Cluster, clusterName string) *types.Cluster { + for _, cluster := range clusters { + if aws.ToString(cluster.Name) == clusterName { + clusterCopy := cluster + return &clusterCopy + } + } + return nil +} diff --git a/internal/addon_context_test.go b/internal/addon_context_test.go new file mode 100644 index 0000000..862d107 --- /dev/null +++ b/internal/addon_context_test.go @@ -0,0 +1,58 @@ +package internal + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks/types" +) + +func TestBuildAddonPolicyInputIncludesAddonContext(t *testing.T) { + addon := types.Addon{ + ClusterName: aws.String("prod"), + AddonName: aws.String("vpc-cni"), + AddonArn: aws.String("arn:aws:eks:eu-west-2:123456789012:addon/prod/vpc-cni/id"), + AddonVersion: aws.String("v1.19.0-eksbuild.1"), + Status: types.AddonStatusActive, + Owner: aws.String("aws"), + Publisher: aws.String("eks"), + ServiceAccountRoleArn: aws.String("arn:aws:iam::123456789012:role/eks-addon"), + Tags: map[string]string{"Environment": "prod"}, + } + + input, err := BuildAddonPolicyInput(addon, "eu-west-2", RegionDatasets{ + Clusters: []types.Cluster{{Name: aws.String("prod")}}, + }) + if err != nil { + t.Fatalf("BuildAddonPolicyInput returned error: %v", err) + } + + if _, ok := input["addon"].(map[string]interface{}); !ok { + t.Fatalf("input[addon] should contain the raw add-on map") + } + + contextMap, ok := input["addon_context"].(map[string]interface{}) + if !ok { + t.Fatalf("input[addon_context] has unexpected type %T", input["addon_context"]) + } + current, ok := contextMap["current"].(map[string]interface{}) + if !ok { + t.Fatalf("addon_context[current] has unexpected type %T", contextMap["current"]) + } + if current["cluster_name"] != "prod" { + t.Fatalf("current.cluster_name = %v, want prod", current["cluster_name"]) + } + if current["addon_name"] != "vpc-cni" { + t.Fatalf("current.addon_name = %v, want vpc-cni", current["addon_name"]) + } + hasRole, ok := current["has_service_account_role"].(bool) + if !ok { + t.Fatalf("current.has_service_account_role has unexpected type %T", current["has_service_account_role"]) + } + if !hasRole { + t.Fatalf("current.has_service_account_role = %v, want true", hasRole) + } + if contextMap["cluster"] == nil { + t.Fatal("addon_context.cluster should include the parent cluster when available") + } +} diff --git a/internal/cluster.go b/internal/cluster.go new file mode 100644 index 0000000..f191eec --- /dev/null +++ b/internal/cluster.go @@ -0,0 +1,154 @@ +package internal + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/compliance-framework/agent/runner/proto" +) + +func EvaluateClusterPolicies(deps EvaluationDependencies, policyPaths []string, clusters []types.Cluster, region string, datasets RegionDatasets) ResourceEvaluationErrors { + return EvaluateResources( + deps, + policyPaths, + clusters, + func(cluster types.Cluster) ResourceEvidenceContext { + return BuildClusterEvidenceContext(cluster, region) + }, + func(cluster types.Cluster) (interface{}, error) { + return BuildClusterPolicyInput(cluster, region, datasets) + }, + func(cluster types.Cluster, err error) { + deps.Logger.Error("unable to build EKS cluster policy input", "cluster_name", aws.ToString(cluster.Name), "region", region, "error", err) + }, + func(evidences []*proto.Evidence, cluster types.Cluster) { + PrefixEvidenceTitles(evidences, ClusterDisplayName(cluster)) + }, + ) +} + +func BuildClusterPolicyInput(cluster types.Cluster, region string, datasets RegionDatasets) (map[string]interface{}, error) { + clusterValue, err := ToInterfaceMap(cluster) + if err != nil { + return nil, err + } + + contextValue, err := ToInterfaceMap(buildClusterSupplementaryContext(cluster, region, datasets)) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "cluster": clusterValue, + "cluster_context": contextValue, + }, nil +} + +func buildClusterSupplementaryContext(cluster types.Cluster, region string, datasets RegionDatasets) map[string]interface{} { + clusterName := aws.ToString(cluster.Name) + + return map[string]interface{}{ + "current": map[string]interface{}{ + "cluster_name": clusterName, + "cluster_arn": aws.ToString(cluster.Arn), + "region": region, + "status": string(cluster.Status), + "version": aws.ToString(cluster.Version), + "platform_version": aws.ToString(cluster.PlatformVersion), + "health_issue_count": clusterHealthIssueCount(cluster), + "tags_present": len(cluster.Tags) > 0, + "endpoint_public_access": clusterEndpointPublicAccess(cluster), + "endpoint_private_access": clusterEndpointPrivateAccess(cluster), + "public_access_cidrs": clusterPublicAccessCidrs(cluster), + "enabled_control_plane_log_types": enabledControlPlaneLogTypes(cluster), + "authentication_mode": clusterAuthenticationMode(cluster), + "bootstrap_creator_admin_permissions": clusterBootstrapCreatorAdminPermissions(cluster), + "secrets_encryption_configured": clusterSecretsEncryptionConfigured(cluster), + "secrets_encryption_customer_key_arns": clusterSecretsEncryptionKeyARNs(cluster), + "deletion_protection": aws.ToBool(cluster.DeletionProtection), + "related_managed_nodegroup_count": len(filterNodegroupsByCluster(datasets.Nodegroups, clusterName)), + "related_managed_addon_count": len(filterAddonsByCluster(datasets.Addons, clusterName)), + "related_managed_addon_names": addonNames(filterAddonsByCluster(datasets.Addons, clusterName)), + "active_related_managed_addon_names": activeAddonNames(filterAddonsByCluster(datasets.Addons, clusterName)), + "related_managed_nodegroup_names": nodegroupNames(filterNodegroupsByCluster(datasets.Nodegroups, clusterName)), + "active_related_managed_nodegroup_names": activeNodegroupNames(filterNodegroupsByCluster(datasets.Nodegroups, clusterName)), + }, + "addons": filterAddonsByCluster(datasets.Addons, clusterName), + "nodegroups": filterNodegroupsByCluster(datasets.Nodegroups, clusterName), + } +} + +func ClusterDisplayName(cluster types.Cluster) string { + return aws.ToString(cluster.Name) +} + +func clusterHealthIssueCount(cluster types.Cluster) int { + if cluster.Health == nil { + return 0 + } + return len(cluster.Health.Issues) +} + +func clusterPublicAccessCidrs(cluster types.Cluster) []string { + if cluster.ResourcesVpcConfig == nil { + return nil + } + return cluster.ResourcesVpcConfig.PublicAccessCidrs +} + +func enabledControlPlaneLogTypes(cluster types.Cluster) []string { + if cluster.Logging == nil { + return nil + } + + enabled := make([]string, 0) + for _, setup := range cluster.Logging.ClusterLogging { + if !aws.ToBool(setup.Enabled) { + continue + } + for _, logType := range setup.Types { + enabled = append(enabled, string(logType)) + } + } + return enabled +} + +func clusterAuthenticationMode(cluster types.Cluster) string { + if cluster.AccessConfig == nil { + return "" + } + return string(cluster.AccessConfig.AuthenticationMode) +} + +func clusterBootstrapCreatorAdminPermissions(cluster types.Cluster) *bool { + if cluster.AccessConfig == nil { + return nil + } + return cluster.AccessConfig.BootstrapClusterCreatorAdminPermissions +} + +func clusterSecretsEncryptionConfigured(cluster types.Cluster) bool { + for _, encryptionConfig := range cluster.EncryptionConfig { + for _, resource := range encryptionConfig.Resources { + if resource == "secrets" { + return true + } + } + } + return false +} + +func clusterSecretsEncryptionKeyARNs(cluster types.Cluster) []string { + keyARNs := make([]string, 0) + for _, encryptionConfig := range cluster.EncryptionConfig { + if encryptionConfig.Provider == nil { + continue + } + for _, resource := range encryptionConfig.Resources { + if resource == "secrets" && aws.ToString(encryptionConfig.Provider.KeyArn) != "" { + keyARNs = append(keyARNs, aws.ToString(encryptionConfig.Provider.KeyArn)) + break + } + } + } + return keyARNs +} diff --git a/internal/cluster_context_test.go b/internal/cluster_context_test.go new file mode 100644 index 0000000..774e980 --- /dev/null +++ b/internal/cluster_context_test.go @@ -0,0 +1,90 @@ +package internal + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks/types" +) + +func TestBuildClusterPolicyInputIncludesClusterContext(t *testing.T) { + bootstrapAdmin := false + deletionProtection := true + cluster := types.Cluster{ + Name: aws.String("prod"), + Arn: aws.String("arn:aws:eks:eu-west-2:123456789012:cluster/prod"), + Status: types.ClusterStatusActive, + Version: aws.String("1.30"), + PlatformVersion: aws.String("eks.12"), + Tags: map[string]string{"Environment": "prod"}, + DeletionProtection: &deletionProtection, + AccessConfig: &types.AccessConfigResponse{ + AuthenticationMode: types.AuthenticationModeApiAndConfigMap, + BootstrapClusterCreatorAdminPermissions: &bootstrapAdmin, + }, + ResourcesVpcConfig: &types.VpcConfigResponse{ + EndpointPublicAccess: true, + EndpointPrivateAccess: true, + PublicAccessCidrs: []string{"10.0.0.0/8"}, + }, + Logging: &types.Logging{ + ClusterLogging: []types.LogSetup{ + {Enabled: aws.Bool(true), Types: []types.LogType{types.LogTypeAudit, types.LogTypeAuthenticator}}, + {Enabled: aws.Bool(false), Types: []types.LogType{types.LogTypeApi}}, + }, + }, + EncryptionConfig: []types.EncryptionConfig{ + { + Resources: []string{"secrets"}, + Provider: &types.Provider{KeyArn: aws.String("arn:aws:kms:eu-west-2:123456789012:key/abc")}, + }, + }, + } + + input, err := BuildClusterPolicyInput(cluster, "eu-west-2", RegionDatasets{ + Addons: []types.Addon{ + {ClusterName: aws.String("prod"), AddonName: aws.String("vpc-cni"), Status: types.AddonStatusActive}, + {ClusterName: aws.String("prod"), AddonName: aws.String("coredns"), Status: types.AddonStatusDegraded}, + {ClusterName: aws.String("other"), AddonName: aws.String("kube-proxy"), Status: types.AddonStatusActive}, + }, + Nodegroups: []types.Nodegroup{ + {ClusterName: aws.String("prod"), NodegroupName: aws.String("workers"), Status: types.NodegroupStatusActive}, + }, + }) + if err != nil { + t.Fatalf("BuildClusterPolicyInput returned error: %v", err) + } + + if _, ok := input["cluster"].(map[string]interface{}); !ok { + t.Fatalf("input[cluster] should contain the raw cluster map") + } + + contextMap, ok := input["cluster_context"].(map[string]interface{}) + if !ok { + t.Fatalf("input[cluster_context] has unexpected type %T", input["cluster_context"]) + } + current, ok := contextMap["current"].(map[string]interface{}) + if !ok { + t.Fatalf("cluster_context[current] has unexpected type %T", contextMap["current"]) + } + if current["cluster_name"] != "prod" { + t.Fatalf("current.cluster_name = %v, want prod", current["cluster_name"]) + } + if current["authentication_mode"] != "API_AND_CONFIG_MAP" { + t.Fatalf("current.authentication_mode = %v, want API_AND_CONFIG_MAP", current["authentication_mode"]) + } + if current["secrets_encryption_configured"] != true { + t.Fatalf("current.secrets_encryption_configured = %v, want true", current["secrets_encryption_configured"]) + } + if current["related_managed_addon_count"] != float64(2) { + t.Fatalf("current.related_managed_addon_count = %v, want 2", current["related_managed_addon_count"]) + } + + activeAddons, ok := current["active_related_managed_addon_names"].([]interface{}) + if !ok { + t.Fatalf("current[active_related_managed_addon_names] has unexpected type %T", current["active_related_managed_addon_names"]) + } + if len(activeAddons) != 1 || activeAddons[0] != "vpc-cni" { + t.Fatalf("active add-ons = %v, want [vpc-cni]", activeAddons) + } +} diff --git a/internal/config.go b/internal/config.go index 9872a8c..08322a1 100644 --- a/internal/config.go +++ b/internal/config.go @@ -3,11 +3,13 @@ package internal import ( "encoding/json" "fmt" + "os" "strings" ) type PluginConfig struct { PolicyLabels map[string]string + Regions []string } func ParseConfig(raw map[string]string) (*PluginConfig, error) { @@ -19,5 +21,35 @@ func ParseConfig(raw map[string]string) (*PluginConfig, error) { } } + config.Regions = parseRegions(raw) return config, nil } + +func parseRegions(raw map[string]string) []string { + if regionsStr := strings.TrimSpace(raw["regions"]); regionsStr != "" { + parts := strings.Split(regionsStr, ",") + regions := make([]string, 0, len(parts)) + seen := make(map[string]bool) + for _, part := range parts { + region := strings.TrimSpace(part) + if region == "" || seen[region] { + continue + } + seen[region] = true + regions = append(regions, region) + } + if len(regions) > 0 { + return regions + } + } + + if region := strings.TrimSpace(raw["region"]); region != "" { + return []string{region} + } + + if region := strings.TrimSpace(os.Getenv("AWS_REGION")); region != "" { + return []string{region} + } + + return []string{"us-east-1"} +} diff --git a/internal/config_test.go b/internal/config_test.go new file mode 100644 index 0000000..f444699 --- /dev/null +++ b/internal/config_test.go @@ -0,0 +1,48 @@ +package internal + +import "testing" + +func TestParseConfigResolvesRegionsFromCommaSeparatedConfig(t *testing.T) { + t.Setenv("AWS_REGION", "us-east-1") + + config, err := ParseConfig(map[string]string{ + "regions": "eu-west-2, us-east-1, eu-west-2", + }) + if err != nil { + t.Fatalf("ParseConfig returned error: %v", err) + } + + expected := []string{"eu-west-2", "us-east-1"} + if len(config.Regions) != len(expected) { + t.Fatalf("regions = %v, want %v", config.Regions, expected) + } + for i := range expected { + if config.Regions[i] != expected[i] { + t.Fatalf("regions = %v, want %v", config.Regions, expected) + } + } +} + +func TestParseConfigFallsBackFromWhitespaceRegionToEnvironment(t *testing.T) { + t.Setenv("AWS_REGION", "eu-west-2") + + config, err := ParseConfig(map[string]string{"region": " \t "}) + if err != nil { + t.Fatalf("ParseConfig returned error: %v", err) + } + if len(config.Regions) != 1 || config.Regions[0] != "eu-west-2" { + t.Fatalf("regions = %v, want [eu-west-2]", config.Regions) + } +} + +func TestParseConfigParsesPolicyLabels(t *testing.T) { + config, err := ParseConfig(map[string]string{ + "policy_labels": `{"environment":"prod"}`, + }) + if err != nil { + t.Fatalf("ParseConfig returned error: %v", err) + } + if config.PolicyLabels["environment"] != "prod" { + t.Fatalf("policy label environment = %q, want prod", config.PolicyLabels["environment"]) + } +} diff --git a/internal/data.go b/internal/data.go deleted file mode 100644 index e8522d9..0000000 --- a/internal/data.go +++ /dev/null @@ -1,33 +0,0 @@ -package internal - -import ( - "github.com/compliance-framework/agent/runner/proto" - "github.com/hashicorp/go-hclog" -) - -type DataFetcher struct { - logger hclog.Logger - config *PluginConfig -} - -func NewDataFetcher(logger hclog.Logger, config *PluginConfig) *DataFetcher { - return &DataFetcher{ - logger: logger, - config: config, - } -} - -func (df DataFetcher) FetchData() (map[string]any, error) { - steps := make([]*proto.Step, 0) - - steps = append(steps, &proto.Step{ - Title: "Fetch some data", - Description: "Fetch some data with more details. This should be replaced with the detailed steps you undertake to fetch data in your actual plugin.", - Remarks: StringAddressed("Put any remarks here"), - }) - - return map[string]any{ - "data_key": "data_value", - "data_key_2": "data_value_2", - }, nil -} diff --git a/internal/eval.go b/internal/eval.go deleted file mode 100644 index 161a234..0000000 --- a/internal/eval.go +++ /dev/null @@ -1,67 +0,0 @@ -package internal - -import ( - "context" - "errors" - - policyManager "github.com/compliance-framework/agent/policy-manager" - "github.com/compliance-framework/agent/runner/proto" - "github.com/hashicorp/go-hclog" -) - -type PolicyEvaluator struct { - ctx context.Context - logger hclog.Logger - stepActivities []*proto.Activity -} - -func NewPolicyEvaluator(ctx context.Context, logger hclog.Logger, stepActivities []*proto.Activity) *PolicyEvaluator { - return &PolicyEvaluator{ - ctx: ctx, - logger: logger, - stepActivities: stepActivities, - } -} - -// Eval is used to run policies against the data you've collected. You could also consider an -// `EvalAndSend` by passing in the `apiHelper` that sends the observations directly to the API. -func (pe *PolicyEvaluator) Eval(ctx context.Context, input map[string]interface{}, policyPaths []string, policyData map[string]interface{}, labels map[string]string) ([]*proto.Evidence, error) { - var accumulatedErrors error - - evidences := make([]*proto.Evidence, 0) - activities := pe.stepActivities - - for _, policyPath := range policyPaths { - steps := make([]*proto.Step, 0) - steps = append(steps, &proto.Step{ - Title: "Compile policy bundle", - Description: "Using a locally addressable policy path, compile the policy files to an in memory executable.", - }) - steps = append(steps, &proto.Step{ - Title: "Execute policy bundle", - Description: "Using previously collected JSON-formatted installed OS package data, execute the compiled policies", - }) - // The Policy Manager aggregates much of the policy execution and output structuring. - processor := policyManager.NewPolicyProcessor( - pe.logger, - MergeMaps(labels, map[string]string{ - "provider": "my_plugin", - "additional_label": "value", - }), - []*proto.Subject{}, - []*proto.Component{}, - []*proto.InventoryItem{}, - []*proto.OriginActor{}, - activities, - policyData, - ) - - evidence, perr := processor.GenerateResults(ctx, policyPath, input) - evidences = append(evidences, evidence...) - if perr != nil { - accumulatedErrors = errors.Join(accumulatedErrors, perr) - } - } - - return evidences, accumulatedErrors -} diff --git a/internal/evidence.go b/internal/evidence.go new file mode 100644 index 0000000..2ab7a7e --- /dev/null +++ b/internal/evidence.go @@ -0,0 +1,177 @@ +package internal + +import ( + "fmt" + "strconv" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/compliance-framework/agent/runner/proto" +) + +func BuildClusterEvidenceContext(cluster types.Cluster, region string) ResourceEvidenceContext { + metadata := GetResourceMetadata(ResourceTypeCluster) + clusterName := aws.ToString(cluster.Name) + clusterArn := aws.ToString(cluster.Arn) + inventoryID := fmt.Sprintf("%s/%s/%s", metadata.LabelPrefix, region, clusterName) + + labels := map[string]string{ + "provider": "aws", + "type": string(ResourceTypeCluster), + "region": region, + "cluster_name": clusterName, + "cluster_arn": clusterArn, + } + + return NewResourceEvidenceContext( + labels, + resourceSubjects(metadata, inventoryID), + resourceComponents(metadata), + []*proto.InventoryItem{ + { + Identifier: inventoryID, + Type: metadata.InventoryType, + Title: fmt.Sprintf("%s [%s]", metadata.ComponentTitle, clusterName), + Description: fmt.Sprintf("Amazon EKS cluster %s in region %s.", clusterName, region), + Props: []*proto.Property{ + {Name: "cluster-name", Value: clusterName}, + {Name: "cluster-arn", Value: clusterArn}, + {Name: "region", Value: region}, + {Name: "status", Value: string(cluster.Status)}, + {Name: "version", Value: aws.ToString(cluster.Version)}, + {Name: "endpoint-public-access", Value: strconv.FormatBool(clusterEndpointPublicAccess(cluster))}, + {Name: "endpoint-private-access", Value: strconv.FormatBool(clusterEndpointPrivateAccess(cluster))}, + }, + ImplementedComponents: implementedComponents(metadata), + }, + }, + ) +} + +func BuildNodegroupEvidenceContext(nodegroup types.Nodegroup, region string) ResourceEvidenceContext { + metadata := GetResourceMetadata(ResourceTypeNodegroup) + clusterName := aws.ToString(nodegroup.ClusterName) + nodegroupName := aws.ToString(nodegroup.NodegroupName) + nodegroupArn := aws.ToString(nodegroup.NodegroupArn) + inventoryID := fmt.Sprintf("%s/%s/%s/%s", metadata.LabelPrefix, region, clusterName, nodegroupName) + + labels := map[string]string{ + "provider": "aws", + "type": string(ResourceTypeNodegroup), + "region": region, + "cluster_name": clusterName, + "nodegroup_name": nodegroupName, + "nodegroup_arn": nodegroupArn, + } + + return NewResourceEvidenceContext( + labels, + resourceSubjects(metadata, inventoryID), + resourceComponents(metadata), + []*proto.InventoryItem{ + { + Identifier: inventoryID, + Type: metadata.InventoryType, + Title: fmt.Sprintf("%s [%s/%s]", metadata.ComponentTitle, clusterName, nodegroupName), + Description: fmt.Sprintf("Amazon EKS managed node group %s for cluster %s in region %s.", nodegroupName, clusterName, region), + Props: []*proto.Property{ + {Name: "cluster-name", Value: clusterName}, + {Name: "nodegroup-name", Value: nodegroupName}, + {Name: "nodegroup-arn", Value: nodegroupArn}, + {Name: "region", Value: region}, + {Name: "status", Value: string(nodegroup.Status)}, + {Name: "version", Value: aws.ToString(nodegroup.Version)}, + {Name: "subnet-count", Value: strconv.Itoa(len(nodegroup.Subnets))}, + }, + ImplementedComponents: implementedComponents(metadata), + }, + }, + ) +} + +func BuildAddonEvidenceContext(addon types.Addon, region string) ResourceEvidenceContext { + metadata := GetResourceMetadata(ResourceTypeAddon) + clusterName := aws.ToString(addon.ClusterName) + addonName := aws.ToString(addon.AddonName) + addonArn := aws.ToString(addon.AddonArn) + inventoryID := fmt.Sprintf("%s/%s/%s/%s", metadata.LabelPrefix, region, clusterName, addonName) + + labels := map[string]string{ + "provider": "aws", + "type": string(ResourceTypeAddon), + "region": region, + "cluster_name": clusterName, + "addon_name": addonName, + "addon_arn": addonArn, + } + + return NewResourceEvidenceContext( + labels, + resourceSubjects(metadata, inventoryID), + resourceComponents(metadata), + []*proto.InventoryItem{ + { + Identifier: inventoryID, + Type: metadata.InventoryType, + Title: fmt.Sprintf("%s [%s/%s]", metadata.ComponentTitle, clusterName, addonName), + Description: fmt.Sprintf("Amazon EKS managed add-on %s for cluster %s in region %s.", addonName, clusterName, region), + Props: []*proto.Property{ + {Name: "cluster-name", Value: clusterName}, + {Name: "addon-name", Value: addonName}, + {Name: "addon-arn", Value: addonArn}, + {Name: "region", Value: region}, + {Name: "status", Value: string(addon.Status)}, + {Name: "version", Value: aws.ToString(addon.AddonVersion)}, + }, + ImplementedComponents: implementedComponents(metadata), + }, + }, + ) +} + +func resourceComponents(metadata ResourceMetadata) []*proto.Component { + return []*proto.Component{ + { + Identifier: metadata.ComponentID, + Type: metadata.ComponentType, + Title: metadata.ComponentTitle, + Description: metadata.ComponentDesc, + Purpose: metadata.ComponentPurpose, + }, + } +} + +func resourceSubjects(metadata ResourceMetadata, inventoryID string) []*proto.Subject { + return []*proto.Subject{ + { + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Identifier: metadata.ComponentID, + }, + { + Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Identifier: inventoryID, + }, + } +} + +func implementedComponents(metadata ResourceMetadata) []*proto.InventoryItemImplementedComponent { + return []*proto.InventoryItemImplementedComponent{ + { + Identifier: metadata.ComponentID, + }, + } +} + +func clusterEndpointPublicAccess(cluster types.Cluster) bool { + if cluster.ResourcesVpcConfig == nil { + return false + } + return cluster.ResourcesVpcConfig.EndpointPublicAccess +} + +func clusterEndpointPrivateAccess(cluster types.Cluster) bool { + if cluster.ResourcesVpcConfig == nil { + return false + } + return cluster.ResourcesVpcConfig.EndpointPrivateAccess +} diff --git a/internal/evidence_labels_test.go b/internal/evidence_labels_test.go new file mode 100644 index 0000000..76bf8c8 --- /dev/null +++ b/internal/evidence_labels_test.go @@ -0,0 +1,48 @@ +package internal + +import ( + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks/types" +) + +func TestEvidenceLabelKeysUseUnderscoreNotation(t *testing.T) { + contexts := []struct { + name string + labels map[string]string + }{ + { + name: "cluster", + labels: BuildClusterEvidenceContext(types.Cluster{ + Name: aws.String("prod"), + Arn: aws.String("arn:aws:eks:eu-west-2:123456789012:cluster/prod"), + }, "eu-west-2").Labels, + }, + { + name: "nodegroup", + labels: BuildNodegroupEvidenceContext(types.Nodegroup{ + ClusterName: aws.String("prod"), + NodegroupName: aws.String("workers"), + NodegroupArn: aws.String("arn:aws:eks:eu-west-2:123456789012:nodegroup/prod/workers/id"), + }, "eu-west-2").Labels, + }, + { + name: "addon", + labels: BuildAddonEvidenceContext(types.Addon{ + ClusterName: aws.String("prod"), + AddonName: aws.String("vpc-cni"), + AddonArn: aws.String("arn:aws:eks:eu-west-2:123456789012:addon/prod/vpc-cni/id"), + }, "eu-west-2").Labels, + }, + } + + for _, context := range contexts { + for key := range context.labels { + if strings.Contains(key, "-") { + t.Fatalf("%s evidence label key %q must use underscore notation", context.name, key) + } + } + } +} diff --git a/internal/nodegroup.go b/internal/nodegroup.go new file mode 100644 index 0000000..dec96f3 --- /dev/null +++ b/internal/nodegroup.go @@ -0,0 +1,140 @@ +package internal + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/compliance-framework/agent/runner/proto" +) + +func EvaluateNodegroupPolicies(deps EvaluationDependencies, policyPaths []string, nodegroups []types.Nodegroup, region string, datasets RegionDatasets) ResourceEvaluationErrors { + return EvaluateResources( + deps, + policyPaths, + nodegroups, + func(nodegroup types.Nodegroup) ResourceEvidenceContext { + return BuildNodegroupEvidenceContext(nodegroup, region) + }, + func(nodegroup types.Nodegroup) (interface{}, error) { + return BuildNodegroupPolicyInput(nodegroup, region, datasets) + }, + func(nodegroup types.Nodegroup, err error) { + deps.Logger.Error("unable to build EKS node group policy input", "cluster_name", aws.ToString(nodegroup.ClusterName), "nodegroup_name", aws.ToString(nodegroup.NodegroupName), "region", region, "error", err) + }, + func(evidences []*proto.Evidence, nodegroup types.Nodegroup) { + PrefixEvidenceTitles(evidences, NodegroupDisplayName(nodegroup)) + }, + ) +} + +func BuildNodegroupPolicyInput(nodegroup types.Nodegroup, region string, datasets RegionDatasets) (map[string]interface{}, error) { + nodegroupValue, err := ToInterfaceMap(nodegroup) + if err != nil { + return nil, err + } + + contextValue, err := ToInterfaceMap(buildNodegroupSupplementaryContext(nodegroup, region, datasets)) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "nodegroup": nodegroupValue, + "nodegroup_context": contextValue, + }, nil +} + +func buildNodegroupSupplementaryContext(nodegroup types.Nodegroup, region string, datasets RegionDatasets) map[string]interface{} { + clusterName := aws.ToString(nodegroup.ClusterName) + scaling := nodegroupScalingSummary(nodegroup) + + return map[string]interface{}{ + "current": map[string]interface{}{ + "cluster_name": clusterName, + "nodegroup_name": aws.ToString(nodegroup.NodegroupName), + "nodegroup_arn": aws.ToString(nodegroup.NodegroupArn), + "region": region, + "status": string(nodegroup.Status), + "version": aws.ToString(nodegroup.Version), + "release_version": aws.ToString(nodegroup.ReleaseVersion), + "capacity_type": string(nodegroup.CapacityType), + "ami_type": string(nodegroup.AmiType), + "health_issue_count": nodegroupHealthIssueCount(nodegroup), + "subnet_count": len(nodegroup.Subnets), + "tags_present": len(nodegroup.Tags) > 0, + "has_remote_access": nodegroup.RemoteAccess != nil, + "uses_launch_template": nodegroup.LaunchTemplate != nil, + "desired_size": scaling["desired_size"], + "min_size": scaling["min_size"], + "max_size": scaling["max_size"], + "scale_out_headroom": scaling["scale_out_headroom"], + "desired_at_or_above_min": scaling["desired_at_or_above_min"], + }, + "cluster": findClusterByName(datasets.Clusters, clusterName), + } +} + +func NodegroupDisplayName(nodegroup types.Nodegroup) string { + return aws.ToString(nodegroup.NodegroupName) +} + +func nodegroupHealthIssueCount(nodegroup types.Nodegroup) int { + if nodegroup.Health == nil { + return 0 + } + return len(nodegroup.Health.Issues) +} + +func nodegroupScalingSummary(nodegroup types.Nodegroup) map[string]interface{} { + summary := map[string]interface{}{ + "desired_size": nil, + "min_size": nil, + "max_size": nil, + "scale_out_headroom": nil, + "desired_at_or_above_min": nil, + } + if nodegroup.ScalingConfig == nil { + return summary + } + + desiredSize := aws.ToInt32(nodegroup.ScalingConfig.DesiredSize) + minSize := aws.ToInt32(nodegroup.ScalingConfig.MinSize) + maxSize := aws.ToInt32(nodegroup.ScalingConfig.MaxSize) + summary["desired_size"] = desiredSize + summary["min_size"] = minSize + summary["max_size"] = maxSize + summary["scale_out_headroom"] = maxSize - desiredSize + summary["desired_at_or_above_min"] = desiredSize >= minSize + return summary +} + +func filterNodegroupsByCluster(nodegroups []types.Nodegroup, clusterName string) []types.Nodegroup { + filtered := make([]types.Nodegroup, 0) + for _, nodegroup := range nodegroups { + if aws.ToString(nodegroup.ClusterName) == clusterName { + filtered = append(filtered, nodegroup) + } + } + return filtered +} + +func nodegroupNames(nodegroups []types.Nodegroup) []string { + names := make([]string, 0, len(nodegroups)) + for _, nodegroup := range nodegroups { + if name := aws.ToString(nodegroup.NodegroupName); name != "" { + names = append(names, name) + } + } + return names +} + +func activeNodegroupNames(nodegroups []types.Nodegroup) []string { + names := make([]string, 0, len(nodegroups)) + for _, nodegroup := range nodegroups { + if nodegroup.Status == types.NodegroupStatusActive { + if name := aws.ToString(nodegroup.NodegroupName); name != "" { + names = append(names, name) + } + } + } + return names +} diff --git a/internal/nodegroup_context_test.go b/internal/nodegroup_context_test.go new file mode 100644 index 0000000..0c5760b --- /dev/null +++ b/internal/nodegroup_context_test.go @@ -0,0 +1,63 @@ +package internal + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks/types" +) + +func TestBuildNodegroupPolicyInputIncludesNodegroupContext(t *testing.T) { + nodegroup := types.Nodegroup{ + ClusterName: aws.String("prod"), + NodegroupName: aws.String("workers"), + NodegroupArn: aws.String("arn:aws:eks:eu-west-2:123456789012:nodegroup/prod/workers/id"), + Status: types.NodegroupStatusActive, + Version: aws.String("1.30"), + ReleaseVersion: aws.String("1.30.1"), + CapacityType: types.CapacityTypesOnDemand, + AmiType: types.AMITypesAl2X8664, + Subnets: []string{"subnet-1", "subnet-2"}, + Tags: map[string]string{"Environment": "prod"}, + ScalingConfig: &types.NodegroupScalingConfig{ + MinSize: aws.Int32(2), + DesiredSize: aws.Int32(3), + MaxSize: aws.Int32(5), + }, + } + + input, err := BuildNodegroupPolicyInput(nodegroup, "eu-west-2", RegionDatasets{ + Clusters: []types.Cluster{{Name: aws.String("prod")}}, + }) + if err != nil { + t.Fatalf("BuildNodegroupPolicyInput returned error: %v", err) + } + + if _, ok := input["nodegroup"].(map[string]interface{}); !ok { + t.Fatalf("input[nodegroup] should contain the raw node group map") + } + + contextMap, ok := input["nodegroup_context"].(map[string]interface{}) + if !ok { + t.Fatalf("input[nodegroup_context] has unexpected type %T", input["nodegroup_context"]) + } + cur, ok := contextMap["current"].(map[string]interface{}) + if !ok { + t.Fatalf("nodegroup_context[current] has unexpected type %T", contextMap["current"]) + } + if cur["cluster_name"] != "prod" { + t.Fatalf("current.cluster_name = %v, want prod", cur["cluster_name"]) + } + if cur["subnet_count"] != float64(2) { + t.Fatalf("current.subnet_count = %v, want 2", cur["subnet_count"]) + } + if cur["scale_out_headroom"] != float64(2) { + t.Fatalf("current.scale_out_headroom = %v, want 2", cur["scale_out_headroom"]) + } + if cur["desired_at_or_above_min"] != true { + t.Fatalf("current.desired_at_or_above_min = %v, want true", cur["desired_at_or_above_min"]) + } + if contextMap["cluster"] == nil { + t.Fatal("nodegroup_context.cluster should include the parent cluster when available") + } +} diff --git a/internal/policy_evaluation.go b/internal/policy_evaluation.go new file mode 100644 index 0000000..fd762b0 --- /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{} + PolicyLabels map[string]string +} + +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 + } + + labels := MergeMaps(deps.PolicyLabels, resourceCtx.Labels) + evidences, err := GenerateResourceEvidences(deps.Context, deps.Logger, deps.Actors, policyPaths, input, 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/region_datasets.go b/internal/region_datasets.go new file mode 100644 index 0000000..cb9ec63 --- /dev/null +++ b/internal/region_datasets.go @@ -0,0 +1,193 @@ +package internal + +import ( + "context" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/hashicorp/go-hclog" +) + +type EKSClient interface { + ListClusters(context.Context, *eks.ListClustersInput, ...func(*eks.Options)) (*eks.ListClustersOutput, error) + DescribeCluster(context.Context, *eks.DescribeClusterInput, ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) + ListNodegroups(context.Context, *eks.ListNodegroupsInput, ...func(*eks.Options)) (*eks.ListNodegroupsOutput, error) + DescribeNodegroup(context.Context, *eks.DescribeNodegroupInput, ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) + ListAddons(context.Context, *eks.ListAddonsInput, ...func(*eks.Options)) (*eks.ListAddonsOutput, error) + DescribeAddon(context.Context, *eks.DescribeAddonInput, ...func(*eks.Options)) (*eks.DescribeAddonOutput, error) +} + +type RegionDatasets struct { + Clusters []types.Cluster + Nodegroups []types.Nodegroup + Addons []types.Addon +} + +func CollectRegionDatasets(ctx context.Context, logger hclog.Logger, client EKSClient, requiredDatasets map[string]bool) (RegionDatasets, error) { + if !requiresEKSClient(requiredDatasets) { + return RegionDatasets{}, nil + } + if client == nil { + return RegionDatasets{}, errors.New("eks client is required for requested region datasets") + } + + clusterNames, err := listClusterNames(ctx, client) + if err != nil { + logger.Error("unable to list EKS clusters", "error", err) + return RegionDatasets{}, err + } + + clusters, err := describeClusters(ctx, client, clusterNames) + if err != nil { + logger.Error("unable to describe EKS clusters", "error", err) + return RegionDatasets{}, err + } + + datasets := RegionDatasets{ + Clusters: clusters, + } + + if requiredDatasets["nodegroups"] { + datasets.Nodegroups, err = collectNodegroups(ctx, client, clusterNames) + if err != nil { + logger.Error("unable to collect EKS node groups", "error", err) + return RegionDatasets{}, err + } + } + + if requiredDatasets["addons"] { + datasets.Addons, err = collectAddons(ctx, client, clusterNames) + if err != nil { + logger.Error("unable to collect EKS add-ons", "error", err) + return RegionDatasets{}, err + } + } + + return datasets, nil +} + +func listClusterNames(ctx context.Context, client EKSClient) ([]string, error) { + names := make([]string, 0) + var nextToken *string + for { + output, err := client.ListClusters(ctx, &eks.ListClustersInput{NextToken: nextToken}) + if err != nil { + return nil, err + } + names = append(names, output.Clusters...) + if output.NextToken == nil { + return names, nil + } + nextToken = output.NextToken + } +} + +func describeClusters(ctx context.Context, client EKSClient, clusterNames []string) ([]types.Cluster, error) { + clusters := make([]types.Cluster, 0, len(clusterNames)) + for _, clusterName := range clusterNames { + output, err := client.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: aws.String(clusterName)}) + if err != nil { + return nil, fmt.Errorf("describe cluster %s: %w", clusterName, err) + } + if output.Cluster != nil { + clusters = append(clusters, *output.Cluster) + } + } + return clusters, nil +} + +func collectNodegroups(ctx context.Context, client EKSClient, clusterNames []string) ([]types.Nodegroup, error) { + nodegroups := make([]types.Nodegroup, 0) + for _, clusterName := range clusterNames { + nodegroupNames, err := listNodegroupNames(ctx, client, clusterName) + if err != nil { + return nil, err + } + for _, nodegroupName := range nodegroupNames { + output, err := client.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{ + ClusterName: aws.String(clusterName), + NodegroupName: aws.String(nodegroupName), + }) + if err != nil { + return nil, fmt.Errorf("describe node group %s/%s: %w", clusterName, nodegroupName, err) + } + if output.Nodegroup != nil { + nodegroups = append(nodegroups, *output.Nodegroup) + } + } + } + return nodegroups, nil +} + +func listNodegroupNames(ctx context.Context, client EKSClient, clusterName string) ([]string, error) { + names := make([]string, 0) + var nextToken *string + for { + output, err := client.ListNodegroups(ctx, &eks.ListNodegroupsInput{ + ClusterName: aws.String(clusterName), + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("list node groups for cluster %s: %w", clusterName, err) + } + names = append(names, output.Nodegroups...) + if output.NextToken == nil { + return names, nil + } + nextToken = output.NextToken + } +} + +func collectAddons(ctx context.Context, client EKSClient, clusterNames []string) ([]types.Addon, error) { + addons := make([]types.Addon, 0) + for _, clusterName := range clusterNames { + addonNames, err := listAddonNames(ctx, client, clusterName) + if err != nil { + return nil, err + } + for _, addonName := range addonNames { + output, err := client.DescribeAddon(ctx, &eks.DescribeAddonInput{ + ClusterName: aws.String(clusterName), + AddonName: aws.String(addonName), + }) + if err != nil { + return nil, fmt.Errorf("describe add-on %s/%s: %w", clusterName, addonName, err) + } + if output.Addon != nil { + addons = append(addons, *output.Addon) + } + } + } + return addons, nil +} + +func listAddonNames(ctx context.Context, client EKSClient, clusterName string) ([]string, error) { + names := make([]string, 0) + var nextToken *string + for { + output, err := client.ListAddons(ctx, &eks.ListAddonsInput{ + ClusterName: aws.String(clusterName), + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("list add-ons for cluster %s: %w", clusterName, err) + } + names = append(names, output.Addons...) + if output.NextToken == nil { + return names, nil + } + nextToken = output.NextToken + } +} + +func requiresEKSClient(requiredDatasets map[string]bool) bool { + for _, datasetName := range []string{"clusters", "nodegroups", "addons"} { + if requiredDatasets[datasetName] { + return true + } + } + return false +} diff --git a/internal/region_datasets_test.go b/internal/region_datasets_test.go new file mode 100644 index 0000000..d10e5da --- /dev/null +++ b/internal/region_datasets_test.go @@ -0,0 +1,161 @@ +package internal + +import ( + "context" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/hashicorp/go-hclog" +) + +func TestCollectRegionDatasetsRequiresEKSClientForEKSDatasets(t *testing.T) { + _, err := CollectRegionDatasets(context.Background(), hclog.NewNullLogger(), nil, map[string]bool{ + "clusters": true, + }) + if err == nil || !strings.Contains(err.Error(), "eks client is required") { + t.Fatalf("CollectRegionDatasets error = %v, want EKS client required error", err) + } +} + +func TestCollectRegionDatasetsAllowsNilClientWhenNoDatasetsRequired(t *testing.T) { + if _, err := CollectRegionDatasets(context.Background(), hclog.NewNullLogger(), nil, nil); err != nil { + t.Fatalf("CollectRegionDatasets returned error with no required datasets: %v", err) + } +} + +func TestCollectRegionDatasetsReusesClusterListForAddonCollection(t *testing.T) { + client := &fakeEKSClient{ + listClustersOutputs: []*eks.ListClustersOutput{ + {Clusters: []string{"prod", "stage"}}, + }, + clusters: map[string]types.Cluster{ + "prod": {Name: aws.String("prod")}, + "stage": {Name: aws.String("stage")}, + }, + addons: map[string][]types.Addon{ + "prod": {{ClusterName: aws.String("prod"), AddonName: aws.String("vpc-cni")}}, + "stage": {{ClusterName: aws.String("stage"), AddonName: aws.String("coredns")}}, + }, + } + + datasets, err := CollectRegionDatasets(context.Background(), hclog.NewNullLogger(), client, map[string]bool{ + "clusters": true, + "addons": true, + }) + if err != nil { + t.Fatalf("CollectRegionDatasets returned error: %v", err) + } + + if len(datasets.Clusters) != 2 || len(datasets.Addons) != 2 { + t.Fatalf("datasets = %+v, want 2 clusters and 2 add-ons", datasets) + } + if client.listClustersCalls != 1 { + t.Fatalf("ListClusters calls = %d, want 1", client.listClustersCalls) + } + if client.listNodegroupsCalls != 0 || client.describeNodegroupCalls != 0 { + t.Fatalf("node group calls = list:%d describe:%d, want zero", client.listNodegroupsCalls, client.describeNodegroupCalls) + } + if client.listAddonsCalls != 2 || client.describeAddonCalls != 2 { + t.Fatalf("add-on calls = list:%d describe:%d, want 2/2", client.listAddonsCalls, client.describeAddonCalls) + } +} + +func TestCollectRegionDatasetsHandlesClusterPagination(t *testing.T) { + client := &fakeEKSClient{ + listClustersOutputs: []*eks.ListClustersOutput{ + {Clusters: []string{"prod"}, NextToken: aws.String("next")}, + {Clusters: []string{"stage"}}, + }, + clusters: map[string]types.Cluster{ + "prod": {Name: aws.String("prod")}, + "stage": {Name: aws.String("stage")}, + }, + } + + datasets, err := CollectRegionDatasets(context.Background(), hclog.NewNullLogger(), client, map[string]bool{ + "clusters": true, + }) + if err != nil { + t.Fatalf("CollectRegionDatasets returned error: %v", err) + } + if len(datasets.Clusters) != 2 { + t.Fatalf("cluster count = %d, want 2", len(datasets.Clusters)) + } + if client.listClustersCalls != 2 { + t.Fatalf("ListClusters calls = %d, want 2", client.listClustersCalls) + } +} + +type fakeEKSClient struct { + listClustersOutputs []*eks.ListClustersOutput + clusters map[string]types.Cluster + nodegroups map[string][]types.Nodegroup + addons map[string][]types.Addon + + listClustersCalls int + describeClusterCalls int + listNodegroupsCalls int + describeNodegroupCalls int + listAddonsCalls int + describeAddonCalls int +} + +func (f *fakeEKSClient) ListClusters(context.Context, *eks.ListClustersInput, ...func(*eks.Options)) (*eks.ListClustersOutput, error) { + f.listClustersCalls++ + index := f.listClustersCalls - 1 + if index >= len(f.listClustersOutputs) { + return &eks.ListClustersOutput{}, nil + } + return f.listClustersOutputs[index], nil +} + +func (f *fakeEKSClient) DescribeCluster(_ context.Context, input *eks.DescribeClusterInput, _ ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) { + f.describeClusterCalls++ + cluster := f.clusters[aws.ToString(input.Name)] + return &eks.DescribeClusterOutput{Cluster: &cluster}, nil +} + +func (f *fakeEKSClient) ListNodegroups(_ context.Context, input *eks.ListNodegroupsInput, _ ...func(*eks.Options)) (*eks.ListNodegroupsOutput, error) { + f.listNodegroupsCalls++ + nodegroups := f.nodegroups[aws.ToString(input.ClusterName)] + names := make([]string, 0, len(nodegroups)) + for _, nodegroup := range nodegroups { + names = append(names, aws.ToString(nodegroup.NodegroupName)) + } + return &eks.ListNodegroupsOutput{Nodegroups: names}, nil +} + +func (f *fakeEKSClient) DescribeNodegroup(_ context.Context, input *eks.DescribeNodegroupInput, _ ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) { + f.describeNodegroupCalls++ + for _, nodegroup := range f.nodegroups[aws.ToString(input.ClusterName)] { + if aws.ToString(nodegroup.NodegroupName) == aws.ToString(input.NodegroupName) { + nodegroupCopy := nodegroup + return &eks.DescribeNodegroupOutput{Nodegroup: &nodegroupCopy}, nil + } + } + return &eks.DescribeNodegroupOutput{}, nil +} + +func (f *fakeEKSClient) ListAddons(_ context.Context, input *eks.ListAddonsInput, _ ...func(*eks.Options)) (*eks.ListAddonsOutput, error) { + f.listAddonsCalls++ + addons := f.addons[aws.ToString(input.ClusterName)] + names := make([]string, 0, len(addons)) + for _, addon := range addons { + names = append(names, aws.ToString(addon.AddonName)) + } + return &eks.ListAddonsOutput{Addons: names}, nil +} + +func (f *fakeEKSClient) DescribeAddon(_ context.Context, input *eks.DescribeAddonInput, _ ...func(*eks.Options)) (*eks.DescribeAddonOutput, error) { + f.describeAddonCalls++ + for _, addon := range f.addons[aws.ToString(input.ClusterName)] { + if aws.ToString(addon.AddonName) == aws.ToString(input.AddonName) { + addonCopy := addon + return &eks.DescribeAddonOutput{Addon: &addonCopy}, nil + } + } + return &eks.DescribeAddonOutput{}, nil +} diff --git a/internal/types.go b/internal/types.go new file mode 100644 index 0000000..dfc3e2e --- /dev/null +++ b/internal/types.go @@ -0,0 +1,60 @@ +package internal + +type ResourceType string + +const ( + ResourceTypeCluster ResourceType = "cluster" + ResourceTypeNodegroup ResourceType = "nodegroup" + ResourceTypeAddon ResourceType = "addon" +) + +type ResourceMetadata struct { + Type ResourceType + ComponentID string + ComponentTitle string + ComponentType string + ComponentDesc string + ComponentPurpose string + InventoryType string + LabelPrefix string +} + +func GetResourceMetadata(resourceType ResourceType) ResourceMetadata { + switch resourceType { + case ResourceTypeCluster: + return ResourceMetadata{ + Type: ResourceTypeCluster, + ComponentID: "common-components/amazon-eks", + ComponentTitle: "Amazon EKS", + ComponentType: "service", + ComponentDesc: "Amazon Elastic Kubernetes Service provides managed Kubernetes control planes in AWS.", + ComponentPurpose: "To provide managed Kubernetes cluster control planes for containerized workloads.", + InventoryType: "container-orchestration", + LabelPrefix: "aws-eks-cluster", + } + case ResourceTypeNodegroup: + return ResourceMetadata{ + Type: ResourceTypeNodegroup, + ComponentID: "common-components/amazon-eks-managed-node-group", + ComponentTitle: "Amazon EKS Managed Node Group", + ComponentType: "service", + ComponentDesc: "Amazon EKS managed node groups provide managed EC2 worker node lifecycle integration for EKS clusters.", + ComponentPurpose: "To provide managed compute capacity for Kubernetes workloads running on Amazon EKS.", + InventoryType: "compute", + LabelPrefix: "aws-eks-nodegroup", + } + case ResourceTypeAddon: + return ResourceMetadata{ + Type: ResourceTypeAddon, + ComponentID: "common-components/amazon-eks-addon", + ComponentTitle: "Amazon EKS Add-on", + ComponentType: "service", + ComponentDesc: "Amazon EKS add-ons provide managed operational software components for EKS clusters.", + ComponentPurpose: "To manage lifecycle and health of EKS-supported cluster add-ons.", + InventoryType: "software", + LabelPrefix: "aws-eks-addon", + } + default: + return ResourceMetadata{} + } +} diff --git a/internal/util.go b/internal/util.go index 87d29f1..480dc46 100644 --- a/internal/util.go +++ b/internal/util.go @@ -1,5 +1,7 @@ package internal +import "encoding/json" + func StringAddressed(str string) *string { return &str } @@ -13,3 +15,24 @@ func MergeMaps(maps ...map[string]string) map[string]string { } return result } + +func ResolveRegions(config *PluginConfig) []string { + if config == nil || len(config.Regions) == 0 { + return []string{"us-east-1"} + } + return config.Regions +} + +func ToInterfaceMap(value interface{}) (map[string]interface{}, error) { + content, err := json.Marshal(value) + if err != nil { + return nil, err + } + + result := make(map[string]interface{}) + if err := json.Unmarshal(content, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/main.go b/main.go index 809d491..6810051 100644 --- a/main.go +++ b/main.go @@ -2,11 +2,14 @@ package main import ( "context" + "errors" "fmt" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/eks" "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-eks/internal" "github.com/hashicorp/go-hclog" goplugin "github.com/hashicorp/go-plugin" ) @@ -17,52 +20,13 @@ type CompliancePlugin struct { 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) + parsedConfig, err := internal.ParseConfig(req.GetConfig()) if err != nil { return nil, err } l.config = parsedConfig - // Maps policy data for policy data.xx evaluation. This lets the agent set configuration for policy - // execution. Uses are for overriding values like set list of tags, approved names etc. if req.GetPolicyData() != nil { l.policyData = req.GetPolicyData().AsMap() } else { @@ -72,118 +36,176 @@ 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 runner.InitWithSubjectsAndRisksFromPolicies(ctx, l.logger, req, apiHelper, buildSubjectTemplates()) } -// 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. + if request == nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("eval request is nil") + } ctx := context.Background() - activities := make([]*proto.Activity, 0) + evalStatus := proto.ExecutionStatus_SUCCESS + var accumulatedErrors error + + policyEval := request.WithDefaultPolicyBehavior(defaultPolicyBehaviorMapping()) + policyPathsByBehavior := buildPolicyPathsByBehavior(policyEval) + requiredDatasets := buildRequiredDatasets(policyPathsByBehavior) + if len(requiredDatasets) == 0 { + l.logger.Info("No EKS policy behaviors matched policy paths") + return &proto.EvalResponse{Status: proto.ExecutionStatus_SUCCESS}, nil + } - if request == nil { - return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("eval request is nil") + deps := internal.EvaluationDependencies{ + Context: ctx, + Logger: l.logger, + ApiHelper: apiHelper, + Actors: buildOriginActors(), + PolicyData: l.policyData, + PolicyLabels: policyLabels(l.config), } - dataFetcher := internal.NewDataFetcher(l.logger, l.config) - data, err := dataFetcher.FetchData() - if err != nil { - return &proto.EvalResponse{ - Status: proto.ExecutionStatus_FAILURE, - }, fmt.Errorf("failed to fetch data: %w", err) + for _, region := range internal.ResolveRegions(l.config) { + l.logger.Info("Collecting EKS resources in region", "region", region) + + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + l.logger.Error("unable to load AWS SDK config for region", "region", region, "error", err) + evalStatus = proto.ExecutionStatus_FAILURE + accumulatedErrors = errors.Join(accumulatedErrors, err) + continue + } + + client := eks.NewFromConfig(cfg) + datasets, err := internal.CollectRegionDatasets(ctx, l.logger, client, requiredDatasets) + if err != nil { + evalStatus = proto.ExecutionStatus_FAILURE + accumulatedErrors = errors.Join(accumulatedErrors, err) + continue + } + + if clusterPolicyPaths := policyPathsByBehavior["cluster"]; len(clusterPolicyPaths) > 0 { + result := internal.EvaluateClusterPolicies(deps, clusterPolicyPaths, datasets.Clusters, region, datasets) + if fatal := applyResourceEvaluationErrors(result, &evalStatus, &accumulatedErrors); fatal != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fatal + } + } + + if nodegroupPolicyPaths := policyPathsByBehavior["nodegroup"]; len(nodegroupPolicyPaths) > 0 { + result := internal.EvaluateNodegroupPolicies(deps, nodegroupPolicyPaths, datasets.Nodegroups, region, datasets) + if fatal := applyResourceEvaluationErrors(result, &evalStatus, &accumulatedErrors); fatal != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fatal + } + } + + if addonPolicyPaths := policyPathsByBehavior["addon"]; len(addonPolicyPaths) > 0 { + result := internal.EvaluateAddonPolicies(deps, addonPolicyPaths, datasets.Addons, region, datasets) + if fatal := applyResourceEvaluationErrors(result, &evalStatus, &accumulatedErrors); fatal != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fatal + } + } + } + + return &proto.EvalResponse{Status: evalStatus}, accumulatedErrors +} + +func applyResourceEvaluationErrors(result internal.ResourceEvaluationErrors, evalStatus *proto.ExecutionStatus, accumulatedErrors *error) error { + if result.NonFatal != nil { + *accumulatedErrors = errors.Join(*accumulatedErrors, result.NonFatal) + *evalStatus = proto.ExecutionStatus_FAILURE + } + if result.InputBuildFailure { + *evalStatus = proto.ExecutionStatus_FAILURE } + return result.Fatal +} - policyEvaluator := internal.NewPolicyEvaluator(ctx, l.logger, activities) +func defaultPolicyBehaviorMapping() map[string][]string { + return map[string][]string{ + "aws-eks-policies": {"cluster"}, + "aws-eks-nodegroup-policies": {"nodegroup"}, + "aws-eks-addon-policies": {"addon"}, + } +} - // Simple use case for evaluating all policies against the data collected - evidences, err := policyEvaluator.Eval(ctx, data, request.PolicyPaths, l.policyData, l.config.PolicyLabels) +func supportedPolicyBehaviors() []string { + return []string{ + "cluster", + "nodegroup", + "addon", + } +} - // 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) +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 +} - if err != nil { - return &proto.EvalResponse{ - Status: proto.ExecutionStatus_FAILURE, - }, fmt.Errorf("failed to evaluate policies: %w", err) +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 "cluster": + markRequiredDatasets(requiredDatasets, "clusters", "addons") + case "nodegroup": + markRequiredDatasets(requiredDatasets, "clusters", "nodegroups") + case "addon": + markRequiredDatasets(requiredDatasets, "clusters", "addons") + } } + return requiredDatasets +} - if err := apiHelper.CreateEvidence(ctx, evidences); err != nil { - l.logger.Error("Error creating evidence", "error", err) - return nil, err +func markRequiredDatasets(requiredDatasets map[string]bool, datasetNames ...string) { + for _, datasetName := range datasetNames { + requiredDatasets[datasetName] = true } +} - resp := &proto.EvalResponse{ - Status: proto.ExecutionStatus_SUCCESS, +func policyLabels(config *internal.PluginConfig) map[string]string { + if config == nil { + return nil } + return config.PolicyLabels +} - return resp, nil +func buildOriginActors() []*proto.OriginActor { + return []*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 EKS Plugin", + Type: "tool", + Links: []*proto.Link{ + { + Href: "https://github.com/compliance-framework/plugin-aws-eks", + Rel: internal.StringAddressed("reference"), + Text: internal.StringAddressed("The Continuous Compliance Framework AWS EKS Plugin"), + }, + }, + }, + } } func main() { @@ -195,8 +217,7 @@ func main() { compliancePluginObj := &CompliancePlugin{ logger: logger, } - // pluginMap is the map of plugins we can dispense. - logger.Debug("initiating plugin") + logger.Debug("Initiating AWS EKS plugin") goplugin.Serve(&goplugin.ServeConfig{ HandshakeConfig: runner.HandshakeConfig, diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..47f3416 --- /dev/null +++ b/main_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestSupportedPolicyBehaviorsOnlyIncludesEksResourceFamilies(t *testing.T) { + expected := []string{"cluster", "nodegroup", "addon"} + if actual := supportedPolicyBehaviors(); !reflect.DeepEqual(actual, expected) { + t.Fatalf("supportedPolicyBehaviors() = %v, want exactly %v", actual, expected) + } +} + +func TestDefaultPolicyBehaviorMappingUsesNaturalEksBundleSplit(t *testing.T) { + mapping := defaultPolicyBehaviorMapping() + + expected := map[string][]string{ + "aws-eks-policies": {"cluster"}, + "aws-eks-nodegroup-policies": {"nodegroup"}, + "aws-eks-addon-policies": {"addon"}, + } + if !reflect.DeepEqual(mapping, expected) { + t.Fatalf("defaultPolicyBehaviorMapping() = %v, want %v", mapping, expected) + } +} + +func TestBuildRequiredDatasetsReusesSharedClusterCollection(t *testing.T) { + required := buildRequiredDatasets(map[string][]string{ + "cluster": {"/tmp/cluster-policies"}, + "nodegroup": {"/tmp/nodegroup-policies"}, + "addon": {"/tmp/addon-policies"}, + }) + + for _, dataset := range []string{"clusters", "nodegroups", "addons"} { + if !required[dataset] { + t.Fatalf("expected %s to be required", dataset) + } + } + if len(required) != 3 { + t.Fatalf("required datasets = %v, want only clusters, nodegroups, addons", required) + } +} + +func TestBuildRequiredDatasetsForClusterPoliciesIncludesAddonsForClusterContext(t *testing.T) { + required := buildRequiredDatasets(map[string][]string{ + "cluster": {"/tmp/cluster-policies"}, + }) + + for _, dataset := range []string{"clusters", "addons"} { + if !required[dataset] { + t.Fatalf("expected %s to be required for cluster policies", dataset) + } + } + if required["nodegroups"] { + t.Fatal("nodegroups should not be required for cluster policies") + } +} diff --git a/subject_templates.go b/subject_templates.go new file mode 100644 index 0000000..e469678 --- /dev/null +++ b/subject_templates.go @@ -0,0 +1,78 @@ +package main + +import "github.com/compliance-framework/agent/runner/proto" + +func buildSubjectTemplates() []*proto.SubjectTemplate { + return []*proto.SubjectTemplate{ + { + Name: "aws-eks-cluster", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: `AWS EKS cluster {{ .cluster_name }} in {{ .region }}`, + DescriptionTemplate: `Amazon EKS cluster {{ .cluster_name }} in AWS region {{ .region }}.`, + PurposeTemplate: "Represents an AWS EKS cluster evaluated for control-plane compliance posture.", + IdentityLabelKeys: []string{"provider", "region", "cluster_name"}, + SelectorLabels: selectorLabelsForType("cluster"), + LabelSchema: labelSchema( + label("provider", "Cloud provider for the evaluated resource"), + label("type", "EKS plugin resource type"), + label("region", "AWS region containing the EKS resource"), + label("cluster_name", "AWS EKS cluster name"), + label("cluster_arn", "AWS EKS cluster ARN"), + ), + }, + { + Name: "aws-eks-nodegroup", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: `AWS EKS node group {{ .nodegroup_name }} for {{ .cluster_name }} in {{ .region }}`, + DescriptionTemplate: `Amazon EKS managed node group {{ .nodegroup_name }} for cluster {{ .cluster_name }}.`, + PurposeTemplate: "Represents an AWS EKS managed node group evaluated for managed compute compliance posture.", + IdentityLabelKeys: []string{"provider", "region", "cluster_name", "nodegroup_name"}, + SelectorLabels: selectorLabelsForType("nodegroup"), + LabelSchema: labelSchema( + label("provider", "Cloud provider for the evaluated resource"), + label("type", "EKS plugin resource type"), + label("region", "AWS region containing the EKS resource"), + label("cluster_name", "AWS EKS cluster name"), + label("nodegroup_name", "AWS EKS managed node group name"), + label("nodegroup_arn", "AWS EKS managed node group ARN"), + ), + }, + { + Name: "aws-eks-addon", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: `AWS EKS add-on {{ .addon_name }} for {{ .cluster_name }} in {{ .region }}`, + DescriptionTemplate: `Amazon EKS managed add-on {{ .addon_name }} for cluster {{ .cluster_name }}.`, + PurposeTemplate: "Represents an AWS EKS managed add-on evaluated for add-on compliance posture.", + IdentityLabelKeys: []string{"provider", "region", "cluster_name", "addon_name"}, + SelectorLabels: selectorLabelsForType("addon"), + LabelSchema: labelSchema( + label("provider", "Cloud provider for the evaluated resource"), + label("type", "EKS plugin resource type"), + label("region", "AWS region containing the EKS resource"), + label("cluster_name", "AWS EKS cluster name"), + label("addon_name", "AWS EKS managed add-on name"), + label("addon_arn", "AWS EKS managed add-on ARN"), + ), + }, + } +} + +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 +} diff --git a/subject_templates_test.go b/subject_templates_test.go new file mode 100644 index 0000000..4eda819 --- /dev/null +++ b/subject_templates_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "strings" + "testing" + "text/template" + + "github.com/compliance-framework/agent/runner/proto" +) + +func TestBuildSubjectTemplatesIncludesEksResourceFamilies(t *testing.T) { + templates := buildSubjectTemplates() + if len(templates) != 3 { + t.Fatalf("expected three subject templates, got %d", len(templates)) + } + + names := map[string]bool{} + for _, template := range templates { + names[template.Name] = true + } + + for _, expected := range []string{ + "aws-eks-cluster", + "aws-eks-nodegroup", + "aws-eks-addon", + } { + if !names[expected] { + t.Fatalf("missing subject template %s", expected) + } + } +} + +func TestSubjectTemplatesHaveSelectorsAndSchemasForIdentity(t *testing.T) { + for _, subjectTemplate := range buildSubjectTemplates() { + if subjectTemplate.Type != proto.SubjectType_SUBJECT_TYPE_COMPONENT { + t.Fatalf("template %s must use component subject type", subjectTemplate.Name) + } + if len(subjectTemplate.SelectorLabels) == 0 { + t.Fatalf("template %s missing selector labels", subjectTemplate.Name) + } + if !containsSchemaKey(subjectTemplate.LabelSchema, "type") { + t.Fatalf("template %s must declare type selector in label schema", subjectTemplate.Name) + } + + for _, selector := range subjectTemplate.SelectorLabels { + if strings.Contains(selector.Key, "-") { + t.Fatalf("template %s selector key %s must use underscore notation", subjectTemplate.Name, selector.Key) + } + } + + for _, identityKey := range subjectTemplate.IdentityLabelKeys { + if strings.Contains(identityKey, "-") { + t.Fatalf("template %s identity key %s must use underscore notation", subjectTemplate.Name, identityKey) + } + if !containsSchemaKey(subjectTemplate.LabelSchema, identityKey) { + t.Fatalf("template %s identity key %s missing from label schema", subjectTemplate.Name, identityKey) + } + } + + for _, field := range subjectTemplate.LabelSchema { + if strings.Contains(field.Key, "-") { + t.Fatalf("template %s schema key %s must use underscore notation", subjectTemplate.Name, field.Key) + } + } + } +} + +func TestSubjectTemplateTitleAndDescriptionRenderWithUnderscoreLabels(t *testing.T) { + labels := map[string]string{ + "provider": "aws", + "type": "nodegroup", + "region": "eu-west-2", + "cluster_name": "prod", + "cluster_arn": "arn:aws:eks:eu-west-2:123456789012:cluster/prod", + "nodegroup_name": "workers", + "nodegroup_arn": "arn:aws:eks:eu-west-2:123456789012:nodegroup/prod/workers/id", + "addon_name": "vpc-cni", + "addon_arn": "arn:aws:eks:eu-west-2:123456789012:addon/prod/vpc-cni/id", + } + + for _, subjectTemplate := range buildSubjectTemplates() { + for fieldName, templateText := range map[string]string{ + "title": subjectTemplate.GetTitleTemplate(), + "description": subjectTemplate.GetDescriptionTemplate(), + "purpose": subjectTemplate.GetPurposeTemplate(), + } { + if rendered := renderSubjectTemplate(t, subjectTemplate.GetName(), fieldName, templateText, labels); rendered == "" { + t.Fatalf("template %s rendered empty %s", subjectTemplate.GetName(), fieldName) + } + } + } +} + +func containsSchemaKey(schema []*proto.SubjectLabelSchema, target string) bool { + for _, field := range schema { + if field.Key == target { + return true + } + } + return false +} + +func renderSubjectTemplate(t *testing.T, templateName, fieldName, templateText string, labels map[string]string) string { + t.Helper() + + parsed, err := template.New(templateName + "-" + fieldName).Option("missingkey=zero").Parse(templateText) + if err != nil { + t.Fatalf("template %s has invalid %s template: %v", templateName, fieldName, err) + } + + var buf bytes.Buffer + if err := parsed.Execute(&buf, labels); err != nil { + t.Fatalf("template %s failed to render %s template: %v", templateName, fieldName, err) + } + return buf.String() +}