Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions internal/k8s/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -1527,10 +1527,10 @@ func pdbBlocksEvictionsMessage(pdb *policyv1.PodDisruptionBudget) string {
}

// sharedRWOVolumeConflicts returns the names of PVCs the Deployment's pod
// template MOUNTS whose access modes permit only single-node attach
// (ReadWriteOnce / ReadWriteOncePod, no ReadWriteMany) — a hard conflict for a
// multi-replica Deployment. Only PVCs present in cache with known access modes
// are considered; an unverifiable PVC is skipped rather than guessed at.
// template MOUNTS whose bound access modes permit only single-node attach
// (ReadWriteOnce / ReadWriteOncePod, no ReadWriteMany). Pending PVCs are skipped
// here because the binding/provisioning failure is the actionable blocker until a
// real volume can attach.
func sharedRWOVolumeConflicts(cache *ResourceCache, d *appsv1.Deployment) []string {
pvcl := cache.PersistentVolumeClaims()
if pvcl == nil {
Expand All @@ -1547,7 +1547,7 @@ func sharedRWOVolumeConflicts(cache *ResourceCache, d *appsv1.Deployment) []stri
if err != nil || pvc == nil {
continue
}
if pvcSingleNodeAccessOnly(pvc) {
if pvc.Status.Phase == corev1.ClaimBound && pvcSingleNodeAccessOnly(pvc) {
out = append(out, pvc.Name)
}
}
Expand Down
40 changes: 39 additions & 1 deletion internal/k8s/detect_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ func suspiciousCoreDNSReason(corefile string) (string, bool) {
type envServiceRef struct {
namespace string
name string
host string
port int32
display string
}
Expand Down Expand Up @@ -174,6 +175,15 @@ func findEnvServiceRefChecks(cache *ResourceCache, workloads []envServiceWorkloa
if cache == nil || cache.Services() == nil {
return nil
}
var nodeHosts map[string]bool
nodeHostsLoaded := false
isNodeHost := func(host string) bool {
if !nodeHostsLoaded {
nodeHosts = envServiceNodeHosts(cache)
nodeHostsLoaded = true
}
return nodeHosts[strings.ToLower(host)]
}
var out []EnvServiceRefCheck
for _, wl := range workloads {
containers := make([]corev1.Container, 0, len(wl.spec.InitContainers)+len(wl.spec.Containers))
Expand All @@ -200,6 +210,9 @@ func findEnvServiceRefChecks(cache *ResourceCache, workloads []envServiceWorkloa
if !ok {
continue
}
if isNodeHost(ref.host) {
continue
}
age := ageSeconds(now, wl.created)
check := EnvServiceRefCheck{
WorkloadGroup: wl.group,
Expand Down Expand Up @@ -627,7 +640,7 @@ func parseEnvServiceRef(value, defaultNamespace string) (envServiceRef, bool) {
}

parts := strings.Split(host, ".")
ref := envServiceRef{namespace: defaultNamespace, port: int32(port64), display: fmt.Sprintf("%s:%d", host, port64)}
ref := envServiceRef{namespace: defaultNamespace, host: host, port: int32(port64), display: fmt.Sprintf("%s:%d", host, port64)}
switch {
case len(parts) == 1:
ref.name = parts[0]
Expand All @@ -646,6 +659,31 @@ func parseEnvServiceRef(value, defaultNamespace string) (envServiceRef, bool) {
return ref, true
}

func envServiceNodeHosts(cache *ResourceCache) map[string]bool {
out := map[string]bool{}
if cache == nil || cache.Nodes() == nil {
return out
}
nodes, err := cache.Nodes().List(labels.Everything())
if err != nil {
logConfigListError("Node", "", err)
return out
}
add := func(value string) {
value = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(value), "."))
if value != "" {
out[value] = true
}
}
for _, node := range nodes {
add(node.Name)
for _, addr := range node.Status.Addresses {
add(addr.Address)
}
}
return out
}

func splitHostPort(value string) (string, string, bool) {
if host, port, err := net.SplitHostPort(value); err == nil {
return host, port, true
Expand Down
116 changes: 102 additions & 14 deletions internal/k8s/detect_missing_refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"hash/fnv"
"log"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -627,14 +628,23 @@ func DetectMissingWebhookRefs(cache *ResourceCache, dynamicCache *DynamicResourc
return items
}

emit := func(kind, group, name, source, svcNS, svcName string, age time.Duration) Detection {
return withFix(missingRefProblem(kind, group, "", name,
"Missing webhook backend Service",
fmt.Sprintf("%s references Service %q in namespace %q which does not exist",
source, svcName, svcNS),
emit := func(kind, group, name, sourceRefPhrase, svcNS, svcName, severity, policySummary string, age time.Duration) Detection {
reason := "Missing webhook backend Service"
cause := fmt.Sprintf("Webhook backend Service %q in namespace %q doesn't exist.", svcName, svcNS)
if severity == "warning" {
cause += " One or more referencing webhooks use failurePolicy=Ignore, so admission proceeds but the webhook's validation or mutation is bypassed."
} else {
cause += " At least one referencing webhook uses failurePolicy=Fail (the Kubernetes default), so matching admission requests are blocked."
}
det := withFix(missingRefProblemSev(kind, group, "", name, severity,
reason,
fmt.Sprintf("%s Service %q in namespace %q which does not exist (%s)",
sourceRefPhrase, svcName, svcNS, policySummary),
age),
fmt.Sprintf("Webhook backend Service %q in namespace %q doesn't exist, so admission requests can't reach it — rules are bypassed (failurePolicy=Ignore) or admission is blocked (failurePolicy=Fail).", svcName, svcNS),
cause,
fmt.Sprintf("Restore Service %q and its endpoints in namespace %q, or fix clientConfig.service to point at the correct healthy Service.", svcName, svcNS))
det.Fingerprint = missingRefFingerprint(reason, "clientConfig.service:"+svcNS+"/"+svcName)
return det
}

checkWebhookList := func(items []*unstructured.Unstructured, ownerKind, ownerGroup, webhookPath string) []Detection {
Expand All @@ -645,7 +655,7 @@ func DetectMissingWebhookRefs(cache *ResourceCache, dynamicCache *DynamicResourc
continue
}
age := now.Sub(item.GetCreationTimestamp().Time)
seen := map[string]bool{}
missingByService := map[string]*webhookMissingBackend{}
for _, w := range webhooks {
wm, ok := w.(map[string]any)
if !ok {
Expand All @@ -660,17 +670,25 @@ func DetectMissingWebhookRefs(cache *ResourceCache, dynamicCache *DynamicResourc
if svcName == "" || svcNS == "" {
continue
}
key := svcNS + "/" + svcName
if seen[key] {
continue
}
seen[key] = true
if _, err := svcLister.Services(svcNS).Get(svcName); err != nil {
whName, _ := wm["name"].(string)
source := fmt.Sprintf("webhook %q clientConfig.service", whName)
problems = append(problems, emit(ownerKind, ownerGroup, item.GetName(), source, svcNS, svcName, age))
policy := webhookFailurePolicy(wm)
key := svcNS + "/" + svcName
missing := missingByService[key]
if missing == nil {
missing = &webhookMissingBackend{
serviceNamespace: svcNS,
serviceName: svcName,
}
missingByService[key] = missing
}
missing.addWebhook(whName, policy)
}
}
for _, key := range sortedWebhookMissingBackendKeys(missingByService) {
missing := missingByService[key]
problems = append(problems, emit(ownerKind, ownerGroup, item.GetName(), missing.sourceReferencePhrase(), missing.serviceNamespace, missing.serviceName, missing.severity, missing.policySummary(), age))
}
}
return problems
}
Expand All @@ -687,6 +705,76 @@ func DetectMissingWebhookRefs(cache *ResourceCache, dynamicCache *DynamicResourc
return out
}

type webhookMissingBackend struct {
serviceNamespace string
serviceName string
severity string
webhooks []string
policies map[string]bool
}

func (m *webhookMissingBackend) addWebhook(name string, policy string) {
if name == "" {
name = "<unnamed>"
}
m.webhooks = append(m.webhooks, name)
if m.policies == nil {
m.policies = map[string]bool{}
}
m.policies[policy] = true
severity := webhookFailurePolicySeverity(policy)
if m.severity == "" || severity == "critical" {
m.severity = severity
}
}

func (m *webhookMissingBackend) sourceReferencePhrase() string {
names := append([]string(nil), m.webhooks...)
sort.Strings(names)
quoted := make([]string, 0, len(names))
for _, name := range names {
quoted = append(quoted, fmt.Sprintf("%q", name))
}
if len(quoted) == 1 {
return fmt.Sprintf("webhook %s clientConfig.service references", quoted[0])
}
return fmt.Sprintf("webhooks %s clientConfig.service reference", strings.Join(quoted, ", "))
}

func (m *webhookMissingBackend) policySummary() string {
if m.policies["Fail"] {
if m.policies["Ignore"] {
return "failurePolicy=Fail/Ignore"
}
return "failurePolicy=Fail"
}
return "failurePolicy=Ignore"
}

func webhookFailurePolicy(wm map[string]any) string {
policy, _, _ := unstructured.NestedString(wm, "failurePolicy")
if strings.EqualFold(policy, "Ignore") {
return "Ignore"
}
return "Fail"
}

func webhookFailurePolicySeverity(policy string) string {
if policy == "Ignore" {
return "warning"
}
return "critical"
}

func sortedWebhookMissingBackendKeys(in map[string]*webhookMissingBackend) []string {
keys := make([]string, 0, len(in))
for key := range in {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

// DetectMissingGatewayRefs scans Gateway API Routes for backend Service refs
// that point at missing Services or missing Service ports. Controller status
// usually reports these via ResolvedRefs=False, but this structural check still
Expand Down
93 changes: 92 additions & 1 deletion internal/k8s/detect_missing_refs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,59 @@ func TestDetectMissingWebhookRefs(t *testing.T) {
}
}

func TestDetectMissingWebhookRefsFailurePolicySeverity(t *testing.T) {
defer ResetTestState()
defer ResetTestDynamicState()

now := metav1.NewTime(time.Now().Add(-5 * time.Minute))
client := fake.NewClientset()
if err := InitTestResourceCache(client); err != nil {
t.Fatalf("InitTestResourceCache: %v", err)
}

vwhGVR := schema.GroupVersionResource{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingwebhookconfigurations"}
scheme := runtime.NewScheme()
dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
scheme,
map[schema.GroupVersionResource]string{vwhGVR: "ValidatingWebhookConfigurationList"},
webhookConfig("ValidatingWebhookConfiguration", "validate-policy", now, []any{
webhookWithServicePolicy("explicit-ignore", "hooks", "ignore-svc", "Ignore"),
webhookWithServicePolicy("explicit-fail", "hooks", "fail-svc", "Fail"),
webhookWithService("default-fail", "hooks", "default-svc"),
webhookWithServicePolicy("mixed-ignore", "hooks", "mixed-svc", "Ignore"),
webhookWithServicePolicy("mixed-fail", "hooks", "mixed-svc", "Fail"),
webhookWithURL("external"),
}),
)
if err := InitTestDynamicResourceCache(dynClient, []APIResource{
{Group: "admissionregistration.k8s.io", Version: "v1", Kind: "ValidatingWebhookConfiguration", Name: "validatingwebhookconfigurations", Verbs: []string{"list", "watch"}},
}); err != nil {
t.Fatalf("InitTestDynamicResourceCache: %v", err)
}
dynCache := GetDynamicResourceCache()
if err := dynCache.EnsureWatching(vwhGVR); err != nil {
t.Fatalf("EnsureWatching validating webhooks: %v", err)
}
if !dynCache.WaitForSync(vwhGVR, 2*time.Second) {
t.Fatal("validating webhook dynamic cache did not sync")
}

problems := DetectMissingWebhookRefs(GetResourceCache(), dynCache, GetResourceDiscovery(), "")
if len(problems) != 4 {
t.Fatalf("expected one row per missing backend Service, got %d: %+v", len(problems), problems)
}
assertWebhookBackendSeverity(t, problems, "ignore-svc", "warning", "failurePolicy=Ignore")
assertWebhookBackendSeverity(t, problems, "fail-svc", "critical", "failurePolicy=Fail")
assertWebhookBackendSeverity(t, problems, "default-svc", "critical", "failurePolicy=Fail")
assertWebhookBackendSeverity(t, problems, "mixed-svc", "critical", "failurePolicy=Fail/Ignore")
assertWebhookBackendMessage(t, problems, "mixed-svc", `webhooks "mixed-fail", "mixed-ignore" clientConfig.service reference Service`)
for _, p := range problems {
if hasSubstr(p.Message, "external") {
t.Errorf("URL-based webhook should not flag: %+v", p)
}
}
}

func TestDetectMissingGatewayRefs(t *testing.T) {
defer ResetTestState()
defer ResetTestDynamicState()
Expand Down Expand Up @@ -906,7 +959,11 @@ func gatewayReferenceGrant(name, namespace string, ts metav1.Time, fromGroup, fr
}

func webhookWithService(name, namespace, service string) map[string]any {
return map[string]any{
return webhookWithServicePolicy(name, namespace, service, "")
}

func webhookWithServicePolicy(name, namespace, service, failurePolicy string) map[string]any {
webhook := map[string]any{
"name": name,
"clientConfig": map[string]any{
"service": map[string]any{
Expand All @@ -915,6 +972,40 @@ func webhookWithService(name, namespace, service string) map[string]any {
},
},
}
if failurePolicy != "" {
webhook["failurePolicy"] = failurePolicy
}
return webhook
}

func assertWebhookBackendSeverity(t *testing.T, problems []Detection, service, severity, policyText string) {
t.Helper()
for _, p := range problems {
if !hasSubstr(p.Message, service) {
continue
}
if p.Severity != severity {
t.Fatalf("%s severity = %q, want %q: %+v", service, p.Severity, severity, p)
}
if !hasSubstr(p.Message, policyText) || !hasSubstr(p.Cause, "failurePolicy=") {
t.Fatalf("%s should name policy in message/cause, got message=%q cause=%q", service, p.Message, p.Cause)
}
return
}
t.Fatalf("missing webhook backend problem for Service %s: %+v", service, problems)
}

func assertWebhookBackendMessage(t *testing.T, problems []Detection, service, text string) {
t.Helper()
for _, p := range problems {
if hasSubstr(p.Message, service) {
if !hasSubstr(p.Message, text) {
t.Fatalf("%s message = %q, want substring %q", service, p.Message, text)
}
return
}
}
t.Fatalf("missing webhook backend problem for Service %s: %+v", service, problems)
}

func webhookWithURL(name string) map[string]any {
Expand Down
Loading
Loading