mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-25 16:07:24 +00:00
* 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: add metadata enforcement Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * feat: add metadata enforcement Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: add resourcepoolclaim validation 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> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
532 lines
14 KiB
Go
532 lines
14 KiB
Go
// Copyright 2020-2026 Project Capsule Authors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package validation
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/util/intstr"
|
|
|
|
apirules "github.com/projectcapsule/capsule/pkg/api/rules"
|
|
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
|
ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine"
|
|
"github.com/projectcapsule/capsule/pkg/runtime/events"
|
|
)
|
|
|
|
func TestServiceRulesValidateExternalNames(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
svc *corev1.Service
|
|
enforceBodies []*apirules.NamespaceRuleEnforceBody
|
|
wantNil bool
|
|
wantBlocking bool
|
|
wantFinal bool
|
|
wantAudits int
|
|
wantErr string
|
|
wantMessage []string
|
|
}{
|
|
{
|
|
name: "nil service returns nil evaluation",
|
|
svc: nil,
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "non ExternalName service returns nil evaluation",
|
|
svc: clusterIPServiceForExternalNameTest("cluster-ip"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(apirules.ActionTypeDeny, expressionMatchForTest(".*")),
|
|
},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "empty externalName returns nil evaluation",
|
|
svc: externalNameServiceForTest("empty", " "),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(apirules.ActionTypeDeny, expressionMatchForTest(".*")),
|
|
},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "no rules allows externalName without final decision",
|
|
svc: externalNameServiceForTest("external", "api.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
{
|
|
Action: apirules.ActionTypeAllow,
|
|
},
|
|
},
|
|
wantFinal: false,
|
|
wantBlocking: false,
|
|
},
|
|
{
|
|
name: "allow exact hostname",
|
|
svc: externalNameServiceForTest("external", "internal.git.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAllow,
|
|
exactMatchForTest("internal.git.com"),
|
|
),
|
|
},
|
|
wantFinal: true,
|
|
wantBlocking: false,
|
|
wantMessage: []string{
|
|
`externalName hostname "internal.git.com" at spec.externalName is allowed by namespace rule`,
|
|
`"internal.git.com" matched hostname rule exact: internal.git.com`,
|
|
},
|
|
},
|
|
{
|
|
name: "allow regex hostname",
|
|
svc: externalNameServiceForTest("external", "api.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAllow,
|
|
expressionMatchForTest(".*\\.example\\.com"),
|
|
),
|
|
},
|
|
wantFinal: true,
|
|
wantBlocking: false,
|
|
wantMessage: []string{
|
|
`externalName hostname "api.example.com" at spec.externalName is allowed by namespace rule`,
|
|
`"api.example.com" matched hostname rule exp: .*\.example\.com`,
|
|
},
|
|
},
|
|
{
|
|
name: "allow exact and regex in same matcher",
|
|
svc: externalNameServiceForTest("external", "combined.internal.git.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAllow,
|
|
runtime.ExpressionMatch{
|
|
Exact: []string{
|
|
"combined.internal.git.com",
|
|
},
|
|
ExpressionRegex: runtime.ExpressionRegex{
|
|
Expression: "combined\\..*\\.example\\.com",
|
|
},
|
|
},
|
|
),
|
|
},
|
|
wantFinal: true,
|
|
wantBlocking: false,
|
|
wantMessage: []string{
|
|
`externalName hostname "combined.internal.git.com" at spec.externalName is allowed by namespace rule`,
|
|
`exact: combined.internal.git.com; exp: combined\..*\.example\.com`,
|
|
},
|
|
},
|
|
{
|
|
name: "allow miss denies and reports allowed hostnames",
|
|
svc: externalNameServiceForTest("external", "api.bad.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAllow,
|
|
exactMatchForTest("internal.git.com"),
|
|
expressionMatchForTest(".*\\.example\\.com"),
|
|
),
|
|
},
|
|
wantBlocking: true,
|
|
wantFinal: false,
|
|
wantMessage: []string{
|
|
`externalName hostname "api.bad.com" at spec.externalName is not allowed by namespace rule`,
|
|
`Allowed hostnames`,
|
|
`exact: internal.git.com`,
|
|
`exp: .*\.example\.com`,
|
|
},
|
|
},
|
|
{
|
|
name: "deny matching exact hostname",
|
|
svc: externalNameServiceForTest("external", "blocked.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeDeny,
|
|
exactMatchForTest("blocked.example.com"),
|
|
),
|
|
},
|
|
wantBlocking: true,
|
|
wantFinal: true,
|
|
wantMessage: []string{
|
|
`externalName hostname "blocked.example.com" at spec.externalName is denied by namespace rule`,
|
|
`"blocked.example.com" matched hostname rule exact: blocked.example.com`,
|
|
},
|
|
},
|
|
{
|
|
name: "later deny overrides earlier allow",
|
|
svc: externalNameServiceForTest("external", "blocked.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAllow,
|
|
expressionMatchForTest(".*\\.example\\.com"),
|
|
),
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeDeny,
|
|
exactMatchForTest("blocked.example.com"),
|
|
),
|
|
},
|
|
wantBlocking: true,
|
|
wantFinal: true,
|
|
wantMessage: []string{
|
|
`externalName hostname "blocked.example.com" at spec.externalName is denied by namespace rule`,
|
|
`"blocked.example.com" matched hostname rule exact: blocked.example.com`,
|
|
},
|
|
},
|
|
{
|
|
name: "later allow overrides earlier deny",
|
|
svc: externalNameServiceForTest("external", "trusted.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeDeny,
|
|
expressionMatchForTest(".*\\.example\\.com"),
|
|
),
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAllow,
|
|
exactMatchForTest("trusted.example.com"),
|
|
),
|
|
},
|
|
wantBlocking: false,
|
|
wantFinal: true,
|
|
wantMessage: []string{
|
|
`externalName hostname "trusted.example.com" at spec.externalName is allowed by namespace rule`,
|
|
`"trusted.example.com" matched hostname rule exact: trusted.example.com`,
|
|
},
|
|
},
|
|
{
|
|
name: "audit match is observational",
|
|
svc: externalNameServiceForTest("external", "audit.internal"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAudit,
|
|
expressionMatchForTest("audit\\..*"),
|
|
),
|
|
},
|
|
wantBlocking: false,
|
|
wantFinal: false,
|
|
wantAudits: 1,
|
|
wantMessage: []string{
|
|
`externalName hostname "audit.internal" at spec.externalName matched audit namespace rule`,
|
|
`"audit.internal" matched hostname rule exp: audit\..*`,
|
|
},
|
|
},
|
|
{
|
|
name: "audit does not satisfy allow list",
|
|
svc: externalNameServiceForTest("external", "audit.internal"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAudit,
|
|
expressionMatchForTest("audit\\..*"),
|
|
),
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAllow,
|
|
expressionMatchForTest("allowed\\..*"),
|
|
),
|
|
},
|
|
wantBlocking: true,
|
|
wantFinal: false,
|
|
wantAudits: 1,
|
|
wantMessage: []string{
|
|
`externalName hostname "audit.internal" at spec.externalName is not allowed by namespace rule`,
|
|
`Allowed hostnames`,
|
|
`exp: allowed\..*`,
|
|
},
|
|
},
|
|
{
|
|
name: "negated regex deny matches untrusted hostname",
|
|
svc: externalNameServiceForTest("external", "api.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeDeny,
|
|
negatedExpressionMatchForTest("trusted\\..*"),
|
|
),
|
|
},
|
|
wantBlocking: true,
|
|
wantFinal: true,
|
|
wantMessage: []string{
|
|
`externalName hostname "api.example.com" at spec.externalName is denied by namespace rule`,
|
|
`"api.example.com" matched hostname rule exp: trusted\..*`,
|
|
},
|
|
},
|
|
{
|
|
name: "negated regex deny does not match trusted hostname",
|
|
svc: externalNameServiceForTest("external", "trusted.api"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeDeny,
|
|
negatedExpressionMatchForTest("trusted\\..*"),
|
|
),
|
|
},
|
|
wantBlocking: false,
|
|
wantFinal: false,
|
|
},
|
|
{
|
|
name: "invalid regex returns matcher error",
|
|
svc: externalNameServiceForTest("external", "api.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeDeny,
|
|
expressionMatchForTest("["),
|
|
),
|
|
},
|
|
wantErr: `externalName hostname: invalid rule`,
|
|
},
|
|
{
|
|
name: "nil enforce body is ignored",
|
|
svc: externalNameServiceForTest("external", "api.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
nil,
|
|
externalNameEnforceForTest(
|
|
apirules.ActionTypeAllow,
|
|
expressionMatchForTest(".*\\.example\\.com"),
|
|
),
|
|
},
|
|
wantFinal: true,
|
|
wantBlocking: false,
|
|
},
|
|
{
|
|
name: "enforce without externalName rules is ignored",
|
|
svc: externalNameServiceForTest("external", "api.example.com"),
|
|
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
|
|
{
|
|
Action: apirules.ActionTypeAllow,
|
|
},
|
|
},
|
|
wantFinal: false,
|
|
wantBlocking: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := serviceRulesForTest()
|
|
|
|
evaluation, err := h.validateExternalNames(tt.svc, tt.enforceBodies)
|
|
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
if tt.wantNil {
|
|
if evaluation != nil {
|
|
t.Fatalf("expected nil evaluation, got %#v", evaluation)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if evaluation == nil {
|
|
t.Fatalf("expected evaluation, got nil")
|
|
}
|
|
|
|
if tt.wantBlocking && evaluation.Blocking == nil {
|
|
t.Fatalf("expected blocking decision, got nil")
|
|
}
|
|
|
|
if !tt.wantBlocking && evaluation.Blocking != nil {
|
|
t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking)
|
|
}
|
|
|
|
if tt.wantFinal && evaluation.Final == nil {
|
|
t.Fatalf("expected final decision, got nil")
|
|
}
|
|
|
|
if !tt.wantFinal && evaluation.Final != nil {
|
|
t.Fatalf("expected no final decision, got %#v", evaluation.Final)
|
|
}
|
|
|
|
if len(evaluation.Audits) != tt.wantAudits {
|
|
t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits))
|
|
}
|
|
|
|
if len(tt.wantMessage) > 0 {
|
|
msg := decisionMessageForExternalNameTest(evaluation)
|
|
|
|
for _, expected := range tt.wantMessage {
|
|
if !strings.Contains(msg, expected) {
|
|
t.Fatalf("expected message %q to contain %q", msg, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
if evaluation.Final != nil {
|
|
if evaluation.Final.EventReason != events.ReasonForbiddenExternalName {
|
|
t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenExternalName)
|
|
}
|
|
}
|
|
|
|
if evaluation.Blocking != nil {
|
|
if evaluation.Blocking.EventReason != events.ReasonForbiddenExternalName {
|
|
t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenExternalName)
|
|
}
|
|
}
|
|
|
|
for _, audit := range evaluation.Audits {
|
|
if audit.EventReason != events.ReasonForbiddenExternalName {
|
|
t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenExternalName)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDescribeExpressionMatch(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
match runtime.ExpressionMatch
|
|
want string
|
|
}{
|
|
{
|
|
name: "empty matcher",
|
|
match: runtime.ExpressionMatch{},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "exact only",
|
|
match: runtime.ExpressionMatch{
|
|
Exact: []string{
|
|
"internal.git.com",
|
|
"api.example.com",
|
|
},
|
|
},
|
|
want: "exact: internal.git.com, api.example.com",
|
|
},
|
|
{
|
|
name: "expression only",
|
|
match: runtime.ExpressionMatch{
|
|
ExpressionRegex: runtime.ExpressionRegex{
|
|
Expression: ".*\\.example\\.com",
|
|
},
|
|
},
|
|
want: "exp: .*\\.example\\.com",
|
|
},
|
|
{
|
|
name: "exact and expression",
|
|
match: runtime.ExpressionMatch{
|
|
Exact: []string{
|
|
"internal.git.com",
|
|
},
|
|
ExpressionRegex: runtime.ExpressionRegex{
|
|
Expression: ".*\\.example\\.com",
|
|
},
|
|
},
|
|
want: "exact: internal.git.com; exp: .*\\.example\\.com",
|
|
},
|
|
{
|
|
name: "negate is currently not included in description",
|
|
match: runtime.ExpressionMatch{
|
|
ExpressionRegex: runtime.ExpressionRegex{
|
|
Expression: "trusted\\..*",
|
|
Negate: true,
|
|
},
|
|
},
|
|
want: "exp: trusted\\..*",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := describeExpressionMatch(tt.match)
|
|
if got != tt.want {
|
|
t.Fatalf("describeExpressionMatch() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func externalNameEnforceForTest(
|
|
action apirules.ActionType,
|
|
hostnames ...runtime.ExpressionMatch,
|
|
) *apirules.NamespaceRuleEnforceBody {
|
|
return &apirules.NamespaceRuleEnforceBody{
|
|
Action: action,
|
|
Services: apirules.NamespaceRuleEnforceServicesBody{
|
|
ExternalNames: &apirules.ServiceExternalNameRule{
|
|
Hostnames: hostnames,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func externalNameServiceForTest(name string, externalName string) *corev1.Service {
|
|
return &corev1.Service{
|
|
Spec: corev1.ServiceSpec{
|
|
Type: corev1.ServiceTypeExternalName,
|
|
ExternalName: externalName,
|
|
Ports: []corev1.ServicePort{
|
|
{
|
|
Name: "https",
|
|
Port: 443,
|
|
TargetPort: intstr.FromInt(443),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func clusterIPServiceForExternalNameTest(name string) *corev1.Service {
|
|
return &corev1.Service{
|
|
Spec: corev1.ServiceSpec{
|
|
Type: corev1.ServiceTypeClusterIP,
|
|
Ports: []corev1.ServicePort{
|
|
{
|
|
Name: "http",
|
|
Port: 8080,
|
|
TargetPort: intstr.FromInt(8080),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func exactMatchForTest(values ...string) runtime.ExpressionMatch {
|
|
return runtime.ExpressionMatch{
|
|
Exact: values,
|
|
}
|
|
}
|
|
|
|
func expressionMatchForTest(expression string) runtime.ExpressionMatch {
|
|
return runtime.ExpressionMatch{
|
|
ExpressionRegex: runtime.ExpressionRegex{
|
|
Expression: expression,
|
|
},
|
|
}
|
|
}
|
|
|
|
func negatedExpressionMatchForTest(expression string) runtime.ExpressionMatch {
|
|
return runtime.ExpressionMatch{
|
|
ExpressionRegex: runtime.ExpressionRegex{
|
|
Expression: expression,
|
|
Negate: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func decisionMessageForExternalNameTest(evaluation interface {
|
|
}) string {
|
|
e, ok := evaluation.(*ruleengine.Evaluation)
|
|
if !ok || e == nil {
|
|
return ""
|
|
}
|
|
|
|
switch {
|
|
case e.Blocking != nil:
|
|
return e.Blocking.Message
|
|
case e.Final != nil:
|
|
return e.Final.Message
|
|
case len(e.Audits) > 0:
|
|
return e.Audits[0].Message
|
|
default:
|
|
return ""
|
|
}
|
|
}
|