Skip to content

feat(network): symmetric R0011/R0012 with NetworkPolicy-style internal allowlisting and port-aware alerting - #923

Draft
entlein wants to merge 23 commits into
kubescape:mainfrom
k8sstormcenter:feat/network-v2
Draft

feat(network): symmetric R0011/R0012 with NetworkPolicy-style internal allowlisting and port-aware alerting#923
entlein wants to merge 23 commits into
kubescape:mainfrom
k8sstormcenter:feat/network-v2

Conversation

@entlein

@entlein entlein commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

NOT READY FOR REVIEW

One coherent change replacing the partial PRs #902, #905, #915 (kept open for reference until this lands; storage companion: kubescape/storage#364).

Still carries residue from local testing that wont be upstreamed. The design is getting somewhere now.

Feature: Ports alert, Internal and External Network Slices can be selected for allowlisting

Rules — symmetric egress/ingress, no IP-class gate

  • R0011 (egress) and new R0012 (ingress) are exact twins: internal and external peers treated alike, only loopback (127.*, ::1) excluded. Lateral movement to an unlisted internal peer alerts; a serviceCIDR-wide allowlist entry no longer blinds detection.
  • Both consume the port-aware matcher was_address_port_protocol_in_*: an allowed address on a violated port alerts; an entry with no ports (or Port: 0) means any port, computed per-(port,protocol).

Allowlisting internal traffic, NetworkPolicy-style

  • Peer selectors: podSelector on profile neighbors, matched at event time (was_selector_in_*)
  • Service references: serviceRef{Name}, serviceSelector, entity: host resolve at projection time to ClusterIP + endpoint IPs + the Service FQDN, feeding the ordinary address/DNS surfaces. Unresolvable selectors contribute nothing — never a match-all. Informers are gated behind networkServiceResolutionEnabled (default ON) and strip managedFields/annotations;

measured cost MUST BE REMEASURED

Chart: needs to be moved

Validation

  • Full component-test matrix green on the fork (50/50): including Test_50 (real Flux source-controller; serviceRef/serviceSelector allowlisted egress silent, HelmRepository repointed at an unlisted sibling Service fires R0011) and Test_51 (ingress twin: serviceRef-listed client silent, unlisted k6 client fires R0012).
  • Legacy network tests (Test_21/22/28) green under the widened R0011 with profiles naming their internal peers — the allowlist pressure is the feature.
  • Images: ghcr.io/k8sstormcenter/{node-agent,storage}:net-v2-rc1.

Dependency

go.mod temporarily replaces kubescape/storage with the fork branch of kubescape/storage#364 (schema fields + generated code + loss-guards in collapse/deflate/NetworkPolicy generation). After #364 merges, the replace drops for a pseudo-version pin — no storage release required.

🤖 Generated with Claude Code

entlein added 19 commits August 18, 2026 17:37
…c allowlisting

Signed-off-by: entlein <einentlein@gmail.com>
…rt if delcared and violated

Signed-off-by: entlein <einentlein@gmail.com>
Let a ContainerProfile allowlist cluster-infrastructure egress/ingress by
Service name, Service label selector, or host entity instead of a broad
ipAddresses serviceCIDR that blinds R0011/R0012 to lateral movement.

Each serviceRef/serviceSelector/entity neighbor resolves at projection time
to the concrete ClusterIP + backing-endpoint (or node/gateway) IPs it stands
for, carrying its own ports, and is appended as an ordinary selector-free
ipAddresses neighbor. The existing port-sensitive address matcher enforces
it unchanged; unresolved selectors contribute nothing (never a match-all).

- pkg/networkpeer: Resolve/Matches/ResolveIPs + Lister over Service,
  EndpointSlice and Node informers, with a generation counter so a profile
  projected before the informers synced re-projects once the view changes.
- objectcache/reconciler: mark profiles that use service resolution and
  re-project them when the lister generation advances; plain profiles keep
  the identical old fast-skip path.
- cmd/main.go: cluster-wide Service/EndpointSlice informers + a node-scoped
  Node informer, started non-blocking (no WaitForCacheSync on the hot path).
- fail closed on ServiceSelector MatchExpressions / empty matchLabels and on
  any namespaceSelector other than kubernetes.io/metadata.name=<ns>.
- Test_50 component test (serviceRef egress allowed, external egress still
  fires R0011) + resolve/expand/lister unit tests + fixture-lint R-NN-12
  extended to accept the new target fields.

Depends on the storage schema fields ServiceRefNamespace/ServiceRefName/
ServiceSelector/Entity; go.mod pins the fork's storage until the companion
upstream storage PR lands.

Signed-off-by: tanzee <einentlein@gmail.com>
…rviceRef

Component test Test_50 now generates its traffic from a real Flux
source-controller reconciling HelmRepository CRs instead of exec'ing curl,
and its ContainerProfile is network-only (no syscalls/execs, which only add
false-positive surface to a network test).

The profile names every peer as a Kubernetes object: serviceRef
default/kubernetes for the apiserver, serviceRef kube-system/kube-dns for
resolution, and a serviceSelector role=helm-repo fanning across the two repo
Services. The negative is the lateral move a serviceCIDR entry hides: the
HelmRepository URL is repointed at a sibling Service on the same port that
the selector does not cover, and the controller fetches it itself.
Verified on kind: 0 alerts for the named peers, R0011 within 15s for the
sibling.

Fixes found while validating end to end:
- ClusterRole was missing discovery.k8s.io/endpointslices, so the informer
  was forbidden and Service endpoint IPs never resolved — the feature
  silently degraded to ClusterIP-only.
- Service/EndpointSlice informers are now gated behind
  networkServiceResolutionEnabled and strip managedFields/annotations (and
  per-endpoint fields beyond Addresses) via SetTransform, so agents that do
  not use the feature pay no cluster-wide list+watch and the cache stays
  small on those that do.
- serviceRef/serviceSelector now also imply the Service cluster FQDN as a
  dnsName, so a client dialling the Service by name is allowlisted without a
  parallel dnsNames entry.
- specFromNeighbor no longer allocates a discarded port slice for every
  plain ipAddresses neighbor.
- R0011 no longer excludes private destinations: in-cluster lateral movement
  is exactly what this feature exists to expose.

Signed-off-by: tanzee <einentlein@gmail.com>
Relaxing the shipped R0011 to fire on private destinations made kube-dns
egress alert for every workload that does not name it: Test_21 gained a
spurious R0011 and Test_28 lost allowed_fusioncore_no_alert and
mitm_coredns_poisoning. Restore the stock expression and express the
internal-egress predicate as a test-only rule (R9911) bound by podSelector to
this suite's pods, so nothing outside it changes.

Verified on kind: Test_50 passes both phases against the stock ruleset, and
Test_21 + all six Test_28 subtests are green again.

Signed-off-by: tanzee <einentlein@gmail.com>
Hardcoding the flag in the ConfigMap made it impossible to measure the
feature's cost against itself. Expose it as nodeAgent.config.networkServiceResolution
(on in the test chart, so Test_50 still exercises it) so an A/B can toggle
resolution without rebuilding the image.

Signed-off-by: tanzee <einentlein@gmail.com>
The CEL result cache keys on SpecHash + SyncChecksum. Re-projecting a
serviceRef/serviceSelector/entity profile against a moved cluster view changes
neither: SpecHash tracks the rule projection spec, and SyncChecksum comes from a
learned CP annotation an authored profile does not carry at all. So a result
computed before the Service/EndpointSlice informers filled — 'this ClusterIP is
not in egress' — was served from the LRU indefinitely, and the re-projection the
lister generation correctly triggered had no observable effect. Egress to an
allowlisted Service kept alerting.

Carry the resolution generation on the projected profile and include it in the
key, so the cache moves whenever the resolved addresses can have moved.

Signed-off-by: tanzee <einentlein@gmail.com>
R0011 keeps its external-only scope (!is_private_ip); internal traffic gets its
own rule instead of widening R0011 — rewriting R0011's scope broke Test_21/28
(kube-dns FPs) when tried in the fork CT.

R0012 alerts on OUTGOING to private addresses (loopback excluded — is_private_ip
counts 127.0.0.1/::1 as private) not allowlisted by the profile's egress
addresses, which includes serviceRef/serviceSelector-resolved entries. Uses the
port-aware matcher; behaves address-only until the port projection lands, then
becomes port-sensitive with no rules change. A selector clause
(was_selector_in_egress) is added one-line when the peer-selector fields merge.
Same defaults as R0011; uniqueId keyed on addr_port_proto; bound in the default
binding (new rule names are inert until bound).

Signed-off-by: tanzee <einentlein@gmail.com>
Per design review: R0011 (egress) and R0012 (ingress, new) are symmetric twins.
Neither uses is_private_ip — internal and external peers are treated alike, so
lateral movement to unlisted internal peers alerts; only loopback is excluded.
Allowlisting internal traffic is the profile's job (addresses, resolved
serviceRef/serviceSelector entries), not the rule's.

Both use the port-aware matcher (address-only until port projection lands).
On HOST (incoming) events the gadget's dstAddr/dstPort carry the remote peer
and local port. R0011's scope widens to internal egress: component tests whose
profiles do not list kube-dns et al. will alert until their profiles do —
that pressure is the feature.

Signed-off-by: tanzee <einentlein@gmail.com>
…t/network-v2

# Conflicts:
#	pkg/objectcache/projection_types.go
# Conflicts:
#	tests/chart/templates/node-agent/default-rules.yaml
portalerts carried its own copy of the celnetworkselector peer-selector
functions; the merge kept both and the package no longer compiled. One copy
remains.

Signed-off-by: tanzee <einentlein@gmail.com>
…xes)

Signed-off-by: tanzee <einentlein@gmail.com>
… twin

The scoped R9911 rule and its binding are gone — the widened R0011 covers
internal egress, so the decoy pivot asserts the shipped rule. Test_51 mirrors
it for ingress: nginx serves a serviceRef-listed client (flux
source-controller, resolution covers its ClusterIP and pod endpoint IPs) with
zero R0012, then an unlisted k6 client joins and R0012 must fire. Both use
only real controller/loadgen traffic.

Signed-off-by: tanzee <einentlein@gmail.com>
…eset

Deployable over any kubescape install to replace the stock rules; namespace
templated. A drift test pins the chart copy to the CI-validated test-chart
copy so the shipped semantics are always the tested ones.

Signed-off-by: tanzee <einentlein@gmail.com>
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The selector clause was deferred while the engine lived on a separate branch,
then forgotten when that branch merged: selectors resolved and matched but no
rule consulted them. Both rules now also allowlist via
was_selector_in_egress/ingress, matching the form already deployed downstream.

Signed-off-by: tanzee <einentlein@gmail.com>
@@ -0,0 +1,44 @@
apiVersion: kubescape.io/v1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chicken, please remove those here, thats the PR to rulelibrary, but add them to the tests/** folder

ruleExpression:
- eventType: "network"
expression: "event.pktType == 'OUTGOING' && !net.is_private_ip(event.dstAddr) && !cp.was_address_in_egress(event.containerId, event.dstAddr)"
expression: "event.pktType == 'OUTGOING' && !event.dstAddr.startsWith('127.') && event.dstAddr != '::1' && !cp.was_address_port_protocol_in_egress(event.containerId, event.dstAddr, event.dstPort, event.proto) && !cp.was_selector_in_egress(event.containerId, event.dstNamespace, event.dstPodLabels)"

@entlein entlein Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will make the rules broad and OFF by default. I.e. the private ip and localhost filters will be removed

load.js: |
import http from 'k6/http';
import { sleep } from 'k6';
export const options = { vus: 2, duration: '30m' };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as requested. This lines up with the author's own "NOT READY FOR REVIEW" flag — the networkpeer resolution/fail-closed design (empty selectors → nil, MatchExpressions rejected, ClusterIPNone excluded, gateway IP verified via ipNet.Contains) is sound, but there are concrete blockers before this can merge:

Critical

  • go.mod/go.sum: replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-... pins the module to a fork, with the pre-replace requirement left at the placeholder v0.0.0-00010101000000-000000000000. This is unbuildable for any consumer until kubescape/storage#364 merges and a real pseudo-version replaces it.
  • Unrelated dependency downgrades pulled in alongside the fork pin: kubescape/backend v0.0.39 → v0.0.31 and gotest.tools/v3 v3.5.2 → v3.5.0. These look like tidying against the stale fork rather than an intentional change — please restore both unless there's a reason to downgrade.

High

  • pkg/objectcache/containerprofilecache/reconciler.go (~L499-510): the cache-invalidation fix reads listerGen() after Apply(...) runs, in two separate calls. If a Bump() happens during resolution, the new generation gets stamped onto IPs resolved against the old view, so refreshOneEntry's staleness check never fires and the stale result is served from the LRU indefinitely — the exact bug this fix was meant to close. containerprofilecache.go (~L618-620) does this correctly by reading the generation once, before resolution — please match that pattern here.
  • The widened R0011 (drops net.is_private_ip) and new R0012 ship enabled: true in default-rules.yaml/binding, but EnableNetworkServiceResolution defaults to false in pkg/config/config.go — only the test chart's values.yaml turns it on. On upgrade, existing profiles start alerting on all internal peers with no serviceRef/serviceSelector allowlisting available to quiet them (resolution is off by default). Either default resolution on alongside the widened rules, or ship R0012/the widened R0011 disabled until resolution is on by default.
  • pkg/networkpeer/expand.go (~L127): nil namespaceSelector on a serviceSelector resolves cluster-wide ("cluster-wide by design" per the comment), which contradicts both the PR description ("nil namespaceSelector = same namespace, as in NetworkPolicy") and the same-namespace default used elsewhere (network.go's namespaceSelectorMatches). resolveServices doesn't have the profile's namespace in scope to fix this today — serviceSelector: {app: foo} currently allowlists that label across every namespace in the cluster, which is a much broader allowlist than the feature intends.
  • RBAC (discovery.k8s.io/endpointslices) only lands in tests/chart's ClusterRole. Per the PR description, the shipped chart lives in kubescape/helm-charts and "needs to be moved" — without a companion PR there, real deployments enabling this feature get informer 403s and silently degrade to ClusterIP-only resolution (the exact failure mode already found and fixed once during this PR's own validation).

Medium

  • expand.go's hasServiceFields accepts a ServiceRefNamespace-only neighbor, but specFromNeighbor rejects it — causes permanent re-projection churn for that shape.
  • pkg/utils/cel.go's dstPodLabels returns a raw map[string]string while sibling CEL accessors return celtypes-wrapped values — worth double-checking this doesn't break type coercion in CEL expressions that consume it.

No fork images found in shipped config (only a ghcr.io/fluxcd reference in a test fixture, which is fine). Given the go.mod fork dependency alone, this isn't mergeable yet — requesting changes rather than approving.

@entlein

entlein commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

thanks for catching the regression during the merge/rebase

**AI, **
please
pkg/objectcache/containerprofilecache/reconciler.go (~L499-510): the cache-invalidation fix reads listerGen() after Apply(...) runs, in two separate calls. If a Bump() happens during resolution, the new generation gets stamped onto IPs resolved against the old view, so refreshOneEntry's staleness check never fires and the stale result is served from the LRU indefinitely — the exact bug this fix was meant to close. containerprofilecache.go (~L618-620) does this correctly by reading the generation once, before resolution — please match that pattern here.
-> recheck and fix

Clusterrole RBAC: Thanks for bringing it up -> AI, check how much surface this introduces to the deaomnset overall and align with how e.g. cilium minimizes that role

EnableNetworkServiceResolution defaults to false in pkg/config/config.go : we assume that noone is currently using those profiles, the default loud is desired, else a TDD approach to find/scan for all the connections to add to a profile is difficult.
That being said: you are correct that the mechanism is inconsistent and silly ATM: better to turn it on in ...config.go and ship the rules default OFF
AI, please lets flip config to ON (or even ditch the feature-flag) and we turn the rule OFF (not remove internal IPs, that defeats the purpose, just ship the rules R0011/R0012 as OFF)

fork dependencies: for the CTs to run, AI, please patch storage-tag.sh with our fork ref, else it builds against upstream which doesnt have the change. That defeats the test

Human readers,
Bringing us to point of how to split the rules: the whole slew of changes here break a lot of default kubescape behavior IF people use profiles at all (again: probably noone is): things will be default loud, not default learn.

I m also adding an alert on new Container , such that any workload not known to kubescape will alert instead of learn - that completely flips the basic node-agent UX ->
Possibly there should be just one global flag in the helm values.yaml to toogle all user-defined-profiles to on. Not sure yet.

Reason this is in Draft:

  • turning on these rules with bad profiles causes massive floods of alerts that may even DoS a cluster, if RuleCoolDown is disabled (my default setting), as every internal connection from e.g. DB to prometeus is going to alert ... yes.

Namespaces: That is correct: namespaces are not read - meaning, if there is a collision in names across namespaces they act as allowlists -> I think, that should be how it works.

Exec summary : 🙏 thanks for catching the technical bugs again (this is embarassing) while I sort out my brain

- reconciler: read lister generation once BEFORE service resolution and
  stamp that same gen as ResolvedGen/ListerGen, matching addContainer's
  ordering; a Bump() during resolution now invalidates the projection
  instead of masking stale IPs behind the fast-skip.
- config: networkServiceResolutionEnabled now defaults true (rules ship
  enabled, resolution must match); test chart configmap falls back to
  true via hasKey so an explicit false still renders false.
- deps: restore accidental downgrades kubescape/backend v0.0.31->v0.0.39
  and gotest.tools/v3 v3.5.0->v3.5.2; k8sstormcenter/storage replace pin
  unchanged (tidy normalized the require placeholder to v0.0.258, the
  replace still governs).
- networkpeer: hasServiceFields no longer counts a ServiceRefNamespace-only
  neighbor that specFromNeighbor rejects, ending permanent re-projection
  churn on such profiles; test pins the agreement.
- rbac: drop unnecessary get verb on endpointslices (cache-backed
  informer needs only list+watch).
- ct: storage-tag.sh emits the fork storage image tag (net-v2-rc1) while
  go.mod replaces storage with k8sstormcenter/storage, and the test chart
  pulls ghcr.io/k8sstormcenter/storage, so CTs run a server that has the
  serviceRef/dnsNames schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8UV2B7b6dpDJQci31aC6c
Signed-off-by: tanzee <einentlein@gmail.com>
Comment thread pkg/rulemanager/cel/libraries/containerprofilenetwork/selector_test.go Outdated
Comment thread pkg/rulemanager/cel/libraries/containerprofilenetwork/selector_test.go Outdated
@matthyx matthyx moved this to WIP in KS PRs tracking Aug 25, 2026
@entlein

entlein commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Here s the benchmakr from this run BTW @matthyx : could have sworn that benchmark-thingy posted to the PR for the last round, now the widget is getting 403. Possibly intentional, possibly not.
BTW: I think, the benchmark is wrong, but here we are:

EDIT: entlein does not believe that her nonsense improved the perf :)

eBPF Dedup Benchmark Results

Node-Agent Resource Usage

Metric BEFORE AFTER Delta

Avg CPU (cores) 0.218 0.213 -2.0%
Peak CPU (cores) 0.231 0.230 -0.6%
Avg Memory (MiB) 374.079 297.109 -20.6%
Peak Memory (MiB) 377.723 302.402 -19.9%

Dedup Effectiveness: no data available

Peer matching is on pod labels; a nil namespaceSelector no longer requires the
same namespace — namespace is consulted only when the selector is explicitly
set (collision disambiguation). This aligns peer selectors with the
serviceSelector nil-namespace semantics. An empty podSelector now matches
NOTHING (fail closed → the peer alerts), the opposite of NetworkPolicy's
match-all: an allowlist entry must name what it permits. An unresolved peer
still never matches.

Full truth tables added: wasSelectorInPeers and namespaceSelectorMatches
(pod/namespace edge cases), and serviceRef (via the always-present
default/kubernetes API server) and entity:host resolution + matching in
networkpeer.

Signed-off-by: tanzee <einentlein@gmail.com>
@matthyx

matthyx commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

@entlein I think the benchmark is accurate on main, I have fixed my IG fork to use the latest deepcopy implementation with a pool... we had a more efficient implementation that was never used for months, my bad.

@entlein

entlein commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

NICE!

excludeNamespaces (Config.SkipNamespace) filters which workloads node-agent
profiles; the selector resolver queries a cluster-wide Service/Node view that
takes no namespace-exclusion input. The resulting source/peer asymmetry — a
workload in an excluded namespace is never profiled, yet any monitored profile
may still allowlist a Service in that excluded namespace via serviceRef or an
unscoped serviceSelector — is easy to overlook.

These pin it down: serviceRef into an excluded ns resolves; an unscoped
serviceSelector fans across the exclusion boundary; authored NamespaceLabels is
the only mechanism that scopes fanout (and can deliberately target an excluded
ns); the host entity is orthogonal. Asserted under both the exclude-denylist and
include-allowlist forms of SkipNamespace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8UV2B7b6dpDJQci31aC6c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: WIP

Development

Successfully merging this pull request may close these issues.

2 participants