Repository: plexsystems/konstraint
Pull request #94 (2020-12-08 10:50:10)

Support @matchlabels annotating rule
https://github.com/plexsystems/konstraint/issues/93

```rego
# @matchlabels app=a team=b
package foo

import data.lib.core
...
```
↓
```yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: Foo
metadata:
  name: foo
spec:
  match:
    labelSelector:
      matchLabels:
        app: a
        team: b
```

jalseth: @dulltz Thanks for making those changes! I see you have opted to create a `**MatchLabels:** *` entry in the documentation if there are no match labels set. I would prefer if this was not included if MatchLabels item was empty. Does that sound OK to you?
dulltz: @jalseth 
Sounds good! I'll try it by text/template `{{ if }}` action.
jalseth: LGTM! Thanks again for the contribution! 

Patch:
diff --git a/docs/constraint_creation.md b/docs/constraint_creation.md
index 3c495a6..8e927b1 100644
--- a/docs/constraint_creation.md
+++ b/docs/constraint_creation.md
@@ -48,7 +48,7 @@ This comment block should:
 - Include a human readable description of what the policy does.
 - Set the matchers used when generating the Constraints.
 
-It may also specify the enforcment action (either `deny` or `dryrun`) that Gatekeeper should take when a resource violates the constraint. If no enforcement action is specified, Konstraint defaults to using `deny` to align with Gatekeeper's default action. If the enforcement is set to `dryrun`, the policy will be skipped in the documentation generation.
+It may also specify the enforcement action (either `deny` or `dryrun`) that Gatekeeper should take when a resource violates the constraint. If no enforcement action is specified, Konstraint defaults to using `deny` to align with Gatekeeper's default action. If the enforcement is set to `dryrun`, the policy will be skipped in the documentation generation.
 
 ```rego
 # @title Pods must not run with access to the host IPC
@@ -76,6 +76,17 @@ pod_has_hostipc {
 
 The comment block is also what is used when generating documentation via the `doc` command.
 
+### Annotating rules for matchers
+
+- `@kinds` generates `constraint.spec.match.kinds`. The allowed format is space-separated `<group>/<kind>` list.
+    ```
+    # @kinds apps/DaemonSet apps/Deployment apps/StatefulSet core/Pod
+    ```
+- `@matchlabels` generates `constraint.spec.match.labelSelector.matchLabels`. The allowed format is space-separated `<label-key>=<label-val>` list.
+    ```
+    # @matchlabels app.kubernetes.io/name=mysql app.kubernetes.io/version=5.8
+    ```
+
 ## Using Input Parameters
 
 Gatekeeper has the ability for a single `ConstraintTemplate` resource to be used by multiple `Constraint`s. One of the reasons for this is that it allows for passing input parameters to the policy so a single policy to avoid duplication. Konstraint supports these input parameters via `@parameter` tags in the header comment block. **NOTE:** When input parameters are specified, Konstraint skips the generation of the `Constraint` resource.
diff --git a/examples/container-deny-privileged-if-tenant/constraint.yaml b/examples/container-deny-privileged-if-tenant/constraint.yaml
new file mode 100755
index 0000000..22f946d
--- /dev/null
+++ b/examples/container-deny-privileged-if-tenant/constraint.yaml
@@ -0,0 +1,18 @@
+apiVersion: constraints.gatekeeper.sh/v1beta1
+kind: ContainerDenyPrivilegedIfTenant
+metadata:
+  name: containerdenyprivilegediftenant
+spec:
+  match:
+    kinds:
+    - apiGroups:
+      - apps
+      - ""
+      kinds:
+      - DaemonSet
+      - Deployment
+      - StatefulSet
+      - Pod
+    labelSelector:
+      matchLabels:
+        is-tenant: "true"
diff --git a/examples/container-deny-privileged-if-tenant/src.rego b/examples/container-deny-privileged-if-tenant/src.rego
new file mode 100644
index 0000000..1c3befd
--- /dev/null
+++ b/examples/container-deny-privileged-if-tenant/src.rego
@@ -0,0 +1,33 @@
+# @title Tenants' containers must not run as privileged
+#
+# Privileged containers can easily escalate to root privileges on the node. As
+# such containers running as privileged or with sufficient capabilities granted
+# to obtain the same effect are not allowed if they are labeled as tenant.
+# To take advantage of this policy, it must be combined with another policy
+# that enforces the 'is-tenant' label.
+# This is the example for @matchlabels.
+#
+# @kinds apps/DaemonSet apps/Deployment apps/StatefulSet core/Pod
+# @matchlabels is-tenant=true
+package container_deny_privileged_if_tenant
+
+import data.lib.core
+import data.lib.pods
+import data.lib.security
+
+policyID := "P2006"
+
+violation[msg] {
+    pods.containers[container]
+    container_is_privileged(container)
+
+    msg = core.format_with_id(sprintf("%s/%s/%s: Tenants' containers must not run as privileged", [core.kind, core.name, container.name]), policyID)
+}
+
+container_is_privileged(container) {
+    container.securityContext.privileged
+}
+
+container_is_privileged(container) {
+    security.added_capability(container, "CAP_SYS_ADMIN")
+}
diff --git a/examples/container-deny-privileged-if-tenant/template.yaml b/examples/container-deny-privileged-if-tenant/template.yaml
new file mode 100755
index 0000000..bdfd2ff
--- /dev/null
+++ b/examples/container-deny-privileged-if-tenant/template.yaml
@@ -0,0 +1,150 @@
+apiVersion: templates.gatekeeper.sh/v1beta1
+kind: ConstraintTemplate
+metadata:
+  creationTimestamp: null
+  name: containerdenyprivilegediftenant
+spec:
+  crd:
+    spec:
+      names:
+        kind: ContainerDenyPrivilegedIfTenant
+  targets:
+  - libs:
+    - |-
+      package lib.core
+
+      default is_gatekeeper = false
+
+      is_gatekeeper {
+          has_field(input, "review")
+          has_field(input.review, "object")
+      }
+
+      resource = input.review.object {
+          is_gatekeeper
+      }
+
+      resource = input {
+          not is_gatekeeper
+      }
+
+      format(msg) = {"msg": msg} {
+          true
+      }
+
+      format_with_id(msg, id) = msg_fmt {
+          msg_fmt := {
+              "msg": sprintf("%s: %s", [id, msg]),
+              "details": {"policyID": id}
+          }
+      }
+
+      apiVersion = resource.apiVersion
+      name = resource.metadata.name
+      kind = resource.kind
+      labels = resource.metadata.labels
+      annotations = resource.metadata.annotations
+      gv := split(apiVersion, "/")
+      group = gv[0] {
+          contains(apiVersion, "/")
+      }
+      group = "core" {
+          not contains(apiVersion, "/")
+      }
+      version := gv[count(gv) - 1]
+
+      parameters = input.parameters {
+          is_gatekeeper
+      }
+
+      parameters = data.parameters {
+         not is_gatekeeper
+      }
+
+      has_field(obj, field) {
+          not object.get(obj, field, "N_DEFINED") == "N_DEFINED"
+      }
+
+      missing_field(obj, field) = true {
+          obj[field] == ""
+      }
+
+      missing_field(obj, field) = true {
+          not has_field(obj, field)
+      }
+    - |-
+      package lib.pods
+
+      import data.lib.core
+
+      default pod = false
+
+      pod = core.resource.spec.template {
+          pod_templates := ["daemonset","deployment","job","replicaset","replicationcontroller","statefulset"]
+          lower(core.kind) == pod_templates[_]
+      }
+
+      pod = core.resource {
+          lower(core.kind) == "pod"
+      }
+
+      pod = core.resource.spec.jobTemplate.spec.template {
+          lower(core.kind) == "cronjob"
+      }
+
+      containers[container] {
+          keys = {"containers", "initContainers"}
+          all_containers = [c | keys[k]; c = pod.spec[k][_]]
+          container = all_containers[_]
+      }
+
+      volumes[volume] {
+          volume = pod.spec.volumes[_]
+      }
+    - |-
+      package lib.security
+
+      dropped_capability(container, cap) {
+          lower(container.securityContext.capabilities.drop[_]) == lower(cap)
+      }
+
+      dropped_capability(psp, cap) {
+          lower(psp.spec.requiredDropCapabilities[_]) == lower(cap)
+      }
+
+      added_capability(container, cap) {
+          lower(container.securityContext.capabilities.add[_]) == lower(cap)
+      }
+
+      added_capability(psp, cap) {
+          lower(psp.spec.allowedCapabilities[_]) == lower(cap)
+      }
+
+      added_capability(psp, cap) {
+          lower(psp.spec.defaultAddCapabilities[_]) == lower(cap)
+      }
+    rego: |-
+      package container_deny_privileged_if_tenant
+
+      import data.lib.core
+      import data.lib.pods
+      import data.lib.security
+
+      policyID := "P2006"
+
+      violation[msg] {
+          pods.containers[container]
+          container_is_privileged(container)
+
+          msg = core.format_with_id(sprintf("%s/%s/%s: Tenants' containers must not run as privileged", [core.kind, core.name, container.name]), policyID)
+      }
+
+      container_is_privileged(container) {
+          container.securityContext.privileged
+      }
+
+      container_is_privileged(container) {
+          security.added_capability(container, "CAP_SYS_ADMIN")
+      }
+    target: admission.k8s.gatekeeper.sh
+status: {}
diff --git a/examples/policies.md b/examples/policies.md
index d314b64..82588e9 100755
--- a/examples/policies.md
+++ b/examples/policies.md
@@ -21,6 +21,7 @@
 * [P2001: Images must not use the latest tag](#p2001-images-must-not-use-the-latest-tag)
 * [P2002: Containers must define resource constraints](#p2002-containers-must-define-resource-constraints)
 * [P2005: Roles must not allow use of privileged PodSecurityPolicies](#p2005-roles-must-not-allow-use-of-privileged-podsecuritypolicies)
+* [P2006: Tenants' containers must not run as privileged](#p2006-tenants'-containers-must-not-run-as-privileged)
 
 ## Warnings
 
@@ -695,6 +696,50 @@ psp_is_privileged(psp) {
 
 _source: [role-deny-use-privileged-psp](role-deny-use-privileged-psp)_
 
+## P2006: Tenants' containers must not run as privileged
+
+**Severity:** Violation
+
+**Resources:** apps/DaemonSet apps/Deployment apps/StatefulSet core/Pod
+
+**MatchLabels:** is-tenant=true
+
+Privileged containers can easily escalate to root privileges on the node. As
+such containers running as privileged or with sufficient capabilities granted
+to obtain the same effect are not allowed if they are labeled as tenant.
+To take advantage of this policy, it must be combined with another policy
+that enforces the 'is-tenant' label.
+This is the example for @matchlabels.
+
+### Rego
+
+```rego
+package container_deny_privileged_if_tenant
+
+import data.lib.core
+import data.lib.pods
+import data.lib.security
+
+policyID := "P2006"
+
+violation[msg] {
+    pods.containers[container]
+    container_is_privileged(container)
+
+    msg = core.format_with_id(sprintf("%s/%s/%s: Tenants' containers must not run as privileged", [core.kind, core.name, container.name]), policyID)
+}
+
+container_is_privileged(container) {
+    container.securityContext.privileged
+}
+
+container_is_privileged(container) {
+    security.added_capability(container, "CAP_SYS_ADMIN")
+}
+```
+
+_source: [container-deny-privileged-if-tenant](container-deny-privileged-if-tenant)_
+
 ## P0001: Deprecated Deployment and DaemonSet API
 
 **Severity:** Warning
diff --git a/internal/commands/create.go b/internal/commands/create.go
index 4bc8301..ce76362 100644
--- a/internal/commands/create.go
+++ b/internal/commands/create.go
@@ -184,18 +184,38 @@ func getConstraint(violation rego.Rego) (unstructured.Unstructured, error) {
 		}
 	}
 
-	matchers := violation.Matchers()
+	matchers, err := violation.Matchers()
+	if err != nil {
+		return constraint, err
+	}
 	if len(matchers.KindMatchers) == 0 {
 		return constraint, nil
 	}
 
+	if len(matchers.KindMatchers) != 0 {
+		err := setKindMatcher(&constraint, matchers.KindMatchers)
+		if err != nil {
+			return constraint, err
+		}
+	}
+	if len(matchers.MatchLabelsMatcher) != 0 {
+		err := setMatchLabelsMatcher(&constraint, matchers.MatchLabelsMatcher)
+		if err != nil {
+			return constraint, err
+		}
+	}
+
+	return constraint, nil
+}
+
+func setKindMatcher(constraint *unstructured.Unstructured, kindMatchers []rego.KindMatcher) error {
 	var kinds []interface{}
 	var apiGroups []interface{}
-	for _, kindMatcher := range matchers.KindMatchers {
+	for _, kindMatcher := range kindMatchers {
 		kinds = append(kinds, kindMatcher.Kind)
 	}
 
-	for _, kindMatcher := range matchers.KindMatchers {
+	for _, kindMatcher := range kindMatchers {
 		apiGroup := kindMatcher.APIGroup
 		if kindMatcher.APIGroup == "core" {
 			apiGroup = ""
@@ -218,8 +238,14 @@ func getConstraint(violation rego.Rego) (unstructured.Unstructured, error) {
 	}
 
 	if err := unstructured.SetNestedSlice(constraint.Object, []interface{}{constraintMatcher}, "spec", "match", "kinds"); err != nil {
-		return unstructured.Unstructured{}, fmt.Errorf("set constraint matchers: %w", err)
+		return fmt.Errorf("set constraint kinds matchers: %w", err)
 	}
+	return nil
+}
 
-	return constraint, nil
+func setMatchLabelsMatcher(constraint *unstructured.Unstructured, matcher rego.MatchLabelsMatcher) error {
+	if err := unstructured.SetNestedStringMap(constraint.Object, matcher, "spec", "match", "labelSelector", "matchLabels"); err != nil {
+		return fmt.Errorf("set constraint labelSelector.matchLabels matchers: %w", err)
+	}
+	return nil
 }
diff --git a/internal/commands/document.go b/internal/commands/document.go
index f937491..2f54300 100644
--- a/internal/commands/document.go
+++ b/internal/commands/document.go
@@ -19,6 +19,7 @@ type Header struct {
 	Title       string
 	Description string
 	Resources   string
+	MatchLabels string
 	Anchor      string
 	Parameters  []rego.Parameter
 }
@@ -130,15 +131,21 @@ func getDocumentation(path string, outputDirectory string) (map[rego.Severity][]
 		anchor := strings.ToLower(strings.ReplaceAll(documentTitle, " ", "-"))
 		anchor = strings.ReplaceAll(anchor, ":", "")
 
-		matchers := policy.Matchers().String()
-		if matchers == "" {
-			matchers = "Any Resource"
+		matchers, err := policy.Matchers()
+		if err != nil {
+			return nil, err
 		}
+		resources := matchers.KindMatchers.String()
+		if resources == "" {
+			resources = "Any Resource"
+		}
+		matchLabels := matchers.MatchLabelsMatcher.String()
 
 		header := Header{
 			Title:       documentTitle,
 			Description: policy.Description(),
-			Resources:   matchers,
+			Resources:   resources,
+			MatchLabels: matchLabels,
 			Anchor:      anchor,
 			Parameters:  policy.Parameters(),
 		}
diff --git a/internal/commands/document_template.go b/internal/commands/document_template.go
index c2976ec..c236d84 100644
--- a/internal/commands/document_template.go
+++ b/internal/commands/document_template.go
@@ -17,6 +17,11 @@ const docTemplate = `# Policies
 
 **Resources:** {{ .Header.Resources }}
 
+{{- if .Header.MatchLabels }}
+
+**MatchLabels:** {{ .Header.MatchLabels }}
+{{- end }}
+
 {{- if .Header.Parameters }}
 
 **Parameters:**
diff --git a/internal/rego/matchers.go b/internal/rego/matchers.go
index 10263d4..490a869 100644
--- a/internal/rego/matchers.go
+++ b/internal/rego/matchers.go
@@ -1,17 +1,22 @@
 package rego
 
 import (
+	"fmt"
 	"strings"
 )
 
 // Matchers are all of the matchers that can be applied to constraints.
 type Matchers struct {
-	KindMatchers []KindMatcher
+	KindMatchers       KindMatchers
+	MatchLabelsMatcher MatchLabelsMatcher
 }
 
-func (m Matchers) String() string {
+// KindMatchers is the slice of KindMatcher
+type KindMatchers []KindMatcher
+
+func (k KindMatchers) String() string {
 	var result string
-	for _, kindMatcher := range m.KindMatchers {
+	for _, kindMatcher := range k {
 		result += kindMatcher.APIGroup + "/" + kindMatcher.Kind + " "
 	}
 	result = strings.TrimSpace(result)
@@ -25,16 +30,34 @@ type KindMatcher struct {
 	Kind     string
 }
 
+// MatchLabelsMatcher is the matcher to generate `constraints.spec.match.labelSelector.matchLabels`.
+type MatchLabelsMatcher map[string]string
+
+func (m MatchLabelsMatcher) String() string {
+	var result string
+	for k, v := range m {
+		result += fmt.Sprintf("%s=%s ", k, v)
+	}
+	return strings.TrimSpace(result)
+}
+
 // Matchers returns all of the matchers found in the rego file.
-func (r Rego) Matchers() Matchers {
+func (r Rego) Matchers() (Matchers, error) {
 	var matchers Matchers
 	for _, comment := range r.comments {
 		if strings.HasPrefix(comment, "@kinds") {
 			matchers.KindMatchers = getKindMatchers(comment)
 		}
+		if strings.HasPrefix(comment, "@matchlabels") {
+			var err error
+			matchers.MatchLabelsMatcher, err = getMatchLabelsMatcher(comment)
+			if err != nil {
+				return matchers, err
+			}
+		}
 	}
 
-	return matchers
+	return matchers, nil
 }
 
 func getKindMatchers(comment string) []KindMatcher {
@@ -56,3 +79,18 @@ func getKindMatchers(comment string) []KindMatcher {
 
 	return kindMatchers
 }
+
+func getMatchLabelsMatcher(comment string) (MatchLabelsMatcher, error) {
+	matcher := make(map[string]string)
+
+	matcherText := strings.TrimSpace(strings.SplitAfter(comment, "@matchlabels")[1])
+
+	for _, token := range strings.Fields(matcherText) {
+		split := strings.Split(token, "=")
+		if len(split) != 2 {
+			return nil, fmt.Errorf("invalid @matchlabels annotation token: %s", token)
+		}
+		matcher[split[0]] = split[1]
+	}
+	return matcher, nil
+}


Test patch:
diff --git a/examples/container-deny-privileged-if-tenant/src_test.rego b/examples/container-deny-privileged-if-tenant/src_test.rego
new file mode 100644
index 0000000..c3248b2
--- /dev/null
+++ b/examples/container-deny-privileged-if-tenant/src_test.rego
@@ -0,0 +1,23 @@
+package container_deny_privileged
+
+test_privileged_true {
+    input := {"securityContext": {"privileged": true}}
+
+    container_is_privileged(input)
+}
+
+test_privileged_false {
+    input := {"securityContext": {"privileged": false}}
+
+    not container_is_privileged(input)
+}
+
+test_added_capability {
+    input := {
+        "securityContext": {
+            "capabilities": {"add": ["CAP_SYS_ADMIN"]}
+        }
+    }
+
+    container_is_privileged(input)
+}
diff --git a/internal/rego/matchers_test.go b/internal/rego/matchers_test.go
index 2699b7f..2bf94d7 100644
--- a/internal/rego/matchers_test.go
+++ b/internal/rego/matchers_test.go
@@ -13,14 +13,42 @@ func TestGetKindMatchers(t *testing.T) {
 		comments: comments,
 	}
 
-	expected := []KindMatcher{
+	expected := KindMatchers{
 		{APIGroup: "core", Kind: "Pod"},
 		{APIGroup: "apps", Kind: "Deployment"},
 	}
 
-	actual := rego.Matchers().KindMatchers
+	matchers, err := rego.Matchers()
+	if err != nil {
+		t.Fatal(err)
+	}
+	actual := matchers.KindMatchers
 
 	if !reflect.DeepEqual(expected, actual) {
 		t.Errorf("Unexpected KindMatchers. expected %v, actual %v.", expected, actual)
 	}
 }
+
+func TestGetMatchLabelsMatcher(t *testing.T) {
+	comments := []string{
+		"@matchlabels team=a app.kubernetes.io/name=test",
+	}
+	rego := Rego{
+		comments: comments,
+	}
+
+	expected := MatchLabelsMatcher{
+		"team":                   "a",
+		"app.kubernetes.io/name": "test",
+	}
+
+	matchers, err := rego.Matchers()
+	if err != nil {
+		t.Fatal(err)
+	}
+	actual := matchers.MatchLabelsMatcher
+
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Unexpected MatchLabelMatcher. expected %v, actual %v.", expected, actual)
+	}
+}