diff --git a/.gitignore b/.gitignore index db15f79ba..397e4b1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ resources/ebpf/falco/* node-agent __pycache__ tracers.tar -vendor \ No newline at end of file +vendor +.claude/ diff --git a/go.mod b/go.mod index 54f2392ed..b8e5c2c13 100644 --- a/go.mod +++ b/go.mod @@ -507,4 +507,4 @@ replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspe replace github.com/cilium/ebpf => github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c -replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260509184329-a7e6234349ab +replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260510175248-02c4438f072f diff --git a/go.sum b/go.sum index 4ae88fcc1..521fd16f1 100644 --- a/go.sum +++ b/go.sum @@ -981,8 +981,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/k8sstormcenter/storage v0.0.240-0.20260509184329-a7e6234349ab h1:DNjKAs888GzW7P9gJUKtldL6E7zYzjLiO6pVUTvnzqc= -github.com/k8sstormcenter/storage v0.0.240-0.20260509184329-a7e6234349ab/go.mod h1:amdg/Qok9bqPzs1vZH5FW9/3MbCawc5wVsz9u3uIfu4= +github.com/k8sstormcenter/storage v0.0.240-0.20260510175248-02c4438f072f h1:TaffnMdzqwUKfWgjIcjorDjJRhJD99ISzK3NzxZIq1c= +github.com/k8sstormcenter/storage v0.0.240-0.20260510175248-02c4438f072f/go.mod h1:amdg/Qok9bqPzs1vZH5FW9/3MbCawc5wVsz9u3uIfu4= github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 h1:WdAeg/imY2JFPc/9CST4bZ80nNJbiBFCAdSZCSgrS5Y= github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953/go.mod h1:6o+UrvuZWc4UTyBhQf0LGjW9Ld7qJxLz/OqvSOWWlEc= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go new file mode 100644 index 000000000..7058ca180 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go @@ -0,0 +1,239 @@ +package networkneighborhood + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/cel-go/common/types" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +// TestFixturesParse validates that every YAML fixture under +// tests/resources/network-wildcards/ parses against the v1beta1 +// NetworkNeighborhood schema. This is the user-facing-examples gate: +// the fixtures double as authoritative syntax documentation, so a +// fixture that fails to parse is a documentation bug. +// +// Fixture 14 (recursive-star-rejected) parses but its dnsNames entry +// '**' is rejected at admission time — see the storage REST strategy +// validation test (TestValidate_NetworkProfileEntries). +func TestFixturesParse(t *testing.T) { + fixturesDir := findFixturesDir(t) + entries, err := os.ReadDir(fixturesDir) + require.NoError(t, err) + + if len(entries) == 0 { + t.Fatalf("no fixtures found under %s", fixturesDir) + } + + parsed := 0 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + name := e.Name() + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(fixturesDir, name)) + require.NoError(t, err) + + // Strip the literal "{{NAMESPACE}}" placeholder; the fixtures + // are templates, runtime substitutes a real namespace. + data = []byte(strings.ReplaceAll(string(data), "{{NAMESPACE}}", "test-ns")) + + var nn v1beta1.NetworkNeighborhood + // Strict mode: any unknown field in a fixture is a typo + // against the v1beta1 schema. Documentation must not drift + // from the runtime types. + err = yaml.UnmarshalStrict(data, &nn) + require.NoError(t, err, "fixture %s must parse against v1beta1 schema (strict)", name) + require.Equal(t, "NetworkNeighborhood", nn.Kind, "fixture %s wrong kind", name) + require.NotEmpty(t, nn.Spec.Containers, "fixture %s should declare at least one container", name) + }) + parsed++ + } + if parsed < 20 { + t.Errorf("expected ≥ 20 fixtures, parsed %d", parsed) + } +} + +// TestFixturesMatchExpectedBehaviour walks a curated subset of fixtures +// through the actual CEL library matchers, asserting the documented +// observed→match behaviour from each fixture's header comment. +// +// This is the contract pin between the user-facing examples and the +// runtime: if a fixture says "10.1.2.3 → match" and the matcher +// disagrees, ONE of them is wrong. Today both are pinned by this test. +// +// Coverage: representative cases for each major edge case. Not every +// (fixture × observation) is exercised — that would be brittle as +// the fixtures evolve. +func TestFixturesMatchExpectedBehaviour(t *testing.T) { + cases := []struct { + name string + neighbors []v1beta1.NetworkNeighbor + ingress []v1beta1.NetworkNeighbor + // ipChecks verifies wasAddressInEgress only (back-compat for cases + // with no ingress declared; runs only the egress matcher). + ipChecks []ipCheck + // ipBothChecks verifies BOTH wasAddressInEgress and wasAddressInIngress + // — used for direction-isolation cases so the assertion goes both ways. + ipBothChecks []ipBothCheck + dnsChecks []dnsCheck + }{ + { + name: "fixture-01-literal-ipv4", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.1.2.3"}}, + }, + ipChecks: []ipCheck{ + {"10.1.2.3", true}, + {"10.1.2.4", false}, + }, + }, + { + name: "fixture-03-cidr-ipv4", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.0.0.0/8"}}, + }, + ipChecks: []ipCheck{ + {"10.0.0.0", true}, + {"10.255.255.255", true}, + {"11.0.0.1", false}, + }, + }, + { + name: "fixture-05-any-ip-sentinel", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"*"}}, + }, + ipChecks: []ipCheck{ + {"1.2.3.4", true}, + {"::1", true}, + }, + }, + { + name: "fixture-08-deprecated-ipaddress", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddress: "10.1.2.3"}, // singular, deprecated form + }, + ipChecks: []ipCheck{ + {"10.1.2.3", true}, + {"10.1.2.4", false}, + }, + }, + { + name: "fixture-10-dns-leading-wildcard", + neighbors: []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"*.example.com."}}, + }, + dnsChecks: []dnsCheck{ + {"api.example.com.", true}, + {"v1.api.example.com.", false}, // RFC 4592: exactly one label + {"example.com.", false}, // zero labels + }, + }, + { + name: "fixture-18-cluster-dns-mid-ellipsis", + neighbors: []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"kubernetes.⋯.svc.cluster.local."}}, + }, + dnsChecks: []dnsCheck{ + {"kubernetes.default.svc.cluster.local.", true}, + {"kubernetes.kube-system.svc.cluster.local.", true}, + {"redis.default.svc.cluster.local.", false}, + }, + }, + { + name: "fixture-15-egress-and-ingress-direction-isolation", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"8.8.8.8"}}, + }, + ingress: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.244.0.0/16"}}, + }, + // Direction isolation: each address MUST hit only the direction + // it was declared on. CR (node-agent#41) flagged that the prior + // version only checked egress; this asserts ingress too. + ipBothChecks: []ipBothCheck{ + {observed: "8.8.8.8", wantEgress: true, wantIngress: false}, // egress-only + {observed: "10.244.5.5", wantEgress: false, wantIngress: true}, // ingress-only + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + lib := buildLibWithContainer(t, tc.neighbors, tc.ingress) + for _, c := range tc.ipChecks { + res := lib.wasAddressInEgress(types.String("cid"), types.String(c.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + if res != types.Bool(c.want) { + t.Errorf("egress ip %q: got %v, want %v", c.observed, res, c.want) + } + } + for _, c := range tc.ipBothChecks { + eg := lib.wasAddressInEgress(types.String("cid"), types.String(c.observed)) + eg = cache.ConvertProfileNotAvailableErrToBool(eg, false) + if eg != types.Bool(c.wantEgress) { + t.Errorf("egress ip %q: got %v, want %v", c.observed, eg, c.wantEgress) + } + in := lib.wasAddressInIngress(types.String("cid"), types.String(c.observed)) + in = cache.ConvertProfileNotAvailableErrToBool(in, false) + if in != types.Bool(c.wantIngress) { + t.Errorf("ingress ip %q: got %v, want %v", c.observed, in, c.wantIngress) + } + } + for _, c := range tc.dnsChecks { + res := lib.isDomainInEgress(types.String("cid"), types.String(c.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + if res != types.Bool(c.want) { + t.Errorf("dns %q: got %v, want %v", c.observed, res, c.want) + } + } + }) + } +} + +type ipCheck struct { + observed string + want bool +} + +type ipBothCheck struct { + observed string + wantEgress bool + wantIngress bool +} + +type dnsCheck struct { + observed string + want bool +} + +// findFixturesDir walks up from the test's working directory to locate +// tests/resources/network-wildcards/. The package's own working dir +// when `go test` runs is its source dir, so we walk up to find the +// repo root. +func findFixturesDir(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + require.NoError(t, err) + for i := 0; i < 10; i++ { + candidate := filepath.Join(dir, "tests", "resources", "network-wildcards") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Fatalf("could not find tests/resources/network-wildcards/ from %s", dir) + return "" +} diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go index 0449ebf96..4851412fc 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go @@ -1,15 +1,62 @@ package networkneighborhood import ( - "slices" - "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/networkmatch" ) +// neighborMatchesIP reports whether the observed IP matches any entry on +// the neighbor — either the deprecated singular IPAddress (back-compat) +// or any of the new IPAddresses[] entries (literal, CIDR, or '*' sentinel). +// +// Both the deprecated singular field and the new list field accept the +// SAME wildcard token vocabulary — i.e. a profile that sets +// IPAddress: "10.0.0.0/8" or IPAddress: "*" gets CIDR/sentinel matching +// just like the list form would. This unifies admission validation and +// runtime matching across both back-compat and current shapes. +// +// Built fresh per-call rather than cached. The functionCache layer in +// nn.go memoises the (containerID, address) tuple, so a hot rule firing +// on the same address won't repeatedly recompile the matcher. +func neighborMatchesIP(neighbor *v1beta1.NetworkNeighbor, observed string) bool { + // Route the deprecated singular IPAddress through MatchIP as a single-element + // slice so it gets the same canonicalisation (IPv6 forms, IPv4-mapped) as + // the new IPAddresses[] entries. Symmetric with neighborMatchesDNS, which + // also routes the deprecated singular DNS field through its matcher. + if neighbor.IPAddress != "" && networkmatch.MatchIP([]string{neighbor.IPAddress}, observed) { + return true + } + if len(neighbor.IPAddresses) > 0 { + if networkmatch.MatchIP(neighbor.IPAddresses, observed) { + return true + } + } + return false +} + +// neighborMatchesDNS reports whether the observed DNS name matches any +// entry on the neighbor — the deprecated singular DNS field, or any of +// the DNSNames[] entries (literal, leading-*, trailing-*, mid-⋯). +func neighborMatchesDNS(neighbor *v1beta1.NetworkNeighbor, observed string) bool { + // Route the deprecated singular DNS through MatchDNS as a single-element + // slice so it gets the same trailing-dot stripping + lowercasing as the + // new DNSNames[] entries — back-compat shouldn't mean inconsistent + // normalisation. + if neighbor.DNS != "" && networkmatch.MatchDNS([]string{neighbor.DNS}, observed) { + return true + } + if len(neighbor.DNSNames) > 0 { + if networkmatch.MatchDNS(neighbor.DNSNames, observed) { + return true + } + } + return false +} + func (l *nnLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") @@ -29,8 +76,8 @@ func (l *nnLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { return cache.NewProfileNotAvailableErr("%v", err) } - for _, egress := range cp.Spec.Egress { - if egress.IPAddress == addressStr { + for i := range cp.Spec.Egress { + if neighborMatchesIP(&cp.Spec.Egress[i], addressStr) { return types.Bool(true) } } @@ -57,8 +104,8 @@ func (l *nnLibrary) wasAddressInIngress(containerID, address ref.Val) ref.Val { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ingress := range cp.Spec.Ingress { - if ingress.IPAddress == addressStr { + for i := range cp.Spec.Ingress { + if neighborMatchesIP(&cp.Spec.Ingress[i], addressStr) { return types.Bool(true) } } @@ -85,8 +132,8 @@ func (l *nnLibrary) isDomainInEgress(containerID, domain ref.Val) ref.Val { return cache.NewProfileNotAvailableErr("%v", err) } - for _, egress := range cp.Spec.Egress { - if slices.Contains(egress.DNSNames, domainStr) || egress.DNS == domainStr { + for i := range cp.Spec.Egress { + if neighborMatchesDNS(&cp.Spec.Egress[i], domainStr) { return types.Bool(true) } } @@ -113,8 +160,8 @@ func (l *nnLibrary) isDomainInIngress(containerID, domain ref.Val) ref.Val { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ingress := range cp.Spec.Ingress { - if slices.Contains(ingress.DNSNames, domainStr) { + for i := range cp.Spec.Ingress { + if neighborMatchesDNS(&cp.Spec.Ingress[i], domainStr) { return types.Bool(true) } } @@ -139,6 +186,14 @@ func (l *nnLibrary) wasAddressPortProtocolInEgress(containerID, address, port, p if !ok { return types.MaybeNoSuchOverloadErr(port) } + // Reject out-of-range ports BEFORE narrowing to int32. CEL evaluates + // port as int64, but TCP/UDP wire ports are uint16. A bogus value + // like 4294967739 narrows to 443 and would match — return false + // instead of letting the wrap silently succeed. + if portInt < 0 || portInt > 65535 { + return types.Bool(false) + } + expectedPort := int32(portInt) protocolStr, ok := protocol.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(protocol) @@ -149,12 +204,14 @@ func (l *nnLibrary) wasAddressPortProtocolInEgress(containerID, address, port, p return cache.NewProfileNotAvailableErr("%v", err) } - for _, egress := range cp.Spec.Egress { - if egress.IPAddress == addressStr { - for _, portInfo := range egress.Ports { - if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == int32(portInt) { - return types.Bool(true) - } + for i := range cp.Spec.Egress { + egress := &cp.Spec.Egress[i] + if !neighborMatchesIP(egress, addressStr) { + continue + } + for _, portInfo := range egress.Ports { + if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == expectedPort { + return types.Bool(true) } } } @@ -179,6 +236,11 @@ func (l *nnLibrary) wasAddressPortProtocolInIngress(containerID, address, port, if !ok { return types.MaybeNoSuchOverloadErr(port) } + // See wasAddressPortProtocolInEgress for the int64→int32 wrap rationale. + if portInt < 0 || portInt > 65535 { + return types.Bool(false) + } + expectedPort := int32(portInt) protocolStr, ok := protocol.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(protocol) @@ -189,12 +251,14 @@ func (l *nnLibrary) wasAddressPortProtocolInIngress(containerID, address, port, return cache.NewProfileNotAvailableErr("%v", err) } - for _, ingress := range cp.Spec.Ingress { - if ingress.IPAddress == addressStr { - for _, portInfo := range ingress.Ports { - if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == int32(portInt) { - return types.Bool(true) - } + for i := range cp.Spec.Ingress { + ingress := &cp.Spec.Ingress[i] + if !neighborMatchesIP(ingress, addressStr) { + continue + } + for _, portInfo := range ingress.Ports { + if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == expectedPort { + return types.Bool(true) } } } diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go new file mode 100644 index 000000000..5d2e5f83d --- /dev/null +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go @@ -0,0 +1,371 @@ +package networkneighborhood + +import ( + "testing" + + "github.com/google/cel-go/common/types" + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/objectcache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" +) + +// Helper: build a ready-to-use library with a single-container profile. +func buildLibWithContainer(t *testing.T, neighbors []v1beta1.NetworkNeighbor, ingressNeighbors []v1beta1.NetworkNeighbor) *nnLibrary { + t.Helper() + objCache := objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + objCache.SetSharedContainerData("cid", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: {{Name: "c"}}, + }, + }) + nn := &v1beta1.NetworkNeighborhood{} + nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ + Name: "c", + Egress: neighbors, + Ingress: ingressNeighbors, + }) + objCache.SetNetworkNeighborhood(nn) + return &nnLibrary{ + objectCache: &objCache, + functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), + } +} + +func TestWasAddressInEgress_WildcardCIDRMatch(t *testing.T) { + // Profile uses the new IPAddresses[] field with a CIDR. Old byte-equality + // implementation would fail to match observed IPs that fall inside. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.0.0.0/8"}}, + }, nil) + + cases := []struct { + observed string + want bool + }{ + {"10.1.2.3", true}, // inside CIDR + {"10.255.255.254", true}, + {"11.0.0.1", false}, // outside + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + res := lib.wasAddressInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "address %q", tc.observed) + }) + } +} + +func TestWasAddressInEgress_AnyIPSentinel(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"*"}}, + }, nil) + + for _, addr := range []string{"1.2.3.4", "8.8.8.8", "10.0.0.1", "2001:db8::1"} { + res := lib.wasAddressInEgress(types.String("cid"), types.String(addr)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res, "addr %q", addr) + } +} + +func TestWasAddressInEgress_LegacySingularStillWorks(t *testing.T) { + // Backward compatibility: profiles using the deprecated singular + // IPAddress field MUST keep matching as before. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddress: "10.1.2.3"}, + }, nil) + + res := lib.wasAddressInEgress(types.String("cid"), types.String("10.1.2.3")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res) + + res = lib.wasAddressInEgress(types.String("cid"), types.String("10.1.2.4")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res) +} + +func TestWasAddressInEgress_BothSingularAndPlural(t *testing.T) { + // Mixed profile: one entry uses deprecated IPAddress, another uses new IPAddresses. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddress: "8.8.8.8"}, + {IPAddresses: []string{"10.0.0.0/8"}}, + }, nil) + + for addr, want := range map[string]bool{ + "8.8.8.8": true, // deprecated singular hit + "10.1.2.3": true, // new CIDR hit + "1.2.3.4": false, // neither + } { + res := lib.wasAddressInEgress(types.String("cid"), types.String(addr)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(want), res, "addr %q", addr) + } +} + +func TestIsDomainInEgress_LeadingWildcard(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"*.stripe.com."}}, + }, nil) + + cases := []struct { + observed string + want bool + }{ + {"api.stripe.com.", true}, + {"webhooks.stripe.com.", true}, + {"v1.api.stripe.com.", false}, // two labels deep + {"stripe.com.", false}, // zero labels — RFC 4592 + {"api.stripe.org.", false}, + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + res := lib.isDomainInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "obs %q", tc.observed) + }) + } +} + +func TestIsDomainInEgress_MidEllipsis(t *testing.T) { + // User's specific case: parametric namespace label in K8s service FQDN. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"kubernetes.⋯.svc.cluster.local."}}, + }, nil) + + cases := []struct { + observed string + want bool + }{ + {"kubernetes.default.svc.cluster.local.", true}, + {"kubernetes.kube-system.svc.cluster.local.", true}, + {"redis.default.svc.cluster.local.", false}, // wrong service prefix + {"kubernetes.foo.bar.svc.cluster.local.", false}, // two labels mid + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + res := lib.isDomainInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "obs %q", tc.observed) + }) + } +} + +func TestIsDomainInEgress_TrailingDotResilience(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"api.stripe.com"}}, // no trailing dot in profile + }, nil) + + // Observed name comes WITH trailing dot (FQDN canonical form). + res := lib.isDomainInEgress(types.String("cid"), types.String("api.stripe.com.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res) +} + +// CR (node-agent#41 round 3) flagged that routing the deprecated IPAddress +// through MatchIP (round 2 fix) creates an unspoken behaviour change: the +// deprecated field now ALSO accepts wildcard/CIDR patterns. This is +// intentional — the contract is "deprecated singular gets the same +// semantics as the list form" — and these tests pin it explicitly so it +// can't silently regress. +func TestWasAddressInEgress_DeprecatedIPAddress_AcceptsWildcardAndCIDR(t *testing.T) { + cases := []struct { + profileIP string + observed string + want bool + }{ + // '*' sentinel on the deprecated field — matches any valid IP + {"*", "1.2.3.4", true}, + {"*", "8.8.8.8", true}, + {"*", "::1", true}, + // CIDR on the deprecated field — same membership semantics + {"10.0.0.0/8", "10.1.2.3", true}, + {"10.0.0.0/8", "10.255.255.255", true}, + {"10.0.0.0/8", "11.0.0.1", false}, + {"0.0.0.0/0", "203.0.113.7", true}, // any-IPv4 via CIDR + {"::/0", "2001:db8::1", true}, // any-IPv6 via CIDR + // Literal still works + {"192.168.1.1", "192.168.1.1", true}, + {"192.168.1.1", "192.168.1.2", false}, + } + for _, tc := range cases { + t.Run(tc.profileIP+"_vs_"+tc.observed, func(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddress: tc.profileIP}, // deprecated singular field + }, nil) + res := lib.wasAddressInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "profile=%q observed=%q", tc.profileIP, tc.observed) + }) + } +} + +// CR (node-agent#41 round 2) flagged that the deprecated singular IPAddress +// field originally compared via raw string equality, which would diverge from +// IPAddresses[] behaviour for IPv6 canonicalisation. neighborMatchesIP now +// routes both fields through MatchIP — pin the parity here. +func TestWasAddressInEgress_DeprecatedIPAddress_IPv6Canonicalisation(t *testing.T) { + cases := []struct { + profileIP string + observed string + want bool + }{ + {"2001:db8::1", "2001:db8::1", true}, // identical + {"2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001", true}, // expanded form same address + {"10.0.0.1", "::ffff:10.0.0.1", true}, // IPv4-mapped IPv6 + {"10.0.0.1", "10.0.0.2", false}, // genuine miss + } + for _, tc := range cases { + t.Run(tc.profileIP+"_vs_"+tc.observed, func(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddress: tc.profileIP}, // deprecated singular field + }, nil) + res := lib.wasAddressInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "profile=%q observed=%q", tc.profileIP, tc.observed) + }) + } +} + +// CR (node-agent#41) flagged that the deprecated singular DNS field +// originally compared via raw string equality, which would diverge from +// DNSNames behaviour for trailing-dot variants. neighborMatchesDNS now +// routes both fields through MatchDNS — pin the parity here. +func TestIsDomainInEgress_DeprecatedDNS_TrailingDotParity(t *testing.T) { + cases := []struct { + profileDNS string + observed string + want bool + }{ + {"api.stripe.com.", "api.stripe.com.", true}, // both with dot + {"api.stripe.com", "api.stripe.com.", true}, // profile no dot, observed with dot + {"api.stripe.com.", "api.stripe.com", true}, // profile with dot, observed no dot + {"api.stripe.com", "api.stripe.com", true}, // neither dot + {"api.stripe.com.", "api.stripe.org.", false}, // wrong TLD + } + for _, tc := range cases { + t.Run(tc.profileDNS+"_vs_"+tc.observed, func(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNS: tc.profileDNS}, // deprecated singular field + }, nil) + res := lib.isDomainInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "profile=%q observed=%q", tc.profileDNS, tc.observed) + }) + } +} + +// CR (node-agent#41) flagged int64→int32 wrap risk in port comparison. +// 4294967739 narrows to 443 — without the range guard this would +// incorrectly match a profile entry on port 443. +func TestWasAddressPortProtocolInEgress_PortWrapRejected(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + { + IPAddress: "10.1.2.3", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, + }, + }, + }, nil) + + cases := []struct { + name string + port int64 + want bool + }{ + {"in-range hit", 443, true}, + {"in-range miss", 444, false}, + {"wrap-to-443 rejected", 4294967739, false}, // (1<<32)+443 + {"negative rejected", -1, false}, + {"too-large rejected", 65536, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := lib.wasAddressPortProtocolInEgress( + types.String("cid"), types.String("10.1.2.3"), + types.Int(tc.port), types.String("TCP"), + ) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res) + }) + } +} + +func TestWasAddressInIngress_WildcardCIDR(t *testing.T) { + // Direction isolation: the same address can be allowed on ingress + // but not egress, and vice versa. + lib := buildLibWithContainer(t, + []v1beta1.NetworkNeighbor{ /* empty egress */ }, + []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.244.0.0/16"}}, + }, + ) + + t.Run("ingress-CIDR-hit", func(t *testing.T) { + res := lib.wasAddressInIngress(types.String("cid"), types.String("10.244.5.5")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res) + }) + t.Run("egress-must-stay-empty", func(t *testing.T) { + // Same address on egress must NOT match — direction isolation. + res := lib.wasAddressInEgress(types.String("cid"), types.String("10.244.5.5")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res) + }) +} + +func TestIsDomainInIngress_LeadingWildcard(t *testing.T) { + lib := buildLibWithContainer(t, + nil, + []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"*.internal."}}, + }, + ) + res := lib.isDomainInIngress(types.String("cid"), types.String("api.internal.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res) + + // Egress is empty so the same name must NOT match on egress. + res = lib.isDomainInEgress(types.String("cid"), types.String("api.internal.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res) +} + +func TestWasAddressPortProtocolInEgress_WithCIDR(t *testing.T) { + // Composed match: CIDR + port + protocol. Mirror of fixture 19. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + { + IPAddresses: []string{"10.0.0.0/8"}, + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, + }, + }, + }, nil) + + cases := []struct { + observed string + port int64 + proto string + want bool + }{ + {"10.1.2.3", 443, "TCP", true}, // all three line up + {"10.1.2.3", 80, "TCP", false}, // wrong port + {"10.1.2.3", 443, "UDP", false}, // wrong protocol + {"11.0.0.1", 443, "TCP", false}, // outside CIDR + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + res := lib.wasAddressPortProtocolInEgress( + types.String("cid"), types.String(tc.observed), + types.Int(tc.port), types.String(tc.proto), + ) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res) + }) + } +} diff --git a/tests/resources/network-wildcards/01-literal-ipv4.yaml b/tests/resources/network-wildcards/01-literal-ipv4.yaml new file mode 100644 index 000000000..a9861986c --- /dev/null +++ b/tests/resources/network-wildcards/01-literal-ipv4.yaml @@ -0,0 +1,26 @@ +# Fixture 01 — IPv4 literal in ipAddresses[] +# +# Edge case: single IPv4 literal in the new plural field +# Expects: observed IP "162.0.217.171" matches; "162.0.217.172" does NOT +# Match path: networkmatch.MatchIP(["162.0.217.171"], observed) → true iff equal +# Spec ref: §5.7 "IPv4 / IPv6 literal" row +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-01-literal-ipv4 + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-01 + containers: + - name: client + egress: + - identifier: literal-ipv4 + type: external + ipAddresses: + - "162.0.217.171" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/02-literal-ipv6.yaml b/tests/resources/network-wildcards/02-literal-ipv6.yaml new file mode 100644 index 000000000..b0856b33a --- /dev/null +++ b/tests/resources/network-wildcards/02-literal-ipv6.yaml @@ -0,0 +1,27 @@ +# Fixture 02 — IPv6 literal, canonicalisation +# +# Edge case: IPv6 literal, both compressed and expanded forms MUST compare equal +# Expects: "2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001", +# and "2001:DB8::1" all match each other +# Match path: net.ParseIP(...) normalises before .Equal() — verifier responsibility +# Spec ref: §5.7 — "textual canonicalisation is the verifier's responsibility" +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-02-literal-ipv6 + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-02 + containers: + - name: client + egress: + - identifier: literal-ipv6 + type: external + ipAddresses: + - "2001:db8::1" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/03-cidr-ipv4.yaml b/tests/resources/network-wildcards/03-cidr-ipv4.yaml new file mode 100644 index 000000000..cd803cbc0 --- /dev/null +++ b/tests/resources/network-wildcards/03-cidr-ipv4.yaml @@ -0,0 +1,28 @@ +# Fixture 03 — IPv4 CIDR +# +# Edge case: a single CIDR block covers a range of IPs +# Expects: observed "10.0.0.1" matches; "10.255.255.254" matches; +# "11.0.0.1" does NOT match +# Match path: net.ParseCIDR("10.0.0.0/8") → *IPNet; IPNet.Contains(observed) +# Perf: compile once at profile-load, reuse the *IPNet on every event +# Spec ref: §5.7 "CIDR" row +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-03-cidr-ipv4 + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-03 + containers: + - name: client + egress: + - identifier: rfc1918-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/04-cidr-ipv6.yaml b/tests/resources/network-wildcards/04-cidr-ipv6.yaml new file mode 100644 index 000000000..a885323c7 --- /dev/null +++ b/tests/resources/network-wildcards/04-cidr-ipv6.yaml @@ -0,0 +1,26 @@ +# Fixture 04 — IPv6 CIDR +# +# Edge case: IPv6 CIDR matching +# Expects: observed "2001:db8::1" matches; "2001:db9::1" does NOT +# Match path: same code path as IPv4 CIDR — net.ParseCIDR recognises both +# Spec ref: §5.7 "CIDR" row, second example +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-04-cidr-ipv6 + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-04 + containers: + - name: client + egress: + - identifier: rfc3849-doc-prefix + type: external + ipAddresses: + - "2001:db8::/32" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/05-any-ip-sentinel.yaml b/tests/resources/network-wildcards/05-any-ip-sentinel.yaml new file mode 100644 index 000000000..035fd046a --- /dev/null +++ b/tests/resources/network-wildcards/05-any-ip-sentinel.yaml @@ -0,0 +1,31 @@ +# Fixture 05 — `*` sentinel for ANY IP +# +# Edge case: a single "*" entry — matches any IPv4 or IPv6 address +# Expects: every observed IP matches; this is permissive-mode profiling +# Match path: compileIP("*") returns isAny=true; runtime short-circuits +# Spec ref: §5.7 "* (any-IP sentinel)" row + the warning at the bottom +# Operations: strongly DISCOURAGED outside development profiles — +# equivalent to disabling egress filtering for this workload. +# Producers should normally enumerate concrete IPs/CIDRs. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-05-any-sentinel + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" + # Make the operational risk explicit: + sbob.io/discouraged-wildcards: "ipAddresses-any-sentinel" +spec: + matchLabels: + app: nw-05 + containers: + - name: client + egress: + - identifier: any-ip-development-profile + type: external + ipAddresses: + - "*" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/06-any-as-cidr.yaml b/tests/resources/network-wildcards/06-any-as-cidr.yaml new file mode 100644 index 000000000..b897eb4ec --- /dev/null +++ b/tests/resources/network-wildcards/06-any-as-cidr.yaml @@ -0,0 +1,34 @@ +# Fixture 06 — RFC-aligned alternatives to the `*` sentinel +# +# Edge case: `0.0.0.0/0` (RFC 4632 — all IPv4) and `::/0` (RFC 4291 — all IPv6) +# MUST behave identically to `*` +# Expects: observed "1.2.3.4" matches via 0.0.0.0/0; +# observed "2001:db8::1" matches via ::/0 +# Match path: regular CIDR matching — no special casing needed +# Spec ref: §5.7 — "*" sentinel "is sugar for the union of 0.0.0.0/0 + ::/0" +# Why both forms exist: +# Producers who prefer standards-compliant CIDR over our `*` +# sugar can express "any IP" via these two CIDRs and the +# document will be accepted by tooling that doesn't recognise +# the `*` sentinel. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-06-any-as-cidr + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-06 + containers: + - name: client + egress: + - identifier: any-via-cidrs + type: external + ipAddresses: + - "0.0.0.0/0" + - "::/0" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/07-mixed-ip-list.yaml b/tests/resources/network-wildcards/07-mixed-ip-list.yaml new file mode 100644 index 000000000..dc5d526fb --- /dev/null +++ b/tests/resources/network-wildcards/07-mixed-ip-list.yaml @@ -0,0 +1,36 @@ +# Fixture 07 — mixed list (literal + CIDR + sentinel) +# +# Edge case: a single ipAddresses[] list mixes all three forms +# Expects: +# "10.1.2.3" → matches via 10.0.0.0/8 +# "162.0.217.171" → matches via the literal +# "8.8.8.8" → matches via the `*` sentinel +# (this fixture intentionally has an unconstrained `*` because +# of the sentinel — the literal and CIDR are illustrative) +# Match path: ANY entry matches → match passes (logical OR) +# Spec ref: §5.7 algorithm "for each entry e in profile.ipAddresses" +# Test value: exercises the loop ordering and short-circuit-on-first-match +# behaviour +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-07-mixed-ip-list + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-07 + containers: + - name: client + egress: + - identifier: mixed-shapes + type: external + ipAddresses: + - "162.0.217.171" # IPv4 literal + - "10.0.0.0/8" # IPv4 CIDR + - "2001:db8::/32" # IPv6 CIDR + - "*" # any (sentinel — overrides everything; here for test) + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml b/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml new file mode 100644 index 000000000..5d56b271a --- /dev/null +++ b/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml @@ -0,0 +1,32 @@ +# Fixture 08 — backward compatibility with deprecated singular `ipAddress` +# +# Edge case: only the deprecated singular field populated; ipAddresses absent +# Expects: observed "10.0.0.42" matches via the singular field; +# behaviour unchanged from v0.0.1 +# Match path: verifier walks BOTH singular and plural fields, treating them +# as a logical OR +# Spec ref: §4.7 ipAddress row — "Deprecated since v0.0.2 — kept for back-compat" +# Producer rule: MUST NOT populate both `ipAddress` (singular) and `ipAddresses` +# (plural) on the same entry — admission strategy rejects +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-08-deprecated-ipaddress + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" + # New profiles should use ipAddresses; this fixture exists only to pin + # back-compat behaviour for v0.0.1-era documents that haven't migrated yet. + sbob.io/migration-target: "ipAddresses" +spec: + matchLabels: + app: nw-08 + containers: + - name: legacy-client + egress: + - identifier: legacy-singular-ip + type: external + ipAddress: "10.0.0.42" # DEPRECATED — kept here on purpose to exercise back-compat + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/09-dns-literal.yaml b/tests/resources/network-wildcards/09-dns-literal.yaml new file mode 100644 index 000000000..93b199d49 --- /dev/null +++ b/tests/resources/network-wildcards/09-dns-literal.yaml @@ -0,0 +1,29 @@ +# Fixture 09 — DNS literal +# +# Edge case: plain FQDN, byte-equality after trailing-dot normalisation +# Expects: observed "api.stripe.com." matches; "api.stripe.com" matches +# (both forms equivalent); "v1.api.stripe.com." does NOT +# Match path: normalise trailing dot on both profile entry and observed name, +# then byte-equality +# Spec ref: §5.8 "Literal" row, plus the trailing-dot normalisation paragraph +# RFC ref: RFC 1035 § 3.1 (FQDN syntax) +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-09-dns-literal + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-09 + containers: + - name: client + egress: + - identifier: stripe-api-literal + type: external + dnsNames: + - "api.stripe.com." + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml b/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml new file mode 100644 index 000000000..46802c441 --- /dev/null +++ b/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml @@ -0,0 +1,35 @@ +# Fixture 10 — DNS leading wildcard `*.` +# +# Edge case: RFC 4592 wildcard label — exactly ONE label before the suffix +# Expects: +# observed "api.example.com." → match (one label "api") +# observed "webhooks.example.com." → match (one label "webhooks") +# observed "v1.api.example.com." → NO match (two labels — leading * is exactly one) +# observed "example.com." → NO match (apex — leading * requires at least one) +# observed ".example.com." → NO match (empty label — invalid DNS) +# Match path: label-split + per-position match using the same recursive +# matcher as path wildcards (`compareLabels`) +# Spec ref: §5.8 "*." row + the rationale block +# RFC ref: RFC 4592 (DNS wildcard match) — "exactly one label" is the +# only ratified wildcard form; bind/coredns/cilium/k8s ingress +# all honour this convention +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-10-dns-leading-wildcard + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-10 + containers: + - name: client + egress: + - identifier: example-com-subdomains + type: external + dnsNames: + - "*.example.com." + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml b/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml new file mode 100644 index 000000000..3325329c6 --- /dev/null +++ b/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml @@ -0,0 +1,41 @@ +# Fixture 11 — DNS mid-label `⋯` (DynamicIdentifier) +# +# Edge case: exactly ONE label between two static segments +# (the user's `svc.*.kubernetes.io.` use case, spelt with ⋯ +# because mid-label `*` is non-standard) +# Expects: +# observed "svc.kube-system.cluster.local." → match +# observed "svc.default.cluster.local." → match +# observed "svc.cluster.local." → NO match (zero labels in slot) +# observed "svc.a.b.cluster.local." → NO match (two labels — ⋯ is exactly one) +# Match path: label-split + the existing dynamicpathdetector.CompareDynamic +# (DNS labels and path segments are structurally identical) +# Spec ref: §5.8 ".⋯." row — "DynamicIdentifier — exactly one label" +# Why this exists: +# RFC 4592 only standardises LEADING wildcards. Mid-label `*` is non-standard +# (cilium uses regex; bind/coredns reject it). v0.0.2 uses `⋯` (our token, +# from path/argv wildcards) for mid positions so the wire format never +# claims false RFC 4592 compliance. +# Token reminder: +# `⋯` is U+22EF (MIDLINE HORIZONTAL ELLIPSIS) — ONE Unicode codepoint. +# It is NOT three ASCII periods (`...`). +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-11-dns-mid-ellipsis + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-11 + containers: + - name: dns-client + egress: + - identifier: cluster-svc-resolution + type: internal + dnsNames: + - "svc.⋯.cluster.local." + ports: + - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards/12-dns-trailing-star.yaml b/tests/resources/network-wildcards/12-dns-trailing-star.yaml new file mode 100644 index 000000000..b78723f10 --- /dev/null +++ b/tests/resources/network-wildcards/12-dns-trailing-star.yaml @@ -0,0 +1,46 @@ +# Fixture 12 — DNS trailing wildcard `.*` +# +# Edge case: one OR MORE labels after the prefix (NEVER zero) +# Expects: +# observed "mycorp.com.api." → match (one label after) +# observed "mycorp.com.api.v1." → match (two labels after) +# observed "mycorp.com.api.v1.eu-west-1." → match (three labels after) +# observed "mycorp.com." → NO match (apex — zero labels; +# trailing `*` requires ≥1) +# Match path: label-split + recursive matcher with one-or-more-segment +# semantic on trailing `*`. Same defensive arity rule as paths +# (§5.1) — closes the apex blind spot. +# Spec ref: §5.8 ".*" row, "one or more labels (never zero)" +# +# IMPORTANT clarification on label order: +# DNS names are read LEFT-TO-RIGHT but their label hierarchy goes +# RIGHT-TO-LEFT (the rightmost label is the TLD). So for `mycorp.com.*`, +# the `*` sits in the LEFTMOST positions of any matching name. This +# is opposite to the path convention. Both conventions agree that the +# `*` consumes "1+ tokens at the variable end" — they just differ on +# which end is variable. +# +# Producers should usually prefer `*.mycorp.com.` (leading-`*` per +# RFC 4592) for "any subdomain" intent, since that's the standardised +# form. The trailing form documented here is for cases where the +# variable hierarchy is on the LEFT of a fixed registry suffix. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-12-dns-trailing-star + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-12 + containers: + - name: client + egress: + - identifier: mycorp-anything-deeper + type: external + dnsNames: + - "mycorp.com.*" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml b/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml new file mode 100644 index 000000000..fc7af7bdc --- /dev/null +++ b/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml @@ -0,0 +1,39 @@ +# Fixture 13 — trailing-dot normalisation +# +# Edge case: DNS literals MUST compare equal whether or not the trailing +# dot is present, on either side +# Expects (with profile entry "api.stripe.com." — WITH dot): +# observed "api.stripe.com." → match +# observed "api.stripe.com" → match (verifier normalises) +# Expects (with profile entry "api.stripe.com" — WITHOUT dot): +# observed "api.stripe.com." → match +# observed "api.stripe.com" → match +# Match path: verifier MUST canonicalise both sides before comparison +# (e.g. always append "." if missing) +# Spec ref: §5.8 "Trailing-dot normalisation" paragraph +# Producer guidance: emit the trailing dot — it's the FQDN-canonical form per +# RFC 1035. But verifiers MUST accept either. +# +# This fixture deliberately mixes both forms in dnsNames[] to ensure the +# normalisation runs on profile-side entries, not just observed names. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-13-dns-trailing-dot + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-13 + containers: + - name: client + egress: + - identifier: mixed-trailing-dot-forms + type: external + dnsNames: + - "api.stripe.com." # canonical FQDN form + - "api.github.com" # without trailing dot — equivalent + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/14-recursive-star-rejected.yaml b/tests/resources/network-wildcards/14-recursive-star-rejected.yaml new file mode 100644 index 000000000..703334fd8 --- /dev/null +++ b/tests/resources/network-wildcards/14-recursive-star-rejected.yaml @@ -0,0 +1,38 @@ +# Fixture 14 — `**` recursive wildcard MUST be rejected +# +# Edge case: a producer attempts to use the recursive `**` wildcard +# Expects: apiserver admission strategy REJECTS the document at write time +# (kubectl apply returns an error; nothing is persisted) +# Match path: N/A — never reaches a runtime matcher +# Spec ref: §5.8 last row "** (recursive zero-or-more) — NOT in v0.0.2" +# and "Empty / ** rejection" paragraph +# Why deferred to v0.0.3: +# `**` semantics need careful design — should it match zero labels? +# how does it interact with leading/trailing `*`? Reserve the syntax now +# so producers don't accidentally rely on a future behaviour change. +# +# This fixture is INTENTIONALLY INVALID. The component test should: +# 1. Attempt `kubectl apply -f 14-recursive-star-rejected.yaml` +# 2. Assert the command fails with a validation error +# 3. Assert no NetworkNeighborhood named `nw-14-recursive-rejected` exists +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-14-recursive-rejected + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" + sbob.io/expected-admission: "rejected" +spec: + matchLabels: + app: nw-14 + containers: + - name: client + egress: + - identifier: invalid-recursive + type: external + dnsNames: + - "**.example.com." # INVALID — admission MUST reject + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/15-egress-and-ingress.yaml b/tests/resources/network-wildcards/15-egress-and-ingress.yaml new file mode 100644 index 000000000..348ffc182 --- /dev/null +++ b/tests/resources/network-wildcards/15-egress-and-ingress.yaml @@ -0,0 +1,46 @@ +# Fixture 15 — egress AND ingress on the same container +# +# Edge case: both directions populated; matchers MUST be independently scoped +# Expects: +# pktType=='OUTGOING' to "10.1.2.3" → match in egress (CIDR 10.0.0.0/8) +# pktType=='OUTGOING' to "192.0.2.1" → NO match in egress (NOT in CIDR) +# pktType=='INCOMING' from "192.168.1.42" → match in ingress (CIDR 192.168.0.0/16) +# pktType=='INCOMING' from "10.0.0.42" → NO match in ingress (NOT in 192.168/16) +# (even though 10.0.0.0/8 IS in egress — +# direction isolation is the contract) +# Match path: nn.was_address_in_egress() walks Spec.Egress only; +# nn.was_address_in_ingress() walks Spec.Ingress only +# Spec ref: §4.7 "egress and ingress" — direction isolation contract +# +# Note on current rule coverage: +# The default kubescape rule set (R0005, R0011, etc.) only fires on +# pktType=='OUTGOING'. The ingress block is fully matchable via the +# nn.was_address_in_ingress / nn.is_domain_in_ingress CEL functions, +# but no built-in rule consumes them as of v0.0.2. Custom rules MAY. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-15-egress-and-ingress + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-15 + containers: + - name: bidirectional + egress: + - identifier: outbound-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} + ingress: + - identifier: inbound-rfc1918-class-c + type: internal + ipAddresses: + - "192.168.0.0/16" + ports: + - {name: TCP-8080, protocol: TCP, port: 8080} diff --git a/tests/resources/network-wildcards/16-egress-none.yaml b/tests/resources/network-wildcards/16-egress-none.yaml new file mode 100644 index 000000000..113f87375 --- /dev/null +++ b/tests/resources/network-wildcards/16-egress-none.yaml @@ -0,0 +1,38 @@ +# Fixture 16 — NONE egress (declared zero-egress traffic) +# +# Edge case: egress: [] explicit empty list — declares "this workload +# makes ZERO outbound network connections" +# Expects: verifier emits net.egress_unexpected on the FIRST observed +# outgoing connection (any IP, any DNS, any port) +# Spec ref: §5.4 NONE semantic — "explicit empty list = declared +# zero-activity, hard violation on first observation" +# Distinction from absent: +# `egress:` MISSING from the doc = NULL (verifier-defined posture) +# `egress: []` = NONE (zero-traffic contract) +# This fixture pins the latter. +# +# Producer use case: +# A worker pod that should ONLY accept inbound work and never reach out. +# A locked-down database whose only legitimate traffic is the ingress +# replication stream. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-16-egress-none + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-16 + containers: + - name: locked-down-worker + egress: [] # NONE — any outbound traffic is a violation + ingress: + - identifier: control-plane-only + type: internal + ipAddresses: + - "10.0.0.1" + ports: + - {name: TCP-9000, protocol: TCP, port: 9000} diff --git a/tests/resources/network-wildcards/17-realistic-stripe-api.yaml b/tests/resources/network-wildcards/17-realistic-stripe-api.yaml new file mode 100644 index 000000000..05ae61e2b --- /dev/null +++ b/tests/resources/network-wildcards/17-realistic-stripe-api.yaml @@ -0,0 +1,58 @@ +# Fixture 17 — realistic Stripe API integration +# +# Edge case: end-to-end realistic profile for a workload that calls +# Stripe (well-known external SaaS) plus cluster DNS +# Demonstrates: +# - egress[] with multiple entries (external + internal) +# - ipAddresses[] with both literal and CIDR +# - dnsNames[] with literal AND leading wildcard (RFC 4592) +# - selectors-based internal entry (auto-translated to NetworkPolicy; +# not consulted by R0005/R0011 runtime — see §4.7 caveat) +# - port specifications +# Expects: +# POST https://api.stripe.com (resolved to one of Stripe's IPs) → match +# POST https://files.stripe.com (matches *.stripe.com.) → match +# POST https://api.example.com → NO match +# UDP to kube-dns:53 → match (NetworkPolicy) +# but R0011/R0005 +# don't consult selectors +# — see §4.7 note +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-17-realistic-stripe + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: payment-service + containers: + - name: payment-app + egress: + - identifier: stripe-api + type: external + ipAddresses: + - "162.0.217.171" # Stripe public IP example + - "163.0.0.0/16" # Stripe routing range — for completeness + dnsNames: + - "api.stripe.com." + - "*.stripe.com." # leading-* RFC 4592 — covers files.stripe.com., + # webhooks.stripe.com., billing.stripe.com. + # but NOT v1.api.stripe.com. (two labels deep) + ports: + - {name: TCP-443, protocol: TCP, port: 443} + - identifier: cluster-dns + type: internal + # Selector-based entry — auto-translates to a NetworkPolicy egress rule + # that K8s enforces. Note: R0005/R0011 runtime matchers do NOT consult + # selectors as of v0.0.2 — they only walk ipAddresses/dnsNames. + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml b/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml new file mode 100644 index 000000000..c63df62a6 --- /dev/null +++ b/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml @@ -0,0 +1,55 @@ +# Fixture 18 — Kubernetes service-FQDN resolution via mid-`⋯` +# +# Edge case: The user's specific case from the v0.0.2 design discussion. +# In Kubernetes, services are resolved as +# ..svc.cluster.local. +# A workload that wants to permit "any namespace's +# service" should match exactly one label between fixed +# anchors. +# Expects: +# observed "redis.production.svc.cluster.local." → NO match (we anchored on `redis`, +# and only the namespace label is +# wildcarded) +# observed "redis.staging.svc.cluster.local." → NO match (same — we'd need +# a different fixture for "any svc +# in any ns") +# observed "kubernetes.default.svc.cluster.local." → match (one label "default") +# Match path: label-split + recursive matcher; `⋯` consumes exactly one +# label between two fixed segments +# Spec ref: §5.8 ".⋯." row, the example uses this exact pattern +# +# Why `⋯` and not `*`: +# RFC 4592 only standardises *.. Mid-label `*` is non-standard +# (cilium uses regex; bind/coredns reject it). v0.0.2 uses `⋯` (DynamicIdentifier, +# the project's existing token from path/argv wildcards) for mid positions. +# +# Hardcoded short-circuit removal candidate: +# The default rule R0005 currently has a hardcoded +# `!event.name.endsWith('.svc.cluster.local.')` short-circuit. With this +# fixture's mid-⋯ form, that hardcode becomes profile-expressible — a +# future PR can REMOVE the rule-side short-circuit and let producers +# declare the equivalent via this NN. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-18-cluster-dns-mid-ellipsis + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-18 + containers: + - name: dns-client + egress: + - identifier: any-namespace-kubernetes-svc + type: internal + dnsNames: + - "kubernetes.⋯.svc.cluster.local." + # ↑ matches kubernetes.default.svc.cluster.local. exactly, + # parametric on the namespace label. Use one entry per + # service the workload calls; the wildcard is on the + # namespace position, not the service name. + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml b/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml new file mode 100644 index 000000000..2135851ab --- /dev/null +++ b/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml @@ -0,0 +1,41 @@ +# Fixture 19 — port + protocol + CIDR composed match +# +# Edge case: nn.was_address_port_protocol_in_egress matcher — the granular +# variant that requires IP+port+protocol all to match within +# the same NetworkNeighbor entry +# Expects: +# observed (10.1.2.3, 443, TCP) → match (both CIDR and port match within entry) +# observed (10.1.2.3, 80, TCP) → NO match (CIDR ok but port mismatch) +# observed (192.168.1.1, 443, TCP)→ NO match (port ok but CIDR mismatch) +# observed (10.1.2.3, 443, UDP) → NO match (CIDR + port ok but protocol mismatch) +# Match path: for each NetworkNeighbor: +# if MatchIP(entry.IPs, observed) && entry contains matching +# (port, protocol) tuple → true +# Spec ref: §4.7 ports[] row — name + protocol + port (uint16 nullable) +# +# This fixture validates that the new IP-matcher integration preserves the +# port-protocol grouping contract — a CIDR match alone isn't sufficient +# unless the entry's ports list also contains the (port, protocol) pair. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-19-port-proto-cidr + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-19 + containers: + - name: client + egress: + - identifier: tls-only-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} + # Note: no UDP entry, no port-80 entry — only TCP/443 within this CIDR. + # A request to (10.1.2.3, 80, TCP) should NOT match because the + # port-protocol filter is per-NetworkNeighbor-entry, not global. diff --git a/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml b/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml new file mode 100644 index 000000000..bdc1e417d --- /dev/null +++ b/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml @@ -0,0 +1,54 @@ +# Fixture 20 — multi-container pod with different rules per container +# +# Edge case: a single NetworkNeighborhood applies to a multi-container pod; +# each container has its own egress/ingress block; the verifier +# MUST scope matching by container ID (not pod ID) +# Expects: +# container "frontend" can hit *.example.com. but NOT 10.0.0.0/8; +# container "sidecar" can hit 10.0.0.0/8 but NOT *.example.com.; +# if the verifier conflates containers, both restrictions collapse to "either" +# and the test fails +# Match path: nn.* CEL functions resolve the ContainerProfile by containerID, +# so the matchers operate on the ALREADY-scoped Spec.Egress slice +# Spec ref: §4.2 container entry — each container is independently profiled +# +# This is also the most realistic deployment shape: a frontend that calls +# external APIs plus an in-cluster sidecar that talks to DBs/caches. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-20-multi-container + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-20 + containers: + - name: frontend + egress: + - identifier: external-api + type: external + dnsNames: + - "*.example.com." # leading-* RFC 4592 + - "api.partner.io." # literal + ports: + - {name: TCP-443, protocol: TCP, port: 443} + - name: sidecar + egress: + - identifier: in-cluster-services + type: internal + ipAddresses: + - "10.0.0.0/8" # cluster pod CIDR + - "172.16.0.0/12" # alt cluster service CIDR + ports: + - {name: TCP-6379, protocol: TCP, port: 6379} # redis + - {name: TCP-5432, protocol: TCP, port: 5432} # postgres + ingress: + - identifier: from-frontend + type: internal + ipAddresses: + - "10.244.0.0/16" # narrower — only the frontend pod's CIDR + ports: + - {name: TCP-9090, protocol: TCP, port: 9090} # sidecar metrics diff --git a/tests/resources/network-wildcards/README.md b/tests/resources/network-wildcards/README.md new file mode 100644 index 000000000..305e8f914 --- /dev/null +++ b/tests/resources/network-wildcards/README.md @@ -0,0 +1,80 @@ +# Network-wildcards test fixtures + +Living documentation for the `feat/network-wildcards` work. + +Each `*.yaml` here is a complete `NetworkNeighborhood` document that exercises +ONE edge case in the v0.0.2 wildcard surface. The fixture-walk test +(`TestFixturesParse` + `TestFixturesMatchExpectedBehaviour` in +`pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go`, +plus the lab-side `Test_34_NetworkWildcardSurface`) consumes them +directly; users learning the syntax can copy-paste them as authoritative +examples. + +**Note on `14-recursive-star-rejected.yaml`:** this fixture is intentionally +**rejected at admission** — it carries `dnsNames: ["**"]` to demonstrate +that the recursive-wildcard token is invalid v0.0.2 syntax. Don't `kubectl +apply` it; the apiserver will return a 400. The runtime matcher also +defends by silently dropping it on read, so a broken admission layer +won't accidentally let it through. + +## Wildcard token vocabulary (matches paths + argv vocabulary) + +| Token | Meaning | +|---|---| +| `⋯` (U+22EF, MIDLINE HORIZONTAL ELLIPSIS — single Unicode codepoint, NOT three ASCII periods) | Exactly one segment / argv position / **DNS label** | +| `*` leading | RFC 4592 wildcard — exactly one DNS label before the suffix | +| `*` mid-path | NOT used in DNS — use `⋯` instead | +| `*` trailing | One or more labels after the prefix (never zero — closes the apex blind spot) | +| `*` as `ipAddresses[i]` | Sugar for `0.0.0.0/0` ∪ `::/0` (any IP) | + +## Field summary + +| Field on `NetworkNeighbor` | v0.0.2 status | Match form | +|---|---|---| +| `ipAddress` (string) | **deprecated** — kept for back-compat | byte-equality only | +| `ipAddresses` (list of strings) | **new** | each entry: literal IP / CIDR / `*` sentinel; matches if ANY entry matches | +| `dnsNames` (list of strings) | normative | each entry: literal / leading-`*` / mid-`⋯` / trailing-`*`; matches if ANY entry matches | +| `dns` (single string) | **deprecated** since v0.0.1 | byte-equality only | +| `ports[]` | normative | name + protocol + port (uint16, nullable per §5.4) | +| `podSelector`, `namespaceSelector` | schema-level (passed through to auto-generated NetworkPolicy) | NOT consulted by the runtime CEL matchers — see §4.7 caveat | + +## Fixture index + +| # | File | Edge case | +|---|------|-----------| +| 01 | `01-literal-ipv4.yaml` | Single IPv4 literal in `ipAddresses[]` | +| 02 | `02-literal-ipv6.yaml` | IPv6 literal — verifier MUST canonicalise | +| 03 | `03-cidr-ipv4.yaml` | IPv4 CIDR — `10.0.0.0/8` covers a /8 range | +| 04 | `04-cidr-ipv6.yaml` | IPv6 CIDR — `2001:db8::/32` | +| 05 | `05-any-ip-sentinel.yaml` | The `*` sentinel — discouraged outside dev | +| 06 | `06-any-as-cidr.yaml` | `0.0.0.0/0` + `::/0` (RFC-aligned alternatives to `*`) | +| 07 | `07-mixed-ip-list.yaml` | Mixed list: literal + CIDR + sentinel — first match wins | +| 08 | `08-deprecated-ipaddress.yaml` | Backward compat — singular `ipAddress` field | +| 09 | `09-dns-literal.yaml` | Plain DNS literal with trailing dot | +| 10 | `10-dns-leading-wildcard.yaml` | `*.example.com.` — RFC 4592, exactly ONE label | +| 11 | `11-dns-mid-ellipsis.yaml` | `svc.⋯.cluster.local.` — exactly ONE label between | +| 12 | `12-dns-trailing-star.yaml` | `mycorp.com.*` — ONE OR MORE labels (never zero) | +| 13 | `13-dns-trailing-dot-normalisation.yaml` | `example.com` and `example.com.` MUST be equivalent | +| 14 | `14-recursive-star-rejected.yaml` | `**` — MUST be rejected by apiserver write strategy | +| 15 | `15-egress-and-ingress.yaml` | Both directions populated on same container | +| 16 | `16-egress-none.yaml` | NONE (`egress: []`) — declared zero-egress | +| 17 | `17-realistic-stripe-api.yaml` | Realistic external API call (Stripe) | +| 18 | `18-cluster-dns-via-mid-ellipsis.yaml` | The user's `svc.⋯.kubernetes.io.` use case | +| 19 | `19-port-protocol-with-cidr.yaml` | Ports + protocol + CIDR composed | +| 20 | `20-multi-container-mixed-wildcards.yaml` | Pod with multiple containers, each with different rules — combined real-world example | + +## Expected behaviour matrix + +The accompanying `expectations.json` (generated alongside) lists, per fixture, +the `(observedIP, observedDNS) → expected match result` triples that +`Test_34_NetworkWildcardSurface` walks. + +## Migration note + +Producers writing v0.0.2-conformant SBoBs SHOULD use `ipAddresses` (plural). +The singular `ipAddress` is retained ONLY for back-compat with v0.0.1-era +profiles; producers MUST NOT populate both on the same entry (the apiserver +admission strategy rejects this). + +The deprecated `dns` (single string) field is retained for v0 compatibility; +v0.0.2 producers MUST emit `dnsNames` (list).