From 9e856350cb265d1f3c14321b1dca6b90f6947635 Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 12:49:58 +0200 Subject: [PATCH 01/10] tests(resources): 20 NetworkNeighborhood fixtures for v0.0.2 wildcard surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Living documentation for the feat/network-wildcards work. Each fixture is a complete, kubectl-applicable NetworkNeighborhood document exercising ONE edge case in the v0.0.2 wildcard surface. Test_34 (forthcoming) consumes them directly; users learning the syntax can copy-paste them as authoritative examples. Coverage: 01 — IPv4 literal in ipAddresses[] 02 — IPv6 literal (canonicalisation) 03 — IPv4 CIDR 04 — IPv6 CIDR 05 — '*' sentinel for ANY IP (with discouragement annotation) 06 — 0.0.0.0/0 + ::/0 (RFC-aligned alternative to '*') 07 — mixed list (literal + CIDR + sentinel) 08 — backward-compat singular ipAddress 09 — DNS literal 10 — DNS leading '*' (RFC 4592) 11 — DNS mid '⋯' (DynamicIdentifier) 12 — DNS trailing '*' (one or more, never zero) 13 — trailing-dot normalisation 14 — '**' recursive — admission MUST reject 15 — egress + ingress on same container, direction isolation 16 — egress: [] NONE (declared zero-egress) 17 — realistic Stripe API + cluster DNS 18 — Kubernetes service-FQDN via mid '⋯' (the user's case) 19 — port + protocol + CIDR composed 20 — multi-container pod, different rules per container README.md indexes all fixtures and lists the wildcard token vocabulary. Each fixture's header comment lists the edge case, expected outcomes, match path, spec reference, and operational guidance. Ready to be consumed by node-agent's Test_34_NetworkWildcardSurface (forthcoming) and by storage's networkmatch unit tests via testdata-style references. --- .../network-wildcards/01-literal-ipv4.yaml | 26 +++++++ .../network-wildcards/02-literal-ipv6.yaml | 27 +++++++ .../network-wildcards/03-cidr-ipv4.yaml | 28 ++++++++ .../network-wildcards/04-cidr-ipv6.yaml | 26 +++++++ .../network-wildcards/05-any-ip-sentinel.yaml | 31 ++++++++ .../network-wildcards/06-any-as-cidr.yaml | 34 +++++++++ .../network-wildcards/07-mixed-ip-list.yaml | 36 ++++++++++ .../08-deprecated-ipaddress.yaml | 32 +++++++++ .../network-wildcards/09-dns-literal.yaml | 29 ++++++++ .../10-dns-leading-wildcard.yaml | 35 ++++++++++ .../11-dns-mid-ellipsis.yaml | 41 +++++++++++ .../12-dns-trailing-star.yaml | 46 ++++++++++++ .../13-dns-trailing-dot-normalisation.yaml | 39 +++++++++++ .../14-recursive-star-rejected.yaml | 38 ++++++++++ .../15-egress-and-ingress.yaml | 46 ++++++++++++ .../network-wildcards/16-egress-none.yaml | 38 ++++++++++ .../17-realistic-stripe-api.yaml | 58 +++++++++++++++ .../18-cluster-dns-via-mid-ellipsis.yaml | 55 +++++++++++++++ .../19-port-protocol-with-cidr.yaml | 41 +++++++++++ .../20-multi-container-mixed-wildcards.yaml | 54 ++++++++++++++ tests/resources/network-wildcards/README.md | 70 +++++++++++++++++++ 21 files changed, 830 insertions(+) create mode 100644 tests/resources/network-wildcards/01-literal-ipv4.yaml create mode 100644 tests/resources/network-wildcards/02-literal-ipv6.yaml create mode 100644 tests/resources/network-wildcards/03-cidr-ipv4.yaml create mode 100644 tests/resources/network-wildcards/04-cidr-ipv6.yaml create mode 100644 tests/resources/network-wildcards/05-any-ip-sentinel.yaml create mode 100644 tests/resources/network-wildcards/06-any-as-cidr.yaml create mode 100644 tests/resources/network-wildcards/07-mixed-ip-list.yaml create mode 100644 tests/resources/network-wildcards/08-deprecated-ipaddress.yaml create mode 100644 tests/resources/network-wildcards/09-dns-literal.yaml create mode 100644 tests/resources/network-wildcards/10-dns-leading-wildcard.yaml create mode 100644 tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml create mode 100644 tests/resources/network-wildcards/12-dns-trailing-star.yaml create mode 100644 tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml create mode 100644 tests/resources/network-wildcards/14-recursive-star-rejected.yaml create mode 100644 tests/resources/network-wildcards/15-egress-and-ingress.yaml create mode 100644 tests/resources/network-wildcards/16-egress-none.yaml create mode 100644 tests/resources/network-wildcards/17-realistic-stripe-api.yaml create mode 100644 tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml create mode 100644 tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml create mode 100644 tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml create mode 100644 tests/resources/network-wildcards/README.md 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..9ef29994f --- /dev/null +++ b/tests/resources/network-wildcards/README.md @@ -0,0 +1,70 @@ +# Network-wildcards test fixtures + +Living documentation for the `feat/network-wildcards` work. + +Each `*.yaml` here is a complete, kubectl-applicable `NetworkNeighborhood` document +that exercises ONE edge case in the v0.0.2 wildcard surface. The test suite +(`Test_34_NetworkWildcardSurface`) consumes them directly; users learning the +syntax can copy-paste them as authoritative examples. + +## 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). From b96620063dd359cda5023ebb07f585f8ee36e4a9 Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 13:14:52 +0200 Subject: [PATCH 02/10] feat(nn): rewire CEL functions to use storage networkmatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces byte-equality with the v0.0.2 wildcard-aware matchers from storage's pkg/registry/file/networkmatch — applied symmetrically to all six nn.* CEL functions (egress + ingress mirror images): nn.was_address_in_egress / _in_ingress nn.is_domain_in_egress / _in_ingress nn.was_address_port_protocol_in_egress / _in_ingress Each function now walks BOTH the deprecated singular field (IPAddress / DNS, byte-equality, back-compat) AND the new plural field (IPAddresses / DNSNames, wildcard-aware) on each NetworkNeighbor entry. A profile that uses only the deprecated form behaves exactly as before; a profile that uses the new form gains CIDR + wildcard matching with no rule-side changes required. Two helpers (neighborMatchesIP / neighborMatchesDNS) factor the two-list walk so the six call sites stay readable. Compiled-form caching of the matcher across calls is deferred to a follow-up — the existing cel functionCache still memoises (containerID, observed) tuples, so the per-call MatchIP/MatchDNS overhead only fires on cache misses. Tests cover: - CIDR membership across egress/ingress - '*' sentinel for any IP - leading-* DNS wildcard (RFC 4592, exactly one label) - mid-⋯ DynamicLabel (the kubernetes service-FQDN case) - trailing-dot resilience - direction isolation (egress and ingress lists are walked independently — same address allowed on one direction must NOT match the other) - back-compat: deprecated singular IPAddress/DNS still works - mixed: profile with one entry using singular, another using plural - composed match: CIDR + port + protocol on the granular variant go.mod: temporary local-path replace for kubescape/storage so the node-agent picks up the in-flight feat/network-wildcards work; user flips back to fork ref before pushing. --- go.mod | 5 +- .../libraries/networkneighborhood/network.go | 81 ++++-- .../networkneighborhood/wildcard_test.go | 242 ++++++++++++++++++ 3 files changed, 305 insertions(+), 23 deletions(-) create mode 100644 pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go diff --git a/go.mod b/go.mod index 54f2392ed..d190f688a 100644 --- a/go.mod +++ b/go.mod @@ -507,4 +507,7 @@ 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 +// Local-path replace for the v0.0.2 wildcards work (feat/network-wildcards). +// Storage commits are local-only per the no-push rule; user reverts this +// to the fork ref before pushing the node-agent branch. +replace github.com/kubescape/storage => ../storage diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go index 0449ebf96..3e66a8d04 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go @@ -1,15 +1,48 @@ 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). +// +// 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 { + if neighbor.IPAddress != "" && 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 { + if neighbor.DNS != "" && 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 +62,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 +90,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 +118,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 +146,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) } } @@ -149,12 +182,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 == int32(portInt) { + return types.Bool(true) } } } @@ -189,12 +224,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 == int32(portInt) { + 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..0d50e2f48 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go @@ -0,0 +1,242 @@ +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) +} + +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) + }) + } +} From efdae31a2cc3a3317d4131aa50fe6415ef6ca5ba Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 13:17:05 +0200 Subject: [PATCH 03/10] test(nn): fixture-walk parser + behaviour gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestFixturesParse: every YAML under tests/resources/network-wildcards/ parses against the v1beta1 NetworkNeighborhood schema. The fixtures double as authoritative user-facing syntax documentation, so a fixture that fails to parse is a documentation bug. TestFixturesMatchExpectedBehaviour: representative observed→match triples for each major edge case (literal IP, CIDR, '*' sentinel, deprecated singular IPAddress, leading-* DNS RFC 4592, mid-⋯ DynamicLabel, direction isolation between egress and ingress) are exercised through the actual nn.* CEL functions. If a fixture's header comment says '10.1.2.3 → match' and the matcher disagrees, ONE of them is wrong; this test pins both. True end-to-end Test_34_NetworkWildcardSurface (kubectl-applies the fixtures against a live cluster) belongs in the iximiuz lab; that job is left for the lab pass once the storage + node-agent images ship via the fork CI. --- .../networkneighborhood/fixtures_test.go | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go 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..7f0c35282 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go @@ -0,0 +1,219 @@ +package networkneighborhood + +import ( + "os" + "path/filepath" + "strings" + "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/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 + err = yaml.Unmarshal(data, &nn) + require.NoError(t, err, "fixture %s must parse against v1beta1 schema", 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 + // Each (kind, observed, want) triple to verify + ipChecks []ipCheck + 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"}}, + }, + // Verify direction isolation by exercising both functions on the same address. + ipChecks: []ipCheck{ + {"8.8.8.8", true}, // hits egress entry + {"10.244.5.5", false}, // ingress-only IP must NOT match egress + }, + }, + } + + 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("ip %q: got %v, want %v", c.observed, res, c.want) + } + } + 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 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 "" +} + +// avoid unused import warning when buildLibWithContainer is the only consumer +var _ = maps.NewSafeMap[string, *objectcache.WatchedContainerData] +var _ = objectcachev1.RuleObjectCacheMock{} From f848fd323e7439a893507ed1340e411433b83af9 Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 13:20:42 +0200 Subject: [PATCH 04/10] chore: drop k8sstormcenter/storage from go.sum Local replace points at ../storage so the fork ref isn't fetched. User reverts both go.mod and go.sum before pushing the branch. --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 4ae88fcc1..999079674 100644 --- a/go.sum +++ b/go.sum @@ -981,8 +981,6 @@ 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/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= From 8bddfc76c43f3c5df3150fbe5e4cca6067dd3241 Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 19:18:12 +0200 Subject: [PATCH 05/10] chore: gitignore .claude + pin storage to fork ref carrying networkmatch Updates the storage replace to a pseudo-version on the fork that includes the v0.0.2 wildcard surface (pkg/registry/file/networkmatch/, IPAddresses schema field, REST validation). Build and tests stay green against the pinned ref. The .claude/ entry on .gitignore prevents the agent state directory from being tracked accidentally. --- .gitignore | 3 ++- go.mod | 5 +---- go.sum | 2 ++ 3 files changed, 5 insertions(+), 5 deletions(-) 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 d190f688a..bede6c03d 100644 --- a/go.mod +++ b/go.mod @@ -507,7 +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 -// Local-path replace for the v0.0.2 wildcards work (feat/network-wildcards). -// Storage commits are local-only per the no-push rule; user reverts this -// to the fork ref before pushing the node-agent branch. -replace github.com/kubescape/storage => ../storage +replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260510171618-28d5bfd6cd00 diff --git a/go.sum b/go.sum index 999079674..49d092daa 100644 --- a/go.sum +++ b/go.sum @@ -981,6 +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.20260510171618-28d5bfd6cd00 h1:X9FzeamGYmOcqWOaO0RvBYLUfDYntonibG23rKkARqE= +github.com/k8sstormcenter/storage v0.0.240-0.20260510171618-28d5bfd6cd00/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= From f6d2c9660e116424a6e5ab150372f9499a393540 Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 19:35:19 +0200 Subject: [PATCH 06/10] fix(nn): address CodeRabbit review on PR #41 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings, all legit, all fixed: - Port range guard (Major): wasAddressPortProtocolInEgress/Ingress now reject portInt outside [0, 65535] BEFORE narrowing to int32. Without this, a CEL value like 4294967739 wraps to 443 and would falsely match a port-443 entry. New TestWasAddressPortProtocolInEgress_ PortWrapRejected pins the contract. - neighborMatchesDNS now routes the deprecated singular DNS field through MatchDNS (single-element slice) instead of raw string equality, so back-compat behaviour gets the same trailing-dot stripping + lowercasing as the new DNSNames[]. New TestIsDomainInEgress_DeprecatedDNS_TrailingDotParity pins this. - Direction-isolation fixture test now exercises BOTH wasAddressInEgress and wasAddressInIngress for each observation, via a new ipBothCheck struct. The prior version only checked egress, so a regression that broke ingress matching would have slipped through. - TestFixturesParse uses yaml.UnmarshalStrict so a typo in any user- facing fixture (the YAML files double as documentation) fails the test instead of silently parsing. - README clarifies that fixture 14 is intentionally rejected at admission and shouldn't be kubectl-applied — points readers at the index entry so they don't try to use it as a template. Also bumps the storage replace to e1263bf6, which carries storage's CR fixes (deprecated IPAddress validation, ValidateUpdate now also runs network-profile validation, field-path assertions in admission tests). --- go.mod | 2 +- go.sum | 4 +- .../networkneighborhood/fixtures_test.go | 47 +++++++++++--- .../libraries/networkneighborhood/network.go | 23 ++++++- .../networkneighborhood/wildcard_test.go | 64 +++++++++++++++++++ tests/resources/network-wildcards/README.md | 18 ++++-- 6 files changed, 138 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index bede6c03d..1c4c5219e 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.20260510171618-28d5bfd6cd00 +replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260510173120-e1263bf6f667 diff --git a/go.sum b/go.sum index 49d092daa..5104ab808 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.20260510171618-28d5bfd6cd00 h1:X9FzeamGYmOcqWOaO0RvBYLUfDYntonibG23rKkARqE= -github.com/k8sstormcenter/storage v0.0.240-0.20260510171618-28d5bfd6cd00/go.mod h1:amdg/Qok9bqPzs1vZH5FW9/3MbCawc5wVsz9u3uIfu4= +github.com/k8sstormcenter/storage v0.0.240-0.20260510173120-e1263bf6f667 h1:3+quC2Z+ANnhH5jSMlk7M+pWsRM72Ufp+mO+gkRWKxE= +github.com/k8sstormcenter/storage v0.0.240-0.20260510173120-e1263bf6f667/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 index 7f0c35282..0bed41fcc 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go @@ -49,8 +49,11 @@ func TestFixturesParse(t *testing.T) { data = []byte(strings.ReplaceAll(string(data), "{{NAMESPACE}}", "test-ns")) var nn v1beta1.NetworkNeighborhood - err = yaml.Unmarshal(data, &nn) - require.NoError(t, err, "fixture %s must parse against v1beta1 schema", name) + // 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) }) @@ -77,9 +80,13 @@ func TestFixturesMatchExpectedBehaviour(t *testing.T) { name string neighbors []v1beta1.NetworkNeighbor ingress []v1beta1.NetworkNeighbor - // Each (kind, observed, want) triple to verify - ipChecks []ipCheck - dnsChecks []dnsCheck + // 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", @@ -152,10 +159,12 @@ func TestFixturesMatchExpectedBehaviour(t *testing.T) { ingress: []v1beta1.NetworkNeighbor{ {IPAddresses: []string{"10.244.0.0/16"}}, }, - // Verify direction isolation by exercising both functions on the same address. - ipChecks: []ipCheck{ - {"8.8.8.8", true}, // hits egress entry - {"10.244.5.5", false}, // ingress-only IP must NOT match egress + // 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 }, }, } @@ -167,7 +176,19 @@ func TestFixturesMatchExpectedBehaviour(t *testing.T) { res := lib.wasAddressInEgress(types.String("cid"), types.String(c.observed)) res = cache.ConvertProfileNotAvailableErrToBool(res, false) if res != types.Bool(c.want) { - t.Errorf("ip %q: got %v, want %v", c.observed, res, 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 { @@ -186,6 +207,12 @@ type ipCheck struct { want bool } +type ipBothCheck struct { + observed string + wantEgress bool + wantIngress bool +} + type dnsCheck struct { observed string want bool diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go index 3e66a8d04..09682f9ee 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go @@ -32,7 +32,11 @@ func neighborMatchesIP(neighbor *v1beta1.NetworkNeighbor, observed string) bool // 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 { - if neighbor.DNS != "" && neighbor.DNS == observed { + // 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 { @@ -172,6 +176,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) @@ -188,7 +200,7 @@ func (l *nnLibrary) wasAddressPortProtocolInEgress(containerID, address, port, p continue } for _, portInfo := range egress.Ports { - if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == int32(portInt) { + if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == expectedPort { return types.Bool(true) } } @@ -214,6 +226,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) @@ -230,7 +247,7 @@ func (l *nnLibrary) wasAddressPortProtocolInIngress(containerID, address, port, continue } for _, portInfo := range ingress.Ports { - if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == int32(portInt) { + 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 index 0d50e2f48..4c7f9ebbc 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go @@ -167,6 +167,70 @@ func TestIsDomainInEgress_TrailingDotResilience(t *testing.T) { assert.Equal(t, types.Bool(true), res) } +// 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. diff --git a/tests/resources/network-wildcards/README.md b/tests/resources/network-wildcards/README.md index 9ef29994f..305e8f914 100644 --- a/tests/resources/network-wildcards/README.md +++ b/tests/resources/network-wildcards/README.md @@ -2,10 +2,20 @@ Living documentation for the `feat/network-wildcards` work. -Each `*.yaml` here is a complete, kubectl-applicable `NetworkNeighborhood` document -that exercises ONE edge case in the v0.0.2 wildcard surface. The test suite -(`Test_34_NetworkWildcardSurface`) consumes them directly; users learning the -syntax can copy-paste them as authoritative examples. +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) From 07d4bc05a2a33e69a1c387944e0146ca34154373 Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 19:42:54 +0200 Subject: [PATCH 07/10] chore(deps): bump storage SHA to 0910dc3f (CR round 2) Pulls in storage's CR round-2 fixes: deterministic admission error ordering across container groups, and field-path assertions on the ValidateUpdate test. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1c4c5219e..985d10a10 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.20260510173120-e1263bf6f667 +replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260510174154-0910dc3f26ee diff --git a/go.sum b/go.sum index 5104ab808..430846fd8 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.20260510173120-e1263bf6f667 h1:3+quC2Z+ANnhH5jSMlk7M+pWsRM72Ufp+mO+gkRWKxE= -github.com/k8sstormcenter/storage v0.0.240-0.20260510173120-e1263bf6f667/go.mod h1:amdg/Qok9bqPzs1vZH5FW9/3MbCawc5wVsz9u3uIfu4= +github.com/k8sstormcenter/storage v0.0.240-0.20260510174154-0910dc3f26ee h1:5cyLskUQBZ7qzmW4TxnT7vVv5jcQwUFiFzmFZCd8m5c= +github.com/k8sstormcenter/storage v0.0.240-0.20260510174154-0910dc3f26ee/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= From bb5702a555dc29d4e89980d7b06605770c5dd17e Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 19:53:35 +0200 Subject: [PATCH 08/10] chore(deps): bump storage SHA to 02c4438f (CR round 3) Pulls in storage's deprecated-DNS validation parity fix. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 985d10a10..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.20260510174154-0910dc3f26ee +replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260510175248-02c4438f072f diff --git a/go.sum b/go.sum index 430846fd8..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.20260510174154-0910dc3f26ee h1:5cyLskUQBZ7qzmW4TxnT7vVv5jcQwUFiFzmFZCd8m5c= -github.com/k8sstormcenter/storage v0.0.240-0.20260510174154-0910dc3f26ee/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= From f89fc8002143317585bcd583c64d5a226d76a387 Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 19:59:46 +0200 Subject: [PATCH 09/10] fix(nn): address CodeRabbit round 2 on PR #41 Two findings, both nitpick-level, both applied: - Remove the unused 'maps', 'objectcache', 'objectcachev1' imports from fixtures_test.go along with the blank-identifier _ = ... lines at the bottom that existed only to silence the unused-import error. buildLibWithContainer is defined in wildcard_test.go (same package), so fixtures_test.go has no real need for those imports. - Route the deprecated singular IPAddress through networkmatch.MatchIP for symmetry with the deprecated singular DNS (which round 1 already routed through MatchDNS). Both deprecated fields now get the same canonicalisation (IPv6 expanded forms, IPv4-mapped IPv6) as the new list fields. New TestWasAddressInEgress_DeprecatedIPAddress_ IPv6Canonicalisation pins this. --- .../networkneighborhood/fixtures_test.go | 7 ----- .../libraries/networkneighborhood/network.go | 6 ++++- .../networkneighborhood/wildcard_test.go | 27 +++++++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go index 0bed41fcc..7058ca180 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go @@ -7,9 +7,6 @@ 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/require" @@ -240,7 +237,3 @@ func findFixturesDir(t *testing.T) string { t.Fatalf("could not find tests/resources/network-wildcards/ from %s", dir) return "" } - -// avoid unused import warning when buildLibWithContainer is the only consumer -var _ = maps.NewSafeMap[string, *objectcache.WatchedContainerData] -var _ = objectcachev1.RuleObjectCacheMock{} diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go index 09682f9ee..6e20409bd 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go @@ -17,7 +17,11 @@ import ( // 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 { - if neighbor.IPAddress != "" && neighbor.IPAddress == observed { + // 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 { diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go index 4c7f9ebbc..65814e648 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go @@ -167,6 +167,33 @@ func TestIsDomainInEgress_TrailingDotResilience(t *testing.T) { assert.Equal(t, types.Bool(true), res) } +// 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 From 4c90e22de6cd77d88951916fb02083a6a3747a50 Mon Sep 17 00:00:00 2001 From: Entlein Date: Sun, 10 May 2026 20:20:16 +0200 Subject: [PATCH 10/10] test(nn): pin wildcard/CIDR semantics on deprecated IPAddress (CR round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR caught that the round-2 routing of deprecated IPAddress through MatchIP had a documentation gap: existing tests only proved literal + canonical (IPv6) matching, never the wildcard/CIDR semantics that MatchIP now also enables on the deprecated field. Adds TestWasAddressInEgress_DeprecatedIPAddress_AcceptsWildcardAndCIDR which pins the contract: deprecated singular field accepts the SAME wildcard token vocabulary as the new list form — '*' sentinel, CIDRs, 0.0.0.0/0 and ::/0 alternatives. Comment on neighborMatchesIP documents this is intentional unification, not accidental. --- .../libraries/networkneighborhood/network.go | 6 +++ .../networkneighborhood/wildcard_test.go | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go index 6e20409bd..4851412fc 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go @@ -13,6 +13,12 @@ import ( // 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. diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go index 65814e648..5d2e5f83d 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go @@ -167,6 +167,44 @@ func TestIsDomainInEgress_TrailingDotResilience(t *testing.T) { 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