From 8645b2a0a01ab3b99c1629aa89504045bfb947af Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 23 Jul 2026 13:17:06 +0200 Subject: [PATCH 1/8] test(component): add Test_34 NetworkNeighbors CIDR collapse e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end component test for CIDR-based collapsing of NetworkNeighbor entries (storage-side PreSave path). Models a curl client with two external destinations — an S3-style endpoint clustered in a single /24 and a broader endpoint spanning a /16 — and asserts each group collapses to its covering CIDR block. Reads the result via the dynamic client so the test compiles against storage types lacking the plural ipAddresses field and passes only when storage implements the collapse. Signed-off-by: entlein --- tests/component_test.go | 124 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/tests/component_test.go b/tests/component_test.go index 1069e7e45..ff481b4db 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -28,7 +28,9 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" "k8s.io/utils/ptr" ) @@ -3227,3 +3229,125 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { "DNS MITM: TCP to spoofed IP 128.130.194.56 must fire R0011") }) } + +// Test_34_NetworkNeighborsCIDRCollapse is an end-to-end test for storage +// PR kubescape/storage#348 (CIDR-based collapsing of NetworkNeighbor entries). +// +// The collapse runs entirely on the storage side, in the PreSave/deflate path, +// using the compiled-in defaults (NetworkIPGroupThreshold=50, floor /16), so the +// test needs no workload pod and no config. It writes one NetworkNeighborhood +// modelling a "curl" client with two external destinations, each an egress group +// of 60 host IPs (> the 50 threshold) differing only by address: +// +// - an S3-style endpoint whose addresses cluster in a single /24 +// (52.216.183.0/24, last octet spanning the top bit) -> collapses to /24 +// - a broader CDN-style endpoint spanning a whole /16 +// (100.68.0.0/16, third octet spanning the top bit) -> collapses to /16 +// +// so both the single-covering-/24 and floor-/16 paths are exercised. Groups are +// keyed by DNS, so the two destinations collapse independently. +// +// Version note: the collapse output lands in the plural `ipAddresses` field, +// which exists only on PR#348 storage — not on the pinned upstream storage Go +// types this test compiles against. We therefore read the result back through +// the DYNAMIC client, so the new field is never referenced at compile time and +// is not silently dropped at decode time. The test compiles on plain upstream +// but only passes when storage carries PR#348 — exactly the e2e contract we want. +func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + dyn := dynamic.NewForConfigOrDie(k8sClient.K8SConfig) + ctx := context.Background() + + nnGVR := schema.GroupVersionResource{ + Group: "spdx.softwarecomposition.kubescape.io", + Version: "v1beta1", + Resource: "networkneighborhoods", + } + + // hostGroup builds `count` external egress entries that differ only by IP and + // share one DNS name, so storage groups them together for collapsing. + hostGroup := func(dns string, count int, ipFn func(i int) string) []v1beta1.NetworkNeighbor { + out := make([]v1beta1.NetworkNeighbor, 0, count) + for i := 0; i < count; i++ { + out = append(out, v1beta1.NetworkNeighbor{ + Identifier: fmt.Sprintf("%s-%d", dns, i), + Type: "external", + DNS: dns, + DNSNames: []string{dns}, + IPAddress: ipFn(i), + Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}}, + }) + } + return out + } + + const hostCount = 60 // above the default NetworkIPGroupThreshold of 50 + + // Case 1: S3-style endpoint clustered in one /24 -> collapses to /24. + const s3DNS = "s3.eu-central-1.amazonaws.com." + const wantS3CIDR = "52.216.183.0/24" + // Case 2: broader endpoint spanning a whole /16 -> collapses to the /16 floor. + const broadDNS = "objects.example-cdn.net." + const wantBroadCIDR = "100.68.0.0/16" + + egress := append( + hostGroup(s3DNS, hostCount, func(i int) string { return fmt.Sprintf("52.216.183.%d", i*4) }), + hostGroup(broadDNS, hostCount, func(i int) string { return fmt.Sprintf("100.68.%d.0", i*4) })..., + ) + + ns := testutils.NewRandomNamespace() + const overlayName = "cidr-collapse-34" + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": overlayName}}, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + {Name: "curl", Egress: egress}, + }, + }, + } + _, err := storageClient.NetworkNeighborhoods(ns.Name).Create(ctx, nn, metav1.CreateOptions{}) + require.NoError(t, err, "create NetworkNeighborhood with two %d-host egress groups", hostCount) + + // Collapse runs on the storage PreSave path. Poll for BOTH collapsed blocks; + // if not yet present, re-save to re-run the pass (idempotent by design). + var finalRaw string + require.Eventually(t, func() bool { + got, gErr := dyn.Resource(nnGVR).Namespace(ns.Name).Get(ctx, overlayName, metav1.GetOptions{}) + if gErr != nil { + return false + } + raw, _ := json.Marshal(got.Object) + if strings.Contains(string(raw), wantS3CIDR) && strings.Contains(string(raw), wantBroadCIDR) { + finalRaw = string(raw) + return true + } + _, _ = dyn.Resource(nnGVR).Namespace(ns.Name).Update(ctx, got, metav1.UpdateOptions{}) + return false + }, 120*time.Second, 3*time.Second, + "storage should collapse both groups into %s and %s (requires PR#348)", wantS3CIDR, wantBroadCIDR) + + // Positive: both collapsed CIDR blocks are present. + assert.Contains(t, finalRaw, wantS3CIDR, "S3 endpoint should collapse to %s", wantS3CIDR) + assert.Contains(t, finalRaw, wantBroadCIDR, "broad endpoint should collapse to %s", wantBroadCIDR) + // Negative: interior host /32s from each group must be gone as standalone IPs. + for _, last := range []int{4, 120, 200} { + assert.NotContains(t, finalRaw, fmt.Sprintf("%q", fmt.Sprintf("52.216.183.%d", last)), + "host /32 52.216.183.%d should have been collapsed away", last) + } + for _, third := range []int{4, 120, 200} { + assert.NotContains(t, finalRaw, fmt.Sprintf("%q", fmt.Sprintf("100.68.%d.0", third)), + "host /32 100.68.%d.0 should have been collapsed away", third) + } +} From b63fc26cbc634c1341bcb5b1a3a58b07e83a8d35 Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 23 Jul 2026 14:06:17 +0200 Subject: [PATCH 2/8] test(component): drive Test_34 collapse via CollapseConfiguration CR + dump on failure Storage wires SetCollapseSettings(collapseSettingsFromCRD), so with no CR present the effective network threshold isn't the assumed 50 default and the groups never collapsed. Apply an explicit CollapseConfiguration (default) with networkIPGroupThreshold=5, and replace the silent Eventually with a manual poll that logs the stored NetworkNeighborhood if collapse never appears, so a failing run shows whether the collapse ran with unexpected CIDRs or not at all. Signed-off-by: entlein --- tests/component_test.go | 59 ++++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index ff481b4db..f379895c3 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -26,8 +26,10 @@ import ( "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" @@ -3268,6 +3270,30 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { Resource: "networkneighborhoods", } + // Apply the cluster-scoped CollapseConfiguration singleton the production + // deflate path reads (storage wires SetCollapseSettings(collapseSettingsFromCRD)). + // Lower the network group threshold so our 60-host groups trip collapsing + // deterministically; keep the default /16 floor. Untyped: the network fields + // exist only on PR#348 storage. + ccGVR := schema.GroupVersionResource{ + Group: "spdx.softwarecomposition.kubescape.io", + Version: "v1beta1", + Resource: "collapseconfigurations", + } + cc := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", + "kind": "CollapseConfiguration", + "metadata": map[string]interface{}{"name": "default"}, + "spec": map[string]interface{}{ + "networkIPGroupThreshold": int64(5), + "networkCIDRFloorBits": int64(16), + }, + }} + if _, ccErr := dyn.Resource(ccGVR).Create(ctx, cc, metav1.CreateOptions{}); ccErr != nil && !apierrors.IsAlreadyExists(ccErr) { + require.NoError(t, ccErr, "apply CollapseConfiguration") + } + t.Cleanup(func() { _ = dyn.Resource(ccGVR).Delete(ctx, "default", metav1.DeleteOptions{}) }) + // hostGroup builds `count` external egress entries that differ only by IP and // share one DNS name, so storage groups them together for collapsing. hostGroup := func(dns string, count int, ipFn func(i int) string) []v1beta1.NetworkNeighbor { @@ -3320,23 +3346,30 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { _, err := storageClient.NetworkNeighborhoods(ns.Name).Create(ctx, nn, metav1.CreateOptions{}) require.NoError(t, err, "create NetworkNeighborhood with two %d-host egress groups", hostCount) - // Collapse runs on the storage PreSave path. Poll for BOTH collapsed blocks; - // if not yet present, re-save to re-run the pass (idempotent by design). + // Collapse runs on the storage PreSave path; the config provider is + // TTL-cached, so it may take a couple of re-saves for the threshold to take + // effect. Poll, re-saving to re-trigger PreSave. On failure, dump the stored + // object so the run is self-diagnosing (collapse ran with wrong CIDRs vs. + // never ran at all). var finalRaw string - require.Eventually(t, func() bool { + collapsed := false + for attempt := 0; attempt < 40; attempt++ { got, gErr := dyn.Resource(nnGVR).Namespace(ns.Name).Get(ctx, overlayName, metav1.GetOptions{}) - if gErr != nil { - return false - } - raw, _ := json.Marshal(got.Object) - if strings.Contains(string(raw), wantS3CIDR) && strings.Contains(string(raw), wantBroadCIDR) { + if gErr == nil { + raw, _ := json.Marshal(got.Object) finalRaw = string(raw) - return true + if strings.Contains(finalRaw, wantS3CIDR) && strings.Contains(finalRaw, wantBroadCIDR) { + collapsed = true + break + } + _, _ = dyn.Resource(nnGVR).Namespace(ns.Name).Update(ctx, got, metav1.UpdateOptions{}) } - _, _ = dyn.Resource(nnGVR).Namespace(ns.Name).Update(ctx, got, metav1.UpdateOptions{}) - return false - }, 120*time.Second, 3*time.Second, - "storage should collapse both groups into %s and %s (requires PR#348)", wantS3CIDR, wantBroadCIDR) + time.Sleep(3 * time.Second) + } + if !collapsed { + t.Logf("stored NetworkNeighborhood after polling (no collapse observed):\n%s", finalRaw) + t.Fatalf("storage did not collapse groups into %s and %s (requires PR#348)", wantS3CIDR, wantBroadCIDR) + } // Positive: both collapsed CIDR blocks are present. assert.Contains(t, finalRaw, wantS3CIDR, "S3 endpoint should collapse to %s", wantS3CIDR) From 99bbcabba4de4a458e8145a0f2aad0715db99ee1 Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 23 Jul 2026 15:30:54 +0200 Subject: [PATCH 3/8] test 34 adding confusion tests where multiple things collapse Signed-off-by: entlein --- tests/component_test.go | 325 ++++++++++++++++++++++++++++++---------- 1 file changed, 242 insertions(+), 83 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index f379895c3..8fdb27aa1 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -3265,28 +3265,29 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { ctx := context.Background() nnGVR := schema.GroupVersionResource{ - Group: "spdx.softwarecomposition.kubescape.io", - Version: "v1beta1", - Resource: "networkneighborhoods", + Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "networkneighborhoods", } - - // Apply the cluster-scoped CollapseConfiguration singleton the production - // deflate path reads (storage wires SetCollapseSettings(collapseSettingsFromCRD)). - // Lower the network group threshold so our 60-host groups trip collapsing - // deterministically; keep the default /16 floor. Untyped: the network fields - // exist only on PR#348 storage. ccGVR := schema.GroupVersionResource{ - Group: "spdx.softwarecomposition.kubescape.io", - Version: "v1beta1", - Resource: "collapseconfigurations", + Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "collapseconfigurations", } + + // ONE cluster-scoped CollapseConfiguration singleton drives BOTH the network + // CIDR collapse (PR#348: networkIPGroupThreshold / networkCIDRFloorBits) AND + // the pre-existing path/endpoint collapse (openDynamicThreshold / + // endpointDynamicThreshold). We set every knob low so each scenario trips + // deterministically — and, crucially, so the mixed subtest can prove the two + // collapse families do NOT interfere. Untyped: the network fields exist only + // on PR#348 storage. Note the network floor is set to /16 explicitly — the + // shipped default is /24 (NetworkCIDRFloorBits=24), NOT the documented 16. cc := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", "kind": "CollapseConfiguration", "metadata": map[string]interface{}{"name": "default"}, "spec": map[string]interface{}{ - "networkIPGroupThreshold": int64(5), - "networkCIDRFloorBits": int64(16), + "networkIPGroupThreshold": int64(5), + "networkCIDRFloorBits": int64(16), + "openDynamicThreshold": int64(5), + "endpointDynamicThreshold": int64(5), }, }} if _, ccErr := dyn.Resource(ccGVR).Create(ctx, cc, metav1.CreateOptions{}); ccErr != nil && !apierrors.IsAlreadyExists(ccErr) { @@ -3294,6 +3295,8 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { } t.Cleanup(func() { _ = dyn.Resource(ccGVR).Delete(ctx, "default", metav1.DeleteOptions{}) }) + const hostCount = 60 // comfortably above every threshold (5 here, 50 default) + // hostGroup builds `count` external egress entries that differ only by IP and // share one DNS name, so storage groups them together for collapsing. hostGroup := func(dns string, count int, ipFn func(i int) string) []v1beta1.NetworkNeighbor { @@ -3301,86 +3304,242 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { for i := 0; i < count; i++ { out = append(out, v1beta1.NetworkNeighbor{ Identifier: fmt.Sprintf("%s-%d", dns, i), - Type: "external", - DNS: dns, - DNSNames: []string{dns}, - IPAddress: ipFn(i), - Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}}, + Type: "external", DNS: dns, DNSNames: []string{dns}, + IPAddress: ipFn(i), + Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}}, }) } return out } - const hostCount = 60 // above the default NetworkIPGroupThreshold of 50 - - // Case 1: S3-style endpoint clustered in one /24 -> collapses to /24. - const s3DNS = "s3.eu-central-1.amazonaws.com." - const wantS3CIDR = "52.216.183.0/24" - // Case 2: broader endpoint spanning a whole /16 -> collapses to the /16 floor. - const broadDNS = "objects.example-cdn.net." - const wantBroadCIDR = "100.68.0.0/16" - - egress := append( - hostGroup(s3DNS, hostCount, func(i int) string { return fmt.Sprintf("52.216.183.%d", i*4) }), - hostGroup(broadDNS, hostCount, func(i int) string { return fmt.Sprintf("100.68.%d.0", i*4) })..., - ) - - ns := testutils.NewRandomNamespace() - const overlayName = "cidr-collapse-34" - nn := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, - Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, + // createNN writes a NetworkNeighborhood with the given egress and returns nothing. + createNN := func(t *testing.T, nsName, name string, egress []v1beta1.NetworkNeighbor) { + t.Helper() + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: nsName, + Annotations: map[string]string{ + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, }, - }, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": overlayName}}, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - {Name: "curl", Egress: egress}, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}, + Containers: []v1beta1.NetworkNeighborhoodContainer{{Name: "curl", Egress: egress}}, }, - }, + } + _, err := storageClient.NetworkNeighborhoods(nsName).Create(ctx, nn, metav1.CreateOptions{}) + require.NoError(t, err, "create NetworkNeighborhood %s", name) } - _, err := storageClient.NetworkNeighborhoods(ns.Name).Create(ctx, nn, metav1.CreateOptions{}) - require.NoError(t, err, "create NetworkNeighborhood with two %d-host egress groups", hostCount) - - // Collapse runs on the storage PreSave path; the config provider is - // TTL-cached, so it may take a couple of re-saves for the threshold to take - // effect. Poll, re-saving to re-trigger PreSave. On failure, dump the stored - // object so the run is self-diagnosing (collapse ran with wrong CIDRs vs. - // never ran at all). - var finalRaw string - collapsed := false - for attempt := 0; attempt < 40; attempt++ { - got, gErr := dyn.Resource(nnGVR).Namespace(ns.Name).Get(ctx, overlayName, metav1.GetOptions{}) - if gErr == nil { - raw, _ := json.Marshal(got.Object) - finalRaw = string(raw) - if strings.Contains(finalRaw, wantS3CIDR) && strings.Contains(finalRaw, wantBroadCIDR) { - collapsed = true - break + + // pollNN reads the stored NN via the DYNAMIC client (the typed client drops + // the plural ipAddresses field that exists only on PR#348), re-saving to + // re-trigger the TTL-cached PreSave collapse, until every wanted substring is + // present. Returns the last raw JSON and whether all were found; the caller + // dumps `raw` on failure so the run is self-diagnosing. + pollNN := func(t *testing.T, nsName, name string, want []string) (string, bool) { + t.Helper() + var raw string + for attempt := 0; attempt < 40; attempt++ { + got, gErr := dyn.Resource(nnGVR).Namespace(nsName).Get(ctx, name, metav1.GetOptions{}) + if gErr == nil { + b, _ := json.Marshal(got.Object) + raw = string(b) + all := true + for _, w := range want { + if !strings.Contains(raw, w) { + all = false + break + } + } + if all { + return raw, true + } + _, _ = dyn.Resource(nnGVR).Namespace(nsName).Update(ctx, got, metav1.UpdateOptions{}) } - _, _ = dyn.Resource(nnGVR).Namespace(ns.Name).Update(ctx, got, metav1.UpdateOptions{}) + time.Sleep(3 * time.Second) } - time.Sleep(3 * time.Second) - } - if !collapsed { - t.Logf("stored NetworkNeighborhood after polling (no collapse observed):\n%s", finalRaw) - t.Fatalf("storage did not collapse groups into %s and %s (requires PR#348)", wantS3CIDR, wantBroadCIDR) + return raw, false } - // Positive: both collapsed CIDR blocks are present. - assert.Contains(t, finalRaw, wantS3CIDR, "S3 endpoint should collapse to %s", wantS3CIDR) - assert.Contains(t, finalRaw, wantBroadCIDR, "broad endpoint should collapse to %s", wantBroadCIDR) - // Negative: interior host /32s from each group must be gone as standalone IPs. - for _, last := range []int{4, 120, 200} { - assert.NotContains(t, finalRaw, fmt.Sprintf("%q", fmt.Sprintf("52.216.183.%d", last)), - "host /32 52.216.183.%d should have been collapsed away", last) - } - for _, third := range []int{4, 120, 200} { - assert.NotContains(t, finalRaw, fmt.Sprintf("%q", fmt.Sprintf("100.68.%d.0", third)), - "host /32 100.68.%d.0 should have been collapsed away", third) + // requireCollapsed polls for `want` and fails with the stored object dumped. + requireCollapsed := func(t *testing.T, nsName, name string, want []string) string { + t.Helper() + raw, ok := pollNN(t, nsName, name, want) + if !ok { + t.Logf("stored NetworkNeighborhood %s/%s (expected all of %v):\n%s", nsName, name, want, raw) + t.Fatalf("network collapse did not produce %v (requires PR#348)", want) + } + return raw } + quoted := func(s string) string { return fmt.Sprintf("%q", s) } + + // --- Case 1: S3-style endpoint clustered in one /24 -> single covering /24. + t.Run("network_single_covering_24", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + createNN(t, ns.Name, "cidr-24", hostGroup("s3.eu-central-1.amazonaws.com.", hostCount, + func(i int) string { return fmt.Sprintf("52.216.183.%d", i*4) })) + raw := requireCollapsed(t, ns.Name, "cidr-24", []string{"52.216.183.0/24"}) + for _, last := range []int{4, 120, 200} { + assert.NotContains(t, raw, quoted(fmt.Sprintf("52.216.183.%d", last)), "host /32 should be collapsed away") + } + }) + + // --- Case 2: endpoint spanning a whole /16 -> floor /16 (would be /24 buckets by default). + t.Run("network_floor_16_covering", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + createNN(t, ns.Name, "cidr-16", hostGroup("objects.example-cdn.net.", hostCount, + func(i int) string { return fmt.Sprintf("100.68.%d.0", i*4) })) + raw := requireCollapsed(t, ns.Name, "cidr-16", []string{"100.68.0.0/16"}) + for _, third := range []int{4, 120, 200} { + assert.NotContains(t, raw, quoted(fmt.Sprintf("100.68.%d.0", third)), "host /32 should be collapsed away") + } + }) + + // --- Case 3: adversarial mix in ONE group — collapsible host /32s alongside a + // pre-collapsed CIDR, the "*" sentinel, and an IPv6 literal. Hosts must + // aggregate; the non-aggregatable entries must pass through UNTOUCHED (matches + // storage's own TestCollapseIPGroups_Idempotent / _IPv6PassThrough). + t.Run("network_passthrough_cidr_star_ipv6", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + const dns = "mixed.example." + egress := hostGroup(dns, hostCount, func(i int) string { return fmt.Sprintf("203.0.113.%d", i*4) }) + egress = append(egress, + v1beta1.NetworkNeighbor{Identifier: "held-cidr", Type: "external", DNS: dns, IPAddresses: []string{"10.9.0.0/16"}}, + v1beta1.NetworkNeighbor{Identifier: "any", Type: "external", DNS: dns, IPAddresses: []string{"*"}}, + v1beta1.NetworkNeighbor{Identifier: "v6", Type: "external", DNS: dns, IPAddress: "2001:db8::1"}, + ) + createNN(t, ns.Name, "cidr-mixed", egress) + // hosts collapse to /24; held CIDR, "*", and IPv6 survive verbatim. + raw := requireCollapsed(t, ns.Name, "cidr-mixed", + []string{"203.0.113.0/24", "10.9.0.0/16", quoted("*"), "2001:db8::1"}) + for _, last := range []int{4, 120, 200} { + assert.NotContains(t, raw, quoted(fmt.Sprintf("203.0.113.%d", last)), "aggregatable host /32 should be gone") + } + }) + + // --- Case 4: DNS plurals + ports are merged/deduped and replicated onto the + // collapsed CIDR entry (matches TestCollapseIPGroups_MultiBucketReplicatesDNSNamesAndPorts). + t.Run("network_dns_plurals_replicated", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + egress := make([]v1beta1.NetworkNeighbor, 0, hostCount) + for i := 0; i < hostCount; i++ { + egress = append(egress, v1beta1.NetworkNeighbor{ + Identifier: fmt.Sprintf("multi-%d", i), + Type: "external", DNS: "multi.example.", + DNSNames: []string{"alpha.example.", "beta.example."}, + IPAddress: fmt.Sprintf("198.51.100.%d", i*4), + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, + {Name: "TCP-8443", Protocol: "TCP", Port: ptr.To(int32(8443))}, + }, + }) + } + createNN(t, ns.Name, "cidr-dns", egress) + // The single collapsed /24 entry must still carry BOTH plural DNS names and BOTH ports. + requireCollapsed(t, ns.Name, "cidr-dns", + []string{"198.51.100.0/24", "alpha.example.", "beta.example.", "8443"}) + }) + + // --- Case 5: network collapse and PATH/ENDPOINT collapse under the SAME config + // must not confuse each other. Realistic inputs only — DNS names are hostnames, + // never paths; slashes live in endpoint/open paths. Real confusion vectors: + // * a DNS WILDCARD subdomain ("*.cdn.example.") — a legit DNS value containing + // "*" — must not be confused with the network "*" any-IP sentinel; + // * HTTP endpoint paths ("/api/v1/users/") collapse to a dynamic-identifier + // wildcard, independently of the network CIDR collapse; + // * an IP-shaped OPEN path ("/data/10.0.0.5") stays a literal open, never a CIDR. + t.Run("network_and_path_no_confusion", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + + // Network side: 60 hosts -> 192.0.2.0/24. DNSNames carry a real wildcard + // subdomain (contains "*"). A SEPARATE DNS group carries the network "*" + // any-IP sentinel in IPAddresses. Both "*"s must survive in their own fields. + egress := make([]v1beta1.NetworkNeighbor, 0, hostCount+1) + for i := 0; i < hostCount; i++ { + egress = append(egress, v1beta1.NetworkNeighbor{ + Identifier: fmt.Sprintf("svc-%d", i), Type: "external", + DNS: "api.internal.svc.", DNSNames: []string{"api.internal.svc.", "*.cdn.example."}, + IPAddress: fmt.Sprintf("192.0.2.%d", i*4), + Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}}, + }) + } + egress = append(egress, v1beta1.NetworkNeighbor{ + Identifier: "any-ip", Type: "external", + DNS: "telemetry.example.", DNSNames: []string{"telemetry.example."}, + IPAddresses: []string{"*"}, + }) + createNN(t, ns.Name, "mix-net", egress) + + // Path side: 60 HTTP endpoints differing only by a high-cardinality id + // segment -> collapse to one ":80/api/v1/users/". Plus an + // IP-shaped OPEN path that must remain a literal open (never a CIDR). + const ipShapedOpen = "/data/10.0.0.5" + endpoints := make([]v1beta1.HTTPEndpoint, 0, hostCount) + for i := 0; i < hostCount; i++ { + endpoints = append(endpoints, v1beta1.HTTPEndpoint{ + Endpoint: fmt.Sprintf(":80/api/v1/users/%d", i), Methods: []string{"GET"}, + }) + } + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mix-path", Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{ + Name: "curl", + Execs: []v1beta1.ExecCalls{{Path: "/bin/cat", Args: []string{"/bin/cat"}}}, + Opens: []v1beta1.OpenCalls{{Path: ipShapedOpen, Flags: []string{"O_RDONLY"}}}, + Endpoints: endpoints, + }}, + }, + } + _, err := storageClient.ApplicationProfiles(ns.Name).Create(ctx, ap, metav1.CreateOptions{}) + require.NoError(t, err, "create ApplicationProfile mix-path") + + // Network assertions: hosts collapse to /24; the DNS wildcard subdomain and + // the "*" any-IP sentinel both survive (distinct fields, not confused). The + // path wildcard identifier must never appear inside the network object. + netRaw := requireCollapsed(t, ns.Name, "mix-net", + []string{"192.0.2.0/24", "*.cdn.example.", quoted("*")}) + assert.NotContains(t, netRaw, quoted("192.0.2.4"), "network host /32 should be collapsed") + assert.NotContains(t, netRaw, dynamicpathdetector.DynamicIdentifier, "path wildcard must not leak into the NetworkNeighborhood") + + // Endpoint collapse (typed read): many /users/ -> one /users/. + wantEndpoint := ":80/api/v1/users/" + dynamicpathdetector.DynamicIdentifier + var endpts, openPaths []string + collapsedEp := false + for attempt := 0; attempt < 40; attempt++ { + got, gErr := storageClient.ApplicationProfiles(ns.Name).Get(ctx, "mix-path", metav1.GetOptions{}) + if gErr == nil && len(got.Spec.Containers) > 0 { + endpts, openPaths = endpts[:0], openPaths[:0] + for _, e := range got.Spec.Containers[0].Endpoints { + endpts = append(endpts, e.Endpoint) + } + for _, o := range got.Spec.Containers[0].Opens { + openPaths = append(openPaths, o.Path) + } + if slices.Contains(endpts, wantEndpoint) { + collapsedEp = true + break + } + } + time.Sleep(3 * time.Second) + } + if !collapsedEp { + t.Logf("stored ApplicationProfile (expected endpoint %s):\n endpoints=%v\n opens=%v", wantEndpoint, endpts, openPaths) + t.Fatalf("HTTP endpoints did not collapse to %s", wantEndpoint) + } + assert.NotContains(t, endpts, ":80/api/v1/users/5", "per-id endpoint should have collapsed away") + + // Cross-contamination guards. + assert.Contains(t, openPaths, ipShapedOpen, "IP-shaped open path must stay a literal path, not a CIDR") + for _, e := range endpts { + assert.NotContains(t, e, "192.0.2.0/24", "network CIDR must not leak into an endpoint path") + } + }) } From d6191de8e2c8882a806987cd895c0abb5a9034c0 Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 23 Jul 2026 16:31:27 +0200 Subject: [PATCH 4/8] test 34 first wait for the config to be set , next step create the expected NN as a fixutre Signed-off-by: entlein --- tests/component_test.go | 388 +++++++++++++--------------------------- 1 file changed, 126 insertions(+), 262 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index 8fdb27aa1..c978a09c2 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -2802,11 +2802,11 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { helpersv1.CompletionMetadataKey: helpersv1.Full, }, Labels: map[string]string{ - helpersv1.ApiGroupMetadataKey: "apps", - helpersv1.ApiVersionMetadataKey: "v1", - helpersv1.RelatedKindMetadataKey: "Deployment", - helpersv1.RelatedNameMetadataKey: "curl-28", - helpersv1.RelatedNamespaceMetadataKey: ns.Name, + helpersv1.ApiGroupMetadataKey: "apps", + helpersv1.ApiVersionMetadataKey: "v1", + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "curl-28", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, }, }, Spec: v1beta1.NetworkNeighborhoodSpec{ @@ -3235,26 +3235,16 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { // Test_34_NetworkNeighborsCIDRCollapse is an end-to-end test for storage // PR kubescape/storage#348 (CIDR-based collapsing of NetworkNeighbor entries). // -// The collapse runs entirely on the storage side, in the PreSave/deflate path, -// using the compiled-in defaults (NetworkIPGroupThreshold=50, floor /16), so the -// test needs no workload pod and no config. It writes one NetworkNeighborhood -// modelling a "curl" client with two external destinations, each an egress group -// of 60 host IPs (> the 50 threshold) differing only by address: -// -// - an S3-style endpoint whose addresses cluster in a single /24 -// (52.216.183.0/24, last octet spanning the top bit) -> collapses to /24 -// - a broader CDN-style endpoint spanning a whole /16 -// (100.68.0.0/16, third octet spanning the top bit) -> collapses to /16 +// Flow: apply the cluster-scoped CollapseConfiguration, WAIT until it is actually +// live (a small probe below the compiled-in threshold only collapses once the CR +// is read), then write one "learnt" NetworkNeighborhood, wait for storage to +// produce the collapsed profile on its PreSave/deflate path, and assert the +// result equals an explicit EXPECTED TARGET egress set — no stray host /32s. // -// so both the single-covering-/24 and floor-/16 paths are exercised. Groups are -// keyed by DNS, so the two destinations collapse independently. -// -// Version note: the collapse output lands in the plural `ipAddresses` field, -// which exists only on PR#348 storage — not on the pinned upstream storage Go -// types this test compiles against. We therefore read the result back through -// the DYNAMIC client, so the new field is never referenced at compile time and -// is not silently dropped at decode time. The test compiles on plain upstream -// but only passes when storage carries PR#348 — exactly the e2e contract we want. +// The collapse runs entirely storage-side, so no workload pod is needed. The +// plural `ipAddresses` field exists only on PR#348, so the result is read via +// the DYNAMIC client (never referenced at compile time). The test compiles on +// plain upstream but only passes when storage carries PR#348. func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { start := time.Now() defer tearDownTest(t, start) @@ -3263,61 +3253,50 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) dyn := dynamic.NewForConfigOrDie(k8sClient.K8SConfig) ctx := context.Background() + ns := testutils.NewRandomNamespace() - nnGVR := schema.GroupVersionResource{ - Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "networkneighborhoods", - } - ccGVR := schema.GroupVersionResource{ - Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "collapseconfigurations", - } + nnGVR := schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "networkneighborhoods"} + ccGVR := schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "collapseconfigurations"} - // ONE cluster-scoped CollapseConfiguration singleton drives BOTH the network - // CIDR collapse (PR#348: networkIPGroupThreshold / networkCIDRFloorBits) AND - // the pre-existing path/endpoint collapse (openDynamicThreshold / - // endpointDynamicThreshold). We set every knob low so each scenario trips - // deterministically — and, crucially, so the mixed subtest can prove the two - // collapse families do NOT interfere. Untyped: the network fields exist only - // on PR#348 storage. Note the network floor is set to /16 explicitly — the - // shipped default is /24 (NetworkCIDRFloorBits=24), NOT the documented 16. + // 1) Apply the CollapseConfiguration the deflate path reads. Threshold 5 (below + // the compiled-in default 50) lets us gate on the config being live; explicit + // /16 floor (the shipped default is /24). cc := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", "kind": "CollapseConfiguration", "metadata": map[string]interface{}{"name": "default"}, - "spec": map[string]interface{}{ - "networkIPGroupThreshold": int64(5), - "networkCIDRFloorBits": int64(16), - "openDynamicThreshold": int64(5), - "endpointDynamicThreshold": int64(5), - }, + "spec": map[string]interface{}{"networkIPGroupThreshold": int64(5), "networkCIDRFloorBits": int64(16)}, }} - if _, ccErr := dyn.Resource(ccGVR).Create(ctx, cc, metav1.CreateOptions{}); ccErr != nil && !apierrors.IsAlreadyExists(ccErr) { - require.NoError(t, ccErr, "apply CollapseConfiguration") + if _, err := dyn.Resource(ccGVR).Create(ctx, cc, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { + require.NoError(t, err, "apply CollapseConfiguration") } t.Cleanup(func() { _ = dyn.Resource(ccGVR).Delete(ctx, "default", metav1.DeleteOptions{}) }) - const hostCount = 60 // comfortably above every threshold (5 here, 50 default) - - // hostGroup builds `count` external egress entries that differ only by IP and - // share one DNS name, so storage groups them together for collapsing. - hostGroup := func(dns string, count int, ipFn func(i int) string) []v1beta1.NetworkNeighbor { - out := make([]v1beta1.NetworkNeighbor, 0, count) - for i := 0; i < count; i++ { - out = append(out, v1beta1.NetworkNeighbor{ - Identifier: fmt.Sprintf("%s-%d", dns, i), - Type: "external", DNS: dns, DNSNames: []string{dns}, - IPAddress: ipFn(i), - Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}}, - }) + // ---- helpers ------------------------------------------------------------- + neighbor := func(id, dns, ip string, ips ...string) v1beta1.NetworkNeighbor { + n := v1beta1.NetworkNeighbor{ + Identifier: id, Type: "external", DNS: dns, DNSNames: []string{dns}, + Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}}, + } + if ip != "" { + n.IPAddress = ip + } + if len(ips) > 0 { + n.IPAddresses = ips + } + return n + } + group := func(dns string, n int, ipFn func(i int) string) []v1beta1.NetworkNeighbor { + out := make([]v1beta1.NetworkNeighbor, 0, n) + for i := 0; i < n; i++ { + out = append(out, neighbor(fmt.Sprintf("%s-%d", dns, i), dns, ipFn(i))) } return out } - - // createNN writes a NetworkNeighborhood with the given egress and returns nothing. - createNN := func(t *testing.T, nsName, name string, egress []v1beta1.NetworkNeighbor) { - t.Helper() + writeNN := func(name string, egress []v1beta1.NetworkNeighbor) { nn := &v1beta1.NetworkNeighborhood{ ObjectMeta: metav1.ObjectMeta{ - Name: name, Namespace: nsName, + Name: name, Namespace: ns.Name, Annotations: map[string]string{ helpersv1.StatusMetadataKey: helpersv1.Completed, helpersv1.CompletionMetadataKey: helpersv1.Full, @@ -3328,218 +3307,103 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { Containers: []v1beta1.NetworkNeighborhoodContainer{{Name: "curl", Egress: egress}}, }, } - _, err := storageClient.NetworkNeighborhoods(nsName).Create(ctx, nn, metav1.CreateOptions{}) + _, err := storageClient.NetworkNeighborhoods(ns.Name).Create(ctx, nn, metav1.CreateOptions{}) require.NoError(t, err, "create NetworkNeighborhood %s", name) } - // pollNN reads the stored NN via the DYNAMIC client (the typed client drops - // the plural ipAddresses field that exists only on PR#348), re-saving to - // re-trigger the TTL-cached PreSave collapse, until every wanted substring is - // present. Returns the last raw JSON and whether all were found; the caller - // dumps `raw` on failure so the run is self-diagnosing. - pollNN := func(t *testing.T, nsName, name string, want []string) (string, bool) { - t.Helper() - var raw string - for attempt := 0; attempt < 40; attempt++ { - got, gErr := dyn.Resource(nnGVR).Namespace(nsName).Get(ctx, name, metav1.GetOptions{}) - if gErr == nil { - b, _ := json.Marshal(got.Object) - raw = string(b) - all := true - for _, w := range want { - if !strings.Contains(raw, w) { - all = false - break + // egressPairs extracts the set of "|" entries actually stored in a + // NetworkNeighborhood's egress (both the singular ipAddress and the plural + // ipAddresses), so the produced profile can be compared to an expected target. + egressPairs := func(obj map[string]interface{}) map[string]bool { + out := map[string]bool{} + conts, _, _ := unstructured.NestedSlice(obj, "spec", "containers") + for _, c := range conts { + cm, ok := c.(map[string]interface{}) + if !ok { + continue + } + eg, _, _ := unstructured.NestedSlice(cm, "egress") + for _, e := range eg { + em, ok := e.(map[string]interface{}) + if !ok { + continue + } + dns, _, _ := unstructured.NestedString(em, "dns") + if ips, ok, _ := unstructured.NestedStringSlice(em, "ipAddresses"); ok { + for _, ip := range ips { + out[dns+"|"+ip] = true } } - if all { - return raw, true + if s, _, _ := unstructured.NestedString(em, "ipAddress"); s != "" { + out[dns+"|"+s] = true } - _, _ = dyn.Resource(nnGVR).Namespace(nsName).Update(ctx, got, metav1.UpdateOptions{}) } - time.Sleep(3 * time.Second) } - return raw, false + return out } - - // requireCollapsed polls for `want` and fails with the stored object dumped. - requireCollapsed := func(t *testing.T, nsName, name string, want []string) string { - t.Helper() - raw, ok := pollNN(t, nsName, name, want) - if !ok { - t.Logf("stored NetworkNeighborhood %s/%s (expected all of %v):\n%s", nsName, name, want, raw) - t.Fatalf("network collapse did not produce %v (requires PR#348)", want) + keysOf := func(m map[string]bool) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) } - return raw + sort.Strings(ks) + return ks } - quoted := func(s string) string { return fmt.Sprintf("%q", s) } - - // --- Case 1: S3-style endpoint clustered in one /24 -> single covering /24. - t.Run("network_single_covering_24", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - createNN(t, ns.Name, "cidr-24", hostGroup("s3.eu-central-1.amazonaws.com.", hostCount, - func(i int) string { return fmt.Sprintf("52.216.183.%d", i*4) })) - raw := requireCollapsed(t, ns.Name, "cidr-24", []string{"52.216.183.0/24"}) - for _, last := range []int{4, 120, 200} { - assert.NotContains(t, raw, quoted(fmt.Sprintf("52.216.183.%d", last)), "host /32 should be collapsed away") - } - }) - - // --- Case 2: endpoint spanning a whole /16 -> floor /16 (would be /24 buckets by default). - t.Run("network_floor_16_covering", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - createNN(t, ns.Name, "cidr-16", hostGroup("objects.example-cdn.net.", hostCount, - func(i int) string { return fmt.Sprintf("100.68.%d.0", i*4) })) - raw := requireCollapsed(t, ns.Name, "cidr-16", []string{"100.68.0.0/16"}) - for _, third := range []int{4, 120, 200} { - assert.NotContains(t, raw, quoted(fmt.Sprintf("100.68.%d.0", third)), "host /32 should be collapsed away") - } - }) - - // --- Case 3: adversarial mix in ONE group — collapsible host /32s alongside a - // pre-collapsed CIDR, the "*" sentinel, and an IPv6 literal. Hosts must - // aggregate; the non-aggregatable entries must pass through UNTOUCHED (matches - // storage's own TestCollapseIPGroups_Idempotent / _IPv6PassThrough). - t.Run("network_passthrough_cidr_star_ipv6", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - const dns = "mixed.example." - egress := hostGroup(dns, hostCount, func(i int) string { return fmt.Sprintf("203.0.113.%d", i*4) }) - egress = append(egress, - v1beta1.NetworkNeighbor{Identifier: "held-cidr", Type: "external", DNS: dns, IPAddresses: []string{"10.9.0.0/16"}}, - v1beta1.NetworkNeighbor{Identifier: "any", Type: "external", DNS: dns, IPAddresses: []string{"*"}}, - v1beta1.NetworkNeighbor{Identifier: "v6", Type: "external", DNS: dns, IPAddress: "2001:db8::1"}, - ) - createNN(t, ns.Name, "cidr-mixed", egress) - // hosts collapse to /24; held CIDR, "*", and IPv6 survive verbatim. - raw := requireCollapsed(t, ns.Name, "cidr-mixed", - []string{"203.0.113.0/24", "10.9.0.0/16", quoted("*"), "2001:db8::1"}) - for _, last := range []int{4, 120, 200} { - assert.NotContains(t, raw, quoted(fmt.Sprintf("203.0.113.%d", last)), "aggregatable host /32 should be gone") - } - }) - - // --- Case 4: DNS plurals + ports are merged/deduped and replicated onto the - // collapsed CIDR entry (matches TestCollapseIPGroups_MultiBucketReplicatesDNSNamesAndPorts). - t.Run("network_dns_plurals_replicated", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - egress := make([]v1beta1.NetworkNeighbor, 0, hostCount) - for i := 0; i < hostCount; i++ { - egress = append(egress, v1beta1.NetworkNeighbor{ - Identifier: fmt.Sprintf("multi-%d", i), - Type: "external", DNS: "multi.example.", - DNSNames: []string{"alpha.example.", "beta.example."}, - IPAddress: fmt.Sprintf("198.51.100.%d", i*4), - Ports: []v1beta1.NetworkPort{ - {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, - {Name: "TCP-8443", Protocol: "TCP", Port: ptr.To(int32(8443))}, - }, - }) - } - createNN(t, ns.Name, "cidr-dns", egress) - // The single collapsed /24 entry must still carry BOTH plural DNS names and BOTH ports. - requireCollapsed(t, ns.Name, "cidr-dns", - []string{"198.51.100.0/24", "alpha.example.", "beta.example.", "8443"}) - }) - - // --- Case 5: network collapse and PATH/ENDPOINT collapse under the SAME config - // must not confuse each other. Realistic inputs only — DNS names are hostnames, - // never paths; slashes live in endpoint/open paths. Real confusion vectors: - // * a DNS WILDCARD subdomain ("*.cdn.example.") — a legit DNS value containing - // "*" — must not be confused with the network "*" any-IP sentinel; - // * HTTP endpoint paths ("/api/v1/users/") collapse to a dynamic-identifier - // wildcard, independently of the network CIDR collapse; - // * an IP-shaped OPEN path ("/data/10.0.0.5") stays a literal open, never a CIDR. - t.Run("network_and_path_no_confusion", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - - // Network side: 60 hosts -> 192.0.2.0/24. DNSNames carry a real wildcard - // subdomain (contains "*"). A SEPARATE DNS group carries the network "*" - // any-IP sentinel in IPAddresses. Both "*"s must survive in their own fields. - egress := make([]v1beta1.NetworkNeighbor, 0, hostCount+1) - for i := 0; i < hostCount; i++ { - egress = append(egress, v1beta1.NetworkNeighbor{ - Identifier: fmt.Sprintf("svc-%d", i), Type: "external", - DNS: "api.internal.svc.", DNSNames: []string{"api.internal.svc.", "*.cdn.example."}, - IPAddress: fmt.Sprintf("192.0.2.%d", i*4), - Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}}, - }) - } - egress = append(egress, v1beta1.NetworkNeighbor{ - Identifier: "any-ip", Type: "external", - DNS: "telemetry.example.", DNSNames: []string{"telemetry.example."}, - IPAddresses: []string{"*"}, - }) - createNN(t, ns.Name, "mix-net", egress) - - // Path side: 60 HTTP endpoints differing only by a high-cardinality id - // segment -> collapse to one ":80/api/v1/users/". Plus an - // IP-shaped OPEN path that must remain a literal open (never a CIDR). - const ipShapedOpen = "/data/10.0.0.5" - endpoints := make([]v1beta1.HTTPEndpoint, 0, hostCount) - for i := 0; i < hostCount; i++ { - endpoints = append(endpoints, v1beta1.HTTPEndpoint{ - Endpoint: fmt.Sprintf(":80/api/v1/users/%d", i), Methods: []string{"GET"}, - }) - } - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "mix-path", Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "curl", - Execs: []v1beta1.ExecCalls{{Path: "/bin/cat", Args: []string{"/bin/cat"}}}, - Opens: []v1beta1.OpenCalls{{Path: ipShapedOpen, Flags: []string{"O_RDONLY"}}}, - Endpoints: endpoints, - }}, - }, - } - _, err := storageClient.ApplicationProfiles(ns.Name).Create(ctx, ap, metav1.CreateOptions{}) - require.NoError(t, err, "create ApplicationProfile mix-path") - - // Network assertions: hosts collapse to /24; the DNS wildcard subdomain and - // the "*" any-IP sentinel both survive (distinct fields, not confused). The - // path wildcard identifier must never appear inside the network object. - netRaw := requireCollapsed(t, ns.Name, "mix-net", - []string{"192.0.2.0/24", "*.cdn.example.", quoted("*")}) - assert.NotContains(t, netRaw, quoted("192.0.2.4"), "network host /32 should be collapsed") - assert.NotContains(t, netRaw, dynamicpathdetector.DynamicIdentifier, "path wildcard must not leak into the NetworkNeighborhood") - - // Endpoint collapse (typed read): many /users/ -> one /users/. - wantEndpoint := ":80/api/v1/users/" + dynamicpathdetector.DynamicIdentifier - var endpts, openPaths []string - collapsedEp := false + // pollNN re-saves the NN to re-run PreSave until want(pairs) holds; returns the + // last stored object and whether it matched. + pollNN := func(name string, want func(map[string]bool) bool) (map[string]interface{}, bool) { + var last map[string]interface{} for attempt := 0; attempt < 40; attempt++ { - got, gErr := storageClient.ApplicationProfiles(ns.Name).Get(ctx, "mix-path", metav1.GetOptions{}) - if gErr == nil && len(got.Spec.Containers) > 0 { - endpts, openPaths = endpts[:0], openPaths[:0] - for _, e := range got.Spec.Containers[0].Endpoints { - endpts = append(endpts, e.Endpoint) - } - for _, o := range got.Spec.Containers[0].Opens { - openPaths = append(openPaths, o.Path) - } - if slices.Contains(endpts, wantEndpoint) { - collapsedEp = true - break + got, err := dyn.Resource(nnGVR).Namespace(ns.Name).Get(ctx, name, metav1.GetOptions{}) + if err == nil { + last = got.Object + if want(egressPairs(last)) { + return last, true } + _, _ = dyn.Resource(nnGVR).Namespace(ns.Name).Update(ctx, got, metav1.UpdateOptions{}) } time.Sleep(3 * time.Second) } - if !collapsedEp { - t.Logf("stored ApplicationProfile (expected endpoint %s):\n endpoints=%v\n opens=%v", wantEndpoint, endpts, openPaths) - t.Fatalf("HTTP endpoints did not collapse to %s", wantEndpoint) - } - assert.NotContains(t, endpts, ":80/api/v1/users/5", "per-id endpoint should have collapsed away") - - // Cross-contamination guards. - assert.Contains(t, openPaths, ipShapedOpen, "IP-shaped open path must stay a literal path, not a CIDR") - for _, e := range endpts { - assert.NotContains(t, e, "192.0.2.0/24", "network CIDR must not leak into an endpoint path") - } - }) + return last, false + } + + // 2) Gate: wait until the config is LIVE. Eight probe hosts (below the default + // threshold of 50) collapse only once networkIPGroupThreshold=5 is actually + // read — so nothing below runs against stale compiled-in defaults (which would + // emit /24 buckets that then stick, per the held-stable rule). + writeNN("cc-probe", group("probe.local.", 8, func(i int) string { return fmt.Sprintf("192.168.7.%d", i*30) })) + _, live := pollNN("cc-probe", func(p map[string]bool) bool { return p["probe.local.|192.168.7.0/24"] }) + require.True(t, live, "CollapseConfiguration never became active — the 8-host probe did not collapse at threshold 5") + _ = dyn.Resource(nnGVR).Namespace(ns.Name).Delete(ctx, "cc-probe", metav1.DeleteOptions{}) + + // 3) Write the "learnt" NetworkNeighborhood: three external destinations + // recorded per-IP, plus (in one group) a pre-collapsed CIDR, the "*" sentinel, + // and an IPv6 literal that must pass through untouched. + egress := group("s3.amazonaws.com.", 60, func(i int) string { return fmt.Sprintf("52.216.%d.0", i*4) }) // spans a /16 + egress = append(egress, group("cdn.example.", 60, func(i int) string { return fmt.Sprintf("203.0.113.%d", i*4) })...) // spans a /24 + egress = append(egress, group("mixed.example.", 60, func(i int) string { return fmt.Sprintf("198.51.100.%d", i*4) })...) // spans a /24 + egress = append(egress, + neighbor("mixed-held", "mixed.example.", "", "10.9.0.0/16"), + neighbor("mixed-any", "mixed.example.", "", "*"), + neighbor("mixed-v6", "mixed.example.", "2001:db8::1"), + ) + writeNN("cidr-nn", egress) + + // 4/5) Wait for the collapsed profile to be produced and assert it equals the + // EXPECTED TARGET egress set exactly (no stray host /32s left behind). + expected := map[string]bool{ + "s3.amazonaws.com.|52.216.0.0/16": true, // /16 covering (floor 16) + "cdn.example.|203.0.113.0/24": true, // /24 covering + "mixed.example.|198.51.100.0/24": true, // hosts collapse + "mixed.example.|10.9.0.0/16": true, // held CIDR passthrough + "mixed.example.|*": true, // any-IP sentinel passthrough + "mixed.example.|2001:db8::1": true, // IPv6 passthrough + } + obj, ok := pollNN("cidr-nn", func(p map[string]bool) bool { return reflect.DeepEqual(p, expected) }) + if !ok { + raw, _ := json.Marshal(obj) + t.Logf("collapsed egress mismatch\n produced: %v\n expected: %v\n stored NetworkNeighborhood:\n%s", + keysOf(egressPairs(obj)), keysOf(expected), string(raw)) + t.Fatalf("collapsed NetworkNeighborhood does not match the expected target (requires PR#348)") + } } From 94edfa97a02f13341779aab48bfc42a33181ed8c Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 23 Jul 2026 17:49:25 +0200 Subject: [PATCH 5/8] test 34 with external network "probe" the multiple to-be-collapsed endpoints AFTER the collapsconfig was changed Signed-off-by: entlein --- tests/component_test.go | 199 ++++++------------ .../networkneighbors-cidr-fanout.yaml | 26 +++ 2 files changed, 87 insertions(+), 138 deletions(-) create mode 100644 tests/resources/networkneighbors-cidr-fanout.yaml diff --git a/tests/component_test.go b/tests/component_test.go index c978a09c2..13ab17029 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -3235,32 +3235,36 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { // Test_34_NetworkNeighborsCIDRCollapse is an end-to-end test for storage // PR kubescape/storage#348 (CIDR-based collapsing of NetworkNeighbor entries). // -// Flow: apply the cluster-scoped CollapseConfiguration, WAIT until it is actually -// live (a small probe below the compiled-in threshold only collapses once the CR -// is read), then write one "learnt" NetworkNeighborhood, wait for storage to -// produce the collapsed profile on its PreSave/deflate path, and assert the -// result equals an explicit EXPECTED TARGET egress set — no stray host /32s. +// It exercises the REAL learn→collapse path, not an injected profile: apply the +// CollapseConfiguration, wait for it to go live, deploy a workload that egresses +// to many IPs in 52.216.0.0/24, wait for node-agent to LEARN the profile to +// completion, then assert the learnt egress collapsed into a covering CIDR with +// no host /32 left behind. // -// The collapse runs entirely storage-side, so no workload pod is needed. The -// plural `ipAddresses` field exists only on PR#348, so the result is read via -// the DYNAMIC client (never referenced at compile time). The test compiles on -// plain upstream but only passes when storage carries PR#348. +// Why not inject a NetworkNeighborhood directly: storage rejects/empties a +// directly-created `completion: complete` profile ("object is completed"), and +// the deflate only runs at node-agent's write time — so only a genuinely learnt +// profile exercises the collapse. Validated on a real k3s: 60 IPs -> one CIDR. +// +// The collapsed CIDR lands in the plural `ipAddresses` field, which exists only +// on PR#348 storage, so the result is read via the DYNAMIC client (never +// referenced at compile time). Compiles on plain upstream; passes only on PR#348. func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { start := time.Now() defer tearDownTest(t, start) k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) dyn := dynamic.NewForConfigOrDie(k8sClient.K8SConfig) ctx := context.Background() - ns := testutils.NewRandomNamespace() nnGVR := schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "networkneighborhoods"} ccGVR := schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "collapseconfigurations"} - // 1) Apply the CollapseConfiguration the deflate path reads. Threshold 5 (below - // the compiled-in default 50) lets us gate on the config being live; explicit - // /16 floor (the shipped default is /24). + // Apply the CollapseConfiguration BEFORE learning starts. Deflate collapses at + // write time using whatever config is live then, and the provider is + // TTL-cached (~10s) — so apply, then wait for it to go live, before deploying + // the workload. Threshold 5 (< the compiled-in default 50) so a modest fan-out + // trips it; explicit /16 floor (the shipped default is /24). cc := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", "kind": "CollapseConfiguration", @@ -3271,139 +3275,58 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { require.NoError(t, err, "apply CollapseConfiguration") } t.Cleanup(func() { _ = dyn.Resource(ccGVR).Delete(ctx, "default", metav1.DeleteOptions{}) }) + time.Sleep(20 * time.Second) // let the TTL-cached provider pick up the CR - // ---- helpers ------------------------------------------------------------- - neighbor := func(id, dns, ip string, ips ...string) v1beta1.NetworkNeighbor { - n := v1beta1.NetworkNeighbor{ - Identifier: id, Type: "external", DNS: dns, DNSNames: []string{dns}, - Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}}, - } - if ip != "" { - n.IPAddress = ip - } - if len(ips) > 0 { - n.IPAddresses = ips - } - return n - } - group := func(dns string, n int, ipFn func(i int) string) []v1beta1.NetworkNeighbor { - out := make([]v1beta1.NetworkNeighbor, 0, n) - for i := 0; i < n; i++ { - out = append(out, neighbor(fmt.Sprintf("%s-%d", dns, i), dns, ipFn(i))) - } - return out - } - writeNN := func(name string, egress []v1beta1.NetworkNeighbor) { - nn := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, - }, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}, - Containers: []v1beta1.NetworkNeighborhoodContainer{{Name: "curl", Egress: egress}}, - }, + // Deploy a workload that egresses to 60 IPs in 52.216.0.0/24 so node-agent + // learns a NetworkNeighborhood whose external group exceeds the threshold. + ns := testutils.NewRandomNamespace() + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/networkneighbors-cidr-fanout.yaml")) + require.NoError(t, err, "deploy fan-out workload") + require.NoError(t, wl.WaitForReady(80), "fan-out workload not ready") + + // Wait for the LEARNT profile to finalise (completion: complete). + require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(120), "network neighborhood did not complete learning") + + nnTyped, err := wl.GetNetworkNeighborhood() + require.NoError(t, err, "get learnt network neighborhood") + + // Read the learnt NN via the DYNAMIC client (typed drops the plural field). + got, err := dyn.Resource(nnGVR).Namespace(nnTyped.Namespace).Get(ctx, nnTyped.Name, metav1.GetOptions{}) + require.NoError(t, err, "dynamic get network neighborhood %s/%s", nnTyped.Namespace, nnTyped.Name) + + // The fan-out egress (52.216.0.0/24) must have collapsed into a covering CIDR, + // with NO individual host /32 for that range left behind. + var collapsedCIDRs, bareHosts []string + conts, _, _ := unstructured.NestedSlice(got.Object, "spec", "containers") + for _, c := range conts { + cm, ok := c.(map[string]interface{}) + if !ok { + continue } - _, err := storageClient.NetworkNeighborhoods(ns.Name).Create(ctx, nn, metav1.CreateOptions{}) - require.NoError(t, err, "create NetworkNeighborhood %s", name) - } - - // egressPairs extracts the set of "|" entries actually stored in a - // NetworkNeighborhood's egress (both the singular ipAddress and the plural - // ipAddresses), so the produced profile can be compared to an expected target. - egressPairs := func(obj map[string]interface{}) map[string]bool { - out := map[string]bool{} - conts, _, _ := unstructured.NestedSlice(obj, "spec", "containers") - for _, c := range conts { - cm, ok := c.(map[string]interface{}) + eg, _, _ := unstructured.NestedSlice(cm, "egress") + for _, e := range eg { + em, ok := e.(map[string]interface{}) if !ok { continue } - eg, _, _ := unstructured.NestedSlice(cm, "egress") - for _, e := range eg { - em, ok := e.(map[string]interface{}) - if !ok { - continue - } - dns, _, _ := unstructured.NestedString(em, "dns") - if ips, ok, _ := unstructured.NestedStringSlice(em, "ipAddresses"); ok { - for _, ip := range ips { - out[dns+"|"+ip] = true + if ips, ok, _ := unstructured.NestedStringSlice(em, "ipAddresses"); ok { + for _, ip := range ips { + if strings.HasPrefix(ip, "52.216.0.") && strings.Contains(ip, "/") { + collapsedCIDRs = append(collapsedCIDRs, ip) } } - if s, _, _ := unstructured.NestedString(em, "ipAddress"); s != "" { - out[dns+"|"+s] = true - } } - } - return out - } - keysOf := func(m map[string]bool) []string { - ks := make([]string, 0, len(m)) - for k := range m { - ks = append(ks, k) - } - sort.Strings(ks) - return ks - } - // pollNN re-saves the NN to re-run PreSave until want(pairs) holds; returns the - // last stored object and whether it matched. - pollNN := func(name string, want func(map[string]bool) bool) (map[string]interface{}, bool) { - var last map[string]interface{} - for attempt := 0; attempt < 40; attempt++ { - got, err := dyn.Resource(nnGVR).Namespace(ns.Name).Get(ctx, name, metav1.GetOptions{}) - if err == nil { - last = got.Object - if want(egressPairs(last)) { - return last, true - } - _, _ = dyn.Resource(nnGVR).Namespace(ns.Name).Update(ctx, got, metav1.UpdateOptions{}) + if s, _, _ := unstructured.NestedString(em, "ipAddress"); strings.HasPrefix(s, "52.216.0.") { + bareHosts = append(bareHosts, s) } - time.Sleep(3 * time.Second) } - return last, false - } - - // 2) Gate: wait until the config is LIVE. Eight probe hosts (below the default - // threshold of 50) collapse only once networkIPGroupThreshold=5 is actually - // read — so nothing below runs against stale compiled-in defaults (which would - // emit /24 buckets that then stick, per the held-stable rule). - writeNN("cc-probe", group("probe.local.", 8, func(i int) string { return fmt.Sprintf("192.168.7.%d", i*30) })) - _, live := pollNN("cc-probe", func(p map[string]bool) bool { return p["probe.local.|192.168.7.0/24"] }) - require.True(t, live, "CollapseConfiguration never became active — the 8-host probe did not collapse at threshold 5") - _ = dyn.Resource(nnGVR).Namespace(ns.Name).Delete(ctx, "cc-probe", metav1.DeleteOptions{}) - - // 3) Write the "learnt" NetworkNeighborhood: three external destinations - // recorded per-IP, plus (in one group) a pre-collapsed CIDR, the "*" sentinel, - // and an IPv6 literal that must pass through untouched. - egress := group("s3.amazonaws.com.", 60, func(i int) string { return fmt.Sprintf("52.216.%d.0", i*4) }) // spans a /16 - egress = append(egress, group("cdn.example.", 60, func(i int) string { return fmt.Sprintf("203.0.113.%d", i*4) })...) // spans a /24 - egress = append(egress, group("mixed.example.", 60, func(i int) string { return fmt.Sprintf("198.51.100.%d", i*4) })...) // spans a /24 - egress = append(egress, - neighbor("mixed-held", "mixed.example.", "", "10.9.0.0/16"), - neighbor("mixed-any", "mixed.example.", "", "*"), - neighbor("mixed-v6", "mixed.example.", "2001:db8::1"), - ) - writeNN("cidr-nn", egress) - - // 4/5) Wait for the collapsed profile to be produced and assert it equals the - // EXPECTED TARGET egress set exactly (no stray host /32s left behind). - expected := map[string]bool{ - "s3.amazonaws.com.|52.216.0.0/16": true, // /16 covering (floor 16) - "cdn.example.|203.0.113.0/24": true, // /24 covering - "mixed.example.|198.51.100.0/24": true, // hosts collapse - "mixed.example.|10.9.0.0/16": true, // held CIDR passthrough - "mixed.example.|*": true, // any-IP sentinel passthrough - "mixed.example.|2001:db8::1": true, // IPv6 passthrough - } - obj, ok := pollNN("cidr-nn", func(p map[string]bool) bool { return reflect.DeepEqual(p, expected) }) - if !ok { - raw, _ := json.Marshal(obj) - t.Logf("collapsed egress mismatch\n produced: %v\n expected: %v\n stored NetworkNeighborhood:\n%s", - keysOf(egressPairs(obj)), keysOf(expected), string(raw)) - t.Fatalf("collapsed NetworkNeighborhood does not match the expected target (requires PR#348)") } + + if len(collapsedCIDRs) == 0 || len(bareHosts) > 0 { + raw, _ := json.Marshal(got.Object) + t.Logf("learnt NetworkNeighborhood %s/%s:\n%s", nnTyped.Namespace, nnTyped.Name, string(raw)) + t.Fatalf("expected the 52.216.0.0/24 fan-out to collapse into a CIDR (got cidrs=%v, bare /32s=%v); requires PR#348", + collapsedCIDRs, bareHosts) + } + t.Logf("learnt fan-out collapsed to CIDR(s) %v (no bare /32s) — PR#348 confirmed", collapsedCIDRs) } diff --git a/tests/resources/networkneighbors-cidr-fanout.yaml b/tests/resources/networkneighbors-cidr-fanout.yaml new file mode 100644 index 000000000..3a6e90d98 --- /dev/null +++ b/tests/resources/networkneighbors-cidr-fanout.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: cidr-fanout + name: cidr-fanout +spec: + replicas: 1 + selector: + matchLabels: + app: cidr-fanout + template: + metadata: + labels: + app: cidr-fanout + spec: + containers: + - name: fanout + image: busybox:1.36 + command: ["sh", "-c"] + args: + - | + while true; do + for i in $(seq 1 60); do nc -w 1 -z 52.216.0.$i 443 2>/dev/null; done + sleep 3 + done From 01a55ef16b0ea1a18957a8feeed2be1b026de608 Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 23 Jul 2026 20:49:02 +0200 Subject: [PATCH 6/8] grouping CIDRs to avoid dedup, must be built against addon storage PR Signed-off-by: entlein --- tests/component_test.go | 184 ++++++++++++++---- .../networkneighbors-cidr-spread.yaml | 26 +++ tests/resources/networkneighbors-s3-27.yaml | 23 +++ tests/resources/networkneighbors-s3-28.yaml | 23 +++ .../resources/networkneighbors-scattered.yaml | 24 +++ tests/resources/networkneighbors-v6-124.yaml | 23 +++ 6 files changed, 260 insertions(+), 43 deletions(-) create mode 100644 tests/resources/networkneighbors-cidr-spread.yaml create mode 100644 tests/resources/networkneighbors-s3-27.yaml create mode 100644 tests/resources/networkneighbors-s3-28.yaml create mode 100644 tests/resources/networkneighbors-scattered.yaml create mode 100644 tests/resources/networkneighbors-v6-124.yaml diff --git a/tests/component_test.go b/tests/component_test.go index 13ab17029..28d8a8ff3 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -3249,54 +3249,67 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { // The collapsed CIDR lands in the plural `ipAddresses` field, which exists only // on PR#348 storage, so the result is read via the DYNAMIC client (never // referenced at compile time). Compiles on plain upstream; passes only on PR#348. -func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) +// nnCollapseGVR / ccCollapseGVR name the CIDR-collapse resources. The collapsed +// value lands in the plural ipAddresses field, which exists only on PR#348 +// storage, so learnt NNs are read via the dynamic client (never referenced at +// compile time). This file compiles on plain upstream and passes only on PR#348 +// storage carrying the collapse dedup fix. +var ( + nnCollapseGVR = schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "networkneighborhoods"} + ccCollapseGVR = schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "collapseconfigurations"} +) - k8sClient := k8sinterface.NewKubernetesApi() - dyn := dynamic.NewForConfigOrDie(k8sClient.K8SConfig) +// applyCollapseFloor create-or-updates the cluster-scoped CollapseConfiguration +// singleton to threshold 5 (< the compiled-in default 50, so a modest fan-out +// trips collapse) and the given CIDR floor. Deflate collapses at write time +// using whatever config is live then, via a TTL-cached (~10s) provider — so +// callers must wait after this before deploying a learner. +func applyCollapseFloor(t *testing.T, dyn dynamic.Interface, floorBits int64) { ctx := context.Background() - - nnGVR := schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "networkneighborhoods"} - ccGVR := schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "collapseconfigurations"} - - // Apply the CollapseConfiguration BEFORE learning starts. Deflate collapses at - // write time using whatever config is live then, and the provider is - // TTL-cached (~10s) — so apply, then wait for it to go live, before deploying - // the workload. Threshold 5 (< the compiled-in default 50) so a modest fan-out - // trips it; explicit /16 floor (the shipped default is /24). cc := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", "kind": "CollapseConfiguration", "metadata": map[string]interface{}{"name": "default"}, - "spec": map[string]interface{}{"networkIPGroupThreshold": int64(5), "networkCIDRFloorBits": int64(16)}, + "spec": map[string]interface{}{"networkIPGroupThreshold": int64(5), "networkCIDRFloorBits": floorBits}, }} - if _, err := dyn.Resource(ccGVR).Create(ctx, cc, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { - require.NoError(t, err, "apply CollapseConfiguration") - } - t.Cleanup(func() { _ = dyn.Resource(ccGVR).Delete(ctx, "default", metav1.DeleteOptions{}) }) - time.Sleep(20 * time.Second) // let the TTL-cached provider pick up the CR + _, err := dyn.Resource(ccCollapseGVR).Create(ctx, cc, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + cur, gerr := dyn.Resource(ccCollapseGVR).Get(ctx, "default", metav1.GetOptions{}) + require.NoError(t, gerr, "get CollapseConfiguration") + require.NoError(t, unstructured.SetNestedField(cur.Object, floorBits, "spec", "networkCIDRFloorBits")) + require.NoError(t, unstructured.SetNestedField(cur.Object, int64(5), "spec", "networkIPGroupThreshold")) + _, uerr := dyn.Resource(ccCollapseGVR).Update(ctx, cur, metav1.UpdateOptions{}) + require.NoError(t, uerr, "update CollapseConfiguration floor") + return + } + require.NoError(t, err, "apply CollapseConfiguration") +} - // Deploy a workload that egresses to 60 IPs in 52.216.0.0/24 so node-agent - // learns a NetworkNeighborhood whose external group exceeds the threshold. +// deployCIDRLearner deploys an egress fan-out workload and waits for its pod to +// be ready; the caller later waits for the learnt NN to finalise. +func deployCIDRLearner(t *testing.T, resource string) *testutils.TestWorkload { ns := testutils.NewRandomNamespace() - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/networkneighbors-cidr-fanout.yaml")) - require.NoError(t, err, "deploy fan-out workload") - require.NoError(t, wl.WaitForReady(80), "fan-out workload not ready") + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), resource)) + require.NoError(t, err, "deploy %s", resource) + require.NoError(t, wl.WaitForReady(80), "%s not ready", resource) + return wl +} - // Wait for the LEARNT profile to finalise (completion: complete). +// collectLearntCollapse waits for the workload's NetworkNeighborhood to finalise +// (completion: complete), reads it via the dynamic client, and returns the +// sorted, de-duplicated set of 52.216.0.0/16 egress CIDRs plus any bare host /32 +// left behind in that range. +// collectLearntCollapse waits for the workload's NetworkNeighborhood to finalise +// and returns the sorted, de-duplicated set of learnt egress CIDRs (plural +// ipAddresses values carrying a "/") and any bare host ipAddress left behind. +func collectLearntCollapse(t *testing.T, dyn dynamic.Interface, wl *testutils.TestWorkload) (cidrs, bare []string) { require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(120), "network neighborhood did not complete learning") - nnTyped, err := wl.GetNetworkNeighborhood() require.NoError(t, err, "get learnt network neighborhood") - - // Read the learnt NN via the DYNAMIC client (typed drops the plural field). - got, err := dyn.Resource(nnGVR).Namespace(nnTyped.Namespace).Get(ctx, nnTyped.Name, metav1.GetOptions{}) + got, err := dyn.Resource(nnCollapseGVR).Namespace(nnTyped.Namespace).Get(context.Background(), nnTyped.Name, metav1.GetOptions{}) require.NoError(t, err, "dynamic get network neighborhood %s/%s", nnTyped.Namespace, nnTyped.Name) - // The fan-out egress (52.216.0.0/24) must have collapsed into a covering CIDR, - // with NO individual host /32 for that range left behind. - var collapsedCIDRs, bareHosts []string + seen := map[string]struct{}{} conts, _, _ := unstructured.NestedSlice(got.Object, "spec", "containers") for _, c := range conts { cm, ok := c.(map[string]interface{}) @@ -3311,22 +3324,107 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { } if ips, ok, _ := unstructured.NestedStringSlice(em, "ipAddresses"); ok { for _, ip := range ips { - if strings.HasPrefix(ip, "52.216.0.") && strings.Contains(ip, "/") { - collapsedCIDRs = append(collapsedCIDRs, ip) + if strings.Contains(ip, "/") { + if _, s := seen[ip]; !s { + seen[ip] = struct{}{} + cidrs = append(cidrs, ip) + } } } } - if s, _, _ := unstructured.NestedString(em, "ipAddress"); strings.HasPrefix(s, "52.216.0.") { - bareHosts = append(bareHosts, s) + if s, _, _ := unstructured.NestedString(em, "ipAddress"); s != "" { + bare = append(bare, s) } } } + sort.Strings(cidrs) + sort.Strings(bare) + return cidrs, bare +} - if len(collapsedCIDRs) == 0 || len(bareHosts) > 0 { - raw, _ := json.Marshal(got.Object) - t.Logf("learnt NetworkNeighborhood %s/%s:\n%s", nnTyped.Namespace, nnTyped.Name, string(raw)) - t.Fatalf("expected the 52.216.0.0/24 fan-out to collapse into a CIDR (got cidrs=%v, bare /32s=%v); requires PR#348", - collapsedCIDRs, bareHosts) +// withPrefixes returns the members of cidrs whose network address starts with +// one of the given dotted/colon prefixes (e.g. "52.216." or "2606:4700:0:1:"). +func withPrefixes(cidrs []string, prefixes ...string) []string { + var out []string + for _, c := range cidrs { + for _, p := range prefixes { + if strings.HasPrefix(c, p) { + out = append(out, c) + break + } + } } - t.Logf("learnt fan-out collapsed to CIDR(s) %v (no bare /32s) — PR#348 confirmed", collapsedCIDRs) + sort.Strings(out) + return out +} + +// Test_34_NetworkNeighborsCIDRCollapse exercises the REAL learn→collapse path for +// storage PR kubescape/storage#348 (CIDR collapsing) plus the netipx exact-cover +// fix stacked on it. It never injects a profile: storage rejects/empties a +// directly-created `completion: complete` NN and deflate only runs at +// node-agent's write time, so only a genuinely learnt profile exercises collapse. +// +// Workloads egress to REAL cloud-provider address space (AWS S3, Cloudflare, +// Azure, GCP) and assertions pin the EXACT cover — never an over-approximating +// block the workload did not reach. The collapsed value lands in the plural +// ipAddresses field (PR#348 only), read via the dynamic client. +// +// floor /16, full S3 /28 (52.216.1.0/28) -> exactly 52.216.1.0/28 +// floor /16, scattered S3/CF/Azure/GCP IPs -> exactly those /32s (no covering block) +// floor /16, full Cloudflare IPv6 /124 -> exactly 2606:4700:0:1::/124 (dual-stack only) +// floor /28, full S3 /27 (52.216.2.0/27) -> splits into 52.216.2.0/28 + 52.216.2.16/28 +func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + k8sClient := k8sinterface.NewKubernetesApi() + dyn := dynamic.NewForConfigOrDie(k8sClient.K8SConfig) + t.Cleanup(func() { _ = dyn.Resource(ccCollapseGVR).Delete(context.Background(), "default", metav1.DeleteOptions{}) }) + + // -------- Phase 1: /16 floor — exactness on real cloud ranges -------- + applyCollapseFloor(t, dyn, 16) + time.Sleep(20 * time.Second) // let the TTL-cached provider pick up the floor + + s3 := deployCIDRLearner(t, "resources/networkneighbors-s3-28.yaml") + scattered := deployCIDRLearner(t, "resources/networkneighbors-scattered.yaml") + v6 := deployCIDRLearner(t, "resources/networkneighbors-v6-124.yaml") + + // A fully-observed S3 /28 exact-covers to exactly that /28. + s3CIDRs, s3Bare := collectLearntCollapse(t, dyn, s3) + assert.Equal(t, []string{"52.216.1.0/28"}, withPrefixes(s3CIDRs, "52.216.1."), + "a fully-observed S3 /28 must collapse to exactly 52.216.1.0/28") + assert.Empty(t, withPrefixes(s3Bare, "52.216.1."), "no bare host /32 may remain") + + // Scattered IPs across four providers, each in a distinct /16, must stay as + // their exact /32s — exact cover never invents a covering block. + scatteredWant := []string{ + "104.16.100.50/32", "13.107.6.152/32", "172.64.200.10/32", "20.150.10.5/32", + "34.120.50.10/32", "35.190.20.30/32", "52.216.10.20/32", "52.217.50.100/32", + } + scatteredCIDRs, _ := collectLearntCollapse(t, dyn, scattered) + got := withPrefixes(scatteredCIDRs, "52.216.", "52.217.", "104.16.", "172.64.", "20.150.", "13.107.", "34.120.", "35.190.") + assert.Equal(t, scatteredWant, got, "scattered cloud IPs must be covered exactly, as individual /32s") + + // IPv6 exact cover — only on a dual-stack cluster; skip the assertion if the + // pod never egressed over v6 (single-stack), rather than fail. + v6CIDRs, _ := collectLearntCollapse(t, dyn, v6) + if v6got := withPrefixes(v6CIDRs, "2606:4700:0:1:"); len(v6got) == 0 { + t.Log("no IPv6 egress learnt (single-stack cluster) — skipping the v6 assertion") + } else { + assert.Equal(t, []string{"2606:4700:0:1::/124"}, v6got, + "a fully-observed Cloudflare v6 /124 must collapse to exactly that /124") + } + + // -------- Phase 2: /28 floor — a fully-observed /27 splits into two /28s ---- + applyCollapseFloor(t, dyn, 28) + time.Sleep(20 * time.Second) + + split := deployCIDRLearner(t, "resources/networkneighbors-s3-27.yaml") + splitCIDRs, splitBare := collectLearntCollapse(t, dyn, split) + assert.Equal(t, []string{"52.216.2.0/28", "52.216.2.16/28"}, withPrefixes(splitCIDRs, "52.216.2."), + "a fully-observed /27 must split into two /28s under a /28 floor") + assert.Empty(t, withPrefixes(splitBare, "52.216.2.")) + + t.Logf("collapse validated on real cloud ranges: S3=%v scattered=%v split=%v", + withPrefixes(s3CIDRs, "52.216.1."), got, withPrefixes(splitCIDRs, "52.216.2.")) } diff --git a/tests/resources/networkneighbors-cidr-spread.yaml b/tests/resources/networkneighbors-cidr-spread.yaml new file mode 100644 index 000000000..1edd047d1 --- /dev/null +++ b/tests/resources/networkneighbors-cidr-spread.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cidr-spread + labels: + app: cidr-spread +spec: + replicas: 1 + selector: + matchLabels: + app: cidr-spread + template: + metadata: + labels: + app: cidr-spread + spec: + containers: + - name: spread + image: busybox + command: ["sh", "-c"] + # Egress to IPs spread across the whole third octet of 52.216.0.0/16 + # (0..255, including both extremes so the common prefix is exactly the + # /16 boundary). With a /16 floor this collapses to a single 52.216.0.0/16; + # with a /24 floor it splits into one /24 per distinct third octet. + args: + - "while true; do for o3 in 0 20 40 64 96 128 160 192 224 255; do for o4 in 1 2 3; do nc -w 1 -z 52.216.$o3.$o4 443 2>/dev/null; done; done; sleep 2; done" diff --git a/tests/resources/networkneighbors-s3-27.yaml b/tests/resources/networkneighbors-s3-27.yaml new file mode 100644 index 000000000..66054679f --- /dev/null +++ b/tests/resources/networkneighbors-s3-27.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: s3-full-27 + labels: + app: s3-full-27 +spec: + replicas: 1 + selector: + matchLabels: + app: s3-full-27 + template: + metadata: + labels: + app: s3-full-27 + spec: + containers: + - name: c + image: busybox + command: ["sh", "-c"] + # AWS S3 (52.216.0.0/15): fully observe the /27 52.216.2.0/27 + args: + - "while true; do for i in $(seq 0 31); do nc -w 1 -z 52.216.2.$i 443 2>/dev/null; done; sleep 2; done" diff --git a/tests/resources/networkneighbors-s3-28.yaml b/tests/resources/networkneighbors-s3-28.yaml new file mode 100644 index 000000000..14f45fea3 --- /dev/null +++ b/tests/resources/networkneighbors-s3-28.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: s3-full-28 + labels: + app: s3-full-28 +spec: + replicas: 1 + selector: + matchLabels: + app: s3-full-28 + template: + metadata: + labels: + app: s3-full-28 + spec: + containers: + - name: c + image: busybox + command: ["sh", "-c"] + # AWS S3 (52.216.0.0/15): fully observe the /28 52.216.1.0/28 + args: + - "while true; do for i in $(seq 0 15); do nc -w 1 -z 52.216.1.$i 443 2>/dev/null; done; sleep 2; done" diff --git a/tests/resources/networkneighbors-scattered.yaml b/tests/resources/networkneighbors-scattered.yaml new file mode 100644 index 000000000..3960066f3 --- /dev/null +++ b/tests/resources/networkneighbors-scattered.yaml @@ -0,0 +1,24 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloud-scattered + labels: + app: cloud-scattered +spec: + replicas: 1 + selector: + matchLabels: + app: cloud-scattered + template: + metadata: + labels: + app: cloud-scattered + spec: + containers: + - name: c + image: busybox + command: ["sh", "-c"] + # Scattered real IPs across AWS S3 / Cloudflare / Azure / GCP, each in a + # distinct /16 -> exact cover keeps them as individual /32s. + args: + - "while true; do for ip in 52.216.10.20 52.217.50.100 104.16.100.50 172.64.200.10 20.150.10.5 13.107.6.152 34.120.50.10 35.190.20.30; do nc -w 1 -z $ip 443 2>/dev/null; done; sleep 2; done" diff --git a/tests/resources/networkneighbors-v6-124.yaml b/tests/resources/networkneighbors-v6-124.yaml new file mode 100644 index 000000000..f01ab3647 --- /dev/null +++ b/tests/resources/networkneighbors-v6-124.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cf-v6-124 + labels: + app: cf-v6-124 +spec: + replicas: 1 + selector: + matchLabels: + app: cf-v6-124 + template: + metadata: + labels: + app: cf-v6-124 + spec: + containers: + - name: c + image: busybox + command: ["sh", "-c"] + # Cloudflare IPv6 (2606:4700::/32): fully observe the /124 2606:4700:0:1::/124 + args: + - "while true; do for i in 0 1 2 3 4 5 6 7 8 9 a b c d e f; do nc -w 1 -z 2606:4700:0:1::$i 443 2>/dev/null; done; sleep 2; done" From a77453e625d29e1e2c015f51509123fc64d7b509 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 24 Jul 2026 13:08:35 +0200 Subject: [PATCH 7/8] rewrote to address review, lets retest Signed-off-by: entlein --- tests/component_test.go | 43 +++++++++++++------ .../resources/networkneighbors-scattered.yaml | 4 +- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index 28d8a8ff3..52f547f73 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -3365,12 +3365,17 @@ func withPrefixes(cidrs []string, prefixes ...string) []string { // node-agent's write time, so only a genuinely learnt profile exercises collapse. // // Workloads egress to REAL cloud-provider address space (AWS S3, Cloudflare, -// Azure, GCP) and assertions pin the EXACT cover — never an over-approximating -// block the workload did not reach. The collapsed value lands in the plural -// ipAddresses field (PR#348 only), read via the dynamic client. +// Azure, GCP). Assertions pin two properties: a fully-observed block collapses to +// exactly that block (never over-approximating past what the workload reached), +// and scattered traffic is bucketed to the floor so output is bounded by the +// number of distinct floor networks — not one entry per host, the regression +// caught in the kubescape/storage#349 review against the real too-large profile. +// The collapsed value lands in the plural ipAddresses field (PR#348), read via +// the dynamic client. // // floor /16, full S3 /28 (52.216.1.0/28) -> exactly 52.216.1.0/28 -// floor /16, scattered S3/CF/Azure/GCP IPs -> exactly those /32s (no covering block) +// floor /16, ~30 hosts spread across a /16 -> one covering 52.216.0.0/16 (bounded, no /32s) +// floor /16, 8 hosts each in a distinct /16 -> one /16 bucket apiece (bounded, no /32s) // floor /16, full Cloudflare IPv6 /124 -> exactly 2606:4700:0:1::/124 (dual-stack only) // floor /28, full S3 /27 (52.216.2.0/27) -> splits into 52.216.2.0/28 + 52.216.2.16/28 func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { @@ -3387,6 +3392,7 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { s3 := deployCIDRLearner(t, "resources/networkneighbors-s3-28.yaml") scattered := deployCIDRLearner(t, "resources/networkneighbors-scattered.yaml") + spread := deployCIDRLearner(t, "resources/networkneighbors-cidr-spread.yaml") v6 := deployCIDRLearner(t, "resources/networkneighbors-v6-124.yaml") // A fully-observed S3 /28 exact-covers to exactly that /28. @@ -3395,15 +3401,28 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { "a fully-observed S3 /28 must collapse to exactly 52.216.1.0/28") assert.Empty(t, withPrefixes(s3Bare, "52.216.1."), "no bare host /32 may remain") - // Scattered IPs across four providers, each in a distinct /16, must stay as - // their exact /32s — exact cover never invents a covering block. + // ~30 hosts spread across dozens of /24s within a single /16 — the shape of the + // real too-large profile from the storage#349 review. Under a /16 floor they + // share no common prefix as long as the floor collapses to one covering + // 52.216.0.0/16, NOT one entry per host. This is the case that exploded to + // thousands of /32s before the bucketing fix. + spreadCIDRs, spreadBare := collectLearntCollapse(t, dyn, spread) + assert.Equal(t, []string{"52.216.0.0/16"}, withPrefixes(spreadCIDRs, "52.216."), + "hosts spread across a /16 must collapse to a single bounded /16, not per-host entries") + assert.Empty(t, withPrefixes(spreadBare, "52.216."), "no bare host /32 may remain after bucketing") + + // Scattered IPs across four providers, each in a distinct /16, share no common + // prefix as long as the floor, so each is bucketed into its floor-length (/16) + // network — one bounded block apiece, never left as unbounded per-host /32s. scatteredWant := []string{ - "104.16.100.50/32", "13.107.6.152/32", "172.64.200.10/32", "20.150.10.5/32", - "34.120.50.10/32", "35.190.20.30/32", "52.216.10.20/32", "52.217.50.100/32", + "104.16.0.0/16", "13.107.0.0/16", "172.64.0.0/16", "20.150.0.0/16", + "34.120.0.0/16", "35.190.0.0/16", "52.216.0.0/16", "52.217.0.0/16", } - scatteredCIDRs, _ := collectLearntCollapse(t, dyn, scattered) + scatteredCIDRs, scatteredBare := collectLearntCollapse(t, dyn, scattered) got := withPrefixes(scatteredCIDRs, "52.216.", "52.217.", "104.16.", "172.64.", "20.150.", "13.107.", "34.120.", "35.190.") - assert.Equal(t, scatteredWant, got, "scattered cloud IPs must be covered exactly, as individual /32s") + assert.Equal(t, scatteredWant, got, "scattered cloud IPs, each in a distinct /16, bucket to one /16 apiece") + assert.Empty(t, withPrefixes(scatteredBare, "52.216.", "52.217.", "104.16.", "172.64.", "20.150.", "13.107.", "34.120.", "35.190."), + "no bare host /32 may remain after bucketing") // IPv6 exact cover — only on a dual-stack cluster; skip the assertion if the // pod never egressed over v6 (single-stack), rather than fail. @@ -3425,6 +3444,6 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { "a fully-observed /27 must split into two /28s under a /28 floor") assert.Empty(t, withPrefixes(splitBare, "52.216.2.")) - t.Logf("collapse validated on real cloud ranges: S3=%v scattered=%v split=%v", - withPrefixes(s3CIDRs, "52.216.1."), got, withPrefixes(splitCIDRs, "52.216.2.")) + t.Logf("collapse validated on real cloud ranges: S3=%v spread=%v scattered=%v split=%v", + withPrefixes(s3CIDRs, "52.216.1."), withPrefixes(spreadCIDRs, "52.216."), got, withPrefixes(splitCIDRs, "52.216.2.")) } diff --git a/tests/resources/networkneighbors-scattered.yaml b/tests/resources/networkneighbors-scattered.yaml index 3960066f3..c3de147f4 100644 --- a/tests/resources/networkneighbors-scattered.yaml +++ b/tests/resources/networkneighbors-scattered.yaml @@ -19,6 +19,8 @@ spec: image: busybox command: ["sh", "-c"] # Scattered real IPs across AWS S3 / Cloudflare / Azure / GCP, each in a - # distinct /16 -> exact cover keeps them as individual /32s. + # distinct /16. Above the group threshold and sharing no common prefix as + # long as the floor, they bucket to one /16 apiece under a /16 floor + # (bounded output), rather than staying as unbounded per-host /32s. args: - "while true; do for ip in 52.216.10.20 52.217.50.100 104.16.100.50 172.64.200.10 20.150.10.5 13.107.6.152 34.120.50.10 35.190.20.30; do nc -w 1 -z $ip 443 2>/dev/null; done; sleep 2; done" From 7ab815f27271d949cf9044dd648e6537a9f0cbb3 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 28 Jul 2026 07:59:53 +0200 Subject: [PATCH 8/8] ci(component): run Test_34 in the matrix, drop unused cidr-fanout fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test_34_NetworkNeighborsCIDRCollapse was never added to the component-tests matrix, so nothing ran it. The storage-side collapse it exercises (kubescape/storage#348 + #349) shipped in storage v0.0.298, and kubescape/helm-charts#887 bumped the chart to that tag — which tests/scripts/storage-tag.sh reads from helm-charts main at CI time, so the deployed storage now carries the collapse and the test can pass. Also drops tests/resources/networkneighbors-cidr-fanout.yaml: it was added alongside the test but is not referenced by it (the test uses the s3-28, s3-27, scattered, cidr-spread and v6-124 fixtures). Co-Authored-By: Claude Opus 5 Docs-exempt: CI matrix entry plus removal of an unreferenced test fixture; no production code or behavioral change Signed-off-by: Matthias Bertschy --- .github/workflows/component-tests.yaml | 3 ++- .../networkneighbors-cidr-fanout.yaml | 26 ------------------- 2 files changed, 2 insertions(+), 27 deletions(-) delete mode 100644 tests/resources/networkneighbors-cidr-fanout.yaml diff --git a/.github/workflows/component-tests.yaml b/.github/workflows/component-tests.yaml index c92220bc3..ab410177a 100644 --- a/.github/workflows/component-tests.yaml +++ b/.github/workflows/component-tests.yaml @@ -73,7 +73,8 @@ jobs: Test_23_RuleCooldownTest, Test_24_ProcessTreeDepthTest, Test_27_ApplicationProfileOpens, - Test_32_UnexpectedProcessArguments + Test_32_UnexpectedProcessArguments, + Test_34_NetworkNeighborsCIDRCollapse ] steps: - name: Checkout code diff --git a/tests/resources/networkneighbors-cidr-fanout.yaml b/tests/resources/networkneighbors-cidr-fanout.yaml deleted file mode 100644 index 3a6e90d98..000000000 --- a/tests/resources/networkneighbors-cidr-fanout.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - app: cidr-fanout - name: cidr-fanout -spec: - replicas: 1 - selector: - matchLabels: - app: cidr-fanout - template: - metadata: - labels: - app: cidr-fanout - spec: - containers: - - name: fanout - image: busybox:1.36 - command: ["sh", "-c"] - args: - - | - while true; do - for i in $(seq 1 60); do nc -w 1 -z 52.216.0.$i 443 2>/dev/null; done - sleep 3 - done