Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,5 @@ 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/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2

replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260527173728-46f37d32b969
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,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.20260527173728-46f37d32b969 h1:ZEifelzzPQ/n6D9p/znxd18GadIajfwxANrJQDtpx34=
github.com/k8sstormcenter/storage v0.0.240-0.20260527173728-46f37d32b969/go.mod h1:FpV6tCrYXlp2kKWza4yr7zf2Y1q7IGgx871ndN7SMNo=
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=
Expand Down
28 changes: 28 additions & 0 deletions pkg/objectcache/containerprofilecache/containerprofilecache.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/kubescape/go-logger/helpers"
helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
"github.com/kubescape/node-agent/pkg/config"
"github.com/kubescape/node-agent/pkg/exporters"
"github.com/kubescape/node-agent/pkg/metricsmanager"
"github.com/kubescape/node-agent/pkg/objectcache"
"github.com/kubescape/node-agent/pkg/objectcache/callstackcache"
Expand Down Expand Up @@ -122,6 +123,12 @@ type ContainerProfileCacheImpl struct {
specGeneration atomic.Int64 // bumped on each distinct spec hash change
nudge chan struct{} // buffered cap 1; signals reconciler on spec change
refreshPending atomic.Bool // set when a nudge arrives while refresh is running

// Tamper detection state (fork-only). See tamper_alert.go for the full
// description; reintroduced here on top of upstream's reshape so the
// legacy R1016 "Signed profile tampered" wiring keeps working.
tamperAlertExporter exporters.Exporter
tamperEmitted sync.Map // tamperKey -> struct{}
}

// NewContainerProfileCache creates a new ContainerProfileCacheImpl.
Expand Down Expand Up @@ -398,6 +405,22 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
helpers.Error(userAPErr))
userAP = nil
}
// Tamper detection: re-verify the signature on every load. Emits R1016
// when a signed overlay's signature no longer matches (i.e. content
// has been mutated post-sign). No-op when the overlay is unsigned or
// the tamper-alert exporter has not been wired.
// CodeRabbit upstream PR #808 / containerprofilecache.go:414 (Major):
// when EnableSignatureVerification=true and the overlay fails
// verification, verifyUserApplicationProfile returns false. Drop the
// failed overlay before merging so a tampered profile does not
// silently project into the cache. In permissive mode the verifier
// always returns true and the overlay still merges (alert-only
// behaviour preserved).
if userAP != nil {
if !c.verifyUserApplicationProfile(userAP, sharedData.Wlid) {
userAP = nil
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var userNNErr error
_ = c.refreshRPC(ctx, func(rctx context.Context) error {
userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, overlayName)
Expand All @@ -411,6 +434,11 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
helpers.Error(userNNErr))
userNN = nil
}
if userNN != nil {
if !c.verifyUserNetworkNeighborhood(userNN, sharedData.Wlid) {
userNN = nil
}
}
}

// Need SOMETHING to cache. If we have nothing, stay pending and retry.
Expand Down
69 changes: 69 additions & 0 deletions pkg/objectcache/containerprofilecache/projection.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package containerprofilecache

import (
"strings"

helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
"github.com/kubescape/node-agent/pkg/utils"
"github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1"
corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -62,9 +65,75 @@ func projectUserProfiles(
}
}

// Fold the user-overlay identity into the merged profile's SyncChecksum
// annotation. Apply (projection_apply.go) reads this into
// ProjectedContainerProfile.SyncChecksum which the rulemanager's
// function_cache uses as part of its invalidation key (see
// pkg/rulemanager/cel/libraries/cache/function_cache.go:
// HashForContainerProfile).
//
// Without this, an empty-baseline + user-overlay container has a
// constant SyncChecksum="" across both "no overlay yet" and "overlay
// merged" states. Stale ap.was_executed=false results computed during
// the no-overlay window would then persist in the cache and the rule
// evaluator would never see the merged user-overlay paths — which is
// the root cause behind Test_32_UnexpectedProcessArguments's R0001
// precondition failure and the latent R0001-on-nslookup noise in
// Test_28_UserDefinedNetworkNeighborhood.
if userAP != nil || userNN != nil {
stampOverlayIdentity(projected, userAP, userNN)
}

return projected, warnings
}

// stampOverlayIdentity appends user-overlay identity (kind/ns/name@RV)
// to the projected ContainerProfile's SyncChecksumMetadataKey annotation.
// Modifies projected.Annotations in place.
//
// The original baseline checksum (if present) is preserved as the prefix
// so distinct baselines still produce distinct keys. Format:
//
// <baseline-checksum>|ap=<ns>/<name>@<rv>|nn=<ns>/<name>@<rv>
//
// Either ap= or nn= segments are omitted when the corresponding overlay
// is nil. RV is the only piece that needs to change for the cache to
// invalidate, but namespace+name are kept so cross-overlay collisions
// (e.g. two different overlays happening to share RV across namespaces)
// don't alias.
//
// IDEMPOTENT: calling stampOverlayIdentity twice with the same overlay
// produces the same final annotation. The annotation is split on `|`
// and only the FIRST segment is kept as "baseline" — any existing
// ap= / nn= suffixes from prior stamps are discarded before being
// re-appended. (CodeRabbit PR #43 critical on projection.go:115:
// projectUserProfiles is called twice in succession in both
// reconciler.go and containerprofilecache.go, feeding the output of
// the first projection back as input to the second. Without this
// strip step, overlay suffixes accumulate on every reconcile tick,
// churning the function_cache.)
func stampOverlayIdentity(projected *v1beta1.ContainerProfile, userAP *v1beta1.ApplicationProfile, userNN *v1beta1.NetworkNeighborhood) {
if projected.Annotations == nil {
projected.Annotations = map[string]string{}
}
// Strip any prior ap= / nn= suffixes by taking only the first
// `|`-segment as the canonical baseline checksum. This is what
// makes repeat-stamping idempotent.
existing := projected.Annotations[helpersv1.SyncChecksumMetadataKey]
baseline := existing
if idx := strings.IndexByte(existing, '|'); idx >= 0 {
baseline = existing[:idx]
}
parts := []string{baseline}
if userAP != nil {
parts = append(parts, "ap="+userAP.Namespace+"/"+userAP.Name+"@"+userAP.ResourceVersion)
}
if userNN != nil {
parts = append(parts, "nn="+userNN.Namespace+"/"+userNN.Name+"@"+userNN.ResourceVersion)
}
projected.Annotations[helpersv1.SyncChecksumMetadataKey] = strings.Join(parts, "|")
}

// mergeApplicationProfile finds the container entry in userAP matching
// containerName (across Spec.Containers / InitContainers / EphemeralContainers)
// and merges its fields into projected.Spec. Returns the list of pod-spec
Expand Down
99 changes: 86 additions & 13 deletions pkg/objectcache/containerprofilecache/projection_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,30 +44,37 @@ func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, c
}

// Project each data surface.
// The third arg classifies an entry as "dynamic" — routes it to Patterns
// rather than Values. Path surfaces use the ⋯ DynamicIdentifier marker;
// network surfaces accept CIDRs, '*' sentinels, and DNS wildcard tokens
// per the v0.0.2 spec (matched at runtime by storage's networkmatch).
opensPaths := extractOpensPaths(cp)
pcp.Opens = projectField(s.Opens, opensPaths, true)
pcp.Opens = projectField(s.Opens, opensPaths, containsDynamicSegment)

execsPaths := extractExecsPaths(cp)
pcp.Execs = projectField(s.Execs, execsPaths, true)
pcp.Execs = projectField(s.Execs, execsPaths, containsDynamicSegment)
pcp.ExecsByPath = extractExecsByPath(cp)

endpointPaths := extractEndpointPaths(cp)
pcp.Endpoints = projectField(s.Endpoints, endpointPaths, true)
pcp.Endpoints = projectField(s.Endpoints, endpointPaths, containsDynamicSegment)

pcp.Capabilities = projectField(s.Capabilities, cp.Spec.Capabilities, false)
pcp.Syscalls = projectField(s.Syscalls, cp.Spec.Syscalls, false)
pcp.Capabilities = projectField(s.Capabilities, cp.Spec.Capabilities, nil)
pcp.Syscalls = projectField(s.Syscalls, cp.Spec.Syscalls, nil)

pcp.EgressDomains = projectField(s.EgressDomains, extractEgressDomains(cp), false)
pcp.EgressAddresses = projectField(s.EgressAddresses, extractEgressAddresses(cp), false)
pcp.EgressDomains = projectField(s.EgressDomains, extractEgressDomains(cp), isNetworkDNSWildcard)
pcp.EgressAddresses = projectField(s.EgressAddresses, extractEgressAddresses(cp), isNetworkIPWildcard)

pcp.IngressDomains = projectField(s.IngressDomains, extractIngressDomains(cp), false)
pcp.IngressAddresses = projectField(s.IngressAddresses, extractIngressAddresses(cp), false)
pcp.IngressDomains = projectField(s.IngressDomains, extractIngressDomains(cp), isNetworkDNSWildcard)
pcp.IngressAddresses = projectField(s.IngressAddresses, extractIngressAddresses(cp), isNetworkIPWildcard)

return pcp
}

// projectField is the per-surface transform. rawEntries are strings from the
// raw profile. isPathSurface enables retention of dynamic-segment entries.
func projectField(spec objectcache.FieldSpec, rawEntries []string, isPathSurface bool) objectcache.ProjectedField {
// raw profile. isDynamic, if non-nil, is called per entry: returning true
// routes the entry to Patterns rather than Values (cache-miss path runs the
// matcher rather than a map lookup).
func projectField(spec objectcache.FieldSpec, rawEntries []string, isDynamic func(string) bool) objectcache.ProjectedField {
if !spec.InUse {
// No rule declared a requirement for this field — pass all raw entries
// through so existing rules that omit profileDataRequired keep working.
Expand All @@ -92,9 +99,9 @@ func projectField(spec objectcache.FieldSpec, rawEntries []string, isPathSurface
seen := make(map[string]bool) // for Patterns dedup

for _, e := range rawEntries {
isDynamic := isPathSurface && containsDynamicSegment(e)
dynamic := isDynamic != nil && isDynamic(e)

if isDynamic {
if dynamic {
// Dynamic entries always go to Patterns on path surfaces (both
// pass-through and explicit InUse modes).
if !seen[e] {
Expand Down Expand Up @@ -148,6 +155,42 @@ func containsDynamicSegment(e string) bool {
return strings.Contains(e, dynamicpathdetector.DynamicIdentifier)
}

// isNetworkIPWildcard reports whether an IP-surface entry is a v0.0.2
// pattern (CIDR membership, '*' any-IP sentinel, or DynamicIdentifier).
// Literal IPv4/IPv6 addresses are NOT patterns; they go to Values for
// the cheap map lookup path. Spec §5.7.
func isNetworkIPWildcard(e string) bool {
if e == "" {
return false
}
if e == "*" {
return true
}
if strings.Contains(e, "/") {
return true
}
if strings.Contains(e, dynamicpathdetector.DynamicIdentifier) {
return true
}
return false
}

// isNetworkDNSWildcard reports whether a DNS-surface entry uses any of
// the v0.0.2 wildcard tokens — leading '*' (RFC 4592), mid '⋯', trailing
// '*'. Literal FQDNs go to Values. Spec §5.8.
func isNetworkDNSWildcard(e string) bool {
if e == "" {
return false
}
if strings.Contains(e, "*") {
return true
}
if strings.Contains(e, dynamicpathdetector.DynamicIdentifier) {
return true
}
return false
}

// --- Field extractors ---

func extractOpensPaths(cp *v1beta1.ContainerProfile) []string {
Expand All @@ -166,6 +209,32 @@ func extractExecsPaths(cp *v1beta1.ContainerProfile) []string {
return paths
}

// extractExecsByPath builds the path → args map used by the exec-args
// wildcard matcher (CompareExecArgs). Multiple ExecCalls entries with the
// same Path collapse to the last seen; this matches the prior fork-only
// behavior. nil-Args entries are stored as empty slices, which
// CompareExecArgs treats as "no argv constraint".
//
// Args slices are CLONED rather than aliased — Apply is contract-bound to
// be a pure transform, and an alias would let consumers mutate the source
// profile by editing the projected map. (CR #43 finding on this file.)
func extractExecsByPath(cp *v1beta1.ContainerProfile) map[string][]string {
if len(cp.Spec.Execs) == 0 {
return nil
}
m := make(map[string][]string, len(cp.Spec.Execs))
for _, e := range cp.Spec.Execs {
if e.Args == nil {
m[e.Path] = []string{}
continue
}
cloned := make([]string, len(e.Args))
copy(cloned, e.Args)
m[e.Path] = cloned
}
return m
}

func extractEndpointPaths(cp *v1beta1.ContainerProfile) []string {
endpoints := make([]string, len(cp.Spec.Endpoints))
for i, e := range cp.Spec.Endpoints {
Expand All @@ -191,6 +260,9 @@ func extractEgressAddresses(cp *v1beta1.ContainerProfile) []string {
if n.IPAddress != "" {
addrs = append(addrs, n.IPAddress)
}
// v0.0.2 IPAddresses[] — list form supporting CIDRs and '*' sentinel.
// Same semantics as the deprecated singular IPAddress, just plural.
addrs = append(addrs, n.IPAddresses...)

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.

The current github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1.NetworkNeighbor type only exposes IPAddress (singular) — go doc ...NetworkNeighbor has no IPAddresses field. That makes this append, and the matching ingress append below, a compile error against the current dependency, so this needs to stay on the singular field unless the storage API is updated first.

}
return addrs
}
Expand All @@ -212,6 +284,7 @@ func extractIngressAddresses(cp *v1beta1.ContainerProfile) []string {
if n.IPAddress != "" {
addrs = append(addrs, n.IPAddress)
}
addrs = append(addrs, n.IPAddresses...)
}
return addrs
}
Expand Down
Loading