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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions docs/features/network-wildcards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Network Wildcards (CIDRs, wildcards, and plural IP/DNS)

The CEL `networkneighborhood` library matches observed connections against the
`NetworkNeighborhood` profile. As of storage `v0.0.2` the IP and DNS surfaces
accept wildcard, CIDR, and list-valued entries instead of byte-exact strings
only. This lets a profile describe a *range* of allowed peers (a CIDR, a DNS
subdomain family, "any IP") rather than enumerating every literal.

The matching logic lives in
`pkg/rulemanager/cel/libraries/networkneighborhood/network.go` and delegates the
wildcard/CIDR semantics to `kubescape/storage`'s `networkmatch` package
(`MatchIP` / `MatchDNS`). Node-agent pins `kubescape/storage v0.0.290`, which
carries storage [#324](https://github.com/kubescape/storage/pull/324).

## IP matching (`ipAddresses`, `ipAddress`)

`matchIPField` checks, cheapest first:

1. Exact string equality against the profile's `Values` set.
2. Canonicalised IP equality — a single `net.ParseIP`, so observed
`::ffff:10.0.0.1` matches a profile entry of `10.0.0.1`, and expanded IPv6
matches compact IPv6.
3. `networkmatch.MatchIP` over the full entry set, which matches literals,
CIDRs and the `*` sentinel uniformly.

Accepted `ipAddresses[]` entry forms:

| Form | Example | Meaning |
|---|---|---|
| Literal IPv4/IPv6 | `162.0.217.171`, `2001:db8::1` | exact host |
| CIDR | `10.0.0.0/8`, `2001:db8::/32` | any host in range |
| `*` sentinel | `*` | sugar for `0.0.0.0/0` ∪ `::/0` (any IP) — discouraged outside dev |

A match succeeds if **any** entry matches. The singular `ipAddress` (string)
field is deprecated and kept for back-compat; it is matched by byte-equality
only. Producers MUST NOT populate both `ipAddress` and `ipAddresses` on the
same entry.

## DNS matching (`dnsNames`, `dns`)

`matchDNSField` first normalises the FQDN trailing dot (spec §5.8): `example.com`
and `example.com.` are equivalent. It then runs `networkmatch.MatchDNS` over the
full entry set for the wildcard forms:

| Token | Example | Meaning |
|---|---|---|
| Literal | `api.stripe.com.` | exact name |
| Leading `*` | `*.example.com.` | RFC 4592 — exactly **one** label before the suffix |
| Mid `⋯` (U+22EF) | `svc.⋯.cluster.local.` | exactly **one** label in that position |
| Trailing `*` | `mycorp.com.*` | **one or more** labels after the prefix (never zero) |

`⋯` is the single Unicode codepoint MIDLINE HORIZONTAL ELLIPSIS, **not** three
ASCII periods. The recursive token `**` is invalid v0.0.2 syntax: the apiserver
rejects it at admission, and the runtime matcher additionally drops it on read.
A match succeeds if **any** entry matches. The singular `dns` (string) field is
deprecated; v0.0.2 producers MUST emit `dnsNames` (list).

## Port/protocol matching

`wasAddressPortProtocolInEgress` / `...Ingress` validate the port range
(0–65535) and protocol type, but the port/protocol projection
(`AddressPortsByAddr`) is out of scope for the current projection-v1 layer, so
these matchers **degrade to address-only** matching. Wildcard/CIDR IP semantics
are still enforced via `matchIPField`.

## Default rule change

This feature enables the **"Unexpected Egress Network Traffic"** rule
(`R0011`) by default in
`tests/chart/templates/node-agent/default-rules.yaml`. R0011 fires on egress
that is not whitelisted by the application/network profile; the wildcard surface
above is what producers use to whitelist legitimate ranges without enumerating
every IP.

## Tests

- `pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go` and
`fixtures_test.go` — unit coverage of the match semantics.
- `tests/resources/network-wildcards/` — 20 declarative `NetworkNeighborhood`
fixtures, one per edge case, with a `README.md` token/field reference.
- `Test_28_UserDefinedNetworkNeighborhood` in `tests/component_test.go` — an
end-to-end component test of a user-defined NetworkNeighborhood.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ require (
github.com/kubescape/backend v0.0.39
github.com/kubescape/go-logger v0.0.32
github.com/kubescape/k8s-interface v0.0.214
github.com/kubescape/storage v0.0.287
github.com/kubescape/storage v0.0.290
github.com/kubescape/workerpool v0.0.0-20250526074519-0e4a4e7f44cf
github.com/moby/sys/mountinfo v0.7.2
github.com/oleiade/lane/v2 v2.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -885,8 +885,8 @@ github.com/kubescape/go-logger v0.0.32 h1:4mI+XJOV8VFCMewrEE9VIFEIOhzXokYT3nFpNf
github.com/kubescape/go-logger v0.0.32/go.mod h1:Alj7JBQ8/WCxbXe8Ura6ZheSRK45E0p21M3xeqedX90=
github.com/kubescape/k8s-interface v0.0.214 h1:j7KP0/5VvYOoQdBGV2+gRM3qnR8PWLAGF8RM/k/DmJ0=
github.com/kubescape/k8s-interface v0.0.214/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4=
github.com/kubescape/storage v0.0.287 h1:3POQZ4xiGTstVVGpHO94rMPQhK7v079vBBr4C/EuePM=
github.com/kubescape/storage v0.0.287/go.mod h1:ARiTDaeDWLqEcOIbH+zz4dwdMEVxubfu5X5ehdDOqPc=
github.com/kubescape/storage v0.0.290 h1:oIXxz31vrbQiUjBE9I6t/sBmhrwlNTAN4Vs70FxFMA4=
github.com/kubescape/storage v0.0.290/go.mod h1:ARiTDaeDWLqEcOIbH+zz4dwdMEVxubfu5X5ehdDOqPc=
github.com/kubescape/syft v1.32.0-ks.2 h1:xdUksUmKEyyVKsTfJDYW8Z5HawVJtelsUolPOsWtDx0=
github.com/kubescape/syft v1.32.0-ks.2/go.mod h1:E6Kd4iBM2ljUOUQvSt7hVK6vBwaHkMXwcvBZmGMSY5o=
github.com/kubescape/workerpool v0.0.0-20250526074519-0e4a4e7f44cf h1:hI0jVwrB6fT4GJWvuUjzObfci1CUknrZdRHfnRVtKM0=
Expand Down
13 changes: 13 additions & 0 deletions pkg/objectcache/containerprofilecache/projection.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,19 @@ func mergeNetworkNeighbor(normal, user v1beta1.NetworkNeighbor) v1beta1.NetworkN
if user.IPAddress != "" {
merged.IPAddress = user.IPAddress
}
if len(user.IPAddresses) > 0 {
ipSet := make(map[string]struct{})
for _, ip := range merged.IPAddresses {
ipSet[ip] = struct{}{}
}
for _, ip := range user.IPAddresses {
ipSet[ip] = struct{}{}
}
merged.IPAddresses = make([]string, 0, len(ipSet))
for ip := range ipSet {
merged.IPAddresses = append(merged.IPAddresses, ip)
}
}
if user.Type != "" {
merged.Type = user.Type
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/objectcache/containerprofilecache/projection_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ func extractEgressAddresses(cp *v1beta1.ContainerProfile) []string {
if n.IPAddress != "" {
addrs = append(addrs, n.IPAddress)
}
addrs = append(addrs, n.IPAddresses...)
}
return addrs
}
Expand All @@ -247,6 +248,7 @@ func extractIngressAddresses(cp *v1beta1.ContainerProfile) []string {
if n.IPAddress != "" {
addrs = append(addrs, n.IPAddress)
}
addrs = append(addrs, n.IPAddresses...)
}
return addrs
}
Expand Down
32 changes: 22 additions & 10 deletions pkg/objectcache/v1/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,18 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) *
// Egress addresses and domains — All=true: all observed entries are retained.
if !specInstalled || spec.EgressAddresses.InUse || spec.EgressDomains.InUse {
for _, n := range cp.Spec.Egress {
if (!specInstalled || spec.EgressAddresses.InUse) && n.IPAddress != "" {
if pcp.EgressAddresses.Values == nil {
pcp.EgressAddresses.All = true
pcp.EgressAddresses.Values = make(map[string]struct{})
if !specInstalled || spec.EgressAddresses.InUse {
addrs := n.IPAddresses
if n.IPAddress != "" {
addrs = append([]string{n.IPAddress}, addrs...)
}
for _, a := range addrs {
if pcp.EgressAddresses.Values == nil {
pcp.EgressAddresses.All = true
pcp.EgressAddresses.Values = make(map[string]struct{})
}
pcp.EgressAddresses.Values[a] = struct{}{}
}
pcp.EgressAddresses.Values[n.IPAddress] = struct{}{}
}
if !specInstalled || spec.EgressDomains.InUse {
domains := n.DNSNames
Expand All @@ -214,12 +220,18 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) *
// Ingress addresses and domains — All=true: all observed entries are retained.
if !specInstalled || spec.IngressAddresses.InUse || spec.IngressDomains.InUse {
for _, n := range cp.Spec.Ingress {
if (!specInstalled || spec.IngressAddresses.InUse) && n.IPAddress != "" {
if pcp.IngressAddresses.Values == nil {
pcp.IngressAddresses.All = true
pcp.IngressAddresses.Values = make(map[string]struct{})
if !specInstalled || spec.IngressAddresses.InUse {
addrs := n.IPAddresses
if n.IPAddress != "" {
addrs = append([]string{n.IPAddress}, addrs...)
}
for _, a := range addrs {
if pcp.IngressAddresses.Values == nil {
pcp.IngressAddresses.All = true
pcp.IngressAddresses.Values = make(map[string]struct{})
}
pcp.IngressAddresses.Values[a] = struct{}{}
}
pcp.IngressAddresses.Values[n.IPAddress] = struct{}{}
}
if !specInstalled || spec.IngressDomains.InUse {
if n.DNS != "" {
Expand Down
Loading
Loading