Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
4 changes: 3 additions & 1 deletion .github/workflows/component-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,9 @@ jobs:
Test_28_UserDefinedNetworkNeighborhood,
Test_29_SignedApplicationProfile,
Test_30_TamperedSignedProfiles,
Test_31_TamperDetectionAlert
Test_31_TamperDetectionAlert,
Test_32_UnexpectedProcessArguments,
Test_33_AnalyzeOpensWildcardAnchoring
]
steps:
- name: Checkout code
Expand Down
5 changes: 5 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ func main() {
ruleBindingCache.AddNotifier(&ruleBindingNotify)

cpc := containerprofilecache.NewContainerProfileCache(cfg, storageClient, k8sObjectCache, prometheusExporter)
// Wire R1016 tamper alerts: when a user-defined AP/NN overlay is
// loaded but its signature no longer verifies, the CP cache emits
// "Signed profile tampered" through this exporter. Optional —
// nil-safe inside the cache.
cpc.SetTamperAlertExporter(exporter)
cpc.Start(ctx)
logger.L().Info("ContainerProfileCache active; legacy AP/NN caches removed")

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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.20260429052903-0e0366026f05
replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260501173152-4ab95fb87ba2
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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.20260429052903-0e0366026f05 h1:RCEcduxCntYAuo8BleZu84Kk//X0gvsGrutQtdcLMn0=
github.com/k8sstormcenter/storage v0.0.240-0.20260429052903-0e0366026f05/go.mod h1:amdg/Qok9bqPzs1vZH5FW9/3MbCawc5wVsz9u3uIfu4=
github.com/k8sstormcenter/storage v0.0.240-0.20260501173152-4ab95fb87ba2 h1:0yjl3JVdk20Ij2ppjTJ5+awY4/EIuzF9uotncB36SqI=
github.com/k8sstormcenter/storage v0.0.240-0.20260501173152-4ab95fb87ba2/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=
Expand Down
16 changes: 16 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 @@ -109,6 +110,11 @@ type ContainerProfileCacheImpl struct {
k8sObjectCache objectcache.K8sObjectCache
metricsManager metricsmanager.MetricsManager

// tamperAlertExporter receives R1016 "Signed profile tampered" alerts
// when a user-supplied AP/NN overlay fails signature verification. Set
// after construction via SetTamperAlertExporter; nil disables alerting.
tamperAlertExporter exporters.Exporter

reconcileEvery time.Duration
rpcBudget time.Duration
refreshInProgress atomic.Bool
Expand Down Expand Up @@ -383,6 +389,12 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
helpers.Error(userAPErr))
userAP = nil
}
// Re-verify the user-supplied AP signature on every load. Emits
// R1016 if the profile is signed but tampered. Does not gate
// loading unless cfg.EnableSignatureVerification is true.
if userAP != nil && !c.verifyUserApplicationProfile(userAP, containerID) {
userAP = nil
}
var userNNErr error
_ = c.refreshRPC(ctx, func(rctx context.Context) error {
userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, overlayName)
Expand All @@ -396,6 +408,10 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
helpers.Error(userNNErr))
userNN = nil
}
// Same tamper-check on the NN side.
if userNN != nil && !c.verifyUserNetworkNeighborhood(userNN, containerID) {
userNN = nil
}
}

// Need SOMETHING to cache. If we have nothing, stay pending and retry.
Expand Down
155 changes: 155 additions & 0 deletions pkg/objectcache/containerprofilecache/tamper_alert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Tamper detection for user-supplied profile overlays loaded into the
// ContainerProfileCache.
//
// When a user references a signed ApplicationProfile or NetworkNeighborhood
// via the `kubescape.io/user-defined-profile` pod label, this code path
// re-verifies the signature on every cache load and emits an R1016
// "Signed profile tampered" alert via the rule-alert exporter when the
// signature is present but no longer valid.
//
// This is the new home of the legacy applicationprofilecache's tamper
// detection (originally introduced in fork commit c2d681e0 — "Feat/
// tamperalert"). Upstream PR #788 deleted the legacy cache; this re-wires
// the same behavior onto containerprofilecache without changing the alert
// shape so existing component tests (Test_31_TamperDetectionAlert) keep
// working.
package containerprofilecache

import (
"fmt"
"strings"

"github.com/armosec/armoapi-go/armotypes"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/node-agent/pkg/exporters"
"github.com/kubescape/node-agent/pkg/rulemanager/types"
"github.com/kubescape/node-agent/pkg/signature"
"github.com/kubescape/node-agent/pkg/signature/profiles"
"github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1"
)

// SetTamperAlertExporter wires the rule-alert exporter used to emit R1016.
// Optional — when nil, signature verification still runs (and is logged)
// but no alert is emitted. Production wiring lives in cmd/main.go after the
// alert exporter is constructed.
func (c *ContainerProfileCacheImpl) SetTamperAlertExporter(e exporters.Exporter) {
c.tamperAlertExporter = e
}

// verifyUserApplicationProfile re-verifies the signature of a user-supplied
// ApplicationProfile overlay and emits R1016 if the signature is present
// but no longer valid (i.e. the profile was tampered after signing).
//
// Returns true iff the profile is acceptable for further use:
// - profile is signed and verifies → true
// - profile is not signed → true (signing is opt-in; the empty-signature
// case is handled by the caller's normal not-signed flow)
// - profile is signed but verification fails → false (and R1016 emitted)
//
// The boolean lets the caller decide whether to project the overlay into
// the cache. Today we always proceed (the legacy semantics don't actually
// gate loading on verification unless EnableSignatureVerification is true),
// but having the return value keeps the door open for stricter modes.
func (c *ContainerProfileCacheImpl) verifyUserApplicationProfile(profile *v1beta1.ApplicationProfile, containerID string) bool {
if profile == nil {
return true
}
adapter := profiles.NewApplicationProfileAdapter(profile)
if !signature.IsSigned(adapter) {
return true
}
// AllowUntrusted: accept self-signed/local-CA signatures as long as the
// signature itself verifies against the cert in the annotations. We only
// want to flag actual tampering, not the absence of a Sigstore Fulcio
// trust chain. Matches `cmd/sign-object`'s default verifier.
if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil {
logger.L().Warning("user-defined ApplicationProfile signature verification failed (tamper detected)",
helpers.String("profile", profile.Name),
helpers.String("namespace", profile.Namespace),
helpers.String("containerID", containerID),
helpers.Error(err))
c.emitTamperAlert(profile.Name, profile.Namespace, containerID, "ApplicationProfile", err)
return !c.cfg.EnableSignatureVerification
}
return true
}

// verifyUserNetworkNeighborhood is the NN-side counterpart to
// verifyUserApplicationProfile. Same contract, different object kind in
// the alert description.
func (c *ContainerProfileCacheImpl) verifyUserNetworkNeighborhood(nn *v1beta1.NetworkNeighborhood, containerID string) bool {
if nn == nil {
return true
}
adapter := profiles.NewNetworkNeighborhoodAdapter(nn)
if !signature.IsSigned(adapter) {
return true
}
if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil {
logger.L().Warning("user-defined NetworkNeighborhood signature verification failed (tamper detected)",
helpers.String("profile", nn.Name),
helpers.String("namespace", nn.Namespace),
helpers.String("containerID", containerID),
helpers.Error(err))
c.emitTamperAlert(nn.Name, nn.Namespace, containerID, "NetworkNeighborhood", err)
return !c.cfg.EnableSignatureVerification
}
return true
}

// emitTamperAlert sends a single R1016 "Signed profile tampered" alert
// through the rule-alert exporter. No-op when the exporter is unset.
//
// Alert shape mirrors the legacy applicationprofilecache.emitTamperAlert
// (fork commit c2d681e0) so dashboards and component tests keep matching.
func (c *ContainerProfileCacheImpl) emitTamperAlert(profileName, namespace, containerID, objectKind string, verifyErr error) {
if c.tamperAlertExporter == nil {
return
}

ruleFailure := &types.GenericRuleFailure{
BaseRuntimeAlert: armotypes.BaseRuntimeAlert{
AlertName: "Signed profile tampered",
InfectedPID: 1,
Severity: 10,
FixSuggestions: "Investigate who modified the " + objectKind + " '" + profileName + "' in namespace '" + namespace + "'. Re-sign the profile after verifying its contents.",
},
AlertType: armotypes.AlertTypeRule,
RuntimeProcessDetails: armotypes.ProcessTree{
ProcessTree: armotypes.Process{
PID: 1,
Comm: "node-agent",
},
},
RuleAlert: armotypes.RuleAlert{
RuleDescription: fmt.Sprintf("Signed %s '%s' in namespace '%s' has been tampered with: %v",
objectKind, profileName, namespace, verifyErr),
},
RuntimeAlertK8sDetails: armotypes.RuntimeAlertK8sDetails{
Namespace: namespace,
},
RuleID: "R1016",
}

// Best-effort workload identifier. The legacy cache used a wlid string;
// this cache is keyed on containerID, so we just stash that as the
// workload reference. Downstream consumers (Alertmanager, exporter
// pipelines) don't structurally depend on the wlid prefix.
ruleFailure.SetWorkloadDetails(extractWlidFromContainerID(containerID))

c.tamperAlertExporter.SendRuleAlert(ruleFailure)
}

// extractWlidFromContainerID is a placeholder that returns the containerID
// as-is. The legacy cache had a richer "wlid://<cluster>/<namespace>/<kind>/
// <name>/<templateHash>" string available; the new cache is keyed on
// containerID so callers that consume wlid get an opaque identifier here.
// Retained as a separate function so the alert path can be upgraded to a
// proper wlid lookup later without touching emitTamperAlert.
func extractWlidFromContainerID(containerID string) string {
if idx := strings.LastIndex(containerID, "/"); idx > 0 {
return containerID[:idx]
}
return containerID
}
9 changes: 3 additions & 6 deletions pkg/rulemanager/cel/libraries/applicationprofile/exec.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package applicationprofile

import (
"slices"

"github.com/google/cel-go/common/types"

"github.com/google/cel-go/common/types/ref"
Expand All @@ -11,6 +9,7 @@ import (
"github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache"
"github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/celparse"
"github.com/kubescape/node-agent/pkg/rulemanager/profilehelper"
"github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector"
)

func (l *apLibrary) wasExecuted(containerID, path ref.Val) ref.Val {
Expand Down Expand Up @@ -85,10 +84,8 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val
}

for _, exec := range cp.Spec.Execs {
if exec.Path == pathStr {
if slices.Compare(exec.Args, celArgs) == 0 {
return types.Bool(true)
}
if exec.Path == pathStr && dynamicpathdetector.CompareExecArgs(exec.Args, celArgs) {
return types.Bool(true)
}
}

Expand Down
121 changes: 121 additions & 0 deletions pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,127 @@ func TestExecWithArgsNoProfile(t *testing.T) {
assert.False(t, actualResult, "ap.was_executed_with_args should return false when no profile is available")
}

// TestExecWithArgsWildcardInProfile exercises wildcard tokens inside a
// user-defined ApplicationProfile's exec arg vector:
//
// "⋯" (DynamicIdentifier) — matches exactly one argument position.
// "*" (WildcardIdentifier) — matches zero or more consecutive args.
//
// The runtime exec arg vector is matched against the profile via
// dynamicpathdetector.CompareExecArgs (added in
// k8sstormcenter/storage#23 — the matcher that this CEL function now
// routes through instead of slices.Compare).
func TestExecWithArgsWildcardInProfile(t *testing.T) {
objCache := objectcachev1.RuleObjectCacheMock{
ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](),
}

objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{
ContainerType: objectcache.Container,
ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{
objectcache.Container: {
{
Name: "test-container",
},
},
},
})

profile := &v1beta1.ApplicationProfile{}
profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{
Name: "test-container",
Execs: []v1beta1.ExecCalls{
// curl any URL: --user must be literal, value is one position.
{
Path: "/usr/bin/curl",
Args: []string{"--user", "⋯"},
},
// sh -c with any trailing payload (zero or more args).
{
Path: "/bin/sh",
Args: []string{"-c", "*"},
},
// ls -l in any directory — single trailing position.
{
Path: "/bin/ls",
Args: []string{"-l", "⋯"},
},
// echo with any number of greeting words after a literal anchor.
{
Path: "/bin/echo",
Args: []string{"hello", "*"},
},
},
})
objCache.SetApplicationProfile(profile)

env, err := cel.NewEnv(
cel.Variable("containerID", cel.StringType),
cel.Variable("path", cel.StringType),
cel.Variable("args", cel.ListType(cel.StringType)),
AP(&objCache, config.Config{}),
)
if err != nil {
t.Fatalf("failed to create env: %v", err)
}

testCases := []struct {
name string
path string
args []string
expectedResult bool
}{
// curl with --user, dynamic value
{"curl --user alice — ⋯ matches one arg", "/usr/bin/curl", []string{"--user", "alice"}, true},
{"curl --user alice bob — extra arg, ⋯ rejects", "/usr/bin/curl", []string{"--user", "alice", "bob"}, false},
{"curl --user — missing value, ⋯ requires one arg", "/usr/bin/curl", []string{"--user"}, false},
{"curl --pass alice — literal mismatch", "/usr/bin/curl", []string{"--pass", "alice"}, false},

// sh -c with arbitrary trailing payload
{"sh -c with single command", "/bin/sh", []string{"-c", "echo hi"}, true},
{"sh -c with multi-token command", "/bin/sh", []string{"-c", "while", "true;", "do", "sleep", "1;", "done"}, true},
{"sh -c with no trailing args (* matches zero)", "/bin/sh", []string{"-c"}, true},
{"sh -x — wrong flag", "/bin/sh", []string{"-x", "echo hi"}, false},

// ls -l in any directory
{"ls -l /var/log", "/bin/ls", []string{"-l", "/var/log"}, true},
{"ls -l with no directory (⋯ requires one)", "/bin/ls", []string{"-l"}, false},

// echo hello *
{"echo hello world from test", "/bin/echo", []string{"hello", "world", "from", "test"}, true},
{"echo hello (no trailing args)", "/bin/echo", []string{"hello"}, true},
{"echo goodbye world — wrong literal anchor", "/bin/echo", []string{"goodbye", "world"}, false},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`)
if issues != nil {
t.Fatalf("failed to compile expression: %v", issues.Err())
}

program, err := env.Program(ast)
if err != nil {
t.Fatalf("failed to create program: %v", err)
}

result, _, err := program.Eval(map[string]interface{}{
"containerID": "test-container-id",
"path": tc.path,
"args": tc.args,
})
if err != nil {
t.Fatalf("failed to eval program: %v", err)
}

actualResult := result.Value().(bool)
assert.Equal(t, tc.expectedResult, actualResult,
"runtime args %v vs profile (one of curl/sh/ls/echo overlay): got %v want %v",
tc.args, actualResult, tc.expectedResult)
})
}
}

func TestExecWithArgsCompilation(t *testing.T) {
objCache := objectcachev1.RuleObjectCacheMock{}

Expand Down
Loading
Loading