From 949993037d421c53118e753fcb4d0ebf8d7dd71a Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 06:11:28 -0300 Subject: [PATCH 01/11] feat!: implement RunnerV2 with per-resource subjects and main/context input BREAKING CHANGE: The plugin now evaluates policies per individual Kubernetes resource instead of per cluster. The Rego input schema moved from v1 (input.clusters[...]) to v2 (input.main + input.context). Policies that read input.clusters[...] must be rewritten against the new shape. - Bump compliance-framework/agent v0.2.3 -> v0.3.2 - Implement RunnerV2: add Init that registers one SubjectTemplate per main_resources entry (identity keys cluster_name/namespace/app_name/name) via runner.InitWithSubjectsAndRisksFromPolicies - Switch plugin registration to runner.RunnerV2GRPCPlugin - Eval now iterates per main resource instance: each becomes its own subject with its own evidence; policies run once per (instance, policy_path) and evidence is batched per cluster via CreateEvidence - New config fields: - main_resources: subset of resources that produces subjects+evidence (defaults to all of resources); other types are collected as context only - identity_labels: map of identity key -> priority list of metadata.labels keys to try (default {"app_name": ["app.kubernetes.io/name", "app"]}); falls back to metadata.name when no candidate matches - Reserved policy_input keys updated to {schema_version, source, main, context} - README rewritten for v2 schema, new config fields, and subject model Co-Authored-By: Claude Opus 4.7 --- README.md | 192 ++++++++------------- config.go | 64 ++++++- config_test.go | 62 ++++++- go.mod | 11 +- go.sum | 38 +++-- main.go | 444 ++++++++++++++++++++++++++++++++++++++----------- main_test.go | 441 ++++++++++++++++++++++++++++++++++++++++-------- 7 files changed, 935 insertions(+), 317 deletions(-) diff --git a/README.md b/README.md index ac7adc0..7587fd7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A [Continuous Compliance Framework](https://compliance-framework.github.io/docs/ Supports multiple authentication methods (EKS via AWS STS, kubeconfig for any cluster) and concurrent multi-cluster collection. +> **Breaking change in this release.** The plugin now implements the `RunnerV2` interface and evaluates policies **per individual resource** rather than per cluster. The Rego input schema has moved from `v1` (`input.clusters[...]`) to `v2` (`input.main` + `input.context`). Existing policies that read `input.clusters[...]` must be rewritten. Companion policy repos (`plugin-k8s-policies`, `plugin-k8s-opres-policies`) will be updated in a coordinated follow-up. + ## Plugin Configuration The plugin receives configuration as flat string fields from the CCF agent. All structured values are JSON-encoded strings. @@ -12,10 +14,12 @@ The plugin receives configuration as flat string fields from the CCF agent. All |---|---|---| | `clusters` | Yes | JSON array of cluster connection configs | | `resources` | Yes | JSON array of Kubernetes resource types to collect (e.g. `"nodes"`, `"pods"`, `"deployments"`) | +| `main_resources` | No | JSON array — subset of `resources` that produces per-instance subjects and evidence. Defaults to all of `resources`. Types in `resources` but not in `main_resources` are collected as policy context only. | +| `identity_labels` | No | JSON object mapping identity-label key → ordered list of `metadata.labels` keys to try. Default: `{"app_name": ["app.kubernetes.io/name", "app"]}`. First match wins; falls back to `metadata.name` when none are present. | | `namespace_include` | No | JSON array of namespaces to include (empty = all) | | `namespace_exclude` | No | JSON array of namespaces to exclude | | `policy_labels` | No | JSON object of key-value labels added to evidence metadata | -| `policy_input` | No | JSON object of custom fields merged into the Rego input document | +| `policy_input` | No | JSON object of custom fields merged into the Rego input document. Reserved keys: `schema_version`, `source`, `main`, `context`. | ### Cluster Configuration @@ -31,192 +35,138 @@ Each entry in `clusters` has: | `kubeconfig` | No | kubeconfig | Path to kubeconfig file (default: `~/.kube/config`) | | `context` | No | kubeconfig | Kubeconfig context to use (default: current-context) | +## Subjects and evidence model + +During `Init`, the plugin registers one `SubjectTemplate` per entry in `main_resources` (e.g. `k8s-pods`, `k8s-nodes`). Each template has the identity label keys: + +- `cluster_name` +- `namespace` (empty for cluster-scoped resources) +- `app_name` (resolved from `metadata.labels` via `identity_labels`; falls back to `metadata.name`) +- `name` + +During `Eval`, every concrete Kubernetes resource instance that matches a `main_resources` type becomes its own subject and receives its own evidence for every configured policy path. Policies are invoked once per `(resource instance, policy path)` pair. A single `CreateEvidence` call is made per cluster, batching all evidence produced for that cluster. + ## Configuration Examples -### Single EKS Cluster +### Single EKS cluster — evidence per pod, nodes as context ```json { "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\"}]", - "resources": "[\"nodes\",\"pods\",\"deployments\"]", + "resources": "[\"nodes\",\"pods\"]", + "main_resources": "[\"pods\"]", "namespace_exclude": "[\"kube-system\",\"kube-public\"]", "policy_labels": "{\"team\":\"platform\",\"environment\":\"production\"}", "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\",\"us-east-1c\"]}" } ``` -### EKS with Cross-Account Role Assumption +### Custom identity label ```json { - "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\",\"role_arn\":\"arn:aws:iam::123456789012:role/eks-readonly\"}]", - "resources": "[\"nodes\",\"pods\"]", - "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\"]}" + "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\"}]", + "resources": "[\"pods\"]", + "identity_labels": "{\"app_name\":[\"app.company.io/service\",\"app.kubernetes.io/name\"],\"team\":[\"team.company.io/owner\"]}" } ``` -### Local Cluster via Kubeconfig (kind, minikube, k3s) +### EKS with cross-account role assumption ```json { - "clusters": "[{\"name\":\"local-dev\",\"provider\":\"kubeconfig\"}]", - "resources": "[\"pods\",\"services\",\"deployments\"]", - "namespace_include": "[\"default\",\"app\"]" + "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\",\"role_arn\":\"arn:aws:iam::123456789012:role/eks-readonly\"}]", + "resources": "[\"nodes\",\"pods\"]", + "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\"]}" } ``` -### Kubeconfig with Explicit Path and Context +### Local cluster via kubeconfig (kind, minikube, k3s) ```json { - "clusters": "[{\"name\":\"staging\",\"provider\":\"kubeconfig\",\"kubeconfig\":\"/etc/kube/staging.yaml\",\"context\":\"staging-admin\"}]", - "resources": "[\"pods\",\"nodes\",\"networkpolicies\"]" + "clusters": "[{\"name\":\"local-dev\",\"provider\":\"kubeconfig\"}]", + "resources": "[\"pods\",\"services\",\"deployments\"]", + "namespace_include": "[\"default\",\"app\"]" } ``` -### Multi-Cluster (Mixed Providers) +### Multi-cluster (mixed providers) ```json { "clusters": "[{\"name\":\"prod-east\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-east-eks\"},{\"name\":\"prod-west\",\"region\":\"us-west-2\",\"cluster_name\":\"prod-west-eks\"},{\"name\":\"dev\",\"provider\":\"kubeconfig\",\"context\":\"kind-dev\"}]", "resources": "[\"nodes\",\"pods\"]", + "main_resources": "[\"pods\"]", "namespace_exclude": "[\"kube-system\"]", "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\",\"us-west-2a\",\"us-west-2b\"]}" } ``` -## Policy Input Schema +## Policy Input Schema (v2) -The plugin builds a Rego input document from collected data and passes it to each policy bundle. Policies access it via `input`. - -### Schema +Every policy evaluation receives one `main` resource and the full cluster snapshot as `context`. ```json { - "schema_version": "v1", + "schema_version": "v2", "source": "plugin-kubernetes", - "clusters": { - "": { - "name": "string", - "region": "string", - "resources": { - "": [ - { "apiVersion": "...", "kind": "...", "metadata": {...}, "spec": {...}, ... } - ] - } + "main": { /* the single Kubernetes resource being evaluated (unstructured) */ }, + "context": { + "cluster": { "name": "prod", "region": "us-east-1", "provider": "eks" }, + "resources": { + "nodes": [ /* every collected node in this cluster */ ], + "pods": [ /* every collected pod in this cluster — includes the one in input.main */ ] } } } ``` -Any fields from `policy_input` config are merged at the top level. Reserved keys (`schema_version`, `source`, `clusters`) cannot be overridden. +Any fields from `policy_input` config are merged at the top level. Reserved keys (`schema_version`, `source`, `main`, `context`) cannot be overridden. -### Field Reference +### Field reference | Path | Type | Source | Description | |---|---|---|---| -| `input.schema_version` | string | Plugin | Always `"v1"` | +| `input.schema_version` | string | Plugin | Always `"v2"` | | `input.source` | string | Plugin | Always `"plugin-kubernetes"` | -| `input.clusters` | object | Plugin | Map of cluster name to collected data | -| `input.clusters[name].name` | string | Plugin | Cluster display name | -| `input.clusters[name].region` | string | Plugin | AWS region (empty for kubeconfig clusters) | -| `input.clusters[name].resources` | object | Plugin | Map of resource type to array of Kubernetes objects | -| `input.clusters[name].resources[type][]` | object | Plugin | Full unstructured Kubernetes resource objects | -| `input.` | any | `policy_input` | User-defined fields for policy logic | - -### Example: Full Rego Input +| `input.main` | object | Plugin | The full unstructured Kubernetes resource currently being evaluated | +| `input.context.cluster` | object | Plugin | `{name, region, provider}` for the cluster this resource belongs to | +| `input.context.resources` | object | Plugin | Map of resource type → array of Kubernetes objects (full cluster snapshot, including the main resource) | +| `input.` | any | `policy_input` | User-defined fields | -Given this config: - -```json -{ - "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\"}]", - "resources": "[\"nodes\",\"pods\"]", - "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\",\"us-east-1c\"],\"app_label\":\"app.kubernetes.io/name\"}" -} -``` - -The Rego input document will look like: - -```json -{ - "schema_version": "v1", - "source": "plugin-kubernetes", - "expected_azs": ["us-east-1a", "us-east-1b", "us-east-1c"], - "app_label": "app.kubernetes.io/name", - "clusters": { - "prod": { - "name": "prod", - "region": "us-east-1", - "resources": { - "nodes": [ - { - "apiVersion": "v1", - "kind": "Node", - "metadata": { - "name": "ip-10-0-1-100.ec2.internal", - "labels": { - "topology.kubernetes.io/zone": "us-east-1a", - "node.kubernetes.io/instance-type": "m5.xlarge" - } - }, - "spec": { - "providerID": "aws:///us-east-1a/i-0abc123" - } - } - ], - "pods": [ - { - "apiVersion": "v1", - "kind": "Pod", - "metadata": { - "name": "web-abc123", - "namespace": "default", - "labels": { - "app.kubernetes.io/name": "web" - } - }, - "spec": { - "nodeName": "ip-10-0-1-100.ec2.internal", - "containers": [ - { "name": "web", "image": "nginx:1.25" } - ] - } - } - ] - } - } - } -} -``` - -### Writing Policies Against This Input - -Policies are Rego files that produce `violation` and metadata. Example pattern: +### Example: writing a policy ```rego -package compliance_framework.my_policy +package compliance_framework.pod_has_node_binding import rego.v1 -# Access custom policy_input fields -_expected_azs := object.get(input, "expected_azs", []) +violation contains {"remarks": msg} if { + input.main.kind == "Pod" + not input.main.spec.nodeName + msg := sprintf("Pod %q has no nodeName assigned", [input.main.metadata.name]) +} -# Iterate over clusters and their resources +# Cross-reference sibling resources via input.context violation contains {"remarks": msg} if { - some cluster_name, cluster in input.clusters - some pod in object.get(object.get(cluster, "resources", {}), "pods", []) - not pod.spec.nodeName - msg := sprintf("Cluster %q: pod %q has no nodeName assigned", [cluster_name, pod.metadata.name]) + input.main.kind == "Pod" + node_name := input.main.spec.nodeName + not node_exists(node_name) + msg := sprintf("Pod %q binds to unknown node %q", [input.main.metadata.name, node_name]) } -title := "My Compliance Check" +node_exists(name) if { + some n in input.context.resources.nodes + n.metadata.name == name +} -description := sprintf("Evaluated %d cluster(s)", [count(input.clusters)]) +title := "Pod node binding" +description := "Every pod must bind to a known node in its cluster." ``` Key patterns: -- Use `object.get(obj, key, default)` for safe field access -- Iterate clusters with `some cluster_name, cluster in input.clusters` -- Access resources via `cluster.resources.` (e.g. `cluster.resources.nodes`, `cluster.resources.pods`) -- Custom `policy_input` fields are available directly on `input` (e.g. `input.expected_azs`) +- `input.main` is always the single resource under evaluation. +- `input.context.resources.` is the full cluster snapshot; iterate with `some r in ...`. +- `input.context.cluster` gives the cluster name/region/provider for labels and messaging. +- Filter out the main resource from peer checks with `r.metadata.uid != input.main.metadata.uid`. diff --git a/config.go b/config.go index 3803bc6..9165014 100644 --- a/config.go +++ b/config.go @@ -14,13 +14,23 @@ import ( var reservedInputKeys = map[string]bool{ "schema_version": true, "source": true, - "clusters": true, + "main": true, + "context": true, +} + +// defaultIdentityLabels is the fallback identity-label config used when the +// user does not supply one. app_name tries the standard Kubernetes recommended +// label first, then the legacy `app` label. +var defaultIdentityLabels = map[string][]string{ + "app_name": {"app.kubernetes.io/name", "app"}, } // PluginConfig receives string-only config from the agent gRPC interface. type PluginConfig struct { Clusters string `mapstructure:"clusters"` Resources string `mapstructure:"resources"` + MainResources string `mapstructure:"main_resources"` + IdentityLabels string `mapstructure:"identity_labels"` NamespaceInclude string `mapstructure:"namespace_include"` NamespaceExclude string `mapstructure:"namespace_exclude"` PolicyLabels string `mapstructure:"policy_labels"` @@ -31,6 +41,8 @@ type PluginConfig struct { type ParsedConfig struct { Clusters []auth.ClusterConfig Resources []string + MainResources []string + IdentityLabels map[string][]string NamespaceInclude []string NamespaceExclude []string PolicyLabels map[string]string @@ -82,10 +94,58 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { if len(resources) == 0 { return nil, errors.New("resources must not be empty") } + resourceSet := make(map[string]bool, len(resources)) for i, r := range resources { if strings.TrimSpace(r) == "" { return nil, fmt.Errorf("resource at index %d is empty", i) } + resourceSet[strings.ToLower(r)] = true + } + + // --- main_resources (optional; defaults to all resources) --- + var mainResources []string + if strings.TrimSpace(c.MainResources) != "" { + if err := json.Unmarshal([]byte(c.MainResources), &mainResources); err != nil { + return nil, fmt.Errorf("could not parse main_resources: %w", err) + } + for i, r := range mainResources { + if strings.TrimSpace(r) == "" { + return nil, fmt.Errorf("main_resources at index %d is empty", i) + } + if !resourceSet[strings.ToLower(r)] { + return nil, fmt.Errorf("main_resources entry %q is not present in resources", r) + } + } + } + if len(mainResources) == 0 { + mainResources = append([]string(nil), resources...) + } + + // --- identity_labels (optional; defaults to defaultIdentityLabels) --- + identityLabels := map[string][]string{} + if strings.TrimSpace(c.IdentityLabels) != "" { + if err := json.Unmarshal([]byte(c.IdentityLabels), &identityLabels); err != nil { + return nil, fmt.Errorf("could not parse identity_labels: %w", err) + } + for key, candidates := range identityLabels { + if strings.TrimSpace(key) == "" { + return nil, errors.New("identity_labels contains an empty key") + } + if len(candidates) == 0 { + return nil, fmt.Errorf("identity_labels key %q must have at least one candidate label", key) + } + for i, candidate := range candidates { + if strings.TrimSpace(candidate) == "" { + return nil, fmt.Errorf("identity_labels key %q has empty candidate at index %d", key, i) + } + } + } + } + if len(identityLabels) == 0 { + identityLabels = make(map[string][]string, len(defaultIdentityLabels)) + for k, v := range defaultIdentityLabels { + identityLabels[k] = append([]string(nil), v...) + } } // --- namespace_include (optional) --- @@ -128,6 +188,8 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { return &ParsedConfig{ Clusters: clusters, Resources: resources, + MainResources: mainResources, + IdentityLabels: identityLabels, NamespaceInclude: nsInclude, NamespaceExclude: nsExclude, PolicyLabels: policyLabels, diff --git a/config_test.go b/config_test.go index 7207415..dce6ece 100644 --- a/config_test.go +++ b/config_test.go @@ -27,6 +27,12 @@ func TestPluginConfigParse(t *testing.T) { if len(parsed.Resources) != 1 || parsed.Resources[0] != "nodes" { t.Fatalf("unexpected resources: %v", parsed.Resources) } + if len(parsed.MainResources) != 1 || parsed.MainResources[0] != "nodes" { + t.Fatalf("expected main_resources to default to resources, got %v", parsed.MainResources) + } + if got := parsed.IdentityLabels["app_name"]; len(got) == 0 || got[0] != "app.kubernetes.io/name" { + t.Fatalf("expected default identity_labels, got %v", parsed.IdentityLabels) + } if len(parsed.PolicyLabels) != 0 { t.Fatalf("expected empty policy labels") } @@ -180,7 +186,7 @@ func TestPluginConfigParse(t *testing.T) { }) t.Run("reserved key in policy_input rejected", func(t *testing.T) { - for _, key := range []string{"schema_version", "source", "clusters"} { + for _, key := range []string{"schema_version", "source", "main", "context"} { _, err := (&PluginConfig{ Clusters: validClusters, Resources: validResources, @@ -248,6 +254,60 @@ func TestPluginConfigParse(t *testing.T) { } }) + t.Run("main_resources subset defaults to resources", func(t *testing.T) { + cfg := &PluginConfig{Clusters: validClusters, Resources: `["nodes","pods"]`} + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(parsed.MainResources) != 2 { + t.Fatalf("expected main_resources to default to resources") + } + }) + + t.Run("main_resources subset honored", func(t *testing.T) { + cfg := &PluginConfig{Clusters: validClusters, Resources: `["nodes","pods"]`, MainResources: `["pods"]`} + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(parsed.MainResources) != 1 || parsed.MainResources[0] != "pods" { + t.Fatalf("expected MainResources=[pods], got %v", parsed.MainResources) + } + }) + + t.Run("main_resources entry not in resources rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: `["pods"]`, MainResources: `["nodes"]`}).Parse() + if err == nil || !strings.Contains(err.Error(), "not present in resources") { + t.Fatalf("expected not-present error, got: %v", err) + } + }) + + t.Run("identity_labels parsed", func(t *testing.T) { + cfg := &PluginConfig{ + Clusters: validClusters, + Resources: validResources, + IdentityLabels: `{"app_name":["custom-label"],"env":["environment"]}`, + } + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := parsed.IdentityLabels["app_name"]; len(got) != 1 || got[0] != "custom-label" { + t.Fatalf("expected app_name=[custom-label], got %v", got) + } + if _, ok := parsed.IdentityLabels["env"]; !ok { + t.Fatalf("expected env identity key") + } + }) + + t.Run("identity_labels with empty candidate list rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: validResources, IdentityLabels: `{"app_name":[]}`}).Parse() + if err == nil || !strings.Contains(err.Error(), "at least one candidate") { + t.Fatalf("expected at-least-one-candidate error, got: %v", err) + } + }) + t.Run("empty provider defaults to eks", func(t *testing.T) { cfg := &PluginConfig{ Clusters: validClusters, diff --git a/go.mod b/go.mod index cf38c3b..b62cc13 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/eks v1.81.1 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 github.com/aws/smithy-go v1.24.2 - github.com/compliance-framework/agent v0.2.3 + github.com/compliance-framework/agent v0.3.2 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/mitchellh/mapstructure v1.5.0 @@ -29,10 +29,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/compliance-framework/api v0.12.0 // indirect + github.com/compliance-framework/api v0.14.1 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/defenseunicorns/go-oscal v0.7.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -82,14 +84,15 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/grpc v1.79.2 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 451d292..b39bf8b 100644 --- a/go.sum +++ b/go.sum @@ -60,10 +60,14 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/compliance-framework/agent v0.2.3 h1:f7xFSe6qOxk5Ht5tqXRQZaFeDZX8WlrZ39WrGuZ4MPo= -github.com/compliance-framework/agent v0.2.3/go.mod h1:kAPAVruXWPqM4xwSx5Fhe+xxwCC4s4SJB9Fi7tAaYZA= -github.com/compliance-framework/api v0.12.0 h1:SnSxsKoVejJB07PJSEGCvQHFQY+BOlyIjycMusPFpCA= -github.com/compliance-framework/api v0.12.0/go.mod h1:t5wpPBlgNNr3sqXoKo3Gyg+B2Qi6dcrcawGuGAaLJUs= +github.com/compliance-framework/agent v0.3.2 h1:TBMQAiO5l1k3KB0/jUesEyfinoYVbOTJ4iiVdTcZ0qk= +github.com/compliance-framework/agent v0.3.2/go.mod h1:RM2C+FSFUZzsWrvCS0la+GKf3B8zC24vdPhfDXTnFU0= +github.com/compliance-framework/api v0.14.1 h1:Lig39GBwYpVVn8RdC94bcBKWMvt9KuCeG/k5wjUtFCU= +github.com/compliance-framework/api v0.14.1/go.mod h1:CMHwcOOCcVRf1u/n3BeqbrP09WWCuwnFAlD7dQfIWCA= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= @@ -88,8 +92,8 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= -github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -165,8 +169,6 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -254,6 +256,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= @@ -322,6 +326,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= @@ -416,12 +422,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -438,14 +444,14 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index 400692d..6ffaf1b 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,7 @@ import ( ) const ( - schemaVersionV1 = "v1" + schemaVersionV2 = "v2" sourcePluginK8s = "plugin-kubernetes" ) @@ -56,7 +56,6 @@ func (e *DefaultPolicyEvaluator) Generate( activities []*proto.Activity, data interface{}, ) ([]*proto.Evidence, error) { - e.Logger.Debug("Evaluating OPA policy", "policy_path", policyPath) processor := policyManager.NewPolicyProcessor( e.Logger, labels, @@ -66,7 +65,6 @@ func (e *DefaultPolicyEvaluator) Generate( actors, activities, ) - e.Logger.Debug("policy data", "type", fmt.Sprintf("%T", data), "data", data) return processor.GenerateResults(ctx, policyPath, data) } @@ -116,10 +114,25 @@ func (p *Plugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureRespons p.Logger.Info("Kubernetes Plugin configured", "clusters", len(parsed.Clusters), "resources", parsed.Resources, + "main_resources", parsed.MainResources, ) return &proto.ConfigureResponse{}, nil } +// Init registers one SubjectTemplate per configured main resource type and +// extracts RiskTemplates from the supplied policy bundles. +func (p *Plugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { + ctx := context.Background() + if p.parsedConfig == nil { + return nil, errors.New("plugin not configured") + } + + templates := buildSubjectTemplates(p.parsedConfig.MainResources) + p.Logger.Debug("Init: registering subject templates", "count", len(templates)) + + return runner.InitWithSubjectsAndRisksFromPolicies(ctx, p.Logger, req, apiHelper, templates) +} + func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { ctx := context.Background() @@ -130,7 +143,6 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, errors.New("no policy paths provided") } - // Collect resources from all clusters concurrently. clusterData, err := CollectAll( ctx, p.collector, @@ -144,19 +156,330 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err } - // Build the Rego input document. - regoInput := buildRegoInput(clusterData, p.parsedConfig.PolicyInput) + clusterByName := map[string]auth.ClusterConfig{} + for _, cl := range p.parsedConfig.Clusters { + clusterByName[cl.Name] = cl + } + + basePolicyLabels := map[string]string{} + maps.Copy(basePolicyLabels, p.parsedConfig.PolicyLabels) + basePolicyLabels["source"] = sourcePluginK8s + basePolicyLabels["tool"] = sourcePluginK8s + if _, exists := basePolicyLabels["provider"]; !exists { + basePolicyLabels["provider"] = inferProvider(p.parsedConfig.Clusters) + } + + actors := defaultActors() + activities := defaultActivities() + + mainResourceSet := make(map[string]bool, len(p.parsedConfig.MainResources)) + for _, r := range p.parsedConfig.MainResources { + mainResourceSet[strings.ToLower(r)] = true + } + + totalEvaluatorCalls := 0 + successfulPolicyCalls := 0 + var accumulatedErrors error + + for clusterName, cluster := range clusterData { + cfg, ok := clusterByName[clusterName] + if !ok { + cfg = auth.ClusterConfig{Name: clusterName, Region: cluster.Region} + } + + clusterComponent := buildClusterComponent(cfg) + clusterInventory := buildClusterInventory(cfg) + clusterContext := buildClusterContext(cfg, cluster) + + clusterEvidences := make([]*proto.Evidence, 0) + + for _, resourceType := range p.parsedConfig.MainResources { + items, hasItems := cluster.Resources[resourceType] + if !hasItems { + p.Logger.Debug("Eval: no items collected for main_resource", "cluster", clusterName, "resource_type", resourceType) + continue + } + + for _, item := range items { + instance := newResourceInstance(cfg, resourceType, item, p.parsedConfig.IdentityLabels) + + labels := buildInstanceLabels(basePolicyLabels, instance) + subjects := buildInstanceSubjects(instance, clusterComponent) + inventoryItems := append([]*proto.InventoryItem{buildInstanceInventory(instance)}, clusterInventory...) + components := []*proto.Component{clusterComponent} + + regoInput := buildRegoInput(item, clusterContext, p.parsedConfig.PolicyInput) + + for _, policyPath := range req.GetPolicyPaths() { + totalEvaluatorCalls++ + evidences, evalErr := p.evaluator.Generate( + ctx, + policyPath, + labels, + subjects, + components, + inventoryItems, + actors, + activities, + regoInput, + ) + clusterEvidences = append(clusterEvidences, evidences...) + if evalErr != nil { + p.Logger.Warn("Policy evaluation failed", "policy_path", policyPath, "resource", instance.Name, "namespace", instance.Namespace, "cluster", clusterName, "error", evalErr) + accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s [%s/%s/%s]: %w", policyPath, clusterName, instance.Namespace, instance.Name, evalErr)) + continue + } + successfulPolicyCalls++ + } + } + } + + if len(clusterEvidences) > 0 { + if sendErr := apiHelper.CreateEvidence(ctx, clusterEvidences); sendErr != nil { + p.Logger.Error("Error creating evidence", "cluster", clusterName, "error", sendErr) + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, sendErr + } + } + } + + if totalEvaluatorCalls > 0 && successfulPolicyCalls == 0 { + if accumulatedErrors == nil { + accumulatedErrors = errors.New("policy evaluation failed for all paths") + } + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, accumulatedErrors + } + + return &proto.EvalResponse{Status: proto.ExecutionStatus_SUCCESS}, nil +} + +// resourceInstance is the per-resource state used to build labels, subjects and inventory. +type resourceInstance struct { + ClusterName string + ResourceType string + Namespace string // empty for cluster-scoped + Name string + IdentityLabels map[string]string // derived from metadata.labels via config +} + +func newResourceInstance(cluster auth.ClusterConfig, resourceType string, resource map[string]interface{}, identityCfg map[string][]string) *resourceInstance { + namespace, name := extractResourceIdentity(resource) + return &resourceInstance{ + ClusterName: cluster.Name, + ResourceType: resourceType, + Namespace: namespace, + Name: name, + IdentityLabels: resolveIdentityLabels(resource, identityCfg), + } +} + +// extractResourceIdentity reads metadata.name and metadata.namespace from an unstructured resource. +func extractResourceIdentity(resource map[string]interface{}) (namespace, name string) { + meta, ok := resource["metadata"].(map[string]interface{}) + if !ok { + return "", "" + } + if n, ok := meta["name"].(string); ok { + name = n + } + if ns, ok := meta["namespace"].(string); ok { + namespace = ns + } + return namespace, name +} - // Build evidence metadata. - labels := map[string]string{} - maps.Copy(labels, p.parsedConfig.PolicyLabels) - labels["source"] = sourcePluginK8s - labels["tool"] = sourcePluginK8s - if _, exists := labels["provider"]; !exists { - labels["provider"] = inferProvider(p.parsedConfig.Clusters) +// resolveIdentityLabels resolves each configured identity key by walking the +// candidate list against metadata.labels on the resource; falls back to +// metadata.name when none match so the key is never empty. +func resolveIdentityLabels(resource map[string]interface{}, config map[string][]string) map[string]string { + resolved := make(map[string]string, len(config)) + var resourceName string + var metaLabels map[string]interface{} + + if meta, ok := resource["metadata"].(map[string]interface{}); ok { + if n, ok := meta["name"].(string); ok { + resourceName = n + } + if lbls, ok := meta["labels"].(map[string]interface{}); ok { + metaLabels = lbls + } } - actors := []*proto.OriginActor{ + for key, candidates := range config { + value := "" + for _, candidate := range candidates { + if metaLabels == nil { + break + } + if v, ok := metaLabels[candidate].(string); ok && v != "" { + value = v + break + } + } + if value == "" { + value = resourceName + } + resolved[key] = value + } + return resolved +} + +// buildInstanceLabels merges base policy labels with per-instance identity labels. +func buildInstanceLabels(base map[string]string, instance *resourceInstance) map[string]string { + labels := make(map[string]string, len(base)+len(instance.IdentityLabels)+4) + maps.Copy(labels, base) + labels["cluster_name"] = instance.ClusterName + labels["resource_type"] = instance.ResourceType + labels["name"] = instance.Name + if instance.Namespace != "" { + labels["namespace"] = instance.Namespace + } + // Identity labels (e.g. app_name) may override nothing — but a user-provided + // policy_labels entry for the same key stays authoritative. Merge them LAST + // only when not already set. + for k, v := range instance.IdentityLabels { + if _, exists := labels[k]; !exists { + labels[k] = v + } + } + return labels +} + +// resourceInstanceIdentifier composes the stable subject identifier for a single resource. +func resourceInstanceIdentifier(instance *resourceInstance) string { + parts := []string{ + "k8s-" + sanitizeIdentifier(instance.ResourceType), + sanitizeIdentifier(instance.ClusterName), + } + if instance.Namespace != "" { + parts = append(parts, sanitizeIdentifier(instance.Namespace)) + } + parts = append(parts, sanitizeIdentifier(instance.Name)) + return strings.Join(parts, "/") +} + +func buildInstanceSubjects(instance *resourceInstance, clusterComponent *proto.Component) []*proto.Subject { + return []*proto.Subject{ + { + Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Identifier: resourceInstanceIdentifier(instance), + }, + { + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Identifier: clusterComponent.GetIdentifier(), + }, + } +} + +func buildInstanceInventory(instance *resourceInstance) *proto.InventoryItem { + props := []*proto.Property{ + {Name: "cluster_name", Value: instance.ClusterName}, + {Name: "resource_type", Value: instance.ResourceType}, + {Name: "name", Value: instance.Name}, + } + if instance.Namespace != "" { + props = append(props, &proto.Property{Name: "namespace", Value: instance.Namespace}) + } + for k, v := range instance.IdentityLabels { + props = append(props, &proto.Property{Name: k, Value: v}) + } + + title := fmt.Sprintf("Kubernetes %s %s", instance.ResourceType, instance.Name) + if instance.Namespace != "" { + title = fmt.Sprintf("Kubernetes %s %s/%s", instance.ResourceType, instance.Namespace, instance.Name) + } + + return &proto.InventoryItem{ + Identifier: resourceInstanceIdentifier(instance), + Type: "k8s-" + strings.ToLower(instance.ResourceType), + Title: title, + Props: props, + } +} + +func buildClusterComponent(cluster auth.ClusterConfig) *proto.Component { + clusterID := fmt.Sprintf("k8s-cluster/%s", sanitizeIdentifier(cluster.Name)) + return &proto.Component{ + Identifier: clusterID, + Type: "service", + Title: fmt.Sprintf("Kubernetes Cluster: %s", cluster.Name), + Description: fmt.Sprintf("Kubernetes cluster %q in region %s", cluster.ClusterName, cluster.Region), + Purpose: "Kubernetes cluster providing resource data for compliance evaluation.", + } +} + +func buildClusterInventory(cluster auth.ClusterConfig) []*proto.InventoryItem { + clusterID := fmt.Sprintf("k8s-cluster/%s", sanitizeIdentifier(cluster.Name)) + return []*proto.InventoryItem{ + { + Identifier: clusterID, + Type: "k8s-cluster", + Title: fmt.Sprintf("Kubernetes Cluster %s", cluster.Name), + ImplementedComponents: []*proto.InventoryItemImplementedComponent{ + {Identifier: clusterID}, + }, + }, + } +} + +// buildClusterContext assembles the cluster metadata + raw resource snapshot +// exposed to policies as input.context. +func buildClusterContext(cluster auth.ClusterConfig, data *ClusterResources) map[string]interface{} { + return map[string]interface{}{ + "cluster": map[string]interface{}{ + "name": cluster.Name, + "region": cluster.Region, + "provider": cluster.EffectiveProvider(), + }, + "resources": data.Resources, + } +} + +// buildRegoInput shapes the per-resource Rego input document. +func buildRegoInput(main map[string]interface{}, clusterContext map[string]interface{}, policyInput map[string]interface{}) map[string]interface{} { + input := map[string]interface{}{ + "schema_version": schemaVersionV2, + "source": sourcePluginK8s, + "main": main, + "context": clusterContext, + } + for k, v := range policyInput { + input[k] = v + } + return input +} + +// buildSubjectTemplates produces one SubjectTemplate per main resource type. +// Namespaced types include namespace and app_name in identity; cluster-scoped +// types (best-effort by name) omit namespace. The agent treats the union of +// IdentityLabelKeys as the unique key. +func buildSubjectTemplates(mainResources []string) []*proto.SubjectTemplate { + templates := make([]*proto.SubjectTemplate, 0, len(mainResources)) + for _, resourceType := range mainResources { + templateName := "k8s-" + strings.ToLower(resourceType) + identityKeys := []string{"cluster_name", "namespace", "app_name", "name"} + labelSchema := []*proto.SubjectLabelSchema{ + {Key: "cluster_name", Description: "Name of the Kubernetes cluster this resource belongs to"}, + {Key: "namespace", Description: "Namespace of the resource (empty for cluster-scoped resources)"}, + {Key: "app_name", Description: "Application name resolved from metadata.labels via identity_labels config, falling back to metadata.name"}, + {Key: "name", Description: "Value of metadata.name on the Kubernetes resource"}, + {Key: "resource_type", Description: "Kubernetes resource type (e.g. pods, nodes, deployments)"}, + } + + templates = append(templates, &proto.SubjectTemplate{ + Name: templateName, + Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + TitleTemplate: fmt.Sprintf("Kubernetes %s {{ .namespace }}/{{ .name }} in {{ .cluster_name }}", resourceType), + DescriptionTemplate: fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }} under namespace {{ .namespace }}", resourceType, "{{ .name }}"), + PurposeTemplate: fmt.Sprintf("Individual Kubernetes %s instance evaluated by the Kubernetes plugin.", resourceType), + IdentityLabelKeys: identityKeys, + LabelSchema: labelSchema, + }) + } + return templates +} + +func defaultActors() []*proto.OriginActor { + return []*proto.OriginActor{ { Title: "The Continuous Compliance Framework", Type: "assessment-platform", @@ -180,35 +503,10 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot }, }, } +} - var clusterComponents []*proto.Component - var clusterInventory []*proto.InventoryItem - var subjects []*proto.Subject - - for _, cl := range p.parsedConfig.Clusters { - clusterID := fmt.Sprintf("k8s-cluster/%s", sanitizeIdentifier(cl.Name)) - clusterComponents = append(clusterComponents, &proto.Component{ - Identifier: clusterID, - Type: "service", - Title: fmt.Sprintf("Kubernetes Cluster: %s", cl.Name), - Description: fmt.Sprintf("Kubernetes cluster %q in region %s", cl.ClusterName, cl.Region), - Purpose: "Kubernetes cluster providing resource data for compliance evaluation.", - }) - clusterInventory = append(clusterInventory, &proto.InventoryItem{ - Identifier: clusterID, - Type: "k8s-cluster", - Title: fmt.Sprintf("Kubernetes Cluster %s", cl.Name), - ImplementedComponents: []*proto.InventoryItemImplementedComponent{ - {Identifier: clusterID}, - }, - }) - subjects = append(subjects, &proto.Subject{ - Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, - Identifier: clusterID, - }) - } - - activities := []*proto.Activity{ +func defaultActivities() []*proto.Activity { + return []*proto.Activity{ { Title: "Collect Kubernetes Cluster Resources", Steps: []*proto.Step{ @@ -220,67 +518,11 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot { Title: "Evaluate OPA Policy Bundles", Steps: []*proto.Step{ - {Title: "Build Rego Input", Description: "Combine cluster data with user-provided policy input."}, - {Title: "Evaluate Policies", Description: "Run policy bundles against the combined Rego input document."}, + {Title: "Build Rego Input", Description: "Shape per-resource Rego input with main + cluster context."}, + {Title: "Evaluate Policies", Description: "Run policy bundles against each main resource instance."}, }, }, } - - // Evaluate each policy path. - allEvidences := make([]*proto.Evidence, 0) - var accumulatedErrors error - successfulRuns := 0 - - for _, policyPath := range req.GetPolicyPaths() { - evidences, evalErr := p.evaluator.Generate( - ctx, - policyPath, - labels, - subjects, - clusterComponents, - clusterInventory, - actors, - activities, - regoInput, - ) - allEvidences = append(allEvidences, evidences...) - if evalErr != nil { - p.Logger.Warn("Policy evaluation failed", "policy_path", policyPath, "error", evalErr) - accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s: %w", policyPath, evalErr)) - continue - } - successfulRuns++ - } - - if len(allEvidences) > 0 { - if err := apiHelper.CreateEvidence(ctx, allEvidences); err != nil { - p.Logger.Error("Error creating evidence", "error", err) - return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err - } - } - - if successfulRuns == 0 && len(allEvidences) == 0 { - if accumulatedErrors == nil { - accumulatedErrors = errors.New("policy evaluation failed for all paths") - } - return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, accumulatedErrors - } - - return &proto.EvalResponse{Status: proto.ExecutionStatus_SUCCESS}, nil -} - -// buildRegoInput constructs the Rego input document from cluster data and user-provided policy input. -func buildRegoInput(clusters map[string]*ClusterResources, policyInput map[string]interface{}) map[string]interface{} { - input := map[string]interface{}{ - "schema_version": schemaVersionV1, - "source": sourcePluginK8s, - "clusters": clusters, - } - // Merge user-provided policy_input keys (reserved keys are already rejected at config time). - for k, v := range policyInput { - input[k] = v - } - return input } // inferProvider returns the provider label based on cluster configs. @@ -352,7 +594,7 @@ func main() { goplugin.Serve(&goplugin.ServeConfig{ HandshakeConfig: runner.HandshakeConfig, Plugins: map[string]goplugin.Plugin{ - "runner": &runner.RunnerGRPCPlugin{Impl: plugin}, + "runner": &runner.RunnerV2GRPCPlugin{Impl: plugin}, }, GRPCServer: goplugin.DefaultGRPCServer, }) diff --git a/main_test.go b/main_test.go index 6b0ccf6..760e4ea 100644 --- a/main_test.go +++ b/main_test.go @@ -13,10 +13,17 @@ import ( "github.com/hashicorp/go-hclog" ) +type evalCapture struct { + policyPath string + labels map[string]string + subjects []*proto.Subject + inventory []*proto.InventoryItem + data interface{} +} + type fakePolicyEvaluator struct { - calls []string - failPaths map[string]bool - labelsSeen []map[string]string + calls []evalCapture + failPaths map[string]bool } func (f *fakePolicyEvaluator) Generate( @@ -30,40 +37,84 @@ func (f *fakePolicyEvaluator) Generate( activities []*proto.Activity, data interface{}, ) ([]*proto.Evidence, error) { - f.calls = append(f.calls, policyPath) copiedLabels := map[string]string{} for k, v := range labels { copiedLabels[k] = v } - f.labelsSeen = append(f.labelsSeen, copiedLabels) + f.calls = append(f.calls, evalCapture{ + policyPath: policyPath, + labels: copiedLabels, + subjects: subjects, + inventory: inventory, + data: data, + }) if f.failPaths != nil && f.failPaths[policyPath] { return nil, errors.New("forced evaluator error") } - return []*proto.Evidence{{UUID: fmt.Sprintf("ev-%s", policyPath), Labels: labels}}, nil + return []*proto.Evidence{{UUID: fmt.Sprintf("ev-%s-%d", policyPath, len(f.calls)), Labels: copiedLabels}}, nil } type fakeAPIHelper struct { - calls int - evidence []*proto.Evidence - err error + createCalls int + evidence []*proto.Evidence + createErr error + subjectTemplates []*proto.SubjectTemplate + riskTemplatesBy map[string][]*proto.RiskTemplate } func (f *fakeAPIHelper) CreateEvidence(ctx context.Context, evidence []*proto.Evidence) error { - f.calls++ + f.createCalls++ f.evidence = append(f.evidence, evidence...) - return f.err + return f.createErr +} + +func (f *fakeAPIHelper) UpsertSubjectTemplates(ctx context.Context, templates []*proto.SubjectTemplate) error { + f.subjectTemplates = append(f.subjectTemplates, templates...) + return nil +} + +func (f *fakeAPIHelper) UpsertRiskTemplates(ctx context.Context, packageName string, templates []*proto.RiskTemplate) error { + if f.riskTemplatesBy == nil { + f.riskTemplatesBy = map[string][]*proto.RiskTemplate{} + } + f.riskTemplatesBy[packageName] = append(f.riskTemplatesBy[packageName], templates...) + return nil +} + +func newPodItem(name, namespace, appLabel string) map[string]interface{} { + meta := map[string]interface{}{ + "name": name, + "namespace": namespace, + } + if appLabel != "" { + meta["labels"] = map[string]interface{}{"app.kubernetes.io/name": appLabel} + } + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": meta, + } +} + +func newNodeItem(name string) map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "Node", + "metadata": map[string]interface{}{"name": name}, + } } func TestEvalLoopBehavior(t *testing.T) { - t.Run("successful collection and evaluation", func(t *testing.T) { + t.Run("per-resource evaluation emits one evidence per (instance, policy)", func(t *testing.T) { collector := &fakeCollector{ results: map[string]*ClusterResources{ "prod": { Name: "prod", Region: "us-east-1", Resources: map[string][]map[string]interface{}{ - "nodes": {{"metadata": map[string]interface{}{"name": "node-1"}}}, + "pods": {newPodItem("api-1", "app", "api"), newPodItem("worker-1", "app", "worker")}, + "nodes": {newNodeItem("node-1")}, }, }, }, @@ -74,10 +125,12 @@ func TestEvalLoopBehavior(t *testing.T) { plugin := &Plugin{ Logger: hclog.NewNullLogger(), parsedConfig: &ParsedConfig{ - Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, - Resources: []string{"nodes"}, - PolicyLabels: map[string]string{"team": "platform"}, - PolicyInput: map[string]interface{}{"expected_azs": []interface{}{"us-east-1a", "us-east-1b", "us-east-1c"}}, + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods", "nodes"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + PolicyLabels: map[string]string{"team": "platform"}, + PolicyInput: map[string]interface{}{"min_replicas": float64(3)}, }, collector: collector, evaluator: evaluator, @@ -90,21 +143,110 @@ func TestEvalLoopBehavior(t *testing.T) { if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { t.Fatalf("expected success, got %s", resp.GetStatus().String()) } - if len(evaluator.calls) != 2 { - t.Fatalf("expected 2 evaluator calls, got %d", len(evaluator.calls)) + // 2 pods × 2 policies + if len(evaluator.calls) != 4 { + t.Fatalf("expected 4 evaluator calls, got %d", len(evaluator.calls)) + } + if apiHelper.createCalls != 1 { + t.Fatalf("expected 1 batched CreateEvidence call per cluster, got %d", apiHelper.createCalls) + } + if len(apiHelper.evidence) != 4 { + t.Fatalf("expected 4 evidences, got %d", len(apiHelper.evidence)) + } + + // Verify labels and input shape for the first call. + first := evaluator.calls[0] + if first.labels["cluster_name"] != "prod" { + t.Fatalf("expected cluster_name=prod, got %q", first.labels["cluster_name"]) + } + if first.labels["resource_type"] != "pods" { + t.Fatalf("expected resource_type=pods, got %q", first.labels["resource_type"]) + } + if first.labels["namespace"] != "app" { + t.Fatalf("expected namespace=app, got %q", first.labels["namespace"]) + } + if first.labels["app_name"] == "" { + t.Fatalf("expected app_name resolved from label") + } + input, ok := first.data.(map[string]interface{}) + if !ok { + t.Fatalf("expected map input, got %T", first.data) + } + if input["schema_version"] != schemaVersionV2 { + t.Fatalf("expected schema_version v2") + } + if _, ok := input["main"].(map[string]interface{}); !ok { + t.Fatalf("expected input.main to be a map") + } + ctxPayload, ok := input["context"].(map[string]interface{}) + if !ok { + t.Fatalf("expected input.context to be a map") + } + if _, ok := ctxPayload["cluster"].(map[string]interface{}); !ok { + t.Fatalf("expected context.cluster map") + } + resources, ok := ctxPayload["resources"].(map[string][]map[string]interface{}) + if !ok { + t.Fatalf("expected context.resources map") + } + if len(resources["pods"]) != 2 || len(resources["nodes"]) != 1 { + t.Fatalf("expected full cluster snapshot in context, got pods=%d nodes=%d", len(resources["pods"]), len(resources["nodes"])) + } + if input["min_replicas"].(float64) != 3 { + t.Fatalf("expected policy_input merged") + } + + // Each call should have 2 subjects: the resource and the cluster. + if len(first.subjects) != 2 { + t.Fatalf("expected 2 subjects per evidence, got %d", len(first.subjects)) + } + }) + + t.Run("cluster-scoped resource has no namespace label", func(t *testing.T) { + collector := &fakeCollector{ + results: map[string]*ClusterResources{ + "prod": { + Name: "prod", + Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"nodes": {newNodeItem("n1")}}, + }, + }, + } + evaluator := &fakePolicyEvaluator{} + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"nodes"}, + MainResources: []string{"nodes"}, + IdentityLabels: defaultIdentityLabels, + }, + collector: collector, + evaluator: evaluator, + } + _, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(evaluator.calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(evaluator.calls)) } - if apiHelper.calls != 1 { - t.Fatalf("expected 1 CreateEvidence call, got %d", apiHelper.calls) + if _, ok := evaluator.calls[0].labels["namespace"]; ok { + t.Fatalf("cluster-scoped resource should not have namespace label") } - if len(apiHelper.evidence) != 2 { - t.Fatalf("expected 2 evidences, got %d", len(apiHelper.evidence)) + // Fallback: app_name should equal the node's name when no label matches. + if evaluator.calls[0].labels["app_name"] != "n1" { + t.Fatalf("expected app_name fallback to name 'n1', got %q", evaluator.calls[0].labels["app_name"]) } }) t.Run("fails when all policy evaluations fail", func(t *testing.T) { collector := &fakeCollector{ results: map[string]*ClusterResources{ - "prod": {Name: "prod", Region: "us-east-1", Resources: map[string][]map[string]interface{}{"nodes": {}}}, + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": {newPodItem("p1", "app", "api")}}, + }, }, } evaluator := &fakePolicyEvaluator{failPaths: map[string]bool{"bundle-a": true}} @@ -113,10 +255,10 @@ func TestEvalLoopBehavior(t *testing.T) { plugin := &Plugin{ Logger: hclog.NewNullLogger(), parsedConfig: &ParsedConfig{ - Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, - Resources: []string{"nodes"}, - PolicyLabels: map[string]string{}, - PolicyInput: map[string]interface{}{}, + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, }, collector: collector, evaluator: evaluator, @@ -127,31 +269,28 @@ func TestEvalLoopBehavior(t *testing.T) { t.Fatalf("expected eval failure") } if resp.GetStatus() != proto.ExecutionStatus_FAILURE { - t.Fatalf("expected failure status, got %s", resp.GetStatus().String()) + t.Fatalf("expected failure status") } - if apiHelper.calls != 0 { - t.Fatalf("expected no CreateEvidence calls, got %d", apiHelper.calls) + if apiHelper.createCalls != 0 { + t.Fatalf("expected no CreateEvidence calls, got %d", apiHelper.createCalls) } }) t.Run("collection failure returns error", func(t *testing.T) { collector := &fakeCollector{err: errors.New("auth failure")} - evaluator := &fakePolicyEvaluator{} - apiHelper := &fakeAPIHelper{} - plugin := &Plugin{ Logger: hclog.NewNullLogger(), parsedConfig: &ParsedConfig{ - Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, - Resources: []string{"nodes"}, - PolicyLabels: map[string]string{}, - PolicyInput: map[string]interface{}{}, + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, }, collector: collector, - evaluator: evaluator, + evaluator: &fakePolicyEvaluator{}, } - resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, apiHelper) + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) if err == nil { t.Fatalf("expected collection error") } @@ -163,39 +302,68 @@ func TestEvalLoopBehavior(t *testing.T) { t.Run("preserves user provider label", func(t *testing.T) { collector := &fakeCollector{ results: map[string]*ClusterResources{ - "prod": {Name: "prod", Region: "us-east-1", Resources: map[string][]map[string]interface{}{"nodes": {}}}, + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": {newPodItem("p1", "app", "svc")}}, + }, }, } evaluator := &fakePolicyEvaluator{} - apiHelper := &fakeAPIHelper{} - plugin := &Plugin{ Logger: hclog.NewNullLogger(), parsedConfig: &ParsedConfig{ - Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, - Resources: []string{"nodes"}, - PolicyLabels: map[string]string{"provider": "custom"}, - PolicyInput: map[string]interface{}{}, + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + PolicyLabels: map[string]string{"provider": "custom"}, }, collector: collector, evaluator: evaluator, } - resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, apiHelper) + _, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { - t.Fatalf("expected success") + if evaluator.calls[0].labels["provider"] != "custom" { + t.Fatalf("expected provider=custom, got %q", evaluator.calls[0].labels["provider"]) } - if len(evaluator.labelsSeen) == 0 { - t.Fatalf("expected labels to be captured") + if evaluator.calls[0].labels["source"] != sourcePluginK8s { + t.Fatalf("expected source label") } - if evaluator.labelsSeen[0]["provider"] != "custom" { - t.Fatalf("expected provider=custom, got: %s", evaluator.labelsSeen[0]["provider"]) + }) + + t.Run("skips main resource types with no collected items", func(t *testing.T) { + collector := &fakeCollector{ + results: map[string]*ClusterResources{ + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": {}}, + }, + }, } - if evaluator.labelsSeen[0]["source"] != sourcePluginK8s { - t.Fatalf("expected source label") + evaluator := &fakePolicyEvaluator{} + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + }, + collector: collector, + evaluator: evaluator, + } + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { + t.Fatalf("expected success when no main resources exist") + } + if len(evaluator.calls) != 0 { + t.Fatalf("expected 0 evaluator calls, got %d", len(evaluator.calls)) } }) @@ -225,32 +393,159 @@ func TestEvalLoopBehavior(t *testing.T) { }) } -func TestBuildRegoInput(t *testing.T) { - clusters := map[string]*ClusterResources{ - "prod": { - Name: "prod", - Region: "us-east-1", - Resources: map[string][]map[string]interface{}{ - "nodes": {{"metadata": map[string]interface{}{"name": "n1"}}}, +func TestResolveIdentityLabels(t *testing.T) { + config := map[string][]string{ + "app_name": {"app.kubernetes.io/name", "app"}, + "team": {"team"}, + } + + t.Run("picks first candidate present", func(t *testing.T) { + res := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "pod-1", + "labels": map[string]interface{}{"app.kubernetes.io/name": "api", "app": "legacy"}, }, + } + got := resolveIdentityLabels(res, config) + if got["app_name"] != "api" { + t.Fatalf("expected app_name=api, got %q", got["app_name"]) + } + if got["team"] != "pod-1" { + t.Fatalf("expected team fallback to name, got %q", got["team"]) + } + }) + + t.Run("falls back to second candidate", func(t *testing.T) { + res := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "pod-1", + "labels": map[string]interface{}{"app": "legacy"}, + }, + } + got := resolveIdentityLabels(res, config) + if got["app_name"] != "legacy" { + t.Fatalf("expected app_name=legacy, got %q", got["app_name"]) + } + }) + + t.Run("falls back to metadata.name when no labels", func(t *testing.T) { + res := map[string]interface{}{ + "metadata": map[string]interface{}{"name": "pod-1"}, + } + got := resolveIdentityLabels(res, config) + if got["app_name"] != "pod-1" { + t.Fatalf("expected app_name fallback to name, got %q", got["app_name"]) + } + }) + + t.Run("handles missing metadata", func(t *testing.T) { + got := resolveIdentityLabels(map[string]interface{}{}, config) + if got["app_name"] != "" { + t.Fatalf("expected empty app_name when no metadata, got %q", got["app_name"]) + } + }) +} + +func TestResourceInstanceIdentifier(t *testing.T) { + tests := []struct { + name string + instance *resourceInstance + want string + }{ + { + "namespaced", + &resourceInstance{ClusterName: "prod", ResourceType: "pods", Namespace: "app-ns", Name: "api-1"}, + "k8s-pods/prod/app-ns/api-1", }, + { + "cluster-scoped omits namespace segment", + &resourceInstance{ClusterName: "prod", ResourceType: "nodes", Name: "node-1"}, + "k8s-nodes/prod/node-1", + }, + { + "sanitizes noisy cluster name", + &resourceInstance{ClusterName: "Prod East", ResourceType: "pods", Namespace: "Default", Name: "API/1"}, + "k8s-pods/prod-east/default/api-1", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := resourceInstanceIdentifier(tc.instance); got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +func TestBuildSubjectTemplates(t *testing.T) { + templates := buildSubjectTemplates([]string{"pods", "nodes"}) + if len(templates) != 2 { + t.Fatalf("expected 2 templates, got %d", len(templates)) + } + byName := map[string]*proto.SubjectTemplate{} + for _, tpl := range templates { + byName[tpl.GetName()] = tpl + } + if _, ok := byName["k8s-pods"]; !ok { + t.Fatalf("missing k8s-pods template") } - policyInput := map[string]interface{}{"expected_azs": []interface{}{"us-east-1a", "us-east-1b", "us-east-1c"}} + if _, ok := byName["k8s-nodes"]; !ok { + t.Fatalf("missing k8s-nodes template") + } + keys := byName["k8s-pods"].GetIdentityLabelKeys() + wantKeys := []string{"cluster_name", "namespace", "app_name", "name"} + if len(keys) != len(wantKeys) { + t.Fatalf("expected identity keys %v, got %v", wantKeys, keys) + } +} - input := buildRegoInput(clusters, policyInput) +func TestInitUpsertsSubjectTemplates(t *testing.T) { + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{MainResources: []string{"pods", "nodes"}}, + } + api := &fakeAPIHelper{} + _, err := plugin.Init(&proto.InitRequest{}, api) + if err != nil { + t.Fatalf("unexpected Init error: %v", err) + } + if len(api.subjectTemplates) != 2 { + t.Fatalf("expected 2 subject templates upserted, got %d", len(api.subjectTemplates)) + } +} - if input["schema_version"] != schemaVersionV1 { - t.Fatalf("expected schema_version %s", schemaVersionV1) +func TestInitBeforeConfigureReturnsError(t *testing.T) { + plugin := &Plugin{Logger: hclog.NewNullLogger()} + _, err := plugin.Init(&proto.InitRequest{}, &fakeAPIHelper{}) + if err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("expected not configured error, got: %v", err) + } +} + +func TestBuildRegoInput(t *testing.T) { + main := map[string]interface{}{"metadata": map[string]interface{}{"name": "pod-1", "namespace": "app"}} + ctxPayload := map[string]interface{}{ + "cluster": map[string]interface{}{"name": "prod"}, + "resources": map[string][]map[string]interface{}{"nodes": {{"metadata": map[string]interface{}{"name": "n1"}}}}, + } + userInput := map[string]interface{}{"min_replicas": 3} + + input := buildRegoInput(main, ctxPayload, userInput) + + if input["schema_version"] != schemaVersionV2 { + t.Fatalf("expected schema_version v2") } if input["source"] != sourcePluginK8s { - t.Fatalf("expected source %s", sourcePluginK8s) + t.Fatalf("expected source") + } + if input["main"] == nil { + t.Fatalf("expected main populated") } - azs, ok := input["expected_azs"].([]interface{}) - if !ok || len(azs) != 3 { - t.Fatalf("expected expected_azs merged from policy_input") + if input["context"] == nil { + t.Fatalf("expected context populated") } - if _, ok := input["clusters"]; !ok { - t.Fatalf("expected clusters key") + if input["min_replicas"] != 3 { + t.Fatalf("expected user policy_input merged") } } From 968bceb38dd5abc65241bdad3c2653790b522fa5 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 07:06:41 -0300 Subject: [PATCH 02/11] fix: support for multi-cluster checks Signed-off-by: Gustavo Carvalho --- main.go | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 6ffaf1b..58fa90e 100644 --- a/main.go +++ b/main.go @@ -181,6 +181,8 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot successfulPolicyCalls := 0 var accumulatedErrors error + allClustersContext := buildAllClustersContext(clusterByName, clusterData) + for clusterName, cluster := range clusterData { cfg, ok := clusterByName[clusterName] if !ok { @@ -189,7 +191,6 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot clusterComponent := buildClusterComponent(cfg) clusterInventory := buildClusterInventory(cfg) - clusterContext := buildClusterContext(cfg, cluster) clusterEvidences := make([]*proto.Evidence, 0) @@ -208,7 +209,7 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot inventoryItems := append([]*proto.InventoryItem{buildInstanceInventory(instance)}, clusterInventory...) components := []*proto.Component{clusterComponent} - regoInput := buildRegoInput(item, clusterContext, p.parsedConfig.PolicyInput) + regoInput := buildRegoInput(item, allClustersContext, p.parsedConfig.PolicyInput) for _, policyPath := range req.GetPolicyPaths() { totalEvaluatorCalls++ @@ -434,6 +435,42 @@ func buildClusterContext(cluster auth.ClusterConfig, data *ClusterResources) map } } +// buildAllClustersContext aggregates all clusters' metadata and resources for multi-regional policies. +// Policies can now access data from all clusters to derive cross-cluster compliance decisions. +func buildAllClustersContext( + clusterByName map[string]auth.ClusterConfig, + clusterData map[string]*ClusterResources, +) map[string]interface{} { + clusters := make([]map[string]interface{}, 0, len(clusterData)) + allResources := make(map[string]map[string]interface{}) + + for clusterName, data := range clusterData { + cfg, ok := clusterByName[clusterName] + if !ok { + cfg = auth.ClusterConfig{Name: clusterName, Region: data.Region} + } + + clusterInfo := map[string]interface{}{ + "name": cfg.Name, + "region": cfg.Region, + "provider": cfg.EffectiveProvider(), + } + clusters = append(clusters, clusterInfo) + + for resourceType, resources := range data.Resources { + if _, exists := allResources[resourceType]; !exists { + allResources[resourceType] = make(map[string]interface{}) + } + allResources[resourceType][clusterName] = resources + } + } + + return map[string]interface{}{ + "clusters": clusters, + "resources": allResources, + } +} + // buildRegoInput shapes the per-resource Rego input document. func buildRegoInput(main map[string]interface{}, clusterContext map[string]interface{}, policyInput map[string]interface{}) map[string]interface{} { input := map[string]interface{}{ From 40b7c390726260f999deb1b8443965eafd3147e9 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 09:09:39 -0300 Subject: [PATCH 03/11] fix: fixes to make sure this works properly Signed-off-by: Gustavo Carvalho --- config.go | 2 ++ config_test.go | 6 ++-- go.mod | 15 ++++---- go.sum | 32 ++++++++--------- main.go | 97 ++++++++++++++++++++++++++++++++++---------------- main_test.go | 66 +++++++++++++++++++++++++++++++++- 6 files changed, 159 insertions(+), 59 deletions(-) diff --git a/config.go b/config.go index 9165014..d45fbbf 100644 --- a/config.go +++ b/config.go @@ -15,7 +15,9 @@ var reservedInputKeys = map[string]bool{ "schema_version": true, "source": true, "main": true, + "subject": true, "context": true, + "fleet": true, } // defaultIdentityLabels is the fallback identity-label config used when the diff --git a/config_test.go b/config_test.go index dce6ece..1957b2f 100644 --- a/config_test.go +++ b/config_test.go @@ -186,10 +186,10 @@ func TestPluginConfigParse(t *testing.T) { }) t.Run("reserved key in policy_input rejected", func(t *testing.T) { - for _, key := range []string{"schema_version", "source", "main", "context"} { + for _, key := range []string{"schema_version", "source", "main", "subject", "context", "fleet"} { _, err := (&PluginConfig{ - Clusters: validClusters, - Resources: validResources, + Clusters: validClusters, + Resources: validResources, PolicyInput: `{"` + key + `":"override"}`, }).Parse() if err == nil || !strings.Contains(err.Error(), "reserved key") { diff --git a/go.mod b/go.mod index b62cc13..c1edba6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/compliance-framework/plugin-k8s -go 1.25.8 +go 1.26.1 require ( github.com/aws/aws-sdk-go-v2 v1.41.4 @@ -9,7 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/eks v1.81.1 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 github.com/aws/smithy-go v1.24.2 - github.com/compliance-framework/agent v0.3.2 + github.com/compliance-framework/agent v0.4.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/mitchellh/mapstructure v1.5.0 @@ -29,7 +29,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/compliance-framework/api v0.14.1 // indirect + github.com/compliance-framework/api v0.15.0-rc1 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect @@ -82,15 +82,14 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index b39bf8b..ea6515f 100644 --- a/go.sum +++ b/go.sum @@ -60,10 +60,10 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/compliance-framework/agent v0.3.2 h1:TBMQAiO5l1k3KB0/jUesEyfinoYVbOTJ4iiVdTcZ0qk= -github.com/compliance-framework/agent v0.3.2/go.mod h1:RM2C+FSFUZzsWrvCS0la+GKf3B8zC24vdPhfDXTnFU0= -github.com/compliance-framework/api v0.14.1 h1:Lig39GBwYpVVn8RdC94bcBKWMvt9KuCeG/k5wjUtFCU= -github.com/compliance-framework/api v0.14.1/go.mod h1:CMHwcOOCcVRf1u/n3BeqbrP09WWCuwnFAlD7dQfIWCA= +github.com/compliance-framework/agent v0.4.0 h1:UFAgG+w5quvu3XpU8dVPryRwkfAV0lAm0i2H/ozKY1o= +github.com/compliance-framework/agent v0.4.0/go.mod h1:0SDZLOSEXX8NTZDCeuiKYnN6ZqOdWFKaBPRss2qXl+Y= +github.com/compliance-framework/api v0.15.0-rc1 h1:I9SSnMjDWr+izQXmsZSQ1FHuaLmM8hfiO+bqDIUBLd8= +github.com/compliance-framework/api v0.15.0-rc1/go.mod h1:o4up7nNysmeqYT85ZwOOfnHaoavdBRNcnf2i9IfSYZw= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -420,12 +420,12 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -438,14 +438,14 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= diff --git a/main.go b/main.go index 58fa90e..c7be3ef 100644 --- a/main.go +++ b/main.go @@ -172,16 +172,11 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot actors := defaultActors() activities := defaultActivities() - mainResourceSet := make(map[string]bool, len(p.parsedConfig.MainResources)) - for _, r := range p.parsedConfig.MainResources { - mainResourceSet[strings.ToLower(r)] = true - } - totalEvaluatorCalls := 0 successfulPolicyCalls := 0 var accumulatedErrors error - allClustersContext := buildAllClustersContext(clusterByName, clusterData) + fleetContext := buildFleetContext(clusterByName, clusterData) for clusterName, cluster := range clusterData { cfg, ok := clusterByName[clusterName] @@ -191,6 +186,7 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot clusterComponent := buildClusterComponent(cfg) clusterInventory := buildClusterInventory(cfg) + clusterContext := buildClusterContext(cfg, cluster) clusterEvidences := make([]*proto.Evidence, 0) @@ -209,7 +205,7 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot inventoryItems := append([]*proto.InventoryItem{buildInstanceInventory(instance)}, clusterInventory...) components := []*proto.Component{clusterComponent} - regoInput := buildRegoInput(item, allClustersContext, p.parsedConfig.PolicyInput) + regoInput := buildRegoInput(item, buildInputSubject(instance), clusterContext, fleetContext, p.parsedConfig.PolicyInput) for _, policyPath := range req.GetPolicyPaths() { totalEvaluatorCalls++ @@ -294,25 +290,24 @@ func extractResourceIdentity(resource map[string]interface{}) (namespace, name s func resolveIdentityLabels(resource map[string]interface{}, config map[string][]string) map[string]string { resolved := make(map[string]string, len(config)) var resourceName string - var metaLabels map[string]interface{} + labelSources := extractLabelSources(resource) if meta, ok := resource["metadata"].(map[string]interface{}); ok { if n, ok := meta["name"].(string); ok { resourceName = n } - if lbls, ok := meta["labels"].(map[string]interface{}); ok { - metaLabels = lbls - } } for key, candidates := range config { value := "" for _, candidate := range candidates { - if metaLabels == nil { - break + for _, labels := range labelSources { + if v, ok := labels[candidate].(string); ok && v != "" { + value = v + break + } } - if v, ok := metaLabels[candidate].(string); ok && v != "" { - value = v + if value != "" { break } } @@ -324,6 +319,33 @@ func resolveIdentityLabels(resource map[string]interface{}, config map[string][] return resolved } +func extractLabelSources(resource map[string]interface{}) []map[string]interface{} { + labelSources := make([]map[string]interface{}, 0, 3) + + if meta, ok := resource["metadata"].(map[string]interface{}); ok { + if labels, ok := meta["labels"].(map[string]interface{}); ok { + labelSources = append(labelSources, labels) + } + } + + if spec, ok := resource["spec"].(map[string]interface{}); ok { + if template, ok := spec["template"].(map[string]interface{}); ok { + if templateMeta, ok := template["metadata"].(map[string]interface{}); ok { + if labels, ok := templateMeta["labels"].(map[string]interface{}); ok { + labelSources = append(labelSources, labels) + } + } + } + if selector, ok := spec["selector"].(map[string]interface{}); ok { + if matchLabels, ok := selector["matchLabels"].(map[string]interface{}); ok { + labelSources = append(labelSources, matchLabels) + } + } + } + + return labelSources +} + // buildInstanceLabels merges base policy labels with per-instance identity labels. func buildInstanceLabels(base map[string]string, instance *resourceInstance) map[string]string { labels := make(map[string]string, len(base)+len(instance.IdentityLabels)+4) @@ -422,6 +444,25 @@ func buildClusterInventory(cluster auth.ClusterConfig) []*proto.InventoryItem { } } +func buildInputSubject(instance *resourceInstance) map[string]interface{} { + identityLabels := make(map[string]interface{}, len(instance.IdentityLabels)) + for k, v := range instance.IdentityLabels { + identityLabels[k] = v + } + + subject := map[string]interface{}{ + "cluster_name": instance.ClusterName, + "resource_type": instance.ResourceType, + "name": instance.Name, + "identifier": resourceInstanceIdentifier(instance), + "identity_labels": identityLabels, + } + if instance.Namespace != "" { + subject["namespace"] = instance.Namespace + } + return subject +} + // buildClusterContext assembles the cluster metadata + raw resource snapshot // exposed to policies as input.context. func buildClusterContext(cluster auth.ClusterConfig, data *ClusterResources) map[string]interface{} { @@ -435,14 +476,11 @@ func buildClusterContext(cluster auth.ClusterConfig, data *ClusterResources) map } } -// buildAllClustersContext aggregates all clusters' metadata and resources for multi-regional policies. -// Policies can now access data from all clusters to derive cross-cluster compliance decisions. -func buildAllClustersContext( +func buildFleetContext( clusterByName map[string]auth.ClusterConfig, clusterData map[string]*ClusterResources, ) map[string]interface{} { - clusters := make([]map[string]interface{}, 0, len(clusterData)) - allResources := make(map[string]map[string]interface{}) + clusters := make(map[string]interface{}, len(clusterData)) for clusterName, data := range clusterData { cfg, ok := clusterByName[clusterName] @@ -455,29 +493,26 @@ func buildAllClustersContext( "region": cfg.Region, "provider": cfg.EffectiveProvider(), } - clusters = append(clusters, clusterInfo) - - for resourceType, resources := range data.Resources { - if _, exists := allResources[resourceType]; !exists { - allResources[resourceType] = make(map[string]interface{}) - } - allResources[resourceType][clusterName] = resources + clusters[clusterName] = map[string]interface{}{ + "cluster": clusterInfo, + "resources": data.Resources, } } return map[string]interface{}{ - "clusters": clusters, - "resources": allResources, + "clusters": clusters, } } // buildRegoInput shapes the per-resource Rego input document. -func buildRegoInput(main map[string]interface{}, clusterContext map[string]interface{}, policyInput map[string]interface{}) map[string]interface{} { +func buildRegoInput(main map[string]interface{}, subject map[string]interface{}, clusterContext map[string]interface{}, fleet map[string]interface{}, policyInput map[string]interface{}) map[string]interface{} { input := map[string]interface{}{ "schema_version": schemaVersionV2, "source": sourcePluginK8s, "main": main, + "subject": subject, "context": clusterContext, + "fleet": fleet, } for k, v := range policyInput { input[k] = v @@ -504,7 +539,7 @@ func buildSubjectTemplates(mainResources []string) []*proto.SubjectTemplate { templates = append(templates, &proto.SubjectTemplate{ Name: templateName, - Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, TitleTemplate: fmt.Sprintf("Kubernetes %s {{ .namespace }}/{{ .name }} in {{ .cluster_name }}", resourceType), DescriptionTemplate: fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }} under namespace {{ .namespace }}", resourceType, "{{ .name }}"), PurposeTemplate: fmt.Sprintf("Individual Kubernetes %s instance evaluated by the Kubernetes plugin.", resourceType), diff --git a/main_test.go b/main_test.go index 760e4ea..ffda774 100644 --- a/main_test.go +++ b/main_test.go @@ -178,6 +178,16 @@ func TestEvalLoopBehavior(t *testing.T) { if _, ok := input["main"].(map[string]interface{}); !ok { t.Fatalf("expected input.main to be a map") } + subjectPayload, ok := input["subject"].(map[string]interface{}) + if !ok { + t.Fatalf("expected input.subject to be a map") + } + if subjectPayload["cluster_name"] != "prod" { + t.Fatalf("expected subject.cluster_name=prod, got %v", subjectPayload["cluster_name"]) + } + if subjectPayload["resource_type"] != "pods" { + t.Fatalf("expected subject.resource_type=pods, got %v", subjectPayload["resource_type"]) + } ctxPayload, ok := input["context"].(map[string]interface{}) if !ok { t.Fatalf("expected input.context to be a map") @@ -192,6 +202,25 @@ func TestEvalLoopBehavior(t *testing.T) { if len(resources["pods"]) != 2 || len(resources["nodes"]) != 1 { t.Fatalf("expected full cluster snapshot in context, got pods=%d nodes=%d", len(resources["pods"]), len(resources["nodes"])) } + fleetPayload, ok := input["fleet"].(map[string]interface{}) + if !ok { + t.Fatalf("expected input.fleet to be a map") + } + fleetClusters, ok := fleetPayload["clusters"].(map[string]interface{}) + if !ok { + t.Fatalf("expected fleet.clusters map") + } + prodCluster, ok := fleetClusters["prod"].(map[string]interface{}) + if !ok { + t.Fatalf("expected fleet.clusters.prod map") + } + prodResources, ok := prodCluster["resources"].(map[string][]map[string]interface{}) + if !ok { + t.Fatalf("expected fleet cluster resources map") + } + if len(prodResources["pods"]) != 2 || len(prodResources["nodes"]) != 1 { + t.Fatalf("expected fleet cluster snapshot, got pods=%d nodes=%d", len(prodResources["pods"]), len(prodResources["nodes"])) + } if input["min_replicas"].(float64) != 3 { t.Fatalf("expected policy_input merged") } @@ -438,6 +467,23 @@ func TestResolveIdentityLabels(t *testing.T) { } }) + t.Run("uses pod template labels for workload resources", func(t *testing.T) { + res := map[string]interface{}{ + "metadata": map[string]interface{}{"name": "deploy-1"}, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{"app.kubernetes.io/name": "api"}, + }, + }, + }, + } + got := resolveIdentityLabels(res, config) + if got["app_name"] != "api" { + t.Fatalf("expected app_name from pod template labels, got %q", got["app_name"]) + } + }) + t.Run("handles missing metadata", func(t *testing.T) { got := resolveIdentityLabels(map[string]interface{}{}, config) if got["app_name"] != "" { @@ -492,6 +538,9 @@ func TestBuildSubjectTemplates(t *testing.T) { if _, ok := byName["k8s-nodes"]; !ok { t.Fatalf("missing k8s-nodes template") } + if byName["k8s-pods"].GetType() != proto.SubjectType_SUBJECT_TYPE_COMPONENT { + t.Fatalf("expected k8s-pods template type component, got %v", byName["k8s-pods"].GetType()) + } keys := byName["k8s-pods"].GetIdentityLabelKeys() wantKeys := []string{"cluster_name", "namespace", "app_name", "name"} if len(keys) != len(wantKeys) { @@ -524,13 +573,22 @@ func TestInitBeforeConfigureReturnsError(t *testing.T) { func TestBuildRegoInput(t *testing.T) { main := map[string]interface{}{"metadata": map[string]interface{}{"name": "pod-1", "namespace": "app"}} + subject := map[string]interface{}{"cluster_name": "prod", "resource_type": "pods", "name": "pod-1"} ctxPayload := map[string]interface{}{ "cluster": map[string]interface{}{"name": "prod"}, "resources": map[string][]map[string]interface{}{"nodes": {{"metadata": map[string]interface{}{"name": "n1"}}}}, } + fleetPayload := map[string]interface{}{ + "clusters": map[string]interface{}{ + "prod": map[string]interface{}{ + "cluster": map[string]interface{}{"name": "prod"}, + "resources": map[string][]map[string]interface{}{"nodes": {{"metadata": map[string]interface{}{"name": "n1"}}}}, + }, + }, + } userInput := map[string]interface{}{"min_replicas": 3} - input := buildRegoInput(main, ctxPayload, userInput) + input := buildRegoInput(main, subject, ctxPayload, fleetPayload, userInput) if input["schema_version"] != schemaVersionV2 { t.Fatalf("expected schema_version v2") @@ -541,9 +599,15 @@ func TestBuildRegoInput(t *testing.T) { if input["main"] == nil { t.Fatalf("expected main populated") } + if input["subject"] == nil { + t.Fatalf("expected subject populated") + } if input["context"] == nil { t.Fatalf("expected context populated") } + if input["fleet"] == nil { + t.Fatalf("expected fleet populated") + } if input["min_replicas"] != 3 { t.Fatalf("expected user policy_input merged") } From ec0985f6fd27e516e58157d80ae07b9ff75f5e01 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 09:22:41 -0300 Subject: [PATCH 04/11] fix: copilot issues Signed-off-by: Gustavo Carvalho --- README.md | 10 +++++++--- config.go | 16 ++++++++++++---- config_test.go | 14 ++++++++++++++ main.go | 41 ++++++++++++++++++++++++++++++++--------- main_test.go | 19 ++++++++++++++++--- 5 files changed, 81 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7587fd7..de08cdb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The plugin receives configuration as flat string fields from the CCF agent. All | `namespace_include` | No | JSON array of namespaces to include (empty = all) | | `namespace_exclude` | No | JSON array of namespaces to exclude | | `policy_labels` | No | JSON object of key-value labels added to evidence metadata | -| `policy_input` | No | JSON object of custom fields merged into the Rego input document. Reserved keys: `schema_version`, `source`, `main`, `context`. | +| `policy_input` | No | JSON object of custom fields merged into the Rego input document. Reserved keys: `schema_version`, `source`, `main`, `context`, `subject`, `fleet`. | ### Cluster Configuration @@ -40,10 +40,12 @@ Each entry in `clusters` has: During `Init`, the plugin registers one `SubjectTemplate` per entry in `main_resources` (e.g. `k8s-pods`, `k8s-nodes`). Each template has the identity label keys: - `cluster_name` -- `namespace` (empty for cluster-scoped resources) +- `namespace` (only for namespaced resources) - `app_name` (resolved from `metadata.labels` via `identity_labels`; falls back to `metadata.name`) - `name` +For cluster-scoped resources such as `nodes`, the emitted evidence labels still include `namespace` with an empty string so label contracts stay stable, but the corresponding `SubjectTemplate` omits `namespace` from its identity keys and rendering. + During `Eval`, every concrete Kubernetes resource instance that matches a `main_resources` type becomes its own subject and receives its own evidence for every configured policy path. Policies are invoked once per `(resource instance, policy path)` pair. A single `CreateEvidence` call is made per cluster, batching all evidence produced for that cluster. ## Configuration Examples @@ -122,7 +124,7 @@ Every policy evaluation receives one `main` resource and the full cluster snapsh } ``` -Any fields from `policy_input` config are merged at the top level. Reserved keys (`schema_version`, `source`, `main`, `context`) cannot be overridden. +Any fields from `policy_input` config are merged at the top level. Reserved keys (`schema_version`, `source`, `main`, `context`, `subject`, `fleet`) cannot be overridden. ### Field reference @@ -131,8 +133,10 @@ Any fields from `policy_input` config are merged at the top level. Reserved keys | `input.schema_version` | string | Plugin | Always `"v2"` | | `input.source` | string | Plugin | Always `"plugin-kubernetes"` | | `input.main` | object | Plugin | The full unstructured Kubernetes resource currently being evaluated | +| `input.subject` | object | Plugin | Normalized subject identity for the resource under evaluation (`cluster_name`, `resource_type`, `name`, `identifier`, `identity_labels`, and `namespace` for namespaced resources) | | `input.context.cluster` | object | Plugin | `{name, region, provider}` for the cluster this resource belongs to | | `input.context.resources` | object | Plugin | Map of resource type → array of Kubernetes objects (full cluster snapshot, including the main resource) | +| `input.fleet` | object | Plugin | Multi-cluster snapshot keyed by cluster name, each with `{cluster, resources}` | | `input.` | any | `policy_input` | User-defined fields | ### Example: writing a policy diff --git a/config.go b/config.go index d45fbbf..7731136 100644 --- a/config.go +++ b/config.go @@ -9,6 +9,10 @@ import ( "github.com/compliance-framework/plugin-k8s/auth" ) +func normalizeResourceName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + // reservedInputKeys are top-level keys managed by the plugin that users cannot // override via policy_input. var reservedInputKeys = map[string]bool{ @@ -98,10 +102,12 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { } resourceSet := make(map[string]bool, len(resources)) for i, r := range resources { - if strings.TrimSpace(r) == "" { + normalized := normalizeResourceName(r) + if normalized == "" { return nil, fmt.Errorf("resource at index %d is empty", i) } - resourceSet[strings.ToLower(r)] = true + resources[i] = normalized + resourceSet[normalized] = true } // --- main_resources (optional; defaults to all resources) --- @@ -111,12 +117,14 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { return nil, fmt.Errorf("could not parse main_resources: %w", err) } for i, r := range mainResources { - if strings.TrimSpace(r) == "" { + normalized := normalizeResourceName(r) + if normalized == "" { return nil, fmt.Errorf("main_resources at index %d is empty", i) } - if !resourceSet[strings.ToLower(r)] { + if !resourceSet[normalized] { return nil, fmt.Errorf("main_resources entry %q is not present in resources", r) } + mainResources[i] = normalized } } if len(mainResources) == 0 { diff --git a/config_test.go b/config_test.go index 1957b2f..ecc99a2 100644 --- a/config_test.go +++ b/config_test.go @@ -157,6 +157,20 @@ func TestPluginConfigParse(t *testing.T) { } }) + t.Run("resources are trimmed and lowercased", func(t *testing.T) { + cfg := &PluginConfig{Clusters: validClusters, Resources: `[" Nodes ","PODS"]`, MainResources: `[" pods "]`} + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(parsed.Resources) != 2 || parsed.Resources[0] != "nodes" || parsed.Resources[1] != "pods" { + t.Fatalf("expected normalized resources, got %v", parsed.Resources) + } + if len(parsed.MainResources) != 1 || parsed.MainResources[0] != "pods" { + t.Fatalf("expected normalized main_resources, got %v", parsed.MainResources) + } + }) + t.Run("invalid namespace_include JSON", func(t *testing.T) { _, err := (&PluginConfig{Clusters: validClusters, Resources: validResources, NamespaceInclude: "bad"}).Parse() if err == nil || !strings.Contains(err.Error(), "could not parse namespace_include") { diff --git a/main.go b/main.go index c7be3ef..6c08d9f 100644 --- a/main.go +++ b/main.go @@ -286,7 +286,8 @@ func extractResourceIdentity(resource map[string]interface{}) (namespace, name s // resolveIdentityLabels resolves each configured identity key by walking the // candidate list against metadata.labels on the resource; falls back to -// metadata.name when none match so the key is never empty. +// metadata.name when none match. The resolved value may still be empty if +// metadata.name is missing or empty. func resolveIdentityLabels(resource map[string]interface{}, config map[string][]string) map[string]string { resolved := make(map[string]string, len(config)) var resourceName string @@ -353,9 +354,7 @@ func buildInstanceLabels(base map[string]string, instance *resourceInstance) map labels["cluster_name"] = instance.ClusterName labels["resource_type"] = instance.ResourceType labels["name"] = instance.Name - if instance.Namespace != "" { - labels["namespace"] = instance.Namespace - } + labels["namespace"] = instance.Namespace // Identity labels (e.g. app_name) may override nothing — but a user-provided // policy_labels entry for the same key stays authoritative. Merge them LAST // only when not already set. @@ -383,7 +382,7 @@ func resourceInstanceIdentifier(instance *resourceInstance) string { func buildInstanceSubjects(instance *resourceInstance, clusterComponent *proto.Component) []*proto.Subject { return []*proto.Subject{ { - Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, Identifier: resourceInstanceIdentifier(instance), }, { @@ -520,14 +519,24 @@ func buildRegoInput(main map[string]interface{}, subject map[string]interface{}, return input } +func isClusterScopedResourceType(resourceType string) bool { + switch normalizeResourceName(resourceType) { + case "nodes", "namespaces", "persistentvolumes", "clusterroles", "clusterrolebindings", "customresourcedefinitions", "mutatingwebhookconfigurations", "validatingwebhookconfigurations", "storageclasses", "runtimeclasses", "priorityclasses", "csinodes", "volumeattachments": + return true + default: + return false + } +} + // buildSubjectTemplates produces one SubjectTemplate per main resource type. // Namespaced types include namespace and app_name in identity; cluster-scoped -// types (best-effort by name) omit namespace. The agent treats the union of -// IdentityLabelKeys as the unique key. +// types omit namespace from template identity and rendering. The agent treats +// the configured IdentityLabelKeys as the unique key. func buildSubjectTemplates(mainResources []string) []*proto.SubjectTemplate { templates := make([]*proto.SubjectTemplate, 0, len(mainResources)) for _, resourceType := range mainResources { templateName := "k8s-" + strings.ToLower(resourceType) + isClusterScoped := isClusterScopedResourceType(resourceType) identityKeys := []string{"cluster_name", "namespace", "app_name", "name"} labelSchema := []*proto.SubjectLabelSchema{ {Key: "cluster_name", Description: "Name of the Kubernetes cluster this resource belongs to"}, @@ -536,12 +545,26 @@ func buildSubjectTemplates(mainResources []string) []*proto.SubjectTemplate { {Key: "name", Description: "Value of metadata.name on the Kubernetes resource"}, {Key: "resource_type", Description: "Kubernetes resource type (e.g. pods, nodes, deployments)"}, } + titleTemplate := fmt.Sprintf("Kubernetes %s {{ .namespace }}/{{ .name }} in {{ .cluster_name }}", resourceType) + descriptionTemplate := fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }} under namespace {{ .namespace }}", resourceType, "{{ .name }}") + + if isClusterScoped { + identityKeys = []string{"cluster_name", "app_name", "name"} + labelSchema = []*proto.SubjectLabelSchema{ + {Key: "cluster_name", Description: "Name of the Kubernetes cluster this resource belongs to"}, + {Key: "app_name", Description: "Application name resolved from metadata.labels via identity_labels config, falling back to metadata.name"}, + {Key: "name", Description: "Value of metadata.name on the Kubernetes resource"}, + {Key: "resource_type", Description: "Kubernetes resource type (e.g. pods, nodes, deployments)"}, + } + titleTemplate = fmt.Sprintf("Kubernetes %s {{ .name }} in {{ .cluster_name }}", resourceType) + descriptionTemplate = fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }}", resourceType, "{{ .name }}") + } templates = append(templates, &proto.SubjectTemplate{ Name: templateName, Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, - TitleTemplate: fmt.Sprintf("Kubernetes %s {{ .namespace }}/{{ .name }} in {{ .cluster_name }}", resourceType), - DescriptionTemplate: fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }} under namespace {{ .namespace }}", resourceType, "{{ .name }}"), + TitleTemplate: titleTemplate, + DescriptionTemplate: descriptionTemplate, PurposeTemplate: fmt.Sprintf("Individual Kubernetes %s instance evaluated by the Kubernetes plugin.", resourceType), IdentityLabelKeys: identityKeys, LabelSchema: labelSchema, diff --git a/main_test.go b/main_test.go index ffda774..f94eab4 100644 --- a/main_test.go +++ b/main_test.go @@ -229,9 +229,12 @@ func TestEvalLoopBehavior(t *testing.T) { if len(first.subjects) != 2 { t.Fatalf("expected 2 subjects per evidence, got %d", len(first.subjects)) } + if first.subjects[0].GetType() != proto.SubjectType_SUBJECT_TYPE_COMPONENT { + t.Fatalf("expected per-resource subject type component, got %v", first.subjects[0].GetType()) + } }) - t.Run("cluster-scoped resource has no namespace label", func(t *testing.T) { + t.Run("cluster-scoped resource has empty namespace label", func(t *testing.T) { collector := &fakeCollector{ results: map[string]*ClusterResources{ "prod": { @@ -260,8 +263,8 @@ func TestEvalLoopBehavior(t *testing.T) { if len(evaluator.calls) != 1 { t.Fatalf("expected 1 call, got %d", len(evaluator.calls)) } - if _, ok := evaluator.calls[0].labels["namespace"]; ok { - t.Fatalf("cluster-scoped resource should not have namespace label") + if evaluator.calls[0].labels["namespace"] != "" { + t.Fatalf("cluster-scoped resource should have empty namespace label, got %q", evaluator.calls[0].labels["namespace"]) } // Fallback: app_name should equal the node's name when no label matches. if evaluator.calls[0].labels["app_name"] != "n1" { @@ -546,6 +549,16 @@ func TestBuildSubjectTemplates(t *testing.T) { if len(keys) != len(wantKeys) { t.Fatalf("expected identity keys %v, got %v", wantKeys, keys) } + nodeKeys := byName["k8s-nodes"].GetIdentityLabelKeys() + wantNodeKeys := []string{"cluster_name", "app_name", "name"} + if len(nodeKeys) != len(wantNodeKeys) { + t.Fatalf("expected node identity keys %v, got %v", wantNodeKeys, nodeKeys) + } + for i := range wantNodeKeys { + if nodeKeys[i] != wantNodeKeys[i] { + t.Fatalf("expected node identity keys %v, got %v", wantNodeKeys, nodeKeys) + } + } } func TestInitUpsertsSubjectTemplates(t *testing.T) { From 52e4082adddfd6d8bec0c0fda00b8e7db02dad5d Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 09:40:43 -0300 Subject: [PATCH 05/11] fix: copilot issues Signed-off-by: Gustavo Carvalho --- config.go | 25 +++++++++++++++++++++---- config_test.go | 19 ++++++++++++++++++- main.go | 1 + main_test.go | 12 ++++++------ 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/config.go b/config.go index 7731136..846221e 100644 --- a/config.go +++ b/config.go @@ -106,6 +106,9 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { if normalized == "" { return nil, fmt.Errorf("resource at index %d is empty", i) } + if resourceSet[normalized] { + return nil, fmt.Errorf("resources contains duplicate entry %q after normalization", normalized) + } resources[i] = normalized resourceSet[normalized] = true } @@ -113,6 +116,7 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { // --- main_resources (optional; defaults to all resources) --- var mainResources []string if strings.TrimSpace(c.MainResources) != "" { + mainResourceSet := map[string]bool{} if err := json.Unmarshal([]byte(c.MainResources), &mainResources); err != nil { return nil, fmt.Errorf("could not parse main_resources: %w", err) } @@ -124,7 +128,11 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { if !resourceSet[normalized] { return nil, fmt.Errorf("main_resources entry %q is not present in resources", r) } + if mainResourceSet[normalized] { + return nil, fmt.Errorf("main_resources contains duplicate entry %q after normalization", normalized) + } mainResources[i] = normalized + mainResourceSet[normalized] = true } } if len(mainResources) == 0 { @@ -137,19 +145,28 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { if err := json.Unmarshal([]byte(c.IdentityLabels), &identityLabels); err != nil { return nil, fmt.Errorf("could not parse identity_labels: %w", err) } + normalizedIdentityLabels := make(map[string][]string, len(identityLabels)) for key, candidates := range identityLabels { - if strings.TrimSpace(key) == "" { + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { return nil, errors.New("identity_labels contains an empty key") } + if _, exists := normalizedIdentityLabels[trimmedKey]; exists { + return nil, fmt.Errorf("identity_labels contains duplicate key %q after trimming", trimmedKey) + } if len(candidates) == 0 { - return nil, fmt.Errorf("identity_labels key %q must have at least one candidate label", key) + return nil, fmt.Errorf("identity_labels key %q must have at least one candidate label", trimmedKey) } for i, candidate := range candidates { - if strings.TrimSpace(candidate) == "" { - return nil, fmt.Errorf("identity_labels key %q has empty candidate at index %d", key, i) + trimmedCandidate := strings.TrimSpace(candidate) + if trimmedCandidate == "" { + return nil, fmt.Errorf("identity_labels key %q has empty candidate at index %d", trimmedKey, i) } + candidates[i] = trimmedCandidate } + normalizedIdentityLabels[trimmedKey] = candidates } + identityLabels = normalizedIdentityLabels } if len(identityLabels) == 0 { identityLabels = make(map[string][]string, len(defaultIdentityLabels)) diff --git a/config_test.go b/config_test.go index ecc99a2..40c901c 100644 --- a/config_test.go +++ b/config_test.go @@ -171,6 +171,20 @@ func TestPluginConfigParse(t *testing.T) { } }) + t.Run("duplicate resources after normalization rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: `["Pods"," pods "]`}).Parse() + if err == nil || !strings.Contains(err.Error(), "duplicate entry") { + t.Fatalf("expected duplicate resource error, got: %v", err) + } + }) + + t.Run("duplicate main_resources after normalization rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: `["pods","nodes"]`, MainResources: `["Pods"," pods "]`}).Parse() + if err == nil || !strings.Contains(err.Error(), "main_resources contains duplicate entry") { + t.Fatalf("expected duplicate main_resources error, got: %v", err) + } + }) + t.Run("invalid namespace_include JSON", func(t *testing.T) { _, err := (&PluginConfig{Clusters: validClusters, Resources: validResources, NamespaceInclude: "bad"}).Parse() if err == nil || !strings.Contains(err.Error(), "could not parse namespace_include") { @@ -301,7 +315,7 @@ func TestPluginConfigParse(t *testing.T) { cfg := &PluginConfig{ Clusters: validClusters, Resources: validResources, - IdentityLabels: `{"app_name":["custom-label"],"env":["environment"]}`, + IdentityLabels: `{" app_name ":[" custom-label "],"env":[" environment "]}`, } parsed, err := cfg.Parse() if err != nil { @@ -313,6 +327,9 @@ func TestPluginConfigParse(t *testing.T) { if _, ok := parsed.IdentityLabels["env"]; !ok { t.Fatalf("expected env identity key") } + if got := parsed.IdentityLabels["env"]; len(got) != 1 || got[0] != "environment" { + t.Fatalf("expected env=[environment], got %v", got) + } }) t.Run("identity_labels with empty candidate list rejected", func(t *testing.T) { diff --git a/main.go b/main.go index 6c08d9f..597d649 100644 --- a/main.go +++ b/main.go @@ -375,6 +375,7 @@ func resourceInstanceIdentifier(instance *resourceInstance) string { if instance.Namespace != "" { parts = append(parts, sanitizeIdentifier(instance.Namespace)) } + parts = append(parts, sanitizeIdentifier(instance.IdentityLabels["app_name"])) parts = append(parts, sanitizeIdentifier(instance.Name)) return strings.Join(parts, "/") } diff --git a/main_test.go b/main_test.go index f94eab4..133bacf 100644 --- a/main_test.go +++ b/main_test.go @@ -503,18 +503,18 @@ func TestResourceInstanceIdentifier(t *testing.T) { }{ { "namespaced", - &resourceInstance{ClusterName: "prod", ResourceType: "pods", Namespace: "app-ns", Name: "api-1"}, - "k8s-pods/prod/app-ns/api-1", + &resourceInstance{ClusterName: "prod", ResourceType: "pods", Namespace: "app-ns", Name: "api-1", IdentityLabels: map[string]string{"app_name": "api"}}, + "k8s-pods/prod/app-ns/api/api-1", }, { "cluster-scoped omits namespace segment", - &resourceInstance{ClusterName: "prod", ResourceType: "nodes", Name: "node-1"}, - "k8s-nodes/prod/node-1", + &resourceInstance{ClusterName: "prod", ResourceType: "nodes", Name: "node-1", IdentityLabels: map[string]string{"app_name": "node-1"}}, + "k8s-nodes/prod/node-1/node-1", }, { "sanitizes noisy cluster name", - &resourceInstance{ClusterName: "Prod East", ResourceType: "pods", Namespace: "Default", Name: "API/1"}, - "k8s-pods/prod-east/default/api-1", + &resourceInstance{ClusterName: "Prod East", ResourceType: "pods", Namespace: "Default", Name: "API/1", IdentityLabels: map[string]string{"app_name": "Platform API"}}, + "k8s-pods/prod-east/default/platform-api/api-1", }, } for _, tc := range tests { From dcfadd6092d130c2b2a573fff9447a658ea10569 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 09:59:07 -0300 Subject: [PATCH 06/11] fix: copilot issues Signed-off-by: Gustavo Carvalho --- config.go | 3 +++ config_test.go | 7 +++++++ main.go | 36 ++++++++++++++++++++++++++++++------ main_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/config.go b/config.go index 846221e..3c611f6 100644 --- a/config.go +++ b/config.go @@ -166,6 +166,9 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { } normalizedIdentityLabels[trimmedKey] = candidates } + if _, ok := normalizedIdentityLabels["app_name"]; !ok { + return nil, errors.New("identity_labels must include app_name") + } identityLabels = normalizedIdentityLabels } if len(identityLabels) == 0 { diff --git a/config_test.go b/config_test.go index 40c901c..779c5ea 100644 --- a/config_test.go +++ b/config_test.go @@ -339,6 +339,13 @@ func TestPluginConfigParse(t *testing.T) { } }) + t.Run("identity_labels without app_name rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: validResources, IdentityLabels: `{"env":["environment"]}`}).Parse() + if err == nil || !strings.Contains(err.Error(), "must include app_name") { + t.Fatalf("expected app_name validation error, got: %v", err) + } + }) + t.Run("empty provider defaults to eks", func(t *testing.T) { cfg := &PluginConfig{ Clusters: validClusters, diff --git a/main.go b/main.go index 597d649..7c04998 100644 --- a/main.go +++ b/main.go @@ -21,8 +21,9 @@ import ( ) const ( - schemaVersionV2 = "v2" - sourcePluginK8s = "plugin-kubernetes" + schemaVersionV2 = "v2" + sourcePluginK8s = "plugin-kubernetes" + evidenceBatchSize = 100 ) // PolicyEvaluator wraps OPA policy execution so eval loop behavior can be tested with mocks. @@ -175,6 +176,16 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot totalEvaluatorCalls := 0 successfulPolicyCalls := 0 var accumulatedErrors error + flushEvidences := func(clusterName string, evidences []*proto.Evidence) error { + if len(evidences) == 0 { + return nil + } + if err := apiHelper.CreateEvidence(ctx, evidences); err != nil { + p.Logger.Error("Error creating evidence", "cluster", clusterName, "error", err) + return err + } + return nil + } fleetContext := buildFleetContext(clusterByName, clusterData) @@ -221,9 +232,19 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot regoInput, ) clusterEvidences = append(clusterEvidences, evidences...) + if len(clusterEvidences) >= evidenceBatchSize { + if sendErr := flushEvidences(clusterName, clusterEvidences); sendErr != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, sendErr + } + clusterEvidences = clusterEvidences[:0] + } if evalErr != nil { p.Logger.Warn("Policy evaluation failed", "policy_path", policyPath, "resource", instance.Name, "namespace", instance.Namespace, "cluster", clusterName, "error", evalErr) - accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s [%s/%s/%s]: %w", policyPath, clusterName, instance.Namespace, instance.Name, evalErr)) + resourceLocation := fmt.Sprintf("%s/%s", clusterName, instance.Name) + if instance.Namespace != "" { + resourceLocation = fmt.Sprintf("%s/%s/%s", clusterName, instance.Namespace, instance.Name) + } + accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s [%s]: %w", policyPath, resourceLocation, evalErr)) continue } successfulPolicyCalls++ @@ -232,8 +253,7 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot } if len(clusterEvidences) > 0 { - if sendErr := apiHelper.CreateEvidence(ctx, clusterEvidences); sendErr != nil { - p.Logger.Error("Error creating evidence", "cluster", clusterName, "error", sendErr) + if sendErr := flushEvidences(clusterName, clusterEvidences); sendErr != nil { return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, sendErr } } @@ -368,6 +388,10 @@ func buildInstanceLabels(base map[string]string, instance *resourceInstance) map // resourceInstanceIdentifier composes the stable subject identifier for a single resource. func resourceInstanceIdentifier(instance *resourceInstance) string { + appIdentity := instance.IdentityLabels["app_name"] + if appIdentity == "" { + appIdentity = instance.Name + } parts := []string{ "k8s-" + sanitizeIdentifier(instance.ResourceType), sanitizeIdentifier(instance.ClusterName), @@ -375,7 +399,7 @@ func resourceInstanceIdentifier(instance *resourceInstance) string { if instance.Namespace != "" { parts = append(parts, sanitizeIdentifier(instance.Namespace)) } - parts = append(parts, sanitizeIdentifier(instance.IdentityLabels["app_name"])) + parts = append(parts, sanitizeIdentifier(appIdentity)) parts = append(parts, sanitizeIdentifier(instance.Name)) return strings.Join(parts, "/") } diff --git a/main_test.go b/main_test.go index 133bacf..e4e4552 100644 --- a/main_test.go +++ b/main_test.go @@ -306,6 +306,51 @@ func TestEvalLoopBehavior(t *testing.T) { if apiHelper.createCalls != 0 { t.Fatalf("expected no CreateEvidence calls, got %d", apiHelper.createCalls) } + if strings.Contains(err.Error(), "prod//") { + t.Fatalf("expected cluster-scoped resource location without empty namespace, got: %v", err) + } + }) + + t.Run("flushes evidence incrementally when batch size is exceeded", func(t *testing.T) { + pods := make([]map[string]interface{}, 0, evidenceBatchSize+5) + for i := range evidenceBatchSize + 5 { + pods = append(pods, newPodItem(fmt.Sprintf("pod-%d", i), "app", fmt.Sprintf("app-%d", i))) + } + collector := &fakeCollector{ + results: map[string]*ClusterResources{ + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": pods}, + }, + }, + } + evaluator := &fakePolicyEvaluator{} + apiHelper := &fakeAPIHelper{} + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + }, + collector: collector, + evaluator: evaluator, + } + + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, apiHelper) + if err != nil { + t.Fatalf("unexpected eval error: %v", err) + } + if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { + t.Fatalf("expected success, got %s", resp.GetStatus().String()) + } + if apiHelper.createCalls < 2 { + t.Fatalf("expected multiple CreateEvidence flushes, got %d", apiHelper.createCalls) + } + if len(apiHelper.evidence) != evidenceBatchSize+5 { + t.Fatalf("expected %d evidences, got %d", evidenceBatchSize+5, len(apiHelper.evidence)) + } }) t.Run("collection failure returns error", func(t *testing.T) { @@ -516,6 +561,11 @@ func TestResourceInstanceIdentifier(t *testing.T) { &resourceInstance{ClusterName: "Prod East", ResourceType: "pods", Namespace: "Default", Name: "API/1", IdentityLabels: map[string]string{"app_name": "Platform API"}}, "k8s-pods/prod-east/default/platform-api/api-1", }, + { + "falls back to resource name when app_name is empty", + &resourceInstance{ClusterName: "prod", ResourceType: "pods", Namespace: "app", Name: "api-1", IdentityLabels: map[string]string{"app_name": ""}}, + "k8s-pods/prod/app/api-1/api-1", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { From ffe6c11637d0610179766bdb5d90a77f407b6c41 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 10:08:58 -0300 Subject: [PATCH 07/11] fix: copilot issues Signed-off-by: Gustavo Carvalho --- main.go | 14 +++++++------- main_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index 7c04998..c06b0ba 100644 --- a/main.go +++ b/main.go @@ -231,13 +231,6 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot activities, regoInput, ) - clusterEvidences = append(clusterEvidences, evidences...) - if len(clusterEvidences) >= evidenceBatchSize { - if sendErr := flushEvidences(clusterName, clusterEvidences); sendErr != nil { - return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, sendErr - } - clusterEvidences = clusterEvidences[:0] - } if evalErr != nil { p.Logger.Warn("Policy evaluation failed", "policy_path", policyPath, "resource", instance.Name, "namespace", instance.Namespace, "cluster", clusterName, "error", evalErr) resourceLocation := fmt.Sprintf("%s/%s", clusterName, instance.Name) @@ -247,6 +240,13 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s [%s]: %w", policyPath, resourceLocation, evalErr)) continue } + clusterEvidences = append(clusterEvidences, evidences...) + if len(clusterEvidences) >= evidenceBatchSize { + if sendErr := flushEvidences(clusterName, clusterEvidences); sendErr != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, sendErr + } + clusterEvidences = clusterEvidences[:0] + } successfulPolicyCalls++ } } diff --git a/main_test.go b/main_test.go index e4e4552..f985bbb 100644 --- a/main_test.go +++ b/main_test.go @@ -22,8 +22,9 @@ type evalCapture struct { } type fakePolicyEvaluator struct { - calls []evalCapture - failPaths map[string]bool + calls []evalCapture + failPaths map[string]bool + failPathsWithEvidence map[string]bool } func (f *fakePolicyEvaluator) Generate( @@ -52,6 +53,9 @@ func (f *fakePolicyEvaluator) Generate( if f.failPaths != nil && f.failPaths[policyPath] { return nil, errors.New("forced evaluator error") } + if f.failPathsWithEvidence != nil && f.failPathsWithEvidence[policyPath] { + return []*proto.Evidence{{UUID: fmt.Sprintf("ev-%s-%d", policyPath, len(f.calls)), Labels: copiedLabels}}, errors.New("forced evaluator error") + } return []*proto.Evidence{{UUID: fmt.Sprintf("ev-%s-%d", policyPath, len(f.calls)), Labels: copiedLabels}}, nil } @@ -311,6 +315,45 @@ func TestEvalLoopBehavior(t *testing.T) { } }) + t.Run("does not publish evidence returned alongside evaluator errors", func(t *testing.T) { + collector := &fakeCollector{ + results: map[string]*ClusterResources{ + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": {newPodItem("p1", "app", "api")}}, + }, + }, + } + evaluator := &fakePolicyEvaluator{failPathsWithEvidence: map[string]bool{"bundle-a": true}} + apiHelper := &fakeAPIHelper{} + + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + }, + collector: collector, + evaluator: evaluator, + } + + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, apiHelper) + if err == nil { + t.Fatalf("expected eval failure") + } + if resp.GetStatus() != proto.ExecutionStatus_FAILURE { + t.Fatalf("expected failure status") + } + if apiHelper.createCalls != 0 { + t.Fatalf("expected no CreateEvidence calls, got %d", apiHelper.createCalls) + } + if len(apiHelper.evidence) != 0 { + t.Fatalf("expected no published evidence, got %d", len(apiHelper.evidence)) + } + }) + t.Run("flushes evidence incrementally when batch size is exceeded", func(t *testing.T) { pods := make([]map[string]interface{}, 0, evidenceBatchSize+5) for i := range evidenceBatchSize + 5 { From ddb78461c6be162752cf7770385bf51508bf48c6 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 10:20:17 -0300 Subject: [PATCH 08/11] fix: copilot issues Signed-off-by: Gustavo Carvalho --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index de08cdb..9a88812 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ During `Init`, the plugin registers one `SubjectTemplate` per entry in `main_res For cluster-scoped resources such as `nodes`, the emitted evidence labels still include `namespace` with an empty string so label contracts stay stable, but the corresponding `SubjectTemplate` omits `namespace` from its identity keys and rendering. -During `Eval`, every concrete Kubernetes resource instance that matches a `main_resources` type becomes its own subject and receives its own evidence for every configured policy path. Policies are invoked once per `(resource instance, policy path)` pair. A single `CreateEvidence` call is made per cluster, batching all evidence produced for that cluster. +During `Eval`, every concrete Kubernetes resource instance that matches a `main_resources` type becomes its own subject and receives its own evidence for every configured policy path. Policies are invoked once per `(resource instance, policy path)` pair. Evidence is batched per cluster, but may be sent via multiple `CreateEvidence` calls when the `evidenceBatchSize` threshold is reached. ## Configuration Examples From 69d68e33f9804dbc84b9a5a7a76d3944c0cee43c Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 10:30:33 -0300 Subject: [PATCH 09/11] fix: copilot issues Signed-off-by: Gustavo Carvalho --- main.go | 46 +++++++++++++++++----------------------------- main_test.go | 30 +++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/main.go b/main.go index c06b0ba..d84a696 100644 --- a/main.go +++ b/main.go @@ -445,11 +445,21 @@ func buildInstanceInventory(instance *resourceInstance) *proto.InventoryItem { func buildClusterComponent(cluster auth.ClusterConfig) *proto.Component { clusterID := fmt.Sprintf("k8s-cluster/%s", sanitizeIdentifier(cluster.Name)) + description := fmt.Sprintf("Kubernetes cluster %q", cluster.Name) + if provider := cluster.EffectiveProvider(); provider != "" { + description = fmt.Sprintf("%s using provider %s", description, provider) + } + if cluster.ClusterName != "" { + description = fmt.Sprintf("%s (cluster name %q)", description, cluster.ClusterName) + } + if cluster.Region != "" { + description = fmt.Sprintf("%s in region %s", description, cluster.Region) + } return &proto.Component{ Identifier: clusterID, Type: "service", Title: fmt.Sprintf("Kubernetes Cluster: %s", cluster.Name), - Description: fmt.Sprintf("Kubernetes cluster %q in region %s", cluster.ClusterName, cluster.Region), + Description: description, Purpose: "Kubernetes cluster providing resource data for compliance evaluation.", } } @@ -544,46 +554,24 @@ func buildRegoInput(main map[string]interface{}, subject map[string]interface{}, return input } -func isClusterScopedResourceType(resourceType string) bool { - switch normalizeResourceName(resourceType) { - case "nodes", "namespaces", "persistentvolumes", "clusterroles", "clusterrolebindings", "customresourcedefinitions", "mutatingwebhookconfigurations", "validatingwebhookconfigurations", "storageclasses", "runtimeclasses", "priorityclasses", "csinodes", "volumeattachments": - return true - default: - return false - } -} - // buildSubjectTemplates produces one SubjectTemplate per main resource type. -// Namespaced types include namespace and app_name in identity; cluster-scoped -// types omit namespace from template identity and rendering. The agent treats -// the configured IdentityLabelKeys as the unique key. +// Templates always include namespace in the label schema/identity contract, and +// use conditional rendering so cluster-scoped resources with empty namespace do +// not produce awkward titles or descriptions. func buildSubjectTemplates(mainResources []string) []*proto.SubjectTemplate { templates := make([]*proto.SubjectTemplate, 0, len(mainResources)) for _, resourceType := range mainResources { templateName := "k8s-" + strings.ToLower(resourceType) - isClusterScoped := isClusterScopedResourceType(resourceType) identityKeys := []string{"cluster_name", "namespace", "app_name", "name"} labelSchema := []*proto.SubjectLabelSchema{ {Key: "cluster_name", Description: "Name of the Kubernetes cluster this resource belongs to"}, - {Key: "namespace", Description: "Namespace of the resource (empty for cluster-scoped resources)"}, + {Key: "namespace", Description: "Namespace of the resource for namespaced resources; empty for cluster-scoped resources"}, {Key: "app_name", Description: "Application name resolved from metadata.labels via identity_labels config, falling back to metadata.name"}, {Key: "name", Description: "Value of metadata.name on the Kubernetes resource"}, {Key: "resource_type", Description: "Kubernetes resource type (e.g. pods, nodes, deployments)"}, } - titleTemplate := fmt.Sprintf("Kubernetes %s {{ .namespace }}/{{ .name }} in {{ .cluster_name }}", resourceType) - descriptionTemplate := fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }} under namespace {{ .namespace }}", resourceType, "{{ .name }}") - - if isClusterScoped { - identityKeys = []string{"cluster_name", "app_name", "name"} - labelSchema = []*proto.SubjectLabelSchema{ - {Key: "cluster_name", Description: "Name of the Kubernetes cluster this resource belongs to"}, - {Key: "app_name", Description: "Application name resolved from metadata.labels via identity_labels config, falling back to metadata.name"}, - {Key: "name", Description: "Value of metadata.name on the Kubernetes resource"}, - {Key: "resource_type", Description: "Kubernetes resource type (e.g. pods, nodes, deployments)"}, - } - titleTemplate = fmt.Sprintf("Kubernetes %s {{ .name }} in {{ .cluster_name }}", resourceType) - descriptionTemplate = fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }}", resourceType, "{{ .name }}") - } + titleTemplate := fmt.Sprintf("Kubernetes %s {{ if .namespace }}{{ .namespace }}/{{ end }}{{ .name }} in {{ .cluster_name }}", resourceType) + descriptionTemplate := fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }}{{ if .namespace }} under namespace {{ .namespace }}{{ end }}", resourceType, "{{ .name }}") templates = append(templates, &proto.SubjectTemplate{ Name: templateName, diff --git a/main_test.go b/main_test.go index f985bbb..43c1c0f 100644 --- a/main_test.go +++ b/main_test.go @@ -619,6 +619,23 @@ func TestResourceInstanceIdentifier(t *testing.T) { } } +func TestBuildClusterComponent(t *testing.T) { + t.Run("kubeconfig cluster omits empty region and cluster_name", func(t *testing.T) { + component := buildClusterComponent(auth.ClusterConfig{Name: "local", Provider: "kubeconfig"}) + if component.GetDescription() != `Kubernetes cluster "local" using provider kubeconfig` { + t.Fatalf("unexpected description: %q", component.GetDescription()) + } + }) + + t.Run("eks cluster includes provider cluster_name and region", func(t *testing.T) { + component := buildClusterComponent(auth.ClusterConfig{Name: "prod", Provider: "eks", ClusterName: "prod-eks", Region: "us-east-1"}) + want := `Kubernetes cluster "prod" using provider eks (cluster name "prod-eks") in region us-east-1` + if component.GetDescription() != want { + t.Fatalf("unexpected description: %q", component.GetDescription()) + } + }) +} + func TestBuildSubjectTemplates(t *testing.T) { templates := buildSubjectTemplates([]string{"pods", "nodes"}) if len(templates) != 2 { @@ -642,8 +659,13 @@ func TestBuildSubjectTemplates(t *testing.T) { if len(keys) != len(wantKeys) { t.Fatalf("expected identity keys %v, got %v", wantKeys, keys) } + for i := range wantKeys { + if keys[i] != wantKeys[i] { + t.Fatalf("expected pod identity keys %v, got %v", wantKeys, keys) + } + } nodeKeys := byName["k8s-nodes"].GetIdentityLabelKeys() - wantNodeKeys := []string{"cluster_name", "app_name", "name"} + wantNodeKeys := []string{"cluster_name", "namespace", "app_name", "name"} if len(nodeKeys) != len(wantNodeKeys) { t.Fatalf("expected node identity keys %v, got %v", wantNodeKeys, nodeKeys) } @@ -652,6 +674,12 @@ func TestBuildSubjectTemplates(t *testing.T) { t.Fatalf("expected node identity keys %v, got %v", wantNodeKeys, nodeKeys) } } + if byName["k8s-nodes"].GetTitleTemplate() != "Kubernetes nodes {{ if .namespace }}{{ .namespace }}/{{ end }}{{ .name }} in {{ .cluster_name }}" { + t.Fatalf("unexpected node title template: %q", byName["k8s-nodes"].GetTitleTemplate()) + } + if byName["k8s-nodes"].GetDescriptionTemplate() != "Kubernetes nodes {{ .name }} in cluster {{ .cluster_name }}{{ if .namespace }} under namespace {{ .namespace }}{{ end }}" { + t.Fatalf("unexpected node description template: %q", byName["k8s-nodes"].GetDescriptionTemplate()) + } } func TestInitUpsertsSubjectTemplates(t *testing.T) { From c56bc960c8ab3b9f6cee3e1cbd64338da5049533 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 10:41:55 -0300 Subject: [PATCH 10/11] fix: copilot issues Signed-off-by: Gustavo Carvalho --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 9a88812..3c858f3 100644 --- a/README.md +++ b/README.md @@ -114,12 +114,18 @@ Every policy evaluation receives one `main` resource and the full cluster snapsh "schema_version": "v2", "source": "plugin-kubernetes", "main": { /* the single Kubernetes resource being evaluated (unstructured) */ }, + "subject": { + /* stable per-resource identity metadata derived for evidence and policy use */ + }, "context": { "cluster": { "name": "prod", "region": "us-east-1", "provider": "eks" }, "resources": { "nodes": [ /* every collected node in this cluster */ ], "pods": [ /* every collected pod in this cluster — includes the one in input.main */ ] } + }, + "fleet": { + /* multi-cluster collection metadata always included in the evaluation input */ } } ``` From 7716f7c940525ab669b41bc44565b46b8e41f708 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 22 Apr 2026 13:59:39 -0300 Subject: [PATCH 11/11] chore: use protocol_version 2 by default Signed-off-by: Gustavo Carvalho --- .github/workflows/build-and-upload.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index ea22c7e..c9e037e 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -22,7 +22,7 @@ jobs: - name: Authenticate gooci cli run: gooci login ghcr.io --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }} - name: gooci Upload Version - run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{github.ref_name}} + run: gooci upload --annotate="org.ccf.plugin.protocol.version=2" dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{github.ref_name}} - name: gooci Upload Latest if: "!github.event.release.prerelease" - run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest + run: gooci upload --annotate="org.ccf.plugin.protocol.version=2" dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest