Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
23d3d2f
test(component): port Test_28 to upstream's unified user-overlay label
Apr 30, 2026
a83bb69
feat(cel): re-port CompareExecArgs hookup onto upstream's CP cache
Apr 30, 2026
eb0145d
feat(rules): R0040 'Unexpected process arguments' + Test_32 e2e
Apr 30, 2026
0816393
deps(storage): bump to rebased feat/exec-arg-wildcards tip (0de34ebc)
Apr 30, 2026
fbf98b0
ci(component-tests): add Test_32_UnexpectedProcessArguments to matrix
Apr 30, 2026
6f2a5b4
fix(containerprofilecache): re-wire R1016 tamper alert + expand Test_31
Apr 30, 2026
fe80d73
test(component): Test_32 profile uses full-path argv[0]
Apr 30, 2026
f828777
test: AP-fixture linter (R-AP-* rules) + canonical reference profile
Apr 30, 2026
7c10baa
fix(tamper_alert): accept self-signed profiles, only flag actual tamper
Apr 30, 2026
4d374d5
test(component): make Test_30 30b deterministic by re-execing inside …
Apr 30, 2026
a59e284
deps(storage): bump replace to f44fed80 (analyzer trailing-* fix)
May 1, 2026
bcf41ea
deps(storage): bump replace to 4ab95fb8 (PR #25 merged on fork main)
May 1, 2026
9385562
test(component): Test_33_AnalyzeOpensWildcardAnchoring
May 1, 2026
cb57674
test(component): rework Test_33 negative cases to probe under R0002's…
May 1, 2026
484d11c
test(component): fix Test_28 + Test_31 31b flakiness
May 1, 2026
4dd0b39
test(component): sign-after-roundtrip in Test_31 to defeat content-dr…
May 1, 2026
6dda020
test(component): bump Test_33 WaitForReady to 180s for cluster-pressu…
May 1, 2026
a5af261
deps(storage): bump replace to 43795bb4 (storage feat/exec-arg-wildca…
May 4, 2026
c4ac7b5
test(aplint): drop redundant p := p loop var (Go 1.22+, copyloopvar l…
May 4, 2026
e7dc486
fix(tamper_alert): R1016 dedup + use real WLID
May 4, 2026
98bdf97
deps(storage): bump replace to b0d68d3d (empty-Args wildcard match)
May 4, 2026
0cf4a50
fix: address CodeRabbit second-review batch on PR #38
May 9, 2026
8d89240
fix: address CodeRabbit third-review batch on PR #38 (0cf4a503)
May 9, 2026
26bc4bc
Merge remote-tracking branch 'origin/main' into merge/exec-arg-wildcards
May 9, 2026
045940c
fix(test): make dedup-clearing assertion non-trivial
May 9, 2026
eb40bbf
fix(ci): drop 'permissions: read-all' from reusable workflows
May 9, 2026
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.20260503184242-43795bb4f0b6
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.20260503184242-43795bb4f0b6 h1:pzIvtCkXBC6t4v7EIIekbltfBnWfvWKjB6ZsgdhkWr0=
github.com/k8sstormcenter/storage v0.0.240-0.20260503184242-43795bb4f0b6/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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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