diff --git a/Makefile b/Makefile index fe7b156..fefdba5 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ clean: # Cleanup build artifacts build: clean ## Build the plugin package @mkdir -p dist/ - @go build -o dist/plugin main.go + @go build -o dist/plugin . run: build ## Execute the Concom agent with the built plugin @../agent/dist/./concom agent --config ./.config/config.yaml \ No newline at end of file diff --git a/README.md b/README.md index f51a56f..8658b0f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,79 @@ -# Plugin AWS EC2 +# AWS EC2 CCF Plugin -## Assumptions +This plugin collects read-only AWS EC2, EBS, snapshot, and recovery metadata, evaluates CCF Rego policy bundles, and emits evidence back through the CCF agent. -- That the agent running this plugin has access to the classic AWS env vars in order to use SDK methods -- There is a set of policies specfically scoped to EC2 that the config in the agent is pointing to +## Supported resource families + +The collector can evaluate policies for: + +- EC2 instances +- attached security groups +- attached EBS volumes +- account-owned EBS snapshots for attached volumes +- snapshot restore permissions +- account-owned AMIs related to the instance or collected snapshots +- Fast Snapshot Restore state for collected snapshots + +## How it fits in CCF + +The CCF agent starts this binary through HashiCorp `go-plugin`, passes configuration and policy paths over gRPC, and receives generated evidence through the runner callback. This repository does not call the CCF API directly. + +During `Init`, the plugin also registers EC2 subject templates and risk templates discovered from the supplied policy bundles. + +## Default policy bundle mapping + +| Repository | Behavior | Primary input | +| --- | --- | --- | +| `plugin-aws-ec2-policies` | `ec2` | `input.instance` plus related EC2, EBS, and recovery fields | + +## Configuration + +The plugin expects: + +- AWS credentials through the default AWS SDK credential chain +- target regions from `config.regions` +- `AWS_REGION` env as a fallback when plugin config does not provide a region + +`config.regions` can contain a comma-separated list. Duplicate and empty region values are ignored. + +Any agent-supplied `policy_data` is passed through to Rego as `data.*`. + +## Data collected + +For each running, stopped, stopping, or starting EC2 instance in each configured region, the plugin can collect and correlate: + +- instance details and metadata options +- VPC and subnet identifiers +- attached security groups +- attached EBS volumes +- account-owned snapshots for attached volumes +- derived snapshot inventory, including encryption and public restore flags +- snapshot create-volume permissions +- account-owned AMIs that match the instance image or collected snapshots +- Fast Snapshot Restore entries for collected snapshots + +## Development + +Run the local test suite with: + +```shell +go test ./... +``` + +Or use the Makefile wrapper: + +```shell +make test +``` + +Build the plugin binary with: + +```shell +make build +``` + +This writes the compiled plugin to `dist/plugin`. + +## Related repositories + +- [plugin-aws-ec2-policies](https://github.com/compliance-framework/plugin-aws-ec2-policies) diff --git a/main.go b/main.go index 90cb4d3..f9a46ac 100644 --- a/main.go +++ b/main.go @@ -75,7 +75,8 @@ func (l *CompliancePlugin) Configure(req *proto.ConfigureRequest) (*proto.Config } func (l *CompliancePlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { - return &proto.InitResponse{}, nil + ctx := context.Background() + return runner.InitWithSubjectsAndRisksFromPolicies(ctx, l.logger, req, apiHelper, buildSubjectTemplates()) } func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { diff --git a/subject_templates.go b/subject_templates.go new file mode 100644 index 0000000..552f4c7 --- /dev/null +++ b/subject_templates.go @@ -0,0 +1,45 @@ +package main + +import "github.com/compliance-framework/agent/runner/proto" + +func buildSubjectTemplates() []*proto.SubjectTemplate { + return []*proto.SubjectTemplate{ + { + Name: "aws-ec2-instance", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: `AWS EC2 instance {{ index . "instance-id" }} in {{ .region }}`, + DescriptionTemplate: `Amazon EC2 instance {{ index . "instance-id" }} in subnet {{ index . "_subnet-id" }} and VPC {{ index . "_vpc-id" }} within AWS region {{ .region }}.`, + PurposeTemplate: "Represents an AWS EC2 instance evaluated for compute, network exposure, and recovery posture.", + IdentityLabelKeys: []string{"provider", "region", "instance-id"}, + SelectorLabels: selectorLabelsForType("ec2"), + LabelSchema: labelSchema( + label("provider", "Cloud provider for the evaluated resource"), + label("type", "EC2 plugin resource type"), + label("instance-id", "AWS EC2 instance identifier"), + label("_vpc-id", "AWS VPC identifier containing the instance"), + label("_subnet-id", "AWS subnet identifier containing the instance"), + label("region", "AWS region containing the instance"), + ), + }, + } +} + +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..a2f65d9 --- /dev/null +++ b/subject_templates_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "testing" + "text/template" + + "github.com/compliance-framework/agent/runner/proto" +) + +func TestBuildSubjectTemplates(t *testing.T) { + templates := buildSubjectTemplates() + if len(templates) != 1 { + t.Fatalf("expected 1 subject template, got %d", len(templates)) + } + + tpl := templates[0] + if tpl.Name != "aws-ec2-instance" { + t.Fatalf("unexpected template name: %s", tpl.Name) + } + if tpl.Type != proto.SubjectType_SUBJECT_TYPE_COMPONENT { + t.Fatalf("unexpected template type: %v", tpl.Type) + } + + expectedIdentityKeys := []string{"provider", "region", "instance-id"} + if len(tpl.IdentityLabelKeys) != len(expectedIdentityKeys) { + t.Fatalf("unexpected identity key count: %d", len(tpl.IdentityLabelKeys)) + } + for i, key := range expectedIdentityKeys { + if tpl.IdentityLabelKeys[i] != key { + t.Fatalf("unexpected identity key at %d: %s", i, tpl.IdentityLabelKeys[i]) + } + } + + if len(tpl.SelectorLabels) != 1 { + t.Fatalf("unexpected selector count: %d", len(tpl.SelectorLabels)) + } + if tpl.SelectorLabels[0].Key != "type" || tpl.SelectorLabels[0].Value != "ec2" { + t.Fatalf("unexpected selector label: %s=%s", tpl.SelectorLabels[0].Key, tpl.SelectorLabels[0].Value) + } + + expectedSchemaKeys := []string{"provider", "type", "instance-id", "_vpc-id", "_subnet-id", "region"} + if len(tpl.LabelSchema) != len(expectedSchemaKeys) { + t.Fatalf("unexpected label schema count: %d", len(tpl.LabelSchema)) + } + for i, key := range expectedSchemaKeys { + if tpl.LabelSchema[i].Key != key { + t.Fatalf("unexpected label schema key at %d: %s", i, tpl.LabelSchema[i].Key) + } + } +} + +func TestBuildSubjectTemplatesRenderCurrentLabels(t *testing.T) { + tpl := buildSubjectTemplates()[0] + labels := map[string]string{ + "provider": "aws", + "type": "ec2", + "region": "eu-west-2", + "instance-id": "i-0123456789abcdef0", + "_vpc-id": "vpc-0123456789abcdef0", + "_subnet-id": "subnet-0123456789abcdef0", + } + + title := executeTemplate(t, tpl.TitleTemplate, labels) + if title != "AWS EC2 instance i-0123456789abcdef0 in eu-west-2" { + t.Fatalf("unexpected title: %s", title) + } + + description := executeTemplate(t, tpl.DescriptionTemplate, labels) + expectedDescription := "Amazon EC2 instance i-0123456789abcdef0 in subnet subnet-0123456789abcdef0 and VPC vpc-0123456789abcdef0 within AWS region eu-west-2." + if description != expectedDescription { + t.Fatalf("unexpected description: %s", description) + } +} + +func executeTemplate(t *testing.T, source string, data map[string]string) string { + t.Helper() + + tpl, err := template.New("subject").Parse(source) + if err != nil { + t.Fatalf("parse template: %v", err) + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, data); err != nil { + t.Fatalf("execute template: %v", err) + } + + return buf.String() +}