feat(rules): add service enforcement rules (#1982)

* fix(controller): decode old object for delete requests

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* fix: preserve ca-bundles injected from external providers

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
Oliver Bähler
2026-06-24 11:20:23 +02:00
committed by GitHub
parent 7669abf063
commit 755cef54bf
63 changed files with 10168 additions and 1435 deletions
+3 -13
View File
@@ -7,7 +7,7 @@ import (
"fmt"
"reflect"
"regexp"
"sort"
"slices"
"strings"
)
@@ -17,18 +17,8 @@ type ForbiddenListSpec struct {
Regex string `json:"deniedRegex,omitempty"`
}
func (in ForbiddenListSpec) ExactMatch(value string) (ok bool) {
if len(in.Exact) > 0 {
sort.SliceStable(in.Exact, func(i, j int) bool {
return strings.ToLower(in.Exact[i]) < strings.ToLower(in.Exact[j])
})
i := sort.SearchStrings(in.Exact, value)
ok = i < len(in.Exact) && in.Exact[i] == value
}
return ok
func (in ForbiddenListSpec) ExactMatch(value string) bool {
return slices.Contains(in.Exact, value)
}
func (in ForbiddenListSpec) RegexMatch(value string) (ok bool) {
+45
View File
@@ -11,6 +11,16 @@ import (
"github.com/projectcapsule/capsule/pkg/api"
)
func denied() api.ForbiddenListSpec {
return api.ForbiddenListSpec{
Exact: []string{
"kubernetes.io/metadata.name",
"pod-security.kubernetes.io/enforce",
"NetworkPolicy",
},
}
}
func TestForbiddenListSpec_ExactMatch(t *testing.T) {
type tc struct {
In []string
@@ -120,3 +130,38 @@ func TestValidateForbidden(t *testing.T) {
}
}
}
func TestForbiddenKeysBypassed(t *testing.T) {
for _, k := range []string{"NetworkPolicy", "kubernetes.io/metadata.name"} {
if err := api.ValidateForbidden(map[string]string{k: "owned"}, denied()); err == nil {
t.Errorf("BYPASS CONFIRMED: ValidateForbidden ALLOWED denied key %q (list=%v)", k, denied().Exact)
} else {
t.Logf("(no bypass) correctly denied %q: %v", k, err)
}
}
}
// Positive control: a third denied key in the SAME list is still correctly
// blocked — proving the policy genuinely forbids these keys and the harness is
// wired right (i.e. the bypass above is selective, not a dead enforcement path).
func TestPositiveControl_StillBlocked(t *testing.T) {
if err := api.ValidateForbidden(map[string]string{"pod-security.kubernetes.io/enforce": "privileged"}, denied()); err == nil {
t.Errorf("control failure: denied key 'pod-security.kubernetes.io/enforce' was NOT blocked")
}
}
// Negative control: a key the admin did NOT deny is correctly allowed,
// proving the webhook is not simply denying everything.
func TestPoC_NegativeControl_BenignAllowed(t *testing.T) {
if err := api.ValidateForbidden(map[string]string{"app.kubernetes.io/name": "frontend"}, denied()); err != nil {
t.Errorf("control failure: benign key was wrongly denied: %v", err)
}
}
// Direct primitive check, minimal repro of the root cause.
func TestExactMatch_RootCause(t *testing.T) {
spec := api.ForbiddenListSpec{Exact: []string{"B", "a"}} // mixed case
if !spec.ExactMatch("B") {
t.Errorf("ROOT CAUSE: ExactMatch(%q) returned false though %q is in %v", "B", "B", spec.Exact)
}
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rules
import "github.com/projectcapsule/capsule/pkg/api"
// +kubebuilder:object:generate=true
type NamespaceRuleEnforceServicesBody struct {
// Types defines the Service types matched by this rule.
//
// Supported values:
// - ClusterIP
// - NodePort
// - LoadBalancer
// - ExternalName
//
// +optional
// +kubebuilder:validation:items:Enum=ClusterIP;NodePort;LoadBalancer;ExternalName
Types []ServiceType `json:"types,omitempty"`
// LoadBalancers defines additional constraints for Services of type LoadBalancer.
// +optional
LoadBalancers *ServiceLoadBalancerRule `json:"loadBalancers,omitempty"`
// ExternalNames defines additional constraints for Services of type ExternalName.
// +optional
ExternalNames *ServiceExternalNameRule `json:"externalNames,omitempty"`
// NodePorts defines additional constraints for nodePort values.
// +optional
NodePorts *ServiceNodePortRule `json:"nodePorts,omitempty"`
}
// +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer;ExternalName
type ServiceType string
const (
ServiceTypeClusterIP ServiceType = "ClusterIP"
ServiceTypeNodePort ServiceType = "NodePort"
ServiceTypeLoadBalancer ServiceType = "LoadBalancer"
ServiceTypeExternalName ServiceType = "ExternalName"
)
// +kubebuilder:object:generate=true
type ServiceLoadBalancerRule struct {
// CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges.
// Empty means no additional CIDR restriction once LoadBalancer is allowed by types.
// +optional
CIDRs []string `json:"cidrs,omitempty"`
}
// +kubebuilder:object:generate=true
type ServiceExternalNameRule struct {
// Hostnames restricts spec.externalName.
// Empty means no additional hostname restriction once ExternalName is allowed by types.
// +optional
Hostnames []api.ExpressionMatch `json:"hostnames,omitempty"`
}
// +kubebuilder:object:generate=true
type ServiceNodePortRule struct {
// Ports restricts explicitly requested nodePort values.
// Empty means no additional port restriction once NodePort is allowed by types.
// +optional
Ports []ServiceNodePortRange `json:"ports,omitempty"`
}
// +kubebuilder:object:generate=true
type ServiceNodePortRange struct {
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
From int32 `json:"from"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
To int32 `json:"to"`
}
+4
View File
@@ -14,4 +14,8 @@ type NamespaceRuleEnforceBody struct {
// Enforcement for Workloads (Pods)
Workloads NamespaceRuleEnforceWorkloadsBody `json:"workloads,omitempty"`
// Enforcement for Services.
// +optional
Services NamespaceRuleEnforceServicesBody `json:"services,omitempty"`
}
+113
View File
@@ -63,6 +63,7 @@ func (in *NamespaceRuleBodyTenant) DeepCopy() *NamespaceRuleBodyTenant {
func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) {
*out = *in
in.Workloads.DeepCopyInto(&out.Workloads)
in.Services.DeepCopyInto(&out.Services)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody.
@@ -75,6 +76,41 @@ func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NamespaceRuleEnforceServicesBody) DeepCopyInto(out *NamespaceRuleEnforceServicesBody) {
*out = *in
if in.Types != nil {
in, out := &in.Types, &out.Types
*out = make([]ServiceType, len(*in))
copy(*out, *in)
}
if in.LoadBalancers != nil {
in, out := &in.LoadBalancers, &out.LoadBalancers
*out = new(ServiceLoadBalancerRule)
(*in).DeepCopyInto(*out)
}
if in.ExternalNames != nil {
in, out := &in.ExternalNames, &out.ExternalNames
*out = new(ServiceExternalNameRule)
(*in).DeepCopyInto(*out)
}
if in.NodePorts != nil {
in, out := &in.NodePorts, &out.NodePorts
*out = new(ServiceNodePortRule)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceServicesBody.
func (in *NamespaceRuleEnforceServicesBody) DeepCopy() *NamespaceRuleEnforceServicesBody {
if in == nil {
return nil
}
out := new(NamespaceRuleEnforceServicesBody)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnforceWorkloadsBody) {
*out = *in
@@ -185,3 +221,80 @@ func (in *OCIRegistry) DeepCopy() *OCIRegistry {
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) {
*out = *in
if in.Hostnames != nil {
in, out := &in.Hostnames, &out.Hostnames
*out = make([]api.ExpressionMatch, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExternalNameRule.
func (in *ServiceExternalNameRule) DeepCopy() *ServiceExternalNameRule {
if in == nil {
return nil
}
out := new(ServiceExternalNameRule)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceLoadBalancerRule) DeepCopyInto(out *ServiceLoadBalancerRule) {
*out = *in
if in.CIDRs != nil {
in, out := &in.CIDRs, &out.CIDRs
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceLoadBalancerRule.
func (in *ServiceLoadBalancerRule) DeepCopy() *ServiceLoadBalancerRule {
if in == nil {
return nil
}
out := new(ServiceLoadBalancerRule)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceNodePortRange) DeepCopyInto(out *ServiceNodePortRange) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceNodePortRange.
func (in *ServiceNodePortRange) DeepCopy() *ServiceNodePortRange {
if in == nil {
return nil
}
out := new(ServiceNodePortRange)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceNodePortRule) DeepCopyInto(out *ServiceNodePortRule) {
*out = *in
if in.Ports != nil {
in, out := &in.Ports, &out.Ports
*out = make([]ServiceNodePortRange, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceNodePortRule.
func (in *ServiceNodePortRule) DeepCopy() *ServiceNodePortRule {
if in == nil {
return nil
}
out := new(ServiceNodePortRule)
in.DeepCopyInto(out)
return out
}